Add brokerd's configuration

Implemented crates/brokerd/src/config.rs: typed Paths, Sockets, Approvals and
Config with serde(deny_unknown_fields, default) on every struct, hand-written
ConfigError (Read/Parse) with Display and std::error::Error, and the parse/load/
broker_socket/admin_socket/audit_dir/state_dir methods. Added serde, serde_json
and toml to crates/brokerd/Cargo.toml, registered pub mod config; in lib.rs,
added brokerd to the serde and serde_json Used-by cells in docs/dependencies.md,
and copied the given test and six fixtures byte-identical. 7 config tests pass;
make gate prints gate: ok.

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-19 02:24:15 -07:00
parent d01b2ef2d9
commit 5502de1c90
13 changed files with 257 additions and 3 deletions
Generated
+3
View File
@@ -7,6 +7,9 @@ name = "brokerd"
version = "0.1.0"
dependencies = [
"proto",
"serde",
"serde_json",
"toml",
]
[[package]]
+3
View File
@@ -10,3 +10,6 @@ workspace = true
[dependencies]
proto.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
+107
View File
@@ -0,0 +1,107 @@
//! `brokerd` configuration: read `brokerd.toml` into a typed `Config`.
//!
//! This is our own format, so unknown keys are errors in every table: a
//! misspelt key that silently fell back to its default would be a setting the
//! owner believes is set and is not.
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Paths {
pub home: PathBuf,
pub grants: PathBuf,
}
impl Default for Paths {
fn default() -> Self {
Self {
home: std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")),
grants: PathBuf::from("/etc/boxmaker/grants"),
}
}
}
/// An empty path means "the default under `home`".
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
pub struct Sockets {
pub broker: PathBuf,
pub admin: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Approvals {
pub ttl_ms: u64,
}
impl Default for Approvals {
fn default() -> Self {
Self { ttl_ms: 900_000 }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub paths: Paths,
#[serde(default)]
pub sockets: Sockets,
#[serde(default)]
pub approvals: Approvals,
}
#[derive(Debug)]
pub enum ConfigError {
Read(PathBuf, std::io::Error),
Parse(PathBuf, toml::de::Error),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()),
ConfigError::Parse(path, err) => write!(f, "{}: {err}", path.display()),
}
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub fn parse(text: &str) -> Result<Config, toml::de::Error> {
toml::from_str(text)
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
let text =
std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?;
let config: Config =
toml::from_str(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?;
Ok(config)
}
pub fn broker_socket(&self) -> PathBuf {
if self.sockets.broker.as_os_str().is_empty() {
self.paths.home.join("run/loop-broker/broker.sock")
} else {
self.sockets.broker.clone()
}
}
pub fn admin_socket(&self) -> PathBuf {
if self.sockets.admin.as_os_str().is_empty() {
self.paths.home.join("run/owner-broker/admin.sock")
} else {
self.sockets.admin.clone()
}
}
pub fn audit_dir(&self) -> PathBuf {
self.paths.home.join("audit")
}
pub fn state_dir(&self) -> PathBuf {
self.paths.home.join("broker/sessions")
}
}
+1
View File
@@ -1,4 +1,5 @@
//! The broker: the only role that holds authority.
pub mod config;
pub mod policy;
pub mod runner;
+118
View File
@@ -0,0 +1,118 @@
//! Tests for `brokerd`'s configuration. Do not edit: these define the required behaviour.
use brokerd::config::{Approvals, Config, ConfigError, Sockets};
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/config")
.join(name)
}
/// What `home` must default to in this process. The test does not set the variable: changing the
/// environment of a running test binary would race with the other tests.
fn default_home() -> PathBuf {
std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"))
}
#[test]
fn an_empty_file_gets_every_default() {
let c = Config::load(&fixture("empty.toml")).unwrap();
assert_eq!(c.paths.home, default_home());
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
assert_eq!(c.sockets, Sockets::default());
assert_eq!(c.approvals, Approvals { ttl_ms: 900_000 });
assert_eq!(Approvals::default(), Approvals { ttl_ms: 900_000 });
assert_eq!(c, Config::parse("").unwrap());
assert_eq!(c, Config::default());
}
#[test]
fn sockets_and_directories_default_to_places_under_home() {
let c = Config::load(&fixture("home_only.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/srv/boxmaker/run/loop-broker/broker.sock")
);
assert_eq!(
c.admin_socket(),
PathBuf::from("/srv/boxmaker/run/owner-broker/admin.sock")
);
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
// The grants are not under home: the owner writes them, brokerd only reads them.
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
}
#[test]
fn every_key_can_be_set() {
let c = Config::load(&fixture("full.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(c.paths.grants, PathBuf::from("/srv/boxmaker-grants"));
assert_eq!(c.broker_socket(), PathBuf::from("/run/bx/broker.sock"));
assert_eq!(c.admin_socket(), PathBuf::from("/run/bx/admin.sock"));
assert_eq!(c.approvals.ttl_ms, 60_000);
// The two directories always follow home.
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
}
#[test]
fn one_socket_set_leaves_the_other_at_its_default() {
let c =
Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nadmin = \"/x/admin.sock\"\n").unwrap();
assert_eq!(c.admin_socket(), PathBuf::from("/x/admin.sock"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/h/run/loop-broker/broker.sock")
);
}
#[test]
fn unknown_keys_and_tables_are_errors() {
for name in ["unknown_key.toml", "unknown_table.toml", "wrong_type.toml"] {
match Config::load(&fixture(name)) {
Err(ConfigError::Parse(path, _)) => assert_eq!(path, fixture(name)),
other => panic!("{name}: expected a parse error, got {other:?}"),
}
}
// In every table, not only the one the fixture shows.
for text in [
"[paths]\nhome = \"/h\"\nhouse = \"/h\"\n",
"[sockets]\nbroker = \"/b.sock\"\nloop = \"/l.sock\"\n",
"[approvals]\nttl_ms = 1\nttl_s = 1\n",
"top = 1\n",
"[approvals]\nttl_ms = -5\n",
] {
assert!(Config::parse(text).is_err(), "accepted: {text}");
}
}
#[test]
fn a_missing_file_is_a_read_error_that_names_the_file() {
let path = fixture("does-not-exist.toml");
match Config::load(&path) {
Err(ConfigError::Read(p, _)) => assert_eq!(p, path),
other => panic!("expected a read error, got {other:?}"),
}
let text = Config::load(&path).unwrap_err().to_string();
assert!(text.contains("does-not-exist.toml"), "{text}");
}
#[test]
fn a_parse_error_names_the_file_and_the_key() {
let text = Config::load(&fixture("unknown_key.toml"))
.unwrap_err()
.to_string();
assert!(text.contains("unknown_key.toml"), "{text}");
assert!(text.contains("ttl"), "{text}");
}
+1
View File
@@ -0,0 +1 @@
# Nothing set: every value is a default.
+11
View File
@@ -0,0 +1,11 @@
# Every key set.
[paths]
home = "/srv/boxmaker"
grants = "/srv/boxmaker-grants"
[sockets]
broker = "/run/bx/broker.sock"
admin = "/run/bx/admin.sock"
[approvals]
ttl_ms = 60000
+2
View File
@@ -0,0 +1,2 @@
[paths]
home = "/srv/boxmaker"
+3
View File
@@ -0,0 +1,3 @@
[approvals]
ttl_ms = 60000
ttl = 5
@@ -0,0 +1,2 @@
[secrets]
store = "/etc/boxmaker/secrets"
+2
View File
@@ -0,0 +1,2 @@
[approvals]
ttl_ms = "15 min"
+2 -2
View File
@@ -4,8 +4,8 @@ Every external crate has a row here. `scripts/check-dep-docs.sh` enforces it.
| Crate | Version | Used by | Why |
|---|---|---|---|
| `serde` | 1.0.229 | `proto` | Derives serialization for every shared type. MIT OR Apache-2.0. |
| `serde_json` | 1.0.151 | `proto` | JSON for frames and log files. MIT OR Apache-2.0. |
| `serde` | 1.0.229 | `proto`, `brokerd` | Derives serialization for every shared type. MIT OR Apache-2.0. |
| `serde_json` | 1.0.151 | `proto`, `brokerd` | JSON for frames and log files. MIT OR Apache-2.0. |
| `humantime` | 2.4.0 | `proto` | RFC 3339 timestamps. No dependencies of its own. MIT OR Apache-2.0. |
| `toml` | 1.1.6 | `proto` tests; `brokerd` and `bxctl` from M3 | Parses grant files. MIT OR Apache-2.0. |
| `emsha` | 1.0.4 | `proto` | SHA-256. Written by the owner; no dependencies, no `unsafe`. Checked against `sha256sum` on 3,204 inputs. |
+2 -1
View File
@@ -44,7 +44,8 @@ reviewer adds findings under "Reviews" once per milestone.
| M3a/01-proto-audit-types | 2026-09-19 | stopped | 1 | fail | none | The audit types were implemented exactly as the task specifies in audit.rs and lib.rs and the two tests copied; `records` passes (3 passed) and the audit portion of `strict` passes. `make gate` cannot pass: the task's `strict.rs` walks 28 wire fixtures but 16 (approvals/approval_list/approve/refuse/ok/grants_report/turn_event_* and friends) do not exist on the m3a branch and are created by task 02 ("leave wire.rs alone: task 02 changes it"). The `envelopes_reject_unknown_keys_at_every_depth` test fails on the missing `approvals.json`, so the gate fails. The branch was healthy at start (master's `strict` = 5 passed); the block is the task's new `strict.rs` requiring later fixtures. Reverted audit.rs/lib.rs/tests for a clean tree and committed only this row. A later session that has the wire fixtures (or a `strict.rs` scoped to task 01) can finish it. Copied the two given tests (loopd/baseline.rs, bxctl/chat.rs). In channel.rs the busy guard is now dropped before every final frame (the three open/create/assemble session errors, plus the existing turn_done/error path) and Held::drop recovers a poisoned lock with unwrap_or_else(|p| p.into_inner()). baseline::assemble treats a core.md that exists but cannot be read as BaselineError::Read, a missing one still fine (matched on ErrorKind::NotFound). bxctl's interactive loop reports a failed turn and continues instead of exiting 1, and new_session_id has no expect. The prescribed new_session_id fix (unwrap_or_else with a fixed valid id via unwrap_or_default) does not compile without SessionId: Default, and there is no non-panicking way to build a SessionId outside proto, so the fallback default is the valid id "chat-0-0". Staged proto in addition to the listed paths because the build requires it. Gate: ok, 219 tests. | Ornith |
| M3a/02-proto-admin-wire | 2026-09-22 | done | 1 | pass | none | Added four DenyReason (GrantsInvalid, AuditUnavailable, InvalidArguments, StateUnreadable), two ErrorCode (Forbidden, NoSuchApproval), approval ids as u64 in ToolResponse::PendingApproval and TurnEvent::ApprovalPending, TurnEvent::ApprovalPending and ToolDenied, and the eight admin types (Empty {}, PendingApproval, ApprovalList, Approve, ApproveResult, Refuse, GrantProblem, GrantsReport) with deny_unknown_fields; re-exported from lib.rs; added the two required match arms in bxctl chat.rs. Copied four test files and 17 wire fixtures byte-identical. wire 10, turn_wire 5, admin_wire 10, strict 5 passed; `make gate` prints `gate: ok`. | OpenCode |
| M3a/01-proto-audit-types | 2026-09-22 | done | 1 | pass | none | Finished the blocked task. `audit.rs` now holds the chained shapes: `DecisionRecord` (`Allowed {}`, `Ask {}`, `Denied { reason }`), `ApprovalAnswer`, `ResultStatus`, `AuditEvent` (Decision/Approval/Result/Recovery/AcceptedBreak), and `AuditRecord { seq, time, prev, event }`; `lib.rs` re-exports the five names. All `Option`s emit as `null` (no `skip_serializing_if`); `deny_unknown_fields` on all three object enums/struct. Tests copied from `docs/plans/M3a/files/`: `records` 3 passed, `strict` 5 passed. Proved the brace rule has teeth: with `Allowed`/`Ask` as unit variants, `audit_records_reject_unknown_keys_at_every_depth` accepted `{"outcome":"allowed","zz_unknown":true}` and failed; braces restored, it passes again. NOTE: `docs/plans/M3a/files/crates/proto/tests/strict.rs` was already locally modified in the working tree (the committed version walks 16 wire fixtures that do not exist on m3a and are created by task 02) — I copied it as-is from the path, which is why `strict` is 5 passed; I did not touch any other protected file. `git status` was not empty at start because of that pre-existing modification, which I left uncommitted and unstaged. | OpenCode |
| M3a/03-proto-chain-verifier | 2026-09-19 | done | 1 | pass | implementation matches the reference tree's chain.rs verbatim | Wrote crates/proto/src/chain.rs: `ChainVerifier`, a pure line-holding state machine (each line is judged only once the next one has arrived, so a `Recovery` record can mark the line before it not-a-record), plus `ChainFailure`, `TornTail`, `ChainReport`, `Location`. Holds each line, checks recovery against the next, then rule 1 (parse, expected seq, prev with the file-before text for line 1 of a resumed/continued verifier), the failed-region counting of rule 5, the resumed-earlier-file break exception of rule 6, run/ask tracking for `abandoned`/`unfinished`, and clock warnings; `finish` reports the torn tail and the break's required seq/prev. Added `pub mod chain` and the five re-exports to lib.rs and the same line to audit.rs. The single worker subagent for this task entered an unrecoverable reasoning loop on the state machine and was not completing, so the orchestrator implemented it directly from the spec and fixtures. 13 chain tests pass; `make gate` prints `gate: ok`. | OpenCode |
| M3a/03-proto-chain-verifier | 2026-09-19 | done | 1 | pass | implementation matches the reference tree's chain.rs verbatim |
| M3a/04-brokerd-config | 2026-09-18 | done | 1 | pass | none | Wrote crates/brokerd/src/config.rs: Paths (Default: home is $BOXMAKER_HOME via var_os else /var/lib/boxmaker, grants /etc/boxmaker/grants), Sockets (derived Default), Approvals (Default ttl_ms 900_000) and Config (derived Default), all with serde(deny_unknown_fields, default) and Config at top level; hand-written ConfigError Read/Parse with Display and std::error::Error; parse/load/broker_socket/admin_socket/audit_dir/state_dir. Added serde, serde_json, toml to Cargo.toml, `pub mod config;` to lib.rs, and `brokerd` to the serde and serde_json "Used by" cells in dependencies.md. 7 config tests pass; `make gate` prints `gate: ok`. | OpenCode | Wrote crates/proto/src/chain.rs: `ChainVerifier`, a pure line-holding state machine (each line is judged only once the next one has arrived, so a `Recovery` record can mark the line before it not-a-record), plus `ChainFailure`, `TornTail`, `ChainReport`, `Location`. Holds each line, checks recovery against the next, then rule 1 (parse, expected seq, prev with the file-before text for line 1 of a resumed/continued verifier), the failed-region counting of rule 5, the resumed-earlier-file break exception of rule 6, run/ask tracking for `abandoned`/`unfinished`, and clock warnings; `finish` reports the torn tail and the break's required seq/prev. Added `pub mod chain` and the five re-exports to lib.rs and the same line to audit.rs. The single worker subagent for this task entered an unrecoverable reasoning loop on the state machine and was not completing, so the orchestrator implemented it directly from the spec and fixtures. 13 chain tests pass; `make gate` prints `gate: ok`. | OpenCode |
## Reviews