D-63: the 0700 data root is the trust boundary already (I-1: hidden, not locked), so the OS keyring added no wall and cost headless servers a D-Bus session, CI a knob and every OS its own store. The file keeper is the only store: run/tokens/, 0600, written atomically. The three keyring stores, the file_keyring knob and their tests are removed; an unreadable token file is an error, never no token. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2949 lines
106 KiB
Rust
2949 lines
106 KiB
Rust
//! The books open while something holds them (plan 3.9, D-21, D-23): the
|
|
//! host, its leases, the helper, the agents' bridge, and `serve`, `close`
|
|
//! and `books`. Oracles: the INVARIANTS of ledger-host's host.rs,
|
|
//! opener.rs, start.rs, bridge.rs and lease.rs, and the P2 tests the plan
|
|
//! names, each covered here under a name of its own (books_close_with_last_holder, openers_share_one_server,
|
|
//! bridge_opens_closed_books, bridge_survives_server_restart,
|
|
//! close_now_ends_every_lease, host_starts_clean, host_respawns,
|
|
//! respawn_backs_off, lease_socket_accepts_only_owner_intents,
|
|
//! restart_is_not_close, children_match_host_version,
|
|
//! bridge_initializes_during_cold_start, no_local_agent_tcp).
|
|
#![cfg(target_os = "linux")]
|
|
|
|
use serde_json::{Value, json};
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::mpsc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Short timings for tests (the defaults are the plan's).
|
|
const KNOBS: &str =
|
|
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
|
|
|
|
/// A home with a data root, and the program the tests run (possibly a
|
|
/// copy, for restarts).
|
|
struct Home {
|
|
dir: tempfile::TempDir,
|
|
exe: PathBuf,
|
|
}
|
|
|
|
/// Wait until a program just copied can be run. A child another test
|
|
/// thread forked while the copy was being written holds the copy open for
|
|
/// writing until it starts its own program, and running the copy before
|
|
/// then fails with "text file busy".
|
|
fn runnable(program: &Path) {
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
loop {
|
|
match Command::new(program)
|
|
.arg("--help")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
{
|
|
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => {
|
|
assert!(Instant::now() < deadline, "the copy stayed busy");
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
Err(e) => panic!("the copy can't be run: {e}"),
|
|
Ok(_) => return,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Home {
|
|
fn new(tag: &str) -> Self {
|
|
let dir = tempfile::Builder::new()
|
|
.prefix(&format!("wb-held-{tag}-"))
|
|
.tempdir()
|
|
.unwrap();
|
|
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
|
.args(["setup", "--home"])
|
|
.arg(dir.path())
|
|
.env_remove("XDG_DATA_HOME")
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
Self {
|
|
dir,
|
|
exe: PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks")),
|
|
}
|
|
}
|
|
|
|
/// Run the program from a copy in the home, so a test can replace it.
|
|
fn with_copy(mut self) -> Self {
|
|
let bin = self.dir.path().join("bin");
|
|
std::fs::create_dir(&bin).unwrap();
|
|
let copy = bin.join("wanderbooks");
|
|
std::fs::copy(env!("CARGO_BIN_EXE_wanderbooks"), ©).unwrap();
|
|
runnable(©);
|
|
self.exe = copy;
|
|
self
|
|
}
|
|
|
|
/// Replace the program with a new file (as a package upgrade does).
|
|
fn replace_program(&self) {
|
|
let next = self.exe.with_extension("new");
|
|
std::fs::copy(env!("CARGO_BIN_EXE_wanderbooks"), &next).unwrap();
|
|
runnable(&next);
|
|
std::fs::rename(&next, &self.exe).unwrap();
|
|
}
|
|
|
|
fn root(&self) -> PathBuf {
|
|
self.dir.path().join(".local/share/wanderbooks")
|
|
}
|
|
|
|
fn run_dir(&self) -> PathBuf {
|
|
self.root().join("run")
|
|
}
|
|
|
|
/// The program, with the root, the test knobs, and the test home's
|
|
/// environment alone.
|
|
fn cmd(&self, mode: &str) -> Command {
|
|
let mut c = Command::new(&self.exe);
|
|
c.arg(mode)
|
|
.arg("--root")
|
|
.arg(self.root())
|
|
.args(["--test-knobs", KNOBS])
|
|
.env_clear()
|
|
.env("PATH", "/usr/bin:/bin")
|
|
.env("HOME", self.dir.path());
|
|
c
|
|
}
|
|
|
|
fn books(&self, args: &[&str]) -> std::process::Output {
|
|
let mut c = self.cmd("books");
|
|
c.args(args).stdin(Stdio::null());
|
|
c.output().unwrap()
|
|
}
|
|
|
|
/// New books, and the agents' token the bridges use for them.
|
|
fn create(&self, id: &str) {
|
|
let out = self.books(&["create", id]);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let out = self
|
|
.cmd("token")
|
|
.args(["create", "--book", id])
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
}
|
|
|
|
/// The pids running `mode` for this root.
|
|
fn pids(&self, mode: &str) -> Vec<u32> {
|
|
let root = self.root().display().to_string();
|
|
let mut out = Vec::new();
|
|
for entry in std::fs::read_dir("/proc").unwrap().flatten() {
|
|
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
|
|
continue;
|
|
};
|
|
let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else {
|
|
continue;
|
|
};
|
|
let args: Vec<String> = raw
|
|
.split(|b| *b == 0)
|
|
.map(|a| String::from_utf8_lossy(a).into_owned())
|
|
.collect();
|
|
if args.get(1).map(String::as_str) == Some(mode) && args.contains(&root) {
|
|
// A zombie has an empty cmdline; this one is running.
|
|
out.push(pid);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The host. Across a restart the old one hands over to the new for a
|
|
/// moment; any longer, two hosts is a failure.
|
|
fn host(&self) -> Option<u32> {
|
|
wait_for(5, || self.pids("host").len() <= 1);
|
|
let pids = self.pids("host");
|
|
assert!(pids.len() <= 1, "more than one host: {pids:?}");
|
|
pids.first().copied()
|
|
}
|
|
|
|
/// Wait until there is no host (the books closed), at most `secs`.
|
|
fn wait_closed(&self, secs: u64) -> bool {
|
|
wait_for(secs, || {
|
|
self.host().is_none() && self.pids("server").is_empty()
|
|
})
|
|
}
|
|
|
|
/// One line to the lease socket, the answer.
|
|
fn lease_line(&self, line: &str) -> String {
|
|
use std::io::Read;
|
|
let short = ledger_store::root::SocketPath::new(&self.run_dir(), "lease.sock").unwrap();
|
|
let mut s = std::os::unix::net::UnixStream::connect(short.path()).unwrap();
|
|
s.write_all(line.as_bytes()).unwrap();
|
|
s.shutdown(std::net::Shutdown::Write).unwrap();
|
|
let mut out = String::new();
|
|
s.read_to_string(&mut out).unwrap();
|
|
out
|
|
}
|
|
}
|
|
|
|
impl Drop for Home {
|
|
fn drop(&mut self) {
|
|
// Leave nothing running: close the books, then make sure.
|
|
let _ = self
|
|
.cmd("close")
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status();
|
|
// Holders first (one dying could start a host), then the rest,
|
|
// again until nothing of this root is left.
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
loop {
|
|
let mut found = false;
|
|
for mode in ["serve", "mcp", "host", "server"] {
|
|
for pid in self.pids(mode) {
|
|
found = true;
|
|
// SAFETY: a process of this test's own root.
|
|
unsafe { libc::kill(pid as i32, libc::SIGKILL) };
|
|
}
|
|
}
|
|
if !found || Instant::now() > deadline {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn wait_for(secs: u64, mut done: impl FnMut() -> bool) -> bool {
|
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
|
while Instant::now() < deadline {
|
|
if done() {
|
|
return true;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
done()
|
|
}
|
|
|
|
/// A child whose stdout lines arrive on a channel.
|
|
struct Running {
|
|
child: Child,
|
|
lines: mpsc::Receiver<String>,
|
|
}
|
|
|
|
impl Running {
|
|
fn start(mut c: Command) -> Self {
|
|
let mut child = c
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
let (tx, lines) = mpsc::channel();
|
|
let out = child.stdout.take().unwrap();
|
|
std::thread::spawn(move || {
|
|
for line in BufReader::new(out).lines() {
|
|
let Ok(line) = line else { break };
|
|
if tx.send(line).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
Self { child, lines }
|
|
}
|
|
|
|
/// Wait for a line containing `text`.
|
|
fn expect(&self, text: &str, secs: u64) -> String {
|
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
|
loop {
|
|
let left = deadline.saturating_duration_since(Instant::now());
|
|
match self.lines.recv_timeout(left) {
|
|
Ok(line) if line.contains(text) => return line,
|
|
Ok(_) => {}
|
|
Err(_) => panic!("no line with {text:?} in {secs} s"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn wait(&mut self, secs: u64) -> Option<std::process::ExitStatus> {
|
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
|
while Instant::now() < deadline {
|
|
if let Some(s) = self.child.try_wait().unwrap() {
|
|
return Some(s);
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
None
|
|
}
|
|
|
|
fn signal(&self, sig: i32) {
|
|
// SAFETY: our own child.
|
|
unsafe { libc::kill(self.child.id() as i32, sig) };
|
|
}
|
|
|
|
fn stderr(&mut self) -> String {
|
|
use std::io::Read;
|
|
let mut s = String::new();
|
|
if let Some(mut e) = self.child.stderr.take() {
|
|
let _ = e.read_to_string(&mut s);
|
|
}
|
|
s
|
|
}
|
|
}
|
|
|
|
impl Drop for Running {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
/// An agent: the bridge over stdio, spoken to as an MCP client.
|
|
struct Agent {
|
|
run: Running,
|
|
next: u64,
|
|
/// Notifications received.
|
|
notes: Vec<Value>,
|
|
/// How it runs the commands it is given (`wanderbooks upload`), as
|
|
/// [`Home::cmd`] runs them, and the folder its files are in: the
|
|
/// program, the data root, the home.
|
|
commands: Option<(PathBuf, PathBuf, PathBuf)>,
|
|
}
|
|
|
|
impl Agent {
|
|
fn start(home: &Home, book: &str) -> Self {
|
|
let mut c = home.cmd("mcp");
|
|
c.args(["--book", book]);
|
|
let mut a = Self::from(c);
|
|
a.commands = Some((home.exe.clone(), home.root(), home.dir.path().to_path_buf()));
|
|
a
|
|
}
|
|
|
|
fn from(c: Command) -> Self {
|
|
Self {
|
|
run: Running::start(c),
|
|
next: 1,
|
|
notes: Vec::new(),
|
|
commands: None,
|
|
}
|
|
}
|
|
|
|
/// Send `bytes` as `name` as an agent does (D-46): books.begin_upload,
|
|
/// the file written in the home, and the command it returns run on
|
|
/// it. The upload_id.
|
|
fn upload(&mut self, bytes: &[u8], name: &str) -> Result<String, String> {
|
|
let (exe, root, home) = self.commands.clone().expect("an agent Agent::start made");
|
|
let begun = self.call(
|
|
"books.begin_upload",
|
|
json!({"sha256": ledger_store::journal::sha256_hex(bytes), "size": bytes.len(),
|
|
"mime_type": "text/plain", "file_name": name, "purpose": "receipt"}),
|
|
)?;
|
|
let command = begun["command"]
|
|
.as_str()
|
|
.ok_or_else(|| format!("no upload begun: {begun}"))?;
|
|
let words: Vec<&str> = command
|
|
.strip_suffix(" <the file's path>")
|
|
.ok_or_else(|| command.to_string())?
|
|
.split(' ')
|
|
.collect();
|
|
let path = home.join(format!("upload-{}", begun["upload_id"].as_str().unwrap()));
|
|
std::fs::write(&path, bytes).unwrap();
|
|
let out = Command::new(exe)
|
|
.arg(words[1])
|
|
.arg("--root")
|
|
.arg(root)
|
|
.args(["--test-knobs", KNOBS])
|
|
.args(&words[2..])
|
|
.arg(&path)
|
|
.env_clear()
|
|
.env("PATH", "/usr/bin:/bin")
|
|
.env("HOME", &home)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
let _ = std::fs::remove_file(&path);
|
|
if !out.status.success() {
|
|
return Err(String::from_utf8_lossy(&out.stderr).to_string());
|
|
}
|
|
Ok(begun["upload_id"].as_str().unwrap().to_string())
|
|
}
|
|
|
|
fn send(&mut self, message: Value) {
|
|
let stdin = self.run.child.stdin.as_mut().unwrap();
|
|
writeln!(stdin, "{message}").unwrap();
|
|
stdin.flush().unwrap();
|
|
}
|
|
|
|
/// Send a request and wait for its answer.
|
|
fn request(&mut self, method: &str, params: Value, secs: u64) -> Value {
|
|
let id = self.next;
|
|
self.next += 1;
|
|
self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
|
loop {
|
|
let left = deadline.saturating_duration_since(Instant::now());
|
|
let line = self
|
|
.run
|
|
.lines
|
|
.recv_timeout(left)
|
|
.unwrap_or_else(|_| panic!("no answer to {method} in {secs} s"));
|
|
let v: Value = serde_json::from_str(&line).unwrap();
|
|
if v.get("id") == Some(&json!(id)) {
|
|
return v;
|
|
}
|
|
self.notes.push(v);
|
|
}
|
|
}
|
|
|
|
fn initialize(&mut self) -> Value {
|
|
let r = self.request(
|
|
"initialize",
|
|
json!({"protocolVersion": "2025-06-18", "capabilities": {},
|
|
"clientInfo": {"name": "test", "version": "0"}}),
|
|
20,
|
|
);
|
|
self.send(json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
|
|
r
|
|
}
|
|
|
|
/// Call a tool: Ok(its structured result) or Err(the error's text).
|
|
fn call(&mut self, tool: &str, args: Value) -> Result<Value, String> {
|
|
let r = self.request("tools/call", json!({"name": tool, "arguments": args}), 60);
|
|
if let Some(e) = r.get("error") {
|
|
return Err(e["message"].as_str().unwrap_or_default().to_string());
|
|
}
|
|
let result = &r["result"];
|
|
if result["isError"] == true {
|
|
return Err(result["content"][0]["text"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.to_string());
|
|
}
|
|
Ok(result["structuredContent"].clone())
|
|
}
|
|
|
|
/// End the session as an agent does: close stdin.
|
|
fn end(mut self) -> Option<std::process::ExitStatus> {
|
|
drop(self.run.child.stdin.take());
|
|
self.run.wait(30)
|
|
}
|
|
}
|
|
|
|
fn chart(a: &mut Agent) {
|
|
for (code, name, ty) in [
|
|
("1000", "Cash", "asset"),
|
|
("3000", "Capital", "equity"),
|
|
("6000", "Supplies", "expense"),
|
|
] {
|
|
a.call(
|
|
"books.add_account",
|
|
json!({"code": code, "name": name, "account_type": ty}),
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
fn post_one(a: &mut Agent, n: u64) -> Result<u64, String> {
|
|
let e = a.call(
|
|
"books.draft_entry",
|
|
json!({"date": "2026-03-01", "description": format!("entry {n}")}),
|
|
)?;
|
|
let id = e["entry_id"].as_u64().unwrap();
|
|
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
|
a.call(
|
|
"books.add_line",
|
|
json!({"entry_id": id, "account": account, "side": side, "amount": "1.00"}),
|
|
)?;
|
|
}
|
|
// Bytes of each entry's own: the same bytes stored already would be
|
|
// named for linking, not uploaded again.
|
|
let upload = a.upload(format!("receipt {n}").as_bytes(), &format!("r{n}.txt"))?;
|
|
a.call(
|
|
"books.attach_receipt",
|
|
json!({"entry_id": id, "upload_id": upload}),
|
|
)?;
|
|
a.call(
|
|
"books.post_entry",
|
|
json!({"entry_id": id, "confirmed": true, "no_party_reason": "test"}),
|
|
)?;
|
|
Ok(id)
|
|
}
|
|
|
|
fn serve(home: &Home) -> Running {
|
|
let r = Running::start(home.cmd("serve"));
|
|
// The first time, it says how to let go.
|
|
r.expect("The books are open. Press Ctrl-C", 30);
|
|
r
|
|
}
|
|
|
|
#[test]
|
|
fn a_command_opens_the_books_and_they_close_after_the_last_holder() {
|
|
let home = Home::new("last");
|
|
assert!(home.host().is_none());
|
|
home.create("acme");
|
|
// The command let go; the books stay open for the close delay, then
|
|
// close.
|
|
assert!(home.host().is_some(), "closed at once");
|
|
assert!(home.wait_closed(10), "never closed");
|
|
for sock in ["lease.sock", "mcp.sock", "admin.sock"] {
|
|
assert!(!home.run_dir().join(sock).exists(), "{sock} left behind");
|
|
}
|
|
// A lease arriving within the delay cancels the close.
|
|
home.create("beta");
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
let host = home.host().unwrap();
|
|
home.create("gamma");
|
|
std::thread::sleep(Duration::from_millis(1000));
|
|
assert_eq!(
|
|
home.host(),
|
|
Some(host),
|
|
"closed though a lease came in time"
|
|
);
|
|
assert!(home.wait_closed(10));
|
|
// An agent session killed outright: closed after the delay, too.
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
a.call("books.start", json!({})).unwrap();
|
|
a.run.child.kill().unwrap();
|
|
a.run.child.wait().unwrap();
|
|
std::thread::sleep(Duration::from_millis(300));
|
|
assert!(home.host().is_some(), "closed before the delay");
|
|
assert!(home.wait_closed(10), "never closed after the bridge died");
|
|
// A holder that is killed: closed after the delay, not before.
|
|
let mut held = serve(&home);
|
|
let leases = home.lease_line("{\"proto\":1,\"leases\":null}\n");
|
|
assert!(leases.contains("\"serve\""), "{leases}");
|
|
assert!(leases.contains(&held.child.id().to_string()), "{leases}");
|
|
held.child.kill().unwrap();
|
|
held.child.wait().unwrap();
|
|
std::thread::sleep(Duration::from_millis(300));
|
|
assert!(home.host().is_some(), "closed before the delay");
|
|
assert!(home.wait_closed(10), "never closed after the holder died");
|
|
// Ctrl-C at the terminal: serve lets go, and the books close.
|
|
let mut held = serve(&home);
|
|
held.signal(libc::SIGINT);
|
|
assert!(held.wait(10).is_some_and(|s| s.success()));
|
|
assert!(home.wait_closed(10));
|
|
// The books verify after every close.
|
|
let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap();
|
|
store.verify_and_heal().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn openers_share_one_host_and_one_server() {
|
|
let home = Home::new("share");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
// All at once, from closed.
|
|
let mut holders = vec![
|
|
Running::start(home.cmd("serve")),
|
|
Running::start({
|
|
let mut c = home.cmd("mcp");
|
|
c.args(["--book", "acme"]);
|
|
c
|
|
}),
|
|
Running::start({
|
|
let mut c = home.cmd("mcp");
|
|
c.args(["--book", "acme"]);
|
|
c
|
|
}),
|
|
];
|
|
let list = std::thread::spawn({
|
|
let mut c = home.cmd("books");
|
|
c.arg("list").stdin(Stdio::null());
|
|
move || c.output().unwrap()
|
|
});
|
|
holders[0].expect("The books are open", 30);
|
|
let out = list.join().unwrap();
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("acme open"),
|
|
"{out:?}"
|
|
);
|
|
assert!(wait_for(10, || home.pids("host").len() == 1));
|
|
assert_eq!(home.pids("host").len(), 1, "one host");
|
|
assert_eq!(home.pids("server").len(), 1, "one server");
|
|
// Both bridges hold (each takes its lease as it starts).
|
|
assert!(
|
|
wait_for(30, || home
|
|
.lease_line("{\"proto\":1,\"leases\":null}\n")
|
|
.matches("\"bridge\"")
|
|
.count()
|
|
== 2),
|
|
"the bridges' leases"
|
|
);
|
|
holders.clear();
|
|
assert!(home.wait_closed(15));
|
|
}
|
|
|
|
#[test]
|
|
fn the_bridge_opens_closed_books_and_holds_them_for_the_session() {
|
|
let home = Home::new("bridge");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
let mut a = Agent::start(&home, "acme");
|
|
let init = a.initialize();
|
|
// Ready in time: the server's own answer, its connect text.
|
|
let result = &init["result"];
|
|
assert_eq!(result["serverInfo"]["name"], "wanderbooks");
|
|
assert_eq!(result["capabilities"]["tools"]["listChanged"], true);
|
|
let text = result["instructions"].as_str().unwrap();
|
|
assert!(
|
|
!text.contains("opening now"),
|
|
"the bridge's stand-in: {text}"
|
|
);
|
|
assert!(!text.contains(&home.root().display().to_string()));
|
|
chart(&mut a);
|
|
post_one(&mut a, 1).unwrap();
|
|
let tools = a.request("tools/list", json!({}), 20);
|
|
assert!(tools["result"]["tools"].as_array().unwrap().len() > 10);
|
|
// A tool error and a protocol error come through as they are.
|
|
assert!(a.call("books.get_entry", json!({"entry_id": 999})).is_err());
|
|
let unknown = a.request("no/such/method", json!({}), 20);
|
|
assert!(unknown.get("error").is_some(), "{unknown}");
|
|
// Held while the session lasts, even past the close delay.
|
|
std::thread::sleep(Duration::from_millis(1500));
|
|
assert!(home.host().is_some());
|
|
// The bridge never uses TCP (no_local_agent_tcp), and TCP is off.
|
|
let bridge = a.run.child.id();
|
|
let server = home.pids("server")[0];
|
|
for pid in [bridge, server] {
|
|
assert!(tcp_sockets(pid).is_empty(), "pid {pid} has TCP sockets");
|
|
}
|
|
assert!(a.end().is_some_and(|s| s.success()));
|
|
assert!(home.wait_closed(10));
|
|
// Unknown books: a plain error, no folder named.
|
|
let mut a = Agent::start(&home, "nobody");
|
|
a.initialize();
|
|
let err = a.call("books.start", json!({})).unwrap_err();
|
|
assert!(err.contains("no books called"), "{err}");
|
|
assert!(!err.contains(&home.root().display().to_string()));
|
|
}
|
|
|
|
/// The TCP sockets a process holds (by inode, against /proc/net/tcp*).
|
|
fn tcp_sockets(pid: u32) -> Vec<String> {
|
|
let mut tcp = String::new();
|
|
for f in ["/proc/net/tcp", "/proc/net/tcp6"] {
|
|
tcp.push_str(&std::fs::read_to_string(f).unwrap_or_default());
|
|
}
|
|
let inodes: Vec<&str> = tcp
|
|
.lines()
|
|
.skip(1)
|
|
.filter_map(|l| l.split_whitespace().nth(9))
|
|
.collect();
|
|
let mut found = Vec::new();
|
|
if let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) {
|
|
for fd in fds.flatten() {
|
|
if let Ok(target) = std::fs::read_link(fd.path()) {
|
|
let t = target.to_string_lossy().into_owned();
|
|
if let Some(inode) = t.strip_prefix("socket:[").and_then(|r| r.strip_suffix(']'))
|
|
&& inodes.contains(&inode)
|
|
{
|
|
found.push(t);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
#[test]
|
|
fn the_bridge_answers_initialize_itself_during_a_slow_start() {
|
|
let home = Home::new("cold");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
let mut c = home.cmd("mcp");
|
|
c.args([
|
|
"--book",
|
|
"acme",
|
|
"--test-knobs",
|
|
"close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000",
|
|
]);
|
|
let mut a = Agent::from(c);
|
|
let started = Instant::now();
|
|
let init = a.initialize();
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(5),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
let text = init["result"]["instructions"].as_str().unwrap();
|
|
assert!(text.contains("books.start"), "{text}");
|
|
assert!(text.contains("opening now"), "{text}");
|
|
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
|
|
// The first call waits for the books, then runs.
|
|
// A ping is answered at once, not after the books open.
|
|
let asked = Instant::now();
|
|
let pong = a.request("ping", json!({}), 10);
|
|
assert!(pong.get("result").is_some(), "{pong}");
|
|
assert!(
|
|
asked.elapsed() < Duration::from_secs(1),
|
|
"{:?}",
|
|
asked.elapsed()
|
|
);
|
|
// A call cancelled while it waits for the books is never made.
|
|
let id = a.next;
|
|
a.next += 1;
|
|
a.send(
|
|
json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {
|
|
"name": "books.add_account",
|
|
"arguments": {"code": "1000", "name": "Cash", "account_type": "asset"}}}),
|
|
);
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
a.send(
|
|
json!({"jsonrpc": "2.0", "method": "notifications/cancelled",
|
|
"params": {"requestId": id, "reason": "changed my mind"}}),
|
|
);
|
|
// A batch is refused, each request in it answered.
|
|
a.send(json!([
|
|
{"jsonrpc": "2.0", "id": "b1", "method": "ping"},
|
|
{"jsonrpc": "2.0", "method": "notifications/progress", "params": {}},
|
|
]));
|
|
let line = a.run.lines.recv_timeout(Duration::from_secs(10)).unwrap();
|
|
let batch: Value = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(batch[0]["id"], "b1", "{batch}");
|
|
assert_eq!(batch[0]["error"]["code"], -32600);
|
|
// The first call waits for the books, then runs.
|
|
let start = a.call("books.start", json!({})).unwrap();
|
|
assert!(start.is_object());
|
|
assert!(started.elapsed() >= Duration::from_secs(3));
|
|
let chart = a.call("books.chart", json!({})).unwrap();
|
|
assert_eq!(chart["accounts"], json!([]), "the cancelled call was made");
|
|
assert!(
|
|
!a.notes.iter().any(|n| n.get("id") == Some(&json!(id))),
|
|
"the cancelled call was answered: {:?}",
|
|
a.notes
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_bridge_gives_up_on_books_that_never_open() {
|
|
let home = Home::new("never");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
let mut c = home.cmd("mcp");
|
|
c.args([
|
|
"--book",
|
|
"acme",
|
|
"--test-knobs",
|
|
"close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000",
|
|
]);
|
|
let mut a = Agent::from(c);
|
|
a.initialize();
|
|
let err = a.call("books.start", json!({})).unwrap_err();
|
|
assert!(err.contains("wanderbooks doctor"), "{err}");
|
|
// A ping is answered even so.
|
|
let pong = a.request("ping", json!({}), 10);
|
|
assert!(pong.get("result").is_some(), "{pong}");
|
|
}
|
|
|
|
#[test]
|
|
fn closing_the_books_ends_every_lease_and_the_bridge_stays_closed() {
|
|
let home = Home::new("closenow");
|
|
home.create("acme");
|
|
let mut held = serve(&home);
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
a.call("books.start", json!({})).unwrap();
|
|
let out = home.cmd("close").output().unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("The books are closed."),
|
|
"{out:?}"
|
|
);
|
|
held.expect("The books were closed", 10);
|
|
assert!(held.wait(10).is_some_and(|s| s.success()));
|
|
assert!(home.wait_closed(10));
|
|
// The agent's session carries on, told the owner closed them; it
|
|
// never opens them again.
|
|
let err = a.call("books.start", json!({})).unwrap_err();
|
|
assert!(err.contains("owner closed the books"), "{err}");
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
assert!(home.host().is_none(), "the bridge reopened the books");
|
|
// Closing closed books says so.
|
|
let out = home.cmd("close").output().unwrap();
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("already closed"),
|
|
"{out:?}"
|
|
);
|
|
}
|
|
|
|
/// The host half of books_close_cleanly: the owner closes the books, or
|
|
/// the host is signalled, during a burst of posts through a bridge.
|
|
#[test]
|
|
fn closing_during_a_burst_of_posts_through_the_bridge_loses_nothing() {
|
|
for how in ["close", "term", "hup"] {
|
|
let home = Home::new(&format!("burst-{how}"));
|
|
home.create("acme");
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
chart(&mut a);
|
|
let poster = std::thread::spawn(move || {
|
|
let mut posted = Vec::new();
|
|
let mut why = String::new();
|
|
for k in 0.. {
|
|
match post_one(&mut a, k) {
|
|
Ok(id) => posted.push(id),
|
|
Err(e) => {
|
|
why = e;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
(posted, why)
|
|
});
|
|
std::thread::sleep(Duration::from_millis(1500));
|
|
match how {
|
|
"close" => assert!(home.cmd("close").output().unwrap().status.success()),
|
|
_ => {
|
|
let host = home.host().unwrap() as i32;
|
|
let sig = if how == "term" {
|
|
libc::SIGTERM
|
|
} else {
|
|
libc::SIGHUP
|
|
};
|
|
// SAFETY: this test's own host.
|
|
unsafe { libc::kill(host, sig) };
|
|
}
|
|
}
|
|
let (mut posted, why) = poster.join().unwrap();
|
|
assert!(!posted.is_empty(), "{how}: nothing posted");
|
|
// Told the owner closed them: never "closing, not run", which the
|
|
// bridge sends again, and never "may have run".
|
|
assert!(why.contains("closed the books"), "{how}: stopped by {why}");
|
|
assert!(
|
|
!why.contains("may or may not"),
|
|
"{how}: a call was cut off: {why}"
|
|
);
|
|
assert!(home.wait_closed(15), "{how}");
|
|
let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap();
|
|
store.verify_and_heal().unwrap();
|
|
let mut in_books: Vec<u64> = store.ledger().posted_entries().map(|e| e.id()).collect();
|
|
in_books.sort_unstable();
|
|
posted.sort_unstable();
|
|
assert_eq!(
|
|
in_books, posted,
|
|
"{how}: a post lost, or made without its answer"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_host_starts_clean_from_a_hostile_bridge() {
|
|
let home = Home::new("clean");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
// A pipe the bridge inherits; the host must not.
|
|
let (read_end, write_end) = std::io::pipe().unwrap();
|
|
use std::os::fd::AsRawFd;
|
|
let leaked = write_end.as_raw_fd();
|
|
let mut c = home.cmd("mcp");
|
|
c.args(["--book", "acme"])
|
|
.env("XDG_DATA_HOME", "/tmp/wb-evil-data")
|
|
.env("LD_PRELOAD", "/nonexistent/wb-evil.so")
|
|
.env("WB_EVIL", "1")
|
|
.env("XDG_RUNTIME_DIR", "/tmp")
|
|
.env("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/evil-bus");
|
|
{
|
|
use std::os::unix::process::CommandExt;
|
|
// SAFETY: dup2 into the child before exec, keeping the pipe open
|
|
// across it (no close-on-exec) as descriptor 9.
|
|
unsafe {
|
|
c.pre_exec(move || {
|
|
if libc::dup2(leaked, 9) < 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|
|
let mut a = Agent::from(c);
|
|
a.initialize();
|
|
a.call("books.start", json!({})).unwrap();
|
|
let host = home.host().expect("a host");
|
|
let environ = std::fs::read(format!("/proc/{host}/environ")).unwrap();
|
|
let vars: Vec<String> = environ
|
|
.split(|b| *b == 0)
|
|
.filter(|v| !v.is_empty())
|
|
.map(|v| String::from_utf8_lossy(v).into_owned())
|
|
.collect();
|
|
for v in &vars {
|
|
let name = v.split('=').next().unwrap();
|
|
assert!(
|
|
[
|
|
"PATH",
|
|
"HOME",
|
|
"XDG_RUNTIME_DIR",
|
|
"DBUS_SESSION_BUS_ADDRESS",
|
|
// Set by systemd itself for the host's scope, not passed on.
|
|
"INVOCATION_ID",
|
|
"SYSTEMD_EXEC_PID",
|
|
]
|
|
.contains(&name),
|
|
"the host inherited {v}"
|
|
);
|
|
}
|
|
assert!(!vars.iter().any(|v| v.contains("evil")), "{vars:?}");
|
|
let home_var = vars.iter().find(|v| v.starts_with("HOME=")).unwrap();
|
|
assert_eq!(
|
|
home_var,
|
|
&format!(
|
|
"HOME={}",
|
|
ledger_store::root::account_home().unwrap().display()
|
|
),
|
|
"HOME from the account's record"
|
|
);
|
|
// No inherited descriptor: nothing points at the pipe.
|
|
let pipe = std::fs::read_link(format!("/proc/self/fd/{}", read_end.as_raw_fd())).unwrap();
|
|
for fd in std::fs::read_dir(format!("/proc/{host}/fd"))
|
|
.unwrap()
|
|
.flatten()
|
|
{
|
|
let target = std::fs::read_link(fd.path()).unwrap_or_default();
|
|
assert_ne!(target, pipe, "the host holds the inherited pipe");
|
|
}
|
|
// Its own session, its own umask, and not confined.
|
|
let status = std::fs::read_to_string(format!("/proc/{host}/status")).unwrap();
|
|
assert!(status.contains("Umask:\t0077"), "{status}");
|
|
assert!(status.contains("Seccomp:\t0"));
|
|
let session = |pid: u32| {
|
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap();
|
|
let after = stat.rsplit_once(") ").unwrap().1.to_string();
|
|
after.split_whitespace().nth(3).unwrap().to_string()
|
|
};
|
|
assert_ne!(session(host), session(a.run.child.id()));
|
|
assert_eq!(
|
|
session(host),
|
|
host.to_string(),
|
|
"the host leads its session"
|
|
);
|
|
// The server is started from the host's own program, by /proc/self/exe.
|
|
let server = home.pids("server")[0];
|
|
assert_eq!(
|
|
std::fs::metadata(format!("/proc/{server}/exe"))
|
|
.unwrap()
|
|
.len(),
|
|
std::fs::metadata(format!("/proc/{host}/exe"))
|
|
.unwrap()
|
|
.len()
|
|
);
|
|
drop(write_end);
|
|
}
|
|
|
|
#[test]
|
|
fn a_killed_host_is_replaced_by_its_holders() {
|
|
let home = Home::new("respawn");
|
|
home.create("acme");
|
|
let mut held = serve(&home);
|
|
let first = home.host().unwrap();
|
|
// SAFETY: this test's own host.
|
|
unsafe { libc::kill(first as i32, libc::SIGKILL) };
|
|
held.expect("opening the books again", 10);
|
|
held.expect("The books are open again", 30);
|
|
let second = home.host().unwrap();
|
|
assert_ne!(first, second);
|
|
let out = home.books(&["list"]);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("acme open"),
|
|
"{out:?}"
|
|
);
|
|
held.signal(libc::SIGTERM);
|
|
assert!(held.wait(10).is_some_and(|s| s.success()));
|
|
assert!(home.wait_closed(10));
|
|
}
|
|
|
|
#[test]
|
|
fn holders_back_off_and_give_up_on_a_host_that_keeps_failing() {
|
|
let home = Home::new("backoff");
|
|
// The server can't bind its socket: every start fails.
|
|
std::fs::create_dir(home.run_dir().join("mcp.sock")).unwrap();
|
|
let started = Instant::now();
|
|
let mut held = Running::start(home.cmd("serve"));
|
|
let status = held.wait(60).expect("never gave up");
|
|
assert_eq!(status.code(), Some(1));
|
|
let err = held.stderr();
|
|
assert!(
|
|
err.contains("keeps failing") && err.contains("wanderbooks doctor"),
|
|
"{err}"
|
|
);
|
|
// 50 + 100 + 200 + 400 + 800 ms of waiting at least.
|
|
assert!(
|
|
started.elapsed() >= Duration::from_millis(1550),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
let log = std::fs::read_to_string(home.run_dir().join("host.log")).unwrap();
|
|
// One start and five tries; a busy machine may add a start (a host
|
|
// slow to take its lock counts as one that died), never fewer.
|
|
let starts = log.matches("the books server stopped").count();
|
|
assert!(starts >= 6, "one start and five tries: {log}");
|
|
std::fs::remove_dir(home.run_dir().join("mcp.sock")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn the_lease_socket_takes_only_the_owners_intents() {
|
|
let home = Home::new("intents");
|
|
home.create("acme");
|
|
let _held = serve(&home);
|
|
for (line, want) in [
|
|
("hello\n", "not understood"),
|
|
("{\"proto\":1,\"intent\":\"delete\"}\n", "not understood"),
|
|
(
|
|
"{\"proto\":1,\"intent\":{\"restore\":{\"path\":\"/etc\"}}}\n",
|
|
"not understood",
|
|
),
|
|
("{\"proto\":1,\"exec\":\"sh\"}\n", "not understood"),
|
|
("{\"proto\":9,\"leases\":null}\n", "another version"),
|
|
(
|
|
"{\"proto\":1,\"intent\":{\"consent_format_upgrade\":{\"book\":\"acme\"}}}\n",
|
|
"passphrase",
|
|
),
|
|
(
|
|
"{\"proto\":1,\"intent\":{\"back_up_now\":{\"book\":\"acme\"}}}\n",
|
|
"not understood",
|
|
),
|
|
(
|
|
"{\"proto\":1,\"intent\":{\"restore\":{\"source\":\"/x\",\"id\":\"acme\"}}}\n",
|
|
"not understood",
|
|
),
|
|
(
|
|
"{\"proto\":1,\"intent\":{\"new_books\":{\"id\":\"beta\",\"currency\":\"USD\"}}}\n",
|
|
"not understood",
|
|
),
|
|
] {
|
|
let answer = home.lease_line(line);
|
|
assert!(answer.contains(want), "{line:?}: {answer}");
|
|
assert!(
|
|
!answer.contains(&home.root().display().to_string()),
|
|
"{answer}"
|
|
);
|
|
}
|
|
let out = home.books(&["list"]);
|
|
assert!(
|
|
!String::from_utf8_lossy(&out.stdout).contains("beta"),
|
|
"{out:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_restart_is_not_a_close() {
|
|
let home = Home::new("restart").with_copy();
|
|
home.create("acme");
|
|
let held = serve(&home);
|
|
let other = serve(&home);
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
chart(&mut a);
|
|
let tools_before = a.request("tools/list", json!({}), 20);
|
|
let host = home.host().unwrap();
|
|
let server = home.pids("server")[0];
|
|
// The server runs the host's program: the same file.
|
|
let exe = |pid: u32| std::fs::read_link(format!("/proc/{pid}/exe")).unwrap();
|
|
assert_eq!(exe(server), exe(host));
|
|
home.replace_program();
|
|
// Writes made all through the restart are each answered, and each
|
|
// answered one is in the books, once: those that meet the restart
|
|
// wait for the new server.
|
|
let deadline = Instant::now() + Duration::from_secs(40);
|
|
let (mut restarting, mut reopened, mut during) = (false, false, 0);
|
|
let mut written = Vec::new();
|
|
let mut k = 0u32;
|
|
while !reopened {
|
|
assert!(Instant::now() < deadline, "the restart never finished");
|
|
// Sent while the restart is known to be under way, or (a restart
|
|
// quicker than the lines that tell of it) sent before the books
|
|
// reopened and answered only after waiting for the new server.
|
|
let mid_restart = restarting && !reopened;
|
|
let code = format!("{}", 5000 + k);
|
|
k += 1;
|
|
let sent = Instant::now();
|
|
let r = a.call(
|
|
"books.add_account",
|
|
json!({"code": code, "name": format!("R{code}"), "account_type": "expense"}),
|
|
);
|
|
assert!(r.is_ok(), "a write across the restart: {r:?}");
|
|
written.push(code);
|
|
if mid_restart || sent.elapsed() > Duration::from_millis(300) {
|
|
during += 1;
|
|
}
|
|
while let Ok(line) = held.lines.try_recv() {
|
|
restarting |= line.contains("restarting");
|
|
reopened |= line.contains("open again");
|
|
}
|
|
}
|
|
assert!(
|
|
restarting && during > 0,
|
|
"no write was sent during the restart"
|
|
);
|
|
let chart = a.call("books.chart", json!({})).unwrap();
|
|
let mut in_books: Vec<String> = chart["accounts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter_map(|acc| acc["code"].as_str().map(str::to_string))
|
|
.filter(|c| c.starts_with('5'))
|
|
.collect();
|
|
in_books.sort();
|
|
written.sort();
|
|
assert_eq!(
|
|
in_books, written,
|
|
"a write lost, or made twice, across the restart"
|
|
);
|
|
other.expect("The books are open again", 30);
|
|
// The holders waited for the new host: none started one of its own.
|
|
let log = std::fs::read_to_string(home.run_dir().join("host.log")).unwrap();
|
|
assert!(!log.contains("another host has these books open"), "{log}");
|
|
let new_host = home.host().unwrap();
|
|
assert_ne!(new_host, host);
|
|
let new_server = home.pids("server")[0];
|
|
assert_eq!(
|
|
exe(new_server),
|
|
exe(new_host),
|
|
"children match the host's version"
|
|
);
|
|
assert!(!exe(new_host).to_string_lossy().contains("(deleted)"));
|
|
// The agent carries on with no reconnect, and its tools didn't
|
|
// change, so it is told nothing.
|
|
post_one(&mut a, 7).unwrap();
|
|
let tools_after = a.request("tools/list", json!({}), 20);
|
|
assert_eq!(tools_before["result"], tools_after["result"]);
|
|
assert!(
|
|
!a.notes
|
|
.iter()
|
|
.any(|n| n["method"] == "notifications/tools/list_changed"),
|
|
"{:?}",
|
|
a.notes
|
|
);
|
|
assert!(a.call("books.get_entry", json!({"entry_id": 1})).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn books_commands_hold_the_books_while_they_run() {
|
|
let home = Home::new("cmds");
|
|
let out = home.books(&["list"]);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("No books yet"),
|
|
"{out:?}"
|
|
);
|
|
home.create("acme");
|
|
let out = home.books(&["create", "acme"]);
|
|
assert_eq!(out.status.code(), Some(1));
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("already books called"),
|
|
"{out:?}"
|
|
);
|
|
let out = home.books(&["create", "euro", "--currency", "EUR"]);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("(EUR)"),
|
|
"{out:?}"
|
|
);
|
|
// Adopting: previewed, and not done without a yes.
|
|
let old = tempfile::Builder::new()
|
|
.prefix("wb-held-old-")
|
|
.tempdir()
|
|
.unwrap();
|
|
let data = old.path().join("books-data");
|
|
drop(ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap());
|
|
std::fs::write(
|
|
old.path().join("wanderbooks.toml"),
|
|
"[company]\nname = \"Old Co\"\n",
|
|
)
|
|
.unwrap();
|
|
let toml = old.path().join("wanderbooks.toml");
|
|
let args = |extra: &[&str]| {
|
|
let mut v = vec![
|
|
"adopt",
|
|
data.to_str().unwrap(),
|
|
"--id",
|
|
"old",
|
|
"--toml",
|
|
toml.to_str().unwrap(),
|
|
];
|
|
v.extend_from_slice(extra);
|
|
v.into_iter().map(String::from).collect::<Vec<_>>()
|
|
};
|
|
let run = |a: Vec<String>| {
|
|
let a: Vec<&str> = a.iter().map(String::as_str).collect();
|
|
home.books(&a)
|
|
};
|
|
let out = run(args(&[]));
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
assert!(text.contains("company.name = \"Old Co\""), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("confirm at a terminal"),
|
|
"{out:?}"
|
|
);
|
|
assert_eq!(out.status.code(), Some(1));
|
|
// A vault beside the books must be named.
|
|
std::fs::create_dir(old.path().join("vault")).unwrap();
|
|
let out = run(args(&["--yes"]));
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("--no-vault"),
|
|
"{out:?}"
|
|
);
|
|
let out = run(args(&["--yes", "--no-vault"]));
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("Adopted as \"old\""),
|
|
"{out:?}"
|
|
);
|
|
let out = home.books(&["list"]);
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
for id in ["acme", "euro", "old"] {
|
|
assert!(text.contains(&format!("{id} open")), "{text}");
|
|
}
|
|
// Nothing printed names the books' private folder.
|
|
let root = home.root().display().to_string();
|
|
for o in [
|
|
&out,
|
|
&run(args(&["--yes", "--no-vault"])),
|
|
&home.books(&["create", "acme"]),
|
|
] {
|
|
for text in [&o.stdout, &o.stderr] {
|
|
assert!(!String::from_utf8_lossy(text).contains(&root), "{o:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Under a systemd app scope (as uwsm sets up), closing the app that
|
|
/// started the host leaves the host for the other holders. Run only where
|
|
/// a systemd user manager is there to make scopes.
|
|
#[test]
|
|
fn the_host_outlives_the_app_scope_that_started_it() {
|
|
let uid = unsafe { libc::geteuid() };
|
|
let cgroup = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
|
|
if !cgroup.contains(&format!("/user@{uid}.service/"))
|
|
|| !Path::new("/usr/bin/systemd-run").exists()
|
|
{
|
|
eprintln!("skipped: no systemd user manager here");
|
|
return;
|
|
}
|
|
let home = Home::new("scope");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
let unit = format!("wb-test-{}", std::process::id());
|
|
// `serve` in its own app scope, as an app launched from a desktop.
|
|
let mut c = Command::new("/usr/bin/systemd-run");
|
|
c.args([
|
|
"--user",
|
|
"--scope",
|
|
"--quiet",
|
|
"--collect",
|
|
&format!("--unit={unit}.scope"),
|
|
])
|
|
.arg(&home.exe)
|
|
.arg("serve")
|
|
.arg("--root")
|
|
.arg(home.root())
|
|
.args(["--test-knobs", KNOBS])
|
|
.env("HOME", home.dir.path());
|
|
let held = Running::start(c);
|
|
held.expect("The books are open", 30);
|
|
let host = home.host().unwrap();
|
|
// The app's scope stops: everything in it is killed.
|
|
let out = Command::new("systemctl")
|
|
.args(["--user", "stop", &format!("{unit}.scope")])
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
std::thread::sleep(Duration::from_millis(300));
|
|
// The host wasn't in it: it lives on, then closes as nothing holds it.
|
|
let alive = std::fs::metadata(format!("/proc/{host}")).is_ok();
|
|
assert!(alive, "the host died with the app's scope");
|
|
assert!(home.wait_closed(10));
|
|
}
|
|
|
|
/// Plan 3.2: both protocol eras are served. A 2026-07-28 client has no
|
|
/// initialize; each request carries its version and client in `_meta`.
|
|
#[test]
|
|
fn the_bridge_serves_a_client_without_initialize() {
|
|
let home = Home::new("stateless");
|
|
home.create("acme");
|
|
let mut a = Agent::start(&home, "acme");
|
|
let meta = json!({
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientCapabilities": {},
|
|
"io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"},
|
|
});
|
|
let found = a.request("server/discover", json!({"_meta": meta}), 30);
|
|
assert_eq!(
|
|
found["result"]["capabilities"]["tools"],
|
|
json!({}),
|
|
"{found}"
|
|
);
|
|
let chart = a.request(
|
|
"tools/call",
|
|
json!({"name": "books.chart", "arguments": {}, "_meta": meta}),
|
|
30,
|
|
);
|
|
assert_eq!(
|
|
chart["result"]["structuredContent"]["base_currency"], "USD",
|
|
"{chart}"
|
|
);
|
|
}
|
|
|
|
/// Review round 3, finding 1: the server dying under a bridge (with a call
|
|
/// going out) doesn't wedge it: it holds again, a new host starts, and
|
|
/// the agent's next calls are answered.
|
|
#[test]
|
|
fn the_bridge_recovers_when_the_server_dies_under_it() {
|
|
let home = Home::new("dies");
|
|
home.create("acme");
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
a.call("books.start", json!({})).unwrap();
|
|
let server = home.pids("server")[0];
|
|
// SAFETY: this test's own server.
|
|
unsafe { libc::kill(server as i32, libc::SIGKILL) };
|
|
// A call at once: refused unrun (or answered "may have run"), then
|
|
// the next ones work again on a new host and server.
|
|
let _ = a.call("books.start", json!({}));
|
|
let deadline = Instant::now() + Duration::from_secs(30);
|
|
loop {
|
|
let r = a.call("books.chart", json!({}));
|
|
if r.is_ok() {
|
|
break;
|
|
}
|
|
if Instant::now() > deadline {
|
|
let log = std::fs::read_to_string(home.run_dir().join("host.log")).unwrap_or_default();
|
|
let _ = a.run.child.kill();
|
|
let err = a.run.stderr();
|
|
panic!("the bridge never recovered: {r:?}\nhost.log:\n{log}\nbridge:\n{err}");
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
assert!(home.pids("server").iter().all(|p| *p != server));
|
|
}
|
|
|
|
/// Review round 3, finding 2: a cancel that follows its call at once is
|
|
/// still in time while the call waits for the books.
|
|
#[test]
|
|
fn a_call_cancelled_at_once_is_never_made() {
|
|
let home = Home::new("cancel");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
let mut c = home.cmd("mcp");
|
|
c.args([
|
|
"--book",
|
|
"acme",
|
|
"--test-knobs",
|
|
"close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000",
|
|
]);
|
|
let mut a = Agent::from(c);
|
|
a.initialize();
|
|
let mut ids = Vec::new();
|
|
for k in 0..20u64 {
|
|
let id = a.next;
|
|
a.next += 1;
|
|
ids.push(id);
|
|
// The call and its cancel in one write.
|
|
let stdin = a.run.child.stdin.as_mut().unwrap();
|
|
writeln!(
|
|
stdin,
|
|
"{}\n{}",
|
|
json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {
|
|
"name": "books.add_account",
|
|
"arguments": {"code": format!("{}", 1000 + k), "name": format!("A{k}"),
|
|
"account_type": "asset"}}}),
|
|
json!({"jsonrpc": "2.0", "method": "notifications/cancelled",
|
|
"params": {"requestId": id}})
|
|
)
|
|
.unwrap();
|
|
stdin.flush().unwrap();
|
|
}
|
|
let chart = a.call("books.chart", json!({})).unwrap();
|
|
assert_eq!(chart["accounts"], json!([]), "a cancelled call was made");
|
|
assert!(
|
|
!a.notes.iter().any(|n| n
|
|
.get("id")
|
|
.is_some_and(|i| ids.contains(&i.as_u64().unwrap_or(0)))),
|
|
"a cancelled call was answered: {:?}",
|
|
a.notes
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_says_whether_the_books_are_open_and_what_holds_them() {
|
|
let home = Home::new("doctor");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
let text = |o: std::process::Output| String::from_utf8_lossy(&o.stdout).into_owned();
|
|
let closed = text(home.cmd("doctor").output().unwrap());
|
|
assert!(closed.contains("The books are closed."), "{closed}");
|
|
assert!(closed.contains("same_user (advisory)"), "{closed}");
|
|
let _held = serve(&home);
|
|
let open = text(home.cmd("doctor").output().unwrap());
|
|
assert!(open.contains("The books are open."), "{open}");
|
|
assert!(open.contains("acme open"), "{open}");
|
|
assert!(
|
|
open.contains("Held by:") && open.contains("wanderbooks serve"),
|
|
"{open}"
|
|
);
|
|
assert!(open.contains("The host, last said:"), "{open}");
|
|
for t in [&closed, &open] {
|
|
assert!(!t.contains(&home.root().display().to_string()), "{t}");
|
|
}
|
|
}
|
|
|
|
/// Review round 4, finding 2: a bridge stopped by a signal goes at once,
|
|
/// even with a call waiting for the books (never sent).
|
|
#[test]
|
|
fn a_signalled_bridge_goes_at_once() {
|
|
let home = Home::new("sigbridge");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
let mut c = home.cmd("mcp");
|
|
c.args([
|
|
"--book",
|
|
"acme",
|
|
"--test-knobs",
|
|
"close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000",
|
|
]);
|
|
let mut a = Agent::from(c);
|
|
a.initialize();
|
|
a.send(json!({"jsonrpc": "2.0", "id": 99, "method": "tools/call",
|
|
"params": {"name": "books.start", "arguments": {}}}));
|
|
std::thread::sleep(Duration::from_millis(300));
|
|
let stopped = Instant::now();
|
|
a.run.signal(libc::SIGTERM);
|
|
assert!(a.run.wait(10).is_some(), "the bridge lingered");
|
|
assert!(
|
|
stopped.elapsed() < Duration::from_secs(5),
|
|
"{:?}",
|
|
stopped.elapsed()
|
|
);
|
|
}
|
|
|
|
/// Review round 4, finding 10: a message too large is refused and the
|
|
/// session goes on.
|
|
#[test]
|
|
fn a_message_too_large_is_refused_and_the_session_goes_on() {
|
|
let home = Home::new("toolarge");
|
|
home.create("acme");
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
{
|
|
let stdin = a.run.child.stdin.as_mut().unwrap();
|
|
// One byte over the limit (1 MiB), its newline aside.
|
|
let max = ledger_mcp::local::MAX_BODY;
|
|
stdin.write_all(b"{\"x\":\"").unwrap();
|
|
stdin.write_all(&vec![b'a'; max + 1 - 8]).unwrap();
|
|
stdin.write_all(b"\"}\n").unwrap();
|
|
stdin.flush().unwrap();
|
|
}
|
|
let line = a.run.lines.recv_timeout(Duration::from_secs(30)).unwrap();
|
|
assert!(line.contains("too large"), "{line}");
|
|
let pong = a.request("ping", json!({}), 20);
|
|
assert!(pong.get("result").is_some(), "{pong}");
|
|
assert!(a.call("books.start", json!({})).is_ok());
|
|
}
|
|
|
|
/// Review round 4, finding 1: where systemd-run can't make its scope (no
|
|
/// user bus reachable, as in a container), the host starts directly.
|
|
#[test]
|
|
fn the_host_starts_without_a_scope_when_systemd_run_fails() {
|
|
// SAFETY: geteuid has no preconditions.
|
|
let uid = unsafe { libc::geteuid() };
|
|
let cgroup = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
|
|
let in_scope = cgroup.lines().any(|l| {
|
|
l.starts_with("0::")
|
|
&& l.contains(&format!("/user@{uid}.service/"))
|
|
&& l.ends_with(".scope")
|
|
});
|
|
if !in_scope || !Path::new("/usr/bin/systemd-run").exists() {
|
|
eprintln!("skipped: not in a systemd app scope");
|
|
return;
|
|
}
|
|
let home = Home::new("noscope");
|
|
// A private runtime folder with no bus in it.
|
|
let runtime = home.dir.path().join("rt");
|
|
std::fs::create_dir(&runtime).unwrap();
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let started = Instant::now();
|
|
let out = home
|
|
.cmd("books")
|
|
.args(["create", "acme"])
|
|
.env("XDG_RUNTIME_DIR", &runtime)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(10),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
}
|
|
|
|
/// Review round 5, findings 2 and 3: a malformed byte is answered and the
|
|
/// session goes on; a message exactly at the limit is taken, and the next
|
|
/// one is not lost.
|
|
#[test]
|
|
fn malformed_and_boundary_input_never_ends_the_session() {
|
|
let home = Home::new("bytes");
|
|
home.create("acme");
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
{
|
|
let stdin = a.run.child.stdin.as_mut().unwrap();
|
|
stdin
|
|
.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":\"\xff\",\"method\":\"ping\"}\n")
|
|
.unwrap();
|
|
stdin.flush().unwrap();
|
|
}
|
|
let line = a.run.lines.recv_timeout(Duration::from_secs(20)).unwrap();
|
|
assert!(line.contains("-32700"), "{line}");
|
|
let pong = a.request("ping", json!({}), 20);
|
|
assert!(pong.get("result").is_some(), "{pong}");
|
|
// Exactly the limit, 1 MiB of message (a JSON string padded with
|
|
// spaces), then a ping: the big one is the server's to refuse, the
|
|
// ping answered.
|
|
{
|
|
let max = 1024 * 1024;
|
|
let head = b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/x\",\"params\":{\"pad\":\"";
|
|
let tail = b"\"}}";
|
|
let stdin = a.run.child.stdin.as_mut().unwrap();
|
|
stdin.write_all(head).unwrap();
|
|
let mut left = max - head.len() - tail.len();
|
|
let chunk = vec![b' '; 1024 * 1024];
|
|
while left > 0 {
|
|
let n = left.min(chunk.len());
|
|
stdin.write_all(&chunk[..n]).unwrap();
|
|
left -= n;
|
|
}
|
|
stdin.write_all(tail).unwrap();
|
|
stdin.write_all(b"\n").unwrap();
|
|
stdin.flush().unwrap();
|
|
}
|
|
let pong = a.request("ping", json!({}), 60);
|
|
assert!(pong.get("result").is_some(), "{pong}");
|
|
assert!(
|
|
!a.notes.iter().any(|n| n.to_string().contains("too large")),
|
|
"a message at the limit was refused: {:?}",
|
|
a.notes
|
|
);
|
|
}
|
|
|
|
/// Review round 5, finding 4: `doctor` doesn't hang on a server that
|
|
/// doesn't answer, and still shows the logs.
|
|
#[test]
|
|
fn doctor_doesnt_hang_on_a_frozen_server() {
|
|
let home = Home::new("frozen");
|
|
home.create("acme");
|
|
let _held = serve(&home);
|
|
let server = home.pids("server")[0] as i32;
|
|
// SAFETY: this test's own server.
|
|
unsafe { libc::kill(server, libc::SIGSTOP) };
|
|
let started = Instant::now();
|
|
let out = home.cmd("doctor").output().unwrap();
|
|
// SAFETY: as above.
|
|
unsafe { libc::kill(server, libc::SIGCONT) };
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(15),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
assert!(text.contains("doesn't answer"), "{text}");
|
|
assert!(text.contains("The host, last said:"), "{text}");
|
|
}
|
|
|
|
/// Review round 5, finding 1: an app started with a scrubbed environment
|
|
/// (no XDG_RUNTIME_DIR, as Codex starts its tools) doesn't take the host
|
|
/// down with it when it stops.
|
|
#[test]
|
|
fn a_scrubbed_app_scope_doesnt_take_the_host_with_it() {
|
|
// SAFETY: geteuid has no preconditions.
|
|
let uid = unsafe { libc::geteuid() };
|
|
let cgroup = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
|
|
if !cgroup.contains(&format!("/user@{uid}.service/"))
|
|
|| !Path::new("/usr/bin/systemd-run").exists()
|
|
{
|
|
eprintln!("skipped: no systemd user manager here");
|
|
return;
|
|
}
|
|
let home = Home::new("scrubbed");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
let unit = format!("wb-scrub-{}", std::process::id());
|
|
let mut c = Command::new("/usr/bin/systemd-run");
|
|
c.args([
|
|
"--user",
|
|
"--scope",
|
|
"--quiet",
|
|
"--collect",
|
|
&format!("--unit={unit}.scope"),
|
|
])
|
|
.arg("/usr/bin/env")
|
|
.arg("-i")
|
|
.arg("PATH=/usr/bin:/bin")
|
|
.arg(format!("HOME={}", home.dir.path().display()))
|
|
.arg(&home.exe)
|
|
.arg("serve")
|
|
.arg("--root")
|
|
.arg(home.root())
|
|
.args(["--test-knobs", KNOBS]);
|
|
let held = Running::start(c);
|
|
held.expect("The books are open", 30);
|
|
let host = home.host().unwrap();
|
|
let host_cgroup = std::fs::read_to_string(format!("/proc/{host}/cgroup")).unwrap();
|
|
assert!(
|
|
!host_cgroup.contains(&unit),
|
|
"the host is in the app's scope: {host_cgroup}"
|
|
);
|
|
let out = Command::new("systemctl")
|
|
.args(["--user", "stop", &format!("{unit}.scope")])
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
std::thread::sleep(Duration::from_millis(300));
|
|
assert!(
|
|
std::fs::metadata(format!("/proc/{host}")).is_ok(),
|
|
"the host died with the app"
|
|
);
|
|
assert!(home.wait_closed(10));
|
|
}
|
|
|
|
/// Review round 6, finding 1: a command ends at its deadline even while a
|
|
/// restart holds the update lock.
|
|
#[test]
|
|
fn a_command_ends_at_its_deadline_during_a_restart() {
|
|
let home = Home::new("deadline");
|
|
let lock = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.truncate(false)
|
|
.write(true)
|
|
.open(home.run_dir().join("update.lock"))
|
|
.unwrap();
|
|
lock.lock().unwrap();
|
|
let started = Instant::now();
|
|
let out = home
|
|
.cmd("books")
|
|
.args(["list", "--test-knobs", "open_wait=1000,close_after=2500"])
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("didn't open in time"),
|
|
"{out:?}"
|
|
);
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(6),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
drop(lock);
|
|
}
|
|
|
|
/// Review round 6, finding 2: `doctor` doesn't wait long on a frozen host.
|
|
#[test]
|
|
fn doctor_doesnt_hang_on_a_frozen_host() {
|
|
let home = Home::new("frozenhost");
|
|
home.create("acme");
|
|
let _held = serve(&home);
|
|
let host = home.host().unwrap() as i32;
|
|
// SAFETY: this test's own host.
|
|
unsafe { libc::kill(host, libc::SIGSTOP) };
|
|
let started = Instant::now();
|
|
let out = home.cmd("doctor").output().unwrap();
|
|
// SAFETY: as above.
|
|
unsafe { libc::kill(host, libc::SIGCONT) };
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(15),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
assert!(text.contains("isn't answering"), "{text}");
|
|
}
|
|
|
|
/// Review round 6, finding 3: Ctrl-C reaches `serve` while it waits to
|
|
/// try again.
|
|
#[test]
|
|
fn serve_hears_ctrl_c_while_it_waits_to_try_again() {
|
|
let home = Home::new("ctrlc");
|
|
std::fs::create_dir(home.run_dir().join("mcp.sock")).unwrap();
|
|
let mut held = Running::start({
|
|
let mut c = home.cmd("serve");
|
|
c.args(["--test-knobs", "close_after=2500,backoff=20000"]);
|
|
c
|
|
});
|
|
// The first start fails at once; serve then waits 20 s to try again.
|
|
held.expect("Opening the books", 30);
|
|
assert!(wait_for(10, || std::fs::read_to_string(
|
|
home.run_dir().join("host.log")
|
|
)
|
|
.is_ok_and(|l| l.contains("the books server stopped"))));
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
let asked = Instant::now();
|
|
held.signal(libc::SIGINT);
|
|
let status = held.wait(10).expect("serve didn't hear Ctrl-C");
|
|
assert!(status.success(), "{status:?}");
|
|
assert!(
|
|
asked.elapsed() < Duration::from_secs(5),
|
|
"{:?}",
|
|
asked.elapsed()
|
|
);
|
|
std::fs::remove_dir(home.run_dir().join("mcp.sock")).unwrap();
|
|
}
|
|
|
|
/// Review round 6, finding 4: every malformed message is answered.
|
|
#[test]
|
|
fn every_malformed_message_is_answered() {
|
|
let home = Home::new("malformed");
|
|
home.create("acme");
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
for (line, id) in [
|
|
(r#"{"jsonrpc":"2.0","id":5,"method":7}"#, json!(5)),
|
|
("42", Value::Null),
|
|
(r#"{"jsonrpc":"2.0","id":6}"#, json!(6)),
|
|
(
|
|
r#"{"jsonrpc":"2.0","id":{"a":1},"method":"ping"}"#,
|
|
Value::Null,
|
|
),
|
|
("[]", Value::Null),
|
|
] {
|
|
let stdin = a.run.child.stdin.as_mut().unwrap();
|
|
writeln!(stdin, "{line}").unwrap();
|
|
stdin.flush().unwrap();
|
|
let answer = a.run.lines.recv_timeout(Duration::from_secs(20)).unwrap();
|
|
let v: Value = serde_json::from_str(&answer).unwrap();
|
|
assert_eq!(v["error"]["code"], -32600, "{line}: {answer}");
|
|
assert_eq!(v["id"], id, "{line}: {answer}");
|
|
}
|
|
// And the session goes on.
|
|
assert!(a.call("books.start", json!({})).is_ok());
|
|
}
|
|
|
|
/// Review round 7, finding 1: a call to books that didn't open is
|
|
/// refused with why (it never ran), even for a client that skips
|
|
/// initialize; and a duplicate in-flight id is refused.
|
|
#[test]
|
|
fn books_that_didnt_open_refuse_plainly() {
|
|
let home = Home::new("damaged");
|
|
home.create("acme");
|
|
assert!(home.wait_closed(10));
|
|
// A damaged last line: the books won't open.
|
|
use std::io::Write as _;
|
|
let mut j = std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(home.root().join("books/acme/journal.jsonl"))
|
|
.unwrap();
|
|
writeln!(j, "{{\"not\": \"a journal line\"}}").unwrap();
|
|
drop(j);
|
|
let mut a = Agent::start(&home, "acme");
|
|
let meta = json!({
|
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
|
"io.modelcontextprotocol/clientCapabilities": {},
|
|
"io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"},
|
|
});
|
|
let r = a.request(
|
|
"tools/call",
|
|
json!({"name": "books.chart", "arguments": {}, "_meta": meta}),
|
|
60,
|
|
);
|
|
let why = r["error"]["message"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
assert!(why.contains("didn't open"), "{r}");
|
|
assert!(!why.contains("may or may not"), "{r}");
|
|
assert!(!why.contains(&home.root().display().to_string()), "{r}");
|
|
}
|
|
|
|
/// Review round 7, findings 2 and 3: `close` and `books list` don't hang
|
|
/// on a frozen host or server.
|
|
#[test]
|
|
fn close_and_list_dont_hang_on_frozen_processes() {
|
|
let home = Home::new("frozen2");
|
|
home.create("acme");
|
|
let _held = serve(&home);
|
|
let server = home.pids("server")[0] as i32;
|
|
let host = home.host().unwrap() as i32;
|
|
// SAFETY: this test's own server and host.
|
|
unsafe { libc::kill(server, libc::SIGSTOP) };
|
|
let started = Instant::now();
|
|
let out = home.books(&["list"]);
|
|
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(45),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
// SAFETY: as above.
|
|
unsafe {
|
|
libc::kill(server, libc::SIGCONT);
|
|
libc::kill(host, libc::SIGSTOP);
|
|
}
|
|
let started = Instant::now();
|
|
let out = home.cmd("close").output().unwrap();
|
|
// SAFETY: as above.
|
|
unsafe { libc::kill(host, libc::SIGCONT) };
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(15),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("isn't answering"),
|
|
"{out:?}"
|
|
);
|
|
}
|
|
|
|
/// Review round 8, findings 1, 2 and 7: books made after the agent's
|
|
/// session began are reached on the first call after; an initialize the
|
|
/// server won't take fails plainly, not as books that never open; and a
|
|
/// request id still in flight is refused.
|
|
#[test]
|
|
fn books_made_mid_session_and_bad_requests() {
|
|
let home = Home::new("midsession");
|
|
home.create("acme");
|
|
let mut a = Agent::start(&home, "beta");
|
|
a.initialize();
|
|
let err = a.call("books.start", json!({})).unwrap_err();
|
|
assert!(err.contains("no books called"), "{err}");
|
|
home.create("beta");
|
|
assert!(
|
|
a.call("books.start", json!({})).is_ok(),
|
|
"the first call after books create"
|
|
);
|
|
// An initialize without clientInfo: the server's own refusal, fast.
|
|
let mut b = Agent::start(&home, "acme");
|
|
let started = Instant::now();
|
|
let _ = b.request(
|
|
"initialize",
|
|
json!({"protocolVersion": "2025-06-18", "capabilities": {}}),
|
|
20,
|
|
);
|
|
b.send(json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
|
|
let err = b.call("books.start", json!({})).unwrap_err();
|
|
assert!(!err.contains("didn't open within"), "{err}");
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(20),
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
// Two requests with one id while the books open: the second refused.
|
|
let home2 = Home::new("dupid");
|
|
home2.create("acme");
|
|
assert!(home2.wait_closed(10));
|
|
let mut c = home2.cmd("mcp");
|
|
c.args([
|
|
"--book",
|
|
"acme",
|
|
"--test-knobs",
|
|
"close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000",
|
|
]);
|
|
let mut d = Agent::from(c);
|
|
d.initialize();
|
|
let call = json!({"jsonrpc": "2.0", "id": "same", "method": "tools/call",
|
|
"params": {"name": "books.start", "arguments": {}}});
|
|
d.send(call.clone());
|
|
d.send(call);
|
|
let first = d.run.lines.recv_timeout(Duration::from_secs(20)).unwrap();
|
|
let v: Value = serde_json::from_str(&first).unwrap();
|
|
assert_eq!(v["id"], "same", "{first}");
|
|
assert_eq!(v["error"]["code"], -32600, "{first}");
|
|
let second = d.run.lines.recv_timeout(Duration::from_secs(30)).unwrap();
|
|
let v: Value = serde_json::from_str(&second).unwrap();
|
|
assert!(
|
|
v.get("result").is_some(),
|
|
"the first one is still answered: {second}"
|
|
);
|
|
}
|
|
|
|
/// `wanderbooks books`: its flags taken for what they are, its usage on
|
|
/// request, and adoption's choices honoured (slice 3 mutation testing).
|
|
#[test]
|
|
fn the_books_command_takes_its_flags_and_asks_before_adopting() {
|
|
let home = Home::new("flags");
|
|
home.create("acme");
|
|
let out = home.books(&["--help"]);
|
|
assert_eq!(out.status.code(), Some(0));
|
|
let usage = String::from_utf8_lossy(&out.stderr);
|
|
assert!(usage.contains("adopt DATA-DIR --id ID"), "{usage}");
|
|
let out = home.books(&["-h"]);
|
|
assert_eq!(out.status.code(), Some(0));
|
|
let out = home.books(&["list", "--bogus"]);
|
|
assert_eq!(out.status.code(), Some(2));
|
|
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown flag: --bogus"));
|
|
// Old books to adopt: one setting the books don't know.
|
|
let old = tempfile::Builder::new()
|
|
.prefix("wb-held-flags-")
|
|
.tempdir()
|
|
.unwrap();
|
|
let data = old.path().join("books-data");
|
|
drop(ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap());
|
|
let toml = old.path().join("wanderbooks.toml");
|
|
std::fs::write(&toml, "[company]\nname = \"Old Co\"\nmystery = \"what\"\n").unwrap();
|
|
let json = old.path().join("config.json");
|
|
std::fs::write(&json, "{}").unwrap();
|
|
let vault = old.path().join("elsewhere");
|
|
std::fs::create_dir(&vault).unwrap();
|
|
let key = old.path().join("vault.key");
|
|
std::fs::write(&key, "not used").unwrap();
|
|
let adopt = |extra: &[&str]| {
|
|
let mut a = vec![
|
|
"adopt",
|
|
data.to_str().unwrap(),
|
|
"--id",
|
|
"old",
|
|
"--toml",
|
|
toml.to_str().unwrap(),
|
|
];
|
|
a.extend_from_slice(extra);
|
|
home.books(&a)
|
|
};
|
|
let err = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).to_string();
|
|
// A vault and its key come together, and never with --no-vault.
|
|
let v = vault.to_str().unwrap();
|
|
let k = key.to_str().unwrap();
|
|
for (extra, want) in [
|
|
(vec!["--vault", v], "give the vault's key too"),
|
|
(vec!["--vault-key", k], "give the vault's folder too"),
|
|
(vec!["--no-vault", "--vault", v], "can't both be given"),
|
|
(vec!["--no-vault", "--vault-key", k], "can't both be given"),
|
|
] {
|
|
let out = adopt(&extra);
|
|
assert_eq!(out.status.code(), Some(1), "{extra:?}: {out:?}");
|
|
assert!(err(&out).contains(want), "{extra:?}: {}", err(&out));
|
|
}
|
|
// An entry the books don't know: refused unless left behind.
|
|
let out = adopt(&[
|
|
"--yes",
|
|
"--no-vault",
|
|
"--config-json",
|
|
json.to_str().unwrap(),
|
|
]);
|
|
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
|
assert!(err(&out).contains("--skip-unknown"), "{}", err(&out));
|
|
// At a terminal: asked, and only a yes adopts.
|
|
let at_terminal = |answer: &str| {
|
|
use std::io::Write;
|
|
let mut args: Vec<String> = vec![
|
|
home.exe.display().to_string(),
|
|
"books".into(),
|
|
"--root".into(),
|
|
home.root().display().to_string(),
|
|
"--test-knobs".into(),
|
|
KNOBS.into(),
|
|
];
|
|
for a in [
|
|
"adopt",
|
|
data.to_str().unwrap(),
|
|
"--id",
|
|
"old",
|
|
"--toml",
|
|
toml.to_str().unwrap(),
|
|
"--no-vault",
|
|
"--skip-unknown",
|
|
"--config-json",
|
|
json.to_str().unwrap(),
|
|
] {
|
|
args.push(a.to_string());
|
|
}
|
|
let mut child = Command::new("script")
|
|
.args(["-qefc", &args.join(" "), "/dev/null"])
|
|
.env_clear()
|
|
.env("PATH", "/usr/bin:/bin")
|
|
.env("HOME", home.dir.path())
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
let mut stdin = child.stdin.take().unwrap();
|
|
std::thread::sleep(Duration::from_millis(1500));
|
|
let _ = writeln!(stdin, "{answer}");
|
|
let deadline = Instant::now() + Duration::from_secs(60);
|
|
while child.try_wait().unwrap().is_none() {
|
|
assert!(Instant::now() < deadline, "the adoption never ended");
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
drop(stdin);
|
|
let out = child.wait_with_output().unwrap();
|
|
String::from_utf8_lossy(&out.stdout).to_string()
|
|
};
|
|
let shown = at_terminal("n");
|
|
assert!(shown.contains("Adopt them? [y/N]"), "{shown}");
|
|
assert!(shown.contains("Not adopted."), "{shown}");
|
|
let listed = String::from_utf8_lossy(&home.books(&["list"]).stdout).to_string();
|
|
assert!(!listed.contains("old"), "{listed}");
|
|
let shown = at_terminal("y");
|
|
assert!(shown.contains("Adopted as \"old\""), "{shown}");
|
|
assert!(shown.contains("1 settings moved in."), "{shown}");
|
|
let head = shown
|
|
.lines()
|
|
.find(|l| l.starts_with("Head: event"))
|
|
.unwrap_or_else(|| panic!("{shown}"));
|
|
assert!(
|
|
head.trim_end().ends_with(").") && head.contains(" ("),
|
|
"{head}"
|
|
);
|
|
let hash = head
|
|
.split('(')
|
|
.nth(1)
|
|
.unwrap()
|
|
.trim_end_matches([')', '.', '\r']);
|
|
assert_eq!(hash.len(), 12, "{head}");
|
|
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "{head}");
|
|
}
|
|
|
|
#[test]
|
|
fn adopted_books_old_copies_are_put_aside_from_the_command_line() {
|
|
let home = Home::new("aside");
|
|
let old = tempfile::Builder::new()
|
|
.prefix("wb-held-aside-")
|
|
.tempdir()
|
|
.unwrap();
|
|
let data = old.path().join("books-data");
|
|
drop(ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap());
|
|
let toml = old.path().join("wanderbooks.toml");
|
|
std::fs::write(&toml, "[company]\nname = \"Old Co\"\n").unwrap();
|
|
let (d, t) = (data.to_str().unwrap(), toml.to_str().unwrap());
|
|
let out = home.books(&[
|
|
"adopt",
|
|
d,
|
|
"--id",
|
|
"old",
|
|
"--toml",
|
|
t,
|
|
"--no-vault",
|
|
"--yes",
|
|
]);
|
|
assert_eq!(out.status.code(), Some(0), "{out:?}");
|
|
let err = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).to_string();
|
|
let said = |o: &std::process::Output| String::from_utf8_lossy(&o.stdout).to_string();
|
|
// Not at a terminal and no --yes: what would go aside, and nothing moved.
|
|
let out = home.books(&["put-aside", "old", d, t]);
|
|
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
|
assert!(err(&out).contains("not put aside"), "{}", err(&out));
|
|
assert!(
|
|
said(&out).contains("These go aside, each whole"),
|
|
"{}",
|
|
said(&out)
|
|
);
|
|
assert!(said(&out).contains("byte for byte"), "{}", said(&out));
|
|
assert!(said(&out).contains("Nothing is deleted"), "{}", said(&out));
|
|
assert!(data.is_dir() && toml.is_file());
|
|
// A vault comes with its key; books of no such name hold nothing.
|
|
let out = home.books(&["put-aside", "old", d, "--vault", t, "--yes"]);
|
|
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
|
assert!(err(&out).contains("together"), "{}", err(&out));
|
|
let out = home.books(&["put-aside", "nobody", d, "--yes"]);
|
|
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
|
assert!(data.is_dir());
|
|
// With --yes: moved, whole, into the legacy folder; the books go on.
|
|
let out = home.books(&["put-aside", "old", d, t, "--yes"]);
|
|
assert_eq!(out.status.code(), Some(0), "{out:?}");
|
|
assert!(said(&out).contains("Put aside: 2 paths"), "{}", said(&out));
|
|
assert!(!data.exists() && !toml.exists());
|
|
let legacy: Vec<_> = std::fs::read_dir(home.root().join("legacy"))
|
|
.unwrap()
|
|
.map(|e| e.unwrap().path())
|
|
.collect();
|
|
assert_eq!(legacy.len(), 1, "{legacy:?}");
|
|
assert!(legacy[0].join("1/books-data/journal.jsonl").is_file());
|
|
assert!(legacy[0].join("put-aside.txt").is_file());
|
|
assert_eq!(
|
|
std::fs::read_to_string(legacy[0].join("2/wanderbooks.toml")).unwrap(),
|
|
"[company]\nname = \"Old Co\"\n"
|
|
);
|
|
let listed = said(&home.books(&["list"]));
|
|
assert!(listed.contains("old"), "{listed}");
|
|
}
|
|
|
|
/// A program that starts the bridge with its stderr closed (some launchers
|
|
/// do) still gets a working bridge, its descriptor 2 on /dev/null: Rust's
|
|
/// runtime opens it before main, so no socket the bridge opens can take
|
|
/// its number and be sent the error messages.
|
|
#[test]
|
|
fn a_bridge_started_without_stderr_keeps_its_descriptors_apart() {
|
|
use std::os::unix::process::CommandExt;
|
|
let home = Home::new("nostderr");
|
|
home.create("acme");
|
|
let mut c = home.cmd("mcp");
|
|
c.args(["--book", "acme"]);
|
|
// SAFETY: close is async-signal-safe.
|
|
unsafe {
|
|
c.pre_exec(|| {
|
|
libc::close(2);
|
|
Ok(())
|
|
});
|
|
}
|
|
let mut a = Agent::from(c);
|
|
a.initialize();
|
|
assert!(a.call("books.start", json!({})).is_ok());
|
|
let pid = a.run.child.id();
|
|
let fd2 = std::fs::read_link(format!("/proc/{pid}/fd/2")).unwrap();
|
|
assert_eq!(fd2, Path::new("/dev/null"));
|
|
assert!(a.end().is_some_and(|s| s.success()));
|
|
}
|
|
|
|
/// P2 slice 4: `wanderbooks token` keeps the token where the bridge finds
|
|
/// it (commands.rs INVARIANT 4, bridge.rs INVARIANT 11): made, used,
|
|
/// limited by its scopes, listed without its text, revoked at once.
|
|
#[test]
|
|
fn the_token_commands_and_the_bridge() {
|
|
let home = Home::new("tokencmd");
|
|
let out = home.books(&["create", "acme"]);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let token = |args: &[&str]| {
|
|
let out = home
|
|
.cmd("token")
|
|
.args(args)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
(
|
|
out.status.code(),
|
|
String::from_utf8_lossy(&out.stdout).to_string(),
|
|
String::from_utf8_lossy(&out.stderr).to_string(),
|
|
)
|
|
};
|
|
// A session begun before any token: told how to make one.
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
let err = a.call("books.start", json!({})).unwrap_err();
|
|
assert!(err.contains("wanderbooks token create"), "{err}");
|
|
// A read-only token, made: the same session uses it from now on.
|
|
let (code, made, _) = token(&["create", "--book", "acme", "--scopes", "read"]);
|
|
assert_eq!(code, Some(0), "{made}");
|
|
assert!(
|
|
made.contains("kept in a token file in the data root"),
|
|
"{made}"
|
|
);
|
|
assert!(
|
|
made.contains("books:read") && !made.contains("wb_"),
|
|
"{made}"
|
|
);
|
|
assert!(!made.contains(&home.root().display().to_string()));
|
|
assert!(a.call("books.start", json!({})).is_ok());
|
|
let err = a
|
|
.call(
|
|
"books.add_account",
|
|
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.contains("needs books:write"), "{err}");
|
|
// A name taken; replaced on request, and the running session takes
|
|
// the new token at its next call (the old one refused at the door, the
|
|
// token file read again: review of slice 4).
|
|
let (code, _, err) = token(&["create", "--book", "acme"]);
|
|
assert_eq!(code, Some(1));
|
|
assert!(err.contains("already have a token"), "{err}");
|
|
let (code, _, _) = token(&["create", "--book", "acme", "--replace"]);
|
|
assert_eq!(code, Some(0));
|
|
a.call(
|
|
"books.add_account",
|
|
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
|
)
|
|
.unwrap();
|
|
assert!(a.end().is_some());
|
|
let mut b = Agent::start(&home, "acme");
|
|
b.initialize();
|
|
for (code, name, ty) in [
|
|
("3000", "Capital", "equity"),
|
|
("6000", "Supplies", "expense"),
|
|
] {
|
|
b.call(
|
|
"books.add_account",
|
|
json!({"code": code, "name": name, "account_type": ty}),
|
|
)
|
|
.unwrap();
|
|
}
|
|
// Listed without its text; printed only by `print`.
|
|
let (_, listed, _) = token(&["list"]);
|
|
assert!(
|
|
listed.contains("acme agent books:read,books:write,books:post,books:admin"),
|
|
"{listed}"
|
|
);
|
|
assert!(!listed.contains("wb_"));
|
|
let (code, printed, _) = token(&["print", "--book", "acme"]);
|
|
assert_eq!(code, Some(0));
|
|
assert!(
|
|
printed.trim().starts_with("wb_") && printed.trim().len() == 67,
|
|
"{printed}"
|
|
);
|
|
// Revoked: the next call is refused, and the token file forgets it.
|
|
let (code, said, _) = token(&["revoke", "--book", "acme"]);
|
|
assert_eq!(code, Some(0), "{said}");
|
|
assert!(b.call("books.chart", json!({})).is_err());
|
|
let (code, _, err) = token(&["print", "--book", "acme"]);
|
|
assert_eq!(code, Some(1));
|
|
assert!(err.contains("keeps no token"), "{err}");
|
|
let (_, listed, _) = token(&["list"]);
|
|
assert!(listed.contains("No tokens yet"), "{listed}");
|
|
// Asked of books there aren't, or with a scope there isn't.
|
|
let (code, _, err) = token(&["create", "--book", "nobody"]);
|
|
assert_eq!(code, Some(1));
|
|
assert!(err.contains("no books called"), "{err}");
|
|
let (code, _, err) = token(&["create", "--book", "acme", "--scopes", "read,everything"]);
|
|
assert_eq!(code, Some(2));
|
|
assert!(err.contains("isn't a scope"), "{err}");
|
|
assert!(b.end().is_some());
|
|
}
|
|
|
|
/// Run `command` (as books.begin_upload or books.get_receipt gave it,
|
|
/// with its placeholder cut off) with `more` words after it: its output.
|
|
fn run_given(
|
|
home: &Home,
|
|
command: &str,
|
|
placeholder: &str,
|
|
more: &[&std::ffi::OsStr],
|
|
) -> std::process::Output {
|
|
let words: Vec<&str> = command
|
|
.strip_suffix(placeholder)
|
|
.unwrap_or_else(|| panic!("{command}"))
|
|
.split(' ')
|
|
.collect();
|
|
assert_eq!(words[0], "wanderbooks", "{command}");
|
|
home.cmd(words[1])
|
|
.args(&words[2..])
|
|
.args(more)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_sends_and_saves_files_with_the_commands_it_is_given() {
|
|
let home = Home::new("files");
|
|
home.create("acme");
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
let folder = home.dir.path().join("Downloads");
|
|
std::fs::create_dir(&folder).unwrap();
|
|
let mut bytes = b"%PDF-1.4 a big receipt ".to_vec();
|
|
bytes.resize(400 * 1024, b'x');
|
|
let file = folder.join("big receipt.pdf");
|
|
std::fs::write(&file, &bytes).unwrap();
|
|
let begin = |a: &mut Agent| {
|
|
a.call(
|
|
"books.begin_upload",
|
|
json!({"sha256": ledger_store::journal::sha256_hex(&bytes), "size": bytes.len(),
|
|
"mime_type": "application/pdf", "file_name": "big receipt.pdf",
|
|
"purpose": "receipt"}),
|
|
)
|
|
.unwrap()
|
|
};
|
|
let begun = begin(&mut a);
|
|
let upload = begun["upload_id"].as_str().unwrap().to_string();
|
|
let command = begun["command"].as_str().unwrap().to_string();
|
|
let quiet = |out: &std::process::Output| {
|
|
let text = format!(
|
|
"{}{}",
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
assert!(!text.contains(&home.root().display().to_string()), "{text}");
|
|
text
|
|
};
|
|
// Another file than the one declared: refused, nothing kept.
|
|
let other = folder.join("other.pdf");
|
|
std::fs::write(&other, b"%PDF-1.4 not it").unwrap();
|
|
let out = run_given(&home, &command, " <the file's path>", &[other.as_os_str()]);
|
|
assert!(!out.status.success());
|
|
assert!(quiet(&out).contains("not the"), "{}", quiet(&out));
|
|
// A file of exactly the largest size isn't refused for its size.
|
|
let edge = folder.join("edge.pdf");
|
|
std::fs::File::create(&edge)
|
|
.unwrap()
|
|
.set_len(25 * 1024 * 1024)
|
|
.unwrap();
|
|
let out = run_given(&home, &command, " <the file's path>", &[edge.as_os_str()]);
|
|
assert!(!out.status.success());
|
|
assert!(
|
|
quiet(&out).contains("more than the") && !quiet(&out).contains("at most"),
|
|
"{}",
|
|
quiet(&out)
|
|
);
|
|
std::fs::remove_file(&edge).unwrap();
|
|
// A file larger than any upload is refused before it is read.
|
|
let huge = folder.join("huge.pdf");
|
|
std::fs::File::create(&huge)
|
|
.unwrap()
|
|
.set_len(25 * 1024 * 1024 + 1)
|
|
.unwrap();
|
|
let out = run_given(&home, &command, " <the file's path>", &[huge.as_os_str()]);
|
|
assert!(!out.status.success());
|
|
assert!(quiet(&out).contains("at most"), "{}", quiet(&out));
|
|
// With no token kept for the name given: what the human does.
|
|
let out = home
|
|
.cmd("upload")
|
|
.args(["--book", "acme", "--token", "nobody", &upload])
|
|
.arg(&file)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert!(!out.status.success());
|
|
assert!(
|
|
quiet(&out).contains("wanderbooks token create"),
|
|
"{}",
|
|
quiet(&out)
|
|
);
|
|
// A path that looks like a flag goes after the command's `--`.
|
|
let dashed = folder.join("-r.pdf");
|
|
std::fs::copy(&file, &dashed).unwrap();
|
|
let out = home
|
|
.cmd("upload")
|
|
.args(["--book", "acme", "not-an-id", "--"])
|
|
.arg(&dashed)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
|
assert!(
|
|
quiet(&out).contains("isn't an upload's id"),
|
|
"{}",
|
|
quiet(&out)
|
|
);
|
|
// The file itself goes (by its copy whose name starts with `-`).
|
|
let out = run_given(&home, &command, " <the file's path>", &[dashed.as_os_str()]);
|
|
assert!(out.status.success(), "{}", quiet(&out));
|
|
assert!(quiet(&out).contains(&format!("Sent {} bytes", bytes.len())));
|
|
let filed = a
|
|
.call(
|
|
"books.file_document",
|
|
json!({"category": "other", "upload_id": upload}),
|
|
)
|
|
.unwrap();
|
|
let att = filed["attachment_id"].as_u64().unwrap();
|
|
// Handed over (D-46): the command saves a copy, beside any file of
|
|
// its name, never over it.
|
|
let back = a
|
|
.call(
|
|
"books.get_receipt",
|
|
json!({"attachment_id": att, "view": "bytes"}),
|
|
)
|
|
.unwrap();
|
|
let download = back["file"]["download"].as_str().unwrap().to_string();
|
|
for n in 1..=2 {
|
|
let out = run_given(
|
|
&home,
|
|
&download,
|
|
" --to <a folder>",
|
|
&["--to".as_ref(), folder.as_os_str()],
|
|
);
|
|
assert!(out.status.success(), "{}", quiet(&out));
|
|
let saved = folder.join(format!("big receipt ({n}).pdf"));
|
|
assert_eq!(std::fs::read(&saved).unwrap(), bytes);
|
|
assert!(quiet(&out).contains(&saved.display().to_string()));
|
|
}
|
|
assert_eq!(std::fs::read(&file).unwrap(), bytes, "never overwritten");
|
|
let out = run_given(
|
|
&home,
|
|
&download,
|
|
" --to <a folder>",
|
|
&["--to".as_ref(), file.as_os_str()],
|
|
);
|
|
assert!(!out.status.success());
|
|
assert!(quiet(&out).contains("isn't a folder"));
|
|
let out = home
|
|
.cmd("download")
|
|
.args(["--book", "acme", "999", "--to"])
|
|
.arg(&folder)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert!(!out.status.success());
|
|
quiet(&out);
|
|
assert_eq!(
|
|
std::fs::read_dir(&folder).unwrap().count(),
|
|
6,
|
|
"nothing else saved"
|
|
);
|
|
a.end();
|
|
}
|
|
|
|
#[test]
|
|
fn the_commands_given_run_as_the_agents_own_token() {
|
|
let home = Home::new("files-token");
|
|
home.create("acme");
|
|
let out = home
|
|
.cmd("token")
|
|
.args(["create", "--book", "acme", "--name", "bookkeeper"])
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
let mut c = home.cmd("mcp");
|
|
c.args(["--book", "acme", "--token", "bookkeeper"]);
|
|
let mut a = Agent::from(c);
|
|
a.initialize();
|
|
let folder = home.dir.path().join("Downloads");
|
|
std::fs::create_dir(&folder).unwrap();
|
|
let mut bytes = b"%PDF-1.4 kept by the bookkeeper ".to_vec();
|
|
bytes.resize(300 * 1024, b'y');
|
|
let file = folder.join("r.pdf");
|
|
std::fs::write(&file, &bytes).unwrap();
|
|
let begun = a
|
|
.call(
|
|
"books.begin_upload",
|
|
json!({"sha256": ledger_store::journal::sha256_hex(&bytes), "size": bytes.len(),
|
|
"mime_type": "application/pdf", "file_name": "r.pdf", "purpose": "receipt"}),
|
|
)
|
|
.unwrap();
|
|
let command = begun["command"].as_str().unwrap().to_string();
|
|
assert!(command.contains(" --token bookkeeper "), "{command}");
|
|
let out = run_given(&home, &command, " <the file's path>", &[file.as_os_str()]);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let filed = a
|
|
.call(
|
|
"books.file_document",
|
|
json!({"category": "other", "upload_id": begun["upload_id"]}),
|
|
)
|
|
.unwrap();
|
|
let back = a
|
|
.call(
|
|
"books.get_receipt",
|
|
json!({"attachment_id": filed["attachment_id"], "view": "bytes"}),
|
|
)
|
|
.unwrap();
|
|
let download = back["file"]["download"].as_str().unwrap().to_string();
|
|
assert!(download.contains(" --token bookkeeper "), "{download}");
|
|
let out = run_given(
|
|
&home,
|
|
&download,
|
|
" --to <a folder>",
|
|
&["--to".as_ref(), folder.as_os_str()],
|
|
);
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert_eq!(std::fs::read(folder.join("r (1).pdf")).unwrap(), bytes);
|
|
// A file the books make (an export) is saved the same way. The 1099
|
|
// worksheet counts payments out of the books' cash accounts.
|
|
let _ = a.call(
|
|
"books.add_account",
|
|
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
|
);
|
|
a.call(
|
|
"books.set_setting",
|
|
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
|
)
|
|
.unwrap();
|
|
let w = a
|
|
.call("books.vendor_1099", json!({"year": 2026, "csv": true}))
|
|
.unwrap();
|
|
let out = run_given(
|
|
&home,
|
|
w["csv"]["download"].as_str().unwrap(),
|
|
" --to <a folder>",
|
|
&["--to".as_ref(), folder.as_os_str()],
|
|
);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let csv = std::fs::read(folder.join("1099-worksheet-2026.csv")).unwrap();
|
|
assert_eq!(
|
|
w["csv"]["sha256"],
|
|
json!(ledger_store::journal::sha256_hex(&csv))
|
|
);
|
|
assert!(csv.starts_with(b"vendor_id,name,"));
|
|
// A file whose name isn't text goes, and comes back into a folder
|
|
// whose name isn't text.
|
|
use std::os::unix::ffi::OsStrExt;
|
|
let odd = folder.join(std::ffi::OsStr::from_bytes(b"r\xff.pdf"));
|
|
let mut other = bytes.clone();
|
|
other[40] = b'z';
|
|
std::fs::write(&odd, &other).unwrap();
|
|
let begun = a
|
|
.call(
|
|
"books.begin_upload",
|
|
json!({"sha256": ledger_store::journal::sha256_hex(&other), "size": other.len(),
|
|
"mime_type": "application/pdf", "file_name": "r2.pdf", "purpose": "receipt"}),
|
|
)
|
|
.unwrap();
|
|
let out = run_given(
|
|
&home,
|
|
begun["command"].as_str().unwrap(),
|
|
" <the file's path>",
|
|
&[odd.as_os_str()],
|
|
);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let filed = a
|
|
.call(
|
|
"books.file_document",
|
|
json!({"category": "other", "upload_id": begun["upload_id"]}),
|
|
)
|
|
.unwrap();
|
|
let odd_folder = folder.join(std::ffi::OsStr::from_bytes(b"out\xfe"));
|
|
std::fs::create_dir(&odd_folder).unwrap();
|
|
let out = home
|
|
.cmd("download")
|
|
.args(["--book", "acme", "--token", "bookkeeper"])
|
|
.arg(filed["attachment_id"].to_string())
|
|
.arg("--to")
|
|
.arg(&odd_folder)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert_eq!(std::fs::read(odd_folder.join("r2.pdf")).unwrap(), other);
|
|
// Anywhere else, a word that isn't text is refused plainly.
|
|
let out = home
|
|
.cmd("books")
|
|
.arg(std::ffi::OsStr::from_bytes(b"\xff"))
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
|
assert!(String::from_utf8_lossy(&out.stderr).contains("isn't UTF-8"));
|
|
a.end();
|
|
}
|
|
|
|
#[test]
|
|
fn a_backup_is_restored_from_the_command_line() {
|
|
let home = Home::new("restore");
|
|
home.create("acme");
|
|
let disk = home.dir.path().join("disk");
|
|
std::fs::create_dir(&disk).unwrap();
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
chart(&mut a);
|
|
post_one(&mut a, 1).unwrap();
|
|
a.call(
|
|
"books.set_setting",
|
|
json!({"changes": [{"key": "backup.folder", "value": disk.display().to_string()}],
|
|
"decided_by": "Test Owner"}),
|
|
)
|
|
.unwrap();
|
|
// Backed up by the server alone, soon after (the host passed on its
|
|
// quiet).
|
|
let mut s = Value::Null;
|
|
assert!(
|
|
wait_for(30, || {
|
|
s = a.call("books.backup", json!({})).unwrap();
|
|
s["targets"][0]["through_seq"] == s["head_seq"]
|
|
}),
|
|
"never backed up: {s}"
|
|
);
|
|
assert_eq!(s["targets"][0]["last_error"], Value::Null, "{s}");
|
|
let head = s["head_seq"].as_u64().unwrap();
|
|
let backup = disk.join("acme.git");
|
|
let backup = backup.to_str().unwrap();
|
|
// Previewed, and not done without a yes.
|
|
let out = home.books(&["restore", backup, "--id", "copy"]);
|
|
let said = String::from_utf8_lossy(&out.stdout);
|
|
assert!(said.contains(&format!("through event {head}")), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("confirm at a terminal"),
|
|
"{out:?}"
|
|
);
|
|
assert_eq!(out.status.code(), Some(1));
|
|
// Under its own name, over books that exist: refused.
|
|
let out = home.books(&["restore", backup, "--yes"]);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("already books called"),
|
|
"{out:?}"
|
|
);
|
|
// A repository named otherwise: restored as the books it holds, by
|
|
// default (here: over those, refused), said.
|
|
let renamed = home.dir.path().join("disk").join("fernhill-backup.git");
|
|
let out = Command::new("git")
|
|
.args(["clone", "-q", "--bare"])
|
|
.arg(backup)
|
|
.arg(&renamed)
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{out:?}");
|
|
let out = home.books(&["restore", renamed.to_str().unwrap(), "--yes"]);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("already books called \"acme\""),
|
|
"{out:?}"
|
|
);
|
|
let out = home.books(&["restore", renamed.to_str().unwrap(), "--id", "other"]);
|
|
let said = String::from_utf8_lossy(&out.stdout);
|
|
assert!(
|
|
said.contains("It is a backup of the books \"acme\""),
|
|
"{out:?}"
|
|
);
|
|
assert!(said.contains("--id acme"), "{out:?}");
|
|
// A repository whose name is no id: still restored as its books.
|
|
let odd = home.dir.path().join("disk").join("Fernhill_Backup.git");
|
|
std::fs::rename(&renamed, &odd).unwrap();
|
|
let out = home.books(&["restore", odd.to_str().unwrap(), "--yes"]);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("already books called \"acme\""),
|
|
"{out:?}"
|
|
);
|
|
std::fs::rename(&odd, &renamed).unwrap();
|
|
// Beside them.
|
|
let out = home.books(&["restore", backup, "--id", "copy", "--yes"]);
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("Restored as \"copy\""),
|
|
"{out:?}"
|
|
);
|
|
// Replacing them, the old kept: from the repository named otherwise,
|
|
// given no id (the books it holds are replaced).
|
|
post_one(&mut a, 2).unwrap();
|
|
let out = home.books(&["restore", renamed.to_str().unwrap(), "--replace", "--yes"]);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let said = String::from_utf8_lossy(&out.stdout);
|
|
assert!(
|
|
said.contains("kept in the legacy folder as acme-"),
|
|
"{out:?}"
|
|
);
|
|
// The head it restored (post 2 may have been backed up by then).
|
|
let restored: u64 = said
|
|
.split("Restored as \"acme\": through event ")
|
|
.nth(1)
|
|
.and_then(|rest| rest.split(' ').next())
|
|
.and_then(|n| n.parse().ok())
|
|
.expect("the restored head is said");
|
|
assert!(restored >= head, "{said}");
|
|
let head = restored;
|
|
assert!(!said.contains(&home.root().display().to_string()), "{said}");
|
|
let out = home.books(&["list"]);
|
|
let listed = String::from_utf8_lossy(&out.stdout);
|
|
assert!(
|
|
listed.contains("acme open") && listed.contains("copy open"),
|
|
"{out:?}"
|
|
);
|
|
// The session of before never reaches the books replaced: refused, or
|
|
// (its bridge opening a new session) the restored books.
|
|
if let Ok(start) = a.call("books.start", json!({})) {
|
|
assert_eq!(start["head"]["seq"].as_u64(), Some(head), "{start}");
|
|
}
|
|
a.end();
|
|
// A new session sees the restored books, at the backup's head.
|
|
let mut fresh = Agent::start(&home, "acme");
|
|
fresh.initialize();
|
|
let start = fresh.call("books.start", json!({})).unwrap();
|
|
assert_eq!(start["head"]["seq"].as_u64(), Some(head), "{start}");
|
|
fresh.end();
|
|
}
|
|
|
|
/// A stand-in for an agent's own command line (`claude`, `codex`): it
|
|
/// notes each call's arguments, a line each.
|
|
fn fake_agent(home: &Home, name: &str) -> (PathBuf, PathBuf) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let bin = home.dir.path().join("agent-bin");
|
|
std::fs::create_dir_all(&bin).unwrap();
|
|
let log = home.dir.path().join(format!("{name}.log"));
|
|
let script = bin.join(name);
|
|
std::fs::write(
|
|
&script,
|
|
format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n", log.display()),
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
(bin, log)
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_is_connected_and_reaches_the_books_by_what_it_was_given() {
|
|
let home = Home::new("connect");
|
|
let out = home.books(&["create", "beta"]);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let (bin, log) = fake_agent(&home, "claude");
|
|
let connect = |agent: &str, path: &Path| {
|
|
home.cmd("connect")
|
|
.args([agent, "--book", "beta"])
|
|
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap()
|
|
};
|
|
let out = connect("claude", &bin);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let said = String::from_utf8_lossy(&out.stdout);
|
|
assert!(
|
|
said.contains("Made the agent token of the books beta,"),
|
|
"{said}"
|
|
);
|
|
assert!(
|
|
said.contains("Claude Code reaches the books beta"),
|
|
"{said}"
|
|
);
|
|
let calls = std::fs::read_to_string(&log).unwrap();
|
|
let lines: Vec<&str> = calls.lines().collect();
|
|
assert_eq!(lines[0], "mcp remove --scope user wanderbooks-beta");
|
|
let add = lines[1];
|
|
assert!(
|
|
add.starts_with("mcp add --scope user wanderbooks-beta -- "),
|
|
"{add}"
|
|
);
|
|
assert!(add.contains(" mcp --book beta --root "), "{add}");
|
|
assert!(!said.contains("wb_"), "a token's text was shown");
|
|
// The command it was given reaches the books (with the token kept).
|
|
let given: Vec<&str> = add.split(" -- ").nth(1).unwrap().split(' ').collect();
|
|
let mut c = Command::new(given[0]);
|
|
c.args(&given[1..])
|
|
.env_clear()
|
|
.env("PATH", "/usr/bin:/bin")
|
|
.env("HOME", home.dir.path());
|
|
let mut agent = Agent::from(c);
|
|
agent.initialize();
|
|
let start = agent.call("books.start", json!({})).unwrap();
|
|
assert!(start["head"]["seq"].as_u64().is_some(), "{start}");
|
|
agent.end();
|
|
// Again: the token kept is used, not made anew.
|
|
let out = connect("claude", &bin);
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert!(
|
|
!String::from_utf8_lossy(&out.stdout).contains("Made the agent token"),
|
|
"{out:?}"
|
|
);
|
|
// Codex: its own list, no scope.
|
|
let (bin, log) = fake_agent(&home, "codex");
|
|
let out = connect("codex", &bin);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let calls = std::fs::read_to_string(&log).unwrap();
|
|
assert!(
|
|
calls
|
|
.lines()
|
|
.nth(1)
|
|
.unwrap()
|
|
.starts_with("mcp add wanderbooks-beta -- "),
|
|
"{calls}"
|
|
);
|
|
// An agent that isn't installed: said. (Never where a real one could
|
|
// be found and run: skipped on a machine with one installed system-wide.)
|
|
if ["/usr/local/bin/claude", "/opt/homebrew/bin/claude"]
|
|
.iter()
|
|
.any(|p| Path::new(p).exists())
|
|
{
|
|
return;
|
|
}
|
|
let empty = home.dir.path().join("empty-bin");
|
|
std::fs::create_dir_all(&empty).unwrap();
|
|
let out = home
|
|
.cmd("connect")
|
|
.args(["claude", "--book", "beta"])
|
|
.env("PATH", empty.display().to_string())
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(out.status.code(), Some(1));
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("isn't installed"),
|
|
"{out:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn books_are_backed_up_now_from_the_command_line() {
|
|
let home = Home::new("back-up");
|
|
home.create("acme");
|
|
// None set up: said, and failing.
|
|
let out = home.books(&["back-up", "acme"]);
|
|
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("No backups are set up"),
|
|
"{out:?}"
|
|
);
|
|
let disk = home.dir.path().join("disk");
|
|
std::fs::create_dir(&disk).unwrap();
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
a.call(
|
|
"books.set_setting",
|
|
json!({"changes": [{"key": "backup.folder", "value": disk.display().to_string()}],
|
|
"decided_by": "Test Owner"}),
|
|
)
|
|
.unwrap();
|
|
let out = home.books(&["back-up", "acme"]);
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("Backed up to the backup folder"),
|
|
"{out:?}"
|
|
);
|
|
assert!(disk.join("acme.git").join("HEAD").is_file());
|
|
// A folder gone since: said, and --new-disk begins it again.
|
|
std::fs::rename(disk.join("acme.git"), home.dir.path().join("away.git")).unwrap();
|
|
let out = home.books(&["back-up", "acme"]);
|
|
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("holds no backup"),
|
|
"{out:?}"
|
|
);
|
|
let out = home.books(&["back-up", "acme", "--new-disk"]);
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert!(disk.join("acme.git").join("HEAD").is_file());
|
|
a.end();
|
|
}
|
|
|
|
/// The viewer's owner (ledger-cli's `ViewerOwner`, over these ledger-host
|
|
/// functions): its hold is a lease of kind app; new books are made by the
|
|
/// server; "Close the books" ends every lease, the viewer's hold with it.
|
|
/// (A `serve` holds the books first: run in this test's process, a hold
|
|
/// that had to start the host would start this test's own program.)
|
|
#[test]
|
|
fn the_viewers_hold_new_books_and_close_the_books() {
|
|
use ledger_host::commands::{Common, close_books, hold_for_viewer, new_books};
|
|
let home = Home::new("viewer-owner");
|
|
home.create("acme");
|
|
let serving = serve(&home);
|
|
let common = Common {
|
|
root: Some(home.root()),
|
|
knobs: ledger_host::knobs::Knobs::parse(KNOBS).unwrap(),
|
|
..Default::default()
|
|
};
|
|
let hold = hold_for_viewer(&common, Some("acme".into()));
|
|
assert!(
|
|
wait_for(20, || home
|
|
.lease_line("{\"proto\":1,\"leases\":null}\n")
|
|
.contains("\"app\"")),
|
|
"the viewer's lease never showed"
|
|
);
|
|
assert_eq!(
|
|
new_books(&common, "beta", "USD").unwrap(),
|
|
"Made new books \"beta\" (USD)."
|
|
);
|
|
assert!(
|
|
new_books(&common, "beta", "USD")
|
|
.unwrap_err()
|
|
.contains("already books called")
|
|
);
|
|
let out = home.books(&["list"]);
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("beta open"),
|
|
"{out:?}"
|
|
);
|
|
let said = close_books(&common).unwrap();
|
|
assert!(said.starts_with("The books are closing"), "{said}");
|
|
// The viewer's hold lets go, for good (closed by the owner).
|
|
let (tx, rx) = mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let _ = tx.send(hold.join().is_ok());
|
|
});
|
|
assert_eq!(rx.recv_timeout(Duration::from_secs(30)), Ok(true));
|
|
serving.expect("closed", 30);
|
|
assert!(home.wait_closed(30));
|
|
// Closed: said so.
|
|
assert_eq!(close_books(&common).unwrap(), "The books are closed.");
|
|
}
|
|
|
|
#[test]
|
|
fn connect_makes_anew_a_token_whose_file_is_gone_and_says_when_an_agent_refuses() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let home = Home::new("connect-lost");
|
|
home.create("acme");
|
|
// The server keeps the books' agent token; its token file is gone.
|
|
std::fs::remove_dir_all(home.run_dir().join("tokens")).unwrap();
|
|
let (bin, _log) = fake_agent(&home, "claude");
|
|
let connect = |path: &Path| {
|
|
home.cmd("connect")
|
|
.args(["claude", "--book", "acme"])
|
|
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap()
|
|
};
|
|
let out = connect(&bin);
|
|
assert!(out.status.success(), "{out:?}");
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("Made the agent token of the books acme,"),
|
|
"{out:?}"
|
|
);
|
|
// The agent's own command refuses: said, with what it said.
|
|
let refusing = home.dir.path().join("refusing-bin");
|
|
std::fs::create_dir_all(&refusing).unwrap();
|
|
let script = refusing.join("claude");
|
|
std::fs::write(
|
|
&script,
|
|
"#!/bin/sh\n[ \"$2\" = add ] && { echo 'no room at the inn' >&2; exit 1; }\nexit 0\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
let out = connect(&refusing);
|
|
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
|
let err = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
err.contains("Claude Code didn't add the books: no room at the inn"),
|
|
"{err}"
|
|
);
|
|
}
|
|
|
|
/// tokens.rs INVARIANT 4, commands.rs INVARIANT 4, bridge.rs INVARIANT
|
|
/// 11: `token check` says whether the token file's token is the books'
|
|
/// live one, never its text; an unreadable token file is said, by the
|
|
/// check and by a bridge, never "no token"; and a token the file can't
|
|
/// keep is revoked again.
|
|
#[test]
|
|
fn token_check_says_where_the_token_file_stands_and_an_unreadable_one_is_said() {
|
|
let home = Home::new("tokencheck");
|
|
let out = home.books(&["create", "acme"]);
|
|
assert!(out.status.success(), "{out:?}");
|
|
let token = |args: &[&str]| {
|
|
let out = home
|
|
.cmd("token")
|
|
.args(args)
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
let said = (
|
|
out.status.code(),
|
|
String::from_utf8_lossy(&out.stdout).to_string(),
|
|
String::from_utf8_lossy(&out.stderr).to_string(),
|
|
);
|
|
assert!(
|
|
!said.1.contains("wb_") && !said.2.contains("wb_"),
|
|
"{said:?}"
|
|
);
|
|
said
|
|
};
|
|
let check = || token(&["check", "--book", "acme"]);
|
|
let (code, said, _) = check();
|
|
assert_eq!(code, Some(1));
|
|
assert!(said.contains("There is no token \"agent\""), "{said}");
|
|
let (code, _, err) = token(&["create", "--book", "acme"]);
|
|
assert_eq!(code, Some(0), "{err}");
|
|
let (code, said, _) = check();
|
|
assert_eq!(code, Some(0), "{said}");
|
|
assert!(said.contains("is the live one"), "{said}");
|
|
// The one file kept, mode 0600.
|
|
let folder = home.run_dir().join("tokens");
|
|
let item = std::fs::read_dir(&folder)
|
|
.unwrap()
|
|
.map(|e| e.unwrap().path())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(item.len(), 1);
|
|
let item = item[0].clone();
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mode = std::fs::metadata(&item).unwrap().permissions().mode();
|
|
assert_eq!(mode & 0o777, 0o600);
|
|
}
|
|
let mut a = Agent::start(&home, "acme");
|
|
a.initialize();
|
|
a.call("books.start", json!({})).unwrap();
|
|
assert!(a.end().is_some());
|
|
// The file holding a token replaced since: not the live one.
|
|
let old = std::fs::read_to_string(&item).unwrap();
|
|
let (code, _, _) = token(&["create", "--book", "acme", "--replace"]);
|
|
assert_eq!(code, Some(0));
|
|
std::fs::write(&item, &old).unwrap();
|
|
let (code, said, _) = check();
|
|
assert_eq!(code, Some(1));
|
|
assert!(
|
|
said.contains("isn't the live one") && said.contains("--replace"),
|
|
"{said}"
|
|
);
|
|
// A token file that can't be read (a folder in its place): a bridge
|
|
// with no token says so, never "make a token"; and a token made then
|
|
// is revoked again.
|
|
std::fs::remove_file(&item).unwrap();
|
|
std::fs::create_dir(&item).unwrap();
|
|
let mut b = Agent::start(&home, "acme");
|
|
b.initialize();
|
|
let err = b.call("books.start", json!({})).unwrap_err();
|
|
assert!(
|
|
err.contains("token file in the data root couldn't be read")
|
|
&& err.contains("token itself is fine"),
|
|
"{err}"
|
|
);
|
|
assert!(!err.contains("token create"), "{err}");
|
|
assert!(b.end().is_some());
|
|
let (code, _, err) = check();
|
|
assert_eq!(code, Some(1));
|
|
assert!(err.contains("token file couldn't give"), "{err}");
|
|
let (code, _, err) = token(&["create", "--book", "acme", "--replace"]);
|
|
assert_eq!(code, Some(1));
|
|
assert!(
|
|
err.contains("couldn't keep it") && err.contains("revoked again"),
|
|
"{err}"
|
|
);
|
|
std::fs::remove_dir(&item).unwrap();
|
|
let (_, listed, _) = token(&["list"]);
|
|
assert!(listed.contains("No tokens yet"), "{listed}");
|
|
}
|