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:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent d1f3ad891a
commit b26c099a68
44 files changed
+5075 -2739

No files matched your search

+266
View File
@@ -0,0 +1,266 @@
//! Files in (plan 4.7, P2 slice 5, D-28): `books.begin_upload`, and the
//! bytes a tool is given, as an upload or inline.
//!
//! ```text
//! GOAL: an agent files a document of any size up to the limit without
//! its bytes passing through its context, and a small one inline,
//! both exactly as the agent declared them.
//! SOURCE: plan 4.7 (begin_upload; "content_base64 (≤256 KiB + required
//! sha256)"; guard base64_corrupt), 2.1 ("256 KiB with a mandatory
//! matching sha256"), D-28 (the agent does it all).
//! INVARIANTS:
//! 1. A tool's bytes are an upload made by the same token, or inline
//! base64 of at most INLINE_MAX bytes whose SHA-256 is the one given;
//! never both, never neither. An upload is used up only by the call
//! that files it (TakenFile::filed); a call that fails leaves it for
//! the next.
//! 2. begin_upload makes nothing for a receipt or document whose bytes
//! are stored already: it names the attachment (not withdrawn) that
//! holds them.
//! 3. Uploads exist only for books served from the data root (the
//! server's socket carries the bytes); over stdio, only inline.
//! 4. An upload is filed only by a tool of the purpose it was begun for
//! (a receipt or document, a CSV import, a W-9); another tool's
//! refusal leaves it for the right one.
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused
//! before it is decoded.
//! ```
use super::*;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// The most bytes given inline (plan 2.1: 256 KiB).
pub(crate) const INLINE_MAX: usize = 256 * 1024;
/// Why an upload is refused on a connection that has none (INVARIANT 3).
const STDIO_ONLY_INLINE: &str = "this connection takes files inline only (content_base64 with its sha256, at most 256 KiB): a larger one needs these books served by `wanderbooks mcp`; tell the human";
/// What the bytes of a file a tool files came as (INVARIANT 1), and the
/// upload purposes the tool takes (INVARIANT 4).
pub(crate) struct Given<'a> {
pub upload_id: Option<&'a str>,
pub content_base64: Option<&'a str>,
pub sha256: Option<&'a str>,
pub purposes: &'a [&'a str],
}
/// What attach_receipt and file_document take.
pub(crate) const FILED: &[&str] = &["receipt", "document"];
/// What the CSV imports take.
pub(crate) const IMPORTED: &[&str] = &["import"];
/// What set_w9 takes.
pub(crate) const W9: &[&str] = &["w9"];
/// The bytes, and the name and type an upload declared.
pub(crate) struct TakenFile {
pub bytes: Vec<u8>,
pub file_name: Option<String>,
pub mime: Option<String>,
/// The upload's hold, if it came as one (INVARIANT 1).
pub claim: Option<ledger_store::uploads::Claim>,
}
/// ` --token NAME` for a command given to the agent, when its token isn't
/// the one the commands use by default: the command runs as the caller.
pub(crate) fn token_flag() -> String {
CALLER.with(|c| match c.borrow().as_ref() {
Some((_, name)) if name != ledger_store::auth::DEFAULT_NAME => {
format!(" --token {name}")
}
_ => String::new(),
})
}
/// The bytes are in the books: an upload they came as is used up
/// (INVARIANT 1). Dropped instead, it is given back.
pub(crate) fn filed(claim: Option<ledger_store::uploads::Claim>) {
if let Some(c) = claim {
c.spend();
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BeginUploadParams {
/// The file's SHA-256, hex (`sha256sum FILE`).
pub sha256: String,
/// Its size in bytes (at most 25 MiB).
pub size: u64,
/// Its MIME type, e.g. "application/pdf".
pub mime_type: String,
/// Its name, e.g. "receipt.pdf".
pub file_name: String,
/// What it is for: receipt, document, import (a bank or chart CSV), or
/// w9 (books.set_w9).
pub purpose: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct BeginUploadOut {
/// The upload's id: name it in the tool that files it.
#[serde(skip_serializing_if = "Option::is_none")]
pub upload_id: Option<String>,
/// The command that sends the file (run it with the file's path).
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
/// When the upload lapses (UTC).
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
/// The most bytes taken.
pub max_bytes: u64,
/// These bytes are stored already, in this attachment: link it
/// (books.link_attachment) instead of filing a copy.
#[serde(skip_serializing_if = "Option::is_none")]
pub existing_attachment_id: Option<u64>,
}
impl BooksServer {
/// The token of the call running on this thread (its id), if any.
fn caller_token(&self) -> Option<String> {
CALLER.with(|c| c.borrow().as_ref().map(|(id, _)| id.clone()))
}
/// The bytes a tool was given (INVARIANT 1).
pub(crate) fn take_file(&self, given: Given<'_>) -> Result<TakenFile, String> {
match (given.upload_id, given.content_base64) {
(Some(_), Some(_)) => Err("give upload_id or content_base64, not both".into()),
(None, None) => Err(
"the file's bytes are missing: upload it (books.begin_upload), or give content_base64 with its sha256 (at most 256 KiB)"
.into(),
),
(Some(id), None) => {
// INVARIANT 3.
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
return Err(STDIO_ONLY_INLINE.into());
};
let got = uploads
.take(id.trim(), &token, chrono::Utc::now().timestamp())
.map_err(|e| e.to_string())?;
// INVARIANT 4: dropped, the claim gives it back.
if !given.purposes.contains(&got.grant.purpose.as_str()) {
return Err(format!(
"that upload was begun for {}: this tool takes one begun for {}",
got.grant.purpose,
given.purposes.join(" or ")
));
}
Ok(TakenFile {
bytes: got.bytes,
file_name: Some(got.grant.file_name),
mime: Some(got.grant.mime),
claim: Some(got.claim),
})
}
(None, Some(b64)) => {
let Some(sha) = given.sha256 else {
return Err("content_base64 comes with its sha256 (the file's SHA-256, hex), so a byte lost on the way is caught".into());
};
// INVARIANT 5.
if b64.len() > INLINE_MAX.div_ceil(3) * 4 {
return Err(format!(
"inline is at most {INLINE_MAX} bytes: upload it with books.begin_upload"
));
}
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
.map_err(|_| "content_base64 is not valid base64 (guard base64_corrupt): send the file with books.begin_upload".to_string())?;
if bytes.len() > INLINE_MAX {
return Err(format!(
"the file is {} bytes; inline is at most {INLINE_MAX}: upload it with books.begin_upload",
bytes.len()
));
}
if ledger_store::journal::sha256_hex(&bytes) != sha.trim().to_ascii_lowercase() {
return Err("content_base64 doesn't have the sha256 given: some of it was lost on the way (guard base64_corrupt). Send the file with books.begin_upload instead".into());
}
Ok(TakenFile {
bytes,
file_name: None,
mime: None,
claim: None,
})
}
}
}
}
#[tool_router(router = files_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.begin_upload",
title = "Begin an upload",
description = "Send a file of up to 25 MiB (books served by `wanderbooks mcp`; elsewhere only inline) without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn begin_upload(
&self,
params: Parameters<BeginUploadParams>,
) -> Result<Json<BeginUploadOut>, String> {
let p = params.0;
let max = ledger_store::uploads::MAX_UPLOAD;
let sha = p.sha256.trim().to_ascii_lowercase();
if !ledger_store::uploads::PURPOSES.contains(&p.purpose.as_str()) {
return Err("purpose is receipt, document, import (a bank or chart CSV) or w9".into());
}
// INVARIANT 2: an attachment that can be linked, for a file that
// would be one (an import or a W-9 needs its bytes).
let store = self.lock()?;
let existing = if FILED.contains(&p.purpose.as_str()) {
store
.list_attachments(None)
.map_err(|e| e.to_string())?
.into_iter()
.find(|a| a.blob_sha == sha && a.withdrawn_at.is_none())
.map(|a| a.id)
} else {
None
};
let book = store
.data_dir()
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
drop(store);
if let Some(id) = existing {
return Ok(Json(BeginUploadOut {
upload_id: None,
command: None,
expires_at: None,
max_bytes: max,
existing_attachment_id: Some(id),
}));
}
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
return Err(STDIO_ONLY_INLINE.into());
};
let now = chrono::Utc::now();
let grant = uploads
.grant(
&token,
&sha,
p.size,
&p.mime_type,
&p.file_name,
&p.purpose,
now.timestamp(),
)
.map_err(|e| e.to_string())?;
let expires = chrono::DateTime::from_timestamp(grant.expires, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_default();
Ok(Json(BeginUploadOut {
command: Some(format!(
"wanderbooks upload --book {book}{} {} -- <the file's path>",
token_flag(),
grant.id
)),
upload_id: Some(grant.id),
expires_at: Some(expires),
max_bytes: max,
existing_attachment_id: None,
}))
}
}