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
+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;