P2 slice 8: the viewer's menu, setup, no confinement (D-32)

No Landlock, no seccomp, no helper: isolation is "same_user
(advisory)". The lease socket keeps two intents (close, format-upgrade
consent). The viewer holds the books open while it runs and gains a
Books menu: New books, Back up now, Restore (preview, then confirm),
Close, Connect Claude Code / Codex. New commands `wanderbooks books
back-up` and `wanderbooks connect claude|codex`. The scaffold
(`new`/`init` and its templates) is gone; install.sh needs no sudo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:22 -06:00
1 parent 019d8d76a3
commit 3cf359a0cd
28 files changed
+1976 -1931

No files matched your search

+1 -4
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "wanderbooks CLI: one command from nothing to a working books directory."
description = "wanderbooks: the one executable (viewer, books server, host, agents' bridge, owner's commands)."
[[bin]]
name = "wanderbooks"
@@ -24,17 +24,14 @@ ledger-gui = { path = "../ledger-gui", optional = true }
[dev-dependencies]
base64 = "0.22"
chrono = "0.4"
libc = "0.2"
reqwest = "0.13"
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
ledger-config = { path = "../ledger-config" }
ledger-core = { path = "../ledger-core" }
ledger-vault = { path = "../ledger-vault" }
serde_json = "1"
sha2 = "0.10"
toml = "1"
tempfile = "3"
[lints]
File diff suppressed because it is too large. Load diff
-458
View File
@@ -1,458 +0,0 @@
//! Integration tests for `wanderbooks new`: one command lands a complete
//! books dir with the MCP server registered for it, the TOML and JSON
//! parse, and re-running is refused without `--force` (which never
//! touches the company profile).
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
}
/// Stand-ins for the installed binaries, so tests never run cargo install.
fn fake_bins(tmp: &Path) -> PathBuf {
let dir = tmp.join("bin");
fs::create_dir_all(&dir).unwrap();
for b in ["ledger-mcp", "ledger-gui"] {
fs::write(dir.join(b), "#!/bin/sh\n").unwrap();
}
dir
}
fn run(tmp: &Path, args: &[&str]) -> (bool, String) {
let path = format!(
"{}:{}",
fake_bins(tmp).display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(bin())
.args(args)
.arg("--no-start")
.env("PATH", path)
.output()
.expect("run wanderbooks");
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), s)
}
fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) {
let mut args = vec!["new", books.to_str().unwrap()];
args.extend(extra);
run(tmp, &args)
}
#[test]
fn scaffold_lands_complete() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("acme-books");
let (ok, out) = new(tmp.path(), &books, &[]);
assert!(ok, "new should succeed: {out}");
for f in [
"justfile",
"AGENTS.md",
"CLAUDE.md",
"wanderbooks.toml",
".gitignore",
".mcp.json",
".claude/settings.json",
".codex/hooks.json",
] {
let p = books.join(f);
assert!(p.exists(), "{f} exists");
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
}
assert!(books.join("books-data").is_dir(), "books-data dir exists");
assert!(books.join(".git").is_dir(), "fresh dir gets git init");
let log = Command::new("git")
.args(["log", "--oneline"])
.current_dir(&books)
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&log.stdout).contains("init"),
"scaffold committed"
);
}
#[test]
fn the_server_is_registered_for_this_dir() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let books = books.canonicalize().unwrap();
let mcp: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap();
let server = &mcp["mcpServers"]["wanderbooks"];
assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp"));
assert_eq!(
server["args"],
serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()])
);
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap())
.unwrap();
assert_eq!(
settings["enabledMcpjsonServers"],
serde_json::json!(["wanderbooks"])
);
// Every session starts by checking the viewer against open_at_start.
let hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("SessionStart hook command");
assert!(hook.ends_with("just session-start"), "{hook}");
let codex: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".codex/hooks.json")).unwrap())
.unwrap();
let hook = codex["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("Codex SessionStart hook command");
assert!(hook.ends_with("just session-start"), "{hook}");
// The justfile opens this dir's books in the GUI.
let just = fs::read_to_string(books.join("justfile")).unwrap();
assert!(just.contains("ledger-gui\" --data-dir \"{{justfile_directory()}}/books-data\""));
for recipe in [
"agent:",
"gui:",
"gui-fg:",
"gui-status:",
"session-start:",
"backup:",
"update:",
] {
assert!(just.contains(recipe), "justfile has {recipe}");
}
assert_eq!(
fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(),
"@AGENTS.md"
);
let toml: toml::Value =
toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap();
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
}
#[test]
fn agents_md_carries_the_contract() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
for phrase in [
"preview",
"Posted is forever",
"reversing entries",
"receipt",
"onboard",
"chart_template",
"to the cent",
"just gui",
"just gui-status",
"open_at_start",
] {
assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}");
}
}
/// The agent reaches the books only through the MCP tools. The scaffolded
/// AGENTS.md once said "the journal is the evidence — read it", and an
/// agent `cat`ed the journal file instead of asking the books.
#[test]
fn agents_md_reaches_the_books_only_through_tools() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
for phrase in [
"only through the",
"wanderbooks MCP tools",
"Never open, list or parse",
"books.get_entry",
"books.search_entries",
"general_ledger",
"can't answer, say so.",
] {
assert!(md.contains(phrase), "AGENTS.md says {phrase:?}");
}
// Nothing points the agent at the books' files.
for path in ["books-data", "journal.jsonl", "wanderbooks.db", "read it"] {
assert!(!md.contains(path), "AGENTS.md never mentions {path:?}");
}
}
#[test]
fn rerun_refused_without_force_and_never_clobbers_the_profile() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let (ok, out) = new(tmp.path(), &books, &[]);
assert!(!ok, "second new without --force fails");
assert!(out.contains("--force"), "error points at --force: {out}");
fs::write(
books.join("wanderbooks.toml"),
"[company]\nname = \"Acme LLC\"\n",
)
.unwrap();
let (ok, _) = new(tmp.path(), &books, &["--force"]);
assert!(ok, "second new with --force succeeds");
assert!(
fs::read_to_string(books.join("wanderbooks.toml"))
.unwrap()
.contains("Acme LLC"),
"--force rewrites the scaffold, never the company profile"
);
}
#[test]
fn init_is_an_alias_and_respects_an_existing_repo() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
fs::create_dir_all(&books).unwrap();
assert!(
Command::new("git")
.args(["init", "-q"])
.current_dir(&books)
.status()
.unwrap()
.success()
);
let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]);
assert!(ok, "{out}");
assert!(books.join("wanderbooks.toml").exists());
}
#[test]
fn codex_gets_its_own_named_server() {
let tmp = tempfile::tempdir().unwrap();
let bin = fake_bins(tmp.path());
let log = tmp.path().join("codex-args");
// A stand-in codex that records how it was called.
fs::write(
bin.join("codex"),
format!("#!/bin/sh\necho \"$@\" > {}\n", log.display()),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(bin.join("codex"), fs::Permissions::from_mode(0o755)).unwrap();
let books = tmp.path().join("Acme Books");
let (ok, out) = new(tmp.path(), &books, &["--codex"]);
assert!(ok, "{out}");
let args = fs::read_to_string(&log).unwrap();
let books = books.canonicalize().unwrap();
assert!(
args.starts_with("mcp add wanderbooks-acme-books -- "),
"{args}"
);
assert!(
args.trim_end().ends_with(&format!(
"--data-dir {}",
books.join("books-data").display()
)),
"{args}"
);
let just = fs::read_to_string(books.join("justfile")).unwrap();
assert!(just.contains("codex mcp add \"wanderbooks-acme-books\""));
assert!(out.contains("just codex"), "{out}");
}
/// The `wanderbooks.toml` a fresh `new` writes.
fn scaffolded_toml(tmp: &Path) -> (PathBuf, String) {
let books = tmp.join("b");
assert!(new(tmp, &books, &[]).0);
let path = books.join("wanderbooks.toml");
let text = fs::read_to_string(&path).unwrap();
(path, text)
}
/// The example a commented line holds, if it is one: `# key = value`, or
/// `# [section]` / `# [[section]]`. Prose comments are not examples.
fn example(line: &str) -> Option<&str> {
let rest = line.strip_prefix("# ")?;
let is_key = |k: &str| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
let header = rest
.trim_start_matches('[')
.trim_end_matches(']')
.split('.')
.all(is_key);
let assignment = rest
.split_once(" = ")
.is_some_and(|(k, v)| is_key(k) && !v.trim().is_empty());
((rest.starts_with('[') && rest.ends_with(']') && header) || assignment).then_some(rest)
}
/// The template with the examples `pick` chooses uncommented.
fn uncomment(text: &str, pick: impl Fn(&str) -> bool) -> String {
text.lines()
.map(|l| match example(l) {
Some(ex) if pick(ex) => ex,
_ => l,
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_scaffolded_toml_loads_with_the_defaults() {
let tmp = tempfile::tempdir().unwrap();
let (path, text) = scaffolded_toml(tmp.path());
let s = ledger_config::load(&path).expect("scaffolded wanderbooks.toml loads");
let c = &s.company;
assert_eq!(c.name, "Unnamed company", "name is left for onboarding");
assert_eq!(c.fiscal_year_start, (1, 1));
assert_eq!(c.basis, "Accrual");
assert!(c.members.is_empty());
assert_eq!(c.tax_form, None);
assert_eq!(c.close_into, None);
assert!(c.close_sweep.is_empty());
assert_eq!(
c.profile,
[
("base_currency".to_string(), "USD".to_string()),
("fiscal_year_start".to_string(), "01-01".to_string()),
]
);
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
assert!(s.cash_accounts.is_empty());
assert!(s.financing_accounts.is_empty());
assert!(s.investing_accounts.is_empty());
let toml: toml::Value = toml::from_str(&text).unwrap();
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
// The justfile's session-start reads this line with sed, from column 0.
assert!(
text.lines().any(|l| l == "open_at_start = \"ask\""),
"{text}"
);
assert!(text.contains("SSN"), "the template warns off storing SSNs");
}
#[test]
fn every_example_in_the_toml_is_valid() {
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
let examples = text.lines().filter_map(example).count();
assert!(examples >= 20, "only {examples} examples found");
// close_into is for a single owner and refused with members, so it
// gets a pass of its own; together the two passes cover every example.
let single_owner = |ex: &str| ex.starts_with("close_into ");
let all = ledger_config::parse(&uncomment(&text, |ex| !single_owner(ex)))
.unwrap_or_else(|e| panic!("examples (without close_into) parse: {e}"));
let c = &all.company;
assert_eq!(c.name, "Acme Widgets LLC");
assert_eq!(c.tax_form, Some(ledger_core::TaxForm::Form1065));
assert_eq!(c.members.len(), 2);
assert_eq!(c.members[1].distribution_account.as_deref(), Some("3101"));
assert_eq!(c.close_sweep, ["3100"]);
assert!(c.profile.iter().any(|(k, _)| k == "ein"));
assert_eq!(all.cash_accounts, ["1000", "1010"]);
assert_eq!(all.financing_accounts, ["2100"]);
assert_eq!(all.investing_accounts, ["1400"]);
let single = ledger_config::parse(&uncomment(&text, single_owner))
.unwrap_or_else(|e| panic!("close_into example parses: {e}"));
assert_eq!(single.company.close_into.as_deref(), Some("3000"));
assert!(
ledger_config::parse(&uncomment(&text, |_| true)).is_err(),
"close_into with members is refused, as the template says"
);
}
/// The serde field names of `struct name` in ledger-config's source.
fn fields(src: &str, name: &str) -> Vec<String> {
let start = src
.find(&format!("struct {name} {{"))
.unwrap_or_else(|| panic!("ledger-config has struct {name}"));
let body = &src[start..];
let body = &body[..body.find("\n}").expect("struct ends")];
let found: Vec<String> = body
.lines()
.skip(1)
.map(str::trim)
.filter(|l| !l.starts_with("//") && !l.starts_with("#["))
.filter_map(|l| l.split_once(':'))
.map(|(k, _)| k.trim_start_matches("pub ").to_string())
.collect();
assert!(!found.is_empty(), "struct {name} has fields");
found
}
#[test]
fn the_toml_shows_every_setting_the_config_reads() {
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
// (section, key) for every assignment, examples included.
let mut section = String::new();
let mut present = Vec::new();
for line in uncomment(&text, |_| true).lines() {
if line.starts_with('[') {
section = line.trim_matches(['[', ']']).to_string();
} else if let Some((k, _)) = line.split_once(" = ") {
present.push((section.clone(), k.to_string()));
}
}
let has = |s: &str, k: &str| present.iter().any(|(ps, pk)| ps == s && pk == k);
// Read from the config crate's serde structs, so a new setting there
// fails here until the template shows it.
let src = include_str!("../../ledger-config/src/lib.rs");
assert_eq!(fields(src, "BooksToml"), ["company", "books", "vault"]);
for (strukt, sect) in [
("CompanyToml", "company"),
("MemberToml", "company.members"),
("BooksSection", "books"),
("VaultSection", "vault"),
] {
for key in fields(src, strukt) {
if strukt == "CompanyToml" && key == "members" {
assert!(text.contains("# [[company.members]]"));
continue;
}
assert!(has(sect, &key), "[{sect}] {key} is in the template");
}
}
// Read elsewhere: the justfile's session-start (main.rs), and the two
// the scaffold has always written live.
for (sect, key) in [
("viewer", "open_at_start"),
("company", "base_currency"),
("books", "data_dir"),
] {
assert!(has(sect, key), "[{sect}] {key} is in the template");
}
}
/// Moving the scaffolded settings file, and every example in it, into
/// books: each imports, reads as the file does (the import refuses
/// otherwise), and exporting it back and importing the export changes
/// nothing (plan/served.md P1: toml_round_trip_zero_events).
#[test]
fn the_toml_and_its_examples_import_and_round_trip() {
use ledger_core::settings::{Settings, Source};
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
let single_owner = |ex: &str| ex.starts_with("close_into ");
let source = Source {
decided_by: "onboarding".into(),
decided_on: chrono_date(),
citation: None,
note: None,
};
for file in [
text.clone(),
uncomment(&text, |ex| !single_owner(ex)),
uncomment(&text, single_owner),
] {
let import = ledger_config::codec::import(&file, None, &Settings::default(), "USD")
.unwrap_or_else(|e| panic!("imports: {e}\n{file}"));
assert!(import.unknown.is_empty(), "{:?}", import.unknown);
let mut books = Settings::default();
let changes = books.check_changes(&import.changes, &source).unwrap();
books.apply(&changes, &source, 1).unwrap();
let export = ledger_config::codec::export(&books, "USD");
let again = ledger_config::codec::import(&export, None, &books, "USD")
.unwrap_or_else(|e| panic!("the export imports: {e}\n{export}"));
assert!(again.changes.is_empty(), "{:?}\n{export}", again.changes);
}
}
fn chrono_date() -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()
}
+14
View File
@@ -150,6 +150,20 @@ fn a_mode_refuses_what_it_does_not_know() {
assert_eq!(out.status.code(), Some(2), "{out:?}");
}
#[test]
fn the_books_folder_scaffold_is_gone_and_says_so() {
for gone in ["new", "init"] {
let out = wanderbooks().args([gone, "acme"]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{gone}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains(&format!("`wanderbooks {gone}` is gone")),
"{err}"
);
assert!(err.contains("wanderbooks books create ID"), "{err}");
}
}
#[test]
fn a_mode_comes_first() {
for mode in ["gui", "dev-stdio", "server"] {
+247 -13
View File
@@ -170,7 +170,7 @@ impl Drop for Home {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let mut found = false;
for mode in ["serve", "mcp", "helper", "host", "server"] {
for mode in ["serve", "mcp", "host", "server"] {
for pid in self.pids(mode) {
found = true;
// SAFETY: a process of this test's own root.
@@ -922,19 +922,15 @@ fn the_lease_socket_takes_only_the_owners_intents() {
),
(
"{\"proto\":1,\"intent\":{\"back_up_now\":{\"book\":\"acme\"}}}\n",
"books.back_up_now",
"not understood",
),
(
"{\"proto\":1,\"intent\":{\"restore\":{\"source\":\"/x\",\"id\":\"acme\",\"replace\":true}}}\n",
"wanderbooks books restore",
"{\"proto\":1,\"intent\":{\"restore\":{\"source\":\"/x\",\"id\":\"acme\"}}}\n",
"not understood",
),
(
"{\"proto\":1,\"intent\":{\"new_books\":{\"id\":\"beta\",\"currency\":\"USD\"}}}\n",
"new books beta made",
),
(
"{\"proto\":1,\"intent\":{\"new_books\":{\"id\":\"beta\",\"currency\":\"USD\"}}}\n",
"already books called",
"not understood",
),
] {
let answer = home.lease_line(line);
@@ -946,7 +942,7 @@ fn the_lease_socket_takes_only_the_owners_intents() {
}
let out = home.books(&["list"]);
assert!(
String::from_utf8_lossy(&out.stdout).contains("beta open"),
!String::from_utf8_lossy(&out.stdout).contains("beta"),
"{out:?}"
);
}
@@ -1920,9 +1916,8 @@ fn the_books_command_takes_its_flags_and_asks_before_adopting() {
/// 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 the helper's socket can't take its
/// number and be sent the error messages (asked in slice 3's mutation
/// testing: the helper's descriptor 3 then is never its own pair's end).
/// 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;
@@ -2464,3 +2459,242 @@ fn a_backup_is_restored_from_the_command_line() {
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 books beta' agent token"), "{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 books"),
"{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(),
};
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_the_keyring_lost_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; this computer's keyring
// lost it.
std::fs::remove_dir_all(home.run_dir().join("test-keyring")).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 books acme' agent token"),
"{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}"
);
}
+3 -2
View File
@@ -1,7 +1,8 @@
//! Company settings from the books dir's `wanderbooks.toml`.
//!
//! `wanderbooks init` writes that file next to `books-data/`; onboarding
//! fills in `[company]`. The server reads the company name (report headers),
//! An older books dir keeps that file next to `books-data/` until
//! `books.import_settings` moves it into the journal (books of the data
//! root never read one). The server reads the company name (report headers),
//! the fiscal year start (fiscal-year-to-date reports), the members of a
//! multi-member LLC (`[[company.members]]`: name, share, capital account —
//! for the statement of members' equity), `[books] accounting_basis`
+5
View File
@@ -147,6 +147,8 @@ pub struct App {
live_label: bool,
/// The export menu (menu.rs).
menu: crate::menu::Menu,
/// The owner's Books menu (owner.rs).
owner_menu: crate::owner::OwnerMenu,
}
impl App {
@@ -191,6 +193,7 @@ impl App {
updated_at: -1e9,
live_label: false,
menu: Default::default(),
owner_menu: Default::default(),
}
}
@@ -644,6 +647,7 @@ impl App {
if let Some(b) = &books {
ui.add_space(2.0);
self.menu.ui(ui, b, false);
self.owner_menu.ui(ui, b, false);
}
ui.add_space(6.0);
if let Some(b) = &books {
@@ -742,6 +746,7 @@ impl App {
self.scheme_menu(ui, true);
if let Some(b) = &books {
self.menu.ui(ui, b, true);
self.owner_menu.ui(ui, b, true);
}
ui.add_space(6.0);
if let Some(b) = &books {
+1
View File
@@ -13,6 +13,7 @@ pub mod export;
pub mod fmt;
pub mod menu;
pub mod nav;
pub mod owner;
pub mod pages;
pub mod palette;
mod prefs;
+436
View File
@@ -0,0 +1,436 @@
//! The viewer's Books menu (plan P2 "Viewer menu", D-19, D-32): the
//! owner's own actions on the books, handed to the books server (back up
//! now, restore, new books, close the books) or to the owner's agent
//! (connect). The viewer itself still never writes the books.
//!
//! ```text
//! GOAL: the owner backs up, restores, starts new books, closes the books
//! and connects an agent from the viewer, without a terminal.
//! SOURCE: plan P2 ("Viewer menu (D-19)": "Back up now", "Restore…",
//! "New books…", "Close the books", "Connect Claude Code" and
//! "Connect Codex"; "Nothing in the viewer writes a book"); D-32
//! (no confinement: the viewer asks the server itself, as the
//! command line does).
//! INVARIANTS:
//! 1. Every action goes through the [`Owner`] the program gives the
//! viewer (the books server, or the agent's own command): nothing
//! here writes a file of the books.
//! 2. Each runs off the UI thread; what came of it is shown, in words.
//! 3. A restore is shown before it is done (the backup proven, what it
//! would do), and done only for that backup, at that head.
//! 4. Without an owner (the viewer on its own, older books dirs, the
//! demo) the menu isn't there.
//! ```
use crate::books::Books;
use egui::{RichText, Ui, vec2};
use egui_phosphor::regular as icon;
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, OnceLock};
/// The owner's agents the viewer can connect.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Agent {
/// Claude Code.
Claude,
/// Codex.
Codex,
}
impl Agent {
fn label(self) -> &'static str {
match self {
Agent::Claude => "Claude Code",
Agent::Codex => "Codex",
}
}
}
/// A restore previewed: what it would do, and what it is of (INVARIANT 3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RestorePlan {
/// The backup, as the server is told it.
pub source: String,
/// The books' id it comes back as.
pub id: String,
/// Whether it replaces the books of that id (kept in legacy/).
pub replace: bool,
/// The backup's head, proven.
pub head: (u64, String),
/// What it would do, a line each.
pub lines: Vec<String>,
}
/// The owner's actions, done by the program the viewer runs in (ledger-cli
/// over the books server and the agents' own commands). Each blocks: the
/// menu calls it off the UI thread.
pub trait Owner: Send + Sync {
/// Close the books: everything holding them lets go.
fn close_books(&self) -> Result<String, String>;
/// New, empty books `id` in `currency`.
fn new_books(&self, id: &str, currency: &str) -> Result<String, String>;
/// Back the books `book` up now.
fn back_up_now(&self, book: &str) -> Result<Vec<String>, String>;
/// Preview restoring the backup at `source` (as `id`, else its own
/// books' id), replacing the books of that id or not.
fn plan_restore(
&self,
source: &str,
id: Option<String>,
replace: bool,
) -> Result<RestorePlan, String>;
/// Restore as `plan` previewed it.
fn restore(&self, plan: &RestorePlan) -> Result<Vec<String>, String>;
/// Let `agent` reach the books `book`.
fn connect(&self, agent: Agent, book: &str) -> Result<Vec<String>, String>;
}
static OWNER: OnceLock<Arc<dyn Owner>> = OnceLock::new();
/// Give the viewer its owner (once, at start; INVARIANT 4).
pub fn set_owner(owner: Arc<dyn Owner>) {
let _ = OWNER.set(owner);
}
fn owner() -> Option<Arc<dyn Owner>> {
OWNER.get().cloned()
}
/// The id of books of the data root (their folder's name), or None for
/// an older books dir (INVARIANT 4).
pub fn book_id(books: &Books) -> Option<String> {
ledger_store::root::inside_a_root(&books.data_dir)
.then(|| books.data_dir.file_name()?.to_str().map(str::to_string))
.flatten()
}
type Outcome = Result<Vec<String>, String>;
/// A dialog open over the viewer.
#[derive(Default)]
enum Dialog {
#[default]
None,
/// "New books…": their id and currency.
NewBooks { id: String, currency: String },
/// "Restore…": the backup, then its preview.
Restore(Box<RestoreForm>),
/// "Close the books": asked first.
Close,
}
/// A restore's check under way, and what it was asked: the backup, the
/// id, whether it replaces.
type Checking = (
Receiver<Result<RestorePlan, String>>,
(String, String, bool),
);
#[derive(Default)]
struct RestoreForm {
source: String,
id: String,
replace: bool,
/// A check under way, and what it was asked (a result for anything
/// since edited is dropped).
planning: Option<Checking>,
plan: Option<RestorePlan>,
problem: Option<String>,
}
/// The menu's state: a dialog, the job under way, and the last outcome.
#[derive(Default)]
pub struct OwnerMenu {
/// The books last shown (by identity) and their id: found once for
/// each load, not every frame.
book: Option<(usize, Option<String>)>,
dialog: Dialog,
job: Option<(String, Receiver<Outcome>)>,
done: Option<(String, Outcome)>,
}
impl OwnerMenu {
/// The menu button and a note of the last outcome; its dialogs.
pub fn ui(&mut self, ui: &mut Ui, books: &Arc<Books>, compact: bool) {
let Some(owner) = owner() else {
return;
};
let key = Arc::as_ptr(books) as usize;
if self.book.as_ref().is_none_or(|(k, _)| *k != key) {
self.book = Some((key, book_id(books)));
}
let Some(book) = self.book.as_ref().and_then(|(_, id)| id.clone()) else {
return;
};
let p = crate::theme::pal(ui.ctx());
if let Some((what, rx)) = &self.job
&& let Ok(done) = rx.try_recv()
{
self.done = Some((what.clone(), done));
self.job = None;
}
let text = if compact {
RichText::new(icon::BOOKS).size(16.0).color(p.muted)
} else {
RichText::new(format!("{} Books", icon::BOOKS))
.font(crate::theme::regular(12.5))
.color(p.muted)
};
let mut button = egui::Button::new(text).frame(false);
if compact {
button = button.min_size(vec2(32.0, 32.0));
}
let busy = self.job.is_some();
let mut start: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None;
let response = ui
.add_enabled_ui(!busy, |ui| {
egui::containers::menu::MenuButton::from_button(button).ui(ui, |ui| {
ui.set_min_width(240.0);
if ui
.button(format!("{} Back up now", icon::CLOUD_ARROW_UP))
.on_hover_text("Back these books up now, to every backup their settings name")
.clicked()
{
let (o, b) = (owner.clone(), book.clone());
start = Some(("Back up now".into(), Box::new(move || o.back_up_now(&b))));
}
if ui
.button(format!("{} Restore…", icon::CLOCK_COUNTER_CLOCKWISE))
.on_hover_text("Bring back books from a backup, proven whole first")
.clicked()
{
self.dialog = Dialog::Restore(Box::default());
}
if ui
.button(format!("{} New books…", icon::BOOK_OPEN))
.on_hover_text("Start new, empty books beside these")
.clicked()
{
self.dialog = Dialog::NewBooks {
id: String::new(),
currency: "USD".into(),
};
}
ui.separator();
for agent in [Agent::Claude, Agent::Codex] {
if ui
.button(format!("{} Connect {}", icon::PLUGS_CONNECTED, agent.label()))
.on_hover_text(format!(
"Let {} reach these books (it keeps their token in this computer's keyring)",
agent.label()
))
.clicked()
{
let (o, b) = (owner.clone(), book.clone());
start = Some((
format!("Connect {}", agent.label()),
Box::new(move || o.connect(agent, &b)),
));
}
}
ui.separator();
if ui
.button(format!("{} Close the books", icon::POWER))
.on_hover_text("Close the books now: agents' sessions and every other holder let go")
.clicked()
{
self.dialog = Dialog::Close;
}
})
})
.inner;
if let Some((what, work)) = start {
self.start(ui.ctx(), what, work);
}
self.dialogs(ui.ctx(), &owner);
let said = if self.job.is_some() {
Some(("Working…".to_string(), p.muted))
} else {
self.done.as_ref().map(|(what, done)| match done {
Ok(lines) => (format!("{what}: {}", lines.join(" ")), p.muted),
Err(why) => (format!("{what}: {why}"), p.warning),
})
};
match said {
Some((text, _)) if compact => {
response.0.on_hover_text(text);
}
Some((text, color)) => {
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap());
}
None => {}
}
}
fn start(
&mut self,
ctx: &egui::Context,
what: String,
work: Box<dyn FnOnce() -> Outcome + Send>,
) {
let (tx, rx) = channel();
let ctx = ctx.clone();
std::thread::spawn(move || {
let _ = tx.send(work());
ctx.request_repaint();
});
self.job = Some((what, rx));
self.done = None;
}
/// The open dialog, if any.
fn dialogs(&mut self, ctx: &egui::Context, owner: &Arc<dyn Owner>) {
let mut close = false;
let mut run: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None;
match &mut self.dialog {
Dialog::None => {}
Dialog::Close => {
egui::Modal::new(egui::Id::new("owner-close")).show(ctx, |ui| {
ui.heading("Close the books?");
ui.label("Agents' sessions and everything else holding them let go. They open again when something holds them.");
ui.horizontal(|ui| {
if ui.button("Close the books").clicked() {
let o = owner.clone();
run = Some(("Close the books".into(), Box::new(move || o.close_books().map(|l| vec![l]))));
close = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
}
Dialog::NewBooks { id, currency } => {
egui::Modal::new(egui::Id::new("owner-new-books")).show(ctx, |ui| {
ui.heading("New books");
ui.label("Their id: lowercase letters, digits and hyphens.");
ui.text_edit_singleline(id);
ui.label("Their base currency (fixed for good):");
ui.text_edit_singleline(currency);
ui.horizontal(|ui| {
let ready = !id.trim().is_empty() && !currency.trim().is_empty();
if ui
.add_enabled(ready, egui::Button::new("Make them"))
.clicked()
{
let (o, i, c) = (owner.clone(), id.clone(), currency.clone());
run = Some((
"New books".into(),
Box::new(move || o.new_books(&i, &c).map(|l| vec![l])),
));
close = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
}
Dialog::Restore(form) => {
if let Some((rx, asked)) = &form.planning
&& let Ok(planned) = rx.try_recv()
{
let current = (form.source.clone(), form.id.clone(), form.replace);
let still = *asked == current;
form.planning = None;
match planned {
// What was checked is still what is asked.
Ok(plan) if still => {
form.id = plan.id.clone();
form.plan = Some(plan);
form.problem = None;
}
Err(why) if still => form.problem = Some(why),
_ => {}
}
}
egui::Modal::new(egui::Id::new("owner-restore")).show(ctx, |ui| {
ui.set_max_width(520.0);
ui.heading("Restore books");
ui.label("The backup: its repository's address, or its folder (the books' .git folder in your backup folder).");
// Not changed while it is being checked.
let open = form.planning.is_none();
let edited = ui
.add_enabled(
open,
egui::TextEdit::singleline(&mut form.source)
.desired_width(f32::INFINITY)
.hint_text("/media/backup/acme.git, or git@host:you/books.git"),
)
.changed();
ui.horizontal(|ui| {
ui.label("As the books");
let id_edited = ui
.add_enabled(
open,
egui::TextEdit::singleline(&mut form.id).hint_text("its own books' id"),
)
.changed();
let replace_edited = ui
.add_enabled(
open,
egui::Checkbox::new(&mut form.replace, "replacing them (kept in the legacy folder)"),
)
.changed();
if id_edited || replace_edited {
form.plan = None;
}
});
if edited {
form.plan = None;
form.problem = None;
}
if let Some(why) = &form.problem {
ui.colored_label(crate::theme::pal(ui.ctx()).warning, why);
}
if let Some(plan) = &form.plan {
ui.separator();
for line in &plan.lines {
ui.label(line);
}
}
ui.horizontal(|ui| {
let planning = form.planning.is_some();
match &form.plan {
None => {
let ready = !planning && !form.source.trim().is_empty();
if ui.add_enabled(ready, egui::Button::new("Check it")).clicked() {
let (tx, rx) = channel();
let (o, s, r) = (owner.clone(), form.source.clone(), form.replace);
let id = Some(form.id.trim().to_string()).filter(|i| !i.is_empty());
let ctx = ui.ctx().clone();
std::thread::spawn(move || {
let _ = tx.send(o.plan_restore(&s, id, r));
ctx.request_repaint();
});
let asked = (form.source.clone(), form.id.clone(), form.replace);
form.planning = Some((rx, asked));
form.problem = None;
}
if planning {
ui.label("Checking the backup…");
}
}
Some(plan) => {
if ui.button("Restore it").clicked() {
let (o, plan) = (owner.clone(), plan.clone());
run = Some(("Restore".into(), Box::new(move || o.restore(&plan))));
close = true;
}
}
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
}
}
if close {
self.dialog = Dialog::None;
}
if let Some((what, work)) = run {
self.start(ctx, what, work);
}
}
}
+407
View File
@@ -0,0 +1,407 @@
//! The viewer's Books menu (owner.rs, plan P2 "Viewer menu", D-32): each
//! item hands its work to the owner the program gives the viewer, off the
//! UI thread, and says what came of it; a restore is previewed first; the
//! viewer itself writes nothing of the books; without an owner, or for an
//! older books dir, the menu isn't there.
use chrono::NaiveDate;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::Currency;
use ledger_gui::App;
use ledger_gui::books::Books;
use ledger_gui::data::{self, Source};
use ledger_gui::nav::Route;
use ledger_gui::owner::{Agent, Owner, RestorePlan};
use ledger_store::Store;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
/// An owner that does nothing but say what it was asked.
#[derive(Default)]
struct Fake {
asked: Mutex<Vec<String>>,
}
impl Fake {
fn said(&self, what: String) {
self.asked
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(what);
}
}
impl Owner for Fake {
fn close_books(&self) -> Result<String, String> {
self.said("close".into());
Ok("The books are closing.".into())
}
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
self.said(format!("new {id} {currency}"));
Ok(format!("Made new books \"{id}\" ({currency})."))
}
fn back_up_now(&self, book: &str) -> Result<Vec<String>, String> {
// Slow enough to be seen at work.
std::thread::sleep(std::time::Duration::from_millis(300));
self.said(format!("back up {book}"));
Err("Not backed up to the backup folder: the backup folder isn't there (is its disk connected?)".into())
}
fn plan_restore(
&self,
source: &str,
id: Option<String>,
replace: bool,
) -> Result<RestorePlan, String> {
std::thread::sleep(std::time::Duration::from_millis(300));
self.said(format!("plan {source} {id:?} {replace}"));
Ok(RestorePlan {
source: source.to_string(),
id: id.unwrap_or_else(|| "acme".into()),
replace,
head: (7, "ab".repeat(32)),
lines: vec!["The backup at the disk is whole: through event 7.".into()],
})
}
fn restore(&self, plan: &RestorePlan) -> Result<Vec<String>, String> {
self.said(format!(
"restore {} {} {}",
plan.source, plan.id, plan.head.0
));
Ok(vec![format!("Restored as \"{}\".", plan.id)])
}
fn connect(&self, agent: Agent, book: &str) -> Result<Vec<String>, String> {
self.said(format!("connect {agent:?} {book}"));
Ok(vec!["Claude Code reaches the books.".into()])
}
}
/// One test at a time: they share the one owner and its record of calls.
fn alone() -> std::sync::MutexGuard<'static, ()> {
static ONE: Mutex<()> = Mutex::new(());
ONE.lock().unwrap_or_else(|e| e.into_inner())
}
/// The one owner of this test binary (the viewer takes one, once).
fn fake() -> Arc<Fake> {
static FAKE: OnceLock<Arc<Fake>> = OnceLock::new();
FAKE.get_or_init(|| {
let f = Arc::new(Fake::default());
ledger_gui::owner::set_owner(f.clone());
f
})
.clone()
}
fn today() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap()
}
/// Books in a data root (`root`: a folder holding the root's marker), or
/// an older books dir's.
fn books(root: bool) -> (tempfile::TempDir, PathBuf, Books) {
let dir = tempfile::Builder::new()
.prefix("wb-gui-owner-")
.tempdir()
.unwrap();
let book = if root {
std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap();
dir.path().join("books").join("acme")
} else {
dir.path().join("books-data")
};
std::fs::create_dir_all(&book).unwrap();
drop(Store::create(&book, Currency::USD).unwrap());
let b = data::load_once(&Source {
data_dir: book.clone(),
today: Some(today()),
})
.unwrap();
(dir, book, b)
}
/// Every file under `dir`, with its bytes.
fn files(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut out = Vec::new();
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
out.extend(files(&p));
} else {
out.push((p.clone(), std::fs::read(&p).unwrap()));
}
}
out.sort();
out
}
fn harness(b: Books) -> Harness<'static, App> {
let mut h = Harness::builder()
.with_size(egui::vec2(1200.0, 900.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview));
h.run_steps(3);
h
}
/// Run until the owner was asked `n` things, and the outcome shows.
fn settle(h: &mut Harness<'static, App>, f: &Fake, n: usize) {
for _ in 0..200 {
h.run_steps(2);
if f.asked.lock().unwrap().len() >= n {
h.run_steps(4);
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("the owner wasn't asked: {:?}", f.asked.lock().unwrap());
}
fn open_menu(h: &mut Harness<'static, App>) {
h.get_by_label_contains(" Books").click();
h.run_steps(3);
}
#[test]
fn the_books_menu_hands_each_action_to_the_owner_and_writes_nothing() {
let _alone = alone();
let f = fake();
let (_dir, book, b) = books(true);
let before = files(&book);
let mut h = harness(b);
let start = f.asked.lock().unwrap().len();
// Back up now: done by the owner; its trouble said, in its words.
open_menu(&mut h);
h.get_by_label_contains("Back up now").click();
settle(&mut h, &f, start + 1);
assert_eq!(f.asked.lock().unwrap()[start], "back up acme");
assert!(h.query_by_label_contains("is its disk connected").is_some());
// Connect Claude Code.
open_menu(&mut h);
h.get_by_label_contains("Connect Claude Code").click();
settle(&mut h, &f, start + 2);
assert_eq!(f.asked.lock().unwrap()[start + 1], "connect Claude acme");
// New books: asked for, then made.
let page_fields = h
.query_all_by_role(egui::accesskit::Role::TextInput)
.count();
open_menu(&mut h);
h.get_by_label_contains("New books").click();
h.run_steps(3);
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.focus();
h.run_steps(2);
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.type_text("beta");
h.run_steps(2);
h.get_by_label("Make them").click();
settle(&mut h, &f, start + 3);
assert_eq!(f.asked.lock().unwrap()[start + 2], "new beta USD");
// Restore: previewed (the backup checked), then done for that plan.
open_menu(&mut h);
h.get_by_label_contains("Restore…").click();
h.run_steps(3);
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.focus();
h.run_steps(2);
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.type_text("/media/disk/acme.git");
h.run_steps(2);
assert!(
h.query_by_label("Restore it").is_none(),
"not before it is checked"
);
h.get_by_label("Check it").click();
settle(&mut h, &f, start + 4);
assert_eq!(
f.asked.lock().unwrap()[start + 3],
"plan /media/disk/acme.git None false"
);
assert!(
h.query_by_label_contains("is whole: through event 7")
.is_some()
);
h.get_by_label("Restore it").click();
settle(&mut h, &f, start + 5);
assert_eq!(
f.asked.lock().unwrap()[start + 4],
"restore /media/disk/acme.git acme 7"
);
// Close the books: asked first; cancelled, nothing; then done.
open_menu(&mut h);
h.get_by_label_contains("Close the books").click();
h.run_steps(3);
h.get_by_label("Cancel").click();
h.run_steps(3);
assert_eq!(f.asked.lock().unwrap().len(), start + 5);
open_menu(&mut h);
h.get_by_label_contains("Close the books").click();
h.run_steps(3);
h.get_by_label("Close the books").click();
settle(&mut h, &f, start + 6);
assert_eq!(f.asked.lock().unwrap()[start + 5], "close");
// The viewer wrote nothing of the books.
drop(h);
let after: Vec<_> = files(&book)
.into_iter()
.filter(|(p, _)| !p.starts_with(book.join("ops")))
.collect();
let before: Vec<_> = before
.into_iter()
.filter(|(p, _)| !p.starts_with(book.join("ops")))
.collect();
assert_eq!(after, before);
}
/// Whether the widget is disabled.
fn disabled(node: &egui_kittest::Node<'_>) -> bool {
use egui_kittest::kittest::NodeT;
node.accesskit_node().is_disabled()
}
#[test]
fn nothing_is_changed_while_it_is_at_work() {
let _alone = alone();
let f = fake();
let (_dir, _book, b) = books(true);
let mut h = harness(b);
// A backup under way: the menu waits for it.
let n = f.asked.lock().unwrap().len();
open_menu(&mut h);
h.get_by_label_contains("Back up now").click();
h.run_steps(2);
assert!(
disabled(&h.get_by_label_contains(" Books")),
"the menu works on while busy"
);
settle(&mut h, &f, n + 1);
for _ in 0..50 {
h.run_steps(2);
if !disabled(&h.get_by_label_contains(" Books")) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(!disabled(&h.get_by_label_contains(" Books")));
// A restore being checked: what it asks can't change under it.
let page_fields = h
.query_all_by_role(egui::accesskit::Role::TextInput)
.count();
open_menu(&mut h);
h.get_by_label_contains("Restore…").click();
h.run_steps(3);
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.focus();
h.run_steps(2);
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.type_text("/media/disk/acme.git");
h.run_steps(2);
h.get_by_label("Check it").click();
h.run_steps(2);
for field in h
.get_all_by_role(egui::accesskit::Role::TextInput)
.skip(page_fields)
{
assert!(
disabled(&field),
"a field could change while it was checked"
);
}
let n = f.asked.lock().unwrap().len();
settle(&mut h, &f, n + 1);
assert!(h.query_by_label("Restore it").is_some());
// Edited after the check: checked again before it can be restored.
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.focus();
h.run_steps(2);
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.type_text("x");
h.run_steps(3);
assert!(h.query_by_label("Restore it").is_none());
assert!(h.query_by_label("Check it").is_some());
}
#[test]
fn an_older_books_dir_has_no_books_menu() {
let _alone = alone();
let _ = fake();
let (_dir, _book, b) = books(false);
let h = harness(b);
assert!(h.query_by_label_contains(" Books").is_none());
}
/// Screenshots of the menu and its dialogs, for eyes: set `SHOT_DIR`.
#[test]
fn screenshots() {
let Ok(out) = std::env::var("SHOT_DIR") else {
return;
};
let _alone = alone();
let out = PathBuf::from(out);
std::fs::create_dir_all(&out).unwrap();
let f = fake();
let (_dir, _book, b) = books(true);
let mut h = harness(b);
let shot = |h: &mut Harness<'static, App>, name: &str| {
h.run_steps(3);
h.render()
.unwrap()
.save(out.join(format!("{name}.png")))
.unwrap();
};
open_menu(&mut h);
shot(&mut h, "menu");
h.get_by_label_contains("Back up now").click();
let n = f.asked.lock().unwrap().len();
settle(&mut h, &f, n + 1);
shot(&mut h, "backed-up");
let page_fields = h
.query_all_by_role(egui::accesskit::Role::TextInput)
.count();
open_menu(&mut h);
h.get_by_label_contains("New books").click();
shot(&mut h, "new-books");
h.get_by_label("Cancel").click();
open_menu(&mut h);
h.get_by_label_contains("Restore…").click();
h.run_steps(3);
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.focus();
h.run_steps(2);
h.get_all_by_role(egui::accesskit::Role::TextInput)
.nth(page_fields)
.unwrap()
.type_text("/media/disk/acme.git");
shot(&mut h, "restore-ask");
h.get_by_label("Check it").click();
let n = f.asked.lock().unwrap().len();
settle(&mut h, &f, n + 1);
shot(&mut h, "restore-plan");
h.get_by_label("Cancel").click();
open_menu(&mut h);
h.get_by_label_contains("Close the books").click();
shot(&mut h, "close");
}
+2 -11
View File
@@ -50,7 +50,7 @@
use crate::knobs::{FLAG, Knobs};
use crate::lease::{HostMsg, Kind, Reason};
use crate::opener::{Opener, Starter};
use crate::opener::Opener;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
@@ -1241,15 +1241,7 @@ pub fn run(
let book = ledger_store::root::BookId::parse(&book)
.map_err(|e| e.to_string())?
.to_string();
// The helper first, before any thread (helper.rs INVARIANT 4).
let helper = Arc::new(crate::helper::Client::spawn(root.as_deref(), &knobs)?);
let opener = Opener::new(
root.as_deref(),
knobs,
Starter::Helper(helper.clone()),
Kind::Bridge,
Some(book.clone()),
)?;
let opener = Opener::new(root.as_deref(), knobs, Kind::Bridge, Some(book.clone()))?;
// Kept for the whole session: the client reaches the socket by it.
let socket =
ledger_store::root::SocketPath::new(opener.run_dir(), ledger_mcp::local::MCP_SOCKET)
@@ -1337,7 +1329,6 @@ pub fn run(
Ok::<(), String>(())
});
rt.shutdown_timeout(Duration::from_secs(1));
drop(helper);
result.map_err(Into::into)
}
+553 -123
View File
@@ -1,10 +1,14 @@
//! The owner's commands: `wanderbooks serve`, `close`, `books` and
//! `token` (plan 3.3, 3.9, 5.5).
//! The owner's commands: `wanderbooks serve`, `close`, `books` (list,
//! create, adopt, restore, back-up), `token`, `connect`, `doctor` and
//! `passphrase` (plan 3.3, 3.7, 3.9, 5.5), and the same work for the
//! viewer's Books menu (`new_books`, `back_up_now`, `plan_restore`,
//! `carry_out_restore`, `connect_agent`, `close_books`, `hold_for_viewer`).
//!
//! ```text
//! GOAL: from a terminal the owner holds the books open, closes them, and
//! makes, lists and adopts books, without ever naming or touching
//! the books' private folder.
//! GOAL: from a terminal, or the viewer's menu, the owner holds the books
//! open, closes them, makes, adopts, backs up and restores books and
//! connects agents, without ever naming or touching the books'
//! private folder.
//! SOURCE: plan/served.md 3.9 (a terminal running `wanderbooks serve`, and
//! any command that needs the server, holds a lease; "Close the
//! books" ends every lease), 3.1 (the CLI talks to admin.sock and
@@ -20,12 +24,16 @@
//! 4. A token made is kept in the keyring before anything is said of it,
//! and its text is printed only by `token print`; one that can't be
//! kept is revoked again. A token revoked is forgotten by the keyring
//! too.
//! too. `connect` makes the books' agent token only when the keyring
//! has none, replacing the server's (no one could be using it: the
//! bridge reads its token from this keyring).
//! 5. The viewer's hold lets go only when the owner closes the books;
//! books that stop, or won't open, are tried again.
//! ```
use crate::knobs::{FLAG, Knobs};
use crate::lease::{Ask, HostMsg, Intent, Kind, Reason};
use crate::opener::{HoldError, Lease, Opener, Starter};
use crate::opener::{HoldError, Lease, Opener};
use ledger_mcp::admin::{self, Request, Response};
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -38,10 +46,13 @@ const LIST_WAIT: Duration = Duration::from_secs(30);
const CLOSE_WAIT: Duration = Duration::from_secs(120);
/// Common flags: `--root DIR` and the test knobs.
#[derive(Default)]
struct Common {
root: Option<PathBuf>,
knobs: Knobs,
#[derive(Default, Clone)]
pub struct Common {
/// The data root named (`--root`), else the one this account's host
/// finds.
pub root: Option<PathBuf>,
/// The test knobs.
pub knobs: Knobs,
}
impl Common {
@@ -119,13 +130,7 @@ fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBu
.build()
.map_err(|e| e.to_string())?;
let outcome = rt.block_on(async {
let mut opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Cli,
None,
)?;
let mut opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)?;
let lease = open(&mut opener).await.map_err(|e| match e {
HoldError::ClosedByOwner => {
"the books were closed just now (Close the books): nothing was done".to_string()
@@ -219,7 +224,6 @@ pub fn serve(
let mut opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Serve,
None,
)?;
@@ -393,9 +397,13 @@ fn books_usage(prog: &str) {
" {prog} adopt DATA-DIR --id ID [--toml FILE] [--config-json FILE] [--vault DIR --vault-key FILE | --no-vault] [--skip-unknown] [--yes]"
);
eprintln!(" {prog} restore BACKUP [--id ID] [--replace] [--yes]");
eprintln!(" {prog} back-up ID [--new-disk]");
eprintln!(" list the books, and whether each is open");
eprintln!(" create start new, empty books (base currency USD unless given)");
eprintln!(" adopt copy existing books in, verified; the original is left as it was");
eprintln!(
" back-up back the books up now to every backup their settings name (--new-disk: the backup folder's disk is new)"
);
eprintln!(
" restore bring back a backup (a git repository's address, or BOOK.git in the backup folder), proven whole first: as new books, or replacing books (kept in legacy/)"
);
@@ -418,6 +426,7 @@ pub fn books(
let mut skip_unknown = false;
let mut yes = false;
let mut replace = false;
let mut new_disk = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
@@ -434,6 +443,7 @@ pub fn books(
"--no-vault" => no_vault = true,
"--skip-unknown" => skip_unknown = true,
"--replace" => replace = true,
"--new-disk" => new_disk = true,
"--yes" => yes = true,
"--help" | "-h" => {
books_usage(prog);
@@ -478,6 +488,20 @@ pub fn books(
);
}
["restore", source] => return restore(&common, source, id, replace, yes),
["back-up", book] => {
return match back_up_now(&common, book, new_disk) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => {
eprintln!("error: {why}");
Err(exit(1))
}
};
}
_ => {
books_usage(prog);
return Err(exit(2));
@@ -521,6 +545,386 @@ pub fn books(
}
}
/// Back the books `book` up now (the server does it): what came of it, a
/// line each; Err when it couldn't be done, or a target didn't take it.
pub fn back_up_now(common: &Common, book: &str, new_disk: bool) -> Result<Vec<String>, String> {
match with_server(
common,
Request::BackUpNow {
id: book.to_string(),
new_disk,
},
)? {
Response::BackedUp { head_seq, targets } => {
let lines = backed_up_lines(head_seq, &targets);
if targets.is_empty() || targets.iter().any(|t| t.problem.is_some()) {
Err(lines.join("\n"))
} else {
Ok(lines)
}
}
Response::Refused(why) => Err(why),
other => Err(format!("the books server answered oddly: {other:?}")),
}
}
/// "New books…": new, empty books `id` in `currency` (the server makes
/// them).
pub fn new_books(common: &Common, id: &str, currency: &str) -> Result<String, String> {
match with_server(
common,
Request::Create {
id: id.trim().to_string(),
currency: currency.trim().to_string(),
},
)? {
Response::Created { id, problem: None } => {
Ok(format!("Made new books \"{id}\" ({}).", currency.trim()))
}
Response::Created {
id,
problem: Some(why),
} => Err(format!(
"new books \"{id}\" were made but didn't open: {why}. Closing the books and opening them again tries again."
)),
Response::Refused(why) => Err(why),
other => Err(format!("the books server answered oddly: {other:?}")),
}
}
/// "Close the books": everything holding them lets go (the lease
/// socket's close intent). What came of it.
pub fn close_books(common: &Common) -> Result<String, String> {
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
match crate::opener::ask_once_within(&run, Ask::Intent(Intent::Close), HOST_WAIT)? {
None => Ok("The books are closed.".into()),
Some(HostMsg::Done { detail }) => Ok(detail
.map(|d| {
let mut c = d.chars();
c.next()
.map(|first| format!("{}{}.", first.to_uppercase(), c.as_str()))
.unwrap_or_default()
})
.filter(|d| !d.is_empty())
.unwrap_or_else(|| "The books are closing.".into())),
Some(HostMsg::Refused(why)) => Err(why),
Some(HostMsg::Uncertain(why)) => Err(why),
Some(other) => Err(format!("the books' host answered oddly: {other:?}")),
}
}
/// The longest an agent's own command may take to add the books.
const CONNECT_WAIT: Duration = Duration::from_secs(60);
/// How long the viewer's hold waits before trying again after books that
/// wouldn't open.
const VIEWER_RETRY: Duration = Duration::from_secs(30);
/// Hold the books `book` open while the viewer runs (a lease of kind
/// app), on a thread of its own: the window never waits for it. It lets
/// go only when the owner closes the books (and when the viewer ends,
/// with the process); books that stop, or won't open, are tried again.
pub fn hold_for_viewer(common: &Common, book: Option<String>) -> std::thread::JoinHandle<()> {
let common = common.clone();
std::thread::spawn(move || {
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return;
};
rt.block_on(async {
let Ok(mut opener) = Opener::new(common.root.as_deref(), common.knobs, Kind::App, book)
else {
return;
};
loop {
// The same opener each time round (it knows it held the
// books, so it never reopens books the owner closed); its
// failures counted afresh.
opener.forget_failures();
loop {
let mut lease = match open(&mut opener).await {
Ok(lease) => lease,
Err(HoldError::ClosedByOwner) => return,
Err(HoldError::Failed(_)) => break,
};
match lease.next().await {
Some(HostMsg::Ending {
reason: Reason::ClosedByOwner,
..
}) => return,
Some(HostMsg::Ending {
reason: Reason::Restarting | Reason::Closing,
..
}) => {}
_ => {
if opener.failed().await.is_err() {
break;
}
}
}
}
tokio::time::sleep(VIEWER_RETRY).await;
}
});
})
}
/// What a backup made now came to, a line each.
pub fn backed_up_lines(head_seq: u64, targets: &[admin::BackedUpTarget]) -> Vec<String> {
if targets.is_empty() {
return vec![
"No backups are set up: ask your agent to set the private repository and the backup folder.".into(),
];
}
targets
.iter()
.map(|t| match (&t.problem, t.through_seq) {
(None, Some(seq)) if seq == head_seq => {
format!("Backed up to {} (through event {seq}).", t.at)
}
(None, Some(seq)) => format!("Backed up to {} through event {seq}.", t.at),
(None, None) => format!("Not yet backed up to {}.", t.at),
(Some(why), _) => format!("Not backed up to {}: {why}", t.at),
})
.collect()
}
/// `wanderbooks connect claude|codex --book ID`: an agent of the owner's
/// reaches the books.
pub fn connect(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut common = Common::default();
let mut book: Option<String> = None;
let mut words: Vec<String> = Vec::new();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if common.take(&arg, &mut args)? {
continue;
}
match arg.as_str() {
"--book" => book = Some(args.next().ok_or("--book needs an id")?),
"--help" | "-h" => {
eprintln!("usage: {prog} claude|codex --book ID");
eprintln!(
" Lets Claude Code or Codex reach the books: keeps their token in this computer's keyring and adds the books to the agent's own list."
);
return Ok(());
}
s if s.starts_with('-') => {
eprintln!("unknown flag: {s}");
eprintln!("usage: {prog} claude|codex --book ID");
return Err(exit(2));
}
_ => words.push(arg),
}
}
let (Some(agent), Some(book), 1) = (words.first(), book, words.len()) else {
eprintln!("usage: {prog} claude|codex --book ID");
return Err(exit(2));
};
match connect_agent(&common, agent, &book) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => {
eprintln!("error: {why}");
Err(exit(1))
}
}
}
/// Let the agent `agent` ("claude" or "codex") reach the books `book`:
/// their token kept in the keyring (made if none is), and `wanderbooks
/// mcp --book <book>` added to the agent's own list as
/// `wanderbooks-<book>`. What was done, a line each.
pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<String>, String> {
let (cli, label) = match agent {
"claude" => ("claude", "Claude Code"),
"codex" => ("codex", "Codex"),
other => return Err(format!("{other:?}: connect claude or codex")),
};
let book = ledger_store::root::BookId::parse(book)
.map_err(|e| e.to_string())?
.to_string();
// The PATH first; then where the agents' installers put them (a
// viewer started from the desktop has a short PATH).
// ($HOME: a test's own home is never the account's.)
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.filter(|h| h.is_absolute());
let usual = [
".local/bin",
".npm-global/bin",
".bun/bin",
".cargo/bin",
".volta/bin",
]
.into_iter()
.filter_map(|d| home.as_ref().map(|h| h.join(d)))
.chain(
["/usr/local/bin", "/opt/homebrew/bin"]
.into_iter()
.map(PathBuf::from),
);
let program = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.chain(usual)
.filter(|d| d.is_absolute())
.map(|d| d.join(cli))
.find(|p| {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
})
.ok_or_else(|| {
format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)")
})?;
let mut lines = Vec::new();
// The token: kept in the keyring, or made now.
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let account = crate::tokens::Account::new(found.path(), &book, crate::tokens::DEFAULT_NAME);
if crate::tokens::keeper(&run, &common.knobs)
.get(&account)?
.is_none()
{
let scopes = ledger_store::auth::Scope::ALL
.iter()
.map(|s| s.as_str().to_string())
.collect();
make_token(common, &book, crate::tokens::DEFAULT_NAME, scopes, true)?;
lines.push(format!(
"Made the books {book}' agent token, kept in this computer's keyring."
));
}
// The agent's own list: the books' entry made anew.
let exe = crate::start::this_executable()?;
let name = format!("wanderbooks-{book}");
let mut serve: Vec<std::ffi::OsString> = vec![
exe.into_os_string(),
"mcp".into(),
"--book".into(),
book.clone().into(),
];
if common.root.is_some() {
serve.push("--root".into());
serve.push(found.path().as_os_str().to_os_string());
}
serve.extend(common.knobs.args().into_iter().map(Into::into));
let scope: &[&str] = if agent == "claude" {
&["--scope", "user"]
} else {
&[]
};
// Each call within a minute: an agent's command that hangs is said,
// never waited on for good.
let run_cli = |args: Vec<std::ffi::OsString>| -> Result<std::process::Output, String> {
let mut child = std::process::Command::new(&program)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("{label} couldn't be run: {}", e.kind()))?;
let deadline = std::time::Instant::now() + CONNECT_WAIT;
loop {
if child.try_wait().map_err(|e| e.to_string())?.is_some() {
return child.wait_with_output().map_err(|e| e.to_string());
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
return Err(format!("{label} didn't answer in time: nothing was added"));
}
std::thread::sleep(Duration::from_millis(50));
}
};
let mut remove: Vec<std::ffi::OsString> = vec!["mcp".into(), "remove".into()];
remove.extend(scope.iter().map(Into::into));
remove.push(name.clone().into());
let _ = run_cli(remove);
let mut add: Vec<std::ffi::OsString> = vec!["mcp".into(), "add".into()];
add.extend(scope.iter().map(Into::into));
add.push(name.clone().into());
add.push("--".into());
add.extend(serve);
let out = run_cli(add)?;
if !out.status.success() {
let said: String = String::from_utf8_lossy(&out.stderr)
.trim()
.chars()
.take(300)
.collect();
return Err(format!("{label} didn't add the books: {said}"));
}
lines.push(format!(
"{label} reaches the books {book} as \"{name}\" (restart {label} to see it)."
));
Ok(lines)
}
/// Make the token `name` for `book` (the server does) and keep it in this
/// computer's keyring (INVARIANT 4: kept first, or taken back).
fn make_token(
common: &Common,
book: &str,
name: &str,
scopes: Vec<String>,
replace: bool,
) -> Result<admin::TokenInfo, String> {
let request = Request::TokenCreate {
book: book.to_string(),
name: name.to_string(),
scopes,
replace,
};
let (answer, run_dir) = with_server_in(common, request)?;
let (info, text) = match answer {
Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)),
Response::Refused(why) => return Err(why),
other => return Err(format!("the books server answered oddly: {other:?}")),
};
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, book, name);
let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text);
drop(text);
if let Err(why) = kept {
let undone = with_server(
common,
Request::TokenRevoke {
book: book.to_string(),
name: name.to_string(),
// This one only: another may have replaced it.
id: Some(info.id.clone()),
},
);
let after = match undone {
Ok(Response::TokenRevoked(_)) => "so it was revoked again",
_ => "and revoking it again failed too: `wanderbooks token revoke` ends it",
};
let replaced = if replace {
" The token it replaced had already ended."
} else {
""
};
return Err(format!(
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}"
));
}
Ok(info)
}
/// The id a backup is restored as when none is given: its repository's
/// name (`acme.git` → `acme`).
fn restore_id(source: &str) -> Option<String> {
@@ -564,6 +968,61 @@ fn restore(
eprintln!("error: {why}");
Err(exit(1))
};
let plan = match plan_restore(common, source, id, replace, true) {
Ok(plan) => plan,
Err(why) => return fail(why),
};
for line in &plan.lines {
println!("{line}");
}
match confirmed("Restore it?", yes) {
Ok(true) => {}
Ok(false) => {
println!("Not restored.");
return Ok(());
}
Err(why) => return fail(format!("not restored: {why}")),
}
match carry_out_restore(common, &plan) {
Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Err(why) => fail(why),
}
}
/// A restore previewed: the backup proven, the id it comes back as, and
/// what it would do, a line each (INVARIANT 3: shown before it is done).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RestorePlan {
/// The backup, as the server is told it (a folder by its full path).
pub source: String,
/// The books' id it comes back as.
pub id: String,
/// Whether it replaces the books of that id (kept in legacy/).
pub replace: bool,
/// The backup's head, proven: the restore is of this backup only.
pub head: (u64, String),
/// The id of the books it holds, if it says.
pub origin: Option<String>,
/// What it would do, a line each.
pub lines: Vec<String>,
}
/// Preview restoring the backup at `source` (`flags`: name the command
/// line's flags in the advice): the id it comes back as is `id`, else the
/// id of the books the backup holds, else the repository's name; refused
/// over books that exist unless `replace`, and replacing none.
pub fn plan_restore(
common: &Common,
source: &str,
id: Option<String>,
replace: bool,
flags: bool,
) -> Result<RestorePlan, String> {
// A folder is named to the server by its full path (the server's
// working folder isn't this one); anything else is an address.
let source = match Path::new(source) {
@@ -571,11 +1030,10 @@ fn restore(
.unwrap_or_else(|_| p.to_path_buf())
.display()
.to_string(),
_ => source.to_string(),
_ => source.trim().to_string(),
};
// One preview (under the id given, or any: it only reports), then the
// id: given, or else the id of the books the backup holds, or else the
// repository's name.
// id.
let probe = id
.clone()
.or_else(|| restore_id(&source))
@@ -589,10 +1047,17 @@ fn restore(
confirmed: false,
expect_head: None,
},
) {
Ok(Response::RestorePreview(p)) => p,
Ok(Response::Refused(why)) | Err(why) => return fail(format!("{source}: {why}")),
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
)? {
Response::RestorePreview(p) => p,
Response::Refused(why) => return Err(format!("{source}: {why}")),
other => return Err(format!("the books server answered oddly: {other:?}")),
};
let flag = |text: &str| {
if flags {
format!(" ({text})")
} else {
String::new()
}
};
let Some(id) = id
.or_else(|| {
@@ -603,87 +1068,98 @@ fn restore(
})
.or_else(|| restore_id(&source))
else {
eprintln!("error: name the restored books with --id ID");
return Err(exit(2));
return Err(format!("name the restored books{}", flag("--id ID")));
};
let exists = match with_server(common, Request::List) {
Ok(Response::Books(books)) => books.iter().any(|b| b.id == id),
Ok(Response::Refused(why)) | Err(why) => return fail(why),
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
let exists = match with_server(common, Request::List)? {
Response::Books(books) => books.iter().any(|b| b.id == id),
Response::Refused(why) => return Err(why),
other => return Err(format!("the books server answered oddly: {other:?}")),
};
if exists && !replace {
return fail(format!(
"there are already books called \"{id}\": restore them under another id (--id), or replace them (--replace; they are kept in legacy/)"
return Err(format!(
"there are already books called \"{id}\": restore them under another id{}, or replace them{}",
flag("--id"),
flag("--replace; they are kept in legacy/")
));
}
if !exists && replace {
return fail(format!(
return Err(format!(
"there are no books called \"{id}\" to replace: nothing was restored"
));
}
let request = |expect_head: Option<(u64, String)>| Request::Restore {
id: id.clone(),
source: source.clone(),
replace,
confirmed: expect_head.is_some(),
expect_head,
};
println!(
let mut lines = vec![format!(
"The backup at {source} is whole: through event {} ({}), {} stored files re-hashed, {} TIN vault records.",
preview.head.0,
short_hash(&preview.head.1),
preview.blobs,
preview.vault_files
);
)];
if let Some(origin) = &preview.origin {
println!("It is a backup of the books \"{origin}\".");
lines.push(format!("It is a backup of the books \"{origin}\"."));
}
if replace {
println!(
lines.push(if replace {
format!(
"It replaces the books \"{id}\": they are closed and kept whole in the legacy folder, not deleted."
);
)
} else {
println!("It comes back as new books \"{id}\", beside any others.");
}
format!("It comes back as new books \"{id}\", beside any others.")
});
match preview.origin.as_deref() {
Some(o) if o == id => {}
Some(o) => println!(
"Under a name other than its books' (\"{o}\"), they won't back up to the private repository those books back up to (that would stop theirs): if these are those books, restore them as \"{o}\" (--id {o}); else name a repository of their own."
),
None => println!(
Some(o) => lines.push(format!(
"Under a name other than its books' (\"{o}\"), they won't back up to the private repository those books back up to (that would stop theirs): if these are those books, restore them as \"{o}\"{}; else name a repository of their own.",
flag(&format!("--id {o}"))
)),
None => lines.push(
"It doesn't say whose books it holds: they won't back up to the private repository their settings name until one of their own is named."
.into(),
),
}
match confirmed("Restore it?", yes) {
Ok(true) => {}
Ok(false) => {
println!("Not restored.");
return Ok(());
}
Err(why) => return fail(format!("not restored: {why}")),
}
match with_server(common, request(Some(preview.head.clone()))) {
Ok(Response::Restored(r)) => {
println!(
Ok(RestorePlan {
source,
id,
replace,
head: preview.head,
origin: preview.origin,
lines,
})
}
/// Restore as `plan` previewed it (only that backup, at that head): what
/// was done, a line each.
pub fn carry_out_restore(common: &Common, plan: &RestorePlan) -> Result<Vec<String>, String> {
let request = Request::Restore {
id: plan.id.clone(),
source: plan.source.clone(),
replace: plan.replace,
confirmed: true,
expect_head: Some(plan.head.clone()),
};
match with_server(common, request)? {
Response::Restored(r) => {
let mut lines = vec![format!(
"Restored as \"{}\": through event {} ({}), {} stored files checked, {} TIN vault records.",
r.id,
r.head.0,
short_hash(&r.head.1),
r.blobs_verified,
r.vault_files
);
)];
if let Some(kept) = &r.replaced {
println!("The books it replaced are kept in the legacy folder as {kept}.");
}
if let Some(why) = r.problem {
return fail(format!(
"the restored books didn't open: {why}. Closing the books and opening them again tries again."
lines.push(format!(
"The books it replaced are kept in the legacy folder as {kept}."
));
}
Ok(())
if let Some(why) = r.problem {
return Err(format!(
"{}\nThe restored books didn't open: {why}. Closing the books and opening them again tries again.",
lines.join("\n")
));
}
Ok(lines)
}
Ok(Response::Refused(why)) | Err(why) => fail(format!("{source}: {why}")),
Ok(other) => fail(format!("the books server answered oddly: {other:?}")),
Response::Refused(why) => Err(format!("{}: {why}", plan.source)),
other => Err(format!("the books server answered oddly: {other:?}")),
}
}
@@ -1118,46 +1594,7 @@ pub fn token(
match words.as_slice() {
["create"] => {
let book = need_book()?;
let request = Request::TokenCreate {
book: book.clone(),
name: name.clone(),
scopes,
replace,
};
let (answer, run_dir) = with_server_in(&common, request).map_err(fail)?;
let (info, text) = match answer {
Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)),
Response::Refused(why) => return Err(fail(why)),
other => return Err(fail(format!("the books server answered oddly: {other:?}"))),
};
// INVARIANT 4: kept first, or taken back.
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text);
drop(text);
if let Err(why) = kept {
let undone = with_server(
&common,
Request::TokenRevoke {
book: book.clone(),
name: name.clone(),
// This one only: another may have replaced it.
id: Some(info.id.clone()),
},
);
let after = match undone {
Ok(Response::TokenRevoked(_)) => "so it was revoked again",
_ => "and revoking it again failed too: `wanderbooks token revoke` ends it",
};
let replaced = if replace {
" The token it replaced had already ended."
} else {
""
};
return Err(fail(format!(
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}"
)));
}
let info = make_token(&common, &book, &name, scopes, replace).map_err(fail)?;
println!(
"Made the token {:?} for the books {} ({}), kept in this computer's keyring.",
info.name,
@@ -1222,14 +1659,8 @@ pub fn token(
}
["print"] => {
let book = need_book()?;
let opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Cli,
None,
)
.map_err(|e| fail(e.to_string()))?;
let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)
.map_err(|e| fail(e.to_string()))?;
let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, &book, &name);
@@ -1348,7 +1779,6 @@ fn on_the_socket(
let mut opener = Opener::new(
common.root.as_deref(),
common.knobs,
Starter::Direct,
Kind::Cli,
None,
)?;
-337
View File
@@ -1,337 +0,0 @@
//! `wanderbooks helper`: the small unconfined process a confined mode
//! keeps beside it (plan 3.9, "Starting the host cleanly").
//!
//! ```text
//! GOAL: a mode that confines itself (the viewer, the bridge) can still
//! start the host cleanly and hand off the few things a sandbox
//! forbids, through a channel that can ask for nothing else.
//! SOURCE: plan/served.md 3.9 (the helper: spawned over an inherited
//! socket pair before any thread, a fixed message set; it never
//! accepts a path or a command line).
//! INVARIANTS:
//! 1. It talks only over the socket it was handed (descriptor 3), one
//! JSON line each way, and exits when that closes.
//! 2. It accepts exactly start_host, open_with (an attachment id),
//! stage_backup and start_updater, none carrying a path or command;
//! anything else is refused.
//! 3. start_host starts the host cleanly (start.rs).
//! 4. Spawned before the caller starts any thread.
//! 5. A message is exactly its version and one ask, on a line of at
//! most MAX_LINE bytes; anything else ends the helper.
//! ```
use crate::knobs::{FLAG, Knobs};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, Write};
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
/// This protocol's version.
pub const PROTO: u32 = 1;
/// The descriptor the helper is handed its socket on.
const FD: i32 = 3;
/// The longest line either side sends.
const MAX_LINE: u64 = 64 * 1024;
/// A message to the helper (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
/// The sender's protocol version.
pub proto: u32,
/// What it asks.
#[serde(flatten)]
pub ask: HelperAsk,
}
/// What the helper can be asked: exactly these (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum HelperAsk {
/// Start the books' host.
StartHost,
/// Open a document with the system's app for it.
OpenWith {
/// The attachment's id.
attachment: u64,
},
/// Ask the owner for a backup file and stage it for a restore.
StageBackup,
/// Start the updater.
StartUpdater,
}
/// The helper's answer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Answer {
/// Done.
Done,
/// Refused, with why.
Refused(String),
}
/// The caller's end: a running helper.
pub struct Client {
stream: std::sync::Mutex<UnixStream>,
child: std::sync::Mutex<std::process::Child>,
}
impl Client {
/// Spawn a helper for the root (`root`, else this account's), with
/// these knobs. Call it before starting any thread (INVARIANT 4).
pub fn spawn(root: Option<&Path>, knobs: &Knobs) -> Result<Self, String> {
let (ours, theirs) = UnixStream::pair().map_err(|e| e.to_string())?;
let exe = crate::start::this_executable()?;
let mut c = std::process::Command::new(exe);
c.arg("helper");
if let Some(root) = root {
let found = ledger_store::root::Root::find(Some(root)).map_err(|e| e.to_string())?;
c.arg("--root").arg(found.path());
}
c.args(knobs.args())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null());
let fd = theirs.as_raw_fd();
// SAFETY: dup2 and fcntl are async-signal-safe; they give the child
// its end of the pair as descriptor 3, without close-on-exec (which
// a descriptor already numbered 3 would still have).
unsafe {
c.pre_exec(move || {
let ok =
(fd == FD || libc::dup2(fd, FD) >= 0) && libc::fcntl(FD, libc::F_SETFD, 0) >= 0;
if !ok {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let child = c
.spawn()
.map_err(|e| format!("can't start the helper: {e}"))?;
drop(theirs);
Ok(Self {
stream: std::sync::Mutex::new(ours),
child: std::sync::Mutex::new(child),
})
}
/// Ask the helper one thing (blocking).
pub fn ask(&self, ask: HelperAsk) -> Result<Answer, String> {
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
let mut line =
serde_json::to_string(&Message { proto: PROTO, ask }).map_err(|e| e.to_string())?;
line.push('\n');
(&*stream)
.write_all(line.as_bytes())
.map_err(|e| format!("the helper is gone: {}", e.kind()))?;
let mut answer = String::new();
std::io::BufReader::new(std::io::Read::take(&*stream, MAX_LINE))
.read_line(&mut answer)
.map_err(|e| format!("the helper didn't answer: {}", e.kind()))?;
serde_json::from_str(answer.trim_end()).map_err(|_| "the helper is gone".to_string())
}
/// Start the books' host (INVARIANT 3).
pub fn start_host(&self) -> Result<(), String> {
match self.ask(HelperAsk::StartHost)? {
Answer::Done => Ok(()),
Answer::Refused(why) => Err(why),
}
}
}
impl Drop for Client {
fn drop(&mut self) {
// Closing our end ends the helper (INVARIANT 1); reap it.
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
let _ = stream.shutdown(std::net::Shutdown::Both);
drop(stream);
let _ = self.child.lock().unwrap_or_else(|e| e.into_inner()).wait();
}
}
/// What the helper does with one message.
fn answer(ask: HelperAsk, root: Option<&Path>, knobs: &Knobs) -> Answer {
match ask {
HelperAsk::StartHost => match crate::start::start_host(root, knobs) {
Ok(()) => Answer::Done,
Err(why) => Answer::Refused(why),
},
HelperAsk::OpenWith { .. } => {
Answer::Refused("opening documents comes with the viewer's menu".into())
}
HelperAsk::StageBackup => Answer::Refused(
"restore from a terminal for now: `wanderbooks books restore BACKUP`".into(),
),
HelperAsk::StartUpdater => Answer::Refused("updates come with the installers".into()),
}
}
/// `wanderbooks helper`: serve the socket on descriptor 3 until it closes
/// (INVARIANT 1).
pub fn run(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut root: Option<PathBuf> = None;
let mut knobs = Knobs::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?,
"--help" | "-h" => {
eprintln!("usage: {prog} [--root DIR]");
eprintln!(" Started by a confined part of wanderbooks, over descriptor 3.");
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
return Err(Box::new(crate::Exit(2)));
}
}
}
// SAFETY: descriptor 3 is ours by INVARIANT 1; if it isn't a socket,
// the peer check below refuses it.
let stream = unsafe { UnixStream::from_raw_fd(FD) };
// Taken: nothing this helper starts inherits it.
// SAFETY: fcntl on our own descriptor.
unsafe { libc::fcntl(FD, libc::F_SETFD, libc::FD_CLOEXEC) };
if !ledger_mcp::local::peer_is_us(&stream) {
eprintln!("{prog}: descriptor 3 isn't a socket to this account's own process");
return Err(Box::new(crate::Exit(2)));
}
serve(std::io::BufReader::new(&stream), &stream, |ask| {
answer(ask, root.as_deref(), &knobs)
});
Ok(())
}
/// Answer each message read from `reader` on `writer`, a line each, with
/// `reply` for the asks of this version, until the input ends or a line
/// is too long or cut off (INVARIANTS 1, 5).
fn serve<R: BufRead, W: Write>(mut reader: R, mut writer: W, reply: impl Fn(HelperAsk) -> Answer) {
loop {
// INVARIANT 5: never more than a line's worth read.
let mut line = String::new();
match std::io::Read::take(&mut reader, MAX_LINE + 1).read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) if !line.ends_with('\n') => break,
Ok(_) => {}
}
let parsed = crate::lease::strict_pair(line.trim_end())
.and_then(|v| serde_json::from_value::<Message>(v).map_err(|e| e.to_string()));
let answer = match parsed {
Ok(m) if m.proto == PROTO => reply(m.ask),
Ok(_) => Answer::Refused("the helper is another version: restart".into()),
Err(e) => Answer::Refused(format!("not understood: {e}")),
};
let mut text = serde_json::to_string(&answer).unwrap_or_default();
text.push('\n');
if writer.write_all(text.as_bytes()).is_err() {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn exactly_four_asks_and_none_carries_a_path() {
for ok in [
json!({"proto": 1, "start_host": null}),
json!({"proto": 1, "open_with": {"attachment": 7}}),
json!({"proto": 1, "stage_backup": null}),
json!({"proto": 1, "start_updater": null}),
] {
assert!(
serde_json::from_value::<Message>(ok.clone()).is_ok(),
"{ok}"
);
}
for bad in [
json!({"proto": 1, "open_with": {"attachment": 7, "path": "/etc/passwd"}}),
json!({"proto": 1, "open_with": {"path": "/x"}}),
json!({"proto": 1, "run": {"command": "sh"}}),
json!({"proto": 1, "start_host": {"root": "/x"}}),
json!({"proto": 1}),
] {
assert!(
serde_json::from_value::<Message>(bad.clone()).is_err(),
"{bad}"
);
}
// Two asks in one message: refused, not half-read.
let two = r#"{"proto":1,"start_host":null,"start_updater":null}"#;
assert!(crate::lease::strict_pair(two).is_err());
}
/// Serve `input`; the answers, a line each.
fn served(input: &[u8]) -> Vec<String> {
let mut out: Vec<u8> = Vec::new();
serve(input, &mut out, |ask| match ask {
HelperAsk::StartHost => Answer::Done,
_ => Answer::Refused("no".into()),
});
String::from_utf8(out)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
#[test]
fn each_line_is_answered_and_a_bad_one_ends_it() {
let ok = r#"{"proto":1,"start_host":null}"#;
let other = r#"{"proto":2,"start_host":null}"#;
let got = served(format!("{ok}\n{other}\nnonsense\n{ok}\n").as_bytes());
assert_eq!(got[0], r#""done""#);
assert!(got[1].contains("another version"), "{}", got[1]);
assert!(got[2].contains("not understood"), "{}", got[2]);
assert_eq!(got[3], r#""done""#);
assert_eq!(got.len(), 4);
// A line at the limit is read; one byte more ends the helper,
// and so does one cut off without its newline.
let at = format!("{ok}{}\n", " ".repeat(MAX_LINE as usize - ok.len()));
assert_eq!(served(at.as_bytes()), [r#""done""#]);
let over = format!(
"{ok}{}\n{ok}\n",
" ".repeat(MAX_LINE as usize - ok.len() + 1)
);
assert!(served(over.as_bytes()).is_empty());
assert!(served(ok.as_bytes()).is_empty());
// An answer that can't be written ends it.
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let asked = std::cell::Cell::new(0);
serve(format!("{ok}\n{ok}\n").as_bytes(), Broken, |_| {
asked.set(asked.get() + 1);
Answer::Done
});
assert_eq!(asked.get(), 1, "nothing more after the first answer failed");
}
#[test]
fn what_isnt_built_yet_is_refused() {
let k = Knobs::default();
for ask in [
HelperAsk::OpenWith { attachment: 1 },
HelperAsk::StageBackup,
HelperAsk::StartUpdater,
] {
assert!(matches!(answer(ask, None, &k), Answer::Refused(_)));
}
}
}
-55
View File
@@ -539,16 +539,6 @@ impl Host {
Phase::Closing(reason) => HostMsg::Ending { reason, detail: None },
});
}
Event::Intent(Intent::NewBooks { id, currency }, answer)
if self.ready && self.phase == Phase::Open =>
{
// The server does it; the loop goes on meanwhile.
let socket = self.run.join(ledger_mcp::local::ADMIN_SOCKET);
tokio::spawn(async move {
let msg = new_books(socket, id, currency).await;
let _ = answer.send(msg);
});
}
Event::Intent(Intent::Close, answer) if self.phase != Phase::Open => {
// INVARIANT 11; closing anyway: already so.
let _ = answer.send(match self.phase {
@@ -701,27 +691,6 @@ impl Host {
},
Some(Reason::ClosedByOwner),
),
Intent::NewBooks { .. } if self.phase == Phase::Open => (
HostMsg::Refused("the books are still opening: try again in a moment".into()),
None,
),
Intent::NewBooks { .. } => (
HostMsg::Refused("the books are closing: nothing was done".into()),
None,
),
Intent::BackUpNow { .. } => (
HostMsg::Refused(
"the books back themselves up; to back up now, ask your agent (books.back_up_now)"
.into(),
),
None,
),
Intent::Restore { .. } => (
HostMsg::Refused(
"restore from a terminal for now: `wanderbooks books restore BACKUP`".into(),
),
None,
),
Intent::ConsentFormatUpgrade { .. } => (
HostMsg::Refused("no books are waiting for a format upgrade".into()),
None,
@@ -768,30 +737,6 @@ impl Host {
}
}
/// "New books…": the server makes them, through its admin socket.
async fn new_books(socket: PathBuf, id: String, currency: String) -> HostMsg {
use ledger_mcp::admin::{Request, Response};
let answer = tokio::task::spawn_blocking(move || {
ledger_mcp::admin::call(&socket, Request::Create { id, currency })
})
.await;
match answer {
Ok(Ok(Response::Created { id, problem: None })) => HostMsg::Done {
detail: Some(format!("new books {id} made")),
},
Ok(Ok(Response::Created {
id,
problem: Some(why),
})) => HostMsg::Refused(format!("new books {id} were made but didn't open: {why}")),
Ok(Ok(Response::Refused(why))) => HostMsg::Refused(why),
Ok(Ok(_)) => HostMsg::Refused("the books server answered oddly".into()),
Ok(Err(ledger_mcp::admin::CallError::NotSent(why))) => HostMsg::Refused(why),
// Sent, and no answer: never reported as not done.
Ok(Err(ledger_mcp::admin::CallError::Unknown(why))) => HostMsg::Uncertain(why),
Err(_) => HostMsg::Refused("the host hit an internal error".into()),
}
}
/// Take run/update.lock (exclusive), if it is free now.
fn take_update_lock(run: &Path) -> Result<std::fs::File, String> {
let file = std::fs::OpenOptions::new()
+12 -51
View File
@@ -4,7 +4,10 @@
//! GOAL: holders hold the books open, and the owner's few requests reach
//! the server, through one socket that accepts nothing else.
//! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels":
//! leases record their kind and PID; exactly five owner intents;
//! leases record their kind and PID; the owner intents (as built,
//! D-32: two; the viewer, unconfined, backs up, restores and makes
//! books through the server's admin socket as the command line
//! does);
//! consent carries the owner passphrase (a replacing restore no
//! longer does: D-31, the books it replaces are kept in legacy/);
//! "Closing": the host says why on each lease; protocol versions N
@@ -13,9 +16,8 @@
//! 1. A connection's first line is one Ask; a Hold keeps the
//! connection (the lease lasts until it closes), anything else is
//! answered once and the connection closed.
//! 2. Exactly five intents exist: close, back_up_now, new_books,
//! restore (a backup's address or folder, as `books restore` takes
//! it) and consent_format_upgrade; any other request is refused.
//! 2. Exactly two intents exist: close and consent_format_upgrade; any
//! other request is refused.
//! 3. consent_format_upgrade carries the owner passphrase; without one
//! it is refused before anything is done.
//! 4. Before the host ends a lease it sends the reason; a lease that
@@ -102,36 +104,12 @@ pub enum Ask {
Intent(Intent),
}
/// The owner's intents: exactly these five (INVARIANT 2).
/// The owner's intents: exactly these two (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Intent {
/// Close the books now: every lease ends.
Close,
/// Back a book up now.
BackUpNow {
/// Which.
book: String,
},
/// Start new books.
NewBooks {
/// Their id.
id: String,
/// Their base currency.
currency: String,
},
/// Restore a backup, as its own book or replacing one (kept in
/// legacy/).
Restore {
/// The backup: a repository's address, or its folder (`<book>.git`
/// in the backup folder).
source: String,
/// The books' id.
id: String,
/// Replace the books of that id.
#[serde(default)]
replace: bool,
},
/// Let the server upgrade a book's journal format.
ConsentFormatUpgrade {
/// Which.
@@ -277,12 +255,9 @@ mod tests {
}
#[test]
fn exactly_five_intents_are_understood() {
fn exactly_two_intents_are_understood() {
for intent in [
json!("close"),
json!({"back_up_now": {"book": "acme"}}),
json!({"new_books": {"id": "acme", "currency": "USD"}}),
json!({"restore": {"source": "/backups/acme.git", "id": "acme"}}),
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
] {
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
@@ -291,8 +266,9 @@ mod tests {
for bad in [
json!({"proto": 1, "intent": "delete"}),
json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a", "path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"staged": "s1"}}}),
json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a"}}}),
json!({"proto": 1, "intent": {"back_up_now": {"book": "acme"}}}),
json!({"proto": 1, "intent": {"new_books": {"id": "acme", "currency": "USD"}}}),
json!({"proto": 1, "intent": {"run": {"command": "sh"}}}),
json!({"proto": 1, "exec": "sh"}),
json!({"proto": 1}),
@@ -325,22 +301,7 @@ mod tests {
assert!(consent(None).check_passphrase().is_err());
assert!(consent(Some("")).check_passphrase().is_err());
assert!(consent(Some("pw")).check_passphrase().is_ok());
for other in [
// D-31: replacing keeps the old books in legacy/; no passphrase.
Intent::Restore {
source: "/backups/acme.git".into(),
id: "acme".into(),
replace: true,
},
Intent::Close,
Intent::BackUpNow { book: "a".into() },
Intent::NewBooks {
id: "a".into(),
currency: "USD".into(),
},
] {
assert!(other.check_passphrase().is_ok());
}
assert!(Intent::Close.check_passphrase().is_ok());
}
#[test]
+5 -6
View File
@@ -5,18 +5,17 @@
//! the **host**, which starts the books server and holds it open while
//! any lease on its socket lasts, and closes it 10 s after the last one
//! ends. Each part is a mode of the one `wanderbooks` executable:
//! [`host::run`] (`wanderbooks host`), [`helper::run`] (`wanderbooks
//! helper`), [`bridge::run`] (`wanderbooks mcp`), and the commands in
//! [`commands`] (`serve`, `close`, `books`, `token`). Agents' tokens are
//! kept in the OS keyring ([`tokens`]).
//! [`host::run`] (`wanderbooks host`), [`bridge::run`] (`wanderbooks
//! mcp`), and the owner's commands in [`commands`] (`serve`, `close`,
//! `books`, `token`, `connect`, `doctor`, `passphrase`, and what the
//! viewer's Books menu does). Agents' tokens are kept in the OS keyring
//! ([`tokens`]).
#[cfg(unix)]
pub mod bridge;
#[cfg(unix)]
pub mod commands;
#[cfg(unix)]
pub mod helper;
#[cfg(unix)]
pub mod host;
pub mod knobs;
pub mod lease;
+16 -33
View File
@@ -12,7 +12,8 @@
//! INVARIANTS:
//! 1. Before each try the holder waits while a restart holds
//! run/update.lock.
//! 2. No host answering: it starts one (its Starter) and keeps trying;
//! 2. No host answering: it starts one (cleanly, start.rs) and keeps
//! trying;
//! a host that is closing or restarting turns it away and it tries
//! again until a host takes the lease.
//! 3. A start that brings no lease within START_WAIT (or whose host
@@ -87,20 +88,11 @@ impl From<&str> for HoldError {
}
}
/// How a holder starts a host when none answers.
pub enum Starter {
/// Start it itself (a holder that isn't confined).
Direct,
/// Ask its helper to (a confined holder).
Helper(std::sync::Arc<crate::helper::Client>),
}
/// Takes leases for one holder.
pub struct Opener {
root: Option<PathBuf>,
run: PathBuf,
knobs: Knobs,
starter: Starter,
kind: Kind,
book: Option<String>,
failures: u32,
@@ -140,7 +132,6 @@ impl Opener {
pub fn new(
root: Option<&Path>,
knobs: Knobs,
starter: Starter,
kind: Kind,
book: Option<String>,
) -> Result<Self, String> {
@@ -150,7 +141,6 @@ impl Opener {
root: root.map(|_| found.path().to_path_buf()),
run,
knobs,
starter,
kind,
book,
failures: 0,
@@ -192,6 +182,12 @@ impl Opener {
Ok(())
}
/// Start counting failures over (a holder that waited a while after
/// giving up tries again); what it has held is kept (INVARIANT 5).
pub fn forget_failures(&mut self) {
self.failures = 0;
}
/// Take a lease, starting a host if none answers (INVARIANTS 1-4).
pub async fn hold(&mut self) -> Result<Lease, HoldError> {
let mut started: Option<Instant> = None;
@@ -230,8 +226,8 @@ impl Opener {
// for it rather than start another.
let held = host_lock_held(&self.run);
match started {
// A start that fails (a spawn error, the helper
// gone) is a failure like any other (INVARIANT 3).
// A start that fails (a spawn error) is a
// failure like any other (INVARIANT 3).
None if !held => match self.start().await {
Ok(()) => started = Some(Instant::now()),
Err(_) => self.failed().await?,
@@ -256,25 +252,13 @@ impl Opener {
}
}
/// Start a host, through this holder's starter.
/// Start a host, cleanly (start.rs).
async fn start(&self) -> Result<(), String> {
match &self.starter {
Starter::Direct => {
let root = self.root.clone();
let knobs = self.knobs;
tokio::task::spawn_blocking(move || {
crate::start::start_host(root.as_deref(), &knobs)
})
.await
.map_err(|e| e.to_string())?
}
Starter::Helper(helper) => {
let helper = helper.clone();
tokio::task::spawn_blocking(move || helper.start_host())
.await
.map_err(|e| e.to_string())?
}
}
let root = self.root.clone();
let knobs = self.knobs;
tokio::task::spawn_blocking(move || crate::start::start_host(root.as_deref(), &knobs))
.await
.map_err(|e| e.to_string())?
}
/// Say hello on a connection and read the host's first word.
@@ -468,7 +452,6 @@ mod tests {
backoff: Duration::from_millis(1),
..Knobs::default()
},
starter: Starter::Direct,
kind: Kind::Cli,
book: None,
failures: 0,
+35
View File
@@ -116,6 +116,15 @@ pub enum Request {
#[serde(default)]
expect_head: Option<(u64, String)>,
},
/// Back books up now (plan 3.7): to every target their settings name.
BackUpNow {
/// The books.
id: String,
/// The backup folder's disk is new: begin these books' backup
/// there again.
#[serde(default)]
new_disk: bool,
},
/// Make a token for an agent (plan 3.3).
TokenCreate {
/// The books it opens.
@@ -294,6 +303,21 @@ pub struct Restored {
pub problem: Option<String>,
}
/// How one backup target took a backup now.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackedUpTarget {
/// "repository" or "folder".
pub target: String,
/// Where (no path in the data root).
pub at: String,
/// The journal seq it holds now, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub through_seq: Option<u64>,
/// Why it holds no backup of the latest, if it doesn't.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem: Option<String>,
}
/// The server's answer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
@@ -316,6 +340,14 @@ pub enum Response {
RestorePreview(RestorePreview),
/// A backup restored, and open.
Restored(Restored),
/// Books backed up now: their head, and each target's outcome (none
/// named: none set up).
BackedUp {
/// The books' head seq.
head_seq: u64,
/// Each target.
targets: Vec<BackedUpTarget>,
},
/// A token made: its text, shown this once (INVARIANT 6).
TokenCreated {
/// The token.
@@ -371,6 +403,9 @@ pub fn call_within(
Request::Create { .. } | Request::Adopt { .. } | Request::Restore { .. } => {
Some("`wanderbooks books list` shows the books there are")
}
Request::BackUpNow { .. } => {
Some("`wanderbooks books back-up` again backs up whatever it didn't")
}
Request::TokenCreate { .. } | Request::TokenRevoke { .. } => {
Some("`wanderbooks token list` shows the tokens there are")
}
+35
View File
@@ -490,6 +490,41 @@ impl Books {
confirmed,
expect_head,
} => self.restore(id, source, replace, confirmed, expect_head),
Request::BackUpNow { id, new_disk } => {
let id = match BookId::parse(&id) {
Ok(id) => id,
Err(e) => return refused(e),
};
let backups = match self.held().get(id.as_str()) {
Some(Book::Open { backups, .. }) => backups.clone(),
Some(Book::Failed(why)) => {
return Response::Refused(format!("those books didn't open: {why}"));
}
None => {
return Response::Refused(format!(
"there are no books called {:?}",
id.as_str()
));
}
};
let rt = tokio::runtime::Handle::current();
match rt.block_on(backups.run_with("asked", new_disk)) {
Ok(status) => Response::BackedUp {
head_seq: status.head_seq,
targets: status
.targets
.into_iter()
.map(|t| admin::BackedUpTarget {
target: t.target,
at: t.at,
through_seq: t.through_seq,
problem: t.last_error,
})
.collect(),
},
Err(why) => Response::Refused(self.hide(why)),
}
}
Request::Adopt {
id,
source,
+5
View File
@@ -237,6 +237,10 @@ pub struct StartOut {
pub handbook_sections: Vec<SectionOut>,
/// What to do next, in order.
pub next_steps: Vec<NextStepOut>,
/// How the books are kept from agents: "same_user (advisory)", hidden
/// from them in the human's own account, not locked against them
/// (plan D-32): reach them only through these tools.
pub isolation: String,
}
/// One setting change.
@@ -894,6 +898,7 @@ impl BooksServer {
})
.collect(),
next_steps,
isolation: "same_user (advisory)".to_string(),
};
// Fit (INVARIANT 4): whole lists if they fit; else summaries go,
// then the lists shorten from the end, each saying how many more.
+2
View File
@@ -517,6 +517,8 @@ fn start_fits_in_8_kb() {
let cut = start["truncated"].to_string();
assert!(cut.contains("books.conventions"), "{cut}");
assert!(start["conventions"].as_array().unwrap().len() >= 10);
// isolation_label_is_advisory (plan P2, D-32).
assert_eq!(start["isolation"], "same_user (advisory)");
}
/// Review of P1 (2026-10-17): the text imported is the file the server