M5c-1 plan: task 06, memoryd checks [honcho] and its keys at start

memoryd.toml gains an optional [honcho] section, [[honcho.read]] and the
Honcho keys in [secrets.<name>], checked at start into
memoryd::honcho::config::HonchoConfig (13 reasons, runbook entry
honcho-config-invalid given as a fragment), and [sockets] state for
brokerd's state.sock. Keys are checked, never read; no [honcho] leaves
memoryd as it was. memoryd enables bxcore's feature secrets.

Checks: a reference at ref 05 + this task passes make gate with 1188
tests (1171 + 17); /tmp unchanged around the gate; the given tests fail
before as the task says (two targets do not compile, 4 start tests
fail); 49 one-line mutations of the reference, all caught (one gap, a
URL with a user and no password, found and closed); the new tests 40
times under CPU load, no failure; a replay from the task text on a fresh
ref-05 worktree with its own target is byte-identical and passes the
gate, and every before block was found once; given files pass fmt and
the 500-line limit; no chat-template tag in any file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 02:48:16 -07:00
co-authored by Claude Opus 5.5
parent ba8267c7dc
commit 0e1e070fc3
6 changed files with 1686 additions and 0 deletions
@@ -0,0 +1,220 @@
//! `[honcho]` in `memoryd.toml` and its keys in `[secrets.<name>]`, checked at start into a
//! `HonchoConfig` (M5c spec, sections 3, 6 and 10). Nothing here reaches Honcho or reads a key: a
//! key's source is checked here and read only when a client is made.
use std::collections::BTreeMap;
use bxcore::DataClass;
use bxcore::secrets::{SecretSource, SecretSpec};
use serde::Deserialize;
/// The runbook entry for every reason `check` gives.
pub const RUNBOOK: &str = "see docs/runbook.md#honcho-config-invalid";
/// The longest name Honcho accepts for a workspace, a peer or a session (M5c spec, section 6).
pub const MAX_NAME: usize = 512;
/// The names of the keys in `[secrets.<name>]`, one per workspace. A read workspace's key is
/// `READ_KEY` and its agent's name (`read_key`).
pub const TRUSTED_KEY: &str = "honcho_trusted";
pub const UNTRUSTED_KEY: &str = "honcho_untrusted";
pub const FLEET_KEY: &str = "honcho_fleet";
pub const READ_KEY: &str = "honcho_read_";
/// `[honcho]` as written. Our own format: unknown keys are errors.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HonchoSection {
pub url: String,
pub agent: String,
pub owner: String,
pub trusted: String,
pub untrusted: String,
pub fleet: String,
#[serde(default)]
pub read: Vec<ReadSection>,
}
/// One `[[honcho.read]]` as written. `trust` and `class` stay text until `check`, so that a wrong
/// value is one of its reasons.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReadSection {
pub agent: String,
pub workspace: String,
#[serde(default)]
pub trust: Option<String>,
#[serde(default)]
pub class: Option<String>,
}
/// `[honcho]` and its keys, checked: every name is a Honcho name, and the names fit together.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HonchoConfig {
/// `http://` or `https://`, a host and an optional port.
pub url: String,
/// This agent's peer.
pub agent: String,
/// The owner's peer, the same in every workspace of the fleet.
pub owner: String,
/// This agent's three workspaces.
pub trusted: String,
pub untrusted: String,
pub fleet: String,
/// Other agents' workspaces this agent reads, in the order written.
pub read: Vec<ReadWorkspace>,
/// Every `[secrets.<name>]`, by its name: a source that is checked, never read here.
pub keys: BTreeMap<String, SecretSource>,
}
/// One `[[honcho.read]]`, checked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadWorkspace {
pub agent: String,
pub workspace: String,
pub trust: Trust,
pub class: DataClass,
}
/// How the owner labelled another agent's workspace. `Untrusted` unless written out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trust {
Trusted,
Untrusted,
}
/// The name of the key for the workspace of the read agent `agent`.
pub fn read_key(agent: &str) -> String {
format!("{READ_KEY}{agent}")
}
/// True when `name` is a Honcho name: 1 to `MAX_NAME` characters, each an ASCII letter, a digit,
/// `_` or `-`.
pub fn is_name(name: &str) -> bool {
// Not empty, `name.len() <= MAX_NAME`, and every byte of `name.bytes()` is
// `is_ascii_alphanumeric()`, `b'_'` or `b'-'`. Bytes, not chars: a non-ASCII letter is refused.
todo!()
}
/// Check `[honcho]` and the keys in `[secrets]`. No section and no key is `Ok(None)`: Honcho is
/// off. `Err` is the first reason found, one line that names the table and never a key's value.
pub fn check(
section: Option<&HonchoSection>,
secrets: &BTreeMap<String, SecretSpec>,
) -> Result<Option<HonchoConfig>, String> {
let Some(section) = section else {
if let Some(name) = secrets.keys().next() {
return Err(format!(
"[secrets.{name}] is set, but there is no [honcho] section"
));
}
return Ok(None);
};
check_url(&section.url)?;
for (key, name) in [
("agent", &section.agent),
("owner", &section.owner),
("trusted", &section.trusted),
("untrusted", &section.untrusted),
("fleet", &section.fleet),
] {
check_name("[honcho]", key, name)?;
}
if section.agent == section.owner {
return Err(format!(
"[honcho] agent and owner are both {:?}: the agent's peer must not be the owner's",
section.agent
));
}
check_own_workspaces(section)?;
let mut read: Vec<ReadWorkspace> = Vec::new();
for entry in &section.read {
let checked = read_workspace(section, entry, &read)?;
read.push(checked);
}
let keys = check_keys(secrets, &read)?;
Ok(Some(HonchoConfig {
url: section.url.clone(),
agent: section.agent.clone(),
owner: section.owner.clone(),
trusted: section.trusted.clone(),
untrusted: section.untrusted.clone(),
fleet: section.fleet.clone(),
read,
keys,
}))
}
/// `Err` with the reason when `name`, the value of `key` in `table`, is not a Honcho name.
fn check_name(table: &str, key: &str, name: &str) -> Result<(), String> {
if is_name(name) {
return Ok(());
}
Err(format!(
"{table} {key} {name:?} is not a Honcho name: 1 to {MAX_NAME} of a-z, A-Z, 0-9, _ and -"
))
}
/// `http://` or `https://`, then a host, then optionally `:` and a port; nothing else.
fn check_url(url: &str) -> Result<(), String> {
// 1. `url.strip_prefix("https://")`, else `url.strip_prefix("http://")`, else the error below.
// 2. What is left, `split_once(':')`: the host before the first `:` and the port after it.
// 3. The host: not empty, every byte `is_ascii_alphanumeric()`, `b'.'` or `b'-'`.
// 4. A port, if there is a `:`: not empty, every byte `is_ascii_digit()`, and
// `port.parse::<u16>()` is `Ok(n)` with `n > 0`.
// Both good: `Ok(())`. Anything else: `Err(URL_REASON.to_string())`.
todo!()
}
/// The reason for any `url` that `check_url` refuses. It never quotes the URL, which could hold a
/// password (`http://user:password@host`).
const URL_REASON: &str =
"[honcho] url must be http:// or https://, a host and an optional port, and nothing else";
/// `Err` when two of this agent's three workspaces have the same name.
fn check_own_workspaces(section: &HonchoSection) -> Result<(), String> {
// The pairs (trusted, untrusted), (trusted, fleet), (untrusted, fleet), in that order: the first
// pair whose two names are equal is `Err` with reason R5 and that name. None equal: `Ok(())`.
todo!()
}
/// One `[[honcho.read]]`, checked against `[honcho]` and the entries before it, `earlier`.
fn read_workspace(
section: &HonchoSection,
entry: &ReadSection,
earlier: &[ReadWorkspace],
) -> Result<ReadWorkspace, String> {
// In this order, `Err` at the first that fails: `check_name("[[honcho.read]]", "agent", ..)`,
// then the same for "workspace" (R2); the agent is `section.agent` (R6); an entry of `earlier`
// has the same agent (R7); the workspace is `section.trusted`, `.untrusted` or `.fleet` (R8).
// Then `Ok(ReadWorkspace { .. })`: both names cloned, `trust: trust_of(entry)?` and
// `class: class_of(entry)?`.
todo!()
}
/// The entry's `trust`: `Untrusted` when it is not written.
fn trust_of(entry: &ReadSection) -> Result<Trust, String> {
// `match entry.trust.as_deref()`: `None` or `Some("untrusted")` is `Trust::Untrusted`,
// `Some("trusted")` is `Trust::Trusted`, and any other text (case counts) is reason R9.
todo!()
}
/// The entry's `class`: `Private` when it is not written.
fn class_of(entry: &ReadSection) -> Result<DataClass, String> {
// `match entry.class.as_deref()`: `None` or `Some("private")` is `DataClass::Private`,
// `"public"` and `"secret"` are `Public` and `Secret`, any other text (case counts) is R10.
todo!()
}
/// Every `[secrets.<name>]`: a Honcho key's name, and a source that `SecretSpec::source` accepts.
fn check_keys(
secrets: &BTreeMap<String, SecretSpec>,
read: &[ReadWorkspace],
) -> Result<BTreeMap<String, SecretSource>, String> {
// For each `(name, spec)` of `secrets`, in the map's order: a name other than `TRUSTED_KEY`,
// `UNTRUSTED_KEY` and `FLEET_KEY` must be `READ_KEY` and the agent of an entry of `read`
// (`name.strip_prefix(READ_KEY)`); with the prefix and any other agent it is R11, without the
// prefix R12. Then `spec.source()`: `Err(why)` is R13; `Ok(source)` goes into a new
// `BTreeMap` under `name.clone()`. Return the map.
todo!()
}
@@ -0,0 +1,229 @@
//! `[honcho]` in `memoryd.toml`, its `[[honcho.read]]` list and its keys, checked at start (M5c
//! spec, section 10), and `[sockets] state`, `brokerd`'s session-state socket: what a valid
//! section gives, and how `load` refuses an invalid one. The reasons are in `honcho_reasons.rs`.
//! Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::collections::BTreeMap;
use std::path::PathBuf;
use bxcore::DataClass;
use bxcore::secrets::SecretSource;
use memoryd::config::{Config, ConfigError};
use memoryd::honcho::config::{
FLEET_KEY, HonchoConfig, MAX_NAME, READ_KEY, RUNBOOK, ReadWorkspace, TRUSTED_KEY, Trust,
UNTRUSTED_KEY, read_key,
};
use tmp::TempDir;
/// The smallest valid section: the six keys, no read workspace, no key.
const SECTION: &str = "[honcho]
url = \"http://rift.scylla-hammerhead.ts.net:8000\"
agent = \"boxmaker0\"
owner = \"kyle\"
trusted = \"boxmaker0-trusted\"
untrusted = \"boxmaker0-untrusted\"
fleet = \"fleet\"
";
/// Two read workspaces: the first with the defaults, the second with both written out.
const READS: &str = "[[honcho.read]]
agent = \"boxmaker1\"
workspace = \"boxmaker1-trusted\"
[[honcho.read]]
agent = \"boxmaker2\"
workspace = \"boxmaker2-trusted\"
trust = \"trusted\"
class = \"secret\"
";
/// `SECTION` with `from` (found exactly once) replaced by `to`, then `extra`.
fn with(from: &str, to: &str, extra: &str) -> String {
assert_eq!(SECTION.matches(from).count(), 1, "{from}");
format!("{}{extra}", SECTION.replace(from, to))
}
/// The checked section of `text`, which must parse.
fn checked(text: &str) -> Result<Option<HonchoConfig>, String> {
let config = Config::parse(text).unwrap_or_else(|e| panic!("{text}\n{e}"));
config.honcho_config()
}
#[test]
fn without_a_honcho_section_it_is_none() {
for text in [
"",
"[paths]\nhome = \"/srv/bx\"\n",
"[memory]\nreview = true\n[sockets]\nloop = \"/r/l.sock\"\n",
] {
assert_eq!(checked(text), Ok(None), "{text}");
}
}
#[test]
fn the_smallest_section_is_checked_as_written() {
let honcho = checked(SECTION).unwrap().unwrap();
assert_eq!(
honcho,
HonchoConfig {
url: "http://rift.scylla-hammerhead.ts.net:8000".to_string(),
agent: "boxmaker0".to_string(),
owner: "kyle".to_string(),
trusted: "boxmaker0-trusted".to_string(),
untrusted: "boxmaker0-untrusted".to_string(),
fleet: "fleet".to_string(),
read: Vec::new(),
keys: BTreeMap::new(),
}
);
}
#[test]
fn read_workspaces_default_to_untrusted_and_private_and_keep_their_order() {
let honcho = checked(&format!("{SECTION}{READS}")).unwrap().unwrap();
assert_eq!(
honcho.read,
vec![
ReadWorkspace {
agent: "boxmaker1".to_string(),
workspace: "boxmaker1-trusted".to_string(),
trust: Trust::Untrusted,
class: DataClass::Private,
},
ReadWorkspace {
agent: "boxmaker2".to_string(),
workspace: "boxmaker2-trusted".to_string(),
trust: Trust::Trusted,
class: DataClass::Secret,
},
]
);
let written = "[[honcho.read]]\nagent = \"b1\"\nworkspace = \"w1\"\ntrust = \"untrusted\"\nclass = \"public\"\n[[honcho.read]]\nagent = \"b2\"\nworkspace = \"w2\"\nclass = \"private\"\n";
let honcho = checked(&format!("{SECTION}{written}")).unwrap().unwrap();
let labels: Vec<(Trust, DataClass)> = honcho.read.iter().map(|r| (r.trust, r.class)).collect();
assert_eq!(
labels,
vec![
(Trust::Untrusted, DataClass::Public),
(Trust::Untrusted, DataClass::Private)
]
);
}
#[test]
fn every_key_is_checked_by_its_name_and_never_read() {
// None of these exists: a key is checked here, and read only when a client is made.
let keys = "[secrets.honcho_trusted]\nenv = \"BOXMAKER_TEST_NO_SUCH_VARIABLE\"\n\
[secrets.honcho_untrusted]\nfile = \"/nonexistent/boxmaker/untrusted.key\"\n\
[secrets.honcho_fleet]\ncredential = \"honcho-fleet\"\n\
[secrets.honcho_read_boxmaker2]\nenv = \"HONCHO_READ_2\"\n";
let honcho = checked(&format!("{SECTION}{READS}{keys}"))
.unwrap()
.unwrap();
let expected: BTreeMap<String, SecretSource> = [
(
"honcho_trusted",
SecretSource::Env("BOXMAKER_TEST_NO_SUCH_VARIABLE".to_string()),
),
(
"honcho_untrusted",
SecretSource::File(PathBuf::from("/nonexistent/boxmaker/untrusted.key")),
),
(
"honcho_fleet",
SecretSource::Credential("honcho-fleet".to_string()),
),
(
"honcho_read_boxmaker2",
SecretSource::Env("HONCHO_READ_2".to_string()),
),
]
.into_iter()
.map(|(name, source)| (name.to_string(), source))
.collect();
assert_eq!(honcho.keys, expected);
assert_eq!(honcho.read.len(), 2, "the keys change nothing else");
}
#[test]
fn the_key_names_and_the_pointer() {
assert_eq!(TRUSTED_KEY, "honcho_trusted");
assert_eq!(UNTRUSTED_KEY, "honcho_untrusted");
assert_eq!(FLEET_KEY, "honcho_fleet");
assert_eq!(READ_KEY, "honcho_read_");
assert_eq!(read_key("boxmaker1"), "honcho_read_boxmaker1");
assert_eq!(RUNBOOK, "see docs/runbook.md#honcho-config-invalid");
assert_eq!(MAX_NAME, 512);
}
#[test]
fn unknown_keys_and_wrong_types_are_parse_errors() {
for text in [
with("fleet = \"fleet\"", "fleet = \"fleet\"\nfleat = \"x\"", ""),
format!("{SECTION}[[honcho.read]]\nagent = \"b1\"\nworkspace = \"w1\"\nlabel = \"x\"\n"),
format!("{SECTION}[secrets.honcho_trusted]\nenv = \"K\"\nvalue = \"x\"\n"),
format!("{SECTION}[honcho.read]\nagent = \"b1\"\nworkspace = \"w1\"\n"),
format!("{SECTION}[[honcho.read]]\nagent = \"b1\"\nworkspace = \"w1\"\ntrust = true\n"),
format!("{SECTION}[[honcho.read]]\nagent = \"b1\"\n"),
format!("{SECTION}[sockets]\nstates = \"/x\"\n"),
with("owner = \"kyle\"\n", "", ""),
with("agent = \"boxmaker0\"", "agent = 0", ""),
"[secrets]\nhoncho_trusted = \"HONCHO_KEY\"\n".to_string(),
] {
assert!(Config::parse(&text).is_err(), "{text}");
}
}
#[test]
fn the_state_socket_is_under_home_unless_set() {
let config = Config::parse("[paths]\nhome = \"/srv/bx\"\n").unwrap();
assert_eq!(
config.state_socket(),
PathBuf::from("/srv/bx/run/memory-broker/state.sock")
);
let config =
Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nstate = \"/r/s.sock\"\n").unwrap();
assert_eq!(config.state_socket(), PathBuf::from("/r/s.sock"));
let config = Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nstate = \"\"\n").unwrap();
assert_eq!(
config.state_socket(),
PathBuf::from("/h/run/memory-broker/state.sock")
);
// The other three do not move.
assert_eq!(
config.loop_socket(),
PathBuf::from("/h/run/loop-memory/memory.sock")
);
}
#[test]
fn load_refuses_an_invalid_section_and_keeps_a_valid_one() {
let dir = TempDir::new("honcho-load");
let good = dir.write("good.toml", &format!("{SECTION}{READS}"));
let config = Config::load(&good).unwrap();
assert_eq!(config.honcho_config().unwrap().unwrap().read.len(), 2);
let bad = dir.write(
"bad.toml",
&with("owner = \"kyle\"", "owner = \"boxmaker0\"", ""),
);
let err = Config::load(&bad).unwrap_err();
let why =
"[honcho] agent and owner are both \"boxmaker0\": the agent's peer must not be the owner's";
assert!(
matches!(&err, ConfigError::Honcho(path, reason) if path == &bad && reason == why),
"{err:?}"
);
assert_eq!(err.to_string(), format!("{}: {why}", bad.display()));
let unknown = dir.write(
"unknown.toml",
&with("fleet = \"fleet\"", "fleet = \"fleet\"\nfleat = \"x\"", ""),
);
assert!(
matches!(Config::load(&unknown), Err(ConfigError::Parse(..))),
"an unknown key stays a parse error"
);
}
@@ -0,0 +1,338 @@
//! Every reason an invalid `[honcho]` section, `[[honcho.read]]` or Honcho key is refused with at
//! start (M5c spec, sections 6 and 10): each invalid section has one problem and one exact reason.
//! Do not edit.
use memoryd::config::Config;
use memoryd::honcho::config::{HonchoConfig, is_name};
/// The smallest valid section: the six keys, no read workspace, no key.
const SECTION: &str = "[honcho]
url = \"http://rift.scylla-hammerhead.ts.net:8000\"
agent = \"boxmaker0\"
owner = \"kyle\"
trusted = \"boxmaker0-trusted\"
untrusted = \"boxmaker0-untrusted\"
fleet = \"fleet\"
";
const URL_REASON: &str =
"[honcho] url must be http:// or https://, a host and an optional port, and nothing else";
/// `SECTION` with `from` (found exactly once) replaced by `to`, then `extra`.
fn with(from: &str, to: &str, extra: &str) -> String {
assert_eq!(SECTION.matches(from).count(), 1, "{from}");
format!("{}{extra}", SECTION.replace(from, to))
}
/// The checked section of `text`, which must parse.
fn checked(text: &str) -> Result<Option<HonchoConfig>, String> {
let config = Config::parse(text).unwrap_or_else(|e| panic!("{text}\n{e}"));
config.honcho_config()
}
fn reason(text: &str) -> String {
match checked(text) {
Ok(ok) => panic!("accepted:\n{text}\n{ok:?}"),
Err(why) => why,
}
}
fn name_reason(table: &str, key: &str, name: &str) -> String {
format!("{table} {key} {name:?} is not a Honcho name: 1 to 512 of a-z, A-Z, 0-9, _ and -")
}
#[test]
fn honcho_names_at_the_edges() {
let longest = "a".repeat(512);
for name in [
"a",
"Z",
"0",
"_",
"-",
"boxmaker0-trusted",
"A_b-9",
longest.as_str(),
] {
assert!(is_name(name), "{name}");
}
let too_long = "a".repeat(513);
for name in [
"",
too_long.as_str(),
"boxmaker.0",
"box maker",
"a/b",
"a:b",
"kyle@home",
"caf\u{e9}",
"tab\t",
"new\nline",
"\u{0660}",
] {
assert!(!is_name(name), "{name:?}");
}
// The longest name is accepted in every place a name goes.
let text = with("fleet = \"fleet\"", &format!("fleet = \"{longest}\""), "");
assert_eq!(checked(&text).unwrap().unwrap().fleet, longest);
let text = with("owner = \"kyle\"", &format!("owner = \"{too_long}\""), "");
assert_eq!(reason(&text), name_reason("[honcho]", "owner", &too_long));
}
#[test]
fn urls_that_are_accepted() {
for url in [
"http://rift.scylla-hammerhead.ts.net:8000",
"https://rift.scylla-hammerhead.ts.net",
"http://127.0.0.1:1",
"http://honcho:65535",
"https://Rift-2.Example.org:443",
"http://h",
] {
let text = with(
"url = \"http://rift.scylla-hammerhead.ts.net:8000\"",
&format!("url = \"{url}\""),
"",
);
assert_eq!(checked(&text).unwrap().unwrap().url, url);
}
}
#[test]
fn urls_that_are_refused_and_the_reason_never_quotes_them() {
for url in [
"",
"rift:8000",
"ftp://rift:8000",
"HTTP://rift:8000",
"http:/rift",
"http://",
"https://",
"http://:8000",
"http://rift:",
"http://rift:0",
"http://rift:65536",
"http://rift:+80",
"http://rift:80:90",
"http://rift:8000/",
"http://rift:8000/v3",
"http://rift/",
"http://kyle:hunter2@rift:8000",
"http://kyle@rift:8000",
"http://kyle@rift",
"http://rift?x=1",
"http://rift#top",
"http://ri ft",
" http://rift",
"http://rift_1",
"http://[::1]:8000",
] {
let text = with(
"url = \"http://rift.scylla-hammerhead.ts.net:8000\"",
&format!("url = \"{url}\""),
"",
);
assert_eq!(reason(&text), URL_REASON, "{url}");
}
}
#[test]
fn each_invalid_section_gives_its_reason() {
let read = |agent: &str, workspace: &str| {
format!("[[honcho.read]]\nagent = \"{agent}\"\nworkspace = \"{workspace}\"\n")
};
let cases: Vec<(String, String)> = vec![
(
with("agent = \"boxmaker0\"", "agent = \"boxmaker.0\"", ""),
name_reason("[honcho]", "agent", "boxmaker.0"),
),
(
with("owner = \"kyle\"", "owner = \"\"", ""),
name_reason("[honcho]", "owner", ""),
),
(
with(
"trusted = \"boxmaker0-trusted\"",
"trusted = \"boxmaker0 trusted\"",
"",
),
name_reason("[honcho]", "trusted", "boxmaker0 trusted"),
),
(
with(
"untrusted = \"boxmaker0-untrusted\"",
"untrusted = \"boxmaker0/untrusted\"",
"",
),
name_reason("[honcho]", "untrusted", "boxmaker0/untrusted"),
),
(
with("fleet = \"fleet\"", "fleet = \"fleet:all\"", ""),
name_reason("[honcho]", "fleet", "fleet:all"),
),
(
format!("{SECTION}{}", read("boxmaker.1", "boxmaker1-trusted")),
name_reason("[[honcho.read]]", "agent", "boxmaker.1"),
),
(
format!("{SECTION}{}", read("boxmaker1", "boxmaker1.trusted")),
name_reason("[[honcho.read]]", "workspace", "boxmaker1.trusted"),
),
(
with("owner = \"kyle\"", "owner = \"boxmaker0\"", ""),
"[honcho] agent and owner are both \"boxmaker0\": the agent's peer must not be the owner's"
.to_string(),
),
(
with(
"untrusted = \"boxmaker0-untrusted\"",
"untrusted = \"boxmaker0-trusted\"",
"",
),
"[honcho] workspace \"boxmaker0-trusted\" is named twice: trusted, untrusted and fleet must be three different workspaces"
.to_string(),
),
(
with("fleet = \"fleet\"", "fleet = \"boxmaker0-trusted\"", ""),
"[honcho] workspace \"boxmaker0-trusted\" is named twice: trusted, untrusted and fleet must be three different workspaces"
.to_string(),
),
(
with("fleet = \"fleet\"", "fleet = \"boxmaker0-untrusted\"", ""),
"[honcho] workspace \"boxmaker0-untrusted\" is named twice: trusted, untrusted and fleet must be three different workspaces"
.to_string(),
),
(
format!("{SECTION}{}", read("boxmaker0", "boxmaker1-trusted")),
"[[honcho.read]] agent \"boxmaker0\" is this agent ([honcho] agent)".to_string(),
),
(
format!(
"{SECTION}{}{}",
read("boxmaker1", "boxmaker1-trusted"),
read("boxmaker1", "boxmaker1-other")
),
"[[honcho.read]] agent \"boxmaker1\" is listed twice".to_string(),
),
(
format!("{SECTION}{}", read("boxmaker1", "boxmaker0-trusted")),
"[[honcho.read]] workspace \"boxmaker0-trusted\" is one of this agent's own"
.to_string(),
),
(
format!("{SECTION}{}", read("boxmaker1", "boxmaker0-untrusted")),
"[[honcho.read]] workspace \"boxmaker0-untrusted\" is one of this agent's own"
.to_string(),
),
(
format!("{SECTION}{}", read("boxmaker1", "fleet")),
"[[honcho.read]] workspace \"fleet\" is one of this agent's own".to_string(),
),
(
format!(
"{SECTION}{}trust = \"Trusted\"\n",
read("boxmaker1", "boxmaker1-trusted")
),
"[[honcho.read]] trust \"Trusted\" of agent \"boxmaker1\" is not \"trusted\" or \"untrusted\""
.to_string(),
),
(
format!(
"{SECTION}{}trust = \"\"\n",
read("boxmaker1", "boxmaker1-trusted")
),
"[[honcho.read]] trust \"\" of agent \"boxmaker1\" is not \"trusted\" or \"untrusted\""
.to_string(),
),
(
format!(
"{SECTION}{}class = \"top-secret\"\n",
read("boxmaker1", "boxmaker1-trusted")
),
"[[honcho.read]] class \"top-secret\" of agent \"boxmaker1\" is not \"public\", \"private\" or \"secret\""
.to_string(),
),
(
format!(
"{SECTION}{}class = \"Private\"\n",
read("boxmaker1", "boxmaker1-trusted")
),
"[[honcho.read]] class \"Private\" of agent \"boxmaker1\" is not \"public\", \"private\" or \"secret\""
.to_string(),
),
(
format!("{SECTION}[secrets.honcho_trust]\nenv = \"HONCHO_KEY\"\n"),
"[secrets.honcho_trust] is not a Honcho key: memoryd has only honcho_trusted, honcho_untrusted, honcho_fleet and honcho_read_<agent>"
.to_string(),
),
(
format!("{SECTION}[secrets.mattermost_token]\nenv = \"MM_TOKEN\"\n"),
"[secrets.mattermost_token] is not a Honcho key: memoryd has only honcho_trusted, honcho_untrusted, honcho_fleet and honcho_read_<agent>"
.to_string(),
),
(
format!(
"{SECTION}{}[secrets.honcho_read_boxmaker2]\nenv = \"HONCHO_KEY\"\n",
read("boxmaker1", "boxmaker1-trusted")
),
"[secrets.honcho_read_boxmaker2] is the key of agent \"boxmaker2\", which is not in [[honcho.read]]"
.to_string(),
),
(
format!("{SECTION}[secrets.honcho_read_boxmaker0]\nenv = \"HONCHO_KEY\"\n"),
"[secrets.honcho_read_boxmaker0] is the key of agent \"boxmaker0\", which is not in [[honcho.read]]"
.to_string(),
),
(
format!("{SECTION}[secrets.honcho_trusted]\n"),
"[secrets.honcho_trusted] needs exactly one of credential, env and file".to_string(),
),
(
format!(
"{SECTION}[secrets.honcho_fleet]\nenv = \"HONCHO_KEY\"\nfile = \"/etc/honcho.key\"\n"
),
"[secrets.honcho_fleet] needs exactly one of credential, env and file".to_string(),
),
(
format!("{SECTION}[secrets.honcho_untrusted]\nenv = \"honcho_key\"\n"),
"[secrets.honcho_untrusted] env \"honcho_key\" is not a variable name (A-Z, 0-9, _)"
.to_string(),
),
(
format!(
"{SECTION}{}[secrets.honcho_read_boxmaker1]\nfile = \"keys/boxmaker1\"\n",
read("boxmaker1", "boxmaker1-trusted")
),
"[secrets.honcho_read_boxmaker1] file \"keys/boxmaker1\" must be an absolute path"
.to_string(),
),
(
"[secrets.honcho_trusted]\nenv = \"HONCHO_KEY\"\n".to_string(),
"[secrets.honcho_trusted] is set, but there is no [honcho] section".to_string(),
),
];
for (text, want) in &cases {
assert_eq!(&reason(text), want, "\n{text}");
}
assert_eq!(cases.len(), 29);
}
#[test]
fn the_first_problem_is_the_one_reported() {
// A bad url and an owner that is the agent: the url is checked first.
let text = with(
"url = \"http://rift.scylla-hammerhead.ts.net:8000\"",
"url = \"rift\"",
"",
)
.replace("owner = \"kyle\"", "owner = \"boxmaker0\"");
assert_eq!(reason(&text), URL_REASON);
// A read agent that is this agent, with a bad trust: the agent is checked first.
let text = format!(
"{SECTION}[[honcho.read]]\nagent = \"boxmaker0\"\nworkspace = \"w\"\ntrust = \"no\"\n"
);
assert_eq!(
reason(&text),
"[[honcho.read]] agent \"boxmaker0\" is this agent ([honcho] agent)"
);
}
@@ -0,0 +1,238 @@
//! `memoryd serve` with a `[honcho]` section (M5c spec, section 10): an invalid one stops it at
//! start with the reason and the `honcho-config-invalid` pointer, before it takes its lock or
//! touches its home; a valid one starts it as before. A key is never printed. Every wait has a
//! deadline, and every process is killed and every path removed by a guard (tip T44). Do not
//! edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use bxcore::{Empty, Envelope, Message, PROTOCOL_VERSION, read_frame, write_frame};
use tmp::TempDir;
const POINTER: &str = "see docs/runbook.md#honcho-config-invalid";
const START_FAILED: &str = "see docs/runbook.md#memoryd-start-failed";
/// The values of the two keys the configs name. Built from pieces, so that no file holds one
/// whole; neither may appear in anything `memoryd` prints.
fn env_key() -> String {
["hk", "env", "7f3a9c"].join("-")
}
fn file_key() -> String {
["hk", "file", "b81d2e"].join("-")
}
const SECTION: &str = "[honcho]
url = \"http://127.0.0.1:9\"
agent = \"boxmaker0\"
owner = \"kyle\"
trusted = \"boxmaker0-trusted\"
untrusted = \"boxmaker0-untrusted\"
fleet = \"fleet\"
[[honcho.read]]
agent = \"boxmaker1\"
workspace = \"boxmaker1-trusted\"
";
struct Home {
dir: TempDir,
config: PathBuf,
}
impl Home {
/// A home whose `memoryd.toml` is the usual one and `extra`, with a key file holding
/// `file_key()` (mode 0600) at `<home>/keys/honcho.key`.
fn new(tag: &str, extra: &str) -> Home {
let dir = TempDir::new(tag);
let soul = dir.write("etc/soul.md", "You are Boxmaker.\n");
let key = dir.write("keys/honcho.key", &format!("{}\n", file_key()));
std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap();
let text = format!(
"[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\n{extra}",
dir.path().display(),
soul.display()
);
let text = text.replace("KEY_FILE", &key.display().to_string());
let config = dir.write("memoryd.toml", &text);
Home { dir, config }
}
fn path(&self, relative: &str) -> PathBuf {
self.dir.path().join(relative)
}
fn command(&self) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_memoryd"));
command
.args(["serve", "--config"])
.arg(&self.config)
.env("HONCHO_TEST_KEY", env_key())
.stdin(Stdio::null())
.stdout(Stdio::null());
command
}
/// Runs `memoryd serve` to its end, at most 10 s (then it is killed), and gives its exit code
/// (`None` if it had to be killed) and its stderr.
fn run_to_end(&self) -> (Option<i32>, String) {
let mut child = Daemon(self.command().stderr(Stdio::piped()).spawn().unwrap());
let deadline = Instant::now() + Duration::from_secs(10);
let mut code = None;
while Instant::now() < deadline {
if let Ok(Some(status)) = child.0.try_wait() {
code = status.code();
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let _ = child.0.kill();
let _ = child.0.wait();
let mut stderr = String::new();
if let Some(mut pipe) = child.0.stderr.take() {
let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr);
}
(code, stderr)
}
/// `memoryd serve` with its stderr in `<home>/memoryd.err`.
fn start(&self) -> Daemon {
let err = std::fs::File::create(self.path("memoryd.err")).unwrap();
Daemon(self.command().stderr(Stdio::from(err)).spawn().unwrap())
}
fn stderr(&self) -> String {
std::fs::read_to_string(self.path("memoryd.err")).unwrap_or_default()
}
}
/// A running `memoryd serve`, killed and waited for when dropped. Each test declares it after its
/// `Home`, so it drops first.
struct Daemon(Child);
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
/// True once `memoryd` answers `memory_block` on `loopd`'s socket, within 10 s.
fn answers(home: &Home) -> bool {
let socket = home.path("run/loop-memory/memory.sock");
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Ok(mut stream) = UnixStream::connect(&socket) {
let limit = Some(Duration::from_secs(5));
let ask = Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg: Message::MemoryBlock(Empty {}),
};
if stream.set_read_timeout(limit).is_ok()
&& write_frame(&mut stream, &ask).is_ok()
&& matches!(
read_frame(&mut stream),
Ok(Envelope {
msg: Message::MemoryBlockReply(_),
..
})
)
{
return true;
}
}
std::thread::sleep(Duration::from_millis(20));
}
false
}
fn assert_no_key(text: &str) {
assert!(
!text.contains(&env_key()),
"the env key was printed: {text}"
);
assert!(
!text.contains(&file_key()),
"the file key was printed: {text}"
);
}
#[test]
fn an_invalid_section_stops_memoryd_with_its_reason_and_the_pointer() {
let extra = format!(
"{}[secrets.honcho_trusted]\nenv = \"HONCHO_TEST_KEY\"\n[secrets.honcho_fleet]\nfile = \"KEY_FILE\"\n",
SECTION.replace("owner = \"kyle\"", "owner = \"boxmaker0\"")
);
let home = Home::new("honcho-bad", &extra);
let (code, stderr) = home.run_to_end();
assert_eq!(code, Some(1), "{stderr}");
assert_eq!(
stderr,
format!(
"memoryd: {}: [honcho] agent and owner are both \"boxmaker0\": the agent's peer must not be the owner's\n{POINTER}\n",
home.config.display()
)
);
assert_no_key(&stderr);
assert!(
!home.path("run").exists(),
"memoryd stopped before its lock and sockets"
);
assert!(!home.path("memory").exists(), "and before its memory");
}
#[test]
fn a_key_for_an_agent_not_read_stops_memoryd() {
let extra = format!("{SECTION}[secrets.honcho_read_boxmaker2]\nenv = \"HONCHO_TEST_KEY\"\n");
let home = Home::new("honcho-key", &extra);
let (code, stderr) = home.run_to_end();
assert_eq!(code, Some(1), "{stderr}");
assert_eq!(
stderr,
format!(
"memoryd: {}: [secrets.honcho_read_boxmaker2] is the key of agent \"boxmaker2\", which is not in [[honcho.read]]\n{POINTER}\n",
home.config.display()
)
);
assert_no_key(&stderr);
}
#[test]
fn an_unknown_key_in_the_section_is_still_a_start_failure() {
let extra = SECTION.replace("fleet = \"fleet\"", "fleet = \"fleet\"\nfleat = \"x\"");
let home = Home::new("honcho-unknown", &extra);
let (code, stderr) = home.run_to_end();
assert_eq!(code, Some(1), "{stderr}");
assert!(
stderr.starts_with(&format!("memoryd: {}: ", home.config.display())),
"{stderr}"
);
assert!(stderr.contains("fleat"), "{stderr}");
assert!(stderr.ends_with(&format!("\n{START_FAILED}\n")), "{stderr}");
assert!(!stderr.contains(POINTER), "{stderr}");
}
#[test]
fn a_valid_section_with_keys_starts_memoryd_as_before() {
let extra = format!(
"{SECTION}[secrets.honcho_trusted]\nenv = \"HONCHO_TEST_KEY\"\n[secrets.honcho_fleet]\nfile = \"KEY_FILE\"\n[secrets.honcho_read_boxmaker1]\nenv = \"HONCHO_TEST_KEY\"\n"
);
let home = Home::new("honcho-good", &extra);
let _daemon = home.start();
assert!(
answers(&home),
"memoryd never answered; stderr: {}",
home.stderr()
);
let stderr = home.stderr();
assert!(stderr.contains("memoryd: serving\n"), "{stderr}");
assert_no_key(&stderr);
assert!(!stderr.contains(POINTER), "{stderr}");
}
@@ -0,0 +1,50 @@
## honcho-config-invalid
**What you see.** `memoryd` exits 1 at start, before it takes its lock or touches its home, with
one line naming its config file and the reason, then this entry:
```text
memoryd: /etc/boxmaker/memoryd.toml: [honcho] agent and owner are both "kyle": the agent's peer must not be the owner's
see docs/runbook.md#honcho-config-invalid
```
**Why.** `[honcho]` in `memoryd.toml` names this agent's peer, the owner's peer and the
workspaces the agent writes to and reads in Honcho, and `[secrets.<name>]` where each workspace's
key comes from (M5c spec, section 10). A wrong name would send turns and memory to a workspace
they do not belong in, so `memoryd` checks the section before it starts. The reasons:
- `url` is not `http://` or `https://`, a host (letters, digits, `.` and `-`) and an optional
port (1 to 65535), and nothing else: no path, not even a `/`, and no user or password. The line
does not repeat the URL.
- A name (`agent`, `owner`, `trusted`, `untrusted`, `fleet`, and each `[[honcho.read]]`'s `agent`
and `workspace`) is not 1 to 512 of `a-z`, `A-Z`, `0-9`, `_` and `-`, Honcho's own rule: a `.`
or a space is not allowed.
- `agent` and `owner` are the same peer, or two of `trusted`, `untrusted` and `fleet` are the same
workspace.
- A `[[honcho.read]]` names this agent, names an agent twice, or names one of this agent's three
workspaces; or its `trust` is not `trusted` or `untrusted`, or its `class` not `public`,
`private` or `secret` (all lower case).
- A `[secrets.<name>]` is not `honcho_trusted`, `honcho_untrusted`, `honcho_fleet` or
`honcho_read_<agent>` for an agent in `[[honcho.read]]`; or it does not name exactly one
well-formed `credential`, `env` or `file` (as in `secret-unavailable`); or there is no
`[honcho]` section for it.
A key missing from `[honcho]` or `[[honcho.read]]`, an unknown key, or a value of the wrong type
is a parse error instead, and points to `memoryd-start-failed`. A key's value is not read here,
only where it comes from, and it is never printed. While `memoryd` is stopped nothing is synced,
and `bxctl memory sync` cannot reach it.
**Confirm.** The line quotes the table and the value. Open the file it names and compare the
section with the M5c spec, section 10 (`docs/specs/2026-09-27-m5c-honcho.md`):
```sh
grep -n -A6 -e '^\[honcho\]' -e '^\[\[honcho.read\]\]' -e '^\[secrets\.' <config path>
```
**Fix.** Correct the value. Keep a name that is already in use as it is: a new `trusted`,
`untrusted` or `fleet` sends everything from then on to a new, empty workspace, and a new `agent`
is a new peer. `agent` is this agent's own name, different on each agent; `owner` and `fleet` are
the same in every agent's file. To turn Honcho off, remove `[honcho]`, its `[[honcho.read]]`
tables and every `[secrets.honcho_…]`.
**Check.** `memoryd serve --config <path>` prints `memoryd: serving`.