memoryd: [honcho] and its keys checked at start, and brokerd's state socket

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 11:20:10 -07:00
co-authored by ornith-1.5-35b-a3b
parent 9b4e9807b2
commit 129a9eadc3
12 changed files with 1206 additions and 6 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ publish.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
bxcore.workspace = true bxcore = { workspace = true, features = ["secrets"] }
roncho.workspace = true roncho.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
serde.workspace = true serde.workspace = true
+28 -2
View File
@@ -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 //! 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. //! 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 std::path::{Path, PathBuf};
use bxcore::DataClass; use bxcore::DataClass;
use bxcore::secrets::SecretSpec;
use serde::Deserialize; use serde::Deserialize;
use crate::honcho::config::{self as honcho, HonchoConfig, HonchoSection};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)] #[serde(deny_unknown_fields, default)]
pub struct Paths { pub struct Paths {
@@ -65,6 +70,9 @@ pub struct Sockets {
pub loopd: PathBuf, pub loopd: PathBuf,
pub broker: PathBuf, pub broker: PathBuf,
pub admin: 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)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
@@ -74,12 +82,18 @@ pub struct Config {
pub memory: Memory, pub memory: Memory,
pub index: Index, pub index: Index,
pub sockets: Sockets, pub sockets: Sockets,
/// `[honcho]` as written; `honcho_config` checks it. Without it, Honcho is off.
pub honcho: Option<HonchoSection>,
/// `[secrets.<name>]`: the Honcho keys, checked by `honcho_config`, never read here.
pub secrets: BTreeMap<String, SecretSpec>,
} }
#[derive(Debug)] #[derive(Debug)]
pub enum ConfigError { pub enum ConfigError {
Read(PathBuf, std::io::Error), Read(PathBuf, std::io::Error),
Parse(PathBuf, toml::de::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 { impl std::fmt::Display for ConfigError {
@@ -87,6 +101,7 @@ impl std::fmt::Display for ConfigError {
match self { match self {
ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()), ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()),
ConfigError::Parse(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) 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<Config, ConfigError> { pub fn load(path: &Path) -> Result<Config, ConfigError> {
let text = let text =
std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?; std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?;
let mut config = let mut config =
Config::parse(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?; 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() { if config.memory.soul_starter.is_relative() {
let base = path.parent().unwrap_or_else(|| Path::new(".")); let base = path.parent().unwrap_or_else(|| Path::new("."));
config.memory.soul_starter = base.join(&config.memory.soul_starter); config.memory.soul_starter = base.join(&config.memory.soul_starter);
@@ -111,6 +129,11 @@ impl Config {
Ok(config) Ok(config)
} }
/// `[honcho]` and its keys, checked: `Ok(None)` when there is no `[honcho]` and no key.
pub fn honcho_config(&self) -> Result<Option<HonchoConfig>, String> {
honcho::check(self.honcho.as_ref(), &self.secrets)
}
fn socket(&self, set: &Path, default: &str) -> PathBuf { fn socket(&self, set: &Path, default: &str) -> PathBuf {
if set.as_os_str().is_empty() { if set.as_os_str().is_empty() {
self.paths.home.join(default) self.paths.home.join(default)
@@ -128,6 +151,9 @@ impl Config {
pub fn admin_socket(&self) -> PathBuf { pub fn admin_socket(&self) -> PathBuf {
self.socket(&self.sockets.admin, "run/owner-memory/admin.sock") 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 { pub fn memory_dir(&self) -> PathBuf {
self.paths.home.join("memory") self.paths.home.join("memory")
} }
+308
View File
@@ -0,0 +1,308 @@
//! `[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 {
!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<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> {
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::<u16>().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 = [
(&section.trusted, &section.untrusted),
(&section.trusted, &section.fleet),
(&section.untrusted, &section.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<ReadWorkspace, String> {
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<Trust, String> {
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<DataClass, String> {
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.<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> {
let mut keys: BTreeMap<String, SecretSource> = 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_<agent>"
));
}
let source = spec
.source()
.map_err(|why| format!("[secrets.{name}] {why}"))?;
keys.insert(name.clone(), source);
}
Ok(keys)
}
+3
View File
@@ -0,0 +1,3 @@
//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec).
pub mod config;
+1
View File
@@ -4,6 +4,7 @@ pub mod config;
pub mod edit; pub mod edit;
pub mod entries; pub mod entries;
pub mod files; pub mod files;
pub mod honcho;
pub mod index; pub mod index;
pub mod journal; pub mod journal;
pub mod notes; pub mod notes;
+7 -2
View File
@@ -6,7 +6,8 @@ use std::process::ExitCode;
use std::sync::Arc; use std::sync::Arc;
use bxcore::instance; use bxcore::instance;
use memoryd::config::Config; use memoryd::config::{Config, ConfigError};
use memoryd::honcho::config::RUNBOOK as HONCHO_INVALID;
use memoryd::serve; use memoryd::serve;
const USAGE: &str = "usage: memoryd serve --config <path>"; const USAGE: &str = "usage: memoryd serve --config <path>";
@@ -21,7 +22,11 @@ fn main() -> ExitCode {
let config = match Config::load(&path) { let config = match Config::load(&path) {
Ok(config) => config, Ok(config) => config,
Err(e) => { 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); return ExitCode::from(1);
} }
}; };
+229
View File
@@ -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"
);
}
+338
View File
@@ -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)"
);
}
+238
View File
@@ -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}");
}
+1 -1
View File
@@ -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. | | `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` | 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. | | `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. | | `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. | | `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. |
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| 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 <path>` 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/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 <path>` 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<String>`; 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/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<String>`; 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<String,String>`; 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 | | 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<String,String>`; 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 |
+51
View File
@@ -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 **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. 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.<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`.