merge: main into P11c (P11b's dock and books homes, the macOS harness root fix)

One conflict, ledger-host commands.rs: main moved the agent-program lookup
into find_agent_in; kept that and carried the Windows parts into it
(.exe/.cmd/.bat, npm's global folder). The harness already used
default_root, so main's fix and ours agree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 17:26:43 -06:00
commit 84cbe0359c
23 files changed
+3398 -91

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
//! Books homes (P11b, D-27): the folder an agent works in for a book. It
//! holds the agent's pointer and settings and nothing of the books.
#[cfg_attr(not(unix), allow(unused_imports))]
use ledger_host::commands::{
AgentStart, books_home_in, find_agent_in, installer, make_books_home_in, start_agent_in,
};
use std::path::{Path, PathBuf};
/// Every file under `dir`, relative, sorted.
fn names(dir: &Path) -> Vec<String> {
fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
walk(base, &p, out);
} else {
out.push(p.strip_prefix(base).unwrap().to_string_lossy().into_owned());
}
}
}
let mut out = Vec::new();
walk(dir, dir, &mut out);
out.sort();
out
}
#[test]
fn books_home_holds_no_books_data() {
let docs = tempfile::tempdir().unwrap();
// A data root with a journal, a key and a token file, elsewhere.
let root = tempfile::tempdir().unwrap();
let book = root.path().join("books").join("acme");
std::fs::create_dir_all(&book).unwrap();
std::fs::write(book.join("journal.jsonl"), "{}").unwrap();
let exe = PathBuf::from("/usr/bin/wanderbooks");
let home = make_books_home_in(docs.path(), "acme", Some("Acme Inc."), &exe).unwrap();
assert_eq!(home, books_home_in(docs.path(), "acme"));
assert_eq!(home, docs.path().join("Wanderbooks").join("acme"));
// Exactly these four files.
assert_eq!(
names(&home),
[
".claude/settings.json",
".mcp.json",
"AGENTS.md",
"CLAUDE.md"
]
);
// None names the data root, a token, a key or a journal.
for f in names(&home) {
let text = std::fs::read_to_string(home.join(&f)).unwrap();
for bad in [
root.path().to_str().unwrap(),
"token",
"journal",
"vault",
"key",
] {
assert!(!text.contains(bad), "{f} mentions {bad:?}: {text}");
}
}
let agents = std::fs::read_to_string(home.join("AGENTS.md")).unwrap();
assert_eq!(agents.lines().count(), 3);
assert!(agents.contains("Acme Inc.'s books"), "{agents}");
assert!(agents.contains("books.start"));
assert_eq!(
agents,
std::fs::read_to_string(home.join("CLAUDE.md")).unwrap()
);
// The pointer and the allowance.
let mcp: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.join(".mcp.json")).unwrap()).unwrap();
assert_eq!(
mcp["mcpServers"]["wanderbooks-acme"],
serde_json::json!({"command": "/usr/bin/wanderbooks", "args": ["mcp", "--book", "acme"]})
);
let settings: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.join(".claude/settings.json")).unwrap())
.unwrap();
assert_eq!(
settings["permissions"]["allow"],
serde_json::json!(["mcp__wanderbooks-acme__*"])
);
}
#[test]
fn a_books_home_made_again_keeps_what_a_person_kept() {
let docs = tempfile::tempdir().unwrap();
let exe = PathBuf::from("/usr/bin/wanderbooks");
let home = make_books_home_in(docs.path(), "acme", None, &exe).unwrap();
// Their words in AGENTS.md; their own server and rule in the JSON.
std::fs::write(home.join("AGENTS.md"), "Mine.\n").unwrap();
std::fs::write(
home.join(".mcp.json"),
r#"{"mcpServers":{"other":{"command":"x"}}}"#,
)
.unwrap();
std::fs::write(
home.join(".claude/settings.json"),
r#"{"permissions":{"allow":["Bash(ls)"]},"model":"opus"}"#,
)
.unwrap();
let exe2 = PathBuf::from("/opt/wanderbooks");
make_books_home_in(docs.path(), "acme", None, &exe2).unwrap();
make_books_home_in(docs.path(), "acme", None, &exe2).unwrap();
assert_eq!(
std::fs::read_to_string(home.join("AGENTS.md")).unwrap(),
"Mine.\n"
);
let mcp: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.join(".mcp.json")).unwrap()).unwrap();
assert_eq!(mcp["mcpServers"]["other"]["command"], "x");
assert_eq!(
mcp["mcpServers"]["wanderbooks-acme"]["command"],
"/opt/wanderbooks"
);
let settings: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(home.join(".claude/settings.json")).unwrap())
.unwrap();
assert_eq!(settings["model"], "opus");
assert_eq!(
settings["permissions"]["allow"],
serde_json::json!(["Bash(ls)", "mcp__wanderbooks-acme__*"]),
"once, however often it is made"
);
}
#[test]
fn a_books_home_is_never_made_for_a_name_that_is_not_an_id() {
let docs = tempfile::tempdir().unwrap();
let exe = PathBuf::from("/usr/bin/wanderbooks");
for bad in ["../x", "a/b", "", "A B"] {
assert!(
make_books_home_in(docs.path(), bad, None, &exe).is_err(),
"{bad:?}"
);
}
assert!(std::fs::read_dir(docs.path()).unwrap().next().is_none());
// And not into a relative place.
assert!(make_books_home_in(Path::new("Documents"), "acme", None, &exe).is_err());
}
#[cfg(unix)]
#[test]
fn an_agent_is_found_on_the_path_or_where_its_installer_puts_it() {
use std::os::unix::fs::PermissionsExt;
let bin = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let docs = tempfile::tempdir().unwrap();
let exe = PathBuf::from("/usr/bin/wanderbooks");
let path = std::ffi::OsString::from(bin.path());
// Neither: missing, with the vendor's installer to offer.
let nowhere = std::ffi::OsString::from("/nonexistent");
match start_agent_in(
docs.path(),
&exe,
Some(&nowhere),
Some(home.path()),
"claude",
"acme",
)
.unwrap()
{
AgentStart::Missing(i) => {
assert_eq!(i, installer("claude").unwrap());
assert!(i.command.contains("claude.ai/install"), "{}", i.command);
}
other => panic!("{other:?}"),
}
// On the PATH.
let claude = bin.path().join("claude");
std::fs::write(&claude, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(
find_agent_in("claude", Some(&path), Some(home.path())),
Some(claude.clone())
);
// A file that cannot be run is not the agent.
let codex = bin.path().join("codex");
std::fs::write(&codex, "x").unwrap();
assert_eq!(find_agent_in("codex", Some(&path), Some(home.path())), None);
// Where its installer puts it.
let local = home.path().join(".local/bin");
std::fs::create_dir_all(&local).unwrap();
let codex = local.join("codex");
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(
find_agent_in("codex", Some(&nowhere), Some(home.path())),
Some(codex)
);
// Starting: in the books home, nothing marked trusted.
match start_agent_in(
docs.path(),
&exe,
Some(&path),
Some(home.path()),
"claude",
"acme",
)
.unwrap()
{
AgentStart::Ready {
program,
dir,
args,
env,
label,
} => {
assert_eq!(program, claude);
assert_eq!(dir, books_home_in(docs.path(), "acme"));
assert_eq!(label, "Claude Code");
assert!(args.is_empty());
assert!(
env.iter()
.any(|(k, v)| k == "TERM" && v == "xterm-256color")
);
}
other => panic!("{other:?}"),
}
assert!(start_agent_in(docs.path(), &exe, None, None, "gemini", "acme").is_err());
}
#[test]
fn a_first_run_reads_where_it_stands_and_sets_the_first_passphrase_once() {
use ledger_host::commands::{Common, FirstRun, first_run_state, set_first_passphrase};
let home = tempfile::tempdir().unwrap();
let place = home.path().join("data");
let common = Common {
root: Some(place.clone()),
..Default::default()
};
// Nothing there yet.
assert_eq!(first_run_state(&common), FirstRun::default());
// Set up (what `wanderbooks setup --root` does), without a terminal.
ledger_store::root::Root::setup(home.path(), Some(&place), None).unwrap();
assert_eq!(
first_run_state(&common),
FirstRun {
root: true,
books: vec![],
passphrase_set: false
}
);
// Too short is refused; a good one is set; a second first one is refused.
assert!(set_first_passphrase(&common, "short").is_err());
set_first_passphrase(&common, "a long enough passphrase").unwrap();
assert!(first_run_state(&common).passphrase_set);
let again = set_first_passphrase(&common, "another long passphrase").unwrap_err();
assert!(again.contains("wanderbooks passphrase"), "{again}");
}