diff --git a/crates/brokerd/Cargo.toml b/crates/brokerd/Cargo.toml index 38634bf..f6a442d 100644 --- a/crates/brokerd/Cargo.toml +++ b/crates/brokerd/Cargo.toml @@ -9,7 +9,7 @@ publish.workspace = true workspace = true [dependencies] -bxcore.workspace = true +bxcore = { workspace = true, features = ["secrets"] } serde.workspace = true serde_json.workspace = true toml.workspace = true diff --git a/crates/brokerd/src/config.rs b/crates/brokerd/src/config.rs index dfecd7b..98a3ade 100644 --- a/crates/brokerd/src/config.rs +++ b/crates/brokerd/src/config.rs @@ -5,8 +5,11 @@ //! owner believes is set and is not. use serde::Deserialize; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use bxcore::secrets::SecretSpec; + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct Paths { @@ -155,6 +158,10 @@ pub struct Config { pub gateway: Option, #[serde(default)] pub memory: Memory, + #[serde(default)] + pub consult: Option, + #[serde(default)] + pub secrets: BTreeMap, } /// 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) } pub fn broker_socket(&self) -> PathBuf { diff --git a/crates/brokerd/src/consult.rs b/crates/brokerd/src/consult.rs new file mode 100644 index 0000000..fe1c8de --- /dev/null +++ b/crates/brokerd/src/consult.rs @@ -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 { + #[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::().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, +) -> Result, 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)) +} diff --git a/crates/brokerd/src/lib.rs b/crates/brokerd/src/lib.rs index 76ef5ba..c52e62d 100644 --- a/crates/brokerd/src/lib.rs +++ b/crates/brokerd/src/lib.rs @@ -7,6 +7,7 @@ pub mod audit; pub mod broker; pub mod builtin; pub mod config; +pub mod consult; pub mod container; pub mod courier; pub mod grants; diff --git a/crates/brokerd/tests/config_consult.rs b/crates/brokerd/tests/config_consult.rs new file mode 100644 index 0000000..6724ea1 --- /dev/null +++ b/crates/brokerd/tests/config_consult.rs @@ -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 { + 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")); +} diff --git a/crates/brokerd/tests/consult_secret.rs b/crates/brokerd/tests/consult_secret.rs new file mode 100644 index 0000000..4248589 --- /dev/null +++ b/crates/brokerd/tests/consult_secret.rs @@ -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"); +} diff --git a/crates/brokerd/tests/support/rig.rs b/crates/brokerd/tests/support/rig.rs index 6068c58..6823c2c 100644 --- a/crates/brokerd/tests/support/rig.rs +++ b/crates/brokerd/tests/support/rig.rs @@ -43,6 +43,8 @@ impl Rig { runner: None, gateway: None, memory: Default::default(), + consult: None, + secrets: Default::default(), }; Rig { dir, diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 72df0a7..d9deb89 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | 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/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 | diff --git a/docs/runbook.md b/docs/runbook.md index cb2c0c5..2771639 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -1269,3 +1269,47 @@ scope. **Check.** A `subagent` call from a short parent (`chat-*`, `mm-*`, `heartbeat`) completes and writes `sessions/sub--/`. +## 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.]` +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 +``` + +**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 ` prints the serving lines and no secret error.