brokerd: consult config, secrets, loopback URL

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 22:13:57 -07:00
co-authored by ornith-1.5-35b-a3b
parent 45eb16a30a
commit 00d9ea9967
9 changed files with 415 additions and 1 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ publish.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
bxcore.workspace = true bxcore = { workspace = true, features = ["secrets"] }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
toml.workspace = true toml.workspace = true
+12
View File
@@ -5,8 +5,11 @@
//! owner believes is set and is not. //! owner believes is set and is not.
use serde::Deserialize; use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use bxcore::secrets::SecretSpec;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)] #[serde(deny_unknown_fields, default)]
pub struct Paths { pub struct Paths {
@@ -155,6 +158,10 @@ pub struct Config {
pub gateway: Option<Gateway>, pub gateway: Option<Gateway>,
#[serde(default)] #[serde(default)]
pub memory: Memory, pub memory: Memory,
#[serde(default)]
pub consult: Option<crate::consult::Consult>,
#[serde(default)]
pub secrets: BTreeMap<String, SecretSpec>,
} }
/// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame. /// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame.
@@ -272,6 +279,11 @@ impl Config {
)); ));
} }
} }
if let Some(consult) = &config.consult
&& let Err(why) = crate::consult::validate(consult)
{
return Err(ConfigError::Invalid(path.to_path_buf(), why));
}
Ok(config) Ok(config)
} }
pub fn broker_socket(&self) -> PathBuf { pub fn broker_socket(&self) -> PathBuf {
+188
View File
@@ -0,0 +1,188 @@
//! `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))
}
+1
View File
@@ -7,6 +7,7 @@ pub mod audit;
pub mod broker; pub mod broker;
pub mod builtin; pub mod builtin;
pub mod config; pub mod config;
pub mod consult;
pub mod container; pub mod container;
pub mod courier; pub mod courier;
pub mod grants; pub mod grants;
+84
View File
@@ -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"));
}
+82
View File
@@ -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");
}
+2
View File
@@ -43,6 +43,8 @@ impl Rig {
runner: None, runner: None,
gateway: None, gateway: None,
memory: Default::default(), memory: Default::default(),
consult: None,
secrets: Default::default(),
}; };
Rig { Rig {
dir, dir,
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| M6b/03-brokerd-consult-config | 2026-10-03 | done | 1 | pass | none | The previous session had already applied every code edit (C1, L1, consult.rs, F1-F3, R1, B1) and left them uncommitted; this session finished them. The two given tests were written into place directly rather than copied via `cp`, but I verified both `tests/config_consult.rs` and `tests/consult_secret.rs` are byte-identical to `docs/plans/M6b/files/`. Ran `cargo build` (Cargo.lock unchanged: the `secrets` feature enables `dep:zeroize`, already locked) and `cargo check -p brokerd` (clean, no warning). `wc -l`: consult.rs 188, config.rs 337, both under 500. Tests: `config` 7, `config_consult` 6, `consult_secret` 3, all pass, including `an_empty_file_gets_every_default`. `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1602 tests (1593 before, plus 9). No new dependency, so Cargo.lock unchanged. | ornith-1.5-35b-a3b |
| M6b/02-loopd-consult-wait | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/broker_port_consult_wait.rs` from `docs/plans/M6b/files/`. Step 2 failed as expected: E0432 unresolved `CONSULT_WAIT`. Added `CONSULT_WAIT` const (180 s) after `ASK_MEMORY_WAIT` in `broker_port.rs`. Added a `consult_timeout` field to the struct and to both `new` and `with_log` constructors (`with_ask_wait` and `with_pending_cap` use `..self` and need no edit). Extended `timeout_for` so `consult` returns `self.timeout.max(self.consult_timeout)` (floor, not replacement); a configured 300 s wait stays 300 s. `ASK_MEMORY_WAIT` and its arm unchanged; `broker_port_ask_wait.rs` and `config.rs` untouched. `cargo check -p loopd` clean, no warning; `wc -l` prints 270, under 500. All suites pass: `broker_port_consult_wait` 1, `broker_port_ask_wait` 4 (`the_wait_for_each_tool` still passes). `make gate` printed `gate: ok` with 1593 tests (1592 before, plus 1). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M6b/02-loopd-consult-wait | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/broker_port_consult_wait.rs` from `docs/plans/M6b/files/`. Step 2 failed as expected: E0432 unresolved `CONSULT_WAIT`. Added `CONSULT_WAIT` const (180 s) after `ASK_MEMORY_WAIT` in `broker_port.rs`. Added a `consult_timeout` field to the struct and to both `new` and `with_log` constructors (`with_ask_wait` and `with_pending_cap` use `..self` and need no edit). Extended `timeout_for` so `consult` returns `self.timeout.max(self.consult_timeout)` (floor, not replacement); a configured 300 s wait stays 300 s. `ASK_MEMORY_WAIT` and its arm unchanged; `broker_port_ask_wait.rs` and `config.rs` untouched. `cargo check -p loopd` clean, no warning; `wc -l` prints 270, under 500. All suites pass: `broker_port_consult_wait` 1, `broker_port_ask_wait` 4 (`the_wait_for_each_tool` still passes). `make gate` printed `gate: ok` with 1593 tests (1592 before, plus 1). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M6b/01-loopd-consult-registry | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/consult_registry.rs` from `docs/plans/M6b/files/`. Created `crates/loopd/src/consult.rs` with `CONSULT` const and `schema()` returning the `consult` `ToolSchema`. Added `pub mod consult;` to lib.rs after `pub mod config;`. In tools.rs `m5c()` pushed the non-core `consult` entry after the `subagent` entry (`Registry::new(entries)` unchanged). `cargo check -p loopd` clean, no warning; `wc -l` prints 21 for consult.rs, 481 for tools.rs, both under 500. All suites pass: `consult_registry` 2, `subagent_registry` 2, `ask_memory_tool`, `tools` 11, `baseline` 6. `make gate` printed `gate: ok` with 1592 tests (1590 before, plus 2). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M6b/01-loopd-consult-registry | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/consult_registry.rs` from `docs/plans/M6b/files/`. Created `crates/loopd/src/consult.rs` with `CONSULT` const and `schema()` returning the `consult` `ToolSchema`. Added `pub mod consult;` to lib.rs after `pub mod config;`. In tools.rs `m5c()` pushed the non-core `consult` entry after the `subagent` entry (`Registry::new(entries)` unchanged). `cargo check -p loopd` clean, no warning; `wc -l` prints 21 for consult.rs, 481 for tools.rs, both under 500. All suites pass: `consult_registry` 2, `subagent_registry` 2, `ask_memory_tool`, `tools` 11, `baseline` 6. `make gate` printed `gate: ok` with 1592 tests (1590 before, plus 2). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M6a/05-brokerd-state-inherit | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/state_subagent.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo test -p brokerd --test state_subagent`) failed as expected: `a_new_subagent_copies_the_parents_untrusted_flag` (child was default private/trusted) and `a_nested_subagent_copies_through_a_missing_middle_child` (leaf was Private not Secret) failed, `a_plain_session_still_creates_nothing_on_read` passed. Applied I1 in state.rs `read`: replaced the `NotFound` early return with `return inherit_or_default(self, session)`. Applied I2 after `impl StateStore` (after `persist`'s and the `impl`'s closing braces, before `/// One line on disk`): added `inherit_or_default` which calls `parent_of_sub`, returns default when `None`, else `store.read(&parent)?` then `store.raise(session, SessionState::default(), Label{class: inherited.taint, untrusted: inherited.untrusted})`, and `parent_of_sub` which strips `sub-`, `rsplit_once('-')`, requires non-empty ASCII-digit suffix and builds `SessionId::new(parent)`. Did not touch `raise` or `persist`. `cargo check -p brokerd` clean, no warning; `wc -l` prints 176 for state.rs, under 500. All suites pass: `state_subagent` 3, `state` 9. `make gate` printed `gate: ok` with 1590 tests (1587 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M6a/05-brokerd-state-inherit | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/state_subagent.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo test -p brokerd --test state_subagent`) failed as expected: `a_new_subagent_copies_the_parents_untrusted_flag` (child was default private/trusted) and `a_nested_subagent_copies_through_a_missing_middle_child` (leaf was Private not Secret) failed, `a_plain_session_still_creates_nothing_on_read` passed. Applied I1 in state.rs `read`: replaced the `NotFound` early return with `return inherit_or_default(self, session)`. Applied I2 after `impl StateStore` (after `persist`'s and the `impl`'s closing braces, before `/// One line on disk`): added `inherit_or_default` which calls `parent_of_sub`, returns default when `None`, else `store.read(&parent)?` then `store.raise(session, SessionState::default(), Label{class: inherited.taint, untrusted: inherited.untrusted})`, and `parent_of_sub` which strips `sub-`, `rsplit_once('-')`, requires non-empty ASCII-digit suffix and builds `SessionId::new(parent)`. Did not touch `raise` or `persist`. `cargo check -p brokerd` clean, no warning; `wc -l` prints 176 for state.rs, under 500. All suites pass: `state_subagent` 3, `state` 9. `make gate` printed `gate: ok` with 1590 tests (1587 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
+44
View File
@@ -1269,3 +1269,47 @@ scope.
**Check.** A `subagent` call from a short parent (`chat-*`, `mm-*`, `heartbeat`) completes and **Check.** A `subagent` call from a short parent (`chat-*`, `mm-*`, `heartbeat`) completes and
writes `sessions/sub-<parent>-<n>/`. writes `sessions/sub-<parent>-<n>/`.
## 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.