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:
1 parent
019d8d76a3
commit
3cf359a0cd
28 files changed
+1976
-1931
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user