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:
@@ -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_<agent>`;
|
||||
- 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<Client, String>` and
|
||||
`key(config, name, env, warnings) -> Result<Option<secrets::Secret>, 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<OsString>,
|
||||
timeouts: ReadTimeouts) -> Result<(Readers, Vec<String>), 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.<name>]`, 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.<name>]`, 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<Mutex<TurnSync>>,
|
||||
clients: Clients,
|
||||
```
|
||||
|
||||
become
|
||||
|
||||
```rust
|
||||
pub struct Opened {
|
||||
pub sync: Arc<Mutex<TurnSync>>,
|
||||
/// The workspaces `recall` and `ask_memory` read (M5c spec, sections 8 and 9).
|
||||
pub readers: Arc<Readers>,
|
||||
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<String> = 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.
|
||||
@@ -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; `<source>: 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, `<workspace>: 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
|
||||
|
||||
|
||||
@@ -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(§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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user