M5c-2 plan: task 05, memoryd's readers

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 18:31:51 -07:00
co-authored by Claude Opus 5.5
parent c6f43002a8
commit 9a4f0c5875
6 changed files with 1706 additions and 1 deletions
@@ -0,0 +1,226 @@
//! The Honcho workspaces this agent reads (M5c spec, sections 3, 8 and 9): each `[[honcho.read]]`
//! workspace, the fleet's, and the agent's own two, which only `ask_memory` asks. Each has a
//! client for searches and one for chat, whose answers take far longer, and the label its results
//! carry. Nothing here writes to Honcho, keeps a response or shows a key.
use std::ffi::OsString;
use std::time::Duration;
use bxcore::DataClass;
use bxcore::memory::OWN_WORKSPACE;
use roncho::blocking::Client;
use crate::honcho::client::{CHAT_READ, CONNECT, Timeouts, client, key};
use crate::honcho::config::{FLEET_KEY, HonchoConfig, TRUSTED_KEY, Trust, UNTRUSTED_KEY, read_key};
/// How long a search may go without a byte (M5c spec, section 8).
pub const SEARCH_READ: Duration = Duration::from_secs(5);
/// The names `ask_memory` gives the agent's own untrusted workspace and the fleet's. The own
/// trusted one is `bxcore::memory::OWN_WORKSPACE`, `own`.
pub const UNTRUSTED: &str = "untrusted";
pub const FLEET: &str = "fleet";
/// The timeouts the read clients are built with. Tests shorten them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadTimeouts {
pub connect: Duration,
pub search: Duration,
pub chat: Duration,
}
impl ReadTimeouts {
pub const DEFAULT: ReadTimeouts = ReadTimeouts {
connect: CONNECT,
search: SEARCH_READ,
chat: CHAT_READ,
};
}
/// What a reader reads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
/// Another agent's workspace, from `[[honcho.read]]`, by the agent's name.
Agent(String),
Fleet,
/// This agent's own trusted workspace, and its own untrusted one: `ask_memory` only.
Own,
OwnUntrusted,
}
/// What a reader's results are labelled with: the name each hit shows, whether the owner trusts
/// the workspace, and its class.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Label {
pub name: String,
pub trust: Trust,
pub class: DataClass,
}
impl Label {
/// The tag on each hit, as a local `recall` hit has: `memory` when trusted, else
/// `memory-tainted`.
pub fn tag(&self) -> &'static str {
match self.trust {
Trust::Trusted => "memory",
Trust::Untrusted => "memory-tainted",
}
}
/// Whether a result makes the session untrusted.
pub fn untrusted(&self) -> bool {
self.trust == Trust::Untrusted
}
}
/// One workspace this agent reads: what it is, its label, and its two clients.
#[derive(Debug, Clone)]
pub struct Reader {
pub source: Source,
pub label: Label,
search: Client,
chat: Client,
}
impl Reader {
/// The client for searches and conclusion queries, with the short read timeout.
pub fn search(&self) -> &Client {
&self.search
}
/// The client for workspace chat, with the long read timeout.
pub fn chat(&self) -> &Client {
&self.chat
}
}
/// Every workspace this agent reads, built at start with `[honcho]`. Shared by the threads that
/// search and ask (`roncho`'s blocking client is `Send + Sync`).
#[derive(Debug)]
pub struct Readers {
read: Vec<Reader>,
fleet: Reader,
own: Reader,
own_untrusted: Reader,
}
/// What every reader is built from, and the key files' warnings so far.
struct Build<'a> {
config: &'a HonchoConfig,
env: &'a dyn Fn(&str) -> Option<OsString>,
timeouts: ReadTimeouts,
warnings: Vec<String>,
}
impl Build<'_> {
/// A reader of `workspace`: both clients, with the key named `key_name` when one is
/// configured, read once.
fn reader(
&mut self,
workspace: &str,
key_name: &str,
source: Source,
label: Label,
) -> Result<Reader, String> {
let secret = key(self.config, key_name, self.env, &mut self.warnings)?;
let search = Timeouts {
connect: self.timeouts.connect,
read: self.timeouts.search,
};
let chat = Timeouts {
connect: self.timeouts.connect,
read: self.timeouts.chat,
};
let url = &self.config.url;
Ok(Reader {
source,
label,
search: client(url, workspace, secret.as_ref(), search)?,
chat: client(url, workspace, secret.as_ref(), chat)?,
})
}
}
/// A `private` label.
fn private(name: &str, trust: Trust) -> Label {
Label {
name: name.to_string(),
trust,
class: DataClass::Private,
}
}
impl Readers {
/// A reader for each `[[honcho.read]]` workspace, in the order written, then the fleet's and
/// the agent's own two. The warnings are the key files' plaintext warnings, for the caller to
/// log. An error names the key or the workspace, never a key's value.
pub fn new(
config: &HonchoConfig,
env: &dyn Fn(&str) -> Option<OsString>,
timeouts: ReadTimeouts,
) -> Result<(Readers, Vec<String>), String> {
let mut build = Build {
config,
env,
timeouts,
warnings: Vec::new(),
};
let mut read = Vec::new();
for entry in &config.read {
let label = Label {
name: entry.agent.clone(),
trust: entry.trust,
class: entry.class,
};
let source = Source::Agent(entry.agent.clone());
read.push(build.reader(&entry.workspace, &read_key(&entry.agent), source, label)?);
}
let fleet = private(FLEET, Trust::Untrusted);
let fleet = build.reader(&config.fleet, FLEET_KEY, Source::Fleet, fleet)?;
let own = private(OWN_WORKSPACE, Trust::Trusted);
let own = build.reader(&config.trusted, TRUSTED_KEY, Source::Own, own)?;
let own_untrusted = private(UNTRUSTED, Trust::Untrusted);
let own_untrusted = build.reader(
&config.untrusted,
UNTRUSTED_KEY,
Source::OwnUntrusted,
own_untrusted,
)?;
let readers = Readers {
read,
fleet,
own,
own_untrusted,
};
Ok((readers, build.warnings))
}
/// What `recall` searches: the `[[honcho.read]]` workspaces in the order written, then the
/// fleet's. Never the agent's own (M5c spec, section 8): the local index holds their text.
pub fn recall_sources(&self) -> Vec<&Reader> {
self.read
.iter()
.chain(std::iter::once(&self.fleet))
.collect()
}
/// The reader `ask_memory` names `name`: `own`, `untrusted`, `fleet`, or a `[[honcho.read]]`
/// agent. Any other name is the sentence the model is shown, listing the names it may use.
pub fn resolve(&self, name: &str) -> Result<&Reader, String> {
match name {
OWN_WORKSPACE => return Ok(&self.own),
UNTRUSTED => return Ok(&self.own_untrusted),
FLEET => return Ok(&self.fleet),
_ => {}
}
if let Some(reader) = self.read.iter().find(|r| r.label.name == name) {
return Ok(reader);
}
let mut names = vec![OWN_WORKSPACE, UNTRUSTED, FLEET];
names.extend(self.read.iter().map(|r| r.label.name.as_str()));
let (last, rest) = names.split_last().unwrap_or((&FLEET, &[]));
Err(format!(
"no workspace is named {name}; ask {} or {last}",
rest.join(", ")
))
}
}
@@ -0,0 +1,439 @@
//! `memoryd`'s Honcho clients (M5c spec, sections 3 and 6), against a fake Honcho that answers
//! from the responses recorded from rift: the workspace, the peers and the session made before
//! anything is written, each once per run, with the fleet's settings and the agent unobserved;
//! the errors by kind and status, never a body. The keys are in `honcho_keys.rs`. Do not edit.
#[path = "support/honcho.rs"]
mod honcho;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::time::Duration;
use honcho::{FakeHoncho, Mode, Request};
use memoryd::honcho::client::{
CHAT_READ, CONNECT, Clients, HonchoError, READ, Timeouts, classify, fleet_configuration,
};
use memoryd::honcho::config::HonchoConfig;
use memoryd::honcho::ledger::Workspace;
use memoryd::honcho::naming::HonchoSession;
use serde_json::{Value, json};
const FAST: Timeouts = Timeouts {
connect: Duration::from_secs(5),
read: Duration::from_millis(300),
};
fn config(url: &str) -> HonchoConfig {
HonchoConfig {
url: url.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(),
}
}
fn no_env(_: &str) -> Option<OsString> {
None
}
fn clients(fake: &FakeHoncho) -> Clients {
Clients::new(&config(&fake.url), &no_env, FAST).unwrap().0
}
fn session(name: &str, speakers: &[&str]) -> HonchoSession {
HonchoSession {
name: name.to_string(),
speakers: speakers.iter().map(|s| s.to_string()).collect(),
}
}
/// A request as (method, path, body).
fn seen(requests: &[Request]) -> Vec<(String, String, Value)> {
requests
.iter()
.map(|r| {
(
r.method.clone(),
r.path.clone(),
r.body.clone().unwrap_or(Value::Null),
)
})
.collect()
}
fn post(path: &str, body: Value) -> (String, String, Value) {
("POST".to_string(), path.to_string(), body)
}
#[test]
fn the_timeouts() {
assert_eq!(CONNECT, Duration::from_secs(5));
assert_eq!(READ, Duration::from_secs(30));
assert_eq!(CHAT_READ, Duration::from_secs(150));
assert_eq!(
Timeouts::WRITES,
Timeouts {
connect: CONNECT,
read: READ
}
);
}
#[test]
fn each_client_has_its_workspace_and_timeouts() {
let fake = FakeHoncho::start();
let c = clients(&fake);
for (workspace, name) in [
(Workspace::Trusted, "boxmaker0-trusted"),
(Workspace::Untrusted, "boxmaker0-untrusted"),
(Workspace::Fleet, "fleet"),
] {
let client = c.client(workspace);
assert_eq!(client.workspace_id(), name);
// `roncho`'s `Debug` shows the timeouts, and whether there is a key, never the key.
let shown = format!("{client:?}");
assert!(shown.contains("connect_timeout: 5s"), "{shown}");
assert!(shown.contains("read_timeout: 300ms"), "{shown}");
assert!(shown.contains("api_key_present: false"), "{shown}");
}
}
#[test]
fn the_fleets_configuration_is_the_recorded_one() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/honcho/01-workspace-create.json"
);
let recorded: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(
Value::Object(fleet_configuration()),
recorded["request"]["body"]["configuration"]
);
}
#[test]
fn a_session_is_made_after_its_workspace_and_peers() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
c.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
let w = "/v3/workspaces/boxmaker0-trusted";
assert_eq!(
seen(&fake.requests()),
vec![
post("/v3/workspaces", json!({ "id": "boxmaker0-trusted" })),
post(
&format!("{w}/peers"),
json!({ "id": "boxmaker0", "configuration": { "observe_me": false } })
),
post(&format!("{w}/peers"), json!({ "id": "kyle" })),
post(
&format!("{w}/sessions"),
json!({ "id": "chat-1", "peers": {
"boxmaker0": { "observe_me": false, "observe_others": false },
"kyle": { "observe_me": true, "observe_others": false },
} })
),
]
);
}
#[test]
fn what_was_made_is_not_asked_for_again_in_this_run() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
c.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
fake.clear();
c.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
assert!(fake.requests().is_empty(), "{:?}", fake.requests());
// A new speaker: only its peer and the next session.
c.ensure_session(Workspace::Trusted, &session("chat-1-2", &["kyle", "ana_b"]))
.unwrap();
let w = "/v3/workspaces/boxmaker0-trusted";
assert_eq!(
seen(&fake.requests()),
vec![
post(&format!("{w}/peers"), json!({ "id": "ana_b" })),
post(
&format!("{w}/sessions"),
json!({ "id": "chat-1-2", "peers": {
"boxmaker0": { "observe_me": false, "observe_others": false },
"kyle": { "observe_me": true, "observe_others": false },
"ana_b": { "observe_me": true, "observe_others": false },
} })
),
]
);
// Another workspace is made sure of on its own.
fake.clear();
c.ensure_session(Workspace::Untrusted, &session("chat-1", &["kyle"]))
.unwrap();
assert_eq!(fake.requests().len(), 4);
assert!(
fake.requests()
.iter()
.skip(1)
.all(|r| r.path.starts_with("/v3/workspaces/boxmaker0-untrusted/"))
);
}
#[test]
fn after_a_restart_everything_is_made_sure_of_again() {
let fake = FakeHoncho::start();
clients(&fake)
.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
fake.clear();
// Honcho answers 200 now: it has them all. That is success too.
clients(&fake)
.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
assert_eq!(fake.requests().len(), 4);
}
#[test]
fn the_fleet_is_made_with_its_settings_and_the_agent_alone() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
c.ensure_session(Workspace::Fleet, &session("chat-1", &[]))
.unwrap();
assert_eq!(
seen(&fake.requests()),
vec![
post(
"/v3/workspaces",
json!({ "id": "fleet", "configuration": {
"reasoning": { "enabled": false },
"summary": { "enabled": false },
"dream": { "enabled": false },
} })
),
post(
"/v3/workspaces/fleet/peers",
json!({ "id": "boxmaker0", "configuration": { "observe_me": false } })
),
post(
"/v3/workspaces/fleet/sessions",
json!({ "id": "chat-1", "peers": {
"boxmaker0": { "observe_me": false, "observe_others": false },
} })
),
]
);
}
#[test]
fn a_409_on_create_means_it_is_there() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
fake.set(Mode::Status(409));
c.ensure_workspace(Workspace::Trusted).unwrap();
c.ensure_peer(Workspace::Trusted, "kyle").unwrap();
fake.clear();
c.ensure_workspace(Workspace::Trusted).unwrap();
assert!(fake.requests().is_empty());
}
#[test]
fn errors_by_kind_and_status() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
let cases = [
(
500,
HonchoError::Unreachable {
kind: "server",
status: Some(500),
},
),
(
503,
HonchoError::Unreachable {
kind: "server",
status: Some(503),
},
),
(401, HonchoError::KeyRefused { status: 401 }),
(403, HonchoError::KeyRefused { status: 403 }),
(
400,
HonchoError::Refused {
kind: "status",
status: Some(400),
},
),
(
404,
HonchoError::Refused {
kind: "status",
status: Some(404),
},
),
(
422,
HonchoError::Refused {
kind: "status",
status: Some(422),
},
),
];
for (status, expected) in cases {
fake.set(Mode::Status(status));
assert_eq!(
c.ensure_workspace(Workspace::Trusted),
Err(expected.clone())
);
assert_eq!(c.ensure_peer(Workspace::Fleet, "kyle"), Err(expected));
}
// Nothing failed is remembered: the next call asks again, and succeeds.
fake.set(Mode::Fixtures);
fake.clear();
c.ensure_workspace(Workspace::Trusted).unwrap();
assert_eq!(fake.requests().len(), 1);
fake.set(Mode::Hang);
assert_eq!(
c.ensure_peer(Workspace::Trusted, "ana_b"),
Err(HonchoError::Unreachable {
kind: "timeout",
status: None
})
);
}
#[test]
fn nothing_listening_is_unreachable() {
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
};
let (mut c, _) =
Clients::new(&config(&format!("http://127.0.0.1:{port}")), &no_env, FAST).unwrap();
assert_eq!(
c.ensure_workspace(Workspace::Trusted),
Err(HonchoError::Unreachable {
kind: "connect",
status: None
})
);
}
#[test]
fn an_error_never_holds_the_response() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
fake.set(Mode::Status(422));
let err = c
.ensure_peer(Workspace::Trusted, "private-note-text")
.unwrap_err();
let body = fake.requests()[0].body.clone().unwrap();
assert_eq!(body["id"], "private-note-text", "the fake did not echo it");
for shown in [err.to_string(), format!("{err:?}")] {
assert!(!shown.contains("private-note-text"), "{shown}");
}
assert_eq!(
err.to_string(),
"Honcho refused the request (status, HTTP 422)"
);
assert_eq!((err.kind(), err.status()), ("status", Some(422)));
let lines = [
HonchoError::Unreachable {
kind: "timeout",
status: None,
}
.to_string(),
HonchoError::KeyRefused { status: 401 }.to_string(),
];
assert_eq!(lines[0], "Honcho is unreachable (timeout)");
assert_eq!(lines[1], "Honcho refused the key (key-refused, HTTP 401)");
}
#[test]
fn every_roncho_error_is_classified() {
use roncho::blocking::Error;
let io = || std::io::Error::other("x");
let cases = [
(Error::Connect(io()), "connect", None, true),
(Error::Timeout, "timeout", None, true),
(Error::Closed, "closed", None, true),
(Error::Io(io()), "io", None, true),
(
Error::Status {
status: 502,
body: "b".into(),
},
"server",
Some(502),
true,
),
(
Error::Status {
status: 499,
body: "b".into(),
},
"status",
Some(499),
false,
),
(
Error::Redirect { status: 301 },
"redirect",
Some(301),
false,
),
(
Error::ActiveSessions { body: "b".into() },
"active-sessions",
Some(409),
false,
),
(Error::TooLarge, "too-large", None, false),
(Error::Decode("b".into()), "decode", None, false),
(Error::Stream("b".into()), "stream", None, false),
(Error::Config("b".into()), "config", None, false),
];
for (error, kind, status, unreachable) in cases {
let got = classify(&error);
assert_eq!((got.kind(), got.status()), (kind, status), "{error:?}");
assert_eq!(
matches!(got, HonchoError::Unreachable { .. }),
unreachable,
"{error:?}"
);
}
let got = classify(&Error::Unauthorized { status: 403 });
assert_eq!(got, HonchoError::KeyRefused { status: 403 });
}
#[test]
fn a_conclusion_deleted_or_gone_is_done() {
let fake = FakeHoncho::start();
let c = clients(&fake);
c.delete_conclusion(Workspace::Trusted, "YUBMqIqHhuJprV4o-inrg")
.unwrap();
c.delete_conclusion(Workspace::Trusted, "YUBMqIqHhuJprV4o-inrg")
.unwrap();
let requests = fake.requests();
assert_eq!(requests.len(), 2);
assert!(requests.iter().all(|r| r.method == "DELETE"
&& r.path == "/v3/workspaces/boxmaker0-trusted/conclusions/YUBMqIqHhuJprV4o-inrg"));
fake.set(Mode::Status(500));
assert_eq!(
c.delete_conclusion(Workspace::Trusted, "x"),
Err(HonchoError::Unreachable {
kind: "server",
status: Some(500)
})
);
fake.set(Mode::Status(403));
assert_eq!(
c.delete_conclusion(Workspace::Trusted, "x"),
Err(HonchoError::KeyRefused { status: 403 })
);
}
@@ -0,0 +1,367 @@
//! The workspaces `memoryd` reads (M5c spec, sections 3, 8 and 9): a reader for each
//! `[[honcho.read]]` workspace, the fleet's and the agent's own two, each with its own key, a
//! short wait for searches and a long one for chat; the label its results carry; what `recall`
//! searches and the names `ask_memory` may use. Against a fake Honcho. Do not edit.
#[path = "support/honcho.rs"]
mod honcho;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::time::Duration;
use bxcore::DataClass;
use bxcore::secrets::SecretSource;
use honcho::{FakeHoncho, Mode};
use memoryd::honcho::client::{CHAT_READ, CONNECT, classify};
use memoryd::honcho::config::{
FLEET_KEY, HonchoConfig, HonchoSection, RESERVED, ReadSection, ReadWorkspace, TRUSTED_KEY,
Trust, check, read_key,
};
use memoryd::honcho::read::{
FLEET, Label, ReadTimeouts, Reader, Readers, SEARCH_READ, Source, UNTRUSTED,
};
use roncho::MessageSearch;
const FAST: ReadTimeouts = ReadTimeouts {
connect: Duration::from_secs(5),
search: Duration::from_millis(300),
chat: Duration::from_millis(900),
};
/// Key values built from pieces, so that no file holds one whole.
fn key(which: &str) -> String {
["hk", which, "7a31c0"].join("-")
}
fn read(agent: &str, trust: Trust, class: DataClass) -> ReadWorkspace {
ReadWorkspace {
agent: agent.to_string(),
workspace: format!("{agent}-trusted"),
trust,
class,
}
}
/// `boxmaker0`, reading `boxmaker1` (labelled trusted, `secret`) and `boxmaker2` (the defaults).
/// Keys for `boxmaker1`, the fleet and the own trusted workspace; none for the others.
fn config(url: &str) -> HonchoConfig {
let mut keys = BTreeMap::new();
for (name, var) in [
(read_key("boxmaker1"), "K_READ_1"),
(FLEET_KEY.to_string(), "K_FLEET"),
(TRUSTED_KEY.to_string(), "K_TRUSTED"),
] {
keys.insert(name, SecretSource::Env(var.to_string()));
}
HonchoConfig {
url: url.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![
read("boxmaker1", Trust::Trusted, DataClass::Secret),
read("boxmaker2", Trust::Untrusted, DataClass::Public),
],
keys,
}
}
fn env(name: &str) -> Option<OsString> {
match name {
"K_READ_1" => Some(OsString::from(key("read1"))),
"K_FLEET" => Some(OsString::from(key("fleet"))),
"K_TRUSTED" => Some(OsString::from(key("trusted"))),
_ => None,
}
}
fn readers(url: &str) -> Readers {
Readers::new(&config(url), &env, FAST).unwrap().0
}
/// Every reader: `recall`'s, then the own two.
fn all(r: &Readers) -> Vec<&Reader> {
let mut all = r.recall_sources();
all.push(r.resolve("own").unwrap());
all.push(r.resolve("untrusted").unwrap());
all
}
#[test]
fn a_reader_client_can_be_shared_across_threads() {
fn shared<T: Send + Sync>() {}
shared::<roncho::blocking::Client>();
shared::<Reader>();
shared::<Readers>();
}
#[test]
fn the_timeouts() {
assert_eq!(SEARCH_READ, Duration::from_secs(5));
assert_eq!(CHAT_READ, Duration::from_secs(150));
assert_eq!(
ReadTimeouts::DEFAULT,
ReadTimeouts {
connect: CONNECT,
search: SEARCH_READ,
chat: CHAT_READ
}
);
let fake = FakeHoncho::start();
let r = Readers::new(&config(&fake.url), &env, ReadTimeouts::DEFAULT)
.unwrap()
.0;
for reader in all(&r) {
// `roncho`'s `Debug` shows the timeouts, and whether there is a key, never the key.
let search = format!("{:?}", reader.search());
let chat = format!("{:?}", reader.chat());
for shown in [&search, &chat] {
assert!(shown.contains("connect_timeout: 5s"), "{shown}");
}
assert!(search.contains("read_timeout: 5s"), "{search}");
assert!(chat.contains("read_timeout: 150s"), "{chat}");
}
}
#[test]
fn each_reader_reads_its_own_workspace_with_its_own_key() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
let want = [
("boxmaker1-trusted", Some(key("read1"))),
("boxmaker2-trusted", None),
("fleet", Some(key("fleet"))),
("boxmaker0-trusted", Some(key("trusted"))),
("boxmaker0-untrusted", None),
];
let readers = all(&r);
assert_eq!(readers.len(), want.len());
for (reader, (workspace, _)) in readers.iter().zip(&want) {
assert_eq!(reader.search().workspace_id(), *workspace);
assert_eq!(reader.chat().workspace_id(), *workspace);
// The fake answers a search it has no recording for with 404: only the request matters.
let _ = reader
.search()
.search_workspace(&MessageSearch::new("launch"));
let _ = reader
.chat()
.search_workspace(&MessageSearch::new("launch"));
}
let requests = fake.requests();
assert_eq!(requests.len(), 2 * want.len());
for (n, request) in requests.iter().enumerate() {
let (workspace, secret) = &want[n / 2];
assert_eq!(request.path, format!("/v3/workspaces/{workspace}/search"));
let bearer = secret.as_ref().map(|k| format!("Bearer {k}"));
assert_eq!(
request.header("authorization").map(str::to_string),
bearer,
"{workspace}"
);
}
}
#[test]
fn no_key_is_shown_by_a_reader_or_an_error() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
fake.set(Mode::Status(401));
let mut shown = vec![format!("{r:?}")];
for reader in all(&r) {
let err = reader
.search()
.search_workspace(&MessageSearch::new("launch"))
.unwrap_err();
let err = classify(&err);
shown.push(format!("{err:?}"));
shown.push(err.to_string());
}
for text in shown {
for which in ["read1", "fleet", "trusted"] {
assert!(!text.contains(&key(which)), "{text}");
}
}
// A key that cannot be read stops the build, naming the key and never a value.
let mut config = config(&fake.url);
config.keys.insert(
read_key("boxmaker2"),
SecretSource::Env("K_MISSING".to_string()),
);
let err = Readers::new(&config, &env, FAST).unwrap_err();
assert!(err.contains("honcho_read_boxmaker2"), "{err}");
assert!(
err.contains("see docs/runbook.md#secret-unavailable"),
"{err}"
);
for which in ["read1", "fleet", "trusted"] {
assert!(!err.contains(&key(which)), "{err}");
}
}
/// `recall` searches the read workspaces, in the order written, then the fleet's; never the
/// agent's own two (M5c spec, section 8).
#[test]
fn recall_never_searches_the_agents_own_workspaces() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
let sources: Vec<Source> = r
.recall_sources()
.iter()
.map(|s| s.source.clone())
.collect();
assert_eq!(
sources,
[
Source::Agent("boxmaker1".to_string()),
Source::Agent("boxmaker2".to_string()),
Source::Fleet
]
);
let workspaces: Vec<&str> = r
.recall_sources()
.iter()
.map(|s| s.search().workspace_id())
.collect();
assert_eq!(
workspaces,
["boxmaker1-trusted", "boxmaker2-trusted", "fleet"]
);
let mut none = config(&fake.url);
none.read.clear();
let r = Readers::new(&none, &env, FAST).unwrap().0;
let sources: Vec<Source> = r
.recall_sources()
.iter()
.map(|s| s.source.clone())
.collect();
assert_eq!(sources, [Source::Fleet]);
}
fn label(name: &str, trust: Trust, class: DataClass) -> Label {
Label {
name: name.to_string(),
trust,
class,
}
}
/// A result is tagged `memory` only from a workspace the owner labelled trusted, or the agent's
/// own trusted one; the fleet and everything else is `memory-tainted`.
#[test]
fn each_source_has_its_label() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
let got: Vec<(Label, &str, bool)> = all(&r)
.iter()
.map(|reader| {
(
reader.label.clone(),
reader.label.tag(),
reader.label.untrusted(),
)
})
.collect();
assert_eq!(
got,
[
(
label("boxmaker1", Trust::Trusted, DataClass::Secret),
"memory",
false
),
(
label("boxmaker2", Trust::Untrusted, DataClass::Public),
"memory-tainted",
true
),
(
label("fleet", Trust::Untrusted, DataClass::Private),
"memory-tainted",
true
),
(
label("own", Trust::Trusted, DataClass::Private),
"memory",
false
),
(
label("untrusted", Trust::Untrusted, DataClass::Private),
"memory-tainted",
true
),
]
);
}
#[test]
fn ask_memory_names_resolve_to_their_readers() {
let fake = FakeHoncho::start();
let r = readers(&fake.url);
for (name, source, workspace) in [
("own", Source::Own, "boxmaker0-trusted"),
(UNTRUSTED, Source::OwnUntrusted, "boxmaker0-untrusted"),
(FLEET, Source::Fleet, "fleet"),
(
"boxmaker1",
Source::Agent("boxmaker1".to_string()),
"boxmaker1-trusted",
),
(
"boxmaker2",
Source::Agent("boxmaker2".to_string()),
"boxmaker2-trusted",
),
] {
let reader = r.resolve(name).unwrap();
assert_eq!(reader.source, source, "{name}");
assert_eq!(reader.chat().workspace_id(), workspace, "{name}");
}
for unknown in ["boxmaker9", "boxmaker0", "Own", "boxmaker1-trusted"] {
assert_eq!(
r.resolve(unknown).unwrap_err(),
format!(
"no workspace is named {unknown}; ask own, untrusted, fleet, boxmaker1 or boxmaker2"
)
);
}
let mut none = config(&fake.url);
none.read.clear();
let r = Readers::new(&none, &env, FAST).unwrap().0;
assert_eq!(
r.resolve("boxmaker1").unwrap_err(),
"no workspace is named boxmaker1; ask own, untrusted or fleet"
);
}
/// `own`, `untrusted` and `fleet` name this agent's workspaces in `ask_memory`, so no
/// `[[honcho.read]]` agent may be called that: the fleet can never be labelled trusted.
#[test]
fn no_read_agent_takes_a_reserved_name() {
assert_eq!(RESERVED, ["own", "untrusted", "fleet"]);
for agent in RESERVED {
let section = HonchoSection {
url: "http://127.0.0.1:9".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![ReadSection {
agent: agent.to_string(),
workspace: "someone-trusted".to_string(),
trust: Some("trusted".to_string()),
class: None,
}],
};
assert_eq!(
check(Some(&section), &BTreeMap::new()).unwrap_err(),
format!(
"[[honcho.read]] agent {agent:?} is a name ask_memory gives this agent's own \
workspaces: own, untrusted and fleet cannot be agents' names"
)
);
}
}
@@ -0,0 +1,262 @@
//! `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);
}
/// The readers are built at start (M5c-2): a read workspace's key that cannot be read stops
/// `memoryd`, as a written workspace's does.
#[test]
fn a_read_key_that_cannot_be_read_stops_memoryd() {
let extra = format!("{SECTION}[secrets.honcho_read_boxmaker1]\nenv = \"HONCHO_NOT_SET\"\n");
let home = Home::new("honcho-read-key", &extra);
let (code, stderr) = home.run_to_end();
assert_eq!(code, Some(1), "{stderr}");
assert!(stderr.contains("honcho_read_boxmaker1"), "{stderr}");
assert!(
stderr.contains("see docs/runbook.md#secret-unavailable"),
"{stderr}"
);
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);
// The writers and the readers both load the fleet's key file; its warning is logged once.
assert_eq!(
stderr
.matches("secret honcho_fleet is read in plaintext")
.count(),
1,
"{stderr}"
);
assert!(!stderr.contains(POINTER), "{stderr}");
}