diff --git a/crates/memoryd/src/honcho/client.rs b/crates/memoryd/src/honcho/client.rs index e5248ea..ccd6f75 100644 --- a/crates/memoryd/src/honcho/client.rs +++ b/crates/memoryd/src/honcho/client.rs @@ -19,8 +19,8 @@ use crate::honcho::naming::HonchoSession; /// How long a connection may take, and a read may go without a byte. pub const CONNECT: Duration = Duration::from_secs(5); pub const READ: Duration = Duration::from_secs(30); -/// A chat's read: a non-streamed chat sends nothing until it is done (M5c-2's `ask_memory`). -pub const CHAT_READ: Duration = Duration::from_secs(180); +/// A chat's read: a non-streamed chat sends nothing until it is done (M5c spec, section 9). +pub const CHAT_READ: Duration = Duration::from_secs(150); /// The timeouts a client is built with. Tests shorten them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -176,7 +176,7 @@ fn created(result: Result) -> Result<(), HonchoError> { } /// One workspace's client: no `roncho.toml` read, the timeouts given, the key when there is one. -fn client( +pub(crate) fn client( url: &str, workspace: &str, key: Option<&secrets::Secret>, @@ -197,7 +197,7 @@ fn client( } /// The key named `name` in `[secrets]`, if one is configured, read now. -fn key( +pub(crate) fn key( config: &HonchoConfig, name: &str, env: &dyn Fn(&str) -> Option, diff --git a/crates/memoryd/src/honcho/config.rs b/crates/memoryd/src/honcho/config.rs index 0818521..a36e175 100644 --- a/crates/memoryd/src/honcho/config.rs +++ b/crates/memoryd/src/honcho/config.rs @@ -14,6 +14,10 @@ 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 `ask_memory` gives this agent's own workspaces and the fleet's (M5c spec, section 9), +/// which no `[[honcho.read]]` agent may have. +pub const RESERVED: [&str; 3] = ["own", "untrusted", "fleet"]; + /// The names of the keys in `[secrets.]`, one per workspace. A read workspace's key is /// `READ_KEY` and its agent's name (`read_key`). pub const TRUSTED_KEY: &str = "honcho_trusted"; @@ -233,6 +237,13 @@ fn read_workspace( entry.agent )); } + if RESERVED.contains(&entry.agent.as_str()) { + return Err(format!( + "[[honcho.read]] agent {:?} is a name ask_memory gives this agent's own workspaces: \ + own, untrusted and fleet cannot be agents' names", + entry.agent + )); + } if earlier.iter().any(|r| r.agent == entry.agent) { return Err(format!( "[[honcho.read]] agent {:?} is listed twice", diff --git a/crates/memoryd/src/honcho/mod.rs b/crates/memoryd/src/honcho/mod.rs index ad2cddc..2a7fbb9 100644 --- a/crates/memoryd/src/honcho/mod.rs +++ b/crates/memoryd/src/honcho/mod.rs @@ -9,6 +9,7 @@ pub mod ledger; pub mod messages; pub mod mirror; pub mod naming; +pub mod read; pub mod report; pub mod start; pub mod state; diff --git a/crates/memoryd/src/honcho/read.rs b/crates/memoryd/src/honcho/read.rs new file mode 100644 index 0000000..822c44c --- /dev/null +++ b/crates/memoryd/src/honcho/read.rs @@ -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, + 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, + timeouts: ReadTimeouts, + warnings: Vec, +} + +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 { + 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, + timeouts: ReadTimeouts, + ) -> Result<(Readers, Vec), 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(", ") + )) + } +} diff --git a/crates/memoryd/src/honcho/start.rs b/crates/memoryd/src/honcho/start.rs index 902bf12..241721b 100644 --- a/crates/memoryd/src/honcho/start.rs +++ b/crates/memoryd/src/honcho/start.rs @@ -10,6 +10,7 @@ use crate::honcho::client::{Clients, Timeouts}; use crate::honcho::config::HonchoConfig; use crate::honcho::ledger::{Ledger, Record}; use crate::honcho::mirror::Mirror; +use crate::honcho::read::{ReadTimeouts, Readers}; use crate::honcho::state; use crate::honcho::turns::{LEDGER, TurnSync}; use crate::honcho::worker::{AskBox, Tuning, Wake, Worker}; @@ -19,6 +20,8 @@ use crate::serve::{Log, StartError}; /// clients with their keys read. Nothing is bound or started yet. pub struct Opened { pub sync: Arc>, + /// The workspaces `recall` and `ask_memory` read (M5c spec, sections 8 and 9). + pub readers: Arc, clients: Clients, agent: String, owner: String, @@ -65,11 +68,19 @@ pub fn open(config: &Config, honcho: &HonchoConfig, log: &Log) -> Result = read_warnings + .into_iter() + .filter(|w| !warnings.contains(w)) + .collect(); + for warning in warnings.into_iter().chain(read_warnings) { (log)(&warning); } Ok(Opened { sync: Arc::new(Mutex::new(sync)), + readers: Arc::new(readers), clients, agent: honcho.agent.clone(), owner: honcho.owner.clone(), diff --git a/crates/memoryd/tests/honcho_client.rs b/crates/memoryd/tests/honcho_client.rs index a8d5a64..e42220a 100644 --- a/crates/memoryd/tests/honcho_client.rs +++ b/crates/memoryd/tests/honcho_client.rs @@ -74,7 +74,7 @@ fn post(path: &str, body: Value) -> (String, String, Value) { fn the_timeouts() { assert_eq!(CONNECT, Duration::from_secs(5)); assert_eq!(READ, Duration::from_secs(30)); - assert_eq!(CHAT_READ, Duration::from_secs(180)); + assert_eq!(CHAT_READ, Duration::from_secs(150)); assert_eq!( Timeouts::WRITES, Timeouts { diff --git a/crates/memoryd/tests/honcho_read.rs b/crates/memoryd/tests/honcho_read.rs new file mode 100644 index 0000000..c274e04 --- /dev/null +++ b/crates/memoryd/tests/honcho_read.rs @@ -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 { + 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() {} + shared::(); + shared::(); + shared::(); +} + +#[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 = 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 = 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(§ion), &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" + ) + ); + } +} diff --git a/crates/memoryd/tests/honcho_start.rs b/crates/memoryd/tests/honcho_start.rs index d68db4b..cff77a4 100644 --- a/crates/memoryd/tests/honcho_start.rs +++ b/crates/memoryd/tests/honcho_start.rs @@ -204,6 +204,22 @@ fn a_key_for_an_agent_not_read_stops_memoryd() { 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\""); @@ -234,5 +250,13 @@ fn a_valid_section_with_keys_starts_memoryd_as_before() { 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}"); } diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 76c577c..41ef71e 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5c2/05-memoryd-honcho-readers | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_read.rs` (new), new versions of `crates/memoryd/tests/honcho_client.rs` and `honcho_start.rs` (they replace the tree's), and the new source `crates/memoryd/src/honcho/read.rs` (complete, no `todo!`). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed exactly the two named failures: `honcho_read` with one E0432 `unresolved import memoryd::honcho::config::RESERVED` and one E0432 `unresolved import memoryd::honcho::read`; every other target compiled. `cargo test --no-fail-fast` on `honcho_client` and `honcho_start` failed the two named tests: `the_timeouts` (`CHAT_READ` is 180 s) and `a_read_key_that_cannot_be_read_stops_memoryd` (memoryd starts instead of stopping). Made the edits exactly as the task gives: H1 (`pub mod read;` after `pub mod naming;` in `honcho/mod.rs`); C1 (`CHAT_READ` = 150 s, doc now `M5c spec, section 9`); C2 (`client` `pub(crate)`); C3 (`key` `pub(crate)`); G1 (`RESERVED` `["own", "untrusted", "fleet"]` before the key names, doc naming section 9); G2 (the reserved-names check in `read_workspace` after the this-agent check, its sentence split with `\` as shown); O1 (`use ...read::{ReadTimeouts, Readers}`); O2 (`Opened::readers` `Arc` after `sync`, before `clients`); O3 (`Readers::new` beside `Clients::new`, `StartError::Secret`, the readers' warnings filtered to those not among the writers' then logged once); D1 (the reserved names among the `honcho-config-invalid` reasons in `docs/runbook.md`). `cargo check -p memoryd` clean, no warning. `cargo test` passes: 8 in `honcho_read`, 13 in `honcho_client`, 8 in `honcho_config`, 3 in `honcho_keys` (1 ignored), 5 in `honcho_start`. `git diff --stat -- crates/memoryd/src` lists only `client.rs`, `config.rs`, `mod.rs` and `start.rs`; `git diff --stat -- crates/memoryd/src/serve.rs crates/memoryd/src/recall.rs crates/memoryd/src/worker.rs Cargo.toml Cargo.lock` prints nothing. `make gate` printed `gate: ok` with 1403 tests (1402 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately; the +9 over the 1394 before is exactly the 8 `honcho_read` tests and the 1 new `honcho_start` test). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/04-loopd-ask-memory | 2026-09-28 | done | 1 | pass | none | Copied the three given tests from `docs/plans/M5c2/files/crates/loopd/tests/`: `ask_memory_tool.rs` (new) and `broker_port_ask_wait.rs` (new), and a new version of `device.rs` (replaces the tree's). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the 14 named failures: `ask_memory_tool` with one E0432 `unresolved imports loopd::memory::ASK_MEMORY`, `loopd::memory::ask_memory_schema` and three E0599 for `m5c` on `Registry`; `broker_port_ask_wait` with one E0432 `unresolved import loopd::broker_port::ASK_MEMORY_WAIT`, five E0599 for `timeout_for` and three for `with_ask_wait`; `device` with one E0599 for `m5c`; every other target compiled. Made the edits exactly as the task gives: S1 (`ASK_MEMORY` = `bxcore::memory::ASK_MEMORY`), S2 (`ask_memory_schema` with `maxItems: 2`, description one string split with `\` as shown); T1 (`m5a()` comment drops `loopd serve`); T2 (`m5c()` inserts `ask_memory` core at index 3 after `m5a()`); N1 (`main.rs` uses `Registry::m5c()`); Y1 (`config/system.md` last sentence names `ask_memory`); P1 (`ASK_MEMORY_WAIT` 180 s before `POINTER`), P2 (`BrokerPort` gains `ask_timeout` after `timeout`), P3 (`new` sets `ask_timeout: ASK_MEMORY_WAIT`, adds `with_ask_wait` via struct update and `timeout_for` matching `ASK_MEMORY` -> `timeout.max(ask_timeout)` else `timeout`), P4 (`with_log` sets `ask_timeout: ASK_MEMORY_WAIT`), P5 (`call` binds `timeout = self.timeout_for(&request.tool)`, uses it for the first deadline), P6 (write timeout), P7 (the pending-frame extension). `cargo check -p loopd` clean, and `grep -n 'self.timeout'` prints the three expected lines. `cargo test` passes: 6 in `ask_memory_tool`, 4 in `broker_port_ask_wait`, 15 in `broker_port`, 2 in `broker_port_bad`, 2 in `broker_port_cap`, 4 in `memory`, 6 in `turn_broker`. `git diff --stat` lists only the five edited files and `device.rs`; `git diff config/system.md | grep -c '^[-+]You are'` prints 2. `make gate` printed `gate: ok` with 1394 tests (1393 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +10 over the 1384 before is exactly the 6 in `ask_memory_tool.rs` and 4 in `broker_port_ask_wait.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/03-brokerd-ask-memory-wait | 2026-09-28 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/memory_wait.rs` from `docs/plans/M5c2/files/crates/brokerd/tests/`. Step 2 (`cargo build --tests --keep-going -p brokerd`) showed exactly the 10 named failures: one E0432 `unresolved import brokerd::memory_port::ASK_MEMORY_WAIT`, six E0599 for `wait_for`, three E0599 for `with_ask_wait`; every other target compiled. Made edits W1 (module doc naming `ask_memory` and the `ask_memory` wait of its own), W2 (`use crate::args::ToolName`), W3 (`MEMORY_WAIT` doc now says `recall` or `memory`; added `ASK_MEMORY_WAIT` 170 s after it with no blank line between, `MemoryPort` gains `ask_wait` last, `new` sets `ask_wait: ASK_MEMORY_WAIT`, `with_ask_wait` via struct update, `wait_for` matches `AskMemory` -> `ask_wait` else `wait`), W4 (ask comment `self.wait_for(spec.tool())`), W5 (`ask` binds `wait = self.wait_for(spec.tool())` and uses it for both timeouts). `cargo check -p brokerd` clean. `cargo test -p brokerd --test memory_wait --test memory_port --test ask_memory_tool` passes: 3 in `memory_wait`, 5 in `memory_port`, 9 in `ask_memory_tool`. `git diff --stat` lists only `memory_port.rs`; `grep -n 'MEMORY_WAIT' crates/brokerd/src/main.rs` prints the `use` line and `MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT)`. `make gate` printed `gate: ok` with 1384 tests (1383 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +3 over the 1381 before is exactly the 3 new `memory_wait` tests). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/02-brokerd-ask-memory | 2026-09-28 | done | 1 | pass | none | Copied the given tests from `docs/plans/M5c2/files/crates/brokerd/tests/`: `ask_memory_tool.rs` (new) and new versions of `grants.rs` and `memory_tools.rs`. Step 2 (`cargo build --tests --keep-going -p brokerd`) showed exactly the 11 named failures: one E0432 `unresolved import brokerd::builtin::ASK_MEMORY`, one E0599 for `AskMemory` on `ToolArgs`, nine E0599 for `AskMemory` on `ToolName`; every other target compiled. `cargo test --no-fail-fast` on `grants` and `memory_tools` failed the two named lines (`an_empty_directory_is_a_valid_empty_set`, `grant_files_cannot_name_the_memory_tools_or_take_a_built_in_id`). Made edits A1 (`use bxcore::AskMemoryArgs`), A2 (`ToolName::AskMemory`), A3 (`ask_memory` in `parse`), A4 (`ask_memory` in `as_str`), A5 (`is_memory`), A6 (`ToolArgs::AskMemory`), A7 (`tool()`), A8 (`canonical_json`), A9 (`parse` decodes then runs `check`); B1 (module doc naming the three tools), B2 (`ASK_MEMORY` grant text, `BUILT_IN` sentence naming the three, `sources` with `builtin-ask-memory` last), C1 (`time_limit`), P1 (`matching` takes no constraints), R1 (`runner` no mounts/egress). `cargo check -p brokerd` clean. `cargo test` passes: 9 in `ask_memory_tool`, 17 in `grants`, 4 in `memory_tools`, 5 in `memory_port`, 8 in `runner`. `git diff --stat -- crates/brokerd/src` lists only the five files, not `grants.rs`/`container.rs`/`memory_port.rs`; the other-crates diff is empty. `make gate` printed `gate: ok` with 1381 tests (1380 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +9 over the 1372 before is exactly the 9 new `ask_memory_tool` tests). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | diff --git a/docs/runbook.md b/docs/runbook.md index 9acc371..0f20781 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -916,9 +916,10 @@ they do not belong in, so `memoryd` checks the section before it starts. The rea 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 `[[honcho.read]]` names this agent, names an agent twice, names an agent `own`, `untrusted` + or `fleet` (the names `ask_memory` gives this agent's own workspaces), or names one of this + agent's three workspaces; or its `trust` is not `trusted` or `untrusted`, or its `class` not + `public`, `private` or `secret` (all lower case). - A `[secrets.]` is not `honcho_trusted`, `honcho_untrusted`, `honcho_fleet` or `honcho_read_` for an agent in `[[honcho.read]]`; or it does not name exactly one well-formed `credential`, `env` or `file` (as in `secret-unavailable`); or there is no