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

Generated
-3
View File
@@ -3825,8 +3825,6 @@ name = "ledger-cli"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"ledger-config",
"ledger-core",
"ledger-gui",
"ledger-host",
@@ -3840,7 +3838,6 @@ dependencies = [
"sha2 0.10.9",
"tempfile",
"tokio",
"toml 1.1.8+spec-1.1.0",
]
[[package]]
+10 -6
View File
@@ -72,15 +72,17 @@ cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
cargo run -p ledger-gui -- --demo # the viewer, on a demo company
```
New books, one command (installs Rust and wanderbooks if needed, makes the
books dir with the MCP server registered for it, and starts the agent):
books' private folder, starts books called `acme` and lets Claude Code reach
them; nothing needs sudo):
```bash
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
```
Already installed: `wanderbooks new ~/Books/Acme` (add `--codex` to keep the
books with Codex instead of Claude Code). In a books dir, `just`
lists the shortcuts (`just gui`, `just agent`, `just backup`).
Already installed, three commands: `wanderbooks setup`, `wanderbooks books
create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
Then talk to the agent; `wanderbooks gui acme` shows the books, and its
Books menu backs them up, restores, starts new books and closes them.
## Developing
@@ -100,7 +102,9 @@ crates/ledger-store/ # journal + SQLite projection + blob store
crates/ledger-mcp/ # MCP server: 108 tools, resources, handbook, receipt policy
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
crates/ledger-config/ # wanderbooks.toml company settings
crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent
crates/ledger-cli/ # `wanderbooks`: the one executable (every mode)
crates/ledger-host/ # the host, the agents' bridge, the owner's commands
crates/ledger-package/ # The CPA package and the 1099 worksheet (server and viewer)
crates/ledger-gui/ # the viewer: read-only, live (egui)
docs/ # requirements, architecture, oracle, parity, glossary
plan/ # STATUS.md, roadmap.md
+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
+3 -1
View File
@@ -130,7 +130,9 @@ settings-and-conventions section says how to use them.
evidence, parties, approval_rules }, settings_summary: [{ key, value,
set_by, seq }], conventions: [{ id, kind, title, summary }], open_items:
[{ id, title, due }], truncated, handbook_sections, next_steps: [{ tool,
args?, why }] }`. The connect text names it as the first call. Lists cut
args?, why }], isolation }`. The connect text names it as the first call.
`isolation` is `same_user (advisory)` (D-32): the books are hidden from
agents in the owner's own account, not locked against them. Lists cut
to fit say so in `truncated` ("conventions: 4 more (books.conventions)").
`focus` adds steps: `onboarding` the onboarding step, `close` the
company's close steps, `audit` `books.verify`. Not built yet (P5):
+1 -2
View File
@@ -906,7 +906,6 @@ checked to change no figure or entry; `approval_rules_off_by_default`,
`a_newer_key_or_kind_is_newer`;
`books_written_under_p1_open_to_their_record`, fixture
`old-books/p1_record`); `ledger-config` `tests/codec_props.rs`
(`exports_round_trip`) and codec unit tests; `ledger-cli` `tests/init.rs`
`the_toml_and_its_examples_import_and_round_trip`; `ledger-mcp`
(`exports_round_trip`) and codec unit tests; `ledger-mcp`
`tests/spec_kept.rs`; Lean `Step.record_changes_no_figure`,
`Reachable.records`; fuzz `fuzz_settings_replay`.
+30 -23
View File
@@ -1,14 +1,16 @@
#!/bin/sh
# wanderbooks installer. One line on a new machine:
#
# curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
# curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
#
# Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the GUI opens as
# a normal window on Windows 11.
# Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the viewer opens
# as a normal window on Windows 11.
#
# Installs Rust if needed, then the wanderbooks CLI, MCP server, and GUI
# (into ~/.cargo/bin), then — given a directory — makes your books there and
# starts the agent. Without a directory it just installs.
# Installs Rust if needed, then the one `wanderbooks` program (into
# ~/.cargo/bin), then makes the books' private folder (`wanderbooks setup`).
# Given a name (lowercase letters, digits, hyphens), it also starts books of
# that name and lets Claude Code reach them. Nothing runs as root; the
# books close on their own a few seconds after the last command.
#
# WANDERBOOKS_REPO overrides where it installs from (a git URL).
set -eu
@@ -54,27 +56,32 @@ if ! command -v cargo >/dev/null 2>&1; then
fi
say "Installing wanderbooks from $REPO (a few minutes, once)…"
cargo install --locked --git "$REPO" ledger-cli ledger-mcp ledger-gui
if ! command -v just >/dev/null 2>&1; then
say "Installing just (for the books dir's shortcuts)…"
cargo install --locked just
fi
if ! command -v claude >/dev/null 2>&1; then
say "Claude Code isn't installed yet: https://claude.com/claude-code"
fi
cargo install --locked --git "$REPO" ledger-cli
case ":$PATH:" in
*":$HOME/.cargo/bin:"*) ;;
*) say "Add ~/.cargo/bin to your PATH (rustup's installer usually does; open a new terminal)." ;;
esac
if [ $# -gt 0 ]; then
# Hand the terminal to the agent when there is one.
if [ -t 1 ] && [ -e /dev/tty ]; then
exec "$HOME/.cargo/bin/wanderbooks" new "$@" </dev/tty
fi
exec "$HOME/.cargo/bin/wanderbooks" new "$@" --no-start
wb="$HOME/.cargo/bin/wanderbooks"
# The books' private folder, and (at a terminal) the owner passphrase.
if [ -t 1 ] && [ -e /dev/tty ]; then
"$wb" setup </dev/tty
else
"$wb" setup
fi
say "Done. Make your books with: wanderbooks new ~/Books/YourCompany"
if [ $# -gt 0 ]; then
# Books of that name already (the line run again): connected, not made.
if ! "$wb" books list | cut -d' ' -f1 | grep -qx "$1"; then
"$wb" books create "$1"
fi
if command -v claude >/dev/null 2>&1; then
"$wb" connect claude --book "$1"
say "Done. Start Claude Code and say hello: your books \"$1\" are ready."
else
say "Done. Install Claude Code (https://claude.com/claude-code), then: wanderbooks connect claude --book $1"
fi
exit 0
fi
say "Done. Start books with: wanderbooks books create acme then: wanderbooks connect claude --book acme"
+9 -1
View File
@@ -343,6 +343,14 @@ The books are open while something holds them; there is no background service. T
**Format changes are the server's gate, not the updater's.** A server that would write a newer journal format than a book has opens that book read-only, with guard `format_upgrade_pending`, until the owner consents in the app (or on a `/h/` page). Before it asks, it makes a verified local backup that includes the keys (`legacy.key` until P7). Consent comes only from the owner (the passphrase, through the viewer's `consent_format_upgrade` or a `/h/` page), never from an agent. Declining leaves the book as it was, and before consent the way back to a version that can write it is always there: a self-updating install keeps the previous verified app and offers "Go back to version X" (`wanderbooks rollback`); a package-managed one downgrades through its package manager (our apt and dnf repositories keep every release, and the AUR keeps old PKGBUILDs). This holds however the new version arrived: our updater, a package manager, or a hand install. **After consent** the previous version can no longer open that book, so "Go back to version X" refuses for it; the only way back is restoring the pre-upgrade backup, which would drop every entry since, so that restore says how many entries would be lost, arrives as its own book beside the current one (never replacing it), and needs the passphrase.
**As built (P2 slice 8), and where it departs (D-32):**
- **No confinement, no helper.** No mode confines itself (no Landlock, no seccomp), so the helper is gone (`wanderbooks helper`, `ledger-host` helper.rs): the bridge, like every holder, starts the host itself with the clean start (start.rs). The host still refuses to run when it finds itself confined (started from a sandboxed app). Isolation is said as `same_user (advisory)` in `books.start` (`isolation`) and `wanderbooks doctor`.
- **The lease socket's intents are two:** `close` and `consent_format_upgrade`. Backing up, restoring and new books were intents only so a confined viewer could hand them over; the viewer now does them as the command line does, through the server's admin socket (`back_up_now`, `restore`, `create`), holding a lease while it asks.
- **The viewer holds the books open** (a lease of kind app) while it shows books of the data root, on a thread of its own, taken only once its own flags are good and its book is found (the window never waits; with no server it still opens and follows the books from disk). It lets go only when the owner closes the books; books that stop or won't open are tried again (a fresh try every 30 s after five failures). Its **Books** menu (ledger-gui owner.rs, beside Export): Back up now; Restore… (the backup's address or folder typed, checked first: what it would do is shown, then done for that backup at that head); New books…; Connect Claude Code, Connect Codex; Close the books (asked first). Each is done off the UI thread by the `Owner` ledger-cli gives the viewer, over the same ledger-host functions the command line uses; the viewer writes nothing of the books; the menu waits while one runs, and a restore's fields can't change while it is checked (a result for anything since edited is dropped). Older books dirs and the demo have no Books menu.
- **New commands:** `wanderbooks connect claude|codex --book ID` (the books' agent token kept in the keyring, made if none is, replacing the server's, which no one can be using; then the agent's own `claude mcp add --scope user` or `codex mcp add` of `wanderbooks-<book>` running `wanderbooks mcp --book <book>`; the agent's command found on the PATH, else where its installers put it under `$HOME`, or `/usr/local/bin`, `/opt/homebrew/bin`: a viewer started from the desktop has a short PATH), and `wanderbooks books back-up ID [--new-disk]` (the admin socket's `back_up_now`).
- **Scaffold deleted:** `wanderbooks new`/`init` and its templates (justfile, AGENTS.md, CLAUDE.md, wanderbooks.toml, .gitignore, .mcp.json, Claude and Codex settings), and its tests; the settings file's own export is ledger-config's (`books.export_settings`). `wanderbooks new`/`init` now says it is gone and what to run instead. `install.sh` installs the one executable, runs `setup`, and given a name starts those books and connects Claude Code: three commands to the first post, no sudo. The website (`site/index.html`) says the same; its built copy must be redeployed (`just deploy`) when this change is pushed: the old installer runs `wanderbooks new`. The first company's own books dir keeps its files until the cutover (slice 9).
- **Tests:** `ledger-gui/tests/owner_menu.rs` (each item through a fake owner, the restore checked first, nothing of the books written; busy states and a restore's fields frozen while checked; no menu for an older books dir; screenshots), `open_while_held.rs` (connect with fake agents and the command it registers reaching the books, a token the keyring lost, an agent that refuses; back up now from the command line, `--new-disk`; the viewer's hold, new books and close through the owner's functions; the two intents), `one_executable.rs` (the gone scaffold), `spec_kept.rs` (`isolation`).
## 4. The MCP surface after the change
### 4.1 Connect instructions (composed in `get_info`; replaces `INSTRUCTIONS` S:57 and the string replace at S:8179)
@@ -1248,7 +1256,7 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form
| D-29 | The full-TIN outputs, from both doors (owner, 2026-10-19, asked whether the agent's CPA export should go away once the viewer has one: "Both WITH TINs. Stop acting like TINS MATTER SO MUCH THEY DONT") | **The agent and the viewer both make them, TINs included.** `books.export_cpa_package` (TINs by default), `books.vendor_1099` with `include_tins` and `books.get_w9` keep working for the agent; their files are made by the server and handed over by a command, `wanderbooks download`, like a receipt too large for a call (no path parameter, nothing in the agent's context). The viewer's menu makes the same files into Downloads. Over stdio (the development transport) the file comes back inline. TINs get no handling beyond the vault: no passphrase, no extra prompts, no special cleanup. |
| D-30 | Q-1 answered: where the backups go (owner, 2026-10-19, asked "Where should the books' automatic backups go?" with a private git repo, a folder or other disk, or both: chose "Both") | **Two targets.** The archive pushes to a private git repository the owner names and also copies to a folder the owner names (an external disk or a synced folder); both hold the archive (not encrypted since D-31). The repository's address and the folder are asked for when backups are first set up (slice 7, or at the cutover for the first company), and kept as the book's backup settings. |
| D-31 | Backups are not encrypted (owner, 2026-10-19, asked where to keep the backup's recovery key: "Why encrypt them? How about we let the backup system handle the security???? You're over complicating everything.") | **A backup is the book as it is, in git.** No age encryption, no archive key, no recovery key: the archive is a plain git repository of the book's journal, receipts, vault (as the vault keeps it) and keys, one commit per backup, pushed to the owner's private git repository and to a git repository in the owner's folder (D-30). The destinations are trusted to keep it private. This replaces 3.7's and 3.4's age encryption on push and 3.5's archive key. |
| D-32 | No self-confinement (owner, 2026-10-20, asked how much of the planned Landlock/seccomp sandboxing of the server and the viewer, with the viewer's helper process, to build now that the server runs git for its backups: chose "None") | **No Landlock, no seccomp, no helper process for confinement.** The viewer stays read-only by design and by test (it never writes the books; its menu hands work to the server through the lease socket); agents still reach the books only through the MCP. Isolation is reported everywhere as `same_user (advisory)`. This replaces the self-confinement of P2's scope, 3.9's confined modes and their helper, and the archive-vs-Landlock decision row. |
| D-32 | No self-confinement (owner, 2026-10-20, asked how much of the planned Landlock/seccomp sandboxing of the server and the viewer, with the viewer's helper process, to build now that the server runs git for its backups: chose "None") | **No Landlock, no seccomp, no helper process for confinement.** The viewer stays read-only by design and by test (it never writes the books; its menu hands work to the server, through its admin socket as the command line does); agents still reach the books only through the MCP. Isolation is reported everywhere as `same_user (advisory)`. This replaces the self-confinement of P2's scope, 3.9's confined modes and their helper, and the archive-vs-Landlock decision row. |
### 10.2 Deferred
+6 -6
View File
@@ -153,10 +153,10 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
</div>
<div class="install">
<span class="prompt">$</span>
<code id="cmd">curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/YourCompany</code>
<code id="cmd">curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- yourcompany</code>
<button class="copy" id="copy">Copy</button>
</div>
<p class="note">One line installs everything, makes your books, and starts your bookkeeper in Claude Code. Using Codex? Add <code class="inline">--codex</code> to the end. Linux and macOS; Windows via WSL2.</p>
<p class="note">One line installs everything, starts your books (a short name: lowercase letters, digits and hyphens), and connects Claude Code to them; then start Claude Code and say hello. Using Codex? Run <code class="inline">wanderbooks connect codex --book yourcompany</code> after. Linux and macOS; Windows via WSL2.</p>
</div>
<div class="shot hero-shot"><img src="assets/overview.png" alt="The wanderbooks viewer's overview: cash, what you owe, spending, revenue, runway, a cash chart, and what needs attention" width="1440" height="920"></div>
@@ -289,13 +289,13 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
<section id="install">
<h2>Install</h2>
<p class="sub">The one line at the top does everything: installs Rust if you don't have it, builds wanderbooks, makes your books folder with the bookkeeper wired in, and starts a session to set up your company.</p>
<p class="sub">The one line at the top does everything: installs Rust if you don't have it, builds wanderbooks, makes a private folder for your books, starts them, and connects Claude Code. Nothing needs administrator rights, and nothing keeps running in the background once you're done.</p>
<div class="grid">
<div class="card"><h3>Linux</h3><p>Needs a C compiler and git (<code class="inline">build-essential</code> on Debian and Ubuntu). The installer tells you if anything's missing.</p></div>
<div class="card"><h3>macOS</h3><p>Needs Apple's command line tools: <code class="inline">xcode-select --install</code>.</p></div>
<div class="card"><h3>Windows</h3><p>Run it inside WSL2 (<code class="inline">wsl --install</code>, then Ubuntu). On Windows 11 the viewer opens as a normal window. Native Windows is on the way.</p></div>
</div>
<p class="note" style="margin-top:24px">Already installed? <code class="inline">wanderbooks new ~/Books/AnotherCompany</code>. In a books folder, <code class="inline">just agent</code> (or <code class="inline">just codex</code>) starts your bookkeeper and <code class="inline">just gui</code> opens the viewer.</p>
<p class="note" style="margin-top:24px">Already installed? <code class="inline">wanderbooks books create another</code>, then <code class="inline">wanderbooks connect claude --book another</code>. <code class="inline">wanderbooks gui</code> opens the viewer; its Books menu backs up, restores, starts new books and closes them.</p>
</section>
<section id="faq">
@@ -304,7 +304,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
<details><summary>Wait, what's an MCP server?</summary><p>MCP is the standard way AI tools like Claude Code and Codex plug into other software. wanderbooks is an accounting engine that speaks it: your AI calls its tools to draft entries, attach receipts, and run reports, and the engine enforces the accounting rules. The one-line installer sets it up; you never have to touch it.</p></details>
<details><summary>Do I need to be technical?</summary><p>You need to paste one command into a terminal, once. After that you talk to your bookkeeper in plain English, in Claude Code or Codex, and look at your books in the viewer whenever you like.</p></details>
<details><summary>How does it find my receipts?</summary><p>Give your AI access to your email (in Claude, an email connector like Gmail's or the Claude browser extension with your mail open) and ask. It searches for the vendor and amount, files one clean PDF per receipt, and attaches it to the entry. It asks before downloading anything, and it never types passwords or account numbers.</p></details>
<details><summary>Where's my data, and what does the AI see?</summary><p>Your books are files on your computer: a journal, your receipts, and a settings file, in a git repository you control. Nothing is uploaded to wanderbooks; there is no wanderbooks server. What your bookkeeper reads to help you goes to your AI provider (Anthropic or OpenAI), like any other conversation with it.</p></details>
<details><summary>Where's my data, and what does the AI see?</summary><p>Your books are files in a private folder on your computer: a journal and your receipts. They back themselves up to a private git repository and a folder you choose. Nothing is uploaded to wanderbooks; there is no wanderbooks server. What your bookkeeper reads to help you goes to your AI provider (Anthropic or OpenAI), like any other conversation with it.</p></details>
<details><summary>What if the AI gets something wrong?</summary><p>Every posting is previewed before it happens. If something slips through, it's corrected the way accountants do it, with a reversing entry, so the mistake and the fix are both on the record. The engine itself refuses anything that doesn't balance, anything without a required receipt, and anything in a closed period.</p></details>
<details><summary>Is this real accounting, or a spreadsheet with an AI on top?</summary><p>Real double-entry accounting, with the rules enforced in code: exact decimal money, balanced entries, immutable posting, closes, depreciation, accrual or cash basis. Its statements are tested against hand-computed numbers, golden files, and property tests.</p></details>
<details><summary>Can I bring my history from QuickBooks or a spreadsheet?</summary><p>Yes. Export your chart of accounts, journal, and reports, and ask your bookkeeper to move you over. It rebuilds the history and compares every statement against the old system, to the cent, before calling it done. Keep the old system read-only until you've closed a month in wanderbooks.</p></details>
@@ -320,7 +320,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
(function () {
var cmd = document.getElementById("cmd");
if (location.protocol === "https:" && location.hostname !== "wanderbooks.ai") {
cmd.textContent = "curl -fsSL " + location.origin + "/install.sh | sh -s -- ~/Books/YourCompany";
cmd.textContent = "curl -fsSL " + location.origin + "/install.sh | sh -s -- yourcompany";
}
document.getElementById("copy").addEventListener("click", function () {
var b = this;