docs: M6b implementation plan
Six closed tasks for consult: registry, CONSULT_WAIT, [consult]/[secrets], ToolName and grants, Decision body, loopback HTTP. Reference is the git bundle ~/boxmaker-refs/m6b-ref.bundle at 5fb05f0. Gate after task 06 is 1611. Co-Authored-By: Grok 4.6 <noreply@x.ai>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
# M6b task 01: `consult` in the M5c registry, not core
|
||||
|
||||
**Branch:** `m6b`. Check it: `git branch --show-current` prints `m6b` and `git status --short`
|
||||
is empty; otherwise stop. Never switch branch.
|
||||
**Commit subject:** `loopd: consult in the M5c registry, not core`
|
||||
**Log row:** the Task cell is `M6b/01-loopd-consult-registry`.
|
||||
|
||||
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
|
||||
Do not Read `tests/support/`, `turn.rs`, `session.rs`, `docs/implementer-log.md` (except the
|
||||
first 12 lines at the log step). The signatures below are complete.
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/loopd/tests/consult_registry.rs crates/loopd/tests/
|
||||
```
|
||||
|
||||
## Goal
|
||||
|
||||
`consult` is in `Registry::m5c()`, not core. `find_tool` finds it. A new session's epoch
|
||||
`tools` array stays `clock`, `recall`, `memory`, `ask_memory`, `find_tool`, `call_tool`
|
||||
(spec 4). `loopd` does not answer the call: `dispatch` already Ports every non-core name
|
||||
other than `subagent` to `brokerd`.
|
||||
|
||||
## Every place that changes
|
||||
|
||||
| Place | What happens there |
|
||||
|---|---|
|
||||
| `crates/loopd/src/consult.rs` | new: `CONSULT`, `schema()` |
|
||||
| `crates/loopd/src/lib.rs` | `pub mod consult;` |
|
||||
| `crates/loopd/src/tools.rs` | `m5c()` pushes the non-core entry after `subagent` |
|
||||
| `crates/loopd/tests/consult_registry.rs` | new, given |
|
||||
| everything else | **no change**: not `dispatch`, not `broker_port.rs` |
|
||||
|
||||
## Files
|
||||
|
||||
- Copy, from `docs/plans/M6b/files/` (the same path under it): the new test
|
||||
`crates/loopd/tests/consult_registry.rs`.
|
||||
- Create: `crates/loopd/src/consult.rs` (the module below).
|
||||
- Modify: `crates/loopd/src/lib.rs`, `crates/loopd/src/tools.rs`,
|
||||
`docs/implementer-log.md`.
|
||||
- Do not touch: any other file.
|
||||
|
||||
At the log-and-commit step, read only the first 12 lines of `docs/implementer-log.md`.
|
||||
|
||||
## Facts you need
|
||||
|
||||
- **Not core.** `core_schemas()` already filters `e.core`. A non-core `Entry` is found by
|
||||
`find` and by `get`, and is absent from `Baseline.tools`.
|
||||
- **`m5c()` only.** Do not add the entry to `m2b`, `m3a`, or `m5a`. `loopd serve` uses `m5c`.
|
||||
- **`push`, after `subagent`.** `ask_memory` stays at index 3. `consult` is last among the
|
||||
non-core names.
|
||||
- rustfmt `mod` order: `consult` after `config` / before `cron` as rustfmt decides.
|
||||
- Same-crate modules may import each other. This task's module imports nothing from
|
||||
`turn` or `tools`.
|
||||
- The items the edits use. This table is complete.
|
||||
|
||||
| Item | Signature |
|
||||
|---|---|
|
||||
| `ToolSchema` | `{ name: String, description: String, parameters: serde_json::Value }` in `crate::llama` |
|
||||
| `Entry` | `{ schema: ToolSchema, core: bool }` |
|
||||
| `Registry::m5c` | `m5c() -> Registry` |
|
||||
| `Registry::get` | `get(&self, name: &str) -> Option<&Entry>` |
|
||||
| `Registry::find` | `find(&self, query: &str) -> Vec<&ToolSchema>` |
|
||||
| `Registry::core_schemas` | `core_schemas(&self) -> Vec<ToolSchema>` |
|
||||
|
||||
## The edits, exactly
|
||||
|
||||
### `crates/loopd/src/consult.rs`
|
||||
|
||||
Write this module. Do not add a wait constant, a parse, or a runtime (later tasks).
|
||||
|
||||
```rust
|
||||
//! The `consult` tool: `brokerd` decides it and POSTs an allowlisted body (M6 spec, section 4).
|
||||
//! It is not core; the parent finds it with `find_tool` and calls it through `call_tool`.
|
||||
|
||||
pub const CONSULT: &str = "consult";
|
||||
|
||||
pub fn schema() -> crate::llama::ToolSchema {
|
||||
crate::llama::ToolSchema {
|
||||
name: CONSULT.to_string(),
|
||||
description: "Ask a remote model a question. The session history is not sent. Needs a grant from the owner.".to_string(),
|
||||
parameters: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "The question to send. Nothing else of the session is included."
|
||||
}
|
||||
},
|
||||
"required": ["question"]
|
||||
}),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `crates/loopd/src/lib.rs`
|
||||
|
||||
**L1.** After `pub mod config;` add `pub mod consult;`. rustfmt decides the final order.
|
||||
|
||||
### `crates/loopd/src/tools.rs`
|
||||
|
||||
**R1.** The lines (exactly once, inside `m5c`)
|
||||
|
||||
```rust
|
||||
entries.push(Entry {
|
||||
schema: crate::subagent::schema(),
|
||||
core: false,
|
||||
});
|
||||
Registry::new(entries)
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
entries.push(Entry {
|
||||
schema: crate::subagent::schema(),
|
||||
core: false,
|
||||
});
|
||||
entries.push(Entry {
|
||||
schema: crate::consult::schema(),
|
||||
core: false,
|
||||
});
|
||||
Registry::new(entries)
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy the given test.**
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/loopd/tests/consult_registry.rs crates/loopd/tests/
|
||||
```
|
||||
|
||||
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, with a shell timeout of
|
||||
600000 ms: test target `consult_registry` does not compile (`E0432` unresolved
|
||||
`loopd::consult`). Every other target compiles.
|
||||
- [ ] **3. The edits.** Write `consult.rs`, L1, R1. `cargo check -p loopd` prints no error
|
||||
and no warning. `wc -l crates/loopd/src/consult.rs` and `wc -l crates/loopd/src/tools.rs`
|
||||
each ≤ 500.
|
||||
- [ ] **4. The tests.** `cargo test -p loopd --test consult_registry --test subagent_registry
|
||||
--test ask_memory_tool --test tools --test baseline`: every test passes, 2 in
|
||||
`consult_registry`.
|
||||
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/loopd/src/consult.rs`,
|
||||
`crates/loopd/src/lib.rs`, `crates/loopd/src/tools.rs`. `git status --short` also shows
|
||||
the new test as untracked.
|
||||
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of
|
||||
600000 ms. Expected last line: `gate: ok`. That is done counting: **1592** (1590 before,
|
||||
plus the 2 of `consult_registry.rs`). After `gate: ok`, do not add up `test result:`
|
||||
lines and do not run `cargo test --workspace`.
|
||||
- [ ] **7. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
|
||||
`M6b/01-loopd-consult-registry`. Read only the first 12 lines of that file; insert the
|
||||
row under the header. Stage with
|
||||
`git add crates/loopd 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 1592 tests.
|
||||
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/consult.rs`,
|
||||
`crates/loopd/src/lib.rs`, `crates/loopd/src/tools.rs`,
|
||||
`crates/loopd/tests/consult_registry.rs` and `docs/implementer-log.md`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- The branch check at the top fails.
|
||||
- A "before" block is not in its file exactly as shown, or is there more than once.
|
||||
- Step 2 shows a different result: the tests compile, or a different crate fails.
|
||||
- `cargo check` in step 3 prints an error or a warning. Quote it, and stop.
|
||||
- A test fails in step 4. Do not change the test: stop, and give the failing tests' names
|
||||
and their messages.
|
||||
- The fix seems to need a change anywhere else. It does not; stop and say why you think so.
|
||||
- A test outside `consult_registry.rs` fails in the gate: check once whether it fails
|
||||
without your change (`git stash -u`, run it, `git stash pop`), then stop and report both
|
||||
results.
|
||||
|
||||
## What the given tests pin
|
||||
|
||||
`consult` is in `m5c()`, `core` is false, `schema()` matches the entry, `find("consult")`
|
||||
and `find("remote")` both hit it, `subagent` is still present, and a baseline assembled
|
||||
from `m5c()` still lists only the six core names in `tools`.
|
||||
@@ -0,0 +1,205 @@
|
||||
# M6b task 02: `CONSULT_WAIT` through `timeout_for`
|
||||
|
||||
**Branch:** `m6b`. Check it: `git branch --show-current` prints `m6b` and `git status --short`
|
||||
is empty; otherwise stop. Never switch branch.
|
||||
**Commit subject:** `loopd: consult waits one hundred and eighty seconds`
|
||||
**Log row:** the Task cell is `M6b/02-loopd-consult-wait`.
|
||||
|
||||
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
|
||||
Do not Read `tests/support/`, `config.rs`, `docs/implementer-log.md` (except the first 12
|
||||
lines at the log step). Do not edit `crates/loopd/tests/broker_port_ask_wait.rs` or
|
||||
`crates/loopd/src/config.rs`. The quoted blocks are the only edits.
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/loopd/tests/broker_port_consult_wait.rs crates/loopd/tests/
|
||||
```
|
||||
|
||||
## Goal
|
||||
|
||||
`BrokerPort::timeout_for("consult")` is 180 s, or `[broker] timeout_ms` if that is longer
|
||||
(spec 4.3). The default `[broker] timeout_ms` stays 120_000. `ask_memory` is unchanged.
|
||||
|
||||
No test sleeps (T27). This task does not talk to `brokerd`.
|
||||
|
||||
## Every place that changes
|
||||
|
||||
| Place | What happens there |
|
||||
|---|---|
|
||||
| `crates/loopd/src/broker_port.rs` | `CONSULT_WAIT`; `consult_timeout` on `new` and `with_log`; `timeout_for` arm |
|
||||
| `crates/loopd/tests/broker_port_consult_wait.rs` | new, given |
|
||||
| everything else | **no change**: not `config.rs`, not `broker_port_ask_wait.rs` |
|
||||
|
||||
## Files
|
||||
|
||||
- Copy, from `docs/plans/M6b/files/` (the same path under it): the new test
|
||||
`crates/loopd/tests/broker_port_consult_wait.rs`.
|
||||
- Modify: `crates/loopd/src/broker_port.rs`, `docs/implementer-log.md`.
|
||||
- Do not touch: any other file. Do not edit `crates/loopd/tests/broker_port_ask_wait.rs`
|
||||
(`the_wait_for_each_tool` does not list `consult`; leaving it unedited is required).
|
||||
Do not change the default `timeout_ms: 120_000` in `crates/loopd/src/config.rs`.
|
||||
|
||||
## Facts you need
|
||||
|
||||
- **Floor, not a replacement.** `timeout_for("consult")` is
|
||||
`self.timeout.max(self.consult_timeout)`. A configured 300 s wait stays 300 s.
|
||||
- **Both constructors.** `new` and `with_log` each name `consult_timeout: CONSULT_WAIT`.
|
||||
`with_ask_wait` and `with_pending_cap` use `..self` and need no edit.
|
||||
- **`ASK_MEMORY_WAIT` stays.** Do not change its value or its arm.
|
||||
- The items the edits use. This table is complete.
|
||||
|
||||
| Item | Signature |
|
||||
|---|---|
|
||||
| `BrokerPort::new` | `new(socket: PathBuf, timeout: Duration) -> BrokerPort` |
|
||||
| `BrokerPort::with_log` | `with_log(socket: PathBuf, timeout: Duration, log: Box<dyn Fn(&str) + Send + Sync>) -> BrokerPort` |
|
||||
| `BrokerPort::timeout_for` | `timeout_for(&self, tool: &str) -> Duration` |
|
||||
| `crate::consult::CONSULT` | `&str`, `"consult"` (task 01) |
|
||||
| `crate::memory::ASK_MEMORY` | `&str`, `"ask_memory"` |
|
||||
|
||||
## The edits, exactly
|
||||
|
||||
Each quoted "before" block occurs exactly once after task 01.
|
||||
|
||||
### `crates/loopd/src/broker_port.rs`
|
||||
|
||||
**W1.** After `pub const ASK_MEMORY_WAIT: Duration = Duration::from_secs(180);` add:
|
||||
|
||||
```rust
|
||||
/// The longest `BrokerPort` waits for a `consult` answer, unless `[broker] timeout_ms` is
|
||||
/// longer: `brokerd`'s `[consult] timeout_ms` defaults to 180 s (M6 spec, section 4.3).
|
||||
pub const CONSULT_WAIT: Duration = Duration::from_secs(180);
|
||||
```
|
||||
|
||||
**W2.** The struct fields
|
||||
|
||||
```rust
|
||||
socket: PathBuf,
|
||||
timeout: Duration,
|
||||
ask_timeout: Duration,
|
||||
pending_cap: Duration,
|
||||
log: Box<dyn Fn(&str) + Send + Sync>,
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
socket: PathBuf,
|
||||
timeout: Duration,
|
||||
ask_timeout: Duration,
|
||||
consult_timeout: Duration,
|
||||
pending_cap: Duration,
|
||||
log: Box<dyn Fn(&str) + Send + Sync>,
|
||||
```
|
||||
|
||||
**W3.** Inside `new`, the fields
|
||||
|
||||
```rust
|
||||
socket,
|
||||
timeout,
|
||||
ask_timeout: ASK_MEMORY_WAIT,
|
||||
pending_cap: MAX_PENDING_WAIT,
|
||||
log: Box::new(|l| eprintln!("{l}")),
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
socket,
|
||||
timeout,
|
||||
ask_timeout: ASK_MEMORY_WAIT,
|
||||
consult_timeout: CONSULT_WAIT,
|
||||
pending_cap: MAX_PENDING_WAIT,
|
||||
log: Box::new(|l| eprintln!("{l}")),
|
||||
```
|
||||
|
||||
**W4.** The comment and body of `timeout_for`
|
||||
|
||||
```rust
|
||||
/// The wait for one call of `tool`, used wherever the port waits for that call: the timeout,
|
||||
/// or for `ask_memory` the longer of the timeout and its own wait.
|
||||
pub fn timeout_for(&self, tool: &str) -> Duration {
|
||||
if tool == crate::memory::ASK_MEMORY {
|
||||
self.timeout.max(self.ask_timeout)
|
||||
} else {
|
||||
self.timeout
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
/// The wait for one call of `tool`, used wherever the port waits for that call: the timeout,
|
||||
/// or for `ask_memory` and `consult` the longer of the timeout and that tool's own wait.
|
||||
pub fn timeout_for(&self, tool: &str) -> Duration {
|
||||
if tool == crate::memory::ASK_MEMORY {
|
||||
self.timeout.max(self.ask_timeout)
|
||||
} else if tool == crate::consult::CONSULT {
|
||||
self.timeout.max(self.consult_timeout)
|
||||
} else {
|
||||
self.timeout
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**W5.** Inside `with_log`, the fields
|
||||
|
||||
```rust
|
||||
socket,
|
||||
timeout,
|
||||
ask_timeout: ASK_MEMORY_WAIT,
|
||||
pending_cap: MAX_PENDING_WAIT,
|
||||
log,
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
socket,
|
||||
timeout,
|
||||
ask_timeout: ASK_MEMORY_WAIT,
|
||||
consult_timeout: CONSULT_WAIT,
|
||||
pending_cap: MAX_PENDING_WAIT,
|
||||
log,
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy the given test.**
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/loopd/tests/broker_port_consult_wait.rs crates/loopd/tests/
|
||||
```
|
||||
|
||||
- [ ] **2. See it fail.** `cargo test -p loopd --test broker_port_consult_wait`, timeout
|
||||
600000 ms: the test target does not compile (`E0432` unresolved `CONSULT_WAIT`).
|
||||
- [ ] **3. The edits.** W1–W5. `cargo check -p loopd` prints no error and no warning.
|
||||
`wc -l crates/loopd/src/broker_port.rs` ≤ 500.
|
||||
- [ ] **4. The tests.** `cargo test -p loopd --test broker_port_consult_wait --test
|
||||
broker_port_ask_wait`: every test passes, 1 in `broker_port_consult_wait`.
|
||||
`the_wait_for_each_tool` still passes.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
|
||||
line `gate: ok`. Test count **1593** (1592 before, plus 1). After `gate: ok`, do not add
|
||||
up `test result:` lines.
|
||||
- [ ] **6. Log and commit.** Task cell `M6b/02-loopd-consult-wait`. Read only the first 12
|
||||
lines of `docs/implementer-log.md`; insert the row under the header. Stage with
|
||||
`git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
|
||||
says.
|
||||
|
||||
## Done when
|
||||
|
||||
- `make gate` prints `gate: ok` with 1593 tests.
|
||||
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/broker_port.rs`,
|
||||
`crates/loopd/tests/broker_port_consult_wait.rs` and the log.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- The branch check fails; a "before" block is missing or duplicated; step 2 compiles; you
|
||||
edit `broker_port_ask_wait.rs` or `config.rs`; `cargo check` warns; `the_wait_for_each_tool`
|
||||
fails; a test outside the named files fails in the gate (`git stash -u`, rerun,
|
||||
`git stash pop`, report both).
|
||||
|
||||
## What the given tests pin
|
||||
|
||||
`CONSULT_WAIT` is 180 s. `timeout_for("consult")` is 180 s when the port timeout is 120 s,
|
||||
and 300 s when the port timeout is 300 s. `timeout_for("recall")` stays the port timeout.
|
||||
`with_log` matches `new`.
|
||||
@@ -0,0 +1,426 @@
|
||||
# M6b task 03: `[consult]` and `[secrets]`, start fails closed
|
||||
|
||||
**Branch:** `m6b`. Check it: `git branch --show-current` prints `m6b` and `git status --short`
|
||||
is empty; otherwise stop. Never switch branch.
|
||||
**Commit subject:** `brokerd: consult config, secrets, loopback URL`
|
||||
**Log row:** the Task cell is `M6b/03-brokerd-consult-config`.
|
||||
|
||||
**First action.** Run this before any Read of crate source. Do not `ls`
|
||||
`crates/brokerd/tests/`. Do not Read `args.rs`, `broker.rs`, `ledger/`,
|
||||
`docs/implementer-log.md` (except the first 12 lines at the log step). Do not edit
|
||||
`crates/brokerd/tests/config.rs`. The signatures below are complete.
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/config_consult.rs crates/brokerd/tests/
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/consult_secret.rs crates/brokerd/tests/
|
||||
```
|
||||
|
||||
Then append the runbook fragment (step 1 of Steps names the exact command).
|
||||
|
||||
## Goal
|
||||
|
||||
`brokerd.toml` may have `[consult]` and `[secrets.<name>]`. A present `[consult]` is
|
||||
validated at load: loopback `http://` only, non-empty model and secret, `timeout_ms` ≥ 1,
|
||||
temperature from 0 to 0.6 inclusive. Temperature is stored as `temperature_milli: u16` so
|
||||
`Config` stays `Eq`. No `[consult]` is `None` and an empty `[secrets]` map. Loading the
|
||||
named secret at start is `load_secret`; a missing or unreadable secret is
|
||||
`consult-secret-unreadable` and `brokerd` must not start. This task does not POST, parse
|
||||
tool arguments, or change `ToolName`.
|
||||
|
||||
## Every place that changes
|
||||
|
||||
| Place | What happens there |
|
||||
|---|---|
|
||||
| `crates/brokerd/Cargo.toml` | `bxcore` enables feature `secrets` |
|
||||
| `crates/brokerd/src/consult.rs` | new: `Consult`, `validate`, `parse_loopback_http`, `load_secret` |
|
||||
| `crates/brokerd/src/lib.rs` | `pub mod consult;` |
|
||||
| `crates/brokerd/src/config.rs` | `consult`, `secrets`; validate at load |
|
||||
| `crates/brokerd/tests/support/rig.rs` | the `Config { }` literal names the new fields |
|
||||
| `docs/runbook.md` | append both consult entries |
|
||||
| `crates/brokerd/tests/config_consult.rs` | new, given |
|
||||
| `crates/brokerd/tests/consult_secret.rs` | new, given |
|
||||
| everything else | **no change**: not `ToolName`, not `main.rs`, not `ledger` |
|
||||
|
||||
## Files
|
||||
|
||||
- Copy, from `docs/plans/M6b/files/` (the same path under it): the new tests
|
||||
`crates/brokerd/tests/config_consult.rs` and
|
||||
`crates/brokerd/tests/consult_secret.rs`.
|
||||
- Append, from `docs/plans/M6b/files/runbook-consult.md`, to `docs/runbook.md`. Both
|
||||
headings go in now so `scripts/check-runbook.sh` accepts `SECRET_POINTER`; task 05
|
||||
adds the `consult-not-configured` pointer in code.
|
||||
- Create: `crates/brokerd/src/consult.rs` (the module below).
|
||||
- Modify: `crates/brokerd/Cargo.toml`, `crates/brokerd/src/lib.rs`,
|
||||
`crates/brokerd/src/config.rs`, `crates/brokerd/tests/support/rig.rs`,
|
||||
`docs/runbook.md`, `docs/implementer-log.md`.
|
||||
- Do not touch: any other file. Do not edit `crates/brokerd/tests/config.rs`.
|
||||
After the Cargo.toml change, run `cargo build` once so `Cargo.lock` updates.
|
||||
|
||||
## Facts you need
|
||||
|
||||
- **No vendor names.** The URL, model and secret name are configuration. The module must
|
||||
not mention a cloud provider.
|
||||
- **Loopback HTTP only.** `parse_loopback_http` accepts `http://127.0.0.1` or
|
||||
`http://localhost`, optional port 1–65535 and path. `https://`, a non-loopback host, a
|
||||
userinfo (`@`), or port `0` is an error. Default port is 80. A URL with no path uses
|
||||
`"/"`.
|
||||
- **Temperature.** TOML `temperature` is an `f64` in 0..=0.6, or the integer `0`. Any
|
||||
other integer is invalid (`must be from 0 to 0.6`). Store `(v * 1000.0).round()` as
|
||||
`u16` via `format!("{milli:.0}").parse::<u16>()`. Default 600 (0.6). `Config` stays
|
||||
`Eq`; do not store `f64` on `Consult`.
|
||||
- **`#[serde(default)]` on the new `Config` fields.** `an_empty_file_gets_every_default`
|
||||
in `config.rs` compares a loaded empty file to `Config::default()`. Missing tables are
|
||||
`consult: None` and `secrets: {}`.
|
||||
- **The only `Config { }` literal** is `crates/brokerd/tests/support/rig.rs`. Add
|
||||
`consult: None, secrets: Default::default()`. Other tests load TOML.
|
||||
- **Secrets.** Same `SecretSpec` as `gatewayd`: exactly one of `credential` / `env` /
|
||||
`file`. `load` is `bxcore::secrets::load`. Role string `"brokerd"`. Load the consult
|
||||
secret only when `[consult]` is present; absent table is `Ok(None)`.
|
||||
- **Do not add `ToolArgs`, `QUESTION_CAP`, `NOT_CONFIGURED`, a transport, or HTTP.**
|
||||
Those are later tasks. An unused constant fails clippy.
|
||||
- The items the edits use. This table is complete.
|
||||
|
||||
| Item | Signature |
|
||||
|---|---|
|
||||
| `SecretSpec` | `{ credential: Option<String>, env: Option<String>, file: Option<PathBuf> }` in `bxcore::secrets` |
|
||||
| `SecretSpec::source` | `source(&self) -> Result<SecretSource, String>` |
|
||||
| `bxcore::secrets::load` | `load(role: &str, name: &str, source: &SecretSource, env: &dyn Fn(&str) -> Option<OsString>) -> Result<Loaded, SecretError>` |
|
||||
| `Loaded` | `{ secret: Secret, warning: Option<String> }` |
|
||||
| `SecretError` | `Display` names the secret and `docs/runbook.md#secret-unavailable` |
|
||||
| `Config::load` | `load(path: &Path) -> Result<Config, ConfigError>` |
|
||||
| `ConfigError::Invalid` | `Invalid(PathBuf, String)` |
|
||||
| `ConfigError::Parse` | `Parse(PathBuf, toml::de::Error)` |
|
||||
|
||||
## The edits, exactly
|
||||
|
||||
### `crates/brokerd/Cargo.toml`
|
||||
|
||||
**C1.** The line
|
||||
|
||||
```toml
|
||||
bxcore.workspace = true
|
||||
```
|
||||
|
||||
becomes
|
||||
|
||||
```toml
|
||||
bxcore = { workspace = true, features = ["secrets"] }
|
||||
```
|
||||
|
||||
Then `cargo build` once (timeout 600000 ms) so `Cargo.lock` records the feature.
|
||||
|
||||
### `crates/brokerd/src/lib.rs`
|
||||
|
||||
**L1.** After `pub mod config;` add `pub mod consult;`. rustfmt decides the final order.
|
||||
|
||||
### `crates/brokerd/src/consult.rs`
|
||||
|
||||
Write this module. Do not add `ConsultArgs`, `wire_body`, `ConsultTransport`, or
|
||||
`ConsultRuntime`.
|
||||
|
||||
```rust
|
||||
//! `consult`: an allowlisted chat-completions POST decided by the grant's `max_taint`.
|
||||
//! Nothing here names a vendor (M6 spec, section 4).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::time::Duration;
|
||||
|
||||
use bxcore::secrets::{Loaded, SecretError, SecretSpec};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub const CONSULT: &str = "consult";
|
||||
/// Default wait for the remote completion, milliseconds.
|
||||
pub const DEFAULT_TIMEOUT_MS: u64 = 180_000;
|
||||
/// Default sampling temperature as thousandths (0.6).
|
||||
pub const DEFAULT_TEMPERATURE_MILLI: u16 = 600;
|
||||
/// Start failure when the named secret cannot be read.
|
||||
pub const SECRET_POINTER: &str = "see docs/runbook.md#consult-secret-unreadable";
|
||||
|
||||
/// `[consult]` in `brokerd.toml`. Unknown keys are errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Consult {
|
||||
pub url: String,
|
||||
pub model: String,
|
||||
#[serde(
|
||||
rename = "temperature",
|
||||
default = "default_temperature_milli",
|
||||
deserialize_with = "de_temperature_milli"
|
||||
)]
|
||||
pub temperature_milli: u16,
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
fn default_temperature_milli() -> u16 {
|
||||
DEFAULT_TEMPERATURE_MILLI
|
||||
}
|
||||
|
||||
fn default_timeout_ms() -> u64 {
|
||||
DEFAULT_TIMEOUT_MS
|
||||
}
|
||||
|
||||
fn de_temperature_milli<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u16, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Num {
|
||||
F(f64),
|
||||
I(i64),
|
||||
}
|
||||
let v = match Num::deserialize(deserializer).map_err(serde::de::Error::custom)? {
|
||||
Num::F(f) => f,
|
||||
Num::I(0) => 0.0,
|
||||
Num::I(_) => {
|
||||
return Err(serde::de::Error::custom(
|
||||
"[consult] temperature must be from 0 to 0.6",
|
||||
));
|
||||
}
|
||||
};
|
||||
if !v.is_finite() || v < 0.0 || v > 0.6 {
|
||||
return Err(serde::de::Error::custom(
|
||||
"[consult] temperature must be from 0 to 0.6",
|
||||
));
|
||||
}
|
||||
let milli = (v * 1000.0).round();
|
||||
let text = format!("{milli:.0}");
|
||||
text.parse::<u16>().map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
impl Consult {
|
||||
/// The temperature written in the JSON body.
|
||||
pub fn temperature(&self) -> f64 {
|
||||
f64::from(self.temperature_milli) / 1000.0
|
||||
}
|
||||
|
||||
pub fn timeout(&self) -> Duration {
|
||||
Duration::from_millis(self.timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a `[consult]` table cannot be used.
|
||||
pub fn validate(consult: &Consult) -> Result<(), String> {
|
||||
parse_loopback_http(&consult.url).map(|_| ())?;
|
||||
if consult.model.is_empty() {
|
||||
return Err("[consult] model must not be empty".to_string());
|
||||
}
|
||||
if consult.timeout_ms == 0 {
|
||||
return Err("[consult] timeout_ms must be at least 1".to_string());
|
||||
}
|
||||
if consult.secret.is_empty() {
|
||||
return Err("[consult] secret must not be empty".to_string());
|
||||
}
|
||||
if consult.temperature_milli > DEFAULT_TEMPERATURE_MILLI {
|
||||
return Err("[consult] temperature must be from 0 to 0.6".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Host, port and path of a loopback `http://` URL. M6 stays on loopback.
|
||||
pub fn parse_loopback_http(url: &str) -> Result<(String, u16, String), String> {
|
||||
let rest = match url.strip_prefix("http://") {
|
||||
Some(rest) => rest,
|
||||
None => {
|
||||
return Err("[consult] url must be http://127.0.0.1 or http://localhost".to_string());
|
||||
}
|
||||
};
|
||||
if rest.contains('@') {
|
||||
return Err("[consult] url must not include a userinfo".to_string());
|
||||
}
|
||||
let (hostport, path) = match rest.find('/') {
|
||||
Some(i) => match rest.split_at_checked(i) {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
return Err("[consult] url is not a valid http URL".to_string());
|
||||
}
|
||||
},
|
||||
None => (rest, "/"),
|
||||
};
|
||||
let path = if path.is_empty() { "/" } else { path };
|
||||
let (host, port) = match hostport.split_once(':') {
|
||||
Some((host, port)) => {
|
||||
let port: u16 = port
|
||||
.parse()
|
||||
.map_err(|_| "[consult] url port is not a number".to_string())?;
|
||||
if port == 0 {
|
||||
return Err("[consult] url port must be from 1 to 65535".to_string());
|
||||
}
|
||||
(host, port)
|
||||
}
|
||||
None => (hostport, 80),
|
||||
};
|
||||
if host != "127.0.0.1" && host != "localhost" {
|
||||
return Err("[consult] url must be http://127.0.0.1 or http://localhost".to_string());
|
||||
}
|
||||
Ok((host.to_string(), port, path.to_string()))
|
||||
}
|
||||
|
||||
/// Why `brokerd` will not start with `[consult]` set.
|
||||
#[derive(Debug)]
|
||||
pub enum SecretStartError {
|
||||
Missing(String),
|
||||
Spec(String, String),
|
||||
Load(SecretError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SecretStartError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SecretStartError::Missing(name) => {
|
||||
write!(f, "secret {name} is not in [secrets]; {SECRET_POINTER}")
|
||||
}
|
||||
SecretStartError::Spec(name, why) => {
|
||||
write!(f, "[secrets.{name}]: {why}; {SECRET_POINTER}")
|
||||
}
|
||||
SecretStartError::Load(err) => write!(f, "{err}\n{SECRET_POINTER}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SecretStartError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
SecretStartError::Load(err) => Some(err),
|
||||
SecretStartError::Missing(_) | SecretStartError::Spec(_, _) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the bearer for `[consult]`, or `Ok(None)` when the table is absent.
|
||||
pub fn load_secret(
|
||||
cfg: &Config,
|
||||
env: &dyn Fn(&str) -> Option<OsString>,
|
||||
) -> Result<Option<Loaded>, SecretStartError> {
|
||||
let Some(consult) = &cfg.consult else {
|
||||
return Ok(None);
|
||||
};
|
||||
let spec: &SecretSpec = match cfg.secrets.get(&consult.secret) {
|
||||
Some(spec) => spec,
|
||||
None => return Err(SecretStartError::Missing(consult.secret.clone())),
|
||||
};
|
||||
let source = spec
|
||||
.source()
|
||||
.map_err(|why| SecretStartError::Spec(consult.secret.clone(), why))?;
|
||||
let loaded = bxcore::secrets::load("brokerd", &consult.secret, &source, env)
|
||||
.map_err(SecretStartError::Load)?;
|
||||
Ok(Some(loaded))
|
||||
}
|
||||
```
|
||||
|
||||
### `crates/brokerd/src/config.rs`
|
||||
|
||||
**F1.** The imports
|
||||
|
||||
```rust
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bxcore::secrets::SecretSpec;
|
||||
```
|
||||
|
||||
**F2.** After `pub memory: Memory,` (the last field of `Config`) add:
|
||||
|
||||
```rust
|
||||
#[serde(default)]
|
||||
pub consult: Option<crate::consult::Consult>,
|
||||
#[serde(default)]
|
||||
pub secrets: BTreeMap<String, SecretSpec>,
|
||||
```
|
||||
|
||||
**F3.** In `Config::load`, immediately before `Ok(config)`, add:
|
||||
|
||||
```rust
|
||||
if let Some(consult) = &config.consult
|
||||
&& let Err(why) = crate::consult::validate(consult)
|
||||
{
|
||||
return Err(ConfigError::Invalid(path.to_path_buf(), why));
|
||||
}
|
||||
```
|
||||
|
||||
Do not change `time_limit` (no `ToolName::Consult` yet).
|
||||
|
||||
### `crates/brokerd/tests/support/rig.rs`
|
||||
|
||||
**R1.** The `Config {` fields ending
|
||||
|
||||
```rust
|
||||
gateway: None,
|
||||
memory: Default::default(),
|
||||
};
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
gateway: None,
|
||||
memory: Default::default(),
|
||||
consult: None,
|
||||
secrets: Default::default(),
|
||||
};
|
||||
```
|
||||
|
||||
### `docs/runbook.md`
|
||||
|
||||
**B1.** Append the file `docs/plans/M6b/files/runbook-consult.md` as-is (both `##`
|
||||
headings) after the last line of `docs/runbook.md`.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy the given tests and append the runbook.**
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/config_consult.rs crates/brokerd/tests/
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/consult_secret.rs crates/brokerd/tests/
|
||||
cat docs/plans/M6b/files/runbook-consult.md >> docs/runbook.md
|
||||
```
|
||||
|
||||
- [ ] **2. See them fail.** `cargo test -p brokerd --test config_consult --test
|
||||
consult_secret`, timeout 600000 ms: both targets do not compile (`E0432` unresolved
|
||||
`brokerd::consult`).
|
||||
- [ ] **3. The edits.** C1 (then `cargo build`), L1, write `consult.rs`, F1–F3, R1, B1.
|
||||
`cargo check -p brokerd` prints no error and no warning. `wc -l
|
||||
crates/brokerd/src/consult.rs` and `wc -l crates/brokerd/src/config.rs` each ≤ 500.
|
||||
- [ ] **4. The tests.** `cargo test -p brokerd --test config_consult --test consult_secret
|
||||
--test config`: every test passes, 6 in `config_consult`, 3 in `consult_secret`.
|
||||
`an_empty_file_gets_every_default` still passes.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
|
||||
line `gate: ok`. Test count **1602** (1593 before, plus 9). After `gate: ok`, do not add
|
||||
up `test result:` lines.
|
||||
- [ ] **6. Log and commit.** Task cell `M6b/03-brokerd-consult-config`. Read only the first
|
||||
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
|
||||
`git add crates/brokerd Cargo.lock docs/runbook.md docs/implementer-log.md`, then
|
||||
commit as `AGENTS.md` says.
|
||||
|
||||
## Done when
|
||||
|
||||
- `make gate` prints `gate: ok` with 1602 tests.
|
||||
- `git diff HEAD~1 --stat` lists only `crates/brokerd/Cargo.toml`,
|
||||
`crates/brokerd/src/consult.rs`, `crates/brokerd/src/lib.rs`,
|
||||
`crates/brokerd/src/config.rs`, `crates/brokerd/tests/support/rig.rs`,
|
||||
`crates/brokerd/tests/config_consult.rs`, `crates/brokerd/tests/consult_secret.rs`,
|
||||
`docs/runbook.md`, the log, and `Cargo.lock` if it changed.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- The branch check fails; a "before" block is missing or duplicated; step 2 compiles; you
|
||||
edit `tests/config.rs` or add `ToolName::Consult`; `cargo check` warns; existing
|
||||
`config` tests fail; a test outside the named files fails in the gate (`git stash -u`,
|
||||
rerun, `git stash pop`, report both).
|
||||
|
||||
## What the given tests pin
|
||||
|
||||
No `[consult]` is `None` with empty secrets. A table loads url/model/secret and defaults
|
||||
temperature 0.6 / timeout 180_000. `temperature = 0.7` is parse-invalid. A non-loopback
|
||||
URL is `ConfigError::Invalid`. An unknown `[consult]` key is parse-invalid. `[secrets.consult]`
|
||||
loads. A missing secrets entry and an unset env secret both name
|
||||
`consult-secret-unreadable`; a present env secret loads.
|
||||
@@ -0,0 +1,399 @@
|
||||
# M6b task 04: `ToolName::Consult`, parse, grants
|
||||
|
||||
**Branch:** `m6b`. Check it: `git branch --show-current` prints `m6b` and `git status --short`
|
||||
is empty; otherwise stop. Never switch branch.
|
||||
**Commit subject:** `brokerd: consult tool name, parse, grants`
|
||||
**Log row:** the Task cell is `M6b/04-brokerd-consult-tool`.
|
||||
|
||||
**First action.** Run this before any Read of crate source. Do not `ls`
|
||||
`crates/brokerd/tests/`. Do not Read `broker.rs`, `ledger/`, `main.rs`,
|
||||
`docs/implementer-log.md` (except the first 12 lines at the log step). Do not edit
|
||||
`crates/brokerd/tests/grants.rs` (its `rule_3` unknown-tool sentence stays the four
|
||||
container tools). The signatures below are complete.
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/grants_consult.rs crates/brokerd/tests/
|
||||
```
|
||||
|
||||
## Goal
|
||||
|
||||
`consult` is a `ToolName`. `parse` reads `{"question":…}` with `deny_unknown_fields` and
|
||||
caps `question` at 16_384 bytes on a character boundary. A grant with empty constraints
|
||||
loads (`untrusted` still defaults true). Paths or hosts on a consult grant are invalid.
|
||||
`covers` treats consult like the memory tools (`Some(None)`). `ALL` stays the four
|
||||
container tools. `is_memory` is false. Unknown tool names still fail with the four-tool
|
||||
sentence.
|
||||
|
||||
## Every place that changes
|
||||
|
||||
Adding `ToolName::Consult` makes every exhaustive `match` on `ToolName` or `ToolArgs`
|
||||
fail to compile until it has an arm. List each match and fill it.
|
||||
|
||||
| Place | What happens there |
|
||||
|---|---|
|
||||
| `crates/brokerd/src/consult.rs` | `ConsultArgs`, `QUESTION_CAP`, `cap_bytes`, `question_of`, `wire_body` |
|
||||
| `crates/brokerd/src/args.rs` | `Consult` on `ToolName` and `ToolArgs`; `parse` |
|
||||
| `crates/brokerd/src/config.rs` | `time_limit` arm (uses `read_file_ms`; consult never hits a container) |
|
||||
| `crates/brokerd/src/policy/matching.rs` | `covers`: `Some(None)` |
|
||||
| `crates/brokerd/src/grants.rs` | `check_tool_constraints`: no paths, no hosts |
|
||||
| `crates/brokerd/src/runner.rs` | mounts none, egress none |
|
||||
| `crates/brokerd/src/container.rs` | refuse `Consult` with `IN_CONSULT` |
|
||||
| `crates/brokerd/tests/grants_consult.rs` | new, given |
|
||||
| everything else | **no change**: not `ledger`, not `broker.rs`, not `tests/grants.rs` |
|
||||
|
||||
`memory_port::wait_for` already has `_ =>` and needs no edit.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy, from `docs/plans/M6b/files/` (the same path under it): the new test
|
||||
`crates/brokerd/tests/grants_consult.rs`.
|
||||
- Modify: `crates/brokerd/src/consult.rs`, `crates/brokerd/src/args.rs`,
|
||||
`crates/brokerd/src/config.rs`, `crates/brokerd/src/policy/matching.rs`,
|
||||
`crates/brokerd/src/grants.rs`, `crates/brokerd/src/runner.rs`,
|
||||
`crates/brokerd/src/container.rs`, `docs/implementer-log.md`.
|
||||
- Do not touch: any other file. Do not edit `crates/brokerd/tests/grants.rs`.
|
||||
|
||||
## Facts you need
|
||||
|
||||
- **Unknown keys.** `ConsultArgs` has `#[serde(deny_unknown_fields)]`. `parse` maps a
|
||||
serde error to `ArgsError::Shape`. The given test accepts a detail that contains
|
||||
`unknown` or `history`.
|
||||
- **Cap on parse.** `cap_bytes(&value.question, QUESTION_CAP).0` is the stored question.
|
||||
`floor_char_boundary` then `.get(..boundary)`.
|
||||
- **`wire_body`** is used by task 05's tests; write it now so those tests can call it.
|
||||
No history, no `tools` key. When `consult` is `None`, model is `""` and temperature is
|
||||
0.6.
|
||||
- **`ALL` is `[ToolName; 4]`.** Do not put `Consult` in it.
|
||||
- **Rule 3.** `ToolName::parse("consult")` is `Some`, so a consult grant is not "unknown
|
||||
tool". The unknown-tool sentence in `grants.rs` still lists the four container tools.
|
||||
Do not edit that string.
|
||||
- The items the edits use. This table is complete.
|
||||
|
||||
| Item | Signature |
|
||||
|---|---|
|
||||
| `ToolName::parse` | `parse(name: &str) -> Option<ToolName>` |
|
||||
| `ToolName::as_str` | `as_str(self) -> &'static str` |
|
||||
| `ToolName::is_memory` | `is_memory(self) -> bool` |
|
||||
| `ToolName::ALL` | `[ToolName; 4]` |
|
||||
| `parse` | `parse(tool: ToolName, arguments: &str) -> Result<ToolArgs, ArgsError>` |
|
||||
| `ArgsError::Shape` | `Shape(String)` |
|
||||
| `ArgsError::detail` | `detail(&self) -> String` |
|
||||
| `Grant.untrusted` | defaults true |
|
||||
| `str::floor_char_boundary` | `floor_char_boundary(&self, index: usize) -> usize` (Rust 1.98) |
|
||||
|
||||
## The edits, exactly
|
||||
|
||||
Each quoted "before" block occurs exactly once after task 03.
|
||||
|
||||
### `crates/brokerd/src/consult.rs`
|
||||
|
||||
**A1.** Add `Serialize` to the serde import:
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Deserializer};
|
||||
```
|
||||
|
||||
becomes
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
```
|
||||
|
||||
**A2.** After `pub const CONSULT: &str = "consult";` add:
|
||||
|
||||
```rust
|
||||
/// `question` and the answer are cut to this many bytes, on a character boundary.
|
||||
pub const QUESTION_CAP: usize = 16_384;
|
||||
```
|
||||
|
||||
**A3.** After `parse_loopback_http` (after its closing `}`, before `SecretStartError`) add:
|
||||
|
||||
```rust
|
||||
/// The model's `consult` arguments. Unknown keys are errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConsultArgs {
|
||||
pub question: String,
|
||||
}
|
||||
|
||||
/// Cut `text` to `cap` bytes on a character boundary.
|
||||
pub fn cap_bytes(text: &str, cap: usize) -> (String, bool) {
|
||||
if text.len() <= cap {
|
||||
return (text.to_string(), false);
|
||||
}
|
||||
let boundary = text.floor_char_boundary(cap);
|
||||
match text.get(..boundary) {
|
||||
Some(cut) => (cut.to_string(), true),
|
||||
None => (String::new(), true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `question` field of `arguments`, capped, or empty when it is missing or not a string.
|
||||
pub fn question_of(arguments: &str) -> String {
|
||||
let extracted = match serde_json::from_str::<serde_json::Value>(arguments) {
|
||||
Ok(serde_json::Value::Object(obj)) => obj
|
||||
.get("question")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
cap_bytes(&extracted, QUESTION_CAP).0
|
||||
}
|
||||
|
||||
/// The exact JSON body `brokerd` POSTs (and records). No history, no tools.
|
||||
pub fn wire_body(consult: Option<&Consult>, arguments: &str) -> String {
|
||||
let model = consult.map(|c| c.model.as_str()).unwrap_or("");
|
||||
let temperature = consult
|
||||
.map(Consult::temperature)
|
||||
.unwrap_or(f64::from(DEFAULT_TEMPERATURE_MILLI) / 1000.0);
|
||||
wire_body_parts(model, temperature, &question_of(arguments))
|
||||
}
|
||||
|
||||
fn wire_body_parts(model: &str, temperature: f64, question: &str) -> String {
|
||||
serde_json::json!({
|
||||
"model": model,
|
||||
"temperature": temperature,
|
||||
"messages": [{"role": "user", "content": question}],
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
```
|
||||
|
||||
### `crates/brokerd/src/args.rs`
|
||||
|
||||
**N1.** After the `use bxcore::{MemoryArgs, RecallArgs};` line add:
|
||||
|
||||
```rust
|
||||
use crate::consult::{ConsultArgs, QUESTION_CAP, cap_bytes};
|
||||
```
|
||||
|
||||
**N2.** The comment on `ToolName`
|
||||
|
||||
```rust
|
||||
/// The tools the broker can dispatch to: four that run in a container, and the three memory
|
||||
/// tools that `memoryd` runs (M5a spec, section 8; M5c spec, section 9).
|
||||
```
|
||||
|
||||
becomes
|
||||
|
||||
```rust
|
||||
/// The tools the broker can dispatch to: four that run in a container, the three memory
|
||||
/// tools that `memoryd` runs, and `consult` (M6 spec, section 4).
|
||||
```
|
||||
|
||||
**N3.** After `AskMemory,` in `ToolName` add `Consult,`.
|
||||
|
||||
**N4.** In `ToolName::parse`, after `"ask_memory" => Some(ToolName::AskMemory),` add
|
||||
`"consult" => Some(ToolName::Consult),`.
|
||||
|
||||
**N5.** In `as_str`, after `ToolName::AskMemory => "ask_memory",` add
|
||||
`ToolName::Consult => "consult",`.
|
||||
|
||||
**N6.** After `AskMemory(AskMemoryArgs),` in `ToolArgs` add:
|
||||
|
||||
```rust
|
||||
Consult {
|
||||
question: String,
|
||||
},
|
||||
```
|
||||
|
||||
**N7.** In `tool()`, after `ToolArgs::AskMemory(_) => ToolName::AskMemory,` add
|
||||
`ToolArgs::Consult { .. } => ToolName::Consult,`.
|
||||
|
||||
**N8.** In `canonical_json()`, after the `AskMemory` arm add:
|
||||
|
||||
```rust
|
||||
ToolArgs::Consult { question } => {
|
||||
let value = ConsultArgs {
|
||||
question: question.clone(),
|
||||
};
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
**N9.** In `parse()`, after the `AskMemory` arm add:
|
||||
|
||||
```rust
|
||||
ToolName::Consult => {
|
||||
let value: ConsultArgs = serde_json::from_str(arguments)
|
||||
.map_err(|error| ArgsError::Shape(error.to_string()))?;
|
||||
let question = cap_bytes(&value.question, QUESTION_CAP).0;
|
||||
Ok(ToolArgs::Consult { question })
|
||||
}
|
||||
```
|
||||
|
||||
Do not add `Consult` to `is_memory`. Do not put it in `ALL`.
|
||||
|
||||
### `crates/brokerd/src/config.rs`
|
||||
|
||||
**T1.** The `time_limit` arm
|
||||
|
||||
```rust
|
||||
crate::args::ToolName::Recall
|
||||
| crate::args::ToolName::Memory
|
||||
| crate::args::ToolName::AskMemory => self.read_file_ms,
|
||||
```
|
||||
|
||||
becomes
|
||||
|
||||
```rust
|
||||
crate::args::ToolName::Recall
|
||||
| crate::args::ToolName::Memory
|
||||
| crate::args::ToolName::AskMemory
|
||||
| crate::args::ToolName::Consult => self.read_file_ms,
|
||||
```
|
||||
|
||||
### `crates/brokerd/src/policy/matching.rs`
|
||||
|
||||
**M1.** The `covers` arm
|
||||
|
||||
```rust
|
||||
ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => Some(None),
|
||||
```
|
||||
|
||||
becomes
|
||||
|
||||
```rust
|
||||
ToolArgs::Recall(_)
|
||||
| ToolArgs::Memory(_)
|
||||
| ToolArgs::AskMemory(_)
|
||||
| ToolArgs::Consult { .. } => Some(None),
|
||||
```
|
||||
|
||||
### `crates/brokerd/src/grants.rs`
|
||||
|
||||
**G1.** The end of `check_tool_constraints` (exactly once)
|
||||
|
||||
```rust
|
||||
"http_fetch does not take paths",
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
becomes
|
||||
|
||||
```rust
|
||||
"http_fetch does not take paths",
|
||||
);
|
||||
}
|
||||
}
|
||||
"consult" => {
|
||||
if !grant.constraints.paths.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
"consult does not take paths".to_string(),
|
||||
);
|
||||
}
|
||||
if !grant.constraints.hosts.is_empty() {
|
||||
push(
|
||||
problems,
|
||||
file.to_string(),
|
||||
None,
|
||||
"consult does not take hosts".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not edit the unknown-tool sentence.
|
||||
|
||||
### `crates/brokerd/src/runner.rs`
|
||||
|
||||
**U1.** The mounts arm
|
||||
|
||||
```rust
|
||||
ToolArgs::Recall(_) | ToolArgs::Memory(_) | ToolArgs::AskMemory(_) => (Vec::new(), None),
|
||||
```
|
||||
|
||||
becomes
|
||||
|
||||
```rust
|
||||
ToolArgs::Recall(_)
|
||||
| ToolArgs::Memory(_)
|
||||
| ToolArgs::AskMemory(_)
|
||||
| ToolArgs::Consult { .. } => (Vec::new(), None),
|
||||
```
|
||||
|
||||
### `crates/brokerd/src/container.rs`
|
||||
|
||||
**P1.** After `pub const IN_MEMORYD: &str = "the memory tools run in memoryd, not in a container";` add:
|
||||
|
||||
```rust
|
||||
/// A `consult` that reached the container runtime: fixed, it reaches the model.
|
||||
pub const IN_CONSULT: &str = "consult runs in brokerd, not in a container";
|
||||
```
|
||||
|
||||
**P2.** The comment and body at the start of `Podman::run`
|
||||
|
||||
```rust
|
||||
// `Routed` sends the memory tools to `memoryd`; one here is refused, never run.
|
||||
if spec.tool().is_memory() {
|
||||
return Err(RunError::Unavailable(IN_MEMORYD.to_string()));
|
||||
}
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
// `Routed` sends the memory tools to `memoryd`; `ConsultStack` runs `consult`.
|
||||
if spec.tool().is_memory() {
|
||||
return Err(RunError::Unavailable(IN_MEMORYD.to_string()));
|
||||
}
|
||||
if spec.tool() == crate::args::ToolName::Consult {
|
||||
return Err(RunError::Unavailable(IN_CONSULT.to_string()));
|
||||
}
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy the given test.**
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/grants_consult.rs crates/brokerd/tests/
|
||||
```
|
||||
|
||||
- [ ] **2. See it fail.** `cargo test -p brokerd --test grants_consult`, timeout 600000
|
||||
ms: the target does not compile (`E0599` / unresolved `ToolName::Consult` or
|
||||
`ConsultArgs`).
|
||||
- [ ] **3. The edits.** A1–A3, N1–N9, T1, M1, G1, U1, P1–P2. After each file,
|
||||
`cargo check -p brokerd`. When it prints no error and no warning, go on. `wc -l` of
|
||||
`consult.rs`, `args.rs`, `grants.rs`, `container.rs` each ≤ 500.
|
||||
- [ ] **4. The tests.** `cargo test -p brokerd --test grants_consult --test grants --test
|
||||
args`: every test passes, 4 in `grants_consult`. The existing unknown-tool case in
|
||||
`grants.rs` still names the four container tools.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
|
||||
line `gate: ok`. Test count **1606** (1602 before, plus 4). After `gate: ok`, do not add
|
||||
up `test result:` lines.
|
||||
- [ ] **6. Log and commit.** Task cell `M6b/04-brokerd-consult-tool`. Read only the first
|
||||
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
|
||||
`git add crates/brokerd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
|
||||
says.
|
||||
|
||||
## Done when
|
||||
|
||||
- `make gate` prints `gate: ok` with 1606 tests.
|
||||
- `git diff HEAD~1 --stat` lists only the files in the Files section plus the log.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- The branch check fails; a "before" block is missing or duplicated; step 2 compiles; you
|
||||
edit `tests/grants.rs`; you add `Consult` to `ALL` or `is_memory`; `cargo check` warns;
|
||||
an existing `grants` or `args` test fails; a test outside the named files fails in the
|
||||
gate (`git stash -u`, rerun, `git stash pop`, report both).
|
||||
|
||||
## What the given tests pin
|
||||
|
||||
`ToolName::parse("consult")` is `Consult`; `as_str` is `"consult"`; it is not memory and
|
||||
not in `ALL`. `parse` of `{"question":"hello"}` yields that question. A `history` key is
|
||||
invalid-arguments. An empty-constraints consult grant loads with `untrusted` true. Paths
|
||||
or hosts on the grant are rejected with `does not take paths` / `does not take hosts`.
|
||||
@@ -0,0 +1,408 @@
|
||||
# M6b task 05: Decision `body`; denials still record it
|
||||
|
||||
**Branch:** `m6b`. Check it: `git branch --show-current` prints `m6b` and `git status --short`
|
||||
is empty; otherwise stop. Never switch branch.
|
||||
**Commit subject:** `brokerd: consult decisions record the body`
|
||||
**Log row:** the Task cell is `M6b/05-brokerd-consult-audit-body`.
|
||||
|
||||
**First action.** Run this before any Read of crate source. Do not `ls`
|
||||
`crates/brokerd/tests/`. Do not Read `main.rs`, `consult_http`, `docs/implementer-log.md`
|
||||
(except the first 12 lines at the log step). Do not edit `docs/runbook.md` (task 03
|
||||
already appended `## consult-not-configured`). The signatures below are complete.
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/consult_deny.rs crates/brokerd/tests/
|
||||
```
|
||||
|
||||
## Goal
|
||||
|
||||
Every `consult` decision records the JSON body that would be POSTed, including a denial
|
||||
(spec 4.4). `AuditEvent::Decision` gains optional `body` with
|
||||
`#[serde(default, skip_serializing_if = "Option::is_none")]`. Other tools leave it unset.
|
||||
No `[consult]` is `decide_forced` with `InvalidArguments` and `NOT_CONFIGURED` (the
|
||||
runbook pointer). A grant that does not cover the session's taint is `TaintTooHigh` and
|
||||
still records the body. Unknown keys are invalid-arguments. Nothing is POSTed on a
|
||||
denial (`Recording` stays at count 0).
|
||||
|
||||
## Every place that changes
|
||||
|
||||
Adding `body` to `AuditEvent::Decision` makes every struct literal of that variant fail
|
||||
to compile until it names the field. List each constructor. Matches that use `..` need
|
||||
no edit.
|
||||
|
||||
| Place | What happens there |
|
||||
|---|---|
|
||||
| `crates/loopd` / other crates | **no change** except the constructors below |
|
||||
| `crates/bxcore/src/audit.rs` | `body: Option<String>` on `Decision` only |
|
||||
| `crates/bxcore/tests/records.rs` | three `Decision { }` literals: `body: None` |
|
||||
| `crates/brokerd/tests/ledger.rs` | one `Decision { }` literal: `body: None` |
|
||||
| `crates/brokerd/tests/support/audit_dir.rs` | `denied()`: `body: None` |
|
||||
| `crates/brokerd/src/ledger/mod.rs` | `decide_with_body`, `decide_forced`, `decide_inner`; event includes `body` |
|
||||
| `crates/brokerd/src/consult.rs` | `NOT_CONFIGURED` |
|
||||
| `crates/brokerd/src/broker.rs` | consult uses `wire_body` then `decide_forced` / `decide_with_body` |
|
||||
| `crates/brokerd/tests/consult_deny.rs` | new, given |
|
||||
| everything else | **no change**: not `main.rs`, not HTTP, not `docs/runbook.md` |
|
||||
|
||||
`loopd/tests/end_to_end.rs` matches `Decision` with `..` and needs no edit. Do not add
|
||||
`body` to `AuditEvent::Approval`.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy, from `docs/plans/M6b/files/` (the same path under it): the new test
|
||||
`crates/brokerd/tests/consult_deny.rs`.
|
||||
- Modify: `crates/bxcore/src/audit.rs`, `crates/bxcore/tests/records.rs`,
|
||||
`crates/brokerd/src/ledger/mod.rs`, `crates/brokerd/src/consult.rs`,
|
||||
`crates/brokerd/src/broker.rs`, `crates/brokerd/tests/ledger.rs`,
|
||||
`crates/brokerd/tests/support/audit_dir.rs`, `docs/implementer-log.md`.
|
||||
- Do not touch: any other file. Do not edit `docs/runbook.md`.
|
||||
|
||||
## Facts you need
|
||||
|
||||
- **Serde.** `body` is last on `Decision`. `#[serde(default, skip_serializing_if = "Option::is_none")]`.
|
||||
Existing JSONL fixtures without `body` still decode. A `None` still equals a decoded
|
||||
record that omitted the field.
|
||||
- **`decide` stays.** Existing callers keep `decide(...)`. Consult goes through
|
||||
`decide_with_body` or `decide_forced`. `decide` is `decide_inner(..., None, None)`.
|
||||
- **`decide_forced` does not match grants.** The outcome is the given `Denial`. The
|
||||
noconfig test's rig has no consult grant; matching would be `NoGrant`, not
|
||||
`InvalidArguments`.
|
||||
- **`NOT_CONFIGURED`** is exactly
|
||||
`consult is not configured; see docs/runbook.md#consult-not-configured` (no trailing
|
||||
period). `scripts/check-runbook.sh` reads this pointer; the heading already exists.
|
||||
- **`Denial`** is `crate::policy::Denial { reason, grant, grant_sha256, detail }`.
|
||||
- **Wire body on noconfig.** `wire_body(None, arguments)`: empty model, temperature 0.6,
|
||||
one user message.
|
||||
- The items the edits use. This table is complete.
|
||||
|
||||
| Item | Signature |
|
||||
|---|---|
|
||||
| `consult::wire_body` | `wire_body(consult: Option<&Consult>, arguments: &str) -> String` |
|
||||
| `consult::CONSULT` | `&str`, `"consult"` |
|
||||
| `Ledger::decide` | `decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided` |
|
||||
| `policy::Denial` | `{ reason: DenyReason, grant: Option<String>, grant_sha256: Option<Hash32>, detail: Option<String> }` |
|
||||
| `DenyReason::InvalidArguments` | in `bxcore` |
|
||||
| `DenyReason::TaintTooHigh` | in `bxcore` |
|
||||
| `Broker::cfg` | `cfg(&self) -> &Config` |
|
||||
| `Broker::ledger` | `ledger(&self) -> &Ledger` |
|
||||
| `Broker::grants` | as used in `handle` already |
|
||||
|
||||
## The edits, exactly
|
||||
|
||||
Each quoted "before" block occurs exactly once after task 04.
|
||||
|
||||
### `crates/bxcore/src/audit.rs`
|
||||
|
||||
**E1.** The `Decision` fields ending
|
||||
|
||||
```rust
|
||||
taint: DataClass,
|
||||
untrusted: bool,
|
||||
},
|
||||
Approval {
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
taint: DataClass,
|
||||
untrusted: bool,
|
||||
/// The JSON body `consult` would send (or sent). Other tools leave this unset.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
},
|
||||
Approval {
|
||||
```
|
||||
|
||||
Do not add `body` to `Approval`.
|
||||
|
||||
### `crates/bxcore/tests/records.rs`
|
||||
|
||||
**E2, E3, E4.** Each of the three `AuditEvent::Decision {` literals gains `body: None,`
|
||||
after `untrusted: …,`. They are:
|
||||
|
||||
1. `tool: "read_file"` / `untrusted: false,` then `},` of that event
|
||||
2. `tool: "shell"` / `untrusted: true,` then `},` of that event
|
||||
3. `tool: "rm_rf"` / `untrusted: false,` then `},` of that event
|
||||
|
||||
Each `untrusted: false,` / `untrusted: true,` that is the last field of a `Decision`
|
||||
becomes that line plus `body: None,`. Do not add it to `Approval` or `Result`.
|
||||
|
||||
### `crates/brokerd/tests/ledger.rs`
|
||||
|
||||
**E5.** In `an_allowed_call_is_recorded_in_full`, after `untrusted: false,` of the
|
||||
`AuditEvent::Decision` add `body: None,`.
|
||||
|
||||
### `crates/brokerd/tests/support/audit_dir.rs`
|
||||
|
||||
**E6.** In `denied()`, after `untrusted: false,` add `body: None,`.
|
||||
|
||||
### `crates/brokerd/src/consult.rs`
|
||||
|
||||
**N1.** Immediately before `pub const SECRET_POINTER: &str = "see docs/runbook.md#consult-secret-unreadable";` insert:
|
||||
|
||||
```rust
|
||||
/// Denied when `brokerd.toml` has no `[consult]` table.
|
||||
pub const NOT_CONFIGURED: &str =
|
||||
"consult is not configured; see docs/runbook.md#consult-not-configured";
|
||||
```
|
||||
|
||||
### `crates/brokerd/src/ledger/mod.rs`
|
||||
|
||||
**D1.** Replace the start of `decide`
|
||||
|
||||
```rust
|
||||
/// Decide and record a call.
|
||||
pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided {
|
||||
let session = request.session.clone();
|
||||
```
|
||||
|
||||
with
|
||||
|
||||
```rust
|
||||
/// Decide and record a call.
|
||||
pub fn decide(&self, request: ToolRequest, grants: &Grants, now: Timestamp) -> Decided {
|
||||
self.decide_inner(request, grants, now, None, None)
|
||||
}
|
||||
|
||||
/// Decide and record a call, storing `body` on the decision (consult).
|
||||
pub fn decide_with_body(
|
||||
&self,
|
||||
request: ToolRequest,
|
||||
grants: &Grants,
|
||||
now: Timestamp,
|
||||
body: Option<String>,
|
||||
) -> Decided {
|
||||
self.decide_inner(request, grants, now, body, None)
|
||||
}
|
||||
|
||||
/// Record `forced` as the denial, with `body`, without matching grants.
|
||||
pub fn decide_forced(
|
||||
&self,
|
||||
request: ToolRequest,
|
||||
grants: &Grants,
|
||||
now: Timestamp,
|
||||
body: Option<String>,
|
||||
forced: crate::policy::Denial,
|
||||
) -> Decided {
|
||||
self.decide_inner(request, grants, now, body, Some(forced))
|
||||
}
|
||||
|
||||
fn decide_inner(
|
||||
&self,
|
||||
request: ToolRequest,
|
||||
grants: &Grants,
|
||||
now: Timestamp,
|
||||
body: Option<String>,
|
||||
forced: Option<crate::policy::Denial>,
|
||||
) -> Decided {
|
||||
let session = request.session.clone();
|
||||
```
|
||||
|
||||
**D2.** The outcome assignment. The lines
|
||||
|
||||
```rust
|
||||
let (outcome, grant, grant_sha256, payload) = match grants {
|
||||
Err(_) => (
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::GrantsInvalid,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
RecordPayload::Simple(DenyReason::GrantsInvalid),
|
||||
),
|
||||
Ok(set) => match state {
|
||||
Err(()) => (
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::StateUnreadable,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
RecordPayload::Simple(DenyReason::StateUnreadable),
|
||||
),
|
||||
Ok(state) => match crate::policy::decide(request, set, state, now) {
|
||||
crate::policy::Outcome::Allowed(decision) => (
|
||||
DecisionRecord::Allowed {},
|
||||
Some(decision.grant().to_string()),
|
||||
Some(decision.grant_sha256()),
|
||||
RecordPayload::Allowed(decision),
|
||||
),
|
||||
crate::policy::Outcome::Ask(ask) => (
|
||||
DecisionRecord::Ask {},
|
||||
Some(ask.grant().to_string()),
|
||||
Some(ask.grant_sha256()),
|
||||
RecordPayload::Ask(ask),
|
||||
),
|
||||
crate::policy::Outcome::Denied(denial) => (
|
||||
DecisionRecord::Denied {
|
||||
reason: denial.reason,
|
||||
},
|
||||
denial.grant.clone(),
|
||||
denial.grant_sha256,
|
||||
RecordPayload::Denial(denial),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
let (outcome, grant, grant_sha256, payload) = if let Some(denial) = forced {
|
||||
(
|
||||
DecisionRecord::Denied {
|
||||
reason: denial.reason,
|
||||
},
|
||||
denial.grant.clone(),
|
||||
denial.grant_sha256,
|
||||
RecordPayload::Denial(denial),
|
||||
)
|
||||
} else {
|
||||
match grants {
|
||||
Err(_) => (
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::GrantsInvalid,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
RecordPayload::Simple(DenyReason::GrantsInvalid),
|
||||
),
|
||||
Ok(set) => match state {
|
||||
Err(()) => (
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::StateUnreadable,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
RecordPayload::Simple(DenyReason::StateUnreadable),
|
||||
),
|
||||
Ok(state) => match crate::policy::decide(request, set, state, now) {
|
||||
crate::policy::Outcome::Allowed(decision) => (
|
||||
DecisionRecord::Allowed {},
|
||||
Some(decision.grant().to_string()),
|
||||
Some(decision.grant_sha256()),
|
||||
RecordPayload::Allowed(decision),
|
||||
),
|
||||
crate::policy::Outcome::Ask(ask) => (
|
||||
DecisionRecord::Ask {},
|
||||
Some(ask.grant().to_string()),
|
||||
Some(ask.grant_sha256()),
|
||||
RecordPayload::Ask(ask),
|
||||
),
|
||||
crate::policy::Outcome::Denied(denial) => (
|
||||
DecisionRecord::Denied {
|
||||
reason: denial.reason,
|
||||
},
|
||||
denial.grant.clone(),
|
||||
denial.grant_sha256,
|
||||
RecordPayload::Denial(denial),
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**D3.** The event fields ending
|
||||
|
||||
```rust
|
||||
taint: recorded.taint,
|
||||
untrusted: recorded.untrusted,
|
||||
};
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
taint: recorded.taint,
|
||||
untrusted: recorded.untrusted,
|
||||
body,
|
||||
};
|
||||
```
|
||||
|
||||
### `crates/brokerd/src/broker.rs`
|
||||
|
||||
**H1.** The lines
|
||||
|
||||
```rust
|
||||
let now = Timestamp::now();
|
||||
let grants = broker.grants();
|
||||
let answer = match broker.ledger().decide(request, &grants, now) {
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
let now = Timestamp::now();
|
||||
let grants = broker.grants();
|
||||
let decided = if request.tool == crate::consult::CONSULT {
|
||||
let body = crate::consult::wire_body(broker.cfg().consult.as_ref(), &request.arguments);
|
||||
if broker.cfg().consult.is_none() {
|
||||
broker.ledger().decide_forced(
|
||||
request,
|
||||
&grants,
|
||||
now,
|
||||
Some(body),
|
||||
crate::policy::Denial {
|
||||
reason: bxcore::DenyReason::InvalidArguments,
|
||||
grant: None,
|
||||
grant_sha256: None,
|
||||
detail: Some(crate::consult::NOT_CONFIGURED.to_string()),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
broker
|
||||
.ledger()
|
||||
.decide_with_body(request, &grants, now, Some(body))
|
||||
}
|
||||
} else {
|
||||
broker.ledger().decide(request, &grants, now)
|
||||
};
|
||||
let answer = match decided {
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy the given test.**
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/consult_deny.rs crates/brokerd/tests/
|
||||
```
|
||||
|
||||
- [ ] **2. See it fail.** `cargo test -p brokerd --test consult_deny`, timeout 600000 ms:
|
||||
the target does not compile (`E0560` / no field `body` on `AuditEvent::Decision`, or
|
||||
unresolved `NOT_CONFIGURED`).
|
||||
- [ ] **3. The edits.** E1–E6, N1, D1–D3, H1. `cargo check -p bxcore -p brokerd` prints no
|
||||
error and no warning. `wc -l crates/brokerd/src/ledger/mod.rs` and
|
||||
`wc -l crates/brokerd/src/broker.rs` each ≤ 500.
|
||||
- [ ] **4. The tests.** `cargo test -p brokerd --test consult_deny --test ledger --test
|
||||
audit` and `cargo test -p bxcore --test records`: every test passes, 3 in
|
||||
`consult_deny`. Existing Decision equality tests still pass.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
|
||||
line `gate: ok`. Test count **1609** (1606 before, plus 3). After `gate: ok`, do not add
|
||||
up `test result:` lines.
|
||||
- [ ] **6. Log and commit.** Task cell `M6b/05-brokerd-consult-audit-body`. Read only the
|
||||
first 12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
|
||||
`git add crates/bxcore crates/brokerd docs/implementer-log.md Cargo.lock`, then commit
|
||||
as `AGENTS.md` says.
|
||||
|
||||
## Done when
|
||||
|
||||
- `make gate` prints `gate: ok` with 1609 tests.
|
||||
- `git diff HEAD~1 --stat` lists only the files in the Files section plus the log.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- The branch check fails; a "before" block is missing or duplicated (in particular E1
|
||||
matching `Approval` instead of `Decision`); step 2 compiles; you edit `docs/runbook.md`
|
||||
or `main.rs`; you add `body` to `Approval`; `cargo check` warns; an existing ledger or
|
||||
records test fails; a test outside the named files fails in the gate (`git stash -u`,
|
||||
rerun, `git stash pop`, report both).
|
||||
|
||||
## What the given tests pin
|
||||
|
||||
No `[consult]` is `InvalidArguments` naming `consult-not-configured` and `docs/runbook.md`;
|
||||
the audit `body` equals `wire_body(None, question)` and has no `tools`. A secret session
|
||||
against a private consult grant is `TaintTooHigh` with no detail; the body has
|
||||
`"model":"test-model"` and one message. An unknown `history` key is `InvalidArguments`.
|
||||
Nothing reaches the runtime (`count() == 0`).
|
||||
@@ -0,0 +1,425 @@
|
||||
# M6b task 06: allowlisted POST; audit body equals the bytes
|
||||
|
||||
**Branch:** `m6b`. Check it: `git branch --show-current` prints `m6b` and `git status --short`
|
||||
is empty; otherwise stop. Never switch branch.
|
||||
**Commit subject:** `brokerd: consult posts the allowlisted body`
|
||||
**Log row:** the Task cell is `M6b/06-brokerd-consult-http`.
|
||||
|
||||
**First action.** Run this before any Read of crate source. Do not `ls`
|
||||
`crates/brokerd/tests/`. Do not Read `podman.rs`, `docs/implementer-log.md` (except the
|
||||
first 12 lines at the log step). The given test injects an in-process fake; do not open a
|
||||
socket in the tests and do not reach the network (T27).
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/consult_allow.rs crates/brokerd/tests/
|
||||
```
|
||||
|
||||
## Goal
|
||||
|
||||
An allowed `consult` POSTs the allowlisted JSON (`wire_body`) through `ConsultTransport`.
|
||||
Tests inject a fake; production uses loopback HTTP/1.1 (`HttpTransport`). The audit
|
||||
`body` equals the posted bytes. `messages` has one user item and no `tools`. The tool
|
||||
result is `choices[0].message.content`, tag-neutralised, then capped. `ConsultStack`
|
||||
sends `Consult` to that runtime and everything else to `inner`, so existing `Routed`
|
||||
tests stay unedited. `main` loads the secret after `Config::load` and wraps `Routed`
|
||||
when `[consult]` and the secret are both present.
|
||||
|
||||
## Every place that changes
|
||||
|
||||
| Place | What happens there |
|
||||
|---|---|
|
||||
| `crates/brokerd/src/consult.rs` | `ConsultTransport`, `ConsultRuntime`, `ConsultStack`, `parse_message_content` |
|
||||
| `crates/brokerd/src/consult_http.rs` | new: `HttpTransport` |
|
||||
| `crates/brokerd/src/lib.rs` | `pub mod consult_http;` |
|
||||
| `crates/brokerd/src/main.rs` | `load_secret`; wrap `Routed` in `ConsultStack` |
|
||||
| `crates/brokerd/tests/consult_allow.rs` | new, given |
|
||||
| everything else | **no change**: not `ledger`, not `handle`'s decide path |
|
||||
|
||||
## Files
|
||||
|
||||
- Copy, from `docs/plans/M6b/files/` (the same path under it): the new test
|
||||
`crates/brokerd/tests/consult_allow.rs`.
|
||||
- Create: `crates/brokerd/src/consult_http.rs` (the module below).
|
||||
- Modify: `crates/brokerd/src/consult.rs`, `crates/brokerd/src/lib.rs`,
|
||||
`crates/brokerd/src/main.rs`, `docs/implementer-log.md`.
|
||||
- Do not touch: any other file.
|
||||
|
||||
The given test holds `mod runtime` even though it does not name `Recording`:
|
||||
`support/client.rs` imports `crate::runtime`. Leave that module in the test.
|
||||
|
||||
## Facts you need
|
||||
|
||||
- **`Runtime::run_labelled` default calls `run`.** Implement both: `run` forwards to
|
||||
`run_labelled` and drops the label. Implementing only `run_labelled` recurses.
|
||||
- **No `choices[0]` index.** `as_array().and_then(|a| a.first())`. A missing content is
|
||||
`"the response has no content"`.
|
||||
- **Neutralise then cap.** `bxcore::sanitize::neutralise` then `cap_bytes(..., QUESTION_CAP)`.
|
||||
The given test builds the think tag with `concat!("<", "think", ">")` and asserts
|
||||
`<` in the result. Do not write that tag whole anywhere (T33).
|
||||
- **Label.** `run_labelled` returns `(RunOutput { content, truncated }, None)`. Result
|
||||
class comes from the grant, as today.
|
||||
- **HTTP/1.1.** `TcpStream` to the loopback host:port from `parse_loopback_http`. Headers:
|
||||
`POST {path} HTTP/1.1`, `Host`, `Authorization: Bearer {bearer}`,
|
||||
`Content-Type: application/json`, `Content-Length`, `Connection: close`. Response cap
|
||||
262_144 bytes. Status must contain ` 200 ` or end with ` 200`. HTTPS is later.
|
||||
- **`load_secret` in `main`** after `Config::load`, before the instance lock. Print
|
||||
`{e}` and exit 1 on `Err`. Print a `warning` if `Loaded` has one. Role is already
|
||||
`"brokerd"` inside `load_secret`.
|
||||
- **Wrap after `Routed`.** `ConsultStack { consult: ConsultRuntime::new(consult,
|
||||
loaded.secret, HttpTransport), inner: runtime }` when both `cfg.consult` and the
|
||||
loaded secret are `Some`. Otherwise keep `runtime`.
|
||||
- The items the edits use. This table is complete.
|
||||
|
||||
| Item | Signature |
|
||||
|---|---|
|
||||
| `Runtime::run` | `run(&self, spec: &RunSpec) -> Result<RunOutput, RunError>` |
|
||||
| `Runtime::run_labelled` | `run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError>` |
|
||||
| `RunSpec::tool` | `tool(&self) -> ToolName` |
|
||||
| `RunSpec::arguments` | `arguments(&self) -> &ToolArgs` |
|
||||
| `RunError::Unavailable` | `Unavailable(String)` |
|
||||
| `RunOutput` | `{ content: String, truncated: bool }` |
|
||||
| `Secret::new` | `new(text: String) -> Secret` |
|
||||
| `Secret::expose` | `expose(&self) -> &str` |
|
||||
| `Loaded` | `{ secret: Secret, warning: Option<String> }` |
|
||||
| `bxcore::sanitize::neutralise` | `neutralise(s: &str) -> String` |
|
||||
| `Consult::temperature` | `temperature(&self) -> f64` |
|
||||
| `Consult::timeout` | `timeout(&self) -> Duration` |
|
||||
| `parse_loopback_http` | `parse_loopback_http(url: &str) -> Result<(String, u16, String), String>` |
|
||||
| `TcpStream::connect` | `connect<A: ToSocketAddrs>(addr: A) -> io::Result<TcpStream>` |
|
||||
| `TcpStream::set_read_timeout` | `set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()>` |
|
||||
| `TcpStream::set_write_timeout` | `set_write_timeout(&self, dur: Option<Duration>) -> io::Result<()>` |
|
||||
|
||||
## The edits, exactly
|
||||
|
||||
Each quoted "before" block occurs exactly once after task 05.
|
||||
|
||||
### `crates/brokerd/src/consult.rs`
|
||||
|
||||
**I1.** The imports
|
||||
|
||||
```rust
|
||||
use bxcore::secrets::{Loaded, SecretError, SecretSpec};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::config::Config;
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
use bxcore::secrets::{Loaded, Secret, SecretError, SecretSpec};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::args::ToolArgs;
|
||||
use crate::config::Config;
|
||||
use crate::policy::Label;
|
||||
use crate::runner::{RunError, RunOutput, RunSpec, Runtime};
|
||||
```
|
||||
|
||||
**I2.** After `load_secret` (after its closing `}`) append:
|
||||
|
||||
```rust
|
||||
/// One POST of the allowlisted body. Tests inject a fake; production uses loopback HTTP/1.1.
|
||||
pub trait ConsultTransport: Send + Sync {
|
||||
fn post(
|
||||
&self,
|
||||
url: &str,
|
||||
bearer: &str,
|
||||
body: &[u8],
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<u8>, String>;
|
||||
}
|
||||
|
||||
/// Runs an allowed `consult` through `transport`.
|
||||
pub struct ConsultRuntime<T> {
|
||||
consult: Consult,
|
||||
secret: Secret,
|
||||
transport: T,
|
||||
}
|
||||
|
||||
impl<T: ConsultTransport> ConsultRuntime<T> {
|
||||
pub fn new(consult: Consult, secret: Secret, transport: T) -> ConsultRuntime<T> {
|
||||
ConsultRuntime {
|
||||
consult,
|
||||
secret,
|
||||
transport,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ConsultTransport> Runtime for ConsultRuntime<T> {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
self.run_labelled(spec).map(|(output, _)| output)
|
||||
}
|
||||
|
||||
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
||||
let question = match spec.arguments() {
|
||||
ToolArgs::Consult { question } => question.as_str(),
|
||||
_ => {
|
||||
return Err(RunError::Unavailable(
|
||||
"consult was routed a different tool".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let body = wire_body_parts(&self.consult.model, self.consult.temperature(), question);
|
||||
let bytes = self
|
||||
.transport
|
||||
.post(
|
||||
&self.consult.url,
|
||||
self.secret.expose(),
|
||||
body.as_bytes(),
|
||||
self.consult.timeout(),
|
||||
)
|
||||
.map_err(RunError::Unavailable)?;
|
||||
let content = parse_message_content(&bytes).map_err(RunError::Unavailable)?;
|
||||
let neutralised = bxcore::sanitize::neutralise(&content);
|
||||
let (content, truncated) = cap_bytes(&neutralised, QUESTION_CAP);
|
||||
Ok((RunOutput { content, truncated }, None))
|
||||
}
|
||||
}
|
||||
|
||||
/// `choices[0].message.content`, ignoring unknown fields.
|
||||
pub fn parse_message_content(bytes: &[u8]) -> Result<String, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(bytes).map_err(|e| format!("consult response is not JSON: {e}"))?;
|
||||
match value
|
||||
.get("choices")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|c| c.get("message"))
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
{
|
||||
Some(content) => Ok(content.to_string()),
|
||||
None => Err("the response has no content".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `consult` first, everything else to `inner`.
|
||||
pub struct ConsultStack {
|
||||
pub consult: Box<dyn Runtime>,
|
||||
pub inner: Box<dyn Runtime>,
|
||||
}
|
||||
|
||||
impl Runtime for ConsultStack {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
self.run_labelled(spec).map(|(output, _)| output)
|
||||
}
|
||||
|
||||
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
||||
if spec.tool() == crate::args::ToolName::Consult {
|
||||
self.consult.run_labelled(spec)
|
||||
} else {
|
||||
self.inner.run_labelled(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `crates/brokerd/src/consult_http.rs`
|
||||
|
||||
Write this module.
|
||||
|
||||
```rust
|
||||
//! Loopback HTTP/1.1 POST for `consult`. HTTPS is a later milestone.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::consult::{ConsultTransport, parse_loopback_http};
|
||||
|
||||
/// Largest response body accepted, in bytes.
|
||||
pub const MAX_RESPONSE: usize = 262_144;
|
||||
|
||||
/// Production transport: one TCP connection to the configured loopback URL.
|
||||
pub struct HttpTransport;
|
||||
|
||||
impl ConsultTransport for HttpTransport {
|
||||
fn post(
|
||||
&self,
|
||||
url: &str,
|
||||
bearer: &str,
|
||||
body: &[u8],
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
post_http(url, bearer, body, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
fn post_http(url: &str, bearer: &str, body: &[u8], timeout: Duration) -> Result<Vec<u8>, String> {
|
||||
let (host, port, path) = parse_loopback_http(url)?;
|
||||
let addr = format!("{host}:{port}");
|
||||
let mut stream =
|
||||
TcpStream::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(timeout))
|
||||
.map_err(|e| format!("cannot set read timeout: {e}"))?;
|
||||
stream
|
||||
.set_write_timeout(Some(timeout))
|
||||
.map_err(|e| format!("cannot set write timeout: {e}"))?;
|
||||
let host_header = if port == 80 {
|
||||
host
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
};
|
||||
let length = body.len();
|
||||
let header = format!(
|
||||
"POST {path} HTTP/1.1\r\nHost: {host_header}\r\nAuthorization: Bearer {bearer}\r\nContent-Type: application/json\r\nContent-Length: {length}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream
|
||||
.write_all(header.as_bytes())
|
||||
.map_err(|e| format!("cannot write consult request: {e}"))?;
|
||||
stream
|
||||
.write_all(body)
|
||||
.map_err(|e| format!("cannot write consult request: {e}"))?;
|
||||
stream
|
||||
.flush()
|
||||
.map_err(|e| format!("cannot write consult request: {e}"))?;
|
||||
read_response(&mut stream, timeout)
|
||||
}
|
||||
|
||||
fn read_response(stream: &mut TcpStream, timeout: Duration) -> Result<Vec<u8>, String> {
|
||||
let until = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.ok_or_else(|| "the consult timeout is too large".to_string())?;
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
if Instant::now() >= until {
|
||||
return Err("consult timed out".to_string());
|
||||
}
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
stream
|
||||
.set_read_timeout(Some(left))
|
||||
.map_err(|e| format!("cannot set read timeout: {e}"))?;
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.len() > MAX_RESPONSE {
|
||||
return Err("consult response is too large".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {
|
||||
return Err("consult timed out".to_string());
|
||||
}
|
||||
Err(e) => return Err(format!("cannot read consult response: {e}")),
|
||||
}
|
||||
}
|
||||
split_http_body(&buf)
|
||||
}
|
||||
|
||||
fn split_http_body(buf: &[u8]) -> Result<Vec<u8>, String> {
|
||||
const SEP: &[u8] = b"\r\n\r\n";
|
||||
let sep = buf
|
||||
.windows(SEP.len())
|
||||
.position(|w| w == SEP)
|
||||
.ok_or_else(|| "consult response has no header terminator".to_string())?;
|
||||
let (head, rest) = match buf.split_at_checked(sep) {
|
||||
Some(pair) => pair,
|
||||
None => return Err("consult response is truncated".to_string()),
|
||||
};
|
||||
let body = match rest.get(SEP.len()..) {
|
||||
Some(body) => body,
|
||||
None => return Err("consult response is truncated".to_string()),
|
||||
};
|
||||
let head = std::str::from_utf8(head)
|
||||
.map_err(|_| "consult response headers are not UTF-8".to_string())?;
|
||||
let status_line = head.lines().next().unwrap_or("");
|
||||
if !status_line.contains(" 200 ") && !status_line.ends_with(" 200") {
|
||||
return Err(format!("consult HTTP status is not 200: {status_line}"));
|
||||
}
|
||||
Ok(body.to_vec())
|
||||
}
|
||||
```
|
||||
|
||||
### `crates/brokerd/src/lib.rs`
|
||||
|
||||
**L1.** After `pub mod consult;` add `pub mod consult_http;`. rustfmt decides the final
|
||||
order.
|
||||
|
||||
### `crates/brokerd/src/main.rs`
|
||||
|
||||
**S1.** After `use brokerd::config::Config;` add:
|
||||
|
||||
```rust
|
||||
use brokerd::consult::{self, ConsultRuntime, ConsultStack};
|
||||
use brokerd::consult_http::HttpTransport;
|
||||
```
|
||||
|
||||
**S2.** Immediately after the `Config::load` match (after its closing `};`) and before
|
||||
the instance-lock comment, add:
|
||||
|
||||
```rust
|
||||
let consult_secret = match consult::load_secret(&cfg, &|k| std::env::var_os(k)) {
|
||||
Ok(loaded) => loaded,
|
||||
Err(e) => {
|
||||
eprintln!("brokerd: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
if let Some(loaded) = &consult_secret
|
||||
&& let Some(warning) = &loaded.warning
|
||||
{
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
```
|
||||
|
||||
**S3.** After the `Routed { ... });` block, before `let started = match serve::start`, add:
|
||||
|
||||
```rust
|
||||
let runtime: Box<dyn Runtime> = match (cfg.consult.clone(), consult_secret) {
|
||||
(Some(consult), Some(loaded)) => Box::new(ConsultStack {
|
||||
consult: Box::new(ConsultRuntime::new(consult, loaded.secret, HttpTransport)),
|
||||
inner: runtime,
|
||||
}),
|
||||
_ => runtime,
|
||||
};
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy the given test.**
|
||||
|
||||
```sh
|
||||
cp docs/plans/M6b/files/crates/brokerd/tests/consult_allow.rs crates/brokerd/tests/
|
||||
```
|
||||
|
||||
- [ ] **2. See it fail.** `cargo test -p brokerd --test consult_allow`, timeout 600000 ms:
|
||||
the target does not compile (`E0432` unresolved `ConsultRuntime` / `ConsultTransport`).
|
||||
- [ ] **3. The edits.** I1, I2, write `consult_http.rs`, L1, S1–S3. `cargo check -p
|
||||
brokerd` prints no error and no warning. `wc -l crates/brokerd/src/consult.rs`,
|
||||
`consult_http.rs`, `main.rs`, `broker.rs` each ≤ 500.
|
||||
- [ ] **4. The tests.** `cargo test -p brokerd --test consult_allow --test consult_deny
|
||||
--test memory_port --test memory_tools`: every test passes, 2 in `consult_allow`. The
|
||||
memory-port tests still pass (`ConsultStack` is not in those tests; `Routed` is
|
||||
unedited).
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
|
||||
line `gate: ok`. Test count **1611** (1609 before, plus 2). After `gate: ok`, do not add
|
||||
up `test result:` lines.
|
||||
- [ ] **6. Log and commit.** Task cell `M6b/06-brokerd-consult-http`. Read only the first
|
||||
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
|
||||
`git add crates/brokerd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
|
||||
says.
|
||||
|
||||
## Done when
|
||||
|
||||
- `make gate` prints `gate: ok` with 1611 tests.
|
||||
- `git diff HEAD~1 --stat` lists only `crates/brokerd/src/consult.rs`,
|
||||
`crates/brokerd/src/consult_http.rs`, `crates/brokerd/src/lib.rs`,
|
||||
`crates/brokerd/src/main.rs`, `crates/brokerd/tests/consult_allow.rs` and the log.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- The branch check fails; a "before" block is missing or duplicated; step 2 compiles; a
|
||||
test opens a TCP port or sleeps; you write a chat-template tag whole; `cargo check`
|
||||
warns; `consult.rs` exceeds 500 lines; a memory-port test fails; a test outside the
|
||||
named files fails in the gate (`git stash -u`, rerun, `git stash pop`, report both).
|
||||
|
||||
## What the given tests pin
|
||||
|
||||
An allowed consult POSTs bytes equal to `wire_body`; the audit `body` is those bytes;
|
||||
`messages` has length 1, role user, the question, no `tools`; the tool result contains
|
||||
`blue` and `sky` and `<` and not the think tag; class private, untrusted true. A
|
||||
second test with a different question still posts only that question.
|
||||
@@ -0,0 +1,126 @@
|
||||
# M6b implementation plan: consult
|
||||
|
||||
> For the implementer: each task file is one closed task for one fresh session. Read `AGENTS.md`,
|
||||
> then the task file, and do exactly that task. The driver is `tools/run-plan.sh`.
|
||||
|
||||
**Goal:** a `consult` tool, not core, decided by `brokerd`. The grant's `max_taint` is the
|
||||
class gate. `brokerd` POSTs an allowlisted chat-completions body (question only, no
|
||||
history, no tools) over loopback HTTP/1.1. Every decision records that JSON body,
|
||||
including a denial. No vendor names in code.
|
||||
|
||||
**Architecture:** `loopd` registers `consult` after `subagent` and Ports it to `brokerd`.
|
||||
`timeout_for("consult")` is 180 s or `[broker] timeout_ms`, whichever is longer. `brokerd`
|
||||
loads `[consult]` and the named `bxcore::secrets` key at start. No `[consult]` denies with
|
||||
`consult-not-configured`. The HTTP client is not a container and not `http_fetch`.
|
||||
|
||||
**Tech stack:** Rust 1.98.1, edition 2024; no new crate. HTTPS is later.
|
||||
|
||||
**Spec:** `docs/specs/2026-10-04-m6-subagents-consult.md`, sections 4, 5, 6, 7 (M6b tests),
|
||||
and 8 (`consult-not-configured`, `consult-secret-unreadable`).
|
||||
|
||||
## The owner's decisions (2026-10-04)
|
||||
|
||||
1. Given tests use an in-process fake. The device check starts a loopback dummy on
|
||||
straylight. No live vendor in M6.
|
||||
2. Cloud-led sessions are later, not in M6. `consult` stays text-in, text-out, no tools.
|
||||
|
||||
## What the code already has, and what it lacks
|
||||
|
||||
- `subagent` is in `m5c()`, not core. `dispatch` Ports every other non-core name to
|
||||
`brokerd`. There is no `consult` tool.
|
||||
- `BrokerPort::timeout_for` special-cases only `ask_memory`. `[broker] timeout_ms` defaults
|
||||
to 120_000; do not change that default.
|
||||
- `brokerd.toml` has no `[consult]` and no `[secrets]`. `brokerd` does not enable
|
||||
`bxcore`'s `secrets` feature.
|
||||
- `ToolName` has no `Consult`. `Grant.untrusted` already defaults true.
|
||||
- `AuditEvent::Decision` has no `body`.
|
||||
|
||||
## Global constraints
|
||||
|
||||
- Roles depend on `bxcore` only. No `unsafe`, no async runtime, no new crate.
|
||||
- No file the implementer reads holds a chat-template tag whole (T33): build tags from
|
||||
pieces (`concat!("<", "think", ">")`).
|
||||
- Every path a given test creates is removed by a guard (T44); no test depends on timing
|
||||
(T27).
|
||||
- Fail-closed states `consult-not-configured` and `consult-secret-unreadable` have runbook
|
||||
entries, added by the task that adds the pointer (T49).
|
||||
- Each task gives its baseline and its gate count (T50, T53); the branch line is a check,
|
||||
never `git switch` (T51).
|
||||
- `Cargo.lock` is in the `git add` line (T24). Gate timeout 600000 ms (T40).
|
||||
- `docs/implementer-log.md`: at log-and-commit, read only the first 12 lines (T62).
|
||||
- Do not edit existing given tests the task does not list (`broker_port_ask_wait.rs`,
|
||||
`crates/loopd/tests/config.rs`, `crates/brokerd/tests/grants.rs` `rule_3`,
|
||||
`crates/brokerd/tests/config.rs`).
|
||||
|
||||
## Tasks
|
||||
|
||||
Every task below has been written to be replayed from its text. The gate count is the
|
||||
passed total of `cargo test --workspace` plus one, for the ignored `end_to_end` test the
|
||||
gate runs on its own. Before task 01 it is **1590** (straylight, rustc 1.98.1 / clippy
|
||||
0.1.98; M6a).
|
||||
|
||||
| # | Delivers | Crates | Gate after | Tests added | Mutations tried, caught |
|
||||
|---|---|---|---|---|---|
|
||||
| 01 | `consult` in `m5c()`, not core; epoch tools array unchanged | loopd | 1592 | 2 | 1, 1 |
|
||||
| 02 | `CONSULT_WAIT` 180 s via `timeout_for`; default `[broker] timeout_ms` stays 120_000 | loopd | 1593 | 1 | 1, 1 |
|
||||
| 03 | `[consult]` + `[secrets]`; start fails closed; runbook both consult entries | brokerd | 1602 | 9 | 2, 2 |
|
||||
| 04 | `ToolName::Consult`; parse; grants `covers` as `Some(None)`; no paths/hosts | brokerd | 1606 | 4 | 2, 2 |
|
||||
| 05 | Decision `body`; not-configured and `TaintTooHigh` record the body | bxcore, brokerd | 1609 | 3 | 2, 2 |
|
||||
| 06 | Fake HTTP 200; audit body equals posted JSON; tool result is the answer | brokerd | 1611 | 2 | 2, 2 |
|
||||
|
||||
Mutations (reference, one line each, restored after): `core: true` fails both task-01 tests;
|
||||
dropping the `consult` arm of `timeout_for` fails `consult_waits_one_hundred_and_eighty_seconds`
|
||||
and leaves `the_wait_for_each_tool` green; skipping `parse_loopback_http` in `validate` fails
|
||||
`a_non_loopback_url_is_invalid`; skipping the `0.6` check in `de_temperature_milli` fails
|
||||
`temperature_above_zero_point_six_is_invalid`; omitting `Consult` from `parse` fails
|
||||
`consult_parses_and_is_not_a_container_tool`; omitting the paths arm fails
|
||||
`consult_does_not_take_paths_or_hosts`; omitting `decide_forced` when `[consult]` is absent
|
||||
fails `no_consult_table_is_denied_with_the_runbook_and_records_the_body`; omitting `body` on
|
||||
the event fails both deny tests that assert the body; skipping `neutralise` fails the `<`
|
||||
assert in `an_allowed_consult_posts_only_the_question_and_returns_the_answer`; posting a
|
||||
second message in `wire_body_parts` fails `the_consult_body_is_only_the_question`.
|
||||
|
||||
After task 06, the design model runs the M6b device check (spec 7) on straylight: a loopback
|
||||
OpenAI-compatible dummy the check starts; a clean session's audit `body` matches a captured
|
||||
request; a session that first recalled an untrusted note is refused. Slot 1 is left busy. No
|
||||
internet consult.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- `consult` is not in the epoch `tools` array (task 01).
|
||||
- `[broker] timeout_ms` still defaults to 120_000 (task 02).
|
||||
- URL is loopback `http://` only (task 03). Temperature ≤ 0.6.
|
||||
- A consult grant with paths or hosts is invalid; unknown tool names still fail with the
|
||||
four-tool sentence (task 04; do not edit `rule_3`).
|
||||
- A denial still records the body that would have been sent (task 05).
|
||||
- The posted bytes equal the audit `body`; `messages` has one user item, no `tools`
|
||||
(task 06). The answer is tag-neutralised.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **No internet consult in M6.** A live URL later is its own egress row, grant, and secret.
|
||||
- **No HTTPS in M6.** The client is HTTP/1.1 to 127.0.0.1 or localhost.
|
||||
- **Cloud-led sessions are not in M6.**
|
||||
|
||||
## Before hand-over
|
||||
|
||||
Work through `docs/plans/CHECKLIST.md` with a reference in a scratch worktree with its own
|
||||
build directory, replayed from its task text; mutations; `/tmp` compared around a gate run;
|
||||
the reference moved off this machine as a bundle before the run (T18). Task files name their
|
||||
log row (T47).
|
||||
|
||||
## Running it
|
||||
|
||||
The reference (`m6b-ref` at `5fb05f0`, tasks 01 to 06) is a git bundle
|
||||
`~/boxmaker-refs/m6b-ref.bundle`. Copy it to straylight and delete the local worktree
|
||||
(`/home/kyle/boxmaker-refs/m6b-ref`) and branch before the run (T18). Then, from the
|
||||
repository root, with the OpenCode TUI closed, after M6a has landed on master:
|
||||
|
||||
```sh
|
||||
git switch -c m6b master
|
||||
OPENCODE_MODEL=llama.cpp/ornith-1.5-35b-a3b RESUME=1 \
|
||||
SLOTS_URL='http://straylight:11434/slots?model=ornith-1.5-35b-a3b' \
|
||||
tools/run-plan.sh docs/plans/M6b 01
|
||||
```
|
||||
|
||||
After task 06, the gate count is 1611. Then the device check above.
|
||||
@@ -0,0 +1,84 @@
|
||||
//! `[consult]` in `brokerd.toml`. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use brokerd::config::{Config, ConfigError};
|
||||
use brokerd::consult::{DEFAULT_TEMPERATURE_MILLI, DEFAULT_TIMEOUT_MS};
|
||||
use tmp::TempDir;
|
||||
|
||||
fn load(text: &str) -> Result<Config, ConfigError> {
|
||||
let dir = TempDir::new("cfg-consult");
|
||||
let path = dir.write("brokerd.toml", text);
|
||||
Config::load(&path)
|
||||
}
|
||||
|
||||
const TABLE: &str = r#"
|
||||
[consult]
|
||||
url = "http://127.0.0.1:9/v1/chat/completions"
|
||||
model = "test-model"
|
||||
secret = "consult"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn no_consult_table_means_none() {
|
||||
let c = load("").unwrap();
|
||||
assert!(c.consult.is_none());
|
||||
assert!(c.secrets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_consult_table_loads_defaults() {
|
||||
let c = load(TABLE).unwrap();
|
||||
let consult = c.consult.expect("consult");
|
||||
assert_eq!(consult.url, "http://127.0.0.1:9/v1/chat/completions");
|
||||
assert_eq!(consult.model, "test-model");
|
||||
assert_eq!(consult.secret, "consult");
|
||||
assert_eq!(consult.temperature_milli, DEFAULT_TEMPERATURE_MILLI);
|
||||
assert_eq!(consult.timeout_ms, DEFAULT_TIMEOUT_MS);
|
||||
assert!((consult.temperature() - 0.6).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temperature_above_zero_point_six_is_invalid() {
|
||||
let text = format!("{TABLE}temperature = 0.7\n");
|
||||
match load(&text) {
|
||||
Err(ConfigError::Parse(_, err)) => {
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("0.6"), "{msg}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_loopback_url_is_invalid() {
|
||||
let text = r#"
|
||||
[consult]
|
||||
url = "https://example.com/v1/chat/completions"
|
||||
model = "x"
|
||||
secret = "consult"
|
||||
"#;
|
||||
match load(text) {
|
||||
Err(ConfigError::Invalid(_, why)) => {
|
||||
assert!(
|
||||
why.contains("http://127.0.0.1") || why.contains("localhost"),
|
||||
"{why}"
|
||||
);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_consult_keys_are_errors() {
|
||||
let text = format!("{TABLE}vendor = \"x\"\n");
|
||||
assert!(matches!(load(&text), Err(ConfigError::Parse(_, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_spec_loads_under_secrets() {
|
||||
let text = format!("{TABLE}\n[secrets.consult]\nenv = \"CONSULT_TOKEN\"\n");
|
||||
let c = load(&text).unwrap();
|
||||
assert!(c.secrets.contains_key("consult"));
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! An allowed `consult`: fake HTTP 200, audit body is the allowlisted JSON, the tool result is
|
||||
//! the answer text. 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::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use brokerd::consult::{self, Consult, ConsultRuntime, ConsultTransport};
|
||||
use bxcore::secrets::Secret;
|
||||
use bxcore::{AuditEvent, DataClass, DecisionRecord, ResultStatus, ToolRequest, ToolResponse};
|
||||
use client::call;
|
||||
use rig::{Rig, request};
|
||||
|
||||
struct SharedFake {
|
||||
reply: Vec<u8>,
|
||||
seen: Arc<Mutex<Option<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl ConsultTransport for SharedFake {
|
||||
fn post(
|
||||
&self,
|
||||
_url: &str,
|
||||
_bearer: &str,
|
||||
body: &[u8],
|
||||
_timeout: Duration,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
*self.seen.lock().unwrap() = Some(body.to_vec());
|
||||
Ok(self.reply.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn consult_cfg() -> Consult {
|
||||
Consult {
|
||||
url: "http://127.0.0.1:9/v1/chat/completions".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
temperature_milli: 600,
|
||||
timeout_ms: 1_000,
|
||||
secret: "consult".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_consult(rig: &Rig) {
|
||||
rig.grant(
|
||||
"consult",
|
||||
"tool = \"consult\"\nmode = \"auto\"\nmax_taint = \"private\"\n",
|
||||
);
|
||||
}
|
||||
|
||||
fn question_req(question: &str) -> ToolRequest {
|
||||
request(
|
||||
"s1",
|
||||
1,
|
||||
"consult",
|
||||
&format!(
|
||||
r#"{{"question":{}}}"#,
|
||||
serde_json::to_string(question).unwrap()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn broker_with_fake(
|
||||
rig: &Rig,
|
||||
cfg: Consult,
|
||||
reply: Vec<u8>,
|
||||
seen: Arc<Mutex<Option<Vec<u8>>>>,
|
||||
) -> Arc<brokerd::broker::Broker> {
|
||||
let runtime = ConsultRuntime::new(
|
||||
cfg,
|
||||
Secret::new("token".to_string()),
|
||||
SharedFake { reply, seen },
|
||||
);
|
||||
Arc::new(brokerd::broker::Broker::new(
|
||||
rig.cfg.clone(),
|
||||
rig.ledger(),
|
||||
Box::new(runtime),
|
||||
rig.lines.sink(),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allowed_consult_posts_only_the_question_and_returns_the_answer() {
|
||||
let mut rig = Rig::new("consult-allow");
|
||||
let cfg = consult_cfg();
|
||||
rig.cfg.consult = Some(cfg.clone());
|
||||
grant_consult(&rig);
|
||||
let tag = concat!("<", "think", ">");
|
||||
let raw_answer = format!("blue {tag} sky");
|
||||
let reply = serde_json::json!({
|
||||
"id": "ignored",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": raw_answer}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1}
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes();
|
||||
let recorded = Arc::new(Mutex::new(None));
|
||||
let broker = broker_with_fake(&rig, cfg.clone(), reply, Arc::clone(&recorded));
|
||||
let question = "what colour is the sky?";
|
||||
let frames = call(&broker, question_req(question));
|
||||
let want_body = consult::wire_body(Some(&cfg), &format!(r#"{{"question":"{question}"}}"#));
|
||||
match &frames[0].msg {
|
||||
bxcore::Message::ToolResponse(ToolResponse::Result {
|
||||
content,
|
||||
class,
|
||||
untrusted,
|
||||
truncated,
|
||||
}) => {
|
||||
assert!(content.contains("blue"), "{content}");
|
||||
assert!(content.contains("sky"), "{content}");
|
||||
assert!(!content.contains(tag), "{content}");
|
||||
assert!(content.contains("<"), "{content}");
|
||||
assert_eq!(*class, DataClass::Private);
|
||||
assert!(*untrusted);
|
||||
assert!(!*truncated);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let posted = recorded.lock().unwrap().clone().expect("posted");
|
||||
assert_eq!(posted, want_body.as_bytes());
|
||||
match rig.events().as_slice() {
|
||||
[
|
||||
AuditEvent::Decision {
|
||||
outcome,
|
||||
body: Some(body),
|
||||
tool,
|
||||
..
|
||||
},
|
||||
AuditEvent::Result {
|
||||
status: ResultStatus::Result,
|
||||
..
|
||||
},
|
||||
] => {
|
||||
assert_eq!(*outcome, DecisionRecord::Allowed {});
|
||||
assert_eq!(tool, "consult");
|
||||
assert_eq!(body, &want_body);
|
||||
let v: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(v["model"], "test-model");
|
||||
assert_eq!(v["temperature"], 0.6);
|
||||
assert_eq!(v["messages"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(v["messages"][0]["role"], "user");
|
||||
assert_eq!(v["messages"][0]["content"], question);
|
||||
assert!(v.get("tools").is_none());
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The body is only `question`, even when the caller had earlier user text in its own session.
|
||||
#[test]
|
||||
fn the_consult_body_is_only_the_question() {
|
||||
let mut rig = Rig::new("consult-nohist");
|
||||
let cfg = consult_cfg();
|
||||
rig.cfg.consult = Some(cfg.clone());
|
||||
grant_consult(&rig);
|
||||
let reply = serde_json::json!({
|
||||
"choices": [{"message": {"content": "ok"}}]
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes();
|
||||
let recorded = Arc::new(Mutex::new(None));
|
||||
let broker = broker_with_fake(&rig, cfg, reply, Arc::clone(&recorded));
|
||||
let arguments = r#"{"question":"only this"}"#;
|
||||
let _ = call(&broker, request("s1", 4, "consult", arguments));
|
||||
let posted = recorded.lock().unwrap().clone().expect("posted");
|
||||
let v: serde_json::Value = serde_json::from_slice(&posted).unwrap();
|
||||
assert_eq!(v["messages"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(v["messages"][0]["content"], "only this");
|
||||
match &rig.events()[0] {
|
||||
AuditEvent::Decision {
|
||||
body: Some(body), ..
|
||||
} => {
|
||||
assert_eq!(body.as_bytes(), posted.as_slice());
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Consult denials: not configured, taint too high, unknown keys. Each records the body. 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::sync::Arc;
|
||||
|
||||
use brokerd::consult::{self, Consult};
|
||||
use brokerd::policy::{Label, SessionState};
|
||||
use bxcore::{
|
||||
AuditEvent, DataClass, DecisionRecord, DenyReason, SessionId, ToolRequest, ToolResponse,
|
||||
};
|
||||
use client::{Serve, call};
|
||||
use rig::{Rig, request};
|
||||
use runtime::Recording;
|
||||
|
||||
fn consult_req(session: &str, call: u64, arguments: &str) -> ToolRequest {
|
||||
request(session, call, "consult", arguments)
|
||||
}
|
||||
|
||||
fn consult_cfg() -> Consult {
|
||||
Consult {
|
||||
url: "http://127.0.0.1:9/v1/chat/completions".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
temperature_milli: 600,
|
||||
timeout_ms: 1_000,
|
||||
secret: "consult".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_consult(rig: &Rig, max_taint: &str) {
|
||||
rig.grant(
|
||||
"consult",
|
||||
&format!("tool = \"consult\"\nmode = \"auto\"\nmax_taint = \"{max_taint}\"\n"),
|
||||
);
|
||||
}
|
||||
|
||||
fn decision_body(events: &[AuditEvent]) -> Option<&str> {
|
||||
match events.first() {
|
||||
Some(AuditEvent::Decision { body, .. }) => body.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_consult_table_is_denied_with_the_runbook_and_records_the_body() {
|
||||
let rig = Rig::new("consult-noconfig");
|
||||
let rt = Recording::answering("no");
|
||||
let broker = rig.broker(&rt);
|
||||
let question = r#"{"question":"what colour is the sky?"}"#;
|
||||
let frames = call(&broker, consult_req("s1", 1, question));
|
||||
assert_eq!(rt.count(), 0);
|
||||
match &frames[0].msg {
|
||||
bxcore::Message::ToolResponse(ToolResponse::Denied { reason, detail }) => {
|
||||
assert_eq!(*reason, DenyReason::InvalidArguments);
|
||||
let detail = detail.as_deref().unwrap_or("");
|
||||
assert!(detail.contains("consult-not-configured"), "{detail}");
|
||||
assert!(detail.contains("docs/runbook.md"), "{detail}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let events = rig.events();
|
||||
let body = decision_body(&events).expect("body");
|
||||
let want = consult::wire_body(None, question);
|
||||
assert_eq!(body, want);
|
||||
assert!(body.contains("what colour is the sky?"), "{body}");
|
||||
assert!(!body.contains("tools"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_session_is_taint_too_high_and_records_the_body() {
|
||||
let mut rig = Rig::new("consult-taint");
|
||||
rig.cfg.consult = Some(consult_cfg());
|
||||
grant_consult(&rig, "private");
|
||||
let sid = SessionId::new("s1").unwrap();
|
||||
rig.state()
|
||||
.raise(
|
||||
&sid,
|
||||
SessionState {
|
||||
taint: DataClass::Secret,
|
||||
untrusted: false,
|
||||
},
|
||||
Label {
|
||||
class: DataClass::Secret,
|
||||
untrusted: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let rt = Recording::answering("no");
|
||||
let broker = Arc::new(brokerd::broker::Broker::new(
|
||||
rig.cfg.clone(),
|
||||
rig.ledger(),
|
||||
Box::new(runtime::Shared(Arc::clone(&rt))),
|
||||
rig.lines.sink(),
|
||||
));
|
||||
let question = r#"{"question":"summarise the notes"}"#;
|
||||
let frames = call(&broker, consult_req("s1", 2, question));
|
||||
assert_eq!(rt.count(), 0);
|
||||
match &frames[0].msg {
|
||||
bxcore::Message::ToolResponse(ToolResponse::Denied { reason, detail }) => {
|
||||
assert_eq!(*reason, DenyReason::TaintTooHigh);
|
||||
assert_eq!(*detail, None);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
match rig.events().as_slice() {
|
||||
[
|
||||
AuditEvent::Decision {
|
||||
outcome,
|
||||
body: Some(body),
|
||||
taint,
|
||||
..
|
||||
},
|
||||
] => {
|
||||
assert_eq!(
|
||||
*outcome,
|
||||
DecisionRecord::Denied {
|
||||
reason: DenyReason::TaintTooHigh
|
||||
}
|
||||
);
|
||||
assert_eq!(*taint, DataClass::Secret);
|
||||
let want = consult::wire_body(rig.cfg.consult.as_ref(), question);
|
||||
assert_eq!(body, &want);
|
||||
assert!(body.contains("\"model\":\"test-model\""), "{body}");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(body).unwrap()["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_field_is_invalid_arguments() {
|
||||
let mut rig = Rig::new("consult-unknown");
|
||||
rig.cfg.consult = Some(consult_cfg());
|
||||
grant_consult(&rig, "secret");
|
||||
let rt = Recording::answering("no");
|
||||
let broker = Arc::new(brokerd::broker::Broker::new(
|
||||
rig.cfg.clone(),
|
||||
rig.ledger(),
|
||||
Box::new(runtime::Shared(Arc::clone(&rt))),
|
||||
rig.lines.sink(),
|
||||
));
|
||||
let frames = call(
|
||||
&broker,
|
||||
consult_req("s1", 3, r#"{"question":"hi","history":["old"]}"#),
|
||||
);
|
||||
assert_eq!(rt.count(), 0);
|
||||
match &frames[0].msg {
|
||||
bxcore::Message::ToolResponse(ToolResponse::Denied { reason, .. }) => {
|
||||
assert_eq!(*reason, DenyReason::InvalidArguments);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! `[consult]` without a readable secret: `brokerd` does not start. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
use brokerd::config::Config;
|
||||
use brokerd::consult::{self, SECRET_POINTER};
|
||||
use tmp::TempDir;
|
||||
|
||||
#[test]
|
||||
fn a_missing_secrets_entry_is_consult_secret_unreadable() {
|
||||
let dir = TempDir::new("consult-secret-missing");
|
||||
let path = dir.write(
|
||||
"brokerd.toml",
|
||||
r#"
|
||||
[consult]
|
||||
url = "http://127.0.0.1:9/v1/chat/completions"
|
||||
model = "test-model"
|
||||
secret = "consult"
|
||||
"#,
|
||||
);
|
||||
let cfg = Config::load(&path).unwrap();
|
||||
let err = consult::load_secret(&cfg, &|_| None).unwrap_err();
|
||||
let text = err.to_string();
|
||||
assert!(text.contains(SECRET_POINTER), "{text}");
|
||||
assert!(text.contains("consult"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_env_secret_names_both_pointers() {
|
||||
let dir = TempDir::new("consult-secret-env");
|
||||
let path = dir.write(
|
||||
"brokerd.toml",
|
||||
r#"
|
||||
[consult]
|
||||
url = "http://127.0.0.1:9/v1/chat/completions"
|
||||
model = "test-model"
|
||||
secret = "consult"
|
||||
|
||||
[secrets.consult]
|
||||
env = "CONSULT_TOKEN_FOR_THIS_TEST_UNSET"
|
||||
"#,
|
||||
);
|
||||
let cfg = Config::load(&path).unwrap();
|
||||
let err = consult::load_secret(&cfg, &|_| None).unwrap_err();
|
||||
let text = err.to_string();
|
||||
assert!(text.contains(SECRET_POINTER), "{text}");
|
||||
assert!(
|
||||
text.contains("docs/runbook.md#secret-unavailable"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_present_env_secret_loads() {
|
||||
let dir = TempDir::new("consult-secret-ok");
|
||||
let path = dir.write(
|
||||
"brokerd.toml",
|
||||
r#"
|
||||
[consult]
|
||||
url = "http://127.0.0.1:9/v1/chat/completions"
|
||||
model = "test-model"
|
||||
secret = "consult"
|
||||
|
||||
[secrets.consult]
|
||||
env = "CONSULT_TOKEN_FOR_THIS_TEST_SET"
|
||||
"#,
|
||||
);
|
||||
let cfg = Config::load(&path).unwrap();
|
||||
let loaded = consult::load_secret(&cfg, &|k| {
|
||||
if k == "CONSULT_TOKEN_FOR_THIS_TEST_SET" {
|
||||
Some(OsString::from("bearer-value"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
.expect("loaded");
|
||||
assert_eq!(loaded.secret.expose(), "bearer-value");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! A `consult` grant: empty constraints, no paths or hosts. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use brokerd::args::{ToolArgs, ToolName, parse};
|
||||
use brokerd::grants::load;
|
||||
use tmp::TempDir;
|
||||
|
||||
fn body(rest: &str) -> String {
|
||||
format!("tool = \"consult\"\nmode = \"auto\"\nmax_taint = \"private\"\n{rest}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consult_parses_and_is_not_a_container_tool() {
|
||||
assert_eq!(ToolName::parse("consult"), Some(ToolName::Consult));
|
||||
assert_eq!(ToolName::Consult.as_str(), "consult");
|
||||
assert!(!ToolName::Consult.is_memory());
|
||||
assert!(
|
||||
!ToolName::ALL.contains(&ToolName::Consult),
|
||||
"ALL is the container tools"
|
||||
);
|
||||
let args = parse(ToolName::Consult, r#"{"question":"hello"}"#).unwrap();
|
||||
assert_eq!(args.tool(), ToolName::Consult);
|
||||
assert_eq!(
|
||||
args,
|
||||
ToolArgs::Consult {
|
||||
question: "hello".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(args.canonical_json(), r#"{"question":"hello"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_are_invalid_arguments() {
|
||||
let err = parse(ToolName::Consult, r#"{"question":"hello","history":[]}"#).unwrap_err();
|
||||
let detail = err.detail();
|
||||
assert!(
|
||||
detail.contains("unknown") || detail.contains("history"),
|
||||
"{detail}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_consult_grant_with_empty_constraints_loads() {
|
||||
let dir = TempDir::new("grants-consult");
|
||||
dir.write("consult.toml", &body(""));
|
||||
let set = load(dir.path()).unwrap();
|
||||
assert_eq!(set.grants()[0].grant.tool, "consult");
|
||||
assert!(set.grants()[0].grant.untrusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consult_does_not_take_paths_or_hosts() {
|
||||
let dir = TempDir::new("grants-consult-paths");
|
||||
dir.write("consult.toml", &body("[constraints]\npaths = [\"/tmp\"]\n"));
|
||||
let problems = load(dir.path()).expect_err("paths");
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|p| p.problem.contains("does not take paths")),
|
||||
"{problems:?}"
|
||||
);
|
||||
let dir = TempDir::new("grants-consult-hosts");
|
||||
dir.write(
|
||||
"consult.toml",
|
||||
&body("[constraints]\nhosts = [\"example.com\"]\n"),
|
||||
);
|
||||
let problems = load(dir.path()).expect_err("hosts");
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|p| p.problem.contains("does not take hosts")),
|
||||
"{problems:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! How long `BrokerPort` waits for `consult`: 180 s, or `[broker] timeout_ms` if that is longer.
|
||||
//! Do not edit.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use loopd::broker_port::{BrokerPort, CONSULT_WAIT};
|
||||
|
||||
fn quiet(timeout: Duration) -> BrokerPort {
|
||||
BrokerPort::with_log("unused.sock".into(), timeout, Box::new(|_| {}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consult_waits_one_hundred_and_eighty_seconds() {
|
||||
assert_eq!(CONSULT_WAIT, Duration::from_secs(180));
|
||||
let port = BrokerPort::new("unused.sock".into(), Duration::from_secs(120));
|
||||
assert_eq!(port.timeout_for("consult"), CONSULT_WAIT);
|
||||
assert_eq!(port.timeout_for("recall"), Duration::from_secs(120));
|
||||
let port = BrokerPort::new("unused.sock".into(), Duration::from_secs(300));
|
||||
assert_eq!(port.timeout_for("consult"), Duration::from_secs(300));
|
||||
let port = quiet(Duration::from_secs(120));
|
||||
assert_eq!(port.timeout_for("consult"), CONSULT_WAIT, "with_log as new");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! `consult` in the M5c registry, not core. Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use loopd::baseline::Baseline;
|
||||
use loopd::consult::{self, CONSULT};
|
||||
use loopd::tools::Registry;
|
||||
use std::path::Path;
|
||||
use support::Home;
|
||||
|
||||
fn core_names(registry: &Registry) -> Vec<String> {
|
||||
registry
|
||||
.core_schemas()
|
||||
.into_iter()
|
||||
.map(|s| s.name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consult_is_in_m5c_and_is_not_core() {
|
||||
let r = Registry::m5c();
|
||||
let entry = r.get(CONSULT).expect("consult");
|
||||
assert!(!entry.core);
|
||||
assert_eq!(entry.schema, consult::schema());
|
||||
assert_eq!(
|
||||
core_names(&r),
|
||||
[
|
||||
"clock",
|
||||
"recall",
|
||||
"memory",
|
||||
"ask_memory",
|
||||
"find_tool",
|
||||
"call_tool"
|
||||
]
|
||||
);
|
||||
assert!(r.find("consult").iter().any(|s| s.name == CONSULT));
|
||||
assert!(r.find("remote").iter().any(|s| s.name == CONSULT));
|
||||
assert!(r.get("subagent").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baseline_from_m5c_does_not_put_consult_in_the_tools_array() {
|
||||
let home = Home::new();
|
||||
let cfg = home.config(Path::new("/tmp/unused.sock"));
|
||||
let baseline = Baseline::assemble(&cfg, &Registry::m5c()).unwrap();
|
||||
let names: Vec<&str> = baseline.tools.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"clock",
|
||||
"recall",
|
||||
"memory",
|
||||
"ask_memory",
|
||||
"find_tool",
|
||||
"call_tool"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
## consult-not-configured
|
||||
|
||||
**What you see.** A `consult` call is denied as invalid-arguments. The tool result names this
|
||||
entry:
|
||||
|
||||
```text
|
||||
Denied: the arguments are not valid for this tool: consult is not configured; see docs/runbook.md#consult-not-configured.
|
||||
```
|
||||
|
||||
**Why.** `brokerd.toml` has no `[consult]` table. Every `consult` call is denied until the owner
|
||||
writes one. The audit line still holds the JSON body that would have been sent.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
grep -n '\[consult\]' "$BOXMAKER_HOME/../brokerd.toml" /etc/boxmaker/brokerd.toml 2>/dev/null
|
||||
```
|
||||
|
||||
**Fix.** Add a `[consult]` table with `url`, `model` and `secret`, a matching `[secrets.<name>]`
|
||||
entry, and a grant whose `tool` is `consult`. Restart `brokerd`. M6's URL is loopback only.
|
||||
|
||||
**Check.** A `consult` from a session whose taint is at or below the grant's `max_taint` returns
|
||||
an answer, and the audit line's `body` matches the bytes posted.
|
||||
|
||||
## consult-secret-unreadable
|
||||
|
||||
**What you see.** `brokerd` does not start. The line names the secret and this entry, and may
|
||||
also name `secret-unavailable` or `secret-in-a-file`.
|
||||
|
||||
**Why.** `[consult]` names a secret that is missing from `[secrets]`, whose spec is not exactly
|
||||
one of credential/env/file, or that the secret store cannot read (missing, too open, or not
|
||||
UTF-8).
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
# the name in [consult] secret = "…"
|
||||
ls -l <the file or credential path>
|
||||
```
|
||||
|
||||
**Fix.** Put the bearer in the store the spec names, mode `0600` for a file, owned by the
|
||||
user `brokerd` runs as. Restart `brokerd`.
|
||||
|
||||
**Check.** `brokerd serve --config <path>` prints the serving lines and no secret error.
|
||||
Reference in New Issue
Block a user