Task 04's session read the given test and the check.rs skeleton, both holding the tags the check refuses, and Ornith took them for its own template. It lost track of the file and made no change. The tags are now built with concat! and format! in check.rs and in the four given tests that held them, and the spec describes them in words. The rewritten tests pass against the reference, are rustfmt-clean, and still catch the case-folding mutation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
352 lines
10 KiB
Rust
352 lines
10 KiB
Rust
//! `memoryd` serving its three sockets end to end: the block, `session_updated`, `recall` and
|
|
//! `memory` for `brokerd`, the owner's commands, a reindex, and messages on the wrong socket
|
|
//! refused (M5a spec, section 7). Do not edit.
|
|
|
|
#[path = "support/logs.rs"]
|
|
mod logs;
|
|
#[path = "support/tmp.rs"]
|
|
mod tmp;
|
|
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use logs::{append, result, user, write_log};
|
|
use memoryd::config::Config;
|
|
use memoryd::serve;
|
|
use proto::{
|
|
CallId, DataClass, Empty, Envelope, ErrorCode, MemoryAdmin, MemoryCall, MemoryCommand, Message,
|
|
SessionId, SessionUpdated, ToolRequest, ToolResponse, read_frame, write_frame,
|
|
};
|
|
use tmp::TempDir;
|
|
|
|
struct Running {
|
|
dir: TempDir,
|
|
config: Config,
|
|
lines: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
fn start(tag: &str, review: bool) -> Running {
|
|
let dir = TempDir::new(tag);
|
|
dir.write("etc/soul.md", "You are Boxmaker.\n");
|
|
write_log(dir.path(), "mm-old", &[user(1, "the old launch plan")]);
|
|
let text = format!(
|
|
"[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\nreview = {review}\n",
|
|
dir.path().display(),
|
|
dir.path().join("etc/soul.md").display()
|
|
);
|
|
let config = Config::parse(&text).unwrap();
|
|
let lines = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = Arc::clone(&lines);
|
|
let log: serve::Log = Arc::new(move |line: &str| sink.lock().unwrap().push(line.to_string()));
|
|
let running = serve::start(config.clone(), log).unwrap();
|
|
std::thread::spawn(move || running.run());
|
|
Running { dir, config, lines }
|
|
}
|
|
|
|
fn ask(socket: &Path, msg: Message) -> Message {
|
|
let mut stream = UnixStream::connect(socket).unwrap();
|
|
stream
|
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
|
.unwrap();
|
|
let env = Envelope {
|
|
v: 1,
|
|
id: 9,
|
|
r#final: true,
|
|
msg,
|
|
};
|
|
write_frame(&mut stream, &env).unwrap();
|
|
let reply = read_frame(&mut stream).unwrap();
|
|
assert_eq!((reply.id, reply.r#final), (9, true));
|
|
reply.msg
|
|
}
|
|
|
|
fn call(r: &Running, session: &str, tool: &str, arguments: &str, untrusted: bool) -> ToolResponse {
|
|
let msg = Message::MemoryCall(MemoryCall {
|
|
request: ToolRequest {
|
|
session: SessionId::new(session).unwrap(),
|
|
call: CallId(1),
|
|
tool: tool.to_string(),
|
|
arguments: arguments.to_string(),
|
|
},
|
|
taint: DataClass::Private,
|
|
untrusted,
|
|
});
|
|
match ask(&r.config.broker_socket(), msg) {
|
|
Message::ToolResponse(response) => response,
|
|
other => panic!("not a tool response: {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn content(response: ToolResponse) -> (String, bool) {
|
|
match response {
|
|
ToolResponse::Result {
|
|
content, untrusted, ..
|
|
} => (content, untrusted),
|
|
other => panic!("not a result: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_block_and_a_write_that_reaches_the_next_block() {
|
|
let r = start("serve-block", false);
|
|
let Message::MemoryBlockReply(first) =
|
|
ask(&r.config.loop_socket(), Message::MemoryBlock(Empty {}))
|
|
else {
|
|
panic!("no block")
|
|
};
|
|
assert!(
|
|
first
|
|
.text
|
|
.starts_with("# soul.md\nYou are Boxmaker.\n\n# memory.md (0 of 2,200")
|
|
);
|
|
let (answer, _) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"add","file":"user","text":"Prefers UTC."}"#,
|
|
false,
|
|
));
|
|
assert_eq!(
|
|
answer,
|
|
"user.md updated (12 of 1,375 characters); it takes effect in the next session"
|
|
);
|
|
let Message::MemoryBlockReply(next) =
|
|
ask(&r.config.loop_socket(), Message::MemoryBlock(Empty {}))
|
|
else {
|
|
panic!("no block")
|
|
};
|
|
assert!(
|
|
next.text
|
|
.ends_with("# user.md (12 of 1,375 characters)\nPrefers UTC."),
|
|
"{}",
|
|
next.text
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recall_finds_old_sessions_updated_ones_and_notes() {
|
|
let r = start("serve-recall", false);
|
|
let (found, untrusted) = content(call(&r, "mm-a", "recall", r#"{"query":"launch"}"#, false));
|
|
assert!(
|
|
found.contains("the old launch plan"),
|
|
"caught up at start: {found}"
|
|
);
|
|
assert!(!untrusted);
|
|
let log = write_log(
|
|
r.dir.path(),
|
|
"mm-b",
|
|
&[result(1, "page", DataClass::Private, true)],
|
|
);
|
|
append(&log, &user(2, "the launch is Thursday"));
|
|
let update = Message::SessionUpdated(SessionUpdated {
|
|
session: SessionId::new("mm-b").unwrap(),
|
|
});
|
|
assert_eq!(ask(&r.config.loop_socket(), update), Message::Ok(Empty {}));
|
|
call(
|
|
&r,
|
|
"mm-c",
|
|
"memory",
|
|
r#"{"action":"note","text":"example.org moved the launch"}"#,
|
|
true,
|
|
);
|
|
let (found, untrusted) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"recall",
|
|
r#"{"query":"launch","k":10}"#,
|
|
false,
|
|
));
|
|
assert!(found.contains("the launch is Thursday"), "{found}");
|
|
assert!(found.contains("memory-tainted · note,"), "{found}");
|
|
assert!(untrusted);
|
|
}
|
|
|
|
#[test]
|
|
fn an_untrusted_session_writes_notes_not_identity() {
|
|
let r = start("serve-untrusted", false);
|
|
let (answer, _) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"add","file":"memory","text":"Planted fact."}"#,
|
|
true,
|
|
));
|
|
assert_eq!(
|
|
answer,
|
|
"saved as a note (untrusted): this session has read untrusted content"
|
|
);
|
|
let memory = std::fs::read_to_string(r.dir.path().join("memory/memory.md")).unwrap();
|
|
assert_eq!(memory, "");
|
|
let failed = call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"remove","file":"memory","old":"x"}"#,
|
|
true,
|
|
);
|
|
assert_eq!(
|
|
failed,
|
|
ToolResponse::Failed {
|
|
message: "this session has read untrusted content; it can take notes but not change memory.md".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_calls_fail_with_a_sentence() {
|
|
let r = start("serve-bad", false);
|
|
assert!(matches!(
|
|
call(&r, "mm-a", "shell", "{}", false),
|
|
ToolResponse::Failed { .. }
|
|
));
|
|
assert!(matches!(
|
|
call(&r, "mm-a", "memory", r#"{"action":"forget"}"#, false),
|
|
ToolResponse::Failed { .. }
|
|
));
|
|
assert!(matches!(
|
|
call(&r, "mm-a", "recall", r#"{"query":""}"#, false),
|
|
ToolResponse::Failed { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn review_holds_identity_writes_but_not_notes() {
|
|
let r = start("serve-review", true);
|
|
let (answer, _) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"add","file":"memory","text":"Held."}"#,
|
|
false,
|
|
));
|
|
assert!(
|
|
answer.starts_with("waiting for the owner's review (p-"),
|
|
"{answer}"
|
|
);
|
|
let (noted, _) = content(call(
|
|
&r,
|
|
"mm-a",
|
|
"memory",
|
|
r#"{"action":"note","text":"Not held."}"#,
|
|
false,
|
|
));
|
|
assert_eq!(noted, "noted");
|
|
let (untrusted, _) = content(call(
|
|
&r,
|
|
"mm-b",
|
|
"memory",
|
|
r#"{"action":"add","file":"memory","text":"Not held either."}"#,
|
|
true,
|
|
));
|
|
assert_eq!(
|
|
untrusted,
|
|
"saved as a note (untrusted): this session has read untrusted content"
|
|
);
|
|
let admin = |command, arg: &str| match ask(
|
|
&r.config.admin_socket(),
|
|
Message::MemoryAdmin(MemoryAdmin {
|
|
command,
|
|
arg: arg.to_string(),
|
|
}),
|
|
) {
|
|
Message::MemoryAdminReply(reply) => reply.text,
|
|
other => panic!("{other:?}"),
|
|
};
|
|
let pending = admin(MemoryCommand::Pending, "");
|
|
let id = pending.split_whitespace().next().unwrap().to_string();
|
|
assert!(admin(MemoryCommand::Accept, &id).starts_with(&format!("accepted: {id} as ")));
|
|
assert_eq!(
|
|
std::fs::read_to_string(r.dir.path().join("memory/memory.md")).unwrap(),
|
|
"Held.\n"
|
|
);
|
|
let log = admin(MemoryCommand::Log, "");
|
|
assert_eq!(
|
|
log.lines().count(),
|
|
3,
|
|
"a note, a note from an untrusted add, the accepted add: {log}"
|
|
);
|
|
let edit = log
|
|
.lines()
|
|
.last()
|
|
.unwrap()
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap()
|
|
.to_string();
|
|
assert!(admin(MemoryCommand::Undo, &edit).starts_with("undone: "));
|
|
let refused = ask(
|
|
&r.config.admin_socket(),
|
|
Message::MemoryAdmin(MemoryAdmin {
|
|
command: MemoryCommand::Log,
|
|
arg: "ten".to_string(),
|
|
}),
|
|
);
|
|
assert!(
|
|
matches!(refused, Message::Error(ref e) if e.code == ErrorCode::Internal),
|
|
"{refused:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reindex_rebuilds_and_recall_still_answers() {
|
|
let r = start("serve-reindex", false);
|
|
write_log(
|
|
r.dir.path(),
|
|
"mm-new",
|
|
&[user(1, "written while nobody told memoryd")],
|
|
);
|
|
let Message::Reindexed(counts) = ask(&r.config.admin_socket(), Message::Reindex(Empty {}))
|
|
else {
|
|
panic!("not reindexed")
|
|
};
|
|
assert_eq!((counts.logs, counts.records, counts.notes), (2, 2, 0));
|
|
let (found, _) = content(call(&r, "mm-a", "recall", r#"{"query":"launch"}"#, false));
|
|
assert!(found.contains("the old launch plan"));
|
|
let (found, _) = content(call(&r, "mm-a", "recall", r#"{"query":"nobody"}"#, false));
|
|
assert!(
|
|
found.contains("written while nobody told memoryd"),
|
|
"the new index answers: {found}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_message_on_the_wrong_socket_is_refused() {
|
|
let r = start("serve-wrong", false);
|
|
let reply = ask(&r.config.broker_socket(), Message::MemoryBlock(Empty {}));
|
|
assert!(
|
|
matches!(reply, Message::Error(ref e) if e.code == ErrorCode::BadMessage),
|
|
"{reply:?}"
|
|
);
|
|
let reply = ask(&r.config.loop_socket(), Message::Reindex(Empty {}));
|
|
assert!(
|
|
matches!(reply, Message::Error(ref e) if e.code == ErrorCode::BadMessage),
|
|
"{reply:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_entry_that_fails_the_check_is_left_out_and_logged() {
|
|
let r = start("serve-leftout", false);
|
|
std::fs::write(
|
|
r.dir.path().join("memory/memory.md"),
|
|
concat!("Kept.\n§\n<", "think>planted\n"),
|
|
)
|
|
.unwrap();
|
|
let Message::MemoryBlockReply(block) =
|
|
ask(&r.config.loop_socket(), Message::MemoryBlock(Empty {}))
|
|
else {
|
|
panic!("no block")
|
|
};
|
|
assert!(block.text.contains("\nKept.\n"), "{}", block.text);
|
|
assert!(!block.text.contains("planted"));
|
|
let lines = r.lines.lock().unwrap().clone();
|
|
assert!(
|
|
lines
|
|
.iter()
|
|
.any(|l| l.contains("an entry of memory.md was left out")),
|
|
"{lines:?}"
|
|
);
|
|
}
|