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:
2026-10-03 21:39:31 -07:00
co-authored by Grok 4.6
parent 09580c705a
commit 50e9cc2037
15 changed files with 2887 additions and 0 deletions
+179
View File
@@ -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`.
+205
View File
@@ -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`.
+426
View File
@@ -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.
+399
View File
@@ -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`).
+425
View File
@@ -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
`&lt;` 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 `&lt;` and not the think tag; class private, untrusted true. A
second test with a different question still posts only that question.
+126
View File
@@ -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 `&lt;`
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("&lt;"), "{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"
]
);
}
+44
View File
@@ -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.