brokerd: memory calls to memoryd with the session's state; labels only stricter
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -53,6 +53,14 @@ pub struct Gateway {
|
||||
pub socket: PathBuf,
|
||||
}
|
||||
|
||||
/// `[memory]`: where `memoryd` takes the decided `recall` and `memory` calls. The memory tools are
|
||||
/// always on, so the section is optional; an empty path means the default under `home`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Memory {
|
||||
pub socket: PathBuf,
|
||||
}
|
||||
|
||||
fn default_podman() -> PathBuf {
|
||||
PathBuf::from("podman")
|
||||
}
|
||||
@@ -141,6 +149,8 @@ pub struct Config {
|
||||
pub runner: Option<Runner>,
|
||||
#[serde(default)]
|
||||
pub gateway: Option<Gateway>,
|
||||
#[serde(default)]
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame.
|
||||
@@ -283,6 +293,14 @@ impl Config {
|
||||
Some(gateway.socket.clone())
|
||||
}
|
||||
}
|
||||
/// `memoryd`'s socket for `brokerd`.
|
||||
pub fn memory_socket(&self) -> PathBuf {
|
||||
if self.memory.socket.as_os_str().is_empty() {
|
||||
self.paths.home.join("run/broker-memory/memory.sock")
|
||||
} else {
|
||||
self.memory.socket.clone()
|
||||
}
|
||||
}
|
||||
pub fn audit_dir(&self) -> PathBuf {
|
||||
self.paths.home.join("audit")
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ pub const EGRESS_POLL: Duration = Duration::from_millis(20);
|
||||
/// A log sink. Call it as `(self.log)("a line")`.
|
||||
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
||||
|
||||
/// A memory tool that reached the container runtime: fixed, it reaches the model.
|
||||
pub const IN_MEMORYD: &str = "the memory tools run in memoryd, not in a container";
|
||||
|
||||
pub struct Podman {
|
||||
runner: Runner,
|
||||
egress_dir: PathBuf,
|
||||
@@ -240,6 +243,10 @@ impl Runtime for Podman {
|
||||
/// an egress starts the proxy first and gives the tool the directory. `run_container` is step 3
|
||||
/// to 7 for either. Already written.
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
// `Routed` sends the memory tools to `memoryd`; one here is refused, never run.
|
||||
if spec.tool().is_memory() {
|
||||
return Err(RunError::Unavailable(IN_MEMORYD.to_string()));
|
||||
}
|
||||
let n = self.next.fetch_add(1, Ordering::SeqCst);
|
||||
let name = podman::container_name(spec.session(), spec.call(), n);
|
||||
let input = spec.arguments().canonical_json();
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod container;
|
||||
pub mod courier;
|
||||
pub mod grants;
|
||||
pub mod ledger;
|
||||
pub mod memory_port;
|
||||
pub mod pipes;
|
||||
pub mod podman;
|
||||
pub mod policy;
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::Arc;
|
||||
use brokerd::audit::{AuditError, RECOVERED_NOTICE};
|
||||
use brokerd::config::Config;
|
||||
use brokerd::container::Podman;
|
||||
use brokerd::memory_port::{MEMORY_WAIT, MemoryPort, Routed};
|
||||
use brokerd::runner::{Refusing, Runtime};
|
||||
use brokerd::serve::{self, ServeError};
|
||||
|
||||
@@ -66,6 +67,12 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
// The memory tools go to `memoryd`, whatever runs the others (M5a spec, section 8).
|
||||
let runtime: Box<dyn Runtime> = Box::new(Routed {
|
||||
memory: MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT),
|
||||
other: runtime,
|
||||
});
|
||||
|
||||
let started = match serve::start(cfg, accept_break, runtime, log) {
|
||||
Ok(started) => started,
|
||||
Err(ServeError::Audit(e @ AuditError::NothingToAccept)) => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Where decided `recall` and `memory` calls go: to `memoryd` over its `brokerd` socket, with the
|
||||
//! session's state, instead of a container (M5a spec, section 8). `Routed` sends every other call
|
||||
//! to the configured runtime.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use proto::{
|
||||
Envelope, MemoryCall, Message, PROTOCOL_VERSION, ToolRequest, ToolResponse, read_frame,
|
||||
write_frame,
|
||||
};
|
||||
|
||||
use crate::policy::Label;
|
||||
use crate::runner::{RunError, RunOutput, RunSpec, Runtime};
|
||||
|
||||
/// The sentence when `memoryd` cannot be reached or answers wrongly: fixed, it reaches the model.
|
||||
pub const UNREACHABLE: &str =
|
||||
"memory is unavailable: memoryd did not answer (see docs/runbook.md#memoryd-unreachable)";
|
||||
|
||||
/// How long `brokerd` waits for `memoryd` to answer one call.
|
||||
pub const MEMORY_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A client of `memoryd`'s `brokerd` socket. One connection per call.
|
||||
pub struct MemoryPort {
|
||||
socket: PathBuf,
|
||||
wait: Duration,
|
||||
}
|
||||
|
||||
impl MemoryPort {
|
||||
pub fn new(socket: PathBuf, wait: Duration) -> MemoryPort {
|
||||
MemoryPort { socket, wait }
|
||||
}
|
||||
|
||||
fn ask(&self, spec: &RunSpec) -> Option<ToolResponse> {
|
||||
// `None` at the first thing that fails. 1. A MemoryCall: request with the spec's session,
|
||||
// call, `spec.tool().as_str()`, and `spec.arguments().canonical_json()`; taint and
|
||||
// untrusted from `spec.state()`. 2. `UnixStream::connect(&self.socket)`, then read and
|
||||
// write timeouts of `self.wait`. 3. `write_frame` an Envelope { v: PROTOCOL_VERSION, id:
|
||||
// spec.call().0, final: true, msg: Message::MemoryCall(call) }. 4. `read_frame`; a
|
||||
// Message::ToolResponse is Some(it), any other message None.
|
||||
let call = MemoryCall {
|
||||
request: ToolRequest {
|
||||
session: spec.session().clone(),
|
||||
call: spec.call(),
|
||||
tool: spec.tool().as_str().to_string(),
|
||||
arguments: spec.arguments().canonical_json(),
|
||||
},
|
||||
taint: spec.state().taint,
|
||||
untrusted: spec.state().untrusted,
|
||||
};
|
||||
let mut stream = UnixStream::connect(&self.socket).ok()?;
|
||||
stream.set_read_timeout(Some(self.wait)).ok()?;
|
||||
stream.set_write_timeout(Some(self.wait)).ok()?;
|
||||
write_frame(
|
||||
&mut stream,
|
||||
&Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: spec.call().0,
|
||||
r#final: true,
|
||||
msg: Message::MemoryCall(call),
|
||||
},
|
||||
)
|
||||
.ok()?;
|
||||
let env = read_frame(&mut stream).ok()?;
|
||||
match env.msg {
|
||||
Message::ToolResponse(response) => Some(response),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Runtime for MemoryPort {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
self.run_labelled(spec).map(|(output, _)| output)
|
||||
}
|
||||
|
||||
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
||||
// `self.ask(spec)`: Some(Result { content, class, untrusted, truncated }) -> Ok((RunOutput
|
||||
// { content, truncated }, Some(Label { class, untrusted }))); Some(Failed { message }) ->
|
||||
// Err(RunError::Failed(message)); anything else, None included ->
|
||||
// Err(RunError::Unavailable(UNREACHABLE.to_string())).
|
||||
match self.ask(spec) {
|
||||
Some(ToolResponse::Result {
|
||||
content,
|
||||
class,
|
||||
untrusted,
|
||||
truncated,
|
||||
}) => Ok((
|
||||
RunOutput { content, truncated },
|
||||
Some(Label { class, untrusted }),
|
||||
)),
|
||||
Some(ToolResponse::Failed { message }) => Err(RunError::Failed(message)),
|
||||
_ => Err(RunError::Unavailable(UNREACHABLE.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The memory tools to `memoryd`, everything else to `other`.
|
||||
pub struct Routed {
|
||||
pub memory: MemoryPort,
|
||||
pub other: Box<dyn Runtime>,
|
||||
}
|
||||
|
||||
impl Runtime for Routed {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||
self.run_labelled(spec).map(|(output, _)| output)
|
||||
}
|
||||
|
||||
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
||||
// `spec.tool().is_memory()` -> `self.memory.run_labelled(spec)`; otherwise
|
||||
// `self.other.run_labelled(spec)`.
|
||||
if spec.tool().is_memory() {
|
||||
self.memory.run_labelled(spec)
|
||||
} else {
|
||||
self.other.run_labelled(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,8 @@ pub struct Decision {
|
||||
request: ToolRequest,
|
||||
args: ToolArgs,
|
||||
matched: Matched,
|
||||
/// The session's state when the call was decided; `memoryd` needs it (M5a spec, section 8).
|
||||
state: SessionState,
|
||||
}
|
||||
|
||||
/// A call the broker left for the owner: built only in this module, re-decided by `redecide`.
|
||||
@@ -165,6 +167,9 @@ impl Decision {
|
||||
pub fn request(&self) -> &ToolRequest {
|
||||
&self.request
|
||||
}
|
||||
pub fn state(&self) -> SessionState {
|
||||
self.state
|
||||
}
|
||||
pub fn args(&self) -> &ToolArgs {
|
||||
&self.args
|
||||
}
|
||||
@@ -278,6 +283,7 @@ pub fn decide(
|
||||
request,
|
||||
args,
|
||||
matched,
|
||||
state,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -315,6 +321,7 @@ pub fn redecide(
|
||||
request: ask.request().clone(),
|
||||
args: ask.args().clone(),
|
||||
matched,
|
||||
state,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//! ```
|
||||
|
||||
use crate::args::{ToolArgs, ToolName};
|
||||
use crate::policy::Decision;
|
||||
use crate::policy::{Decision, Label, SessionState};
|
||||
use proto::{CallId, SessionId, ToolResponse};
|
||||
|
||||
/// A directory mounted for one call: a path and whether the runtime may write to it.
|
||||
@@ -42,6 +42,7 @@ pub struct RunSpec {
|
||||
arguments: ToolArgs,
|
||||
mounts: Vec<Mount>,
|
||||
egress: Option<Vec<String>>,
|
||||
state: SessionState,
|
||||
}
|
||||
|
||||
impl RunSpec {
|
||||
@@ -63,6 +64,10 @@ impl RunSpec {
|
||||
pub fn egress(&self) -> Option<&[String]> {
|
||||
self.egress.as_deref()
|
||||
}
|
||||
/// The session's state when the call was decided.
|
||||
pub fn state(&self) -> SessionState {
|
||||
self.state
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Runtime`'s answer to one call. Its text reaches the model as a `failed` result labelled
|
||||
@@ -83,6 +88,13 @@ pub struct RunOutput {
|
||||
/// Runs one call. `Send + Sync` so one `Runtime` can serve several brokers at once.
|
||||
pub trait Runtime: Send + Sync {
|
||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError>;
|
||||
|
||||
/// `run`, and a label for what came back when the runtime knows one (`memoryd` does: a
|
||||
/// `recall` hit from an untrusted session). The runner keeps the stricter of this and the
|
||||
/// grant's label. Container runtimes know none.
|
||||
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
||||
self.run(spec).map(|output| (output, None))
|
||||
}
|
||||
}
|
||||
|
||||
/// The production runtime for M3a. It runs nothing; every call is `Unavailable`.
|
||||
@@ -126,14 +138,25 @@ pub fn run(decision: Decision, runtime: &dyn Runtime) -> proto::ToolResponse {
|
||||
arguments: args.clone(),
|
||||
mounts,
|
||||
egress,
|
||||
state: decision.state(),
|
||||
};
|
||||
match runtime.run(&spec) {
|
||||
Ok(output) => ToolResponse::Result {
|
||||
match runtime.run_labelled(&spec) {
|
||||
Ok((output, found)) => {
|
||||
// Labels only get stricter: the higher class, and untrusted if either says so.
|
||||
let (class, untrusted) = match found {
|
||||
Some(found) => (
|
||||
label.class.max(found.class),
|
||||
label.untrusted || found.untrusted,
|
||||
),
|
||||
None => (label.class, label.untrusted),
|
||||
};
|
||||
ToolResponse::Result {
|
||||
content: output.content,
|
||||
class: label.class,
|
||||
untrusted: label.untrusted,
|
||||
class,
|
||||
untrusted,
|
||||
truncated: output.truncated,
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(RunError::Failed(message) | RunError::Unavailable(message)) => {
|
||||
ToolResponse::Failed { message }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Decided memory calls go to `memoryd` with the session's state, everything else to the other
|
||||
//! runtime; `memoryd`'s label can only make a result stricter; and a container runtime never runs
|
||||
//! a memory tool (M5a spec, section 8). Do not edit.
|
||||
|
||||
#[path = "support/build.rs"]
|
||||
mod build;
|
||||
#[path = "support/fake_podman.rs"]
|
||||
mod fake_podman;
|
||||
#[path = "support/runtime.rs"]
|
||||
mod runtime;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use brokerd::container::{IN_MEMORYD, Podman};
|
||||
use brokerd::memory_port::{MemoryPort, Routed, UNREACHABLE};
|
||||
use brokerd::policy::{Outcome, SessionState, decide};
|
||||
use brokerd::runner::run;
|
||||
use build::{grant, now, read, request, set};
|
||||
use fake_podman::{Fake, Lines, serial};
|
||||
use proto::{
|
||||
DataClass, Envelope, MemoryCall, Message, Mode, ToolRequest, ToolResponse, read_frame,
|
||||
write_frame,
|
||||
};
|
||||
use runtime::{Recording, Shared};
|
||||
use tmp::TempDir;
|
||||
|
||||
/// A fake `memoryd`: answers one call with `answer` and hands back what it was sent.
|
||||
fn fake_memoryd(socket: &Path, answer: ToolResponse) -> mpsc::Receiver<MemoryCall> {
|
||||
let listener = UnixListener::bind(socket).unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let env = read_frame(&mut stream).unwrap();
|
||||
let Message::MemoryCall(call) = env.msg else {
|
||||
panic!("not a memory call: {:?}", env.msg)
|
||||
};
|
||||
tx.send(call).unwrap();
|
||||
let reply = Envelope {
|
||||
v: 1,
|
||||
id: env.id,
|
||||
r#final: true,
|
||||
msg: Message::ToolResponse(answer),
|
||||
};
|
||||
write_frame(&mut stream, &reply).unwrap();
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn result(class: DataClass, untrusted: bool) -> ToolResponse {
|
||||
ToolResponse::Result {
|
||||
content: "[1] memory · …".to_string(),
|
||||
class,
|
||||
untrusted,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn decide_in(req: ToolRequest, state: SessionState) -> brokerd::policy::Decision {
|
||||
let grants = set(vec![grant("notes", "read_file", Mode::Auto).paths(&["/n"])]);
|
||||
match decide(req, &grants, state, now()) {
|
||||
Outcome::Allowed(d) => d,
|
||||
other => panic!("not allowed: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn routed(socket: &Path, other: &std::sync::Arc<Recording>) -> Routed {
|
||||
Routed {
|
||||
memory: MemoryPort::new(socket.to_path_buf(), Duration::from_secs(5)),
|
||||
other: Box::new(Shared(std::sync::Arc::clone(other))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_memory_call_goes_to_memoryd_with_the_session_state() {
|
||||
let dir = TempDir::new("mp-route");
|
||||
let socket = dir.path().join("memory.sock");
|
||||
let seen = fake_memoryd(&socket, result(DataClass::Private, false));
|
||||
let other = Recording::answering("container");
|
||||
let state = SessionState {
|
||||
taint: DataClass::Secret,
|
||||
untrusted: true,
|
||||
};
|
||||
let got = run(
|
||||
decide_in(request("memory", r#"{"text":"x","action":"note"}"#), state),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
assert!(matches!(got, ToolResponse::Result { .. }), "{got:?}");
|
||||
let call = seen.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!((call.taint, call.untrusted), (DataClass::Secret, true));
|
||||
assert_eq!(call.request.tool, "memory");
|
||||
assert_eq!(
|
||||
call.request.arguments, r#"{"action":"note","text":"x"}"#,
|
||||
"canonical"
|
||||
);
|
||||
assert_eq!(other.count(), 0, "nothing reached the other runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_tools_go_to_the_other_runtime() {
|
||||
let dir = TempDir::new("mp-other");
|
||||
let other = Recording::answering("the file");
|
||||
let got = run(
|
||||
decide_in(read("/n/a"), SessionState::default()),
|
||||
&routed(&dir.path().join("none.sock"), &other),
|
||||
);
|
||||
assert!(matches!(got, ToolResponse::Result { ref content, .. } if content == "the file"));
|
||||
assert_eq!(other.count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memoryd_can_make_a_result_stricter_but_never_laxer() {
|
||||
let dir = TempDir::new("mp-label");
|
||||
let other = Recording::answering("unused");
|
||||
for (n, (answer, want)) in [
|
||||
((DataClass::Secret, true), (DataClass::Secret, true)),
|
||||
((DataClass::Public, false), (DataClass::Private, false)),
|
||||
((DataClass::Private, true), (DataClass::Private, true)),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let socket = dir.path().join(format!("m-{n}.sock"));
|
||||
let _seen = fake_memoryd(&socket, result(answer.0, answer.1));
|
||||
let got = run(
|
||||
decide_in(
|
||||
request("recall", r#"{"query":"x"}"#),
|
||||
SessionState::default(),
|
||||
),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
let ToolResponse::Result {
|
||||
class, untrusted, ..
|
||||
} = got
|
||||
else {
|
||||
panic!("{got:?}")
|
||||
};
|
||||
assert_eq!((class, untrusted), want, "memoryd said {answer:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_and_silence_are_failed_results() {
|
||||
let dir = TempDir::new("mp-fail");
|
||||
let socket = dir.path().join("memory.sock");
|
||||
let _seen = fake_memoryd(
|
||||
&socket,
|
||||
ToolResponse::Failed {
|
||||
message: "memory.md already has this entry".to_string(),
|
||||
},
|
||||
);
|
||||
let other = Recording::answering("unused");
|
||||
let add = r#"{"action":"add","file":"memory","text":"x"}"#;
|
||||
let got = run(
|
||||
decide_in(request("memory", add), SessionState::default()),
|
||||
&routed(&socket, &other),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: "memory.md already has this entry".to_string()
|
||||
}
|
||||
);
|
||||
let got = run(
|
||||
decide_in(request("memory", add), SessionState::default()),
|
||||
&routed(&dir.path().join("nobody.sock"), &other),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: UNREACHABLE.to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_container_runtime_never_runs_a_memory_tool() {
|
||||
let _s = serial();
|
||||
let fake = Fake::new("memory", "exit 0");
|
||||
let log = Lines::default();
|
||||
let podman = Podman::new(fake.runner(""), fake.dir.join("egress"), log.sink());
|
||||
let got = run(
|
||||
decide_in(
|
||||
request("recall", r#"{"query":"x"}"#),
|
||||
SessionState::default(),
|
||||
),
|
||||
&podman,
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
ToolResponse::Failed {
|
||||
message: IN_MEMORYD.to_string()
|
||||
}
|
||||
);
|
||||
assert!(fake.calls().is_empty(), "podman was never called");
|
||||
}
|
||||
@@ -42,6 +42,7 @@ impl Rig {
|
||||
approvals: Approvals { ttl_ms },
|
||||
runner: None,
|
||||
gateway: None,
|
||||
memory: Default::default(),
|
||||
};
|
||||
Rig {
|
||||
dir,
|
||||
|
||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
|
||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| M5a/12-brokerd-memory-port | 2026-09-24 | done | 2 | fail | none | Copied the skeleton `crates/brokerd/src/memory_port.rs` and the test `crates/brokerd/tests/memory_port.rs` into place, a new `support/rig.rs` (its `Config` literal gains `memory: Default::default()`), and appended the `memoryd-unreachable` runbook entry. Added `pub mod memory_port;` after `pub mod ledger;` in `lib.rs`. In `policy/mod.rs` added the private `state` field to `Decision`, the `state()` accessor after `request()`, and threaded `state` through the `decide`/`redecide` literals (both already take it as a parameter). In `runner.rs` widened the `use` to `::{Decision, Label, SessionState}`; `RunSpec` gained a `state` field and `state()`; `Runtime` gained a default `run_labelled` that calls `run` and returns no label so existing runtimes are unchanged; `run` now calls `run_labelled` and keeps the stricter of the found label and the grant's (higher class via `.max`, untrusted if either). In `container.rs` added the `IN_MEMORYD` const and a `run` guard that refuses a memory tool before spawning. In `config.rs` added the `Memory` struct before `default_podman`, the `memory` field on `Config`, and `memory_socket()` before `audit_dir`. In `main.rs` imported `MemoryPort`/`Routed`/`MEMORY_WAIT` and wrapped the runtime in `Routed { memory: MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT), other }`. Filled `MemoryPort::ask` (build the `MemoryCall` with the spec's session/call/tool/canonical arguments and the state's taint/untrusted, connect, set read and write timeouts to `wait`, write the final envelope, read it — `Some` only on a `Message::ToolResponse`, else `None`); `MemoryPort::run_labelled` (`Result` -> `Ok((output, Some(label)))`, `Failed` -> `Failed`, anything else or `None` -> `Unreachable`); `Routed::run_labelled` (memory tools to `memoryd`, everything else to `other`). `cargo fmt --all`; `memory_port` 5 passed and the full brokerd suite green; `make gate` printed `gate: ok` on the second run — the first failed on rustfmt import order in `memory_port.rs`, fixed by re-running `cargo fmt --all`. No new dependency, so `Cargo.lock` is unchanged. | ? |
|
||||
| M5a/11-brokerd-memory-tools | 2026-09-24 | done | 2 | pass | none | Copied the skeleton `crates/brokerd/src/builtin.rs` and the tests `crates/brokerd/tests/memory_tools.rs` and a new `grants.rs` (the empty-directory test now asserts `matchable()` sees the two built-ins rather than comparing to an empty set), and added `pub mod builtin;` after `pub mod broker;` in `lib.rs`. Filled `builtin::grants`, the only `todo!()`: for each `(id, text)` of `[("builtin-memory", MEMORY), ("builtin-recall", RECALL)]`, `toml::from_str::<Grant>` and `sha256(text.as_bytes())` each error as a `GrantProblem { file: id, line: None, problem: e }`, then a `LoadedGrant { id, grant, sha256 }`, collected into `Result<Vec<LoadedGrant>, GrantProblem>` (first error wins); the tests failed on the `todo!()` first, then passed. In `args.rs` added `use proto::{MemoryArgs, RecallArgs}`; the `Recall`/`Memory` variants of `ToolName` with their `parse`/`as_str` arms, the `is_memory` method, and the `Recall`/`Memory` variants of `ToolArgs` with their `tool`/`canonical_json`/`parse` arms. In `grants.rs` added the `builtin` field to `GrantSet`, the `builtin` join at the end of `from_grants` (only when the set passed), `matchable`, Rule 2b for ids starting `builtin-`, and the built-in sentence for a memory tool in `check_grant`. In `policy/matching.rs` matched against `matchable()` and gave the memory tools a constraint-free `covers` arm; in `runner.rs` routed them to no mounts/egress; in `config.rs` gave them the read_file time limit. `cargo fmt --all`; `memory_tools` 4 and `grants` 17 passed; the full brokerd suite green; `make gate` printed `gate: ok` on the first run. No new dependency, so `Cargo.lock` is unchanged. | ? |
|
||||
| M5a/10-memoryd-serve | 2026-09-24 | done | 2 | fail | none | Copied the skeleton `crates/memoryd/src/serve.rs` and the test `crates/memoryd/tests/serve.rs` into place, and added `pub mod serve;` after `pub mod recall;` in `lib.rs`. Filled the functions the skeleton left as `todo!()`: `start` (open the loop, broker and admin sockets, a `Running { listeners, memoryd }` and a sender that prints the three addresses to stdout); `Running::run` (spawn an accept thread per listener, each clone the shared `memoryd` and the sender, loop `incoming()`, spawn a connection thread per stream and log a spawn failure as "memoryd: cannot start a thread: <e>", an incoming error sends "a listener failed: <e>" and returns); `connection` (read the first frame, match on `MemoryCommand` — `Block` -> `block`, `SessionUpdated` -> `session_updated`, `Call` -> `call`, any other kind, a closed connection, a parse error or a frame past `MAX_FRAME` refused with the right sentence); `block` (session must not be `Refused`, `journal::block` the count, return the reply or the refusal as `Block`); `session_updated` (session must not be `Refused`, `journal::log` the new content, return the reply); `call` (session must not be `Refused`, build the `Context` from session/call/taint/untrusted, `Memory::call` the tool, `ToolResponse::Result` with the class and untrusted flags and text cut to `TOOL_RESPONSE_MAX` chars, `ToolResponse::Failed` with the sentence); `memory` (session must not be `Refused`, `memory::render` the file, the reply with the class and untrusted, or the refusal); `index_notes` (session must not be `Refused`, `index::index_notes` the text, log a `Reindexed` with the counts and problems, return the reply); `reindex` (caller must be the owner, `index::rebuild` the index, log a `Reindexed`, return the reply); `admin` (caller must be the owner, the tool `index`/`recall`/`memory` routed to `index::index`, `index::session_logs`/`index::logs`, or the memory file, every other tool "memoryd runs only recall and memory", a count parsed as usize with the "not a number" and "too large" errors). `cargo fmt --all`; the eight `serve` tests passed; `make gate` printed `gate: ok` on the second run — the first failed on rustfmt import order in the `use proto::{..}` line and a trailing semicolon, fixed by re-running `cargo fmt --all`. No new dependency, so `Cargo.lock` is unchanged. | ? |
|
||||
| M5a/09-memoryd-recall | 2026-09-24 | done | 2 | fail | none | Copied the skeleton `crates/memoryd/src/recall.rs` and the test `crates/memoryd/tests/recall.rs` into place, and added `pub mod recall;` after `pub mod notes;` in `lib.rs`. Filled the seven functions the skeleton left as `todo!()`: `terms` (split on every non-alphanumeric char, drop the empties, wrap each piece in double quotes, join with " OR ", None when there is no piece so no FTS5 operator is parsed); `when` (day = first 10 chars, clock = chars 11..16, as `format!("{day} {clock} UTC")`, the time unchanged when either `get` is None); `day` (first 10 chars via `get(..10)`, or all of it when shorter); `Hit::tag` ("moved" -> "identity", "dropped" -> "memory", any other kind "memory-tainted" when untrusted else "memory"); `Hit::header` (from by kind: "moved"/"dropped" -> format!("moved|dropped {day} from {role}", noted -> format!("note, {when}, session {session}", a session record -> format!("session {session}, {when}, {said}" with "the owner said"/"the assistant called"/"the assistant said" by role), then format!("[{n}] {tag} · {class} · {from}")); `recall` (args.check, terms -> search or no hits, log a Logged {time, session, query, a LoggedHit per hit} as one JSON line to `recalls/{month}.jsonl` after `create_dir_all(store.path("recalls"))`, "(no matches for ...)" when empty, else class walked from Private with class.max and untrusted |= the tag is "memory-tainted", each text cut to SNIPPET chars and headed by its header, joined by newlines); `search` (prepare the JOIN with `snippet(hits,0,'','',…',64)`, query_map building a Hit from columns 0..8 with the class through `class_of`, limit k as i64 with 10 on overflow, every rusqlite error as "index: {e}"). `cargo fmt --all`; the six `recall` tests passed three runs; `make gate` printed `gate: ok` on the second run — the first failed on rustfmt import order in the `use crate::index::{..}` line, fixed by re-running `cargo fmt --all`. No new dependency, so `Cargo.lock` is unchanged. | ? |
|
||||
|
||||
@@ -617,3 +617,21 @@ cannot be a socket). It will not start without the memory it owns.
|
||||
move it aside and start `memoryd`, which rebuilds it (see `memory-index-damaged`).
|
||||
|
||||
**Check.** `memoryd serve --config <path>` prints `memoryd: serving`.
|
||||
|
||||
## memoryd-unreachable
|
||||
|
||||
**What you see.** A `recall` or `memory` call answers `memory is unavailable: memoryd did not
|
||||
answer (see docs/runbook.md#memoryd-unreachable)`, and the model tells you it cannot use its
|
||||
memory.
|
||||
|
||||
**Why.** `brokerd` decided the call and sent it to `memoryd`'s `brokerd` socket
|
||||
(`<home>/run/broker-memory/memory.sock`, or `[memory] socket` in `brokerd.toml`), and nothing
|
||||
answered within 30 seconds, or the answer was not a tool result. `memoryd` is not running, is
|
||||
listening elsewhere, or is stuck.
|
||||
|
||||
**Confirm.** `pgrep -x memoryd`; `ls -l <home>/run/broker-memory/`; the end of `memoryd`'s log.
|
||||
|
||||
**Fix.** Start `memoryd serve --config <path>`, or make `[memory] socket` in `brokerd.toml` and
|
||||
`[sockets] broker` in `memoryd.toml` name the same path.
|
||||
|
||||
**Check.** Ask the agent to recall something; the answer lists hits or says there were none.
|
||||
|
||||
Reference in New Issue
Block a user