diff --git a/docs/plans/M5c2/05-memoryd-honcho-readers.md b/docs/plans/M5c2/05-memoryd-honcho-readers.md new file mode 100644 index 0000000..a28a6ca --- /dev/null +++ b/docs/plans/M5c2/05-memoryd-honcho-readers.md @@ -0,0 +1,410 @@ +# M5c-2 task 05: `memoryd`'s readers, the Honcho workspaces it reads + +**Branch:** `m5c2`. Check it: `git branch --show-current` prints `m5c2` and `git status --short` +is empty; otherwise stop. Never switch branch. +**Commit subject:** `memoryd: the Honcho workspaces it reads, with their clients and labels` +**Log row:** the Task cell is `M5c2/05-memoryd-honcho-readers`. + +## Goal + +`memoryd` writes to three Honcho workspaces through the `Clients` of M5c-1 (the agent's trusted +and untrusted ones and the fleet's), which move into the sync worker's thread. M5c-2 also reads +(M5c spec, sections 3, 8 and 9): `recall` searches other agents' workspaces and the fleet's, and +`ask_memory` asks one or two workspaces by name. Reading needs clients of its own. This task +builds them at start and gives later tasks the types they use; nothing searches or asks yet +(tasks 07 and 08). + +- **`Readers`** (new module `crates/memoryd/src/honcho/read.rs`, given whole): a `Reader` for + - each `[[honcho.read]]` workspace, in the order written, with that entry's `trust` and + `class` and the key `honcho_read_`; + - the fleet's workspace (key `honcho_fleet`), always untrusted and `private`; + - the agent's own trusted workspace (key `honcho_trusted`), trusted, `private`, and its own + untrusted one (key `honcho_untrusted`), untrusted, `private`. These two are for `ask_memory` + only: `recall` never searches the agent's own workspaces (spec, section 8). +- **Two clients per reader.** A `roncho` client has one read timeout. Searches wait 5 s + (`SEARCH_READ`) and chat 150 s (`CHAT_READ`, which this task changes from 180 s: spec 9 gives + chat 150 s, inside `brokerd`'s 170 s); both connect within 5 s (`CONNECT`). +- **The label** of each reader's results: the name each hit shows (the agent's name, `fleet`, + `own` or `untrusted`), the trust, and the class. `Label::tag()` is `memory` for a trusted + workspace and `memory-tainted` otherwise, as a local `recall` hit's tag. +- **`recall_sources()`**: the read workspaces, then the fleet's. **`resolve(name)`**: the reader + `ask_memory` means by `own`, `untrusted`, `fleet` or a read agent's name; any other name is + the sentence the model is shown, which lists the names it may use. +- **Reserved names.** Since `ask_memory` names this agent's own workspaces `own`, `untrusted` + and `fleet`, a `[[honcho.read]]` agent with one of those names is now refused at start + (`honcho-config-invalid`). Otherwise a read agent called `fleet` would shadow the fleet in + `ask_memory`. +- **At start**, `start::open` builds the readers beside the writers' `Clients` and keeps them in + `Opened::readers`. A read key that cannot be read stops `memoryd`, as a written workspace's + does. The three written workspaces' keys are read by both; each key file's warning is logged + once. `serve.rs` does not change: nothing uses the readers until task 07, which takes + `opened.readers` before the worker's thread takes the rest of `Opened`. + +## Every place that changes + +This is the complete list. + +| Place | What happens there | +|---|---| +| `crates/memoryd/src/honcho/read.rs` | new, given whole: copy it | +| `crates/memoryd/src/honcho/mod.rs` | `pub mod read;` (edit H1) | +| `crates/memoryd/src/honcho/client.rs` | `CHAT_READ` is 150 s; `client` and `key` become `pub(crate)`, for `read.rs` (edits C1 to C3) | +| `crates/memoryd/src/honcho/config.rs` | `RESERVED`, and the check that uses it (edits G1, G2) | +| `crates/memoryd/src/honcho/start.rs` | `Opened::readers`, built in `open` (edits O1 to O3) | +| `docs/runbook.md`, `honcho-config-invalid` | the reserved names among the reasons (edit D1) | +| `crates/memoryd/tests/honcho_read.rs` | new, given | +| `crates/memoryd/tests/honcho_client.rs`, `honcho_start.rs` | new versions, given | +| everything else | **no change**: not `serve.rs`, not `recall.rs`, not `worker.rs`, no `Cargo.toml` | + +## Files + +- Copy, from `docs/plans/M5c2/files/` (the same path under it): the new test + `crates/memoryd/tests/honcho_read.rs`; **new versions** of (they replace the files in the tree) + `crates/memoryd/tests/honcho_client.rs` and `crates/memoryd/tests/honcho_start.rs`; and the + new source `crates/memoryd/src/honcho/read.rs`, which is complete: it has no `todo!()`, and you + do not change it. +- Modify: `crates/memoryd/src/honcho/mod.rs`, `crates/memoryd/src/honcho/client.rs`, + `crates/memoryd/src/honcho/config.rs`, `crates/memoryd/src/honcho/start.rs`, + `docs/runbook.md`, `docs/implementer-log.md`. +- Do not touch: any other file. + +## Facts you need + +- **`read.rs` needs** from `client.rs`: `CONNECT`, `CHAT_READ`, `Timeouts`, and the two helpers + `client(url, workspace, key, timeouts) -> Result` and + `key(config, name, env, warnings) -> Result, String>`, which are + private today. Edits C2 and C3 make them `pub(crate)`: visible in `memoryd`, not outside it. + From `config.rs` it needs `FLEET_KEY`, `TRUSTED_KEY`, `UNTRUSTED_KEY`, `read_key`, `Trust` and + `HonchoConfig`, all public already, and from `bxcore` `DataClass` and + `bxcore::memory::OWN_WORKSPACE` (`"own"`, task 01). +- **What `Readers` offers** (for tasks 07 and 08; read `read.rs` for the whole): + + ```rust + pub const SEARCH_READ: Duration; // 5 s + pub const UNTRUSTED: &str; // "untrusted" + pub const FLEET: &str; // "fleet" + pub struct ReadTimeouts { pub connect: Duration, pub search: Duration, pub chat: Duration } + pub enum Source { Agent(String), Fleet, Own, OwnUntrusted } + pub struct Label { pub name: String, pub trust: Trust, pub class: DataClass } + impl Label { pub fn tag(&self) -> &'static str; pub fn untrusted(&self) -> bool } + pub struct Reader { pub source: Source, pub label: Label, /* two clients */ } + impl Reader { pub fn search(&self) -> &Client; pub fn chat(&self) -> &Client } + impl Readers { + pub fn new(config: &HonchoConfig, env: &dyn Fn(&str) -> Option, + timeouts: ReadTimeouts) -> Result<(Readers, Vec), String>; + pub fn recall_sources(&self) -> Vec<&Reader>; + pub fn resolve(&self, name: &str) -> Result<&Reader, String>; + } + ``` + +- **`roncho::blocking::Client`** is `Clone`, `Send` and `Sync` (its documentation says so, and + a given test checks it at compile time), and its `Debug` shows the timeouts and whether a key + is present, never the key. This task's table of `roncho`'s API is complete; `roncho`'s source + (the Cargo registry, `~/.cargo/registry`) is outside the repository and off limits. +- **Why the warnings are filtered.** `Clients::new` and `Readers::new` each read the keys of + the three written workspaces, and each returns a key file's plaintext warning. Edit O3 logs + the writers' warnings, then only those of the readers' that are not among them. +- **The given tests.** + - `honcho_read.rs` (8 tests), against the fake Honcho: the client and the readers are + `Send + Sync`; the timeouts, read from each client's `Debug`; each reader searches its own + workspace with its own key (the fake records each request's `Authorization` header), and no + header without a key; no key in a reader's `Debug`, an error, or the error of a key that + cannot be read; `recall_sources` never holds the agent's own workspaces; each source's + label and tag; `resolve` for every name, and the exact sentence for an unknown one; a read + agent named `own`, `untrusted` or `fleet` refused by `config::check`, with its sentence. + - `honcho_client.rs`: one line changes, `CHAT_READ` is 150 s. + - `honcho_start.rs`: a new test, `a_read_key_that_cannot_be_read_stops_memoryd`, and one new + assertion in `a_valid_section_with_keys_starts_memoryd_as_before`: the fleet key file's + warning is printed once. +- The items the edits use. This table is complete. + + | Item | Where | + |---|---| + | `Readers::new`, `ReadTimeouts::DEFAULT` (from `read.rs`) | O3 | + | `StartError::Secret(String)` | O3, as `Clients::new`'s error beside it | + | `Vec::contains`, `Iterator::filter`, `Iterator::chain` | O3 | + | `Arc::new` | O3 | + | `[&str; 3]::contains(&&str)`, `String::as_str` | G2: `RESERVED.contains(&entry.agent.as_str())` | + +## The edits, exactly + +Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file +when its turn comes (make them in order). Keep the fields in the order shown. + +### `crates/memoryd/src/honcho/mod.rs` + +**H1.** Add one line. The line + +```rust +pub mod naming; +``` + +becomes + +```rust +pub mod naming; +pub mod read; +``` + +### `crates/memoryd/src/honcho/client.rs` + +**C1.** The lines + +```rust +/// 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); +``` + +become + +```rust +/// 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); +``` + +**C2.** The lines + +```rust +/// One workspace's client: no `roncho.toml` read, the timeouts given, the key when there is one. +fn client( +``` + +become + +```rust +/// One workspace's client: no `roncho.toml` read, the timeouts given, the key when there is one. +pub(crate) fn client( +``` + +**C3.** The lines + +```rust +/// The key named `name` in `[secrets]`, if one is configured, read now. +fn key( +``` + +become + +```rust +/// The key named `name` in `[secrets]`, if one is configured, read now. +pub(crate) fn key( +``` + +### `crates/memoryd/src/honcho/config.rs` + +**G1.** The lines + +```rust +/// The names of the keys in `[secrets.]`, one per workspace. A read workspace's key is +``` + +become + +```rust +/// 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 +``` + +**G2.** In `read_workspace`, the lines (its spaces are checked: the sentence is one string split +across two lines with a `\` at the end of the first, and the given test compares it letter for +letter) + +```rust + "[[honcho.read]] agent {:?} is this agent ([honcho] agent)", + entry.agent + )); + } +``` + +become + +```rust + "[[honcho.read]] agent {:?} is this agent ([honcho] agent)", + 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 + )); + } +``` + +### `crates/memoryd/src/honcho/start.rs` + +**O1.** Add one line. The line + +```rust +use crate::honcho::mirror::Mirror; +``` + +becomes + +```rust +use crate::honcho::mirror::Mirror; +use crate::honcho::read::{ReadTimeouts, Readers}; +``` + +**O2.** The lines + +```rust +pub struct Opened { + pub sync: Arc>, + clients: Clients, +``` + +become + +```rust +pub struct Opened { + pub sync: Arc>, + /// The workspaces `recall` and `ask_memory` read (M5c spec, sections 8 and 9). + pub readers: Arc, + clients: Clients, +``` + +**O3.** In `open`, the lines + +```rust + for warning in warnings { + (log)(&warning); + } + Ok(Opened { + sync: Arc::new(Mutex::new(sync)), + clients, +``` + +become + +```rust + let (readers, read_warnings) = + Readers::new(honcho, &env, ReadTimeouts::DEFAULT).map_err(StartError::Secret)?; + // The readers load the keys of the three written workspaces again: each warning once. + let read_warnings: Vec = 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, +``` + +### `docs/runbook.md` + +**D1.** In the entry `honcho-config-invalid`, the lines (its spaces are checked: the entry is +read by people, and `scripts/check-runbook.sh` reads the file) + +```text +- 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). +``` + +become + +```text +- 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). +``` + +## Steps + +- [ ] **1. Copy the given tests.** From the repository root, three commands: + + ```sh + cp docs/plans/M5c2/files/crates/memoryd/tests/honcho_read.rs crates/memoryd/tests/ + cp docs/plans/M5c2/files/crates/memoryd/tests/honcho_client.rs crates/memoryd/tests/ + cp docs/plans/M5c2/files/crates/memoryd/tests/honcho_start.rs crates/memoryd/tests/ + ``` + + Then `git status --short` lists `honcho_client.rs` and `honcho_start.rs` as modified and + `honcho_read.rs` as new, all under `crates/memoryd/tests/`. +- [ ] **2. See them fail.** Two commands, each with a shell timeout of 600000 ms: + - `cargo build --tests --keep-going -p memoryd`: one test target, `honcho_read`, does not + compile: `E0432` for ``unresolved import `memoryd::honcho::read` `` and for + ``unresolved import `memoryd::honcho::config::RESERVED` ``. Every other target compiles. + - `cargo test --no-fail-fast -p memoryd --test honcho_client --test honcho_start`: two tests + fail: `the_timeouts` in `honcho_client` (`CHAT_READ` is 180 s) and + `a_read_key_that_cannot_be_read_stops_memoryd` in `honcho_start` (`memoryd` does not read + that key yet, so it starts, and the test stops it after 10 s). +- [ ] **3. The new module and the edits.** Copy the module: + + ```sh + cp docs/plans/M5c2/files/crates/memoryd/src/honcho/read.rs crates/memoryd/src/honcho/ + ``` + + Then make edits H1, C1 to C3, G1, G2, O1 to O3 and D1, in order. `cargo check -p memoryd` + prints no error and no warning. +- [ ] **4. The tests.** With a shell timeout of 600000 ms, every test passes: 13 in + `honcho_client`, 8 in `honcho_config`, 3 in `honcho_keys` (and 1 ignored, as before), 8 in + `honcho_read`, 5 in `honcho_start`: + + ```sh + cargo test -p memoryd --test honcho_read --test honcho_client --test honcho_start \ + --test honcho_config --test honcho_keys + ``` +- [ ] **5. Check.** Two commands: + - `git diff --stat -- crates/memoryd/src` lists exactly `client.rs`, `config.rs`, `mod.rs` and + `start.rs` under `crates/memoryd/src/honcho/` (the new `read.rs` is untracked, so `git diff` + does not list it), and `git status --short crates/memoryd/src` shows `read.rs` as new. + - `git diff --stat -- crates/memoryd/src/serve.rs crates/memoryd/src/recall.rs Cargo.toml Cargo.lock` + prints nothing. +- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms. + Expected last line: `gate: ok`. The test count is the passed total of `cargo test --workspace` + plus one, for the ignored `end_to_end` test that the gate runs on its own: **1403** after this + task, 1394 before it; the 9 new ones are the 8 tests of `honcho_read.rs` and the new test in + `honcho_start.rs`. To count, add up the `N passed` of every `test result:` line the gate's + `cargo test --workspace` printed, and add 1. Never run the gate again only to count. +- [ ] **7. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is + `M5c2/05-memoryd-honcho-readers`. `git status --short` lists the four edited sources, the two + replaced tests, `docs/runbook.md` and your log as modified, and `read.rs` and `honcho_read.rs` + as new. Stage with `git add crates/memoryd docs/runbook.md docs/implementer-log.md Cargo.lock`, + then commit as `AGENTS.md` says. `Cargo.lock` does not change in this task; the `git add` is + harmless. + +## Done when + +- `make gate` prints `gate: ok` with 1403 tests. +- `git diff HEAD~1 --stat` lists only the four edited sources, `read.rs`, the three test files of + step 1, `docs/runbook.md` and `docs/implementer-log.md`. + +## Stop and report if + +- The branch check at the top fails. +- A "before" block is not in its file exactly as shown when its turn comes, or is there more + than once. +- Step 2 shows a different result: another target failing, other errors, other tests failing. +- `cargo check` in step 3 prints an error or a warning. Quote it, and stop. Do not change + `read.rs` to make it compile. +- A test fails in step 4. Do not change a test: stop, and give the failing tests' names and + their messages. +- The fix seems to need a change anywhere else: `serve.rs`, `recall.rs`, `worker.rs`, a change + to `read.rs`, a new dependency. It does not; stop and say why you think so. +- A test outside the given files fails in the gate: check once whether it fails without your + change (`git stash -u`, run it, `git stash pop`), then stop and report both results. +- The gate passes with a count other than 1403. +- A comment in an edit does not fit the code under it. Quote it and stop. + +## What the given tests pin + +Each of these changes to the finished code makes at least one given test fail (24 tried, all +caught): the search clients given chat's timeout, or chat's the search one; either timeout +constant changed; a read workspace given the fleet's key, the fleet the trusted key, or the own +untrusted workspace the trusted key; the own reader on the untrusted workspace; a key not sent; +a read workspace's trust or class ignored; the fleet trusted, or the own workspace untrusted; +the tags swapped, or `untrusted()` inverted; `recall_sources` holding the own workspace, or not +the fleet's; `untrusted` resolved to the trusted workspace; read agents not resolved, or +resolved by workspace instead of name; the sentence without the read agents' names; the +reserved names allowed; the readers' errors ignored at start; the key file's warning logged +twice. diff --git a/docs/plans/M5c2/README.md b/docs/plans/M5c2/README.md index 32fe317..dae227c 100644 --- a/docs/plans/M5c2/README.md +++ b/docs/plans/M5c2/README.md @@ -77,7 +77,7 @@ Honcho sources and chats outside its state lock, and answers `ask_memory`. | 02 | `brokerd`: `ask_memory` as a built-in memory tool (name, parsing, the grant `builtin-ask-memory`, owner grants naming it refused, routing to `memoryd`); its answer's labels raise the session | brokerd | | 03 | `brokerd`: a per-tool wait on `memoryd`, 170 s for `ask_memory` (`ASK_MEMORY_WAIT`) and 30 s for the others, chosen by `MemoryPort::wait_for` | brokerd | | 04 | `loopd`: `ask_memory`'s schema as a core tool in a new registry; a per-tool wait on `brokerd`, 180 s for `ask_memory` and `[broker] timeout_ms` for the others; the system prompt's line on memory tools | loopd | -| 05 | `memoryd`: read clients, one per `[[honcho.read]]` workspace, the fleet and the agent's own two, with a 5 s limit for searches and 150 s for chat | memoryd | +| 05 | `memoryd`: read clients, one per `[[honcho.read]]` workspace, the fleet and the agent's own two, with a 5 s limit for searches and 150 s for chat; each source's label; `recall_sources` and `resolve`; `own`, `untrusted` and `fleet` reserved as read agents' names | memoryd | | 06 | `memoryd`, pure: Honcho messages and conclusions as `recall` items (tag, source name, class, neutralised, capped), kept only when they share a term with the query, interleaved with the local hits to `k`, the local index first | memoryd | | 07 | `memoryd`: `recall`'s Honcho sources, each on its own thread with its own limit, after the local search and outside the lock; `: unavailable`; a query holding a secret sends nothing | memoryd | | 08 | `memoryd`: `ask_memory`: workspace names checked against `[honcho]`, both chats at once, each answer tagged and capped, labels, `: unavailable`; a question holding a secret sends nothing | memoryd | @@ -101,6 +101,7 @@ The gate count is the passed total of `cargo test --workspace` plus one, for the | 02 | 1381 | 17, 17 | | 03 | 1384 | 10, 10 | | 04 | 1394 | 19, 19 | +| 05 | 1403 | 24, 24 | ## Before hand-over diff --git a/docs/plans/M5c2/files/crates/memoryd/src/honcho/read.rs b/docs/plans/M5c2/files/crates/memoryd/src/honcho/read.rs new file mode 100644 index 0000000..822c44c --- /dev/null +++ b/docs/plans/M5c2/files/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/docs/plans/M5c2/files/crates/memoryd/tests/honcho_client.rs b/docs/plans/M5c2/files/crates/memoryd/tests/honcho_client.rs new file mode 100644 index 0000000..e42220a --- /dev/null +++ b/docs/plans/M5c2/files/crates/memoryd/tests/honcho_client.rs @@ -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 { + 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 }) + ); +} diff --git a/docs/plans/M5c2/files/crates/memoryd/tests/honcho_read.rs b/docs/plans/M5c2/files/crates/memoryd/tests/honcho_read.rs new file mode 100644 index 0000000..c274e04 --- /dev/null +++ b/docs/plans/M5c2/files/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/docs/plans/M5c2/files/crates/memoryd/tests/honcho_start.rs b/docs/plans/M5c2/files/crates/memoryd/tests/honcho_start.rs new file mode 100644 index 0000000..cff77a4 --- /dev/null +++ b/docs/plans/M5c2/files/crates/memoryd/tests/honcho_start.rs @@ -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 `/keys/honcho.key`. + fn new(tag: &str, extra: &str) -> Home { + let dir = TempDir::new(tag); + let soul = dir.write("etc/soul.md", "You are Boxmaker.\n"); + let key = dir.write("keys/honcho.key", &format!("{}\n", file_key())); + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap(); + let text = format!( + "[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\n{extra}", + dir.path().display(), + soul.display() + ); + let text = text.replace("KEY_FILE", &key.display().to_string()); + let config = dir.write("memoryd.toml", &text); + Home { dir, config } + } + + fn path(&self, relative: &str) -> PathBuf { + self.dir.path().join(relative) + } + + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_memoryd")); + command + .args(["serve", "--config"]) + .arg(&self.config) + .env("HONCHO_TEST_KEY", env_key()) + .stdin(Stdio::null()) + .stdout(Stdio::null()); + command + } + + /// Runs `memoryd serve` to its end, at most 10 s (then it is killed), and gives its exit code + /// (`None` if it had to be killed) and its stderr. + fn run_to_end(&self) -> (Option, String) { + let mut child = Daemon(self.command().stderr(Stdio::piped()).spawn().unwrap()); + let deadline = Instant::now() + Duration::from_secs(10); + let mut code = None; + while Instant::now() < deadline { + if let Ok(Some(status)) = child.0.try_wait() { + code = status.code(); + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = child.0.kill(); + let _ = child.0.wait(); + let mut stderr = String::new(); + if let Some(mut pipe) = child.0.stderr.take() { + let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr); + } + (code, stderr) + } + + /// `memoryd serve` with its stderr in `/memoryd.err`. + fn start(&self) -> Daemon { + let err = std::fs::File::create(self.path("memoryd.err")).unwrap(); + Daemon(self.command().stderr(Stdio::from(err)).spawn().unwrap()) + } + + fn stderr(&self) -> String { + std::fs::read_to_string(self.path("memoryd.err")).unwrap_or_default() + } +} + +/// A running `memoryd serve`, killed and waited for when dropped. Each test declares it after its +/// `Home`, so it drops first. +struct Daemon(Child); + +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// True once `memoryd` answers `memory_block` on `loopd`'s socket, within 10 s. +fn answers(home: &Home) -> bool { + let socket = home.path("run/loop-memory/memory.sock"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(mut stream) = UnixStream::connect(&socket) { + let limit = Some(Duration::from_secs(5)); + let ask = Envelope { + v: PROTOCOL_VERSION, + id: 1, + r#final: true, + msg: Message::MemoryBlock(Empty {}), + }; + if stream.set_read_timeout(limit).is_ok() + && write_frame(&mut stream, &ask).is_ok() + && matches!( + read_frame(&mut stream), + Ok(Envelope { + msg: Message::MemoryBlockReply(_), + .. + }) + ) + { + return true; + } + } + std::thread::sleep(Duration::from_millis(20)); + } + false +} + +fn assert_no_key(text: &str) { + assert!( + !text.contains(&env_key()), + "the env key was printed: {text}" + ); + assert!( + !text.contains(&file_key()), + "the file key was printed: {text}" + ); +} + +#[test] +fn an_invalid_section_stops_memoryd_with_its_reason_and_the_pointer() { + let extra = format!( + "{}[secrets.honcho_trusted]\nenv = \"HONCHO_TEST_KEY\"\n[secrets.honcho_fleet]\nfile = \"KEY_FILE\"\n", + SECTION.replace("owner = \"kyle\"", "owner = \"boxmaker0\"") + ); + let home = Home::new("honcho-bad", &extra); + let (code, stderr) = home.run_to_end(); + assert_eq!(code, Some(1), "{stderr}"); + assert_eq!( + stderr, + format!( + "memoryd: {}: [honcho] agent and owner are both \"boxmaker0\": the agent's peer must not be the owner's\n{POINTER}\n", + home.config.display() + ) + ); + assert_no_key(&stderr); + assert!( + !home.path("run").exists(), + "memoryd stopped before its lock and sockets" + ); + assert!(!home.path("memory").exists(), "and before its memory"); +} + +#[test] +fn a_key_for_an_agent_not_read_stops_memoryd() { + let extra = format!("{SECTION}[secrets.honcho_read_boxmaker2]\nenv = \"HONCHO_TEST_KEY\"\n"); + let home = Home::new("honcho-key", &extra); + let (code, stderr) = home.run_to_end(); + assert_eq!(code, Some(1), "{stderr}"); + assert_eq!( + stderr, + format!( + "memoryd: {}: [secrets.honcho_read_boxmaker2] is the key of agent \"boxmaker2\", which is not in [[honcho.read]]\n{POINTER}\n", + home.config.display() + ) + ); + assert_no_key(&stderr); +} + +/// 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}"); +}