P2 slice 5: files in and out
Files reach the books through the server: uploads by hash and size, then filed; exports (the CPA package, the 1099 worksheet, W-9s) handed over as downloads from the server, never read off disk by the agent. 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
d1f3ad891a
commit
b26c099a68
44 files changed
+5075
-2739
No files matched your search
@@ -126,8 +126,7 @@ impl Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's dir and what sits beside it (`.inbox`, `.elsewhere.txt`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
@@ -146,11 +145,6 @@ impl AsRef<std::path::Path> for TestDir {
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
for beside in [".inbox", ".elsewhere.txt"] {
|
||||
let p = std::path::PathBuf::from(format!("{}{beside}", self.0.display()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +179,9 @@ fn mcp_end_to_end_over_stdio() {
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
@@ -267,6 +264,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
"file_name": "rent.png",
|
||||
"mime_type": "image/png",
|
||||
"content_base64": png,
|
||||
"sha256": sha(b"fake-png-bytes"),
|
||||
}),
|
||||
);
|
||||
assert_eq!(attached["deduplicated"], false);
|
||||
@@ -461,6 +459,7 @@ fn mcp_assets_end_to_end_over_stdio() {
|
||||
"file_name": "bill-of-sale.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"fake bill of sale"),
|
||||
"sha256": sha(b"fake bill of sale"),
|
||||
}),
|
||||
);
|
||||
let aid = bill["attachment_id"].as_u64().unwrap();
|
||||
@@ -628,7 +627,8 @@ fn mcp_receipt_corrections_over_stdio() {
|
||||
let att = mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
|
||||
"content_base64": base64_like(receipt)}),
|
||||
"content_base64": base64_like(receipt),
|
||||
"sha256": sha(receipt)}),
|
||||
);
|
||||
let posted = mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -655,7 +655,8 @@ fn mcp_receipt_corrections_over_stdio() {
|
||||
let pdf = mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"pdf-bytes")}),
|
||||
"content_base64": base64_like(b"pdf-bytes"),
|
||||
"sha256": sha(b"pdf-bytes")}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -782,46 +783,6 @@ fn mcp_resources_over_stdio() {
|
||||
assert!(resp.get("error").is_some());
|
||||
}
|
||||
|
||||
/// `--import-root` replaces the default import root (the downloads
|
||||
/// folder): a file in the named folder is filed; one beside it is not.
|
||||
#[test]
|
||||
fn import_root_flag_replaces_the_defaults() {
|
||||
let dir = test_dir("import-root");
|
||||
let inbox = dir.with_extension("inbox");
|
||||
std::fs::create_dir_all(&inbox).unwrap();
|
||||
let inside = inbox.join("receipt.txt");
|
||||
let temp = dir.with_extension("elsewhere.txt");
|
||||
for p in [&inside, &temp] {
|
||||
std::fs::write(p, b"a receipt").unwrap();
|
||||
}
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--import-root"), inbox.as_os_str()],
|
||||
);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
let ok = mcp.call(
|
||||
"books.import_local_file",
|
||||
json!({"path": inside.to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
assert_eq!(ok["file_name"], "receipt.txt");
|
||||
let why = mcp.call_err(
|
||||
"books.import_local_file",
|
||||
json!({"path": temp.to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
assert!(
|
||||
why.contains("no such file in the server's import folders"),
|
||||
"{why}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Start a session: initialize, then initialized.
|
||||
fn init(mcp: &mut Mcp) -> Value {
|
||||
let init = mcp.request(
|
||||
@@ -836,173 +797,25 @@ fn init(mcp: &mut Mcp) -> Value {
|
||||
init
|
||||
}
|
||||
|
||||
/// Through the real tools, inside a root (local_files.rs INVARIANTS 1-3,
|
||||
/// Protected::of; S1 and S5 of the P0 review, I of round 2): the key
|
||||
/// file in force, moved into the root after the server started (a root
|
||||
/// holding the books or a key is refused at startup: startup.rs), is
|
||||
/// sealed with what its link leads to and their folders, but never the
|
||||
/// root itself; links in the root to the journal or a vault record lead
|
||||
/// outside it, and get the one refusal a missing file gets (INVARIANT 2).
|
||||
/// books.import_local_file and books.set_w9's w9_file_path agree.
|
||||
#[test]
|
||||
fn import_local_file_and_set_w9_refuse_the_key_vault_and_journal_inside_a_root() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = test_dir("sealed-in-root");
|
||||
let root = dir.join("root");
|
||||
let books = dir.join("books");
|
||||
let data = books.join("books-data");
|
||||
let tray = root.join("drop");
|
||||
let config = dir.join("config");
|
||||
for d in [
|
||||
&data,
|
||||
&tray,
|
||||
&root.join("alias"),
|
||||
&root.join("hidden"),
|
||||
&config.join("wanderbooks"),
|
||||
] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("hidden/real.key"), "ab".repeat(32)).unwrap();
|
||||
std::fs::write(root.join("hidden/notes.pdf"), b"%PDF beside the key").unwrap();
|
||||
std::fs::write(root.join("loose.key"), "ef".repeat(32)).unwrap();
|
||||
symlink(root.join("hidden/real.key"), root.join("alias/vault.key")).unwrap();
|
||||
std::fs::write(config.join("wanderbooks/vault.key"), "cd".repeat(32)).unwrap();
|
||||
let settings = |key: &std::path::Path| {
|
||||
std::fs::write(
|
||||
books.join("wanderbooks.toml"),
|
||||
format!(
|
||||
"[company]\nname = \"Sealed Co\"\n[vault]\nkey_file = \"{}\"\n",
|
||||
key.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
// Started with the default key (outside the root), so it starts.
|
||||
settings(&config.join("wanderbooks/vault.key"));
|
||||
std::fs::write(tray.join("receipt.pdf"), b"%PDF a receipt").unwrap();
|
||||
std::fs::write(tray.join("w9.pdf"), b"%PDF\xff W-9 scan").unwrap();
|
||||
let root_arg = [std::ffi::OsStr::new("--import-root"), root.as_os_str()];
|
||||
let mut mcp = Mcp::spawn_env(&data, &root_arg, &[("XDG_CONFIG_HOME", &config)]);
|
||||
init(&mut mcp);
|
||||
mcp.call(
|
||||
"books.add_party",
|
||||
json!({"name": "Acme", "roles": ["contractor"]}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.set_tin",
|
||||
json!({"party": "Acme", "tin": "291-12-2846", "tin_type": "ssn"}),
|
||||
);
|
||||
let journal = data.join(ledger_store::JOURNAL_FILE);
|
||||
let vault_record = books
|
||||
.join("vault")
|
||||
.read_dir()
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.find(|p| p.extension().is_some_and(|e| e == "bin"))
|
||||
.unwrap();
|
||||
for (name, to) in [
|
||||
("journal-link.pdf", journal.clone()),
|
||||
("record-link.pdf", vault_record.clone()),
|
||||
("default-key-link.pdf", config.join("wanderbooks/vault.key")),
|
||||
("dangling-link.pdf", config.join("wanderbooks/missing.key")),
|
||||
] {
|
||||
symlink(&to, tray.join(name)).unwrap();
|
||||
}
|
||||
// The key in force moves into the root (a link, alias/vault.key ->
|
||||
// hidden/real.key); the settings reload.
|
||||
settings(&root.join("alias/vault.key"));
|
||||
let refuse = |mcp: &mut Mcp, path: &std::path::Path, why: &str| {
|
||||
let path = path.to_str().unwrap();
|
||||
let err = mcp.call_err(
|
||||
"books.import_local_file",
|
||||
json!({"path": path, "category": "document"}),
|
||||
);
|
||||
assert!(err.contains(why), "{path}: {err}");
|
||||
assert!(!err.contains(dir.to_str().unwrap()), "names a path: {err}");
|
||||
let err = mcp.call_err(
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn", "w9_file_path": path}),
|
||||
);
|
||||
assert!(err.contains(why), "{path}: {err}");
|
||||
};
|
||||
for p in [
|
||||
root.join("hidden/real.key"),
|
||||
root.join("hidden/notes.pdf"),
|
||||
root.join("alias/vault.key"),
|
||||
] {
|
||||
refuse(&mut mcp, &p, "belongs to the books or the TIN vault");
|
||||
}
|
||||
for p in [
|
||||
journal.clone(),
|
||||
vault_record,
|
||||
config.join("wanderbooks/vault.key"),
|
||||
tray.join("journal-link.pdf"),
|
||||
tray.join("record-link.pdf"),
|
||||
tray.join("default-key-link.pdf"),
|
||||
tray.join("dangling-link.pdf"),
|
||||
tray.join("missing.pdf"),
|
||||
] {
|
||||
refuse(
|
||||
&mut mcp,
|
||||
&p,
|
||||
"no such file in the server's import folders (by default your downloads folder)",
|
||||
);
|
||||
}
|
||||
// A key kept loose in the root itself seals the key, not the root.
|
||||
settings(&root.join("loose.key"));
|
||||
refuse(
|
||||
&mut mcp,
|
||||
&root.join("loose.key"),
|
||||
"belongs to the books or the TIN vault",
|
||||
);
|
||||
// Ordinary files in the root are filed (with the key the vault was
|
||||
// made with back in force).
|
||||
mcp.call(
|
||||
"books.import_local_file",
|
||||
json!({"path": tray.join("receipt.pdf").to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
// A link is filed under the name and type of the file it leads to.
|
||||
std::fs::write(tray.join("photo.png"), b"\x89PNG a photo").unwrap();
|
||||
symlink(tray.join("photo.png"), tray.join("named-like.pdf")).unwrap();
|
||||
let filed = mcp.call(
|
||||
"books.import_local_file",
|
||||
json!({"path": tray.join("named-like.pdf").to_str().unwrap(), "category": "receipt"}),
|
||||
);
|
||||
assert_eq!(filed["file_name"], "photo.png");
|
||||
let back = mcp.call(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": filed["attachment_id"]}),
|
||||
);
|
||||
assert_eq!(back["mime_type"], "image/png", "{back}");
|
||||
settings(&config.join("wanderbooks/vault.key"));
|
||||
mcp.call(
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn",
|
||||
"w9_file_path": tray.join("w9.pdf").to_str().unwrap()}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Outputs that carry full TINs (books.get_w9, books.vendor_1099 with
|
||||
/// include_tins, books.export_cpa_package with TINs) are never written
|
||||
/// into an import root, and a copy of one moved into a root is refused by
|
||||
/// its bytes: a TIN-bearing file can't be filed back in as a receipt and
|
||||
/// inside the books, and wherever they are written (a downloads folder
|
||||
/// too), each is refused by its bytes when given back to be filed: a
|
||||
/// TIN-bearing file can't be filed back in as a receipt or document and
|
||||
/// read out through books.get_receipt (S7 of the P0 review, 2026-10-16).
|
||||
#[test]
|
||||
fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
|
||||
let dir = test_dir("tin-files");
|
||||
let root = dir.join("inbox");
|
||||
let out = dir.join("out");
|
||||
let books = dir.join("books");
|
||||
let downloads = dir.join("downloads");
|
||||
let config = dir.join("config");
|
||||
for d in [&root, &out, &config] {
|
||||
for d in [&downloads, &config] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("w9.pdf"), b"%PDF W-9 TIN 291-12-2846").unwrap();
|
||||
let root_arg = [std::ffi::OsStr::new("--import-root"), root.as_os_str()];
|
||||
let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846";
|
||||
let mut mcp = Mcp::spawn_env(
|
||||
&dir.join("books").join("books-data"),
|
||||
&root_arg,
|
||||
&books.join("books-data"),
|
||||
&[],
|
||||
&[("XDG_CONFIG_HOME", &config)],
|
||||
);
|
||||
init(&mut mcp);
|
||||
@@ -1018,61 +831,92 @@ fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn",
|
||||
"w9_file_path": root.join("w9.pdf").to_str().unwrap()}),
|
||||
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
|
||||
);
|
||||
let in_root = |name: &str| root.join(name).to_str().unwrap().to_string();
|
||||
// Inside the books: refused, and nothing written there.
|
||||
let before = std::fs::read_dir(&books).unwrap().count();
|
||||
let in_books = |name: &str| books.join(name).to_str().unwrap().to_string();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme", "save_to": in_root("copy.pdf")}),
|
||||
json!({"party": "Acme", "save_to": in_books("copy.pdf")}),
|
||||
),
|
||||
(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_root("1099.csv")}),
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_books("1099.csv")}),
|
||||
),
|
||||
(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_root("cpa.zip")}),
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_books("cpa.zip")}),
|
||||
),
|
||||
(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": root.to_str().unwrap()}),
|
||||
json!({"cash_accounts": ["1000"], "save_to": books.to_str().unwrap()}),
|
||||
),
|
||||
] {
|
||||
let why = mcp.call_err(tool, args);
|
||||
assert!(
|
||||
why.contains("never saved in the server's import folders"),
|
||||
"{tool}: {why}"
|
||||
);
|
||||
assert!(why.contains("inside the books"), "{tool}: {why}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&root).unwrap().count(),
|
||||
1,
|
||||
"wrote into the root"
|
||||
std::fs::read_dir(&books).unwrap().count(),
|
||||
before,
|
||||
"wrote into the books"
|
||||
);
|
||||
// Saved elsewhere, then moved into the root: refused by its bytes.
|
||||
let w9 = out.join("copy.pdf");
|
||||
// Written to a downloads folder, each is refused by its bytes, by
|
||||
// every way a file is filed.
|
||||
let in_downloads = |name: &str| downloads.join(name).to_str().unwrap().to_string();
|
||||
mcp.call(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme", "save_to": w9.to_str().unwrap()}),
|
||||
json!({"party": "Acme", "save_to": in_downloads("copy.pdf")}),
|
||||
);
|
||||
let csv = out.join("1099.csv");
|
||||
mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}),
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_downloads("1099.csv")}),
|
||||
);
|
||||
for (from, name) in [(&w9, "receipt.pdf"), (&csv, "statement.csv")] {
|
||||
std::fs::copy(from, root.join(name)).unwrap();
|
||||
let why = mcp.call_err(
|
||||
"books.import_local_file",
|
||||
json!({"path": in_root(name), "category": "receipt"}),
|
||||
);
|
||||
assert!(why.contains("wrote with full TINs"), "{why}");
|
||||
mcp.call(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_downloads("cpa.zip")}),
|
||||
);
|
||||
let written: Vec<std::path::PathBuf> = std::fs::read_dir(&downloads)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.collect();
|
||||
assert_eq!(written.len(), 3, "{written:?}");
|
||||
let journal = std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for path in &written {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "receipt.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(&bytes),
|
||||
"sha256": sha(&bytes)}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "statement.csv",
|
||||
"mime_type": "text/csv",
|
||||
"content_base64": base64_like(&bytes),
|
||||
"sha256": sha(&bytes)}),
|
||||
),
|
||||
] {
|
||||
let why = mcp.call_err(tool, args);
|
||||
assert!(
|
||||
why.contains("wrote with full TINs"),
|
||||
"{tool} {}: {why}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
journal,
|
||||
"a refusal stored something"
|
||||
);
|
||||
// Without TINs, the 1099 worksheet may go anywhere a file may.
|
||||
mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "save_csv_to": in_root("plain.csv")}),
|
||||
json!({"year": 2026, "save_csv_to": in_downloads("plain.csv")}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1317,6 +1161,11 @@ fn handbook_tool_and_resources_over_stdio() {
|
||||
assert!(resp.get("error").is_some());
|
||||
}
|
||||
|
||||
/// The SHA-256 (hex) a file given inline comes with.
|
||||
fn sha(bytes: &[u8]) -> String {
|
||||
ledger_store::journal::sha256_hex(bytes)
|
||||
}
|
||||
|
||||
fn base64_like(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
@@ -1409,7 +1258,8 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(desc.as_bytes())}),
|
||||
"content_base64": base64_like(desc.as_bytes()),
|
||||
"sha256": sha(desc.as_bytes())}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -1571,7 +1421,8 @@ fn mcp_period_statements_over_stdio() {
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"r")}),
|
||||
"content_base64": base64_like(b"r"),
|
||||
"sha256": sha(b"r")}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -2047,6 +1898,7 @@ fn mcp_payout_gross_up_over_stdio() {
|
||||
"file_name": "payout.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 statement"),
|
||||
"sha256": sha(b"%PDF-1.4 statement"),
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
@@ -2133,6 +1985,7 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
"file_name": "invoice.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 invoice"),
|
||||
"sha256": sha(b"%PDF-1.4 invoice"),
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
|
||||
Reference in new issue
Block a user