diff --git a/crates/memoryd/Cargo.toml b/crates/memoryd/Cargo.toml index 629bca8..ddd8647 100644 --- a/crates/memoryd/Cargo.toml +++ b/crates/memoryd/Cargo.toml @@ -9,7 +9,7 @@ publish.workspace = true workspace = true [dependencies] -bxcore.workspace = true +bxcore = { workspace = true, features = ["secrets"] } roncho.workspace = true rusqlite.workspace = true serde.workspace = true diff --git a/crates/memoryd/src/config.rs b/crates/memoryd/src/config.rs index 937f8b8..d56ca4a 100644 --- a/crates/memoryd/src/config.rs +++ b/crates/memoryd/src/config.rs @@ -1,13 +1,18 @@ -//! `memoryd` configuration: read `memoryd.toml` into a typed `Config` (M5a spec, section 11). +//! `memoryd` configuration: read `memoryd.toml` into a typed `Config` (M5a spec, section 11), and +//! check its `[honcho]` section at start (M5c spec, section 10). //! //! Unknown keys are errors in every table, as in the other roles: a misspelt key that silently //! fell back to its default would be a setting the owner believes is set and is not. +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use bxcore::DataClass; +use bxcore::secrets::SecretSpec; use serde::Deserialize; +use crate::honcho::config::{self as honcho, HonchoConfig, HonchoSection}; + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct Paths { @@ -65,6 +70,9 @@ pub struct Sockets { pub loopd: PathBuf, pub broker: PathBuf, pub admin: PathBuf, + /// `brokerd`'s `state.sock`, which `memoryd` connects to, to ask for a session's state (M5c + /// spec, section 7). `brokerd.toml` names the same path as its `[sockets] state`. + pub state: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] @@ -74,12 +82,18 @@ pub struct Config { pub memory: Memory, pub index: Index, pub sockets: Sockets, + /// `[honcho]` as written; `honcho_config` checks it. Without it, Honcho is off. + pub honcho: Option, + /// `[secrets.]`: the Honcho keys, checked by `honcho_config`, never read here. + pub secrets: BTreeMap, } #[derive(Debug)] pub enum ConfigError { Read(PathBuf, std::io::Error), Parse(PathBuf, toml::de::Error), + /// `[honcho]` or a key for it is not allowed: the path, and `honcho_config`'s reason. + Honcho(PathBuf, String), } impl std::fmt::Display for ConfigError { @@ -87,6 +101,7 @@ impl std::fmt::Display for ConfigError { match self { ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()), ConfigError::Parse(path, err) => write!(f, "{}: {err}", path.display()), + ConfigError::Honcho(path, why) => write!(f, "{}: {why}", path.display()), } } } @@ -98,12 +113,15 @@ impl Config { toml::from_str(text) } - /// Read and parse `path`; a relative `soul_starter` is taken to be next to it. + /// Read, parse and check `path`; a relative `soul_starter` is taken to be next to it. pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?; let mut config = Config::parse(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?; + if let Err(why) = config.honcho_config() { + return Err(ConfigError::Honcho(path.to_path_buf(), why)); + } if config.memory.soul_starter.is_relative() { let base = path.parent().unwrap_or_else(|| Path::new(".")); config.memory.soul_starter = base.join(&config.memory.soul_starter); @@ -111,6 +129,11 @@ impl Config { Ok(config) } + /// `[honcho]` and its keys, checked: `Ok(None)` when there is no `[honcho]` and no key. + pub fn honcho_config(&self) -> Result, String> { + honcho::check(self.honcho.as_ref(), &self.secrets) + } + fn socket(&self, set: &Path, default: &str) -> PathBuf { if set.as_os_str().is_empty() { self.paths.home.join(default) @@ -128,6 +151,9 @@ impl Config { pub fn admin_socket(&self) -> PathBuf { self.socket(&self.sockets.admin, "run/owner-memory/admin.sock") } + pub fn state_socket(&self) -> PathBuf { + self.socket(&self.sockets.state, "run/memory-broker/state.sock") + } pub fn memory_dir(&self) -> PathBuf { self.paths.home.join("memory") } diff --git a/crates/memoryd/src/honcho/config.rs b/crates/memoryd/src/honcho/config.rs new file mode 100644 index 0000000..0818521 --- /dev/null +++ b/crates/memoryd/src/honcho/config.rs @@ -0,0 +1,308 @@ +//! `[honcho]` in `memoryd.toml` and its keys in `[secrets.]`, 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.]`, 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, +} + +/// 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, + #[serde(default)] + pub class: Option, +} + +/// `[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, + /// Every `[secrets.]`, by its name: a source that is checked, never read here. + pub keys: BTreeMap, +} + +/// 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 { + !name.is_empty() + && name.len() <= MAX_NAME + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +/// 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, +) -> Result, 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(§ion.url)?; + for (key, name) in [ + ("agent", §ion.agent), + ("owner", §ion.owner), + ("trusted", §ion.trusted), + ("untrusted", §ion.untrusted), + ("fleet", §ion.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 = Vec::new(); + for entry in §ion.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> { + let rest = if let Some(rest) = url.strip_prefix("https://") { + rest + } else if let Some(rest) = url.strip_prefix("http://") { + rest + } else { + return Err(URL_REASON.to_string()); + }; + let (host, port) = match rest.split_once(':') { + Some(pair) => pair, + None => { + return if host_ok(rest) { + Ok(()) + } else { + Err(URL_REASON.to_string()) + }; + } + }; + if !host_ok(host) { + return Err(URL_REASON.to_string()); + } + let port_ok = !port.is_empty() + && port.bytes().all(|b| b.is_ascii_digit()) + && port.parse::().ok().is_some_and(|n| n > 0); + if port_ok { + Ok(()) + } else { + Err(URL_REASON.to_string()) + } +} + +/// The host of a URL: not empty, every byte an ASCII letter, digit, `.` or `-`. +fn host_ok(host: &str) -> bool { + !host.is_empty() + && host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') +} + +/// 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> { + let pairs = [ + (§ion.trusted, §ion.untrusted), + (§ion.trusted, §ion.fleet), + (§ion.untrusted, §ion.fleet), + ]; + for (a, b) in pairs { + if a == b { + return Err(format!( + "[honcho] workspace {a:?} is named twice: trusted, untrusted and fleet must be three different workspaces" + )); + } + } + Ok(()) +} + +/// One `[[honcho.read]]`, checked against `[honcho]` and the entries before it, `earlier`. +fn read_workspace( + section: &HonchoSection, + entry: &ReadSection, + earlier: &[ReadWorkspace], +) -> Result { + check_name("[[honcho.read]]", "agent", &entry.agent)?; + check_name("[[honcho.read]]", "workspace", &entry.workspace)?; + if entry.agent == section.agent { + return Err(format!( + "[[honcho.read]] agent {:?} is this agent ([honcho] agent)", + entry.agent + )); + } + if earlier.iter().any(|r| r.agent == entry.agent) { + return Err(format!( + "[[honcho.read]] agent {:?} is listed twice", + entry.agent + )); + } + if entry.workspace == section.trusted + || entry.workspace == section.untrusted + || entry.workspace == section.fleet + { + return Err(format!( + "[[honcho.read]] workspace {:?} is one of this agent's own", + entry.workspace + )); + } + Ok(ReadWorkspace { + agent: entry.agent.clone(), + workspace: entry.workspace.clone(), + trust: trust_of(entry)?, + class: class_of(entry)?, + }) +} + +/// The entry's `trust`: `Untrusted` when it is not written. +fn trust_of(entry: &ReadSection) -> Result { + match entry.trust.as_deref() { + None | Some("untrusted") => Ok(Trust::Untrusted), + Some("trusted") => Ok(Trust::Trusted), + Some(other) => Err(format!( + "[[honcho.read]] trust {other:?} of agent {:?} is not \"trusted\" or \"untrusted\"", + entry.agent + )), + } +} + +/// The entry's `class`: `Private` when it is not written. +fn class_of(entry: &ReadSection) -> Result { + match entry.class.as_deref() { + None | Some("private") => Ok(DataClass::Private), + Some("public") => Ok(DataClass::Public), + Some("secret") => Ok(DataClass::Secret), + Some(other) => Err(format!( + "[[honcho.read]] class {other:?} of agent {:?} is not \"public\", \"private\" or \"secret\"", + entry.agent + )), + } +} + +/// Every `[secrets.]`: a Honcho key's name, and a source that `SecretSpec::source` accepts. +fn check_keys( + secrets: &BTreeMap, + read: &[ReadWorkspace], +) -> Result, String> { + let mut keys: BTreeMap = BTreeMap::new(); + for (name, spec) in secrets { + let is_read_key = matches!(name.strip_prefix(READ_KEY), Some(agent) if read.iter().any(|r| r.agent == agent)); + if !is_read_key && name != TRUSTED_KEY && name != UNTRUSTED_KEY && name != FLEET_KEY { + if let Some(agent) = name.strip_prefix(READ_KEY) { + return Err(format!( + "[secrets.{name}] is the key of agent {agent:?}, which is not in [[honcho.read]]" + )); + } + return Err(format!( + "[secrets.{name}] is not a Honcho key: memoryd has only honcho_trusted, honcho_untrusted, honcho_fleet and honcho_read_" + )); + } + let source = spec + .source() + .map_err(|why| format!("[secrets.{name}] {why}"))?; + keys.insert(name.clone(), source); + } + Ok(keys) +} diff --git a/crates/memoryd/src/honcho/mod.rs b/crates/memoryd/src/honcho/mod.rs new file mode 100644 index 0000000..c9c063e --- /dev/null +++ b/crates/memoryd/src/honcho/mod.rs @@ -0,0 +1,3 @@ +//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec). + +pub mod config; diff --git a/crates/memoryd/src/lib.rs b/crates/memoryd/src/lib.rs index 13fec31..7ccbe43 100644 --- a/crates/memoryd/src/lib.rs +++ b/crates/memoryd/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod edit; pub mod entries; pub mod files; +pub mod honcho; pub mod index; pub mod journal; pub mod notes; diff --git a/crates/memoryd/src/main.rs b/crates/memoryd/src/main.rs index e8c4c2d..ccfff2b 100644 --- a/crates/memoryd/src/main.rs +++ b/crates/memoryd/src/main.rs @@ -6,7 +6,8 @@ use std::process::ExitCode; use std::sync::Arc; use bxcore::instance; -use memoryd::config::Config; +use memoryd::config::{Config, ConfigError}; +use memoryd::honcho::config::RUNBOOK as HONCHO_INVALID; use memoryd::serve; const USAGE: &str = "usage: memoryd serve --config "; @@ -21,7 +22,11 @@ fn main() -> ExitCode { let config = match Config::load(&path) { Ok(config) => config, Err(e) => { - eprintln!("memoryd: {e}\n{START_FAILED}"); + let pointer = match &e { + ConfigError::Read(..) | ConfigError::Parse(..) => START_FAILED, + ConfigError::Honcho(..) => HONCHO_INVALID, + }; + eprintln!("memoryd: {e}\n{pointer}"); return ExitCode::from(1); } }; diff --git a/crates/memoryd/tests/honcho_config.rs b/crates/memoryd/tests/honcho_config.rs new file mode 100644 index 0000000..6030b63 --- /dev/null +++ b/crates/memoryd/tests/honcho_config.rs @@ -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, 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 = [ + ( + "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" + ); +} diff --git a/crates/memoryd/tests/honcho_reasons.rs b/crates/memoryd/tests/honcho_reasons.rs new file mode 100644 index 0000000..96063fd --- /dev/null +++ b/crates/memoryd/tests/honcho_reasons.rs @@ -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, 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_" + .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_" + .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)" + ); +} diff --git a/crates/memoryd/tests/honcho_start.rs b/crates/memoryd/tests/honcho_start.rs new file mode 100644 index 0000000..d68db4b --- /dev/null +++ b/crates/memoryd/tests/honcho_start.rs @@ -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 `/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, 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 `/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}"); +} diff --git a/docs/dependencies.md b/docs/dependencies.md index 09aad3e..b12c9d7 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -11,7 +11,7 @@ Every external crate has a row here. `scripts/check-dep-docs.sh` enforces it. | `emsha` | 1.0.4 | `bxcore` | SHA-256. Written by the owner; no dependencies, no `unsafe`. Checked against `sha256sum` on 3,204 inputs. | | `rustls` | 0.23.45 | `gatewayd` | TLS to Mattermost over the tailnet (M4a, owner's decision). `default-features = false` with `ring`, `std`, `tls12`. With its dependencies on Linux: `ring` (Apache-2.0 AND ISC; builds C and assembly), `rustls-webpki` and `untrusted` (ISC), `rustls-pki-types`, `subtle` (BSD-3-Clause), `zeroize`, `once_cell`, `getrandom`, `libc`, `cfg-if`. Apache-2.0 OR ISC OR MIT. | | `rustls-native-certs` | 0.8.4 | `gatewayd` | The host's trusted certificates, so a CA installed on the host is trusted too; adds `openssl-probe`. Apache-2.0 OR ISC OR MIT. | -| `zeroize` | 1.9.0 | `bxcore`, feature `secrets` (enabled by `gatewayd`; never by `loopd`) | Wipes a secret's memory when it is dropped (`Secret`). Already a dependency of `rustls`. Apache-2.0 OR MIT. | +| `zeroize` | 1.9.0 | `bxcore`, feature `secrets` (enabled by `gatewayd` and `memoryd`; never by `loopd`) | Wipes a secret's memory when it is dropped (`Secret`). Already a dependency of `rustls`. Apache-2.0 OR MIT. | | `rusqlite` | 0.40.2 | `memoryd` from M5a | The full-text index (`index/memory.sqlite3`, FTS5), disposable and rebuilt from files. Feature `bundled`: `libsqlite3-sys` compiles SQLite from source with FTS5 on, so no system SQLite is needed. With its dependencies: `libsqlite3-sys` (MIT; build: `cc`, `pkg-config`, `vcpkg`), `hashlink`, `hashbrown`, `foldhash` (Zlib, added to `deny.toml` for it), `fallible-iterator`, `fallible-streaming-iterator`, `smallvec`, `bitflags`. MIT. | | `roncho` | 1.0.0 | `memoryd` from M5c | The owner's Honcho client (`docs/specs/2026-09-25-roncho-requirements.md`). `default-features = false`, feature `tls` (which implies `blocking`): a blocking client over `std::net`, no async runtime; TLS through `rustls`, already a dependency of `gatewayd`. It adds only itself to the lock file: `serde`, `serde_json`, `thiserror`, `toml`, `zeroize` and the TLS crates are already there. Checked against its requirements on 2026-09-27 (every client built with `.without_config_file()`, spec M5c section 6). Apache-2.0. | diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 3b44e36..dfb54f8 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 | |---|---|---|---|---|---|---|---| +| M5c1/06-memoryd-honcho-config | 2026-09-28 | done | 1 | pass | none | Copied the three given tests from `docs/plans/M5c1/files/crates/memoryd/tests/`: `honcho_config.rs`, `honcho_reasons.rs`, `honcho_start.rs`. Step 2 showed exactly the named failures: `honcho_config` with eight (E0433 cannot find `honcho` in `memoryd`, E0432 unresolved import `bxcore::secrets`, two E0599 for `honcho_config`, three for `state_socket`, one for the `Honcho` variant) and `honcho_reasons` with two (the same E0433 and E0432); `honcho_start` compiled. Made K1 (`bxcore` gains `features = ["secrets"]`) and L1 (`pub mod honcho;` after `pub mod files;`), created `crates/memoryd/src/honcho/mod.rs` with the given one-line module, and copied the skeleton `crates/memoryd/src/honcho/config.rs`. Filled its seven bodies in the given order, each from its comment and `cargo check` after: `is_name` (not empty, byte length `<= MAX_NAME`, every byte `is_ascii_alphanumeric()` or `_` or `-`, bytes not chars so `é` and U+0660 fail); `check_url` (strip `https://` then `http://`, `split_once(':')` into host and port, a `host_ok` helper for the non-empty ASCII-alnum/`.`/`-` host, a port that is non-empty, all digits and parses to `u16` `> 0` via `ok().is_some_and`, the `URL_REASON` which never quotes the URL); `check_own_workspaces` (the three pairs in order, first equal pair is R5 with its name); `trust_of` (match on `as_deref`, `None`/`Some("untrusted")` is `Untrusted`, `Some("trusted")` is `Trusted`, else R9 with the written text); `class_of` (same shape, `Private`/`Public`/`Secret`, else R10); `read_workspace` (check agent then workspace names, R6 the agent is this agent, R7 the agent is listed in `earlier`, R8 the workspace is one of the three, then the `ReadWorkspace` with `trust_of`/`class_of`); `check_keys` (a name other than the three is a read key only when `strip_prefix(READ_KEY)` names an agent in `read`, else R11 with the suffix or R12 without the prefix, then `spec.source()` with R13). Made C1-C8 in `config.rs` (module comment, imports with `BTreeMap`/`SecretSpec`/`honcho`, `Sockets.state`, `Config.honcho` and `.secrets`, `ConfigError::Honcho` and its Display arm, `load` calls `honcho_config` before returning, `honcho_config` and `state_socket`), and M1-M2 in `main.rs` (imports and the pointer matched by error kind on the `Config::load` arm); the pointer edit first landed on the `instance::lock` arm and I moved it to the `Config::load` arm, the diff being exactly M1-M2. Appended `runbook-honcho-config-invalid.md` to `docs/runbook.md` (`grep -c '^## honcho-config-invalid$'` printed 1) and made D1 in `docs/dependencies.md`. Step 7: `cargo build -p memoryd` builds, the runbook grep prints one line, the `secrets::load`/`read_checked` grep prints nothing, the other-crates diff is empty. `cargo test -p memoryd` every suite passes (config 4, honcho_config 8, honcho_reasons 5, honcho_start 4). `make gate` printed `gate: ok` with 1188 tests (1187 in `cargo test --workspace` plus the ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD`; 1171 before this task plus the 17 new). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/05-brokerd-state-socket | 2026-09-28 | done | 2 | pass | none | Copied five files from `docs/plans/M5c1/files/`: `state_socket.rs` and `serve_state.rs` (new) and new versions of `config.rs`, `serve.rs` and `tests/fixtures/config/full.toml` (all now set and check `state`). Step 2 showed exactly the named failures: `config` with four E0599 (`no method named state_socket`) and `state_socket` with one E0432 (`unresolved import brokerd::state_socket`); the new `serve.rs` compiled. `cargo build --tests` reported those two targets only. Made the edits exactly as the task gives: C1 `Sockets` gains `state: PathBuf` with the doc comment, C2 `Config::state_socket` after `admin_socket` returning the default `run/memory-broker/state.sock` when empty; L1 `pub mod state_socket;` after `pub mod state;`. Created `crates/brokerd/src/state_socket.rs` with the given body: `read_request`, match `Message::SessionState(question)` (no `SessionState` import), `StateStore::new(&broker.cfg().state_dir())`, `Ok` -> `SessionStateReply { class: state.taint, untrusted: state.untrusted }`, `Err` -> log `brokerd: {e}` and send `internal` with `e.to_string()`, `forbid` for any other kind; `send` the final frame. V1-V11 in `serve.rs`: doc comment, `use crate::state_socket`, `Started` gains `state: UnixListener`, `state.sock` bound first then tool and admin, `Which::State`, the `run` destructure, the second `accept_loop` with `tx` moved, the `accept-state` name, and `state_socket::handle` in `serve_one`; M1-M3 in `main.rs`: doc comment, `state_path = cfg.state_socket()`, the `serving session state on ` notice. R1-R3 in `docs/runbook.md`: `accept-broker (or accept-admin, accept-state)`, the `state.sock` line in `socket-forbidden`, and `pgrep -a 'loopd|bxctl|memoryd'`. After the edits `cargo check -p brokerd` is clean with no warning; `config` 7, `state_socket` 8, `serve_state` 5, `instance` 2, `serve` 8, `serve_pointers` 10 pass. Step 7: `grep -n 'listen(&cfg'` prints three lines with `state_socket` first, `grep -n '^use' state_socket.rs` prints four and the `bxcore` import carries `ErrorCode`, `Message`, `SessionStateReply`, `WireError` with no `SessionState`, `git diff --stat -- crates/brokerd/src` lists exactly config.rs/lib.rs/main.rs/serve.rs, the other-crates diff is empty. `make gate` printed `gate: ok` with 1171 tests (1158 before this task plus the 13 new: eight in `state_socket.rs`, five in `serve_state.rs`; the new `config.rs` and `serve.rs` kept their counts, 7 and 8). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/04-bxctl-speaker | 2026-09-28 | done | 1 | pass | none | Copied four files from `docs/plans/M5c1/files/`: `chat_speaker.rs` (new) and new versions of `chat.rs`, `chat_approvals.rs` and `cli.rs` (did not copy `chat.rs-task01`). Step 2 showed exactly the named failures: four non-compiling test targets (`chat`, `chat_approvals`, `chat_speaker`, `cli`) with E0061 for `run_turn` (5 args) and `stream_turn` (6 args), E0282 on the closure in `chat.rs`, and E0560/E0609 for the missing `speaker` field. Made the edits exactly as the task gives: C1 the `--speaker NAME` flag added to the chat line of `USAGE` with one explaining line (six-space indent); C2 `ChatOptions` gains `speaker: Option`; C3 `default` sets `speaker: None`; C4 the `--speaker` arm in `parse_chat` refuses a second one and falls through to `i += 2`; C5 `parse_speaker` (empty, or any whitespace/control char -> `UsageError`, else the value as typed). R1 `run_turn` gains `speaker: Option<&str>` after `resume` with the doc comment, R2 `speaker.map(str::to_string)` on the `Turn`, R3 `stream_turn` gains the parameter and passes it. M1-M3 all three `stream_turn` calls in `main.rs` pass `opts.speaker.as_deref()`. `main` unchanged (a `UsageError` already prints the usage text and exits 2). After the edits `cargo check -p bxctl` is clean with no warning; `cli` 15 passed, `chat_speaker` 7 passed, every bxctl suite passes. Step 7: `grep -rn 'speaker: None' crates/bxctl/src` prints one line (the default), `git diff --stat -- crates/bxctl/src` lists exactly chat.rs/cli.rs/main.rs, the other-crates diff is empty. `make gate` printed `gate: ok` with 1158 tests (1148 before plus the 10 new: three in `cli.rs`, seven in `chat_speaker.rs`; `chat.rs` and `chat_approvals.rs` kept their counts). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/03-gatewayd-speaker | 2026-09-28 | done | 2 | pass | none | Copied the seven given files from `docs/plans/M5c1/files/`: `deliver_speaker.rs` and `serve_speaker.rs` (new) and new versions of `sessions.rs`, `deliver.rs`, `deliver_links.rs`, `deliver_notice.rs` and `support/fake_mm.rs` (it gains `refuse_users`). Step 2 showed exactly the named failures: `deliver`, `deliver_links`, `deliver_notice`, `deliver_speaker` and `sessions` did not compile (E0560 for `Batch.senders` / `Message.user`, E0061 for `deliver` taking 5 args, E0282 on the closure), and `serve_speaker` compiled with its four tests failing (three found `None` where a speaker was expected, the fourth had no `"no username for"` log line). Made the edits exactly as the task gives: S1 `Message` gains `user` (the post's `user_id`); S2/S3 set `user` on both `Route::Queue` paths; S4 `Batch` gains `senders` plus `Batch::sender` (`first()?` then `all(|u| u == first)`, `None` for two or more people or for none); S5 `Pending` gains `senders`; S6 `push` pushes `message.user` to `pending.senders` on the `Waiting` path; S7 the `Start` path is `senders: vec![message.user.clone()]` with the new `Pending`'s `senders: Vec::new()`; S8 `finish` takes `senders` with `std::mem::take`. D1 `one_turn` gains `speaker: Option<&str>`; D2 `speaker.map(str::to_string)` on the `Turn` (its comment rewritten); D3 `run_turn` gains `speaker` threaded through both `one_turn` calls; D4 `deliver` gains `speaker`; D5 `deliver` passes `speaker` to `run_turn`. G1 `Gateway` gains `usernames: HashMap`; G2 `new()` inits it. H1 `start` computes `speaker = self.speaker(&batch)` and passes `speaker.as_deref()` to `deliver`. H2 added `Gateway::speaker` (`batch.sender()?`; a cache hit returns it; else `client.username`, `Ok` inserts and returns, `Err` logs `"gatewayd: no username for {user}: {e}; the turn has no speaker"` and returns `None`). `approvals.rs` and `mm/rest.rs` untouched, the text still joins as before and the speaker never enters it. `cargo check -p gatewayd` clean after sessions; after deliver it fails only in `serve/handle.rs` at the `deliver` call (E0061/E0277) as the task predicts, then clean after serve. `sessions` 11 passed, `deliver_speaker` 3, `serve_speaker` 4, every gatewayd suite passes. Step 7: the grep for `speaker: None` prints nothing, `git diff --stat -- crates/gatewayd/src` lists exactly `deliver.rs`, `serve/handle.rs`, `serve/mod.rs`, `sessions.rs`, the other-crates diff is empty. `make gate` printed `gate: ok` with 1148 tests (1137 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD`; 1138 before this task plus the 10 new: 3 in `sessions.rs`, 3 in `deliver_speaker.rs`, 4 in `serve_speaker.rs`). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | diff --git a/docs/runbook.md b/docs/runbook.md index 7b01439..b477ba1 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -872,3 +872,54 @@ identity entry that breaks a new refuse rule leaves the memory block at once (se **Check.** `bxctl sanitize check` with the text gives the new answer (nothing, and exit 0, for a text that must pass). After the rebuild, the agent's next write of it is answered as expected. + +## 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.]` 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.]` is not `honcho_trusted`, `honcho_untrusted`, `honcho_fleet` or + `honcho_read_` 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\.' +``` + +**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 ` prints `memoryd: serving`.