From a298d154b6a861207cc67c447475c1dce20f87ee Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 18 Oct 2026 21:08:49 -0600 Subject: [PATCH] P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23) One wanderbooks executable. The books are open while the viewer, an agent session or a terminal holds them: a host process takes leases on a socket, starts the server (the hidden mode `wanderbooks server`) with a pipe on its input, and closes it 10 s after the last lease ends. No background service. One host even in a race (run/host.lock). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- Cargo.lock | 418 +++- Cargo.toml | 2 +- crates/ledger-cli/Cargo.toml | 6 + crates/ledger-cli/src/main.rs | 84 +- crates/ledger-cli/tests/local_server.rs | 1065 ++++++++++ crates/ledger-cli/tests/one_executable.rs | 76 +- crates/ledger-cli/tests/open_while_held.rs | 1941 ++++++++++++++++++ crates/ledger-cli/tests/setup.rs | 48 +- crates/ledger-host/Cargo.toml | 27 + crates/ledger-host/src/bridge.rs | 2150 ++++++++++++++++++++ crates/ledger-host/src/commands.rs | 940 +++++++++ crates/ledger-host/src/helper.rs | 335 +++ crates/ledger-host/src/host.rs | 1087 ++++++++++ crates/ledger-host/src/knobs.rs | 150 ++ crates/ledger-host/src/lease.rs | 391 ++++ crates/ledger-host/src/lib.rs | 38 + crates/ledger-host/src/opener.rs | 512 +++++ crates/ledger-host/src/start.rs | 378 ++++ crates/ledger-mcp/Cargo.toml | 4 +- crates/ledger-mcp/src/admin.rs | 369 ++++ crates/ledger-mcp/src/lib.rs | 20 +- crates/ledger-mcp/src/local.rs | 1122 ++++++++++ crates/ledger-mcp/src/opening.rs | 89 + crates/ledger-mcp/src/server.rs | 274 ++- crates/ledger-mcp/src/settings.rs | 30 +- crates/ledger-mcp/src/tools/kept.rs | 4 +- crates/ledger-store/src/root.rs | 232 ++- crates/ledger-store/src/store.rs | 2 +- crates/ledger-store/src/viewer.rs | 58 +- crates/ledger-store/tests/root.rs | 187 +- plan/served.md | 10 +- 31 files changed, 11937 insertions(+), 112 deletions(-) create mode 100644 crates/ledger-cli/tests/local_server.rs create mode 100644 crates/ledger-cli/tests/open_while_held.rs create mode 100644 crates/ledger-host/Cargo.toml create mode 100644 crates/ledger-host/src/bridge.rs create mode 100644 crates/ledger-host/src/commands.rs create mode 100644 crates/ledger-host/src/helper.rs create mode 100644 crates/ledger-host/src/host.rs create mode 100644 crates/ledger-host/src/knobs.rs create mode 100644 crates/ledger-host/src/lease.rs create mode 100644 crates/ledger-host/src/lib.rs create mode 100644 crates/ledger-host/src/opener.rs create mode 100644 crates/ledger-host/src/start.rs create mode 100644 crates/ledger-mcp/src/admin.rs create mode 100644 crates/ledger-mcp/src/local.rs create mode 100644 crates/ledger-mcp/src/opening.rs diff --git a/Cargo.lock b/Cargo.lock index d3534d9..3967ade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -503,6 +503,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -1048,6 +1071,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.4" @@ -1176,6 +1208,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1189,7 +1231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -1202,7 +1244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -1591,6 +1633,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1805,6 +1853,20 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -2177,6 +2239,12 @@ dependencies = [ "num-bigint", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2351,8 +2419,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2561,6 +2631,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2918,7 +3007,9 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", + "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -2930,6 +3021,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -2949,9 +3055,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3656,10 +3764,16 @@ dependencies = [ "ledger-config", "ledger-core", "ledger-gui", + "ledger-host", "ledger-mcp", "ledger-store", + "ledger-vault", + "libc", + "reqwest", + "rmcp", "serde_json", "tempfile", + "tokio", "toml 1.1.8+spec-1.1.0", ] @@ -3716,6 +3830,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ledger-host" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "ledger-core", + "ledger-mcp", + "ledger-store", + "libc", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-util", +] + [[package]] name = "ledger-import" version = "0.1.0" @@ -3744,6 +3875,7 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "http-body-util", "jsonschema", "ledger-config", "ledger-core", @@ -3761,6 +3893,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tokio", + "tokio-util", "toml 1.1.8+spec-1.1.0", "tower", "zeroize", @@ -3926,6 +4059,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "lzma-rust2" version = "0.21.0" @@ -4041,6 +4180,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "mutate_once" version = "0.1.2" @@ -4608,6 +4753,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -5194,6 +5345,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.47" @@ -5291,6 +5499,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.5.0" @@ -5493,21 +5710,30 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ "base64 0.23.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -5545,6 +5771,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rmcp" version = "3.5.1" @@ -5723,6 +5963,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -5774,6 +6089,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "1.2.2" @@ -5812,6 +6136,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -6287,6 +6634,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.16.1" @@ -6360,6 +6713,27 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -6536,6 +6910,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -6552,6 +6927,16 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -6572,6 +6957,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "libc", "pin-project-lite", "tokio", @@ -7295,6 +7681,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -7805,6 +8197,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -8108,6 +8509,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -8315,7 +8727,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", diff --git a/Cargo.toml b/Cargo.toml index 8e04721..ef808a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault"] +members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault", "crates/ledger-host"] [workspace.package] edition = "2024" diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml index 1ffc7f6..563b94c 100644 --- a/crates/ledger-cli/Cargo.toml +++ b/crates/ledger-cli/Cargo.toml @@ -17,14 +17,20 @@ default = ["gui"] gui = ["dep:ledger-gui"] [dependencies] +ledger-host = { path = "../ledger-host" } ledger-mcp = { path = "../ledger-mcp" } ledger-store = { path = "../ledger-store" } ledger-gui = { path = "../ledger-gui", optional = true } [dev-dependencies] 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" toml = "1" tempfile = "3" diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index eb124a3..82a5f3d 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -394,11 +394,19 @@ fn print_usage() { eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]"); eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]"); eprintln!(" wanderbooks setup [--root PATH] [--home DIR]"); + eprintln!(" wanderbooks books list | create ID | adopt DATA-DIR --id ID"); + eprintln!(" wanderbooks mcp --book ID"); + eprintln!(" wanderbooks serve | close | doctor"); eprintln!(); eprintln!(" new make a books directory and start the agent in it (alias: init)"); eprintln!(" gui open the live, read-only view of a books directory"); eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)"); eprintln!(" setup make the books' private folder (only your account can open it)"); + eprintln!(" books list, start or adopt books (`books --help`)"); + eprintln!(" mcp connect an agent to books, over stdio (what `claude mcp add` runs)"); + eprintln!(" serve hold the books open until Ctrl-C"); + eprintln!(" close close the books now: everything holding them lets go"); + eprintln!(" doctor whether the books are open, what holds them, what went wrong"); eprintln!(" DIR the books directory (default: current directory)"); eprintln!(" --force rewrite the scaffold in an existing books directory"); eprintln!(" --no-start don't start the agent afterwards"); @@ -847,7 +855,14 @@ fn run_setup(args: Vec) -> i32 { look there (tests and unusual setups only)" ); } - let xdg = env::var_os("XDG_DATA_HOME").map(PathBuf::from); + // XDG_DATA_HOME is this account's, for this account's home: with + // another home given (tests), it is never read, so it can't aim at + // this account's real books. + let xdg = if account.as_ref() == Some(&home) { + env::var_os("XDG_DATA_HOME").map(PathBuf::from) + } else { + None + }; match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) { Ok((_, report)) => { let what = if report.created { @@ -884,6 +899,62 @@ fn run_dev_stdio(args: Vec) -> i32 { } } +/// `wanderbooks server`: the books server for the data root, on its +/// local sockets (plan 3.9). The host starts it. +#[cfg(unix)] +fn run_server(args: Vec) -> i32 { + match ledger_mcp::local::run("wanderbooks server", args) { + Ok(()) => 0, + Err(e) => match e.downcast_ref::() { + Some(exit) => exit.0, + None => { + eprintln!("error: {e}"); + 1 + } + }, + } +} + +/// The modes that open and hold the books (plan 3.9): the host, the +/// helper, the agents' bridge, and the owner's commands. +#[cfg(unix)] +fn run_local(mode: &str, args: Vec) -> i32 { + let prog = format!("wanderbooks {mode}"); + let result = match mode { + "host" => ledger_host::host::run(&prog, args), + "helper" => ledger_host::helper::run(&prog, args), + "mcp" => ledger_host::bridge::run(&prog, args), + "serve" => ledger_host::commands::serve(&prog, args), + "close" => ledger_host::commands::close(&prog, args), + "doctor" => ledger_host::commands::doctor(&prog, args), + _ => ledger_host::commands::books(&prog, args), + }; + match result { + Ok(()) => 0, + Err(e) => match e.downcast_ref::() { + Some(exit) => exit.0, + None => { + eprintln!("error: {e}"); + 1 + } + }, + } +} + +/// The modes that hold the books, where local sockets aren't built yet. +#[cfg(not(unix))] +fn run_local(_mode: &str, _args: Vec) -> i32 { + eprintln!("error: opening the books this way comes to this system with P11"); + 2 +} + +/// `wanderbooks server` where local sockets aren't built yet. +#[cfg(not(unix))] +fn run_server(_args: Vec) -> i32 { + eprintln!("error: the books server's local sockets come to this system with P11"); + 2 +} + fn main() { // The modes run in this process and take their own flags. let mut all = env::args().skip(1); @@ -891,6 +962,11 @@ fn main() { Some("gui") => std::process::exit(run_gui(all.collect())), Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())), Some("setup") => std::process::exit(run_setup(all.collect())), + Some("server") => std::process::exit(run_server(all.collect())), + Some(mode @ ("host" | "helper" | "mcp" | "serve" | "close" | "books" | "doctor")) => { + let mode = mode.to_string(); + std::process::exit(run_local(&mode, all.collect())) + } _ => {} } let mut dir: Option = None; @@ -921,7 +997,11 @@ fn main() { } } } - if let Some(mode @ ("gui" | "dev-stdio" | "setup")) = cmd.as_deref() { + if let Some( + mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "helper" | "mcp" | "serve" + | "close" | "books" | "doctor"), + ) = cmd.as_deref() + { eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]"); std::process::exit(2); } diff --git a/crates/ledger-cli/tests/local_server.rs b/crates/ledger-cli/tests/local_server.rs new file mode 100644 index 0000000..45b41ba --- /dev/null +++ b/crates/ledger-cli/tests/local_server.rs @@ -0,0 +1,1065 @@ +//! `wanderbooks server`: every book in the data root on the root's local +//! sockets (ledger-mcp local.rs). Oracles: the INVARIANTS of local.rs and +//! admin.rs, and plan 3.9 (closing: "no new requests, the one in progress +//! finishes, every writer lock is released"). +#![cfg(unix)] + +use ledger_mcp::admin::{self, Request, Response}; +use rmcp::ServiceExt; +use rmcp::model::{CallToolRequestParams, ClientConfig}; +use rmcp::service::{RoleClient, RunningService}; +use rmcp::transport::StreamableHttpClientTransport; +use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +type Client = RunningService; + +/// A home with a data root made by `wanderbooks setup`. +struct Home { + dir: tempfile::TempDir, +} + +impl Home { + fn new(tag: &str) -> Self { + let dir = tempfile::Builder::new() + .prefix(&format!("wb-srv-{tag}-")) + .tempdir() + .unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["setup", "--home"]) + .arg(dir.path()) + .env_remove("XDG_DATA_HOME") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + Self { dir } + } + + fn root(&self) -> PathBuf { + self.dir.path().join(".local/share/wanderbooks") + } + + fn run(&self) -> PathBuf { + self.root().join("run") + } + + fn admin(&self, request: Request) -> Response { + admin::call(&self.run().join("admin.sock"), request).unwrap() + } + + /// The text of every answer must not name the root (local.rs + /// INVARIANT 7). + fn assert_hidden(&self, text: &str) { + let root = self.root().display().to_string(); + assert!(!text.contains(&root), "names the data root: {text}"); + assert!( + !text.contains(&self.dir.path().display().to_string()), + "names the home: {text}" + ); + } + + fn create(&self, id: &str, currency: &str) { + match self.admin(Request::Create { + id: id.into(), + currency: currency.into(), + }) { + Response::Created { id: made, problem } => { + assert_eq!(made, id); + assert_eq!(problem, None); + } + other => panic!("{other:?}"), + } + } +} + +/// A running `wanderbooks server`, ended (kill) when dropped. +struct Server { + child: Child, +} + +impl Server { + /// Start one and wait for its ready line. + fn start(home: &Home) -> Self { + // Its environment is the test home's alone: the default import + // folder and vault key are there, never the real ones. + let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .arg("server") + .arg("--root") + .arg(home.root()) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", home.dir.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut line = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut line) + .unwrap(); + assert_eq!(line, "ready\n", "the server didn't say ready"); + Self { child } + } + + /// Close it as the host does (stdin ends) and wait for it to exit. + fn close(mut self) -> std::process::ExitStatus { + drop(self.child.stdin.take()); + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Some(status) = self.child.try_wait().unwrap() { + return status; + } + assert!(Instant::now() < deadline, "the server didn't close"); + std::thread::sleep(Duration::from_millis(20)); + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// An HTTP client for the server's MCP socket. +fn mcp_http(home: &Home) -> reqwest::Client { + // The short path is good in this process only, while it lives: kept. + let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap(); + let path = short.path().to_path_buf(); + std::mem::forget(short); + reqwest::Client::builder() + .unix_socket(path) + .build() + .unwrap() +} + +async fn connect(home: &Home, book: &str) -> Result { + let http = mcp_http(home); + let transport = StreamableHttpClientTransport::with_client( + http, + StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp")), + ); + ClientConfig::default() + .serve(transport) + .await + .map_err(|e| e.to_string()) +} + +/// Call a tool: Ok(structured result) or Err(the error text). +async fn call(c: &Client, tool: &str, args: Value) -> Result { + let args = args.as_object().cloned().unwrap_or_default(); + // A client whose server went away may wait forever: a call that + // takes this long counts as refused. + let r = tokio::time::timeout( + Duration::from_secs(20), + c.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args)), + ) + .await + .map_err(|_| "no answer".to_string())? + .map_err(|e| format!("protocol: {e}"))?; + let text = r + .content + .iter() + .find_map(|c| c.as_text().map(|t| t.text.clone())) + .unwrap_or_default(); + if r.is_error == Some(true) { + Err(text) + } else { + Ok(r.structured_content + .unwrap_or_else(|| serde_json::from_str(&text).unwrap_or(Value::String(text)))) + } +} + +async fn chart(c: &Client) { + for (code, name, ty) in [ + ("1000", "Cash", "asset"), + ("3000", "Capital", "equity"), + ("6000", "Supplies", "expense"), + ] { + call( + c, + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + ) + .await + .unwrap(); + } +} + +/// Draft a receipted $1.00 + n entry; post it. Ok(entry id) or Err(why). +async fn post_one(c: &Client, n: u64) -> Result { + let e = call( + c, + "books.draft_entry", + json!({"date": "2026-03-01", "description": format!("entry {n}")}), + ) + .await?; + let id = e["entry_id"].as_u64().unwrap(); + let amount = format!("{}.{:02}", 1 + n / 100, n % 100); + for (account, side) in [("6000", "debit"), ("1000", "credit")] { + call( + c, + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + ) + .await?; + } + call( + c, + "books.attach_receipt", + json!({"entry_id": id, "file_name": format!("r{n}.txt"), + "mime_type": "text/plain", "content_base64": "cmVjZWlwdA=="}), + ) + .await?; + call( + c, + "books.post_entry", + json!({"entry_id": id, "confirmed": true, "no_party_reason": "test"}), + ) + .await?; + Ok(id) +} + +fn mode(path: &Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn every_book_is_served_on_its_own_route() { + let home = Home::new("routes"); + let server = Server::start(&home); + for sock in ["mcp.sock", "admin.sock"] { + assert_eq!(mode(&home.run().join(sock)), 0o600, "{sock}"); + } + assert_eq!(home.admin(Request::List), Response::Books(vec![])); + home.create("acme", "USD"); + home.create("beta", "EUR"); + match home.admin(Request::List) { + Response::Books(books) => { + let ids: Vec<_> = books.iter().map(|b| (b.id.as_str(), b.open)).collect(); + assert_eq!(ids, [("acme", true), ("beta", true)]); + } + other => panic!("{other:?}"), + } + // Each route reaches its own books. + let acme = connect(&home, "acme").await.unwrap(); + let beta = connect(&home, "beta").await.unwrap(); + chart(&acme).await; + let a = call(&acme, "books.chart", json!({})).await.unwrap(); + let b = call(&beta, "books.chart", json!({})).await.unwrap(); + assert_ne!(a, b, "both routes answered from the same books"); + assert!(a.to_string().contains("Supplies"), "{a}"); + assert!(!b.to_string().contains("Supplies"), "{b}"); + let id = post_one(&acme, 1).await.unwrap(); + let entry = call(&acme, "books.get_entry", json!({"entry_id": id})) + .await + .unwrap(); + home.assert_hidden(&entry.to_string()); + let start = call(&beta, "books.start", json!({})).await.unwrap(); + home.assert_hidden(&start.to_string()); + assert!(start.to_string().contains("EUR"), "{start}"); + // A route for no books says so, and names no folder. + let err = connect(&home, "nobody").await.unwrap_err(); + home.assert_hidden(&err); + drop((acme, beta)); + assert!(server.close().success()); +} + +/// local.rs INVARIANT 1: a second server waits for the first (an old +/// one still finishing after its host died), touching nothing, and takes +/// over when it is gone; told to close first, it just goes. +#[test] +fn a_second_server_waits_for_the_first() { + let home = Home::new("second"); + let first = Server::start(&home); + home.create("acme", "USD"); + let spawn = || { + Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .arg("server") + .arg("--root") + .arg(home.root()) + .env_clear() + .env("HOME", home.dir.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap() + }; + // Told to close while waiting: it goes, having done nothing. + let mut quitter = spawn(); + std::thread::sleep(Duration::from_millis(300)); + assert!(quitter.try_wait().unwrap().is_none(), "it didn't wait"); + drop(quitter.stdin.take()); + assert!(wait_exit(&mut quitter, 10).is_some_and(|s| s.success())); + // The first is untouched: its sockets still answer. + assert!(matches!(home.admin(Request::List), Response::Books(b) if b.len() == 1)); + // Another waits, then takes over once the first has closed. + let mut second = spawn(); + std::thread::sleep(Duration::from_millis(300)); + assert!(second.try_wait().unwrap().is_none()); + assert!(first.close().success()); + let mut line = String::new(); + BufReader::new(second.stdout.take().unwrap()) + .read_line(&mut line) + .unwrap(); + assert_eq!(line, "ready\n"); + assert!(matches!(home.admin(Request::List), Response::Books(b) if b.len() == 1 && b[0].open)); + drop(second.stdin.take()); + assert!(wait_exit(&mut second, 20).is_some_and(|s| s.success())); +} + +#[test] +fn closing_lets_go_of_every_book_and_removes_the_sockets() { + let home = Home::new("close"); + let server = Server::start(&home); + home.create("acme", "USD"); + home.create("beta", "USD"); + assert!(server.close().success()); + for sock in ["mcp.sock", "admin.sock"] { + assert!(!home.run().join(sock).exists(), "{sock} left behind"); + } + // Every writer lock released: the books open, and verify. + for id in ["acme", "beta"] { + let mut store = ledger_store::Store::open(&home.root().join("books").join(id)).unwrap(); + store.verify_and_heal().unwrap(); + } + // And a new server opens them all again. + let again = Server::start(&home); + assert!( + matches!(home.admin(Request::List), Response::Books(b) if b.len() == 2 && b.iter().all(|b| b.open)) + ); + assert!(again.close().success()); +} + +#[test] +fn signals_close_the_server_cleanly() { + for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] { + let home = Home::new("signal"); + let mut server = Server::start(&home); + home.create("acme", "USD"); + // SAFETY: the pid is our own child's. + assert_eq!(unsafe { libc::kill(server.child.id() as i32, signal) }, 0); + let status = server.child.wait().unwrap(); + assert!(status.success(), "signal {signal}: {status:?}"); + assert!(ledger_store::Store::open(&home.root().join("books/acme")).is_ok()); + } +} + +/// The server half of `books_close_cleanly` (plan P2 tests): closing +/// during a burst of posts. Every post either completed (and is in the +/// books) or was refused (and isn't); the books verify at the next open. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn closing_during_a_burst_of_posts_loses_nothing() { + let home = Home::new("burst"); + let server = Server::start(&home); + home.create("acme", "USD"); + let setup = connect(&home, "acme").await.unwrap(); + chart(&setup).await; + drop(setup); + let mut tasks = Vec::new(); + for n in 0..4u64 { + let c = connect(&home, "acme").await.unwrap(); + tasks.push(tokio::spawn(async move { + // Until the close turns it away: it can't finish first. + let mut posted = Vec::new(); + for k in 0.. { + match post_one(&c, n * 100_000 + k).await { + Ok(id) => posted.push(id), + Err(why) => return (posted, why), + } + } + unreachable!() + })); + } + tokio::time::sleep(Duration::from_millis(1500)).await; + let closed = tokio::task::spawn_blocking(move || server.close()) + .await + .unwrap(); + assert!(closed.success()); + let mut posted = Vec::new(); + for t in tasks { + let (theirs, why) = t.await.unwrap(); + // Each client was stopped by the close, mid-burst. + assert!( + !theirs.is_empty(), + "a client posted nothing before the close" + ); + assert!( + why.contains("closing") || why.contains("no answer") || why.contains("protocol"), + "stopped by something else: {why}" + ); + posted.extend(theirs); + } + let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap(); + store.verify_and_heal().unwrap(); + let mut in_books: Vec = store.ledger().posted_entries().map(|e| e.id()).collect(); + in_books.sort_unstable(); + posted.sort_unstable(); + assert_eq!( + in_books, posted, + "a post was lost or made without its answer" + ); +} + +#[test] +fn the_admin_socket_refuses_bad_requests_plainly() { + let home = Home::new("admin"); + let server = Server::start(&home); + home.create("acme", "USD"); + for (request, why) in [ + ( + Request::Create { + id: "acme".into(), + currency: "USD".into(), + }, + "already books called", + ), + ( + Request::Create { + id: "Bad Id".into(), + currency: "USD".into(), + }, + "can't name books", + ), + ( + Request::Create { + id: "new".into(), + currency: "dollars".into(), + }, + "isn't a currency", + ), + ( + Adopt { + confirmed: true, + ..Adopt::new("old", &home.root().join("books/acme")) + } + .request(), + "inside the books' private folder", + ), + ] { + match home.admin(request) { + Response::Refused(text) => { + assert!(text.contains(why), "{text}"); + home.assert_hidden(&text); + } + other => panic!("{other:?}"), + } + } + // A vault without its key, or a key without its vault. + let old = tempfile::Builder::new() + .prefix("wb-srv-badv-") + .tempdir() + .unwrap(); + let data = old_books(old.path()); + for vault in [ + (Some(old.path().join("vault")), None), + (None, Some(old.path().join("vault.key"))), + ] { + let r = Request::Adopt { + id: "old".into(), + source: data.clone(), + settings_toml: None, + config_json: None, + vault_dir: vault.0, + vault_key: vault.1, + confirmed: false, + expect_head: None, + skip_unknown: false, + }; + match home.admin(r) { + Response::Refused(why) => assert!(why.contains("comes with its key"), "{why}"), + other => panic!("{other:?}"), + } + } + // Raw lines: not JSON, an unknown operation, another version, none. + let sock = home.run().join("admin.sock"); + for (line, why) in [ + ("hello\n", "isn't understood"), + ( + "{\"proto\":1,\"op\":\"delete\",\"id\":\"acme\"}\n", + "isn't understood", + ), + ("{\"proto\":9,\"op\":\"list\"}\n", "another version"), + ("{\"proto\":0,\"op\":\"list\"}\n", "another version"), + ("{\"proto\":1,\"op\":\"list\"}", "cut off"), + ] { + let answer = raw_admin(&sock, line); + assert!(answer.contains(why), "{line:?}: {answer}"); + } + assert!(server.close().success()); +} + +/// Send `line` to the admin socket as is; the answer's text. +fn raw_admin(sock: &Path, line: &str) -> String { + use std::io::{Read, Write}; + let mut s = std::os::unix::net::UnixStream::connect(sock).unwrap(); + s.write_all(line.as_bytes()).unwrap(); + s.shutdown(std::net::Shutdown::Write).unwrap(); + let mut out = String::new(); + s.read_to_string(&mut out).unwrap(); + out +} + +/// An adoption request, built up. +#[derive(Clone, Default)] +struct Adopt { + id: String, + source: PathBuf, + settings_toml: Option, + vault: Option<(PathBuf, PathBuf)>, + confirmed: bool, + expect_head: Option<(u64, String)>, + skip_unknown: bool, +} + +impl Adopt { + fn new(id: &str, source: &Path) -> Self { + Self { + id: id.into(), + source: source.to_path_buf(), + ..Self::default() + } + } + + /// Confirmed, at the head a preview read. + fn at(mut self, head: &(u64, String)) -> Self { + self.confirmed = true; + self.expect_head = Some(head.clone()); + self + } + + fn request(self) -> Request { + let (vault_dir, vault_key) = match self.vault { + Some((d, k)) => (Some(d), Some(k)), + None => (None, None), + }; + Request::Adopt { + id: self.id, + source: self.source, + settings_toml: self.settings_toml, + config_json: None, + vault_dir, + vault_key, + confirmed: self.confirmed, + expect_head: self.expect_head, + skip_unknown: self.skip_unknown, + } + } +} + +/// Old books in a project folder, as P1 kept them: a data dir with no +/// settings of its own, and its settings file. +fn old_books(dir: &Path) -> PathBuf { + let data = dir.join("books-data"); + let store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap(); + drop(store); + data +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn adoption_previews_then_adopts_and_serves() { + let home = Home::new("adopt"); + let server = Server::start(&home); + let old = tempfile::Builder::new() + .prefix("wb-srv-old-") + .tempdir() + .unwrap(); + let data = old_books(old.path()); + let toml = "[company]\nname = \"Acme Widgets LLC\"\n".to_string(); + let plan = Adopt { + settings_toml: Some(toml.clone()), + ..Adopt::new("acme", &data) + }; + let journal_before = std::fs::read(data.join("journal.jsonl")).unwrap(); + let preview = match home.admin(plan.clone().request()) { + Response::Preview(p) => p, + other => panic!("{other:?}"), + }; + assert!(!preview.keeps_settings); + assert!( + preview + .settings + .iter() + .any(|s| s.key == "company.name" && s.value == Some(json!("Acme Widgets LLC"))), + "{preview:?}" + ); + // A preview writes nothing: no books, the source as it was. + assert_eq!(home.admin(Request::List), Response::Books(vec![])); + assert_eq!( + std::fs::read(data.join("journal.jsonl")).unwrap(), + journal_before + ); + let adopted = match home.admin(plan.at(&preview.source_head).request()) { + Response::Adopted(a) => a, + other => panic!("{other:?}"), + }; + assert_eq!(adopted.id, "acme"); + assert_eq!(adopted.source_head, preview.source_head); + assert_eq!(adopted.settings_imported, preview.settings.len()); + assert_eq!( + std::fs::read(data.join("journal.jsonl")).unwrap(), + journal_before + ); + // Served at once, with its settings. + let c = connect(&home, "acme").await.unwrap(); + let settings = call(&c, "books.settings", json!({})).await.unwrap(); + assert!( + settings.to_string().contains("Acme Widgets LLC"), + "{settings}" + ); + home.assert_hidden(&settings.to_string()); + let verify = call(&c, "books.verify", json!({})).await.unwrap(); + home.assert_hidden(&verify.to_string()); + drop(c); + assert!(server.close().success()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_book_in_the_root_keeps_its_vault_and_key_inside_its_own_folder() { + let home = Home::new("vault"); + let server = Server::start(&home); + home.create("acme", "USD"); + let c = connect(&home, "acme").await.unwrap(); + call( + &c, + "books.add_party", + json!({"name": "Jane Contractor", "roles": []}), + ) + .await + .unwrap(); + call( + &c, + "books.set_tin", + json!({"party": "Jane Contractor", "tin": "241-40-4915", "tin_type": "ssn"}), + ) + .await + .unwrap(); + let book = home.root().join("books/acme"); + assert_eq!(mode(&book.join("keys")), 0o700); + assert_eq!(mode(&book.join("keys/legacy.key")), 0o600); + assert_eq!(mode(&book.join("vault")), 0o700); + assert!( + std::fs::read_dir(book.join("vault")).unwrap().count() > 0, + "the TIN wasn't sealed into the book's vault" + ); + // A file with TINs is never written into the root. + let err = call( + &c, + "books.vendor_1099", + json!({"year": 2026, "include_tins": true, + "save_csv_to": home.root().join("x.csv").display().to_string()}), + ) + .await + .unwrap_err(); + home.assert_hidden(&err); + assert!(!home.root().join("x.csv").exists()); + drop(c); + assert!(server.close().success()); +} + +/// An MCP session over raw HTTP: initialized, its session id. +async fn raw_session(http: &reqwest::Client, book: &str) -> String { + let url = format!("http://localhost/b/{book}/mcp"); + let r = http + .post(&url) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .body( + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}}}) + .to_string(), + ) + .send() + .await + .unwrap(); + let session = r.headers()["mcp-session-id"].to_str().unwrap().to_string(); + let _ = r.text().await; + http.post(&url) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", &session) + .header("mcp-protocol-version", "2025-06-18") + .body(json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string()) + .send() + .await + .unwrap(); + session +} + +/// Wait for a child to exit, at most `secs`. +fn wait_exit(child: &mut Child, secs: u64) -> Option { + let deadline = Instant::now() + Duration::from_secs(secs); + while Instant::now() < deadline { + if let Some(status) = child.try_wait().unwrap() { + return Some(status); + } + std::thread::sleep(Duration::from_millis(20)); + } + None +} + +/// Review 3a, finding 1: a tool whose client hung up still runs to its +/// end before the server lets go of the books (SharedStore INVARIANT 3). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_call_whose_client_hung_up_finishes_before_the_close() { + let home = Home::new("hangup"); + let mut server = Server::start(&home); + home.create("acme", "USD"); + let http = mcp_http(&home); + let session = raw_session(&http, "acme").await; + // A large document: its filing takes long enough to close mid-way. + let mut bytes = b"%PDF-1.4\n".to_vec(); + bytes.extend((0..20_000_000u32).map(|i| (i % 251) as u8)); + use base64_engine::encode; + let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { + "name": "books.file_document", "arguments": { + "category": "formation", "file_name": "big.pdf", + "mime_type": "application/pdf", "content_base64": encode(&bytes)}}}); + let response = http + .post("http://localhost/b/acme/mcp") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", &session) + .header("mcp-protocol-version", "2025-06-18") + .body(call.to_string()) + .send() + .await + .unwrap(); + // Hang up with the headers read (the call is running by then), then + // close. + drop(response); + drop(http); + tokio::time::sleep(Duration::from_millis(200)).await; + drop(server.child.stdin.take()); + let status = tokio::task::spawn_blocking(move || { + let s = wait_exit(&mut server.child, 120); + (s, server) + }) + .await + .unwrap(); + assert!(status.0.is_some_and(|s| s.success()), "{:?}", status.0); + // The document is in the books whole, or not at all; never half. + let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap(); + store.verify_and_heal().unwrap(); + drop(store); + let snap = ledger_store::Snapshot::load(&home.root().join("books/acme")).unwrap(); + // It ran to its end before the server let go: filed whole. + let filed = snap.attachments(None).unwrap(); + assert_eq!(filed.len(), 1, "the call was cut off"); + assert_eq!(snap.attachment_bytes(filed[0].id).unwrap(), bytes); +} + +/// Base64 for the tests (standard alphabet, padded). +mod base64_engine { + pub fn encode(bytes: &[u8]) -> String { + const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len() * 4 / 3 + 4); + for c in bytes.chunks(3) { + let n = (u32::from(c[0]) << 16) + | (u32::from(*c.get(1).unwrap_or(&0)) << 8) + | u32::from(*c.get(2).unwrap_or(&0)); + for (i, shift) in [18, 12, 6, 0].iter().enumerate() { + out.push(if i <= c.len() { + T[((n >> shift) & 63) as usize] as char + } else { + '=' + }); + } + } + out + } +} + +/// Send the start of a request that never finishes. +fn stall(home: &Home) -> std::os::unix::net::UnixStream { + use std::io::Write; + let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap(); + let mut s = std::os::unix::net::UnixStream::connect(short.path()).unwrap(); + s.write_all( + b"POST /b/acme/mcp HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n\ + Accept: application/json, text/event-stream\r\nContent-Length: 1000\r\n\r\n{\"jsonrpc\"", + ) + .unwrap(); + s +} + +/// Review 3a, finding 2: a request that never finishes arriving doesn't +/// hold the books open; a second signal stops the wait for it at once. +#[test] +fn a_stalled_request_doesnt_hold_the_books_open() { + let home = Home::new("stall"); + let mut server = Server::start(&home); + home.create("acme", "USD"); + let _held = stall(&home); + std::thread::sleep(Duration::from_millis(200)); + let started = Instant::now(); + drop(server.child.stdin.take()); + let status = wait_exit(&mut server.child, 40).expect("the server never closed"); + assert!(status.success()); + assert!( + started.elapsed() < Duration::from_secs(30), + "{:?}", + started.elapsed() + ); + + let mut server = Server::start(&home); + let _held = stall(&home); + std::thread::sleep(Duration::from_millis(200)); + let started = Instant::now(); + let pid = server.child.id() as i32; + // SAFETY: our own child. + unsafe { libc::kill(pid, libc::SIGTERM) }; + std::thread::sleep(Duration::from_millis(300)); + // SAFETY: our own child. + unsafe { libc::kill(pid, libc::SIGTERM) }; + let status = wait_exit(&mut server.child, 20).expect("a second signal didn't hurry it"); + assert!(status.success()); + assert!( + started.elapsed() < Duration::from_secs(9), + "{:?}", + started.elapsed() + ); + // And the admin socket, idle, doesn't hold it either. + let mut server = Server::start(&home); + let short = ledger_store::root::SocketPath::new(&home.run(), "admin.sock").unwrap(); + let _idle = std::os::unix::net::UnixStream::connect(short.path()).unwrap(); + let started = Instant::now(); + drop(server.child.stdin.take()); + assert!(wait_exit(&mut server.child, 20).is_some_and(|s| s.success())); + assert!( + started.elapsed() < Duration::from_secs(5), + "{:?}", + started.elapsed() + ); +} + +/// Review 3a, finding 12: told to close while opening, it closes cleanly. +#[test] +fn a_signal_while_opening_still_closes_cleanly() { + let home = Home::new("early"); + let server = Server::start(&home); + home.create("acme", "USD"); + assert!(server.close().success()); + let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["server", "--root"]) + .arg(home.root()) + .env_clear() + .env("HOME", home.dir.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + // SAFETY: our own child. + unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }; + let status = wait_exit(&mut child, 20).unwrap(); + // Either caught (exit 0) or, if it came before the handlers, the + // default (killed by the signal) with nothing written half-way. + assert!(status.success() || status.code().is_none(), "{status:?}"); + let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap(); + store.verify_and_heal().unwrap(); +} + +/// After kill -9 the next server replaces the stale sockets and serves. +#[test] +fn a_killed_server_is_replaced_cleanly() { + let home = Home::new("kill9"); + let mut server = Server::start(&home); + home.create("acme", "USD"); + server.child.kill().unwrap(); + server.child.wait().unwrap(); + assert!(home.run().join("mcp.sock").exists(), "stale socket left"); + let again = Server::start(&home); + assert!(matches!(home.admin(Request::List), Response::Books(b) if b.len() == 1 && b[0].open)); + assert!(again.close().success()); +} + +/// Review 3a, findings 3 and 4: nothing written into the root, and a +/// vault folder made by the filing guard is private, so TINs can follow. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn nothing_is_written_in_the_root_and_the_vault_stays_private() { + let home = Home::new("cpa"); + let server = Server::start(&home); + home.create("acme", "USD"); + let c = connect(&home, "acme").await.unwrap(); + let book = home.root().join("books/acme"); + let err = call( + &c, + "books.export_cpa_package", + json!({"as_of": "2026-10-18", "include_tins": false, + "save_to": book.display().to_string()}), + ) + .await + .unwrap_err(); + home.assert_hidden(&err); + let zips: Vec<_> = std::fs::read_dir(&book) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".zip")) + .collect(); + assert!(zips.is_empty(), "a package was written into the root"); + // A TIN file written outside first: the vault folder it records into + // is made private, and storing a TIN then works. + let out = tempfile::Builder::new() + .prefix("wb-srv-out-") + .tempdir() + .unwrap(); + let _ = call( + &c, + "books.vendor_1099", + json!({"year": 2026, "include_tins": true, + "save_csv_to": out.path().join("1099.csv").display().to_string()}), + ) + .await; + if book.join("vault").exists() { + assert_eq!(mode(&book.join("vault")), 0o700); + } + call( + &c, + "books.add_party", + json!({"name": "Jane Contractor", "roles": []}), + ) + .await + .unwrap(); + call( + &c, + "books.set_tin", + json!({"party": "Jane Contractor", "tin": "241-40-4915", "tin_type": "ssn"}), + ) + .await + .unwrap(); + assert_eq!(mode(&book.join("vault")), 0o700); + // books.export_settings works for books that read no file. + call(&c, "books.export_settings", json!({})).await.unwrap(); + drop(c); + assert!(server.close().success()); +} + +/// Old books with a TIN vault, as P1 kept them: the data dir, `vault/` +/// beside it holding one sealed TIN, and its key. +fn old_books_with_vault(dir: &Path) -> (PathBuf, PathBuf, PathBuf) { + let data = old_books(dir); + let vault = dir.join("vault"); + let key = dir.join("vault.key"); + let v = ledger_vault::Vault::open(&vault, &key, true).unwrap(); + v.put_tin( + 1, + &ledger_vault::Tin::new("241-40-4915", ledger_vault::TinType::Ssn).unwrap(), + ) + .unwrap(); + (data, vault, key) +} + +#[test] +fn adoption_checks_the_vault_key_and_the_settings_file() { + let home = Home::new("adoptv"); + let server = Server::start(&home); + let old = tempfile::Builder::new() + .prefix("wb-srv-oldv-") + .tempdir() + .unwrap(); + let (data, vault, key) = old_books_with_vault(old.path()); + // Another key doesn't open the records: refused, nothing made. + let wrong = old.path().join("other.key"); + ledger_vault::Vault::open(&old.path().join("v2"), &wrong, true).unwrap(); + let toml = "[company]\nname = \"Acme\"\n".to_string(); + let with = |key: &Path| Adopt { + settings_toml: Some(toml.clone()), + vault: Some((vault.clone(), key.to_path_buf())), + ..Adopt::new("acme", &data) + }; + match home.admin(with(&wrong).request()) { + Response::Refused(why) => assert!(why.contains("doesn't open"), "{why}"), + other => panic!("{other:?}"), + } + // A settings file naming another key file: refused. + let named = Adopt { + settings_toml: Some(format!( + "{toml}[vault]\nkey_file = \"{}\"\n", + wrong.display() + )), + ..with(&key) + }; + match home.admin(named.request()) { + Response::Refused(why) => assert!(why.contains("key_file"), "{why}"), + other => panic!("{other:?}"), + } + // Entries the books don't know: shown, then refused unless skipped. + let unknown = Adopt { + settings_toml: Some(format!("{toml}bogus_key = 1\n# kept elsewhere\n")), + ..with(&key) + }; + let preview = match home.admin(unknown.clone().request()) { + Response::Preview(p) => p, + other => panic!("{other:?}"), + }; + assert_eq!(preview.vault_records, 1); + assert!( + preview.unknown.iter().any(|u| u.contains("bogus_key")), + "{preview:?}" + ); + assert!( + preview + .comments + .iter() + .any(|c| c.contains("kept elsewhere")), + "{preview:?}" + ); + match home.admin(unknown.clone().at(&preview.source_head).request()) { + Response::Refused(why) => assert!(why.contains("--skip-unknown"), "{why}"), + other => panic!("{other:?}"), + } + assert_eq!(home.admin(Request::List), Response::Books(vec![])); + // Changed since the preview: refused. + { + let mut s = ledger_store::Store::open(&data).unwrap(); + s.add_account("1000", "Bank", ledger_core::AccountType::Asset, None, false) + .unwrap(); + } + let skipping = Adopt { + skip_unknown: true, + ..unknown + }; + // (A child another test is spawning can hold a copy of the old + // books' lock for an instant after `s` let go: asked again then.) + let answer = (0..50) + .map(|_| home.admin(skipping.clone().at(&preview.source_head).request())) + .find(|r| { + let busy = matches!(r, Response::Refused(why) if why.contains("open for writing")); + if busy { + std::thread::sleep(Duration::from_millis(100)); + } + !busy + }) + .expect("the old books stayed locked"); + match answer { + Response::Refused(why) => assert!(why.contains("changed since the preview"), "{why}"), + other => panic!("{other:?}"), + } + // Previewed again and confirmed: adopted, the vault and its key in. + let head = match home.admin(skipping.clone().request()) { + Response::Preview(p) => p.source_head, + other => panic!("{other:?}"), + }; + match home.admin(skipping.at(&head).request()) { + Response::Adopted(a) => { + assert_eq!(a.vault_files, 1); + assert!(a.key_copied); + assert_eq!(a.problem, None); + } + other => panic!("{other:?}"), + } + let book = home.root().join("books/acme"); + assert_eq!(mode(&book.join("keys/legacy.key")), 0o600); + assert!(book.join("vault/tin-1.bin").is_file()); + assert!(server.close().success()); +} diff --git a/crates/ledger-cli/tests/one_executable.rs b/crates/ledger-cli/tests/one_executable.rs index beaf8b1..9e38d68 100644 --- a/crates/ledger-cli/tests/one_executable.rs +++ b/crates/ledger-cli/tests/one_executable.rs @@ -103,6 +103,14 @@ fn each_mode_names_itself_in_its_usage() { "{err}" ); + let out = wanderbooks().args(["server", "--help"]).output().unwrap(); + assert!(out.status.success()); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("usage: wanderbooks server [--root DIR]"), + "{err}" + ); + let out = wanderbooks().arg("--help").output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); for line in [ @@ -129,9 +137,9 @@ fn the_viewer_names_itself_in_its_usage() { #[test] fn a_mode_refuses_what_it_does_not_know() { let modes: &[&str] = if cfg!(feature = "gui") { - &["dev-stdio", "gui"] + &["dev-stdio", "gui", "server"] } else { - &["dev-stdio"] + &["dev-stdio", "server"] }; for &mode in modes { let out = wanderbooks() @@ -150,7 +158,7 @@ fn a_mode_refuses_what_it_does_not_know() { #[test] fn a_mode_comes_first() { - for mode in ["gui", "dev-stdio"] { + for mode in ["gui", "dev-stdio", "server"] { let out = wanderbooks().args(["--codex", mode]).output().unwrap(); assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}"); let err = String::from_utf8_lossy(&out.stderr); @@ -218,3 +226,65 @@ fn the_server_only_build_has_no_viewer() { "{err}" ); } + +/// Books in the data root are the books server's alone (plan 3.1): the +/// test server refuses them, and the books server needs a root. +#[cfg(unix)] +#[test] +fn dev_stdio_refuses_books_in_the_private_folder() { + let home = tempfile::Builder::new() + .prefix("wb-one-root-") + .tempdir() + .unwrap(); + let out = wanderbooks() + .args(["setup", "--home"]) + .arg(home.path()) + .env_remove("XDG_DATA_HOME") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + let root = home.path().join(".local/share/wanderbooks"); + for inside in [ + root.join("books/acme"), + root.clone(), + root.join("books/a/b"), + ] { + let out = wanderbooks() + .args(["dev-stdio", "--data-dir"]) + .arg(&inside) + .stdin(std::process::Stdio::null()) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2), "{inside:?}: {out:?}"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("only the books server opens"), "{err}"); + assert!(!inside.join("journal.jsonl").exists(), "{inside:?}"); + } + assert!(!root.join("books/acme").exists()); + // A relative path, from inside the root. + let out = wanderbooks() + .args(["dev-stdio", "--data-dir", "acme"]) + .current_dir(root.join("books")) + .stdin(std::process::Stdio::null()) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2), "{out:?}"); + assert!(!root.join("books/acme").exists()); + + // No root yet: the books server says to run setup. + let empty = tempfile::Builder::new() + .prefix("wb-one-noroot-") + .tempdir() + .unwrap(); + let out = wanderbooks() + .args(["server", "--root"]) + .arg(empty.path().join("none")) + .stdin(std::process::Stdio::null()) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("run `wanderbooks setup` first"), + "{out:?}" + ); +} diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs new file mode 100644 index 0000000..5b4c2f2 --- /dev/null +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -0,0 +1,1941 @@ +//! The books open while something holds them (plan 3.9, D-21, D-23): the +//! host, its leases, the helper, the agents' bridge, and `serve`, `close` +//! and `books`. Oracles: the INVARIANTS of ledger-host's host.rs, +//! opener.rs, start.rs, bridge.rs and lease.rs, and the P2 tests the plan +//! names, each covered here under a name of its own (books_close_with_last_holder, openers_share_one_server, +//! bridge_opens_closed_books, bridge_survives_server_restart, +//! close_now_ends_every_lease, host_starts_clean, host_respawns, +//! respawn_backs_off, lease_socket_accepts_only_owner_intents, +//! restart_is_not_close, children_match_host_version, +//! bridge_initializes_during_cold_start, no_local_agent_tcp). +#![cfg(target_os = "linux")] + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +/// Short timings for tests (the defaults are the plan's). +const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000"; + +/// A home with a data root, and the program the tests run (possibly a +/// copy, for restarts). +struct Home { + dir: tempfile::TempDir, + exe: PathBuf, +} + +impl Home { + fn new(tag: &str) -> Self { + let dir = tempfile::Builder::new() + .prefix(&format!("wb-held-{tag}-")) + .tempdir() + .unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + .args(["setup", "--home"]) + .arg(dir.path()) + .env_remove("XDG_DATA_HOME") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + Self { + dir, + exe: PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks")), + } + } + + /// Run the program from a copy in the home, so a test can replace it. + fn with_copy(mut self) -> Self { + let bin = self.dir.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let copy = bin.join("wanderbooks"); + std::fs::copy(env!("CARGO_BIN_EXE_wanderbooks"), ©).unwrap(); + self.exe = copy; + self + } + + /// Replace the program with a new file (as a package upgrade does). + fn replace_program(&self) { + let next = self.exe.with_extension("new"); + std::fs::copy(env!("CARGO_BIN_EXE_wanderbooks"), &next).unwrap(); + std::fs::rename(&next, &self.exe).unwrap(); + } + + fn root(&self) -> PathBuf { + self.dir.path().join(".local/share/wanderbooks") + } + + fn run_dir(&self) -> PathBuf { + self.root().join("run") + } + + /// The program, with the root, the test knobs, and the test home's + /// environment alone. + fn cmd(&self, mode: &str) -> Command { + let mut c = Command::new(&self.exe); + c.arg(mode) + .arg("--root") + .arg(self.root()) + .args(["--test-knobs", KNOBS]) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", self.dir.path()); + c + } + + fn books(&self, args: &[&str]) -> std::process::Output { + let mut c = self.cmd("books"); + c.args(args).stdin(Stdio::null()); + c.output().unwrap() + } + + fn create(&self, id: &str) { + let out = self.books(&["create", id]); + assert!(out.status.success(), "{out:?}"); + } + + /// The pids running `mode` for this root. + fn pids(&self, mode: &str) -> Vec { + let root = self.root().display().to_string(); + let mut out = Vec::new(); + for entry in std::fs::read_dir("/proc").unwrap().flatten() { + let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { + continue; + }; + let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { + continue; + }; + let args: Vec = raw + .split(|b| *b == 0) + .map(|a| String::from_utf8_lossy(a).into_owned()) + .collect(); + if args.get(1).map(String::as_str) == Some(mode) && args.contains(&root) { + // A zombie has an empty cmdline; this one is running. + out.push(pid); + } + } + out + } + + /// The host. Across a restart the old one hands over to the new for a + /// moment; any longer, two hosts is a failure. + fn host(&self) -> Option { + wait_for(5, || self.pids("host").len() <= 1); + let pids = self.pids("host"); + assert!(pids.len() <= 1, "more than one host: {pids:?}"); + pids.first().copied() + } + + /// Wait until there is no host (the books closed), at most `secs`. + fn wait_closed(&self, secs: u64) -> bool { + wait_for(secs, || { + self.host().is_none() && self.pids("server").is_empty() + }) + } + + /// One line to the lease socket, the answer. + fn lease_line(&self, line: &str) -> String { + use std::io::Read; + let short = ledger_store::root::SocketPath::new(&self.run_dir(), "lease.sock").unwrap(); + let mut s = std::os::unix::net::UnixStream::connect(short.path()).unwrap(); + s.write_all(line.as_bytes()).unwrap(); + s.shutdown(std::net::Shutdown::Write).unwrap(); + let mut out = String::new(); + s.read_to_string(&mut out).unwrap(); + out + } +} + +impl Drop for Home { + fn drop(&mut self) { + // Leave nothing running: close the books, then make sure. + let _ = self + .cmd("close") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + // Holders first (one dying could start a host), then the rest, + // again until nothing of this root is left. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let mut found = false; + for mode in ["serve", "mcp", "helper", "host", "server"] { + for pid in self.pids(mode) { + found = true; + // SAFETY: a process of this test's own root. + unsafe { libc::kill(pid as i32, libc::SIGKILL) }; + } + } + if !found || Instant::now() > deadline { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + } +} + +fn wait_for(secs: u64, mut done: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + Duration::from_secs(secs); + while Instant::now() < deadline { + if done() { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + done() +} + +/// A child whose stdout lines arrive on a channel. +struct Running { + child: Child, + lines: mpsc::Receiver, +} + +impl Running { + fn start(mut c: Command) -> Self { + let mut child = c + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let (tx, lines) = mpsc::channel(); + let out = child.stdout.take().unwrap(); + std::thread::spawn(move || { + for line in BufReader::new(out).lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + Self { child, lines } + } + + /// Wait for a line containing `text`. + fn expect(&self, text: &str, secs: u64) -> String { + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + let left = deadline.saturating_duration_since(Instant::now()); + match self.lines.recv_timeout(left) { + Ok(line) if line.contains(text) => return line, + Ok(_) => {} + Err(_) => panic!("no line with {text:?} in {secs} s"), + } + } + } + + fn wait(&mut self, secs: u64) -> Option { + let deadline = Instant::now() + Duration::from_secs(secs); + while Instant::now() < deadline { + if let Some(s) = self.child.try_wait().unwrap() { + return Some(s); + } + std::thread::sleep(Duration::from_millis(20)); + } + None + } + + fn signal(&self, sig: i32) { + // SAFETY: our own child. + unsafe { libc::kill(self.child.id() as i32, sig) }; + } + + fn stderr(&mut self) -> String { + use std::io::Read; + let mut s = String::new(); + if let Some(mut e) = self.child.stderr.take() { + let _ = e.read_to_string(&mut s); + } + s + } +} + +impl Drop for Running { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// An agent: the bridge over stdio, spoken to as an MCP client. +struct Agent { + run: Running, + next: u64, + /// Notifications received. + notes: Vec, +} + +impl Agent { + fn start(home: &Home, book: &str) -> Self { + let mut c = home.cmd("mcp"); + c.args(["--book", book]); + Self::from(c) + } + + fn from(c: Command) -> Self { + Self { + run: Running::start(c), + next: 1, + notes: Vec::new(), + } + } + + fn send(&mut self, message: Value) { + let stdin = self.run.child.stdin.as_mut().unwrap(); + writeln!(stdin, "{message}").unwrap(); + stdin.flush().unwrap(); + } + + /// Send a request and wait for its answer. + fn request(&mut self, method: &str, params: Value, secs: u64) -> Value { + let id = self.next; + self.next += 1; + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + let left = deadline.saturating_duration_since(Instant::now()); + let line = self + .run + .lines + .recv_timeout(left) + .unwrap_or_else(|_| panic!("no answer to {method} in {secs} s")); + let v: Value = serde_json::from_str(&line).unwrap(); + if v.get("id") == Some(&json!(id)) { + return v; + } + self.notes.push(v); + } + } + + fn initialize(&mut self) -> Value { + let r = self.request( + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}}), + 20, + ); + self.send(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + r + } + + /// Call a tool: Ok(its structured result) or Err(the error's text). + fn call(&mut self, tool: &str, args: Value) -> Result { + let r = self.request("tools/call", json!({"name": tool, "arguments": args}), 60); + if let Some(e) = r.get("error") { + return Err(e["message"].as_str().unwrap_or_default().to_string()); + } + let result = &r["result"]; + if result["isError"] == true { + return Err(result["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_string()); + } + Ok(result["structuredContent"].clone()) + } + + /// End the session as an agent does: close stdin. + fn end(mut self) -> Option { + drop(self.run.child.stdin.take()); + self.run.wait(30) + } +} + +fn chart(a: &mut Agent) { + for (code, name, ty) in [ + ("1000", "Cash", "asset"), + ("3000", "Capital", "equity"), + ("6000", "Supplies", "expense"), + ] { + a.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + ) + .unwrap(); + } +} + +fn post_one(a: &mut Agent, n: u64) -> Result { + let e = a.call( + "books.draft_entry", + json!({"date": "2026-03-01", "description": format!("entry {n}")}), + )?; + let id = e["entry_id"].as_u64().unwrap(); + for (account, side) in [("6000", "debit"), ("1000", "credit")] { + a.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": "1.00"}), + )?; + } + a.call( + "books.attach_receipt", + json!({"entry_id": id, "file_name": format!("r{n}.txt"), + "mime_type": "text/plain", "content_base64": "cmVjZWlwdA=="}), + )?; + a.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true, "no_party_reason": "test"}), + )?; + Ok(id) +} + +fn serve(home: &Home) -> Running { + let r = Running::start(home.cmd("serve")); + // The first time, it says how to let go. + r.expect("The books are open. Press Ctrl-C", 30); + r +} + +#[test] +fn a_command_opens_the_books_and_they_close_after_the_last_holder() { + let home = Home::new("last"); + assert!(home.host().is_none()); + home.create("acme"); + // The command let go; the books stay open for the close delay, then + // close. + assert!(home.host().is_some(), "closed at once"); + assert!(home.wait_closed(10), "never closed"); + for sock in ["lease.sock", "mcp.sock", "admin.sock"] { + assert!(!home.run_dir().join(sock).exists(), "{sock} left behind"); + } + // A lease arriving within the delay cancels the close. + home.create("beta"); + std::thread::sleep(Duration::from_millis(200)); + let host = home.host().unwrap(); + home.create("gamma"); + std::thread::sleep(Duration::from_millis(1000)); + assert_eq!( + home.host(), + Some(host), + "closed though a lease came in time" + ); + assert!(home.wait_closed(10)); + // An agent session killed outright: closed after the delay, too. + let mut a = Agent::start(&home, "acme"); + a.initialize(); + a.call("books.start", json!({})).unwrap(); + a.run.child.kill().unwrap(); + a.run.child.wait().unwrap(); + std::thread::sleep(Duration::from_millis(300)); + assert!(home.host().is_some(), "closed before the delay"); + assert!(home.wait_closed(10), "never closed after the bridge died"); + // A holder that is killed: closed after the delay, not before. + let mut held = serve(&home); + let leases = home.lease_line("{\"proto\":1,\"leases\":null}\n"); + assert!(leases.contains("\"serve\""), "{leases}"); + assert!(leases.contains(&held.child.id().to_string()), "{leases}"); + held.child.kill().unwrap(); + held.child.wait().unwrap(); + std::thread::sleep(Duration::from_millis(300)); + assert!(home.host().is_some(), "closed before the delay"); + assert!(home.wait_closed(10), "never closed after the holder died"); + // Ctrl-C at the terminal: serve lets go, and the books close. + let mut held = serve(&home); + held.signal(libc::SIGINT); + assert!(held.wait(10).is_some_and(|s| s.success())); + assert!(home.wait_closed(10)); + // The books verify after every close. + let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap(); + store.verify_and_heal().unwrap(); +} + +#[test] +fn openers_share_one_host_and_one_server() { + let home = Home::new("share"); + home.create("acme"); + assert!(home.wait_closed(10)); + // All at once, from closed. + let mut holders = vec![ + Running::start(home.cmd("serve")), + Running::start({ + let mut c = home.cmd("mcp"); + c.args(["--book", "acme"]); + c + }), + Running::start({ + let mut c = home.cmd("mcp"); + c.args(["--book", "acme"]); + c + }), + ]; + let list = std::thread::spawn({ + let mut c = home.cmd("books"); + c.arg("list").stdin(Stdio::null()); + move || c.output().unwrap() + }); + holders[0].expect("The books are open", 30); + let out = list.join().unwrap(); + assert!( + String::from_utf8_lossy(&out.stdout).contains("acme open"), + "{out:?}" + ); + assert!(wait_for(10, || home.pids("host").len() == 1)); + assert_eq!(home.pids("host").len(), 1, "one host"); + assert_eq!(home.pids("server").len(), 1, "one server"); + // Both bridges hold (each takes its lease as it starts). + assert!( + wait_for(30, || home + .lease_line("{\"proto\":1,\"leases\":null}\n") + .matches("\"bridge\"") + .count() + == 2), + "the bridges' leases" + ); + holders.clear(); + assert!(home.wait_closed(15)); +} + +#[test] +fn the_bridge_opens_closed_books_and_holds_them_for_the_session() { + let home = Home::new("bridge"); + home.create("acme"); + assert!(home.wait_closed(10)); + let mut a = Agent::start(&home, "acme"); + let init = a.initialize(); + // Ready in time: the server's own answer, its connect text. + let result = &init["result"]; + assert_eq!(result["serverInfo"]["name"], "wanderbooks"); + assert_eq!(result["capabilities"]["tools"]["listChanged"], true); + let text = result["instructions"].as_str().unwrap(); + assert!( + !text.contains("opening now"), + "the bridge's stand-in: {text}" + ); + assert!(!text.contains(&home.root().display().to_string())); + chart(&mut a); + post_one(&mut a, 1).unwrap(); + let tools = a.request("tools/list", json!({}), 20); + assert!(tools["result"]["tools"].as_array().unwrap().len() > 10); + // A tool error and a protocol error come through as they are. + assert!(a.call("books.get_entry", json!({"entry_id": 999})).is_err()); + let unknown = a.request("no/such/method", json!({}), 20); + assert!(unknown.get("error").is_some(), "{unknown}"); + // Held while the session lasts, even past the close delay. + std::thread::sleep(Duration::from_millis(1500)); + assert!(home.host().is_some()); + // The bridge never uses TCP (no_local_agent_tcp), and TCP is off. + let bridge = a.run.child.id(); + let server = home.pids("server")[0]; + for pid in [bridge, server] { + assert!(tcp_sockets(pid).is_empty(), "pid {pid} has TCP sockets"); + } + assert!(a.end().is_some_and(|s| s.success())); + assert!(home.wait_closed(10)); + // Unknown books: a plain error, no folder named. + let mut a = Agent::start(&home, "nobody"); + a.initialize(); + let err = a.call("books.start", json!({})).unwrap_err(); + assert!(err.contains("no books called"), "{err}"); + assert!(!err.contains(&home.root().display().to_string())); +} + +/// The TCP sockets a process holds (by inode, against /proc/net/tcp*). +fn tcp_sockets(pid: u32) -> Vec { + let mut tcp = String::new(); + for f in ["/proc/net/tcp", "/proc/net/tcp6"] { + tcp.push_str(&std::fs::read_to_string(f).unwrap_or_default()); + } + let inodes: Vec<&str> = tcp + .lines() + .skip(1) + .filter_map(|l| l.split_whitespace().nth(9)) + .collect(); + let mut found = Vec::new(); + if let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) { + for fd in fds.flatten() { + if let Ok(target) = std::fs::read_link(fd.path()) { + let t = target.to_string_lossy().into_owned(); + if let Some(inode) = t.strip_prefix("socket:[").and_then(|r| r.strip_suffix(']')) + && inodes.contains(&inode) + { + found.push(t); + } + } + } + } + found +} + +#[test] +fn the_bridge_answers_initialize_itself_during_a_slow_start() { + let home = Home::new("cold"); + home.create("acme"); + assert!(home.wait_closed(10)); + let mut c = home.cmd("mcp"); + c.args([ + "--book", + "acme", + "--test-knobs", + "close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000", + ]); + let mut a = Agent::from(c); + let started = Instant::now(); + let init = a.initialize(); + assert!( + started.elapsed() < Duration::from_secs(5), + "{:?}", + started.elapsed() + ); + let text = init["result"]["instructions"].as_str().unwrap(); + assert!(text.contains("books.start"), "{text}"); + assert!(text.contains("opening now"), "{text}"); + assert_eq!(init["result"]["protocolVersion"], "2025-06-18"); + // The first call waits for the books, then runs. + // A ping is answered at once, not after the books open. + let asked = Instant::now(); + let pong = a.request("ping", json!({}), 10); + assert!(pong.get("result").is_some(), "{pong}"); + assert!( + asked.elapsed() < Duration::from_secs(1), + "{:?}", + asked.elapsed() + ); + // A call cancelled while it waits for the books is never made. + let id = a.next; + a.next += 1; + a.send( + json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { + "name": "books.add_account", + "arguments": {"code": "1000", "name": "Cash", "account_type": "asset"}}}), + ); + std::thread::sleep(Duration::from_millis(200)); + a.send( + json!({"jsonrpc": "2.0", "method": "notifications/cancelled", + "params": {"requestId": id, "reason": "changed my mind"}}), + ); + // A batch is refused, each request in it answered. + a.send(json!([ + {"jsonrpc": "2.0", "id": "b1", "method": "ping"}, + {"jsonrpc": "2.0", "method": "notifications/progress", "params": {}}, + ])); + let line = a.run.lines.recv_timeout(Duration::from_secs(10)).unwrap(); + let batch: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(batch[0]["id"], "b1", "{batch}"); + assert_eq!(batch[0]["error"]["code"], -32600); + // The first call waits for the books, then runs. + let start = a.call("books.start", json!({})).unwrap(); + assert!(start.is_object()); + assert!(started.elapsed() >= Duration::from_secs(3)); + let chart = a.call("books.chart", json!({})).unwrap(); + assert_eq!(chart["accounts"], json!([]), "the cancelled call was made"); + assert!( + !a.notes.iter().any(|n| n.get("id") == Some(&json!(id))), + "the cancelled call was answered: {:?}", + a.notes + ); +} + +#[test] +fn the_bridge_gives_up_on_books_that_never_open() { + let home = Home::new("never"); + home.create("acme"); + assert!(home.wait_closed(10)); + let mut c = home.cmd("mcp"); + c.args([ + "--book", + "acme", + "--test-knobs", + "close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000", + ]); + let mut a = Agent::from(c); + a.initialize(); + let err = a.call("books.start", json!({})).unwrap_err(); + assert!(err.contains("wanderbooks doctor"), "{err}"); + // A ping is answered even so. + let pong = a.request("ping", json!({}), 10); + assert!(pong.get("result").is_some(), "{pong}"); +} + +#[test] +fn closing_the_books_ends_every_lease_and_the_bridge_stays_closed() { + let home = Home::new("closenow"); + home.create("acme"); + let mut held = serve(&home); + let mut a = Agent::start(&home, "acme"); + a.initialize(); + a.call("books.start", json!({})).unwrap(); + let out = home.cmd("close").output().unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("The books are closed."), + "{out:?}" + ); + held.expect("The books were closed", 10); + assert!(held.wait(10).is_some_and(|s| s.success())); + assert!(home.wait_closed(10)); + // The agent's session carries on, told the owner closed them; it + // never opens them again. + let err = a.call("books.start", json!({})).unwrap_err(); + assert!(err.contains("owner closed the books"), "{err}"); + std::thread::sleep(Duration::from_millis(500)); + assert!(home.host().is_none(), "the bridge reopened the books"); + // Closing closed books says so. + let out = home.cmd("close").output().unwrap(); + assert!( + String::from_utf8_lossy(&out.stdout).contains("already closed"), + "{out:?}" + ); +} + +/// The host half of books_close_cleanly: the owner closes the books, or +/// the host is signalled, during a burst of posts through a bridge. +#[test] +fn closing_during_a_burst_of_posts_through_the_bridge_loses_nothing() { + for how in ["close", "term", "hup"] { + let home = Home::new(&format!("burst-{how}")); + home.create("acme"); + let mut a = Agent::start(&home, "acme"); + a.initialize(); + chart(&mut a); + let poster = std::thread::spawn(move || { + let mut posted = Vec::new(); + let mut why = String::new(); + for k in 0.. { + match post_one(&mut a, k) { + Ok(id) => posted.push(id), + Err(e) => { + why = e; + break; + } + } + } + (posted, why) + }); + std::thread::sleep(Duration::from_millis(1500)); + match how { + "close" => assert!(home.cmd("close").output().unwrap().status.success()), + _ => { + let host = home.host().unwrap() as i32; + let sig = if how == "term" { + libc::SIGTERM + } else { + libc::SIGHUP + }; + // SAFETY: this test's own host. + unsafe { libc::kill(host, sig) }; + } + } + let (mut posted, why) = poster.join().unwrap(); + assert!(!posted.is_empty(), "{how}: nothing posted"); + // Told the owner closed them: never "closing, not run", which the + // bridge sends again, and never "may have run". + assert!(why.contains("closed the books"), "{how}: stopped by {why}"); + assert!( + !why.contains("may or may not"), + "{how}: a call was cut off: {why}" + ); + assert!(home.wait_closed(15), "{how}"); + let mut store = ledger_store::Store::open(&home.root().join("books/acme")).unwrap(); + store.verify_and_heal().unwrap(); + let mut in_books: Vec = store.ledger().posted_entries().map(|e| e.id()).collect(); + in_books.sort_unstable(); + posted.sort_unstable(); + assert_eq!( + in_books, posted, + "{how}: a post lost, or made without its answer" + ); + } +} + +#[test] +fn the_host_starts_clean_from_a_hostile_bridge() { + let home = Home::new("clean"); + home.create("acme"); + assert!(home.wait_closed(10)); + // A pipe the bridge inherits; the host must not. + let (read_end, write_end) = std::io::pipe().unwrap(); + use std::os::fd::AsRawFd; + let leaked = write_end.as_raw_fd(); + let mut c = home.cmd("mcp"); + c.args(["--book", "acme"]) + .env("XDG_DATA_HOME", "/tmp/wb-evil-data") + .env("LD_PRELOAD", "/nonexistent/wb-evil.so") + .env("WB_EVIL", "1") + .env("XDG_RUNTIME_DIR", "/tmp") + .env("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/evil-bus"); + { + use std::os::unix::process::CommandExt; + // SAFETY: dup2 into the child before exec, keeping the pipe open + // across it (no close-on-exec) as descriptor 9. + unsafe { + c.pre_exec(move || { + if libc::dup2(leaked, 9) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + let mut a = Agent::from(c); + a.initialize(); + a.call("books.start", json!({})).unwrap(); + let host = home.host().expect("a host"); + let environ = std::fs::read(format!("/proc/{host}/environ")).unwrap(); + let vars: Vec = environ + .split(|b| *b == 0) + .filter(|v| !v.is_empty()) + .map(|v| String::from_utf8_lossy(v).into_owned()) + .collect(); + for v in &vars { + let name = v.split('=').next().unwrap(); + assert!( + [ + "PATH", + "HOME", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + // Set by systemd itself for the host's scope, not passed on. + "INVOCATION_ID", + "SYSTEMD_EXEC_PID", + ] + .contains(&name), + "the host inherited {v}" + ); + } + assert!(!vars.iter().any(|v| v.contains("evil")), "{vars:?}"); + let home_var = vars.iter().find(|v| v.starts_with("HOME=")).unwrap(); + assert_eq!( + home_var, + &format!( + "HOME={}", + ledger_store::root::account_home().unwrap().display() + ), + "HOME from the account's record" + ); + // No inherited descriptor: nothing points at the pipe. + let pipe = std::fs::read_link(format!("/proc/self/fd/{}", read_end.as_raw_fd())).unwrap(); + for fd in std::fs::read_dir(format!("/proc/{host}/fd")) + .unwrap() + .flatten() + { + let target = std::fs::read_link(fd.path()).unwrap_or_default(); + assert_ne!(target, pipe, "the host holds the inherited pipe"); + } + // Its own session, its own umask, and not confined. + let status = std::fs::read_to_string(format!("/proc/{host}/status")).unwrap(); + assert!(status.contains("Umask:\t0077"), "{status}"); + assert!(status.contains("Seccomp:\t0")); + let session = |pid: u32| { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap(); + let after = stat.rsplit_once(") ").unwrap().1.to_string(); + after.split_whitespace().nth(3).unwrap().to_string() + }; + assert_ne!(session(host), session(a.run.child.id())); + assert_eq!( + session(host), + host.to_string(), + "the host leads its session" + ); + // The server is started from the host's own program, by /proc/self/exe. + let server = home.pids("server")[0]; + assert_eq!( + std::fs::metadata(format!("/proc/{server}/exe")) + .unwrap() + .len(), + std::fs::metadata(format!("/proc/{host}/exe")) + .unwrap() + .len() + ); + drop(write_end); +} + +#[test] +fn a_killed_host_is_replaced_by_its_holders() { + let home = Home::new("respawn"); + home.create("acme"); + let mut held = serve(&home); + let first = home.host().unwrap(); + // SAFETY: this test's own host. + unsafe { libc::kill(first as i32, libc::SIGKILL) }; + held.expect("opening the books again", 10); + held.expect("The books are open again", 30); + let second = home.host().unwrap(); + assert_ne!(first, second); + let out = home.books(&["list"]); + assert!( + String::from_utf8_lossy(&out.stdout).contains("acme open"), + "{out:?}" + ); + held.signal(libc::SIGTERM); + assert!(held.wait(10).is_some_and(|s| s.success())); + assert!(home.wait_closed(10)); +} + +#[test] +fn holders_back_off_and_give_up_on_a_host_that_keeps_failing() { + let home = Home::new("backoff"); + // The server can't bind its socket: every start fails. + std::fs::create_dir(home.run_dir().join("mcp.sock")).unwrap(); + let started = Instant::now(); + let mut held = Running::start(home.cmd("serve")); + let status = held.wait(60).expect("never gave up"); + assert_eq!(status.code(), Some(1)); + let err = held.stderr(); + assert!( + err.contains("keeps failing") && err.contains("wanderbooks doctor"), + "{err}" + ); + // 50 + 100 + 200 + 400 + 800 ms of waiting at least. + assert!( + started.elapsed() >= Duration::from_millis(1550), + "{:?}", + started.elapsed() + ); + let log = std::fs::read_to_string(home.run_dir().join("host.log")).unwrap(); + // One start and five tries; a busy machine may add a start (a host + // slow to take its lock counts as one that died), never fewer. + let starts = log.matches("the books server stopped").count(); + assert!(starts >= 6, "one start and five tries: {log}"); + std::fs::remove_dir(home.run_dir().join("mcp.sock")).unwrap(); +} + +#[test] +fn the_lease_socket_takes_only_the_owners_intents() { + let home = Home::new("intents"); + home.create("acme"); + let _held = serve(&home); + for (line, want) in [ + ("hello\n", "not understood"), + ("{\"proto\":1,\"intent\":\"delete\"}\n", "not understood"), + ( + "{\"proto\":1,\"intent\":{\"restore\":{\"path\":\"/etc\"}}}\n", + "not understood", + ), + ("{\"proto\":1,\"exec\":\"sh\"}\n", "not understood"), + ("{\"proto\":9,\"leases\":null}\n", "another version"), + ( + "{\"proto\":1,\"intent\":{\"consent_format_upgrade\":{\"book\":\"acme\"}}}\n", + "passphrase", + ), + ( + "{\"proto\":1,\"intent\":{\"restore\":{\"staged\":\"s1\",\"replace\":\"acme\"}}}\n", + "passphrase", + ), + ( + "{\"proto\":1,\"intent\":{\"back_up_now\":{\"book\":\"acme\"}}}\n", + "isn't built yet", + ), + ( + "{\"proto\":1,\"intent\":{\"restore\":{\"staged\":\"s1\"}}}\n", + "isn't built yet", + ), + ( + "{\"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", + ), + ] { + let answer = home.lease_line(line); + assert!(answer.contains(want), "{line:?}: {answer}"); + assert!( + !answer.contains(&home.root().display().to_string()), + "{answer}" + ); + } + let out = home.books(&["list"]); + assert!( + String::from_utf8_lossy(&out.stdout).contains("beta open"), + "{out:?}" + ); +} + +#[test] +fn a_restart_is_not_a_close() { + let home = Home::new("restart").with_copy(); + home.create("acme"); + let held = serve(&home); + let other = serve(&home); + let mut a = Agent::start(&home, "acme"); + a.initialize(); + chart(&mut a); + let tools_before = a.request("tools/list", json!({}), 20); + let host = home.host().unwrap(); + let server = home.pids("server")[0]; + // The server runs the host's program: the same file. + let exe = |pid: u32| std::fs::read_link(format!("/proc/{pid}/exe")).unwrap(); + assert_eq!(exe(server), exe(host)); + home.replace_program(); + // Writes made all through the restart are each answered, and each + // answered one is in the books, once: those that meet the restart + // wait for the new server. + let deadline = Instant::now() + Duration::from_secs(40); + let (mut restarting, mut reopened, mut during) = (false, false, 0); + let mut written = Vec::new(); + let mut k = 0u32; + while !reopened { + assert!(Instant::now() < deadline, "the restart never finished"); + // Sent while the restart is known to be under way, or (a restart + // quicker than the lines that tell of it) sent before the books + // reopened and answered only after waiting for the new server. + let mid_restart = restarting && !reopened; + let code = format!("{}", 5000 + k); + k += 1; + let sent = Instant::now(); + let r = a.call( + "books.add_account", + json!({"code": code, "name": format!("R{code}"), "account_type": "expense"}), + ); + assert!(r.is_ok(), "a write across the restart: {r:?}"); + written.push(code); + if mid_restart || sent.elapsed() > Duration::from_millis(300) { + during += 1; + } + while let Ok(line) = held.lines.try_recv() { + restarting |= line.contains("restarting"); + reopened |= line.contains("open again"); + } + } + assert!( + restarting && during > 0, + "no write was sent during the restart" + ); + let chart = a.call("books.chart", json!({})).unwrap(); + let mut in_books: Vec = chart["accounts"] + .as_array() + .unwrap() + .iter() + .filter_map(|acc| acc["code"].as_str().map(str::to_string)) + .filter(|c| c.starts_with('5')) + .collect(); + in_books.sort(); + written.sort(); + assert_eq!( + in_books, written, + "a write lost, or made twice, across the restart" + ); + other.expect("The books are open again", 30); + // The holders waited for the new host: none started one of its own. + let log = std::fs::read_to_string(home.run_dir().join("host.log")).unwrap(); + assert!(!log.contains("another host has these books open"), "{log}"); + let new_host = home.host().unwrap(); + assert_ne!(new_host, host); + let new_server = home.pids("server")[0]; + assert_eq!( + exe(new_server), + exe(new_host), + "children match the host's version" + ); + assert!(!exe(new_host).to_string_lossy().contains("(deleted)")); + // The agent carries on with no reconnect, and its tools didn't + // change, so it is told nothing. + post_one(&mut a, 7).unwrap(); + let tools_after = a.request("tools/list", json!({}), 20); + assert_eq!(tools_before["result"], tools_after["result"]); + assert!( + !a.notes + .iter() + .any(|n| n["method"] == "notifications/tools/list_changed"), + "{:?}", + a.notes + ); + assert!(a.call("books.get_entry", json!({"entry_id": 1})).is_ok()); +} + +#[test] +fn books_commands_hold_the_books_while_they_run() { + let home = Home::new("cmds"); + let out = home.books(&["list"]); + assert!( + String::from_utf8_lossy(&out.stdout).contains("No books yet"), + "{out:?}" + ); + home.create("acme"); + let out = home.books(&["create", "acme"]); + assert_eq!(out.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("already books called"), + "{out:?}" + ); + let out = home.books(&["create", "euro", "--currency", "EUR"]); + assert!( + String::from_utf8_lossy(&out.stdout).contains("(EUR)"), + "{out:?}" + ); + // Adopting: previewed, and not done without a yes. + let old = tempfile::Builder::new() + .prefix("wb-held-old-") + .tempdir() + .unwrap(); + let data = old.path().join("books-data"); + drop(ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap()); + std::fs::write( + old.path().join("wanderbooks.toml"), + "[company]\nname = \"Old Co\"\n", + ) + .unwrap(); + let toml = old.path().join("wanderbooks.toml"); + let args = |extra: &[&str]| { + let mut v = vec![ + "adopt", + data.to_str().unwrap(), + "--id", + "old", + "--toml", + toml.to_str().unwrap(), + ]; + v.extend_from_slice(extra); + v.into_iter().map(String::from).collect::>() + }; + let run = |a: Vec| { + let a: Vec<&str> = a.iter().map(String::as_str).collect(); + home.books(&a) + }; + let out = run(args(&[])); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("company.name = \"Old Co\""), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("confirm at a terminal"), + "{out:?}" + ); + assert_eq!(out.status.code(), Some(1)); + // A vault beside the books must be named. + std::fs::create_dir(old.path().join("vault")).unwrap(); + let out = run(args(&["--yes"])); + assert!( + String::from_utf8_lossy(&out.stderr).contains("--no-vault"), + "{out:?}" + ); + let out = run(args(&["--yes", "--no-vault"])); + assert!(out.status.success(), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stdout).contains("Adopted as \"old\""), + "{out:?}" + ); + let out = home.books(&["list"]); + let text = String::from_utf8_lossy(&out.stdout); + for id in ["acme", "euro", "old"] { + assert!(text.contains(&format!("{id} open")), "{text}"); + } + // Nothing printed names the books' private folder. + let root = home.root().display().to_string(); + for o in [ + &out, + &run(args(&["--yes", "--no-vault"])), + &home.books(&["create", "acme"]), + ] { + for text in [&o.stdout, &o.stderr] { + assert!(!String::from_utf8_lossy(text).contains(&root), "{o:?}"); + } + } +} + +/// Under a systemd app scope (as uwsm sets up), closing the app that +/// started the host leaves the host for the other holders. Run only where +/// a systemd user manager is there to make scopes. +#[test] +fn the_host_outlives_the_app_scope_that_started_it() { + let uid = unsafe { libc::geteuid() }; + let cgroup = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default(); + if !cgroup.contains(&format!("/user@{uid}.service/")) + || !Path::new("/usr/bin/systemd-run").exists() + { + eprintln!("skipped: no systemd user manager here"); + return; + } + let home = Home::new("scope"); + home.create("acme"); + assert!(home.wait_closed(10)); + let unit = format!("wb-test-{}", std::process::id()); + // `serve` in its own app scope, as an app launched from a desktop. + let mut c = Command::new("/usr/bin/systemd-run"); + c.args([ + "--user", + "--scope", + "--quiet", + "--collect", + &format!("--unit={unit}.scope"), + ]) + .arg(&home.exe) + .arg("serve") + .arg("--root") + .arg(home.root()) + .args(["--test-knobs", KNOBS]) + .env("HOME", home.dir.path()); + let held = Running::start(c); + held.expect("The books are open", 30); + let host = home.host().unwrap(); + // The app's scope stops: everything in it is killed. + let out = Command::new("systemctl") + .args(["--user", "stop", &format!("{unit}.scope")]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + std::thread::sleep(Duration::from_millis(300)); + // The host wasn't in it: it lives on, then closes as nothing holds it. + let alive = std::fs::metadata(format!("/proc/{host}")).is_ok(); + assert!(alive, "the host died with the app's scope"); + assert!(home.wait_closed(10)); +} + +/// Plan 3.2: both protocol eras are served. A 2026-07-28 client has no +/// initialize; each request carries its version and client in `_meta`. +#[test] +fn the_bridge_serves_a_client_without_initialize() { + let home = Home::new("stateless"); + home.create("acme"); + let mut a = Agent::start(&home, "acme"); + let meta = json!({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"}, + }); + let found = a.request("server/discover", json!({"_meta": meta}), 30); + assert_eq!( + found["result"]["capabilities"]["tools"], + json!({}), + "{found}" + ); + let chart = a.request( + "tools/call", + json!({"name": "books.chart", "arguments": {}, "_meta": meta}), + 30, + ); + assert_eq!( + chart["result"]["structuredContent"]["base_currency"], "USD", + "{chart}" + ); +} + +/// Review round 3, finding 1: the server dying under a bridge (with a call +/// going out) doesn't wedge it: it holds again, a new host starts, and +/// the agent's next calls are answered. +#[test] +fn the_bridge_recovers_when_the_server_dies_under_it() { + let home = Home::new("dies"); + home.create("acme"); + let mut a = Agent::start(&home, "acme"); + a.initialize(); + a.call("books.start", json!({})).unwrap(); + let server = home.pids("server")[0]; + // SAFETY: this test's own server. + unsafe { libc::kill(server as i32, libc::SIGKILL) }; + // A call at once: refused unrun (or answered "may have run"), then + // the next ones work again on a new host and server. + let _ = a.call("books.start", json!({})); + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let r = a.call("books.chart", json!({})); + if r.is_ok() { + break; + } + if Instant::now() > deadline { + let log = std::fs::read_to_string(home.run_dir().join("host.log")).unwrap_or_default(); + let _ = a.run.child.kill(); + let err = a.run.stderr(); + panic!("the bridge never recovered: {r:?}\nhost.log:\n{log}\nbridge:\n{err}"); + } + std::thread::sleep(Duration::from_millis(100)); + } + assert!(home.pids("server").iter().all(|p| *p != server)); +} + +/// Review round 3, finding 2: a cancel that follows its call at once is +/// still in time while the call waits for the books. +#[test] +fn a_call_cancelled_at_once_is_never_made() { + let home = Home::new("cancel"); + home.create("acme"); + assert!(home.wait_closed(10)); + let mut c = home.cmd("mcp"); + c.args([ + "--book", + "acme", + "--test-knobs", + "close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000", + ]); + let mut a = Agent::from(c); + a.initialize(); + let mut ids = Vec::new(); + for k in 0..20u64 { + let id = a.next; + a.next += 1; + ids.push(id); + // The call and its cancel in one write. + let stdin = a.run.child.stdin.as_mut().unwrap(); + writeln!( + stdin, + "{}\n{}", + json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { + "name": "books.add_account", + "arguments": {"code": format!("{}", 1000 + k), "name": format!("A{k}"), + "account_type": "asset"}}}), + json!({"jsonrpc": "2.0", "method": "notifications/cancelled", + "params": {"requestId": id}}) + ) + .unwrap(); + stdin.flush().unwrap(); + } + let chart = a.call("books.chart", json!({})).unwrap(); + assert_eq!(chart["accounts"], json!([]), "a cancelled call was made"); + assert!( + !a.notes.iter().any(|n| n + .get("id") + .is_some_and(|i| ids.contains(&i.as_u64().unwrap_or(0)))), + "a cancelled call was answered: {:?}", + a.notes + ); +} + +#[test] +fn doctor_says_whether_the_books_are_open_and_what_holds_them() { + let home = Home::new("doctor"); + home.create("acme"); + assert!(home.wait_closed(10)); + let text = |o: std::process::Output| String::from_utf8_lossy(&o.stdout).into_owned(); + let closed = text(home.cmd("doctor").output().unwrap()); + assert!(closed.contains("The books are closed."), "{closed}"); + assert!(closed.contains("same_user (advisory)"), "{closed}"); + let _held = serve(&home); + let open = text(home.cmd("doctor").output().unwrap()); + assert!(open.contains("The books are open."), "{open}"); + assert!(open.contains("acme open"), "{open}"); + assert!( + open.contains("Held by:") && open.contains("wanderbooks serve"), + "{open}" + ); + assert!(open.contains("The host, last said:"), "{open}"); + for t in [&closed, &open] { + assert!(!t.contains(&home.root().display().to_string()), "{t}"); + } +} + +/// Review round 4, finding 2: a bridge stopped by a signal goes at once, +/// even with a call waiting for the books (never sent). +#[test] +fn a_signalled_bridge_goes_at_once() { + let home = Home::new("sigbridge"); + home.create("acme"); + assert!(home.wait_closed(10)); + let mut c = home.cmd("mcp"); + c.args([ + "--book", + "acme", + "--test-knobs", + "close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000", + ]); + let mut a = Agent::from(c); + a.initialize(); + a.send(json!({"jsonrpc": "2.0", "id": 99, "method": "tools/call", + "params": {"name": "books.start", "arguments": {}}})); + std::thread::sleep(Duration::from_millis(300)); + let stopped = Instant::now(); + a.run.signal(libc::SIGTERM); + assert!(a.run.wait(10).is_some(), "the bridge lingered"); + assert!( + stopped.elapsed() < Duration::from_secs(5), + "{:?}", + stopped.elapsed() + ); +} + +/// Review round 4, finding 10: a message too large is refused and the +/// session goes on. +#[test] +fn a_message_too_large_is_refused_and_the_session_goes_on() { + let home = Home::new("toolarge"); + home.create("acme"); + let mut a = Agent::start(&home, "acme"); + a.initialize(); + { + let stdin = a.run.child.stdin.as_mut().unwrap(); + stdin.write_all(b"{\"x\":\"").unwrap(); + let chunk = vec![b'a'; 1024 * 1024]; + for _ in 0..65 { + stdin.write_all(&chunk).unwrap(); + } + stdin.write_all(b"\"}\n").unwrap(); + stdin.flush().unwrap(); + } + let line = a.run.lines.recv_timeout(Duration::from_secs(30)).unwrap(); + assert!(line.contains("too large"), "{line}"); + let pong = a.request("ping", json!({}), 20); + assert!(pong.get("result").is_some(), "{pong}"); + assert!(a.call("books.start", json!({})).is_ok()); +} + +/// Review round 4, finding 1: where systemd-run can't make its scope (no +/// user bus reachable, as in a container), the host starts directly. +#[test] +fn the_host_starts_without_a_scope_when_systemd_run_fails() { + // SAFETY: geteuid has no preconditions. + let uid = unsafe { libc::geteuid() }; + let cgroup = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default(); + let in_scope = cgroup.lines().any(|l| { + l.starts_with("0::") + && l.contains(&format!("/user@{uid}.service/")) + && l.ends_with(".scope") + }); + if !in_scope || !Path::new("/usr/bin/systemd-run").exists() { + eprintln!("skipped: not in a systemd app scope"); + return; + } + let home = Home::new("noscope"); + // A private runtime folder with no bus in it. + let runtime = home.dir.path().join("rt"); + std::fs::create_dir(&runtime).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o700)).unwrap(); + let started = Instant::now(); + let out = home + .cmd("books") + .args(["create", "acme"]) + .env("XDG_RUNTIME_DIR", &runtime) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!( + started.elapsed() < Duration::from_secs(10), + "{:?}", + started.elapsed() + ); +} + +/// Review round 5, findings 2 and 3: a malformed byte is answered and the +/// session goes on; a message exactly at the limit is taken, and the next +/// one is not lost. +#[test] +fn malformed_and_boundary_input_never_ends_the_session() { + let home = Home::new("bytes"); + home.create("acme"); + let mut a = Agent::start(&home, "acme"); + a.initialize(); + { + let stdin = a.run.child.stdin.as_mut().unwrap(); + stdin + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":\"\xff\",\"method\":\"ping\"}\n") + .unwrap(); + stdin.flush().unwrap(); + } + let line = a.run.lines.recv_timeout(Duration::from_secs(20)).unwrap(); + assert!(line.contains("-32700"), "{line}"); + let pong = a.request("ping", json!({}), 20); + assert!(pong.get("result").is_some(), "{pong}"); + // Exactly 64 MiB of message (a JSON string padded with spaces), then + // a ping: the big one is the server's to refuse, the ping answered. + { + let max = 64 * 1024 * 1024; + let head = b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/x\",\"params\":{\"pad\":\""; + let tail = b"\"}}"; + let stdin = a.run.child.stdin.as_mut().unwrap(); + stdin.write_all(head).unwrap(); + let mut left = max - head.len() - tail.len(); + let chunk = vec![b' '; 1024 * 1024]; + while left > 0 { + let n = left.min(chunk.len()); + stdin.write_all(&chunk[..n]).unwrap(); + left -= n; + } + stdin.write_all(tail).unwrap(); + stdin.write_all(b"\n").unwrap(); + stdin.flush().unwrap(); + } + let pong = a.request("ping", json!({}), 60); + assert!(pong.get("result").is_some(), "{pong}"); + assert!( + !a.notes.iter().any(|n| n.to_string().contains("too large")), + "a message at the limit was refused: {:?}", + a.notes + ); +} + +/// Review round 5, finding 4: `doctor` doesn't hang on a server that +/// doesn't answer, and still shows the logs. +#[test] +fn doctor_doesnt_hang_on_a_frozen_server() { + let home = Home::new("frozen"); + home.create("acme"); + let _held = serve(&home); + let server = home.pids("server")[0] as i32; + // SAFETY: this test's own server. + unsafe { libc::kill(server, libc::SIGSTOP) }; + let started = Instant::now(); + let out = home.cmd("doctor").output().unwrap(); + // SAFETY: as above. + unsafe { libc::kill(server, libc::SIGCONT) }; + let text = String::from_utf8_lossy(&out.stdout); + assert!( + started.elapsed() < Duration::from_secs(15), + "{:?}", + started.elapsed() + ); + assert!(text.contains("doesn't answer"), "{text}"); + assert!(text.contains("The host, last said:"), "{text}"); +} + +/// Review round 5, finding 1: an app started with a scrubbed environment +/// (no XDG_RUNTIME_DIR, as Codex starts its tools) doesn't take the host +/// down with it when it stops. +#[test] +fn a_scrubbed_app_scope_doesnt_take_the_host_with_it() { + // SAFETY: geteuid has no preconditions. + let uid = unsafe { libc::geteuid() }; + let cgroup = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default(); + if !cgroup.contains(&format!("/user@{uid}.service/")) + || !Path::new("/usr/bin/systemd-run").exists() + { + eprintln!("skipped: no systemd user manager here"); + return; + } + let home = Home::new("scrubbed"); + home.create("acme"); + assert!(home.wait_closed(10)); + let unit = format!("wb-scrub-{}", std::process::id()); + let mut c = Command::new("/usr/bin/systemd-run"); + c.args([ + "--user", + "--scope", + "--quiet", + "--collect", + &format!("--unit={unit}.scope"), + ]) + .arg("/usr/bin/env") + .arg("-i") + .arg("PATH=/usr/bin:/bin") + .arg(format!("HOME={}", home.dir.path().display())) + .arg(&home.exe) + .arg("serve") + .arg("--root") + .arg(home.root()) + .args(["--test-knobs", KNOBS]); + let held = Running::start(c); + held.expect("The books are open", 30); + let host = home.host().unwrap(); + let host_cgroup = std::fs::read_to_string(format!("/proc/{host}/cgroup")).unwrap(); + assert!( + !host_cgroup.contains(&unit), + "the host is in the app's scope: {host_cgroup}" + ); + let out = Command::new("systemctl") + .args(["--user", "stop", &format!("{unit}.scope")]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + std::thread::sleep(Duration::from_millis(300)); + assert!( + std::fs::metadata(format!("/proc/{host}")).is_ok(), + "the host died with the app" + ); + assert!(home.wait_closed(10)); +} + +/// Review round 6, finding 1: a command ends at its deadline even while a +/// restart holds the update lock. +#[test] +fn a_command_ends_at_its_deadline_during_a_restart() { + let home = Home::new("deadline"); + let lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(home.run_dir().join("update.lock")) + .unwrap(); + lock.lock().unwrap(); + let started = Instant::now(); + let out = home + .cmd("books") + .args(["list", "--test-knobs", "open_wait=1000,close_after=2500"]) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!( + String::from_utf8_lossy(&out.stderr).contains("didn't open in time"), + "{out:?}" + ); + assert!( + started.elapsed() < Duration::from_secs(6), + "{:?}", + started.elapsed() + ); + drop(lock); +} + +/// Review round 6, finding 2: `doctor` doesn't wait long on a frozen host. +#[test] +fn doctor_doesnt_hang_on_a_frozen_host() { + let home = Home::new("frozenhost"); + home.create("acme"); + let _held = serve(&home); + let host = home.host().unwrap() as i32; + // SAFETY: this test's own host. + unsafe { libc::kill(host, libc::SIGSTOP) }; + let started = Instant::now(); + let out = home.cmd("doctor").output().unwrap(); + // SAFETY: as above. + unsafe { libc::kill(host, libc::SIGCONT) }; + let text = String::from_utf8_lossy(&out.stdout); + assert!( + started.elapsed() < Duration::from_secs(15), + "{:?}", + started.elapsed() + ); + assert!(text.contains("isn't answering"), "{text}"); +} + +/// Review round 6, finding 3: Ctrl-C reaches `serve` while it waits to +/// try again. +#[test] +fn serve_hears_ctrl_c_while_it_waits_to_try_again() { + let home = Home::new("ctrlc"); + std::fs::create_dir(home.run_dir().join("mcp.sock")).unwrap(); + let mut held = Running::start({ + let mut c = home.cmd("serve"); + c.args(["--test-knobs", "close_after=2500,backoff=20000"]); + c + }); + // The first start fails at once; serve then waits 20 s to try again. + held.expect("Opening the books", 30); + assert!(wait_for(10, || std::fs::read_to_string( + home.run_dir().join("host.log") + ) + .is_ok_and(|l| l.contains("the books server stopped")))); + std::thread::sleep(Duration::from_millis(500)); + let asked = Instant::now(); + held.signal(libc::SIGINT); + let status = held.wait(10).expect("serve didn't hear Ctrl-C"); + assert!(status.success(), "{status:?}"); + assert!( + asked.elapsed() < Duration::from_secs(5), + "{:?}", + asked.elapsed() + ); + std::fs::remove_dir(home.run_dir().join("mcp.sock")).unwrap(); +} + +/// Review round 6, finding 4: every malformed message is answered. +#[test] +fn every_malformed_message_is_answered() { + let home = Home::new("malformed"); + home.create("acme"); + let mut a = Agent::start(&home, "acme"); + a.initialize(); + for (line, id) in [ + (r#"{"jsonrpc":"2.0","id":5,"method":7}"#, json!(5)), + ("42", Value::Null), + (r#"{"jsonrpc":"2.0","id":6}"#, json!(6)), + ( + r#"{"jsonrpc":"2.0","id":{"a":1},"method":"ping"}"#, + Value::Null, + ), + ("[]", Value::Null), + ] { + let stdin = a.run.child.stdin.as_mut().unwrap(); + writeln!(stdin, "{line}").unwrap(); + stdin.flush().unwrap(); + let answer = a.run.lines.recv_timeout(Duration::from_secs(20)).unwrap(); + let v: Value = serde_json::from_str(&answer).unwrap(); + assert_eq!(v["error"]["code"], -32600, "{line}: {answer}"); + assert_eq!(v["id"], id, "{line}: {answer}"); + } + // And the session goes on. + assert!(a.call("books.start", json!({})).is_ok()); +} + +/// Review round 7, finding 1: a call to books that didn't open is +/// refused with why (it never ran), even for a client that skips +/// initialize; and a duplicate in-flight id is refused. +#[test] +fn books_that_didnt_open_refuse_plainly() { + let home = Home::new("damaged"); + home.create("acme"); + assert!(home.wait_closed(10)); + // A damaged last line: the books won't open. + use std::io::Write as _; + let mut j = std::fs::OpenOptions::new() + .append(true) + .open(home.root().join("books/acme/journal.jsonl")) + .unwrap(); + writeln!(j, "{{\"not\": \"a journal line\"}}").unwrap(); + drop(j); + let mut a = Agent::start(&home, "acme"); + let meta = json!({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"}, + }); + let r = a.request( + "tools/call", + json!({"name": "books.chart", "arguments": {}, "_meta": meta}), + 60, + ); + let why = r["error"]["message"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!(why.contains("didn't open"), "{r}"); + assert!(!why.contains("may or may not"), "{r}"); + assert!(!why.contains(&home.root().display().to_string()), "{r}"); +} + +/// Review round 7, findings 2 and 3: `close` and `books list` don't hang +/// on a frozen host or server. +#[test] +fn close_and_list_dont_hang_on_frozen_processes() { + let home = Home::new("frozen2"); + home.create("acme"); + let _held = serve(&home); + let server = home.pids("server")[0] as i32; + let host = home.host().unwrap() as i32; + // SAFETY: this test's own server and host. + unsafe { libc::kill(server, libc::SIGSTOP) }; + let started = Instant::now(); + let out = home.books(&["list"]); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!( + started.elapsed() < Duration::from_secs(45), + "{:?}", + started.elapsed() + ); + // SAFETY: as above. + unsafe { + libc::kill(server, libc::SIGCONT); + libc::kill(host, libc::SIGSTOP); + } + let started = Instant::now(); + let out = home.cmd("close").output().unwrap(); + // SAFETY: as above. + unsafe { libc::kill(host, libc::SIGCONT) }; + assert!( + started.elapsed() < Duration::from_secs(15), + "{:?}", + started.elapsed() + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("isn't answering"), + "{out:?}" + ); +} + +/// Review round 8, findings 1, 2 and 7: books made after the agent's +/// session began are reached on the first call after; an initialize the +/// server won't take fails plainly, not as books that never open; and a +/// request id still in flight is refused. +#[test] +fn books_made_mid_session_and_bad_requests() { + let home = Home::new("midsession"); + home.create("acme"); + let mut a = Agent::start(&home, "beta"); + a.initialize(); + let err = a.call("books.start", json!({})).unwrap_err(); + assert!(err.contains("no books called"), "{err}"); + home.create("beta"); + assert!( + a.call("books.start", json!({})).is_ok(), + "the first call after books create" + ); + // An initialize without clientInfo: the server's own refusal, fast. + let mut b = Agent::start(&home, "acme"); + let started = Instant::now(); + let _ = b.request( + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}}), + 20, + ); + b.send(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + let err = b.call("books.start", json!({})).unwrap_err(); + assert!(!err.contains("didn't open within"), "{err}"); + assert!( + started.elapsed() < Duration::from_secs(20), + "{:?}", + started.elapsed() + ); + // Two requests with one id while the books open: the second refused. + let home2 = Home::new("dupid"); + home2.create("acme"); + assert!(home2.wait_closed(10)); + let mut c = home2.cmd("mcp"); + c.args([ + "--book", + "acme", + "--test-knobs", + "close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000", + ]); + let mut d = Agent::from(c); + d.initialize(); + let call = json!({"jsonrpc": "2.0", "id": "same", "method": "tools/call", + "params": {"name": "books.start", "arguments": {}}}); + d.send(call.clone()); + d.send(call); + let first = d.run.lines.recv_timeout(Duration::from_secs(20)).unwrap(); + let v: Value = serde_json::from_str(&first).unwrap(); + assert_eq!(v["id"], "same", "{first}"); + assert_eq!(v["error"]["code"], -32600, "{first}"); + let second = d.run.lines.recv_timeout(Duration::from_secs(30)).unwrap(); + let v: Value = serde_json::from_str(&second).unwrap(); + assert!( + v.get("result").is_some(), + "the first one is still answered: {second}" + ); +} + +/// `wanderbooks books`: its flags taken for what they are, its usage on +/// request, and adoption's choices honoured (slice 3 mutation testing). +#[test] +fn the_books_command_takes_its_flags_and_asks_before_adopting() { + let home = Home::new("flags"); + home.create("acme"); + let out = home.books(&["--help"]); + assert_eq!(out.status.code(), Some(0)); + let usage = String::from_utf8_lossy(&out.stderr); + assert!(usage.contains("adopt DATA-DIR --id ID"), "{usage}"); + let out = home.books(&["-h"]); + assert_eq!(out.status.code(), Some(0)); + let out = home.books(&["list", "--bogus"]); + assert_eq!(out.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&out.stderr).contains("unknown flag: --bogus")); + // Old books to adopt: one setting the books don't know. + let old = tempfile::Builder::new() + .prefix("wb-held-flags-") + .tempdir() + .unwrap(); + let data = old.path().join("books-data"); + drop(ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap()); + let toml = old.path().join("wanderbooks.toml"); + std::fs::write(&toml, "[company]\nname = \"Old Co\"\nmystery = \"what\"\n").unwrap(); + let json = old.path().join("config.json"); + std::fs::write(&json, "{}").unwrap(); + let vault = old.path().join("elsewhere"); + std::fs::create_dir(&vault).unwrap(); + let key = old.path().join("vault.key"); + std::fs::write(&key, "not used").unwrap(); + let adopt = |extra: &[&str]| { + let mut a = vec![ + "adopt", + data.to_str().unwrap(), + "--id", + "old", + "--toml", + toml.to_str().unwrap(), + ]; + a.extend_from_slice(extra); + home.books(&a) + }; + let err = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).to_string(); + // A vault and its key come together, and never with --no-vault. + let v = vault.to_str().unwrap(); + let k = key.to_str().unwrap(); + for (extra, want) in [ + (vec!["--vault", v], "give the vault's key too"), + (vec!["--vault-key", k], "give the vault's folder too"), + (vec!["--no-vault", "--vault", v], "can't both be given"), + (vec!["--no-vault", "--vault-key", k], "can't both be given"), + ] { + let out = adopt(&extra); + assert_eq!(out.status.code(), Some(1), "{extra:?}: {out:?}"); + assert!(err(&out).contains(want), "{extra:?}: {}", err(&out)); + } + // An entry the books don't know: refused unless left behind. + let out = adopt(&[ + "--yes", + "--no-vault", + "--config-json", + json.to_str().unwrap(), + ]); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!(err(&out).contains("--skip-unknown"), "{}", err(&out)); + // At a terminal: asked, and only a yes adopts. + let at_terminal = |answer: &str| { + use std::io::Write; + let mut args: Vec = vec![ + home.exe.display().to_string(), + "books".into(), + "--root".into(), + home.root().display().to_string(), + "--test-knobs".into(), + KNOBS.into(), + ]; + for a in [ + "adopt", + data.to_str().unwrap(), + "--id", + "old", + "--toml", + toml.to_str().unwrap(), + "--no-vault", + "--skip-unknown", + "--config-json", + json.to_str().unwrap(), + ] { + args.push(a.to_string()); + } + let mut child = Command::new("script") + .args(["-qefc", &args.join(" "), "/dev/null"]) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", home.dir.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + std::thread::sleep(Duration::from_millis(1500)); + let _ = writeln!(stdin, "{answer}"); + let deadline = Instant::now() + Duration::from_secs(60); + while child.try_wait().unwrap().is_none() { + assert!(Instant::now() < deadline, "the adoption never ended"); + std::thread::sleep(Duration::from_millis(50)); + } + drop(stdin); + let out = child.wait_with_output().unwrap(); + String::from_utf8_lossy(&out.stdout).to_string() + }; + let shown = at_terminal("n"); + assert!(shown.contains("Adopt them? [y/N]"), "{shown}"); + assert!(shown.contains("Not adopted."), "{shown}"); + let listed = String::from_utf8_lossy(&home.books(&["list"]).stdout).to_string(); + assert!(!listed.contains("old"), "{listed}"); + let shown = at_terminal("y"); + assert!(shown.contains("Adopted as \"old\""), "{shown}"); + assert!(shown.contains("1 settings moved in."), "{shown}"); + let head = shown + .lines() + .find(|l| l.starts_with("Head: event")) + .unwrap_or_else(|| panic!("{shown}")); + assert!( + head.trim_end().ends_with(").") && head.contains(" ("), + "{head}" + ); + let hash = head + .split('(') + .nth(1) + .unwrap() + .trim_end_matches([')', '.', '\r']); + assert_eq!(hash.len(), 12, "{head}"); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "{head}"); +} + +/// 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). +#[test] +fn a_bridge_started_without_stderr_keeps_its_descriptors_apart() { + use std::os::unix::process::CommandExt; + let home = Home::new("nostderr"); + home.create("acme"); + let mut c = home.cmd("mcp"); + c.args(["--book", "acme"]); + // SAFETY: close is async-signal-safe. + unsafe { + c.pre_exec(|| { + libc::close(2); + Ok(()) + }); + } + let mut a = Agent::from(c); + a.initialize(); + assert!(a.call("books.start", json!({})).is_ok()); + let pid = a.run.child.id(); + let fd2 = std::fs::read_link(format!("/proc/{pid}/fd/2")).unwrap(); + assert_eq!(fd2, Path::new("/dev/null")); + assert!(a.end().is_some_and(|s| s.success())); +} diff --git a/crates/ledger-cli/tests/setup.rs b/crates/ledger-cli/tests/setup.rs index 9ffd136..521af07 100644 --- a/crates/ledger-cli/tests/setup.rs +++ b/crates/ledger-cli/tests/setup.rs @@ -69,16 +69,20 @@ fn setup_makes_the_private_folder_and_never_names_it() { } #[test] -fn a_root_elsewhere_is_noted_and_xdg_is_honoured() { +fn a_root_elsewhere_is_noted_for_next_time() { + // (XDG_DATA_HOME is honoured only for this account's own home, which + // a test never touches: Root::setup's tests cover it.) let home = tempfile::Builder::new() - .prefix("wb-setup-xdg-") + .prefix("wb-setup-else-") .tempdir() .unwrap(); - let xdg = home.path().join("xdg"); + let elsewhere = home.path().join("elsewhere").join("wanderbooks"); let out = wanderbooks() .args(["setup", "--home"]) .arg(home.path()) - .env("XDG_DATA_HOME", &xdg) + .arg("--root") + .arg(&elsewhere) + .env_remove("XDG_DATA_HOME") .output() .unwrap(); assert!(out.status.success(), "{out:?}"); @@ -86,13 +90,10 @@ fn a_root_elsewhere_is_noted_and_xdg_is_honoured() { String::from_utf8_lossy(&out.stdout).contains("noted for next time"), "{out:?}" ); - assert_eq!(mode(&xdg.join("wanderbooks")), 0o700); + assert_eq!(mode(&elsewhere), 0o700); let noted = std::fs::read_to_string(home.path().join(".local/share/wanderbooks.location")).unwrap(); - assert_eq!( - noted.trim_end(), - xdg.join("wanderbooks").display().to_string() - ); + assert_eq!(noted.trim_end(), elsewhere.display().to_string()); } #[test] @@ -122,3 +123,32 @@ fn setup_refuses_what_it_does_not_know_and_unsafe_folders() { let out = wanderbooks().args(["--force", "setup"]).output().unwrap(); assert_eq!(out.status.code(), Some(2)); } + +/// With another home given (tests), this account's XDG_DATA_HOME is never +/// read: setup can't be aimed at this account's real books by it. +#[test] +fn another_home_ignores_this_accounts_data_home() { + let home = tempfile::Builder::new() + .prefix("wb-setup-xdg-") + .tempdir() + .unwrap(); + let elsewhere = home.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).unwrap(); + let out = wanderbooks() + .args(["setup", "--home"]) + .arg(home.path()) + .env("XDG_DATA_HOME", &elsewhere) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!( + home.path() + .join(".local/share/wanderbooks/.wanderbooks-root") + .is_file() + ); + assert_eq!( + std::fs::read_dir(&elsewhere).unwrap().count(), + 0, + "XDG_DATA_HOME was used" + ); +} diff --git a/crates/ledger-host/Cargo.toml b/crates/ledger-host/Cargo.toml new file mode 100644 index 0000000..d5a64a5 --- /dev/null +++ b/crates/ledger-host/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ledger-host" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "wanderbooks: the books open while something holds them (the host, its leases, the helper and the agents' bridge)." + +[dependencies] +ledger-core = { path = "../ledger-core" } +ledger-mcp = { path = "../ledger-mcp" } +ledger-store = { path = "../ledger-store" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time", "process"] } +reqwest = "0.13" +base64 = "0.22" +tokio-util = { version = "0.7", features = ["rt"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/ledger-host/src/bridge.rs b/crates/ledger-host/src/bridge.rs new file mode 100644 index 0000000..c7cee8d --- /dev/null +++ b/crates/ledger-host/src/bridge.rs @@ -0,0 +1,2150 @@ +//! `wanderbooks mcp --book ID`: the agents' bridge (plan 3.9, D-23). +//! +//! ```text +//! GOAL: an agent started in any folder reaches one book over stdio; the +//! books open by themselves; a server restart goes unnoticed; and +//! no call the agent made can ever run twice. +//! SOURCE: plan/served.md 3.9 ("The bridge and slow starts", "Closing", +//! "Restarting"), 3.1 (the bridge never reads the books; it has +//! no data-dir flag); the plan's tests bridge_opens_closed_books, +//! bridge_survives_server_restart, bridge_initializes_during_cold_start, +//! no_local_agent_tcp, close_now_ends_every_lease (in +//! ledger-cli's open_while_held.rs, under names of their own). +//! INVARIANTS: +//! 1. It holds a lease for the whole session, and talks only to the +//! root's lease and MCP sockets: never TCP, never the books' files. +//! 2. `initialize` is answered within knobs.init_wait: with the +//! server's own answer when it is ready by then, else with +//! opening.rs's; the server is initialized behind it as soon as it +//! is ready, with the agent's own initialize. +//! 3. A call waits at most knobs.queue_wait for the books to open, then +//! fails with a message naming `wanderbooks doctor`. +//! 4. A call is sent again only when it is known not to have run: the +//! connection was refused before it was sent, the server answered +//! that it is closing and didn't run it, or the session was unknown +//! (after a restart). A call cut off after it was sent is +//! answered with an error saying it may have run; never sent again. +//! 5. After a new upstream session (a restart), the agent is sent +//! tools/list_changed only if the tool list differs from the last +//! one it was given. +//! 6. Once the owner closes the books, every call is answered with +//! that until the session ends; the bridge never reopens them. +//! 7. Every request from the agent gets exactly one answer, unless the +//! agent cancelled it; one the server answered with nothing is +//! reported as possibly run. Every initialize result it is given +//! advertises tools.listChanged (5). +//! 8. A call the agent cancels before it was sent is never sent. +//! 9. The server's socket must belong to this account; and the agent's +//! offers the bridge can't carry back (sampling, elicitation, roots) +//! are not passed on, in initialize or in a request's own metadata. +//! 10. Ended by its stdin or by a signal, the bridge ends its upstream +//! session. +//! ``` + +use crate::knobs::{FLAG, Knobs}; +use crate::lease::{HostMsg, Kind, Reason}; +use crate::opener::{Opener, Starter}; +use serde_json::{Value, json}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{Notify, mpsc, watch}; +use tokio::time::Instant; + +/// The longest message the agent may send (receipts ride in calls until +/// uploads have their own route). +const MAX_MESSAGE: usize = ledger_mcp::local::MAX_BODY; +/// The bridge's own request ids upstream start so. +const OWN_ID: &str = "wanderbooks-bridge-"; +/// JSON-RPC: a server error. +const SERVER_ERROR: i64 = -32000; +/// What the server says when it is closing and didn't run a request. +const CLOSING: &str = "the books are closing"; +/// What rmcp says, as the whole body, for an unknown session. +const NO_SESSION: &str = "Not Found: Session not found"; + +/// What a call cut off after it was sent is answered with (INVARIANT 4). +const MAYBE_RAN: &str = "the connection to the books server broke while this call was running, so it may or may not have been done: check before trying it again (books.digest shows the latest changes)"; + +/// What the agent is told once the owner closed the books (INVARIANT 6). +const CLOSED_BY_OWNER: &str = "the owner closed the books (Close the books, or `wanderbooks close`): nothing more can be done in this session. Start a new agent session to open them again."; + +/// One upstream session with the server. +#[derive(Clone, Debug, Default)] +struct Upstream { + /// The server's session id, if it gave one. + session: Option, + /// The protocol version it negotiated. + version: Option, + /// Its initialize result (only when the agent initialized). + init: Option, + /// Which session this is (1 for the first). + epoch: u64, +} + +/// Where the bridge stands with the server. +#[derive(Clone, Debug)] +enum Link { + /// The books are opening (or reopening). + Opening, + /// The server is ready, with this session. + Ready(Arc), + /// The owner closed the books (INVARIANT 6). + Closed, + /// The books can't be opened, with why. + Failed(String), + /// The server refused these books (none of that name, or they didn't + /// open), with why: tried again at the next call. + Refused(String), +} + +/// How a request sent upstream came out. +enum Sent { + /// Answered (none, one or several messages for the agent). + Answered(Vec), + /// Not run, and safe to send again (INVARIANT 4): why. + NotRun(NotRun), + /// Sent and cut off: it may have run. + MaybeRan, + /// Refused by the server, with why, for the agent. + Refused(String), +} + +/// Why a request didn't run. +#[derive(Debug, PartialEq, Eq)] +enum NotRun { + /// No server answering. + Unreachable, + /// The server is closing. + Closing, + /// The session isn't known (a restart, or idle). + NoSession, +} + +struct Bridge { + book: String, + http: reqwest::Client, + knobs: Knobs, + to_agent: mpsc::UnboundedSender, + link: watch::Sender, + /// The agent's initialize request's params, once it sent one. + agent_init: Mutex>, + /// Woken when the agent initializes, or a session is found gone. + renew: Notify, + /// The last tools/list result the agent was given (INVARIANT 5). + tools_seen: Mutex>, + next_id: std::sync::atomic::AtomicU64, + /// The agent's requests not yet answered, by id (INVARIANT 8). + pending: Mutex>, + /// The server's socket, for its owner check (INVARIANT 9). + socket: PathBuf, +} + +/// Who has the server's socket, when it isn't plainly this account. +enum Owner { + /// Another account: never used. + Other(String), + /// Nobody answering: a failure, tried again. + Unreachable, +} + +/// How long opening a session, or asking for the tool list, may take +/// before it counts as not answering. +const UPSTREAM_WAIT: Duration = Duration::from_secs(10); + +/// Where one of the agent's requests stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Pending { + /// Waiting for the books. + Waiting, + /// Sent upstream: a cancel goes there too, and is remembered in case + /// the call turns out not to have run and is to be sent again. + Sent { + /// The agent cancelled it meanwhile. + cancelled: bool, + }, + /// Cancelled before it was sent: never sent (INVARIANT 8). + Cancelled, +} + +/// The protocol version from which requests carry their method and name +/// in headers (SEP-2243), and per-request metadata. +const HEADERS_FROM: &str = "2026-07-28"; + +/// The per-request protocol version key in `_meta` (2026-07-28). +const META_VERSION: &str = "io.modelcontextprotocol/protocolVersion"; + +/// A header value, wrapped in base64 when it couldn't travel as it is. +fn header_value(value: &str) -> String { + let plain = !value.is_empty() + && value.bytes().all(|b| (0x20..0x7f).contains(&b)) + && !value.starts_with([' ', '\t']) + && !value.ends_with([' ', '\t']) + && !value.starts_with("=?base64?"); + if plain { + value.to_string() + } else { + use base64::Engine; + format!( + "=?base64?{}?=", + base64::engine::general_purpose::STANDARD.encode(value) + ) + } +} + +/// The headers a message needs beyond the session's: its protocol version +/// when it names one, and from 2026-07-28 its method and name. +fn message_headers(message: &Value, session_version: Option<&str>) -> Vec<(&'static str, String)> { + let mut out = Vec::new(); + let params = message.get("params"); + let version = params + .and_then(|p| p.get("_meta")) + .and_then(|m| m.get(META_VERSION)) + .and_then(Value::as_str) + .or(session_version); + let Some(version) = version else { return out }; + out.push(("mcp-protocol-version", version.to_string())); + if version >= HEADERS_FROM + && let Some(method) = message.get("method").and_then(Value::as_str) + { + out.push(("mcp-method", method.to_string())); + let key = match method { + "tools/call" | "prompts/get" => Some("name"), + "resources/read" | "resources/subscribe" | "resources/unsubscribe" => Some("uri"), + "tasks/get" | "tasks/update" | "tasks/cancel" => Some("taskId"), + _ => None, + }; + if let Some(name) = key + .and_then(|k| params.and_then(|p| p.get(k))) + .and_then(Value::as_str) + { + out.push(("mcp-name", header_value(name))); + } + } + out +} + +/// The agent's initialize, without the offers the bridge can't carry +/// back (INVARIANT 9). +fn without_client_offers(mut params: Value) -> Value { + if let Some(caps) = params + .get_mut("capabilities") + .and_then(Value::as_object_mut) + { + for offer in ["sampling", "elicitation", "roots"] { + caps.remove(offer); + } + } + params +} + +/// A request's own `_meta` client capabilities (2026-07-28), without the +/// offers the bridge can't carry back (INVARIANT 9). +fn without_meta_offers(mut message: Value) -> Value { + if let Some(caps) = message + .pointer_mut("/params/_meta/io.modelcontextprotocol~1clientCapabilities") + .and_then(Value::as_object_mut) + { + for offer in ["sampling", "elicitation", "roots"] { + caps.remove(offer); + } + } + message +} + +/// A request id as a map key. +fn id_key(id: &Value) -> String { + id.to_string() +} + +/// A JSON-RPC error answer. +fn error(id: &Value, message: impl Into) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": SERVER_ERROR, "message": message.into()}}) +} + +/// Mark an initialize result as one whose tool list may change +/// (INVARIANT 7). +fn with_list_changed(mut result: Value) -> Value { + if let Some(caps) = result + .get_mut("capabilities") + .and_then(Value::as_object_mut) + { + let tools = caps.entry("tools").or_insert_with(|| json!({})); + if let Some(tools) = tools.as_object_mut() { + tools.insert("listChanged".into(), Value::Bool(true)); + } + } + result +} + +/// The messages in a text/event-stream body. +fn sse_messages(body: &str) -> Vec { + let mut out = Vec::new(); + let mut data = String::new(); + let flush = |data: &mut String, out: &mut Vec| { + if !data.is_empty() { + if let Ok(v) = serde_json::from_str::(data) { + out.push(v); + } + data.clear(); + } + }; + for line in body.lines() { + if line.is_empty() { + flush(&mut data, &mut out); + } else if let Some(rest) = line.strip_prefix("data:") { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(rest.strip_prefix(' ').unwrap_or(rest)); + } + } + flush(&mut data, &mut out); + out +} + +impl Bridge { + fn url(&self) -> String { + format!("http://localhost/b/{}/mcp", self.book) + } + + fn own_id(&self) -> Value { + let n = self + .next_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Value::String(format!("{OWN_ID}{n}")) + } + + fn tell(&self, message: Value) { + let _ = self.to_agent.send(message); + } + + /// Send one message upstream on `up`'s session. + async fn send(&self, message: &Value, up: &Upstream) -> Sent { + let mut req = self + .http + .post(self.url()) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .body(serde_json::to_vec(message).unwrap_or_default()); + if let Some(s) = &up.session { + req = req.header("mcp-session-id", s); + } + for (name, value) in message_headers(message, up.version.as_deref()) { + req = req.header(name, value); + } + let response = match req.send().await { + Ok(r) => r, + Err(e) if e.is_connect() => return Sent::NotRun(NotRun::Unreachable), + Err(_) => return Sent::MaybeRan, + }; + let status = response.status(); + // Refused before it could run (books that didn't open, or none + // so named): the server's words, for the agent (INVARIANT 4). + let refused = response + .headers() + .contains_key(ledger_mcp::local::REFUSED_HEADER); + let is_stream = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|t| t.starts_with("text/event-stream")); + let body = match response.text().await { + Ok(b) => b, + Err(_) if refused => return Sent::Refused("the books server refused the call".into()), + Err(_) => return Sent::MaybeRan, + }; + if refused { + return Sent::Refused(body.trim().chars().take(500).collect()); + } + let answered = |messages: Vec| { + // The server's "closing: not run" (INVARIANT 4). + let not_run = messages.iter().any(|m| { + m.pointer("/error/code").and_then(Value::as_i64) + == Some(i64::from(ledger_mcp::NOT_RUN_CLOSING_CODE)) + }); + if not_run { + Sent::NotRun(NotRun::Closing) + } else { + Sent::Answered(messages) + } + }; + match status.as_u16() { + 202 => Sent::Answered(Vec::new()), + 200 if is_stream => answered(sse_messages(&body)), + 200 => match serde_json::from_str::(&body) { + Ok(v) => answered(vec![v]), + Err(_) => Sent::MaybeRan, + }, + // Only rmcp's own words: a JSON-RPC error that merely echoes + // them (a method so named) is an answer, passed on below. + 404 if body.trim() == NO_SESSION => Sent::NotRun(NotRun::NoSession), + 503 if body.starts_with(CLOSING) => Sent::NotRun(NotRun::Closing), + _ => { + // A JSON-RPC error rides in some error statuses: pass it on. + if let Ok(v) = serde_json::from_str::(&body) + && v.get("jsonrpc").is_some() + { + return answered(vec![v]); + } + // A server error with no answer in it: whether the call + // ran can't be told (INVARIANT 4). + if status.is_server_error() { + return Sent::MaybeRan; + } + Sent::Refused(body.trim().chars().take(500).collect()) + } + } + } + + /// Open a session with the server using the agent's initialize: the + /// session, or `Err(Some(why))` when the server refuses these books, + /// `Err(None)` when it isn't answering yet. + async fn initialize_upstream( + &self, + params: &Value, + epoch: u64, + ) -> Result> { + let request = json!({"jsonrpc": "2.0", "id": self.own_id(), "method": "initialize", "params": params}); + let response = self + .http + .post(self.url()) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .body(request.to_string()) + .send() + .await + .map_err(|_| None)?; + let session = response + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let status = response.status(); + let is_stream = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|t| t.starts_with("text/event-stream")); + let body = response.text().await.map_err(|_| None)?; + match status.as_u16() { + 200 => {} + // Refused (no such books, books that didn't open, or an + // initialize the server won't take): the server's words. Not + // asked again until the next call. + 404 | 503 if !body.starts_with(CLOSING) => { + return Err(Some(body.trim().chars().take(500).collect())); + } + 400..=499 => return Err(Some(body.trim().chars().take(500).collect())), + _ => return Err(None), + } + let messages = if is_stream { + sse_messages(&body) + } else { + serde_json::from_str::(&body).into_iter().collect() + }; + let result = messages + .into_iter() + .find_map(|m| m.get("result").cloned()) + .ok_or(None)?; + let up = Upstream { + session, + version: result + .get("protocolVersion") + .and_then(Value::as_str) + .map(str::to_string), + init: Some(with_list_changed(result)), + epoch, + }; + // The bridge says initialized once per session; the agent's own + // is not sent on. + let _ = self + .send( + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + &up, + ) + .await; + Ok(up) + } + + /// The first page of the server's tool list on `up`. + async fn tool_list(&self, up: &Upstream) -> Option { + let request = + json!({"jsonrpc": "2.0", "id": self.own_id(), "method": "tools/list", "params": {}}); + match tokio::time::timeout(UPSTREAM_WAIT, self.send(&request, up)).await { + Ok(Sent::Answered(messages)) => { + messages.into_iter().find_map(|m| m.get("result").cloned()) + } + _ => None, + } + } + + /// Wait until the link is ready (with a session, if the agent + /// initialized) or ended, until `deadline`. + async fn ready_link(&self, deadline: Instant) -> Option { + let mut rx = self.link.subscribe(); + let wants_session = self + .agent_init + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some(); + let good = |link: &Link| match link { + Link::Ready(up) => !wants_session || up.init.is_some(), + Link::Closed | Link::Failed(_) | Link::Refused(_) => true, + Link::Opening => false, + }; + let waited = tokio::time::timeout_at(deadline, rx.wait_for(good)).await; + match waited { + Ok(Ok(link)) => Some(link.clone()), + _ => None, + } + } + + /// Answer one request from the agent (INVARIANTS 3, 4, 6, 7). + async fn request(self: Arc, message: Value) { + let id = message.get("id").cloned().unwrap_or(Value::Null); + let method = message + .get("method") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if method == "initialize" { + return self.initialize(message, id).await; + } + // A ping is the bridge's to answer while the books aren't open. + if method == "ping" && !matches!(&*self.link.borrow(), Link::Ready(_)) { + return self.answer(&id, json!({"jsonrpc": "2.0", "id": id, "result": {}})); + } + self.clone().request_on(message, id, &method).await; + } + + /// The request `id` is done: no longer waiting, so its id is free + /// again as soon as its answer goes out (and never before). + fn forget(&self, id: &Value) { + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&id_key(id)); + } + + /// Give the request `id` its answer. + fn answer(&self, id: &Value, message: Value) { + self.forget(id); + self.tell(message); + } + + /// A request is waiting from the moment it is read (INVARIANT 8): a + /// cancel that follows it at once finds it. + fn waiting(&self, id: &Value) { + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(id_key(id), Pending::Waiting); + } + + /// A request sent that didn't run waits again; one cancelled while it + /// was out is not sent again (INVARIANT 8). + fn requeue(&self, id: &Value) { + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(p) = pending.get_mut(&id_key(id)) { + *p = match *p { + Pending::Sent { cancelled: true } | Pending::Cancelled => Pending::Cancelled, + _ => Pending::Waiting, + }; + } + } + + /// Mark a request sent, unless the agent cancelled it (INVARIANT 8). + fn may_send(&self, id: &Value) -> bool { + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + match pending.get_mut(&id_key(id)) { + Some(Pending::Cancelled) => false, + Some(p) => { + *p = Pending::Sent { cancelled: false }; + true + } + None => true, + } + } + + /// The agent cancels a request: if it wasn't sent, it never is; if it + /// was, the server hears of it. + fn cancel(&self, message: &Value) -> bool { + let Some(id) = message.pointer("/params/requestId") else { + return true; + }; + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + match pending.get_mut(&id_key(id)) { + Some(p @ Pending::Waiting) => { + *p = Pending::Cancelled; + false + } + Some(Pending::Sent { cancelled }) => { + *cancelled = true; + true + } + _ => true, + } + } + + async fn request_on(self: Arc, message: Value, id: Value, method: &str) { + let message = without_meta_offers(message); + let deadline = Instant::now() + self.knobs.queue_wait; + let mut asked_again = false; + loop { + let link = match self.ready_link(deadline).await { + Some(link) => link, + None if method == "ping" => { + return self.answer(&id, json!({"jsonrpc": "2.0", "id": id, "result": {}})); + } + None => { + return self.answer(&id, error( + &id, + format!( + "the books didn't open within {} s: run `wanderbooks doctor` to see why", + self.knobs.queue_wait.as_secs() + ), + )); + } + }; + let up = match link { + Link::Ready(up) => up, + Link::Closed => return self.answer(&id, error(&id, CLOSED_BY_OWNER)), + Link::Failed(why) => return self.answer(&id, error(&id, why)), + // Refused when last asked: the books may exist now (made + // since), so the server is asked again, once, before + // this call is refused. + Link::Refused(_) if !asked_again => { + asked_again = true; + self.link.send_replace(Link::Opening); + self.renew.notify_one(); + continue; + } + Link::Refused(why) => return self.answer(&id, error(&id, why)), + Link::Opening => continue, + }; + if !self.may_send(&id) { + // Cancelled while waiting: no answer is owed. + return; + } + match self.send(&message, &up).await { + Sent::Answered(messages) + if !messages.iter().any(|m| { + m.get("id") == Some(&id) + && (m.get("result").is_some() || m.get("error").is_some()) + }) => + { + // Answered with nothing (its session ended while it + // ran): it may have been done (INVARIANT 7). + self.forget(&id); + for m in messages { + self.tell(m); + } + return self.answer(&id, error(&id, MAYBE_RAN)); + } + Sent::Answered(messages) => { + self.forget(&id); + for m in messages { + if method == "tools/list" + && m.get("id") == Some(&id) + && let Some(result) = m.get("result") + { + *self.tools_seen.lock().unwrap_or_else(|e| e.into_inner()) = + Some(result.clone()); + } + self.tell(m); + } + return; + } + // A ping not answered is the bridge's to answer. + Sent::NotRun(_) if method == "ping" => { + return self.answer(&id, json!({"jsonrpc": "2.0", "id": id, "result": {}})); + } + Sent::NotRun(NotRun::NoSession) => { + // INVARIANT 4: not run; a new session, then again. + self.requeue(&id); + self.session_gone(up.epoch); + } + Sent::NotRun(_) => { + self.requeue(&id); + self.session_down(up.epoch); + tokio::time::sleep(Duration::from_millis(100)).await; + } + Sent::MaybeRan => return self.answer(&id, error(&id, MAYBE_RAN)), + Sent::Refused(why) => return self.answer(&id, error(&id, why)), + } + } + } + + /// Answer the agent's initialize (INVARIANT 2). + async fn initialize(self: Arc, message: Value, id: Value) { + let params = + without_client_offers(message.get("params").cloned().unwrap_or_else(|| json!({}))); + let asked = params + .get("protocolVersion") + .and_then(Value::as_str) + .map(str::to_string); + *self.agent_init.lock().unwrap_or_else(|e| e.into_inner()) = Some(params); + self.renew.notify_one(); + let deadline = Instant::now() + self.knobs.init_wait; + let result = match self.ready_link(deadline).await { + Some(Link::Ready(up)) => up.init.clone(), + // Not "opening now": closed by the owner, or can't open. + Some(Link::Closed) => return self.answer(&id, error(&id, CLOSED_BY_OWNER)), + Some(Link::Failed(why)) => return self.answer(&id, error(&id, why)), + _ => None, + }; + let result = result.unwrap_or_else(|| { + with_list_changed(ledger_mcp::opening::opening_initialize(asked.as_deref())) + }); + self.answer(&id, json!({"jsonrpc": "2.0", "id": id, "result": result})); + } + + /// The session `epoch` is unknown to the server: open another. + fn session_gone(&self, epoch: u64) { + let gone = matches!(&*self.link.borrow(), Link::Ready(up) if up.epoch == epoch); + if gone { + self.link.send_replace(Link::Opening); + self.renew.notify_one(); + } + } + + /// The server isn't answering on session `epoch`: wait for the lease + /// to say what comes next. + fn session_down(&self, epoch: u64) { + self.session_gone(epoch); + } + + /// Keep the lease, and a session whenever the server is ready + /// (INVARIANTS 1, 2, 5, 6). + async fn keep(self: Arc, mut opener: Opener) { + let mut epoch = 0u64; + loop { + let mut lease = match opener.hold().await { + Ok(lease) => lease, + // INVARIANT 6: closed by the owner while holding again. + Err(crate::opener::HoldError::ClosedByOwner) => { + self.link.send_replace(Link::Closed); + return; + } + Err(crate::opener::HoldError::Failed(why)) => { + self.link.send_replace(Link::Failed(why)); + return; + } + }; + // Until this lease ends: whenever the server is ready and no + // session is up, open one. The lease is always heard, even + // while a session is being opened or a try waits (INVARIANT 1). + let mut ready = false; + let mut retry_at: Option = None; + let ended = loop { + let opening = ready && matches!(&*self.link.borrow(), Link::Opening); + let waiting = still_waiting(retry_at, Instant::now()); + let open_now = opening && waiting.is_none(); + tokio::select! { + msg = lease.next() => match msg { + Some(HostMsg::Ready) => { + opener.opened(); + // INVARIANT 9: the server's socket is ours; one + // not answering is a failure like any other. + match self.socket_is_ours() { + Ok(()) => ready = true, + Err(Owner::Other(why)) => { + self.link.send_replace(Link::Failed(why)); + return; + } + Err(Owner::Unreachable) => break None, + } + } + Some(HostMsg::Ending { reason, .. }) => break Some(reason), + Some(_) => {} + None => break None, + }, + opened = self.open_session(epoch + 1), if open_now => match opened { + Ok(up) => { + epoch = up.epoch; + retry_at = None; + self.after_new_session(&up).await; + self.link.send_replace(Link::Ready(Arc::new(up))); + } + Err(Some(why)) => { + self.link.send_replace(Link::Refused(why)); + } + Err(None) => retry_at = Some(Instant::now() + Duration::from_millis(100)), + }, + () = tokio::time::sleep_until(waiting.unwrap_or_else(Instant::now)), if opening && waiting.is_some() => { + retry_at = None; + } + () = self.renew.notified(), if !open_now => { + // The agent initialized, or a session is gone: + // a session that has no init yet is renewed. + let stale = match &*self.link.borrow() { + Link::Ready(up) => { + up.init.is_none() + && self + .agent_init + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() + } + Link::Refused(_) => true, + _ => false, + }; + if stale { + self.link.send_replace(Link::Opening); + } + } + } + }; + self.link.send_replace(Link::Opening); + match ended { + Some(Reason::ClosedByOwner) => { + self.link.send_replace(Link::Closed); + return; + } + Some(Reason::Restarting | Reason::Closing) => {} + Some(Reason::ServerFailed) | None => { + if let Err(why) = opener.failed().await { + self.link.send_replace(Link::Failed(why)); + return; + } + } + } + } + } + + /// A session with the server: with the agent's initialize when it + /// sent one, else none (a client without the handshake), within + /// UPSTREAM_WAIT. + async fn open_session(&self, epoch: u64) -> Result> { + let params = self + .agent_init + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + match params { + Some(params) => { + tokio::time::timeout(UPSTREAM_WAIT, self.initialize_upstream(¶ms, epoch)) + .await + .unwrap_or(Err(None)) + } + None => Ok(Upstream { + epoch, + ..Upstream::default() + }), + } + } + + /// Whether the server's socket belongs to this account (INVARIANT 9). + fn socket_is_ours(&self) -> Result<(), Owner> { + let stream = std::os::unix::net::UnixStream::connect(&self.socket) + .map_err(|_| Owner::Unreachable)?; + if ledger_mcp::local::peer_is_us(&stream) { + Ok(()) + } else { + Err(Owner::Other( + "the books server's socket belongs to another account: not used".into(), + )) + } + } + + /// End the upstream session, if any (the agent's session ended). + async fn end_session(&self) { + let link = self.link.borrow().clone(); + if let Link::Ready(up) = link + && let Some(session) = &up.session + { + let _ = tokio::time::timeout( + Duration::from_secs(2), + self.http + .delete(self.url()) + .header("mcp-session-id", session) + .send(), + ) + .await; + } + } + + /// After a new session: tell the agent if the tools changed + /// (INVARIANT 5). + async fn after_new_session(&self, up: &Upstream) { + if up.epoch <= 1 { + return; + } + let seen = self + .tools_seen + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let Some(seen) = seen else { return }; + if let Some(now) = self.tool_list(up).await + && now != seen + { + *self.tools_seen.lock().unwrap_or_else(|e| e.into_inner()) = Some(now); + self.tell(json!({"jsonrpc": "2.0", "method": "notifications/tools/list_changed"})); + } + } + + /// One message from the agent. + fn handle(self: &Arc, message: Value, tasks: &tokio_util::task::TaskTracker) { + if let Some(batch) = message.as_array() { + // Batches aren't carried: each request in one is refused, so + // none goes unanswered (INVARIANT 7). + let refused: Vec = batch + .iter() + .filter_map(|m| m.get("id").filter(|id| !id.is_null())) + .map(|id| { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32600, + "message": "batches aren't supported: send each message on its own"}}) + }) + .collect(); + if refused.is_empty() && batch.is_empty() { + // An empty batch is itself an invalid request. + self.tell( + json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, + "message": "an empty batch isn't a request"}}), + ); + } else if !refused.is_empty() { + self.tell(Value::Array(refused)); + } + return; + } + // INVARIANT 7: what isn't a request, a notification or an answer + // is answered as an invalid request, with its id if it has one + // that is an id (a string or a whole number). + let id = message.get("id").filter(|id| !id.is_null()); + let good_id = id.is_none_or(|id| id.is_string() || id.is_i64() || id.is_u64()); + let is_answer = message.get("result").is_some() || message.get("error").is_some(); + let named = message.get("method").is_some_and(Value::is_string); + if !message.is_object() || !good_id || (!named && !is_answer) { + let id = id.filter(|_| good_id).cloned().unwrap_or(Value::Null); + self.tell(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32600, + "message": "invalid request: a message is an object with a method (and a string or number id), or an answer"}})); + return; + } + let has_id = id.is_some(); + let method = message.get("method").and_then(Value::as_str); + match (method, has_id) { + (Some(method), true) => { + // An id already in flight is refused: answers are told + // apart by id (JSON-RPC asks each to be unique). + if let Some(id) = message.get("id") + && self + .pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains_key(&id_key(id)) + { + self.tell(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32600, + "message": "a request with that id is still waiting: give each its own id"}})); + return; + } + let _ = method; + if let Some(id) = message.get("id") { + self.waiting(id); + } + tasks.spawn(self.clone().request(message)); + } + (Some("notifications/initialized"), false) => { + // The bridge sends its own, once per session. + } + (Some("notifications/cancelled"), false) if !self.cancel(&message) => { + // Cancelled before it was sent: nothing to tell the server. + } + (Some(_), false) => { + let me = self.clone(); + tasks.spawn(async move { + let link = me.link.borrow().clone(); + if let Link::Ready(up) = link { + let _ = me.send(&message, &up).await; + } + }); + } + (None, _) => { + // An answer to a request: the bridge sends the agent none. + } + } + } +} + +/// A retry set for `retry_at`, if it is still ahead of `now` (until +/// then the keeper doesn't try to open a session again). +fn still_waiting(retry_at: Option, now: Instant) -> Option { + retry_at.filter(|at| *at > now) +} + +/// Write one message to the agent, a line of JSON, and flush it. +async fn write_line( + out: &mut W, + message: &Value, +) -> std::io::Result<()> { + let mut line = message.to_string(); + line.push('\n'); + out.write_all(line.as_bytes()).await?; + out.flush().await +} + +/// How the agent's input ended. +#[derive(Debug, PartialEq, Eq)] +enum InputEnd { + /// It reached its end (or failed). + Closed, + /// A signal came first. + Signalled, +} + +/// Read the agent's messages, at most `max` bytes each, and hand each to +/// the bridge, until the input ends or `stop` is notified (INVARIANT 7: +/// every malformed one is answered, and the session goes on). +async fn read_messages( + input: &mut R, + max: usize, + bridge: &Arc, + tasks: &tokio_util::task::TaskTracker, + stop: &Notify, +) -> InputEnd { + let mut line: Vec = Vec::new(); + loop { + line.clear(); + // Bytes, not text: a malformed byte is answered, never the end + // of the session (INVARIANT 7). + let mut limited = (&mut *input).take(max as u64 + 1); + let read = tokio::select! { + read = limited.read_until(b'\n', &mut line) => read, + () = stop.notified() => return InputEnd::Signalled, + }; + match read { + Ok(0) | Err(_) => return InputEnd::Closed, + // A whole line, its newline within the limit: at most `max` + // bytes of message. + Ok(_) if line.ends_with(b"\n") => {} + // `max` + 1 bytes and no newline yet: too large. + Ok(n) if n > max => { + // Refused, and the rest of it skipped: the session goes on. + bridge.tell(error( + &Value::Null, + format!( + "that message is too large (at most {} MiB)", + max / (1024 * 1024) + ), + )); + if !skip_line(input).await { + return InputEnd::Closed; + } + continue; + } + // The input's last line, without a newline. + Ok(_) => {} + } + let Ok(text) = std::str::from_utf8(&line) else { + bridge.tell(json!({"jsonrpc": "2.0", "id": null, + "error": {"code": -32700, "message": "parse error: a message is UTF-8 text"}})); + continue; + }; + let text = text.trim(); + if text.is_empty() { + continue; + } + match serde_json::from_str::(text) { + Ok(message) => bridge.handle(message, tasks), + Err(_) => bridge.tell(json!({"jsonrpc": "2.0", "id": null, + "error": {"code": -32700, "message": "parse error"}})), + } + } +} + +/// Skip the rest of a line too long to take; false at the input's end. +async fn skip_line(input: &mut R) -> bool { + loop { + let buf = match input.fill_buf().await { + Ok(buf) if !buf.is_empty() => buf, + _ => return false, + }; + match buf.iter().position(|b| *b == b'\n') { + Some(at) => { + input.consume(at + 1); + return true; + } + None => { + let n = buf.len(); + input.consume(n); + } + } + } +} + +fn usage(prog: &str) { + // (`--root DIR` too, for tests: the bridge never needs a folder.) + eprintln!("usage: {prog} --book ID"); + eprintln!(" Connects an agent (over stdio) to the books called ID, opening them if"); + eprintln!(" they are closed and holding them open for the session."); +} + +/// `wanderbooks mcp --book ID`. +pub fn run( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut book: Option = None; + let mut root: Option = None; + let mut knobs = Knobs::default(); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--book" => book = Some(args.next().ok_or("--book needs an id")?), + "--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" => { + usage(prog); + return Ok(()); + } + other => { + eprintln!("unknown argument: {other}"); + usage(prog); + return Err(Box::new(crate::Exit(2))); + } + } + } + let Some(book) = book else { + eprintln!("{prog}: which books? give --book ID"); + usage(prog); + return Err(Box::new(crate::Exit(2))); + }; + 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()), + )?; + // 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) + .map_err(|e| format!("can't reach the books server: {e}"))?; + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let result = rt.block_on(async move { + // A connection per call: one the server closes while idle can't + // swallow a call (INVARIANT 4). + let http = reqwest::Client::builder() + .unix_socket(socket.path()) + .pool_max_idle_per_host(0) + .no_proxy() + .build() + .map_err(|e| e.to_string())?; + let (to_agent, mut outbox) = mpsc::unbounded_channel::(); + let (link, _) = watch::channel(Link::Opening); + let bridge = Arc::new(Bridge { + book, + http, + knobs, + to_agent, + link, + agent_init: Mutex::new(None), + renew: Notify::new(), + tools_seen: Mutex::new(None), + next_id: std::sync::atomic::AtomicU64::new(1), + pending: Mutex::new(std::collections::HashMap::new()), + socket: socket.path().to_path_buf(), + }); + let keeper = tokio::spawn(bridge.clone().keep(opener)); + let writer = tokio::spawn(async move { + let mut out = tokio::io::stdout(); + while let Some(message) = outbox.recv().await { + if write_line(&mut out, &message).await.is_err() { + break; + } + } + }); + let tasks = tokio_util::task::TaskTracker::new(); + let mut input = tokio::io::BufReader::new(tokio::io::stdin()); + // A client that stops the bridge with a signal (most do) still has + // its session ended upstream (INVARIANT 10). + let stop = Arc::new(Notify::new()); + for kind in [ + tokio::signal::unix::SignalKind::terminate(), + tokio::signal::unix::SignalKind::interrupt(), + tokio::signal::unix::SignalKind::hangup(), + ] { + let mut signals = tokio::signal::unix::signal(kind).map_err(|e| e.to_string())?; + let stop = stop.clone(); + tokio::spawn(async move { + if signals.recv().await.is_some() { + stop.notify_one(); + } + }); + } + let mut signalled = read_messages(&mut input, MAX_MESSAGE, &bridge, &tasks, &stop).await + == InputEnd::Signalled; + // The agent's session ended: every request answered (unless it + // was, or is now, stopped by a signal: it isn't waiting for them), + // its session ended upstream, then the lease goes with this + // process. + tasks.close(); + if !signalled { + tokio::select! { + () = tasks.wait() => {} + () = stop.notified() => signalled = true, + } + } + bridge.end_session().await; + keeper.abort(); + drop(bridge); + if signalled { + // Stopped: nothing more is said, and calls still waiting for + // the books go with the process (they were never sent). + writer.abort(); + } else { + let _ = writer.await; + } + Ok::<(), String>(()) + }); + rt.shutdown_timeout(Duration::from_secs(1)); + drop(helper); + result.map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_event_stream_yields_each_message() { + let body = "id: 1\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"n\"}\n\n\ + data: {\"jsonrpc\":\"2.0\",\n\ + data: \"id\":1,\"result\":{}}\n\n\ + data:\n\n: comment\nretry: 3000\n\n"; + let got = sse_messages(body); + assert_eq!( + got, + vec![ + json!({"jsonrpc": "2.0", "method": "n"}), + json!({"jsonrpc": "2.0", "id": 1, "result": {}}), + ] + ); + assert!(sse_messages("").is_empty()); + assert_eq!( + sse_messages("data: {\"a\":1}").len(), + 1, + "no blank line at the end" + ); + } + + #[test] + fn every_initialize_result_says_the_tools_may_change() { + let r = with_list_changed(json!({"capabilities": {"tools": {}}})); + assert_eq!(r["capabilities"]["tools"]["listChanged"], true); + let r = with_list_changed(json!({"capabilities": {}})); + assert_eq!(r["capabilities"]["tools"]["listChanged"], true); + let r = with_list_changed(json!({"capabilities": {"tools": {"listChanged": false}}})); + assert_eq!(r["capabilities"]["tools"]["listChanged"], true); + } + + #[test] + fn requests_carry_the_headers_their_protocol_needs() { + let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "books.start", "_meta": {META_VERSION: "2026-07-28"}}}); + let h = message_headers(&call, None); + assert!(h.contains(&("mcp-protocol-version", "2026-07-28".into()))); + assert!(h.contains(&("mcp-method", "tools/call".into()))); + assert!(h.contains(&("mcp-name", "books.start".into()))); + let read = json!({"method": "resources/read", "params": {"uri": "books://digest"}}); + let h = message_headers(&read, Some("2026-07-28")); + assert!(h.contains(&("mcp-name", "books://digest".into()))); + // Older sessions: the version alone. + let h = message_headers(&call, Some("2025-06-18")); + assert!( + h.contains(&("mcp-protocol-version", "2026-07-28".into())), + "the message's own wins" + ); + let plain = json!({"method": "tools/list"}); + assert_eq!( + message_headers(&plain, Some("2025-06-18")), + vec![("mcp-protocol-version", "2025-06-18".to_string())] + ); + assert!(message_headers(&plain, None).is_empty()); + // A value that couldn't travel as it is goes in base64. + assert_eq!(header_value("books.start"), "books.start"); + assert!(header_value(" x").starts_with("=?base64?")); + assert!(header_value("café").starts_with("=?base64?")); + } + + #[test] + fn a_requests_own_offers_are_left_out_too() { + let m = without_meta_offers(json!({"method": "tools/list", "params": {"_meta": { + "io.modelcontextprotocol/clientCapabilities": {"sampling": {}, "roots": {}, "x": 1}}}})); + assert_eq!( + m["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"], + json!({"x": 1}) + ); + let plain = json!({"method": "ping"}); + assert_eq!(without_meta_offers(plain.clone()), plain); + } + + #[test] + fn the_agents_offers_the_bridge_cant_carry_are_left_out() { + let p = without_client_offers(json!({"capabilities": { + "sampling": {}, "elicitation": {}, "roots": {"listChanged": true}, "experimental": {}}})); + assert_eq!(p["capabilities"], json!({"experimental": {}})); + assert_eq!(without_client_offers(json!({})), json!({})); + } + + #[test] + fn an_error_answer_keeps_the_request_id() { + let e = error(&json!(7), "no"); + assert_eq!(e["id"], 7); + assert_eq!(e["error"]["code"], SERVER_ERROR); + assert_eq!(e["error"]["code"], -32000, "JSON-RPC's server error"); + assert_eq!(e["error"]["message"], "no"); + } + + #[test] + fn an_events_data_lines_are_joined_by_newlines() { + // Joined without one, these two would read as {"n":12}. + assert!(sse_messages("data: {\"n\":1\ndata: 2}\n\n").is_empty()); + assert_eq!( + sse_messages("data: {\"n\":\ndata: 12}\n\n"), + vec![json!({"n": 12})] + ); + } + + #[test] + fn a_task_request_names_its_task() { + let get = json!({"method": "tasks/get", "params": {"taskId": "t-1"}}); + let h = message_headers(&get, Some("2026-07-28")); + assert!(h.contains(&("mcp-name", "t-1".into())), "{h:?}"); + } + + #[test] + fn a_retry_is_waited_for_only_while_it_is_ahead() { + let now = Instant::now(); + let later = now + Duration::from_secs(1); + assert_eq!(still_waiting(Some(later), now), Some(later)); + assert_eq!(still_waiting(Some(now), now), None); + assert_eq!(still_waiting(Some(now), later), None); + assert_eq!(still_waiting(None, now), None); + } + + #[tokio::test] + async fn a_message_to_the_agent_is_one_line() { + let mut out: Vec = Vec::new(); + write_line(&mut out, &json!({"a": 1})).await.unwrap(); + write_line(&mut out, &json!([2])).await.unwrap(); + assert_eq!(out, b"{\"a\":1}\n[2]\n"); + } +} + +/// The bridge against a scripted server on a Unix socket: every way an +/// answer can come back, and the bridge's own bookkeeping. +#[cfg(test)] +mod against_a_server { + use super::*; + use std::collections::{HashMap, VecDeque}; + use std::path::Path; + + /// What the scripted server answers next. + struct Reply { + status: u16, + headers: Vec<(&'static str, String)>, + body: String, + /// Promise more body than is sent, then hang up. + cut: bool, + } + + fn reply(status: u16, content_type: &str, body: &str) -> Reply { + Reply { + status, + headers: vec![("content-type", content_type.to_string())], + body: body.to_string(), + cut: false, + } + } + + fn json_reply(status: u16, body: Value) -> Reply { + reply(status, "application/json", &body.to_string()) + } + + /// A request the server received. + #[derive(Debug, Clone)] + struct Seen { + method: String, + headers: HashMap, + body: String, + } + + struct Server { + _dir: tempfile::TempDir, + socket: PathBuf, + replies: Arc>>, + seen: Arc>>, + } + + impl Server { + fn start() -> Self { + let dir = tempfile::Builder::new().prefix("wb-br-").tempdir().unwrap(); + let socket = dir.path().join("mcp.sock"); + let listener = tokio::net::UnixListener::bind(&socket).unwrap(); + let replies: Arc>> = Arc::default(); + let seen: Arc>> = Arc::default(); + let (r, s) = (replies.clone(), seen.clone()); + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let (r, s) = (r.clone(), s.clone()); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + let head_end = loop { + let n = stream.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + return; + } + buf.extend_from_slice(&chunk[..n]); + if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break at + 4; + } + }; + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); + let mut lines = head.split("\r\n"); + let method = lines + .next() + .unwrap_or_default() + .split(' ') + .next() + .unwrap_or_default() + .to_string(); + let headers: HashMap = lines + .filter_map(|l| l.split_once(':')) + .map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string())) + .collect(); + let len: usize = headers + .get("content-length") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + while buf.len() < head_end + len { + let n = stream.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + let body = String::from_utf8_lossy(&buf[head_end..]).to_string(); + s.lock().unwrap().push(Seen { + method, + headers, + body, + }); + let next = r.lock().unwrap().pop_front(); + let next = next.unwrap_or_else(|| reply(500, "text/plain", "no reply")); + let mut out = format!( + "HTTP/1.1 {} X\r\ncontent-length: {}\r\nconnection: close\r\n", + next.status, + next.body.len() + if next.cut { 100 } else { 0 } + ); + for (k, v) in &next.headers { + out.push_str(&format!("{k}: {v}\r\n")); + } + out.push_str("\r\n"); + out.push_str(&next.body); + let _ = stream.write_all(out.as_bytes()).await; + let _ = stream.shutdown().await; + }); + } + }); + Self { + _dir: dir, + socket, + replies, + seen, + } + } + + fn script(&self, replies: impl IntoIterator) { + self.replies.lock().unwrap().extend(replies); + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + } + + fn bridge_at(socket: &Path, knobs: Knobs) -> (Arc, mpsc::UnboundedReceiver) { + let http = reqwest::Client::builder() + .unix_socket(socket) + .pool_max_idle_per_host(0) + .no_proxy() + .build() + .unwrap(); + let (to_agent, rx) = mpsc::unbounded_channel(); + let (link, _) = watch::channel(Link::Opening); + let bridge = Arc::new(Bridge { + book: "acme".into(), + http, + knobs, + to_agent, + link, + agent_init: Mutex::new(None), + renew: Notify::new(), + tools_seen: Mutex::new(None), + next_id: std::sync::atomic::AtomicU64::new(1), + pending: Mutex::new(std::collections::HashMap::new()), + socket: socket.to_path_buf(), + }); + (bridge, rx) + } + + fn knobs(queue_ms: u64, init_ms: u64) -> Knobs { + Knobs { + queue_wait: Duration::from_millis(queue_ms), + init_wait: Duration::from_millis(init_ms), + ..Knobs::default() + } + } + + fn up(epoch: u64) -> Upstream { + Upstream { + session: Some("s1".into()), + version: Some("2025-06-18".into()), + init: Some(json!({})), + epoch, + } + } + + fn ready(b: &Bridge, epoch: u64) { + b.link.send_replace(Link::Ready(Arc::new(up(epoch)))); + } + + fn what(sent: &Sent) -> String { + match sent { + Sent::Answered(m) => format!("answered {}", Value::Array(m.clone())), + Sent::NotRun(why) => format!("not run {why:?}"), + Sent::MaybeRan => "maybe ran".into(), + Sent::Refused(why) => format!("refused {why}"), + } + } + + const CALL: &str = + r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"books.start"}}"#; + + #[tokio::test] + async fn every_kind_of_answer_is_read_for_what_it_is() { + let server = Server::start(); + let (b, _rx) = bridge_at(&server.socket, knobs(1000, 1000)); + let answer = json!({"jsonrpc": "2.0", "id": 7, "result": {}}); + let closing = json!({"jsonrpc": "2.0", "id": 7, + "error": {"code": ledger_mcp::NOT_RUN_CLOSING_CODE, "message": "closing"}}); + let refused_header = Reply { + status: 404, + headers: vec![(ledger_mcp::local::REFUSED_HEADER, "no-such-books".into())], + body: "there are no books called \"x\"".into(), + cut: false, + }; + let cases: Vec<(Reply, &str)> = vec![ + (reply(202, "text/plain", ""), "answered []"), + ( + json_reply(200, answer.clone()), + "answered [{\"id\":7,\"jsonrpc\":\"2.0\",\"result\":{}}]", + ), + (json_reply(200, json!({"x": 1})), "answered [{\"x\":1}]"), + ( + reply(200, "text/event-stream", &format!("data: {answer}\n\n")), + "answered [{\"id\":7,\"jsonrpc\":\"2.0\",\"result\":{}}]", + ), + (json_reply(200, closing.clone()), "not run Closing"), + (reply(404, "text/plain", NO_SESSION), "not run NoSession"), + ( + reply(404, "text/plain", "Not Found: Session not found."), + "refused Not Found: Session not found.", + ), + ( + reply( + 503, + "text/plain", + "the books are closing: this request wasn't run", + ), + "not run Closing", + ), + (reply(503, "text/plain", "overloaded"), "maybe ran"), + (refused_header, "refused there are no books called \"x\""), + ( + json_reply(500, answer.clone()), + "answered [{\"id\":7,\"jsonrpc\":\"2.0\",\"result\":{}}]", + ), + (reply(400, "text/plain", "bad"), "refused bad"), + (reply(500, "text/plain", "boom"), "maybe ran"), + ]; + let message: Value = serde_json::from_str(CALL).unwrap(); + for (r, want) in cases { + server.script([r]); + let got = what(&b.send(&message, &up(1)).await); + assert_eq!(got, want); + } + // Every request carried its session and version. + for seen in server.seen() { + assert_eq!(seen.method, "POST"); + assert_eq!( + seen.headers.get("mcp-session-id").map(String::as_str), + Some("s1") + ); + assert_eq!( + seen.headers.get("mcp-protocol-version").map(String::as_str), + Some("2025-06-18") + ); + assert_eq!( + serde_json::from_str::(&seen.body).unwrap(), + serde_json::from_str::(CALL).unwrap() + ); + } + // Refused at the door, its words cut off: still refused, unrun. + server.script([Reply { + status: 503, + headers: vec![(ledger_mcp::local::REFUSED_HEADER, "not-open".into())], + body: "the books acme didn't".into(), + cut: true, + }]); + assert_eq!( + what(&b.send(&message, &up(1)).await), + "refused the books server refused the call" + ); + // An answer cut off: it may have run. + server.script([Reply { + cut: true, + ..json_reply(200, answer.clone()) + }]); + assert_eq!(what(&b.send(&message, &up(1)).await), "maybe ran"); + // Nobody listening: not run, safe to send again. + let gone = server.socket.with_file_name("gone.sock"); + let (b, _rx) = bridge_at(&gone, knobs(1000, 1000)); + assert_eq!(what(&b.send(&message, &up(1)).await), "not run Unreachable"); + } + + #[tokio::test] + async fn a_session_is_opened_or_refused_plainly() { + let server = Server::start(); + let (b, _rx) = bridge_at(&server.socket, knobs(1000, 1000)); + let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "t", "version": "0"}}); + // Closing, or a server error: not answering yet. + server.script([ + reply( + 503, + "text/plain", + "the books are closing: this request wasn't run", + ), + reply(500, "text/plain", "boom"), + ]); + assert_eq!(b.initialize_upstream(¶ms, 1).await.unwrap_err(), None); + assert_eq!(b.initialize_upstream(¶ms, 1).await.unwrap_err(), None); + // Refused: the server's words. + server.script([ + reply(503, "text/plain", "the books acme didn't open: damaged"), + reply(404, "text/plain", "there are no books called acme"), + reply(400, "text/plain", "bad initialize"), + ]); + for want in [ + "the books acme didn't open: damaged", + "there are no books called acme", + "bad initialize", + ] { + assert_eq!( + b.initialize_upstream(¶ms, 1) + .await + .unwrap_err() + .as_deref(), + Some(want) + ); + } + // Opened: its session, its version, and the bridge's initialized. + let result = json!({"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, + "serverInfo": {"name": "wanderbooks", "version": "0"}}); + server.script([ + Reply { + status: 200, + headers: vec![ + ("content-type", "application/json".into()), + ("mcp-session-id", "abc".into()), + ], + body: json!({"jsonrpc": "2.0", "id": "x", "result": result}).to_string(), + cut: false, + }, + reply(202, "text/plain", ""), + ]); + let up = b.initialize_upstream(¶ms, 4).await.unwrap(); + assert_eq!(up.session.as_deref(), Some("abc")); + assert_eq!(up.version.as_deref(), Some("2025-06-18")); + assert_eq!(up.epoch, 4); + assert_eq!( + up.init.unwrap()["capabilities"]["tools"]["listChanged"], + true + ); + let seen = server.seen(); + let last = seen.last().unwrap(); + assert!(last.body.contains("notifications/initialized"), "{last:?}"); + assert_eq!( + last.headers.get("mcp-session-id").map(String::as_str), + Some("abc") + ); + // Without the agent's initialize, a session needs none. + let none = b.open_session(5).await.unwrap(); + assert_eq!(none.epoch, 5); + assert!(none.session.is_none() && none.init.is_none()); + } + + #[tokio::test] + async fn the_tool_list_is_read_from_its_answer() { + let server = Server::start(); + let (b, _rx) = bridge_at(&server.socket, knobs(1000, 1000)); + let tools = json!({"tools": [{"name": "books.start"}]}); + server.script([ + json_reply(200, json!({"jsonrpc": "2.0", "id": "x", "result": tools})), + reply(500, "text/plain", "boom"), + ]); + assert_eq!(b.tool_list(&up(1)).await, Some(tools)); + assert_eq!(b.tool_list(&up(1)).await, None); + } + + #[test] + fn a_request_is_waiting_sent_or_cancelled() { + let (b, mut rx) = bridge_at(Path::new("/nonexistent"), knobs(10, 10)); + let state = |id: i64| { + b.pending + .lock() + .unwrap() + .get(&id_key(&json!(id))) + .map(|p| match p { + Pending::Waiting => "waiting", + Pending::Sent { cancelled: false } => "sent", + Pending::Sent { cancelled: true } => "sent, cancelled", + Pending::Cancelled => "cancelled", + }) + }; + let cancel = + |id: i64| json!({"method": "notifications/cancelled", "params": {"requestId": id}}); + // Cancelled while waiting: never sent, nothing to tell upstream. + b.waiting(&json!(1)); + assert!(!b.cancel(&cancel(1))); + assert_eq!(state(1), Some("cancelled")); + assert!(!b.may_send(&json!(1))); + // Cancelled once sent: upstream hears; not run, it isn't sent again. + b.waiting(&json!(2)); + assert!(b.may_send(&json!(2))); + assert_eq!(state(2), Some("sent")); + assert!(b.cancel(&cancel(2))); + assert_eq!(state(2), Some("sent, cancelled")); + b.requeue(&json!(2)); + assert_eq!(state(2), Some("cancelled")); + assert!(!b.may_send(&json!(2))); + // Sent and not run: waiting again, sent again. + b.waiting(&json!(3)); + assert!(b.may_send(&json!(3))); + b.requeue(&json!(3)); + assert_eq!(state(3), Some("waiting")); + assert!(b.may_send(&json!(3))); + // Answered: forgotten, its id free again. + b.answer(&json!(3), json!({"id": 3, "result": {}})); + assert_eq!(state(3), None); + assert_eq!(rx.try_recv().unwrap(), json!({"id": 3, "result": {}})); + // A cancel naming nothing goes on as it is. + assert!(b.cancel(&json!({"method": "notifications/cancelled", "params": {}}))); + assert!(b.cancel(&cancel(99))); + } + + /// The next message to the agent, within `ms`. + async fn told(rx: &mut mpsc::UnboundedReceiver, ms: u64) -> Option { + tokio::time::timeout(Duration::from_millis(ms), rx.recv()) + .await + .ok() + .flatten() + } + + #[tokio::test] + async fn a_ping_is_the_bridges_to_answer_while_the_books_arent_ready() { + let server = Server::start(); + // A long queue: an answer within a second is the bridge's own. + let (b, mut rx) = bridge_at(&server.socket, knobs(30_000, 1000)); + let ping = json!({"jsonrpc": "2.0", "id": 1, "method": "ping"}); + b.clone().request(ping.clone()).await; + assert_eq!( + told(&mut rx, 1000).await, + Some(json!({"jsonrpc": "2.0", "id": 1, "result": {}})) + ); + // Ready, and the server didn't run it: answered by the bridge too. + ready(&b, 1); + server.script([reply( + 503, + "text/plain", + "the books are closing: this request wasn't run", + )]); + let task = tokio::spawn(b.clone().request_on(ping.clone(), json!(1), "ping")); + assert_eq!( + told(&mut rx, 1000).await, + Some(json!({"jsonrpc": "2.0", "id": 1, "result": {}})) + ); + task.abort(); + // The books never ready in time: still answered, not an error. + let (b, mut rx) = bridge_at(&server.socket, knobs(50, 1000)); + b.clone().request_on(ping, json!(2), "ping").await; + assert_eq!( + told(&mut rx, 1000).await, + Some(json!({"jsonrpc": "2.0", "id": 2, "result": {}})) + ); + } + + #[tokio::test] + async fn a_call_not_run_isnt_answered_as_if_it_had_been() { + let server = Server::start(); + let (b, mut rx) = bridge_at(&server.socket, knobs(300, 1000)); + ready(&b, 1); + server.script([reply( + 503, + "text/plain", + "the books are closing: this request wasn't run", + )]); + let call: Value = serde_json::from_str(CALL).unwrap(); + b.waiting(&json!(7)); + b.clone().request_on(call, json!(7), "tools/call").await; + let got = told(&mut rx, 2000).await.unwrap(); + assert!( + got["error"]["message"] + .as_str() + .unwrap() + .contains("didn't open"), + "{got}" + ); + } + + #[tokio::test] + async fn an_answer_that_isnt_this_calls_says_it_may_have_run() { + let server = Server::start(); + let (b, mut rx) = bridge_at(&server.socket, knobs(1000, 1000)); + ready(&b, 1); + let call: Value = serde_json::from_str(CALL).unwrap(); + let note = json!({"jsonrpc": "2.0", "method": "notifications/progress"}); + let not_ours = [ + // Only a notification. + format!("data: {note}\n\n"), + // An answer to another request. + format!( + "data: {}\n\n", + json!({"jsonrpc": "2.0", "id": 8, "result": {}}) + ), + // Its id, but neither a result nor an error. + format!( + "data: {}\n\n", + json!({"jsonrpc": "2.0", "id": 7, "method": "x"}) + ), + ]; + for body in not_ours { + server.script([reply(200, "text/event-stream", &body)]); + b.waiting(&json!(7)); + b.clone() + .request_on(call.clone(), json!(7), "tools/call") + .await; + let mut last = None; + while let Some(m) = told(&mut rx, 200).await { + last = Some(m); + } + let last = last.unwrap(); + assert_eq!(last["error"]["message"], MAYBE_RAN, "{body}"); + } + // An error answer is this call's answer. + let err = json!({"jsonrpc": "2.0", "id": 7, "error": {"code": -1, "message": "no"}}); + server.script([json_reply(200, err.clone())]); + b.waiting(&json!(7)); + b.clone().request_on(call, json!(7), "tools/call").await; + assert_eq!(told(&mut rx, 1000).await, Some(err)); + // A tool list given to the agent is remembered (INVARIANT 5). + let tools = json!({"tools": [{"name": "books.start"}]}); + server.script([json_reply( + 200, + json!({"jsonrpc": "2.0", "id": 9, "result": tools}), + )]); + let list = json!({"jsonrpc": "2.0", "id": 9, "method": "tools/list"}); + b.waiting(&json!(9)); + b.clone().request_on(list, json!(9), "tools/list").await; + assert!(told(&mut rx, 1000).await.is_some()); + assert_eq!(*b.tools_seen.lock().unwrap(), Some(tools)); + } + + #[tokio::test] + async fn an_initialize_is_refused_when_the_books_are_closed_or_failed() { + let (b, mut rx) = bridge_at(Path::new("/nonexistent"), knobs(1000, 200)); + let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}}}); + b.link.send_replace(Link::Closed); + b.clone().initialize(init.clone(), json!(1)).await; + assert_eq!( + told(&mut rx, 500).await.unwrap()["error"]["message"], + CLOSED_BY_OWNER + ); + b.link.send_replace(Link::Failed("it broke".into())); + b.clone().initialize(init.clone(), json!(2)).await; + assert_eq!( + told(&mut rx, 500).await.unwrap()["error"]["message"], + "it broke" + ); + // Opening still: the bridge's own answer. + b.link.send_replace(Link::Opening); + b.clone().initialize(init, json!(3)).await; + let got = told(&mut rx, 1000).await.unwrap(); + assert!(got["result"]["capabilities"].is_object(), "{got}"); + } + + #[test] + fn only_the_session_found_gone_is_renewed() { + let (b, _rx) = bridge_at(Path::new("/nonexistent"), knobs(10, 10)); + ready(&b, 2); + b.session_gone(1); + assert!( + matches!(&*b.link.borrow(), Link::Ready(_)), + "an older session" + ); + b.session_gone(2); + assert!(matches!(&*b.link.borrow(), Link::Opening)); + ready(&b, 3); + b.session_down(2); + assert!(matches!(&*b.link.borrow(), Link::Ready(_))); + b.session_down(3); + assert!(matches!(&*b.link.borrow(), Link::Opening)); + } + + #[tokio::test] + async fn the_servers_socket_must_answer_and_be_ours() { + let server = Server::start(); + let (b, _rx) = bridge_at(&server.socket, knobs(10, 10)); + assert!(b.socket_is_ours().is_ok()); + let (b, _rx) = bridge_at(&server.socket.with_file_name("gone.sock"), knobs(10, 10)); + assert!(matches!(b.socket_is_ours(), Err(Owner::Unreachable))); + } + + #[tokio::test] + async fn the_agents_session_ending_ends_the_servers() { + let server = Server::start(); + let (b, _rx) = bridge_at(&server.socket, knobs(10, 10)); + // No session: nothing sent. + b.end_session().await; + let mut sessionless = up(1); + sessionless.session = None; + b.link.send_replace(Link::Ready(Arc::new(sessionless))); + b.end_session().await; + assert!(server.seen().is_empty()); + server.script([reply(200, "text/plain", "")]); + ready(&b, 1); + b.end_session().await; + let seen = server.seen(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].method, "DELETE"); + assert_eq!( + seen[0].headers.get("mcp-session-id").map(String::as_str), + Some("s1") + ); + } + + #[tokio::test] + async fn the_agent_hears_of_changed_tools_only_after_a_restart() { + let server = Server::start(); + let (b, mut rx) = bridge_at(&server.socket, knobs(10, 10)); + let a = json!({"tools": [{"name": "a"}]}); + let bb = json!({"tools": [{"name": "b"}]}); + // Nothing seen yet: nothing asked. + b.after_new_session(&up(2)).await; + assert!(server.seen().is_empty()); + *b.tools_seen.lock().unwrap() = Some(a.clone()); + // The first session: nothing asked. + b.after_new_session(&up(1)).await; + assert!(server.seen().is_empty()); + // The same tools after a restart: nothing said. + server.script([json_reply( + 200, + json!({"jsonrpc": "2.0", "id": "x", "result": a}), + )]); + b.after_new_session(&up(2)).await; + assert_eq!(server.seen().len(), 1); + assert!(told(&mut rx, 100).await.is_none()); + // Others: said, and remembered. + server.script([json_reply( + 200, + json!({"jsonrpc": "2.0", "id": "x", "result": bb}), + )]); + b.after_new_session(&up(3)).await; + assert_eq!( + told(&mut rx, 500).await, + Some(json!({"jsonrpc": "2.0", "method": "notifications/tools/list_changed"})) + ); + assert_eq!(*b.tools_seen.lock().unwrap(), Some(bb)); + } + + #[tokio::test] + async fn every_message_the_agent_sends_is_handled_for_what_it_is() { + let server = Server::start(); + let (b, mut rx) = bridge_at(&server.socket, knobs(30_000, 10)); + let tasks = tokio_util::task::TaskTracker::new(); + let invalid = |id: Value| move |m: &Value| m["id"] == id && m["error"]["code"] == -32600; + // A batch: each request in it refused; notifications in it ignored. + b.handle( + json!([{"jsonrpc": "2.0", "id": 1, "method": "ping"}, + {"jsonrpc": "2.0", "method": "notifications/x"}, + {"jsonrpc": "2.0", "id": 2, "method": "ping"}]), + &tasks, + ); + let got = told(&mut rx, 200).await.unwrap(); + assert_eq!(got.as_array().unwrap().len(), 2, "{got}"); + b.handle( + json!([{"jsonrpc": "2.0", "method": "notifications/x"}]), + &tasks, + ); + assert!( + told(&mut rx, 100).await.is_none(), + "notifications only: nothing" + ); + b.handle(json!([]), &tasks); + assert!(invalid(Value::Null)(&told(&mut rx, 200).await.unwrap())); + // Ids: a string or a whole number. + b.handle(json!({"jsonrpc": "2.0", "id": 1.5, "method": "x"}), &tasks); + assert!(invalid(Value::Null)(&told(&mut rx, 200).await.unwrap())); + b.handle(json!({"jsonrpc": "2.0", "id": "a", "method": "x"}), &tasks); + b.handle(json!({"jsonrpc": "2.0", "id": 3, "method": "x"}), &tasks); + b.handle( + json!({"jsonrpc": "2.0", "id": u64::MAX, "method": "x"}), + &tasks, + ); + assert!(told(&mut rx, 100).await.is_none(), "valid ids are taken"); + // An id still waiting is refused. + b.handle(json!({"jsonrpc": "2.0", "id": 3, "method": "x"}), &tasks); + assert!(invalid(json!(3))(&told(&mut rx, 200).await.unwrap())); + // Answers (a result, or an error) are the agent's; nothing said. + b.handle(json!({"jsonrpc": "2.0", "id": 5, "result": {}}), &tasks); + b.handle( + json!({"jsonrpc": "2.0", "id": 6, "error": {"code": 1, "message": "x"}}), + &tasks, + ); + assert!(told(&mut rx, 100).await.is_none()); + // Neither a method nor an answer. + b.handle(json!({"jsonrpc": "2.0", "id": 9}), &tasks); + assert!(invalid(json!(9))(&told(&mut rx, 200).await.unwrap())); + b.handle(json!("hello"), &tasks); + assert!(invalid(Value::Null)(&told(&mut rx, 200).await.unwrap())); + // Notifications: initialized is the bridge's own; a cancel of a + // call not sent stays here; one of a call sent, and others, go up. + ready(&b, 1); + server.script([reply(202, "text/plain", ""), reply(202, "text/plain", "")]); + b.handle( + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + &tasks, + ); + b.waiting(&json!(40)); + b.handle( + json!({"jsonrpc": "2.0", "method": "notifications/cancelled", + "params": {"requestId": 40}}), + &tasks, + ); + b.waiting(&json!(41)); + assert!(b.may_send(&json!(41))); + b.handle( + json!({"jsonrpc": "2.0", "method": "notifications/cancelled", + "params": {"requestId": 41}}), + &tasks, + ); + b.handle( + json!({"jsonrpc": "2.0", "method": "notifications/progress"}), + &tasks, + ); + tokio::time::sleep(Duration::from_millis(300)).await; + // (The requests taken above went up too, once ready.) + let notes: Vec = server + .seen() + .into_iter() + .map(|s| s.body) + .filter(|b| b.contains("notifications/")) + .collect(); + assert_eq!(notes.len(), 2, "{notes:?}"); + assert!(notes.iter().any(|b| b.contains("\"requestId\":41"))); + assert!(notes.iter().any(|b| b.contains("notifications/progress"))); + tasks.close(); + } + + /// Read `input` as the agent's, at most `max` bytes a message: what + /// the agent was told, and how it ended. + async fn read_all(input: &[u8], max: usize) -> (Vec, InputEnd) { + let (b, mut rx) = bridge_at(Path::new("/nonexistent"), knobs(10, 10)); + let tasks = tokio_util::task::TaskTracker::new(); + let stop = Notify::new(); + let mut reader = tokio::io::BufReader::new(input); + let end = read_messages(&mut reader, max, &b, &tasks, &stop).await; + let mut out = Vec::new(); + while let Ok(m) = rx.try_recv() { + out.push(m); + } + (out, end) + } + + fn codes(told: &[Value]) -> Vec { + told.iter() + .map(|m| m["error"]["code"].as_i64().unwrap_or(0)) + .collect() + } + + #[tokio::test] + async fn the_agents_input_is_read_message_by_message() { + // A malformed line is answered and the next one read; a blank one + // is skipped; the last one needs no newline. + let (told, end) = read_all(b"nonsense\n\n\xff\n[]\n[]", 1024).await; + assert_eq!(end, InputEnd::Closed); + assert_eq!(codes(&told), [-32700, -32700, -32600, -32600]); + // At the limit, with no newline: the input's last line, taken. + let (told, _) = read_all(b"[] ", 8).await; + assert_eq!(codes(&told), [-32600]); + // At the limit, with its newline: taken too. + let (told, _) = read_all(b"[] \n[]\n", 8).await; + assert_eq!(codes(&told), [-32600, -32600]); + // One byte over: refused, the rest of it skipped, then on. + let (told, _) = read_all(b"[] and more\n[]\n", 8).await; + assert_eq!(codes(&told), [SERVER_ERROR, -32600]); + assert!( + told[0]["error"]["message"] + .as_str() + .unwrap() + .contains("too large") + ); + // Too large and never ended: refused, then the input's end. + let (told, end) = read_all(b"[] and more", 8).await; + assert_eq!(codes(&told), [SERVER_ERROR]); + assert_eq!(end, InputEnd::Closed); + // Nothing at all. + let (told, end) = read_all(b"", 8).await; + assert!(told.is_empty()); + assert_eq!(end, InputEnd::Closed); + } + + #[tokio::test] + async fn a_signal_ends_the_reading() { + let (b, _rx) = bridge_at(Path::new("/nonexistent"), knobs(10, 10)); + let tasks = tokio_util::task::TaskTracker::new(); + let stop = Notify::new(); + // Input that never ends. + let (_keep, quiet) = tokio::io::duplex(64); + let mut reader = tokio::io::BufReader::new(quiet); + stop.notify_one(); + let end = read_messages(&mut reader, 64, &b, &tasks, &stop).await; + assert_eq!(end, InputEnd::Signalled); + } + + #[tokio::test] + async fn the_rest_of_a_line_too_long_is_skipped() { + let mut r = tokio::io::BufReader::with_capacity(4, &b"abcdefgh\nnext\n"[..]); + assert!(skip_line(&mut r).await, "the line ended"); + let mut rest = String::new(); + r.read_to_string(&mut rest).await.unwrap(); + assert_eq!(rest, "next\n"); + let mut r = tokio::io::BufReader::new(&b"no end"[..]); + assert!(!skip_line(&mut r).await, "the input ended first"); + let mut r = tokio::io::BufReader::new(&b""[..]); + assert!(!skip_line(&mut r).await); + } +} diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs new file mode 100644 index 0000000..b90d475 --- /dev/null +++ b/crates/ledger-host/src/commands.rs @@ -0,0 +1,940 @@ +//! The owner's commands: `wanderbooks serve`, `close` and `books` (plan +//! 3.9, 5.5). +//! +//! ```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. +//! 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 +//! never reads the books), 5.5 (adopt; preview and confirmation at +//! the terminal). +//! INVARIANTS: +//! 1. A command that needs the server holds a lease while it runs, and +//! lets go when it ends. +//! 2. Nothing printed names the data root. +//! 3. Adoption is previewed first and goes ahead only on a yes at the +//! terminal, or --yes; a vault beside the books must be named (or +//! --no-vault given): it is never silently left behind. +//! ``` + +use crate::knobs::{FLAG, Knobs}; +use crate::lease::{Ask, HostMsg, Intent, Kind, Reason}; +use crate::opener::{HoldError, Lease, Opener, Starter}; +use ledger_mcp::admin::{self, Request, Response}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// How long a command waits for the books' host to answer one question. +const HOST_WAIT: Duration = Duration::from_secs(5); +/// How long a command waits for the books server to list the books. +const LIST_WAIT: Duration = Duration::from_secs(30); +/// How long `close` waits to see the books closed. +const CLOSE_WAIT: Duration = Duration::from_secs(120); + +/// Common flags: `--root DIR` and the test knobs. +#[derive(Default)] +struct Common { + root: Option, + knobs: Knobs, +} + +impl Common { + /// Take a common flag (`Ok(true)`), or leave the argument. + fn take(&mut self, arg: &str, args: &mut impl Iterator) -> Result { + match arg { + "--root" => { + self.root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)); + Ok(true) + } + FLAG => { + self.knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?; + Ok(true) + } + _ => Ok(false), + } + } +} + +fn exit(code: i32) -> Box { + Box::new(crate::Exit(code)) +} + +/// Hold the books and wait until they are open (INVARIANT 1). +async fn open(opener: &mut Opener) -> Result { + let deadline = tokio::time::Instant::now() + opener.knobs().open_wait; + let too_long = || -> HoldError { + "the books didn't open in time: run `wanderbooks doctor` to see why".into() + }; + loop { + let mut lease = tokio::time::timeout_at(deadline, opener.hold()) + .await + .map_err(|_| too_long())??; + loop { + match tokio::time::timeout_at(deadline, lease.next()).await { + Err(_) => return Err(too_long()), + Ok(Some(HostMsg::Ready)) => { + opener.opened(); + return Ok(lease); + } + Ok(Some(HostMsg::Ending { + reason: Reason::ClosedByOwner, + .. + })) => { + return Err(HoldError::ClosedByOwner); + } + Ok(Some(HostMsg::Ending { + reason: Reason::Restarting | Reason::Closing, + .. + })) => break, + Ok(Some(HostMsg::Ending { .. })) | Ok(None) => { + // The wait before the next try counts against the + // deadline too. + tokio::time::timeout_at(deadline, opener.failed()) + .await + .map_err(|_| too_long())??; + break; + } + Ok(Some(_)) => {} + } + } + } +} + +/// Ask the server's admin socket, holding the books open meanwhile. +fn with_server(common: &Common, request: Request) -> Result { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .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 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() + } + HoldError::Failed(why) => why, + })?; + let socket = opener.run_dir().join(ledger_mcp::local::ADMIN_SOCKET); + // A list is quick; a create or an adoption takes what it takes. + let wait = matches!(request, Request::List).then_some(LIST_WAIT); + let answer = + tokio::task::spawn_blocking(move || admin::call_within(&socket, request, wait)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()); + drop(lease); + answer + }); + // Past its deadline, the command ends: nothing pending holds it. + rt.shutdown_timeout(Duration::from_secs(1)); + outcome +} + +/// Where the books stand while `close` waits for them to close. +#[derive(Debug, PartialEq, Eq)] +enum Closing { + /// No host, and no server finishing: closed. + Closed, + /// A new host, opened by something since: closed, then opened again. + Reopened, + /// Still on their way (a server finishing, or a host not answering). + Going, +} + +/// What the host's answer (`asked`) and the server's lock say. +fn closing( + asked: &Result, String>, + server_lock_held: impl FnOnce() -> bool, +) -> Closing { + match asked { + Ok(None) if server_lock_held() => Closing::Going, + Ok(None) => Closing::Closed, + Ok(Some(HostMsg::Leases { .. })) => Closing::Reopened, + _ => Closing::Going, + } +} + +/// Whether `deadline` has passed at `now`. +fn past(deadline: std::time::Instant, now: std::time::Instant) -> bool { + now > deadline +} + +/// What `doctor` says of books no host holds. +fn no_host(server_lock_held: bool) -> &'static str { + if server_lock_held { + "The books are closing: a books server is finishing its last call." + } else { + "The books are closed." + } +} + +/// `wanderbooks serve`: hold the books open until Ctrl-C. +pub fn serve( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut common = Common::default(); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if common.take(&arg, &mut args)? { + continue; + } + match arg.as_str() { + "--help" | "-h" => { + eprintln!("usage: {prog}"); + eprintln!(" Opens the books and holds them open until Ctrl-C. They close 10 s"); + eprintln!(" after nothing else (the app, an agent session) holds them."); + return Ok(()); + } + other => { + eprintln!("unknown argument: {other}"); + eprintln!("usage: {prog}"); + return Err(exit(2)); + } + } + } + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let outcome = rt.block_on(async { + let mut opener = Opener::new( + common.root.as_deref(), + common.knobs, + Starter::Direct, + Kind::Serve, + None, + )?; + // Ctrl-C, SIGTERM and SIGHUP are heard from the start, whatever + // serve is doing (opening, waiting to try again): none is missed. + let stop = std::sync::Arc::new(tokio::sync::Notify::new()); + for kind in [ + tokio::signal::unix::SignalKind::interrupt(), + tokio::signal::unix::SignalKind::terminate(), + tokio::signal::unix::SignalKind::hangup(), + ] { + let mut signals = tokio::signal::unix::signal(kind)?; + let stop = stop.clone(); + tokio::spawn(async move { + while signals.recv().await.is_some() { + stop.notify_one(); + } + }); + } + let let_go = || { + println!("Let go of the books."); + Ok(()) + }; + let mut said_open = false; + loop { + println!("Opening the books…"); + let opened = tokio::select! { + opened = open(&mut opener) => opened, + () = stop.notified() => return let_go(), + }; + let mut lease = match opened { + Ok(lease) => lease, + Err(HoldError::ClosedByOwner) => { + println!("The books were closed (Close the books)."); + return Ok(()); + } + Err(HoldError::Failed(why)) => { + eprintln!("error: {why}"); + return Err(exit(1)); + } + }; + if !said_open { + println!( + "The books are open. Press Ctrl-C to let go of them: they close when nothing else holds them." + ); + said_open = true; + } else { + println!("The books are open again."); + } + let ended = tokio::select! { + () = stop.notified() => None, + msg = lease.next() => Some(msg), + }; + match ended { + None => { + drop(lease); + return let_go(); + } + Some(Some(HostMsg::Ending { + reason: Reason::ClosedByOwner, + .. + })) => { + println!("The books were closed (Close the books)."); + return Ok(()); + } + Some(Some(HostMsg::Ending { + reason: Reason::Restarting, + .. + })) => { + println!("The books are restarting (a new version)…"); + } + Some(Some(HostMsg::Ending { + reason: Reason::Closing, + .. + })) => {} + Some(_) => { + println!("The books server stopped; opening the books again…"); + let waited = tokio::select! { + waited = opener.failed() => waited, + () = stop.notified() => return let_go(), + }; + if let Err(why) = waited { + eprintln!("error: {why}"); + return Err(exit(1)); + } + } + } + } + }); + // A holder that gave up isn't held back by work still pending. + rt.shutdown_timeout(Duration::from_secs(1)); + outcome +} + +/// `wanderbooks close`: close the books now; every holder lets go. +pub fn close( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut common = Common::default(); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if common.take(&arg, &mut args)? { + continue; + } + match arg.as_str() { + "--help" | "-h" => { + eprintln!("usage: {prog}"); + eprintln!(" Closes the books now: the app, agent sessions and `serve` let go."); + return Ok(()); + } + other => { + eprintln!("unknown argument: {other}"); + eprintln!("usage: {prog}"); + return Err(exit(2)); + } + } + } + let root = ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?; + let run = root.run_dir().map_err(|e| e.to_string())?; + let asked = crate::opener::ask_once_within(&run, Ask::Intent(Intent::Close), HOST_WAIT) + .map_err(|why| { + // Asked, and no answer: the host may yet close them. + eprintln!( + "error: {why}: the books may still close; `wanderbooks doctor` shows whether they did" + ); + exit(1) + })?; + match asked { + None => { + println!("The books are already closed."); + Ok(()) + } + Some(HostMsg::Done { .. }) => { + println!("Closing the books: everything holding them lets go…"); + let deadline = std::time::Instant::now() + CLOSE_WAIT; + loop { + let asked = crate::opener::ask_once_within(&run, Ask::Leases, HOST_WAIT); + match closing(&asked, || crate::opener::server_lock_held(&run)) { + Closing::Closed => break, + Closing::Reopened => { + println!("The books were closed; something has opened them again."); + return Ok(()); + } + Closing::Going => {} + } + if past(deadline, std::time::Instant::now()) { + println!("They are still closing (a request is finishing)."); + return Ok(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + println!("The books are closed."); + Ok(()) + } + Some(HostMsg::Refused(why)) => { + eprintln!("error: {why}"); + Err(exit(1)) + } + Some(other) => { + eprintln!("error: the books' host answered oddly: {other:?}"); + Err(exit(1)) + } + } +} + +fn books_usage(prog: &str) { + eprintln!("usage: {prog} list"); + eprintln!(" {prog} create ID [--currency CODE]"); + eprintln!( + " {prog} adopt DATA-DIR --id ID [--toml FILE] [--config-json FILE] [--vault DIR --vault-key FILE | --no-vault] [--skip-unknown] [--yes]" + ); + 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"); +} + +/// `wanderbooks books list|create|adopt`. +pub fn books( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut common = Common::default(); + let mut words: Vec = Vec::new(); + let mut currency = "USD".to_string(); + let mut id: Option = None; + let mut toml: Option = None; + let mut config_json: Option = None; + let mut vault: Option = None; + let mut vault_key: Option = None; + let mut no_vault = false; + let mut skip_unknown = false; + let mut yes = false; + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if common.take(&arg, &mut args)? { + continue; + } + let mut value = |what: &str| args.next().ok_or_else(|| format!("{arg} needs {what}")); + match arg.as_str() { + "--currency" => currency = value("a currency code")?, + "--id" => id = Some(value("an id")?), + "--toml" => toml = Some(PathBuf::from(value("a file")?)), + "--config-json" => config_json = Some(PathBuf::from(value("a file")?)), + "--vault" => vault = Some(PathBuf::from(value("a folder")?)), + "--vault-key" => vault_key = Some(PathBuf::from(value("a file")?)), + "--no-vault" => no_vault = true, + "--skip-unknown" => skip_unknown = true, + "--yes" => yes = true, + "--help" | "-h" => { + books_usage(prog); + return Ok(()); + } + s if s.starts_with('-') => { + eprintln!("unknown flag: {s}"); + books_usage(prog); + return Err(exit(2)); + } + _ => words.push(arg), + } + } + let words: Vec<&str> = words.iter().map(String::as_str).collect(); + let answer = match words.as_slice() { + ["list"] => with_server(&common, Request::List), + ["create", new] => with_server( + &common, + Request::Create { + id: (*new).to_string(), + currency: currency.clone(), + }, + ), + ["adopt", source] => { + let Some(id) = id else { + eprintln!("error: name the adopted books with --id ID"); + return Err(exit(2)); + }; + return adopt( + &common, + Path::new(source), + id, + AdoptFiles { + toml, + config_json, + vault, + vault_key, + no_vault, + }, + skip_unknown, + yes, + ); + } + _ => { + books_usage(prog); + return Err(exit(2)); + } + }; + match answer { + Ok(Response::Books(books)) if books.is_empty() => { + println!("No books yet: `wanderbooks books create ID` starts some."); + Ok(()) + } + Ok(Response::Books(books)) => { + for b in books { + match b.problem { + None => println!("{} open", b.id), + Some(why) => println!("{} didn't open: {why}", b.id), + } + } + Ok(()) + } + Ok(Response::Created { id, problem: None }) => { + println!("Made new books \"{id}\" ({currency})."); + Ok(()) + } + Ok(Response::Created { + id, + problem: Some(why), + }) => { + eprintln!( + "error: new books \"{id}\" were made but didn't open: {why}. Closing the books and opening them again tries again." + ); + Err(exit(1)) + } + Ok(Response::Refused(why)) | Err(why) => { + eprintln!("error: {why}"); + Err(exit(1)) + } + Ok(other) => { + eprintln!("error: the books server answered oddly: {other:?}"); + Err(exit(1)) + } + } +} + +/// The files an adoption brings in with the books. +struct AdoptFiles { + toml: Option, + config_json: Option, + vault: Option, + vault_key: Option, + no_vault: bool, +} + +/// A hash's first twelve characters, for people. +fn short_hash(hash: &str) -> String { + hash.chars().take(12).collect() +} + +/// Where a P1 books dir keeps its vault: `vault/` beside the data dir. +fn vault_beside(source: &Path) -> Option { + let source = std::fs::canonicalize(source).ok()?; + let vault = source.parent()?.join("vault"); + vault.is_dir().then_some(vault) +} + +/// Whether standard input is a terminal. +fn at_a_terminal() -> bool { + // SAFETY: isatty has no preconditions. + unsafe { libc::isatty(0) == 1 } +} + +/// `wanderbooks books adopt` (INVARIANT 3). +fn adopt( + common: &Common, + source: &Path, + id: String, + files: AdoptFiles, + skip_unknown: bool, + yes: bool, +) -> Result<(), Box> { + let fail = |why: String| -> Result<(), Box> { + eprintln!("error: {why}"); + Err(exit(1)) + }; + let shown = source.display().to_string(); + if files.no_vault && (files.vault.is_some() || files.vault_key.is_some()) { + return fail("--no-vault and --vault can't both be given".into()); + } + match (&files.vault, &files.vault_key) { + (Some(_), None) => { + return fail("give the vault's key too, with --vault-key FILE (often ~/.config/wanderbooks/vault.key)".into()); + } + (None, Some(_)) => return fail("give the vault's folder too, with --vault DIR".into()), + _ => {} + } + if files.vault.is_none() + && !files.no_vault + && let Some(beside) = vault_beside(source) + { + return fail(format!( + "there is a TIN vault beside these books ({}): name it with --vault DIR and its key with --vault-key FILE, or give --no-vault to leave it behind", + beside.display() + )); + } + let read = |path: &Option| -> Result, String> { + path.as_ref() + .map(|p| { + std::fs::read_to_string(p).map_err(|e| format!("can't read {}: {e}", p.display())) + }) + .transpose() + }; + let settings_toml = match read(&files.toml) { + Ok(t) => t, + Err(why) => return fail(why), + }; + let config_json = match read(&files.config_json) { + Ok(t) => t, + Err(why) => return fail(why), + }; + let source = std::path::absolute(source).unwrap_or_else(|_| source.to_path_buf()); + let request = |expect_head: Option<(u64, String)>| Request::Adopt { + id: id.clone(), + source: source.clone(), + settings_toml: settings_toml.clone(), + config_json: config_json.clone(), + vault_dir: files.vault.clone(), + vault_key: files.vault_key.clone(), + confirmed: expect_head.is_some(), + expect_head, + skip_unknown, + }; + let preview = match with_server(common, request(None)) { + Ok(Response::Preview(p)) => p, + Ok(Response::Refused(why)) | Err(why) => return fail(format!("{shown}: {why}")), + Ok(other) => return fail(format!("the books server answered oddly: {other:?}")), + }; + println!( + "Adopting the books at {} as \"{id}\": through event {} ({}).", + source.display(), + preview.source_head.0, + short_hash(&preview.source_head.1) + ); + if preview.keeps_settings { + println!("They keep their own settings."); + if files.toml.is_some() { + println!("(The settings file given isn't used.)"); + } + } else if preview.settings.is_empty() { + println!("No settings move in."); + } else { + println!("These settings move in from the settings file:"); + for s in &preview.settings { + match &s.value { + Some(v) => println!(" {} = {v}", s.key), + None => println!(" {} (cleared)", s.key), + } + } + } + for n in &preview.notes { + println!(" note: {n}"); + } + for u in &preview.unknown { + println!(" not moved in (the books don't know it): {u}"); + } + if !preview.comments.is_empty() { + println!( + "The settings file's comments (not settings: keep what matters as a convention or a decision):" + ); + for c in &preview.comments { + println!(" # {c}"); + } + } + match &files.vault { + Some(v) => println!( + "The TIN vault at {} is copied in with its key ({} records, each checked to open).", + v.display(), + preview.vault_records + ), + None => println!("No TIN vault is copied."), + } + if !preview.unknown.is_empty() && !skip_unknown { + return fail( + "the settings file has entries the books don't know (above): take them out, or adopt with --skip-unknown to leave them behind".into(), + ); + } + println!("The original is left exactly as it is."); + if !yes { + if !at_a_terminal() { + return fail("not adopted: confirm at a terminal, or run again with --yes".into()); + } + print!("Adopt them? [y/N] "); + use std::io::Write; + std::io::stdout().flush()?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") { + println!("Not adopted."); + return Ok(()); + } + } + match with_server(common, request(Some(preview.source_head.clone()))) { + Ok(Response::Adopted(a)) => { + for line in adopted_lines(&a) { + println!("{line}"); + } + if let Some(why) = a.problem { + return fail(format!( + "the adopted books didn't open: {why}. Closing the books and opening them again tries again." + )); + } + Ok(()) + } + Ok(Response::Refused(why)) | Err(why) => fail(format!("{shown}: {why}")), + Ok(other) => fail(format!("the books server answered oddly: {other:?}")), + } +} + +/// What an adoption did, a line each. +fn adopted_lines(a: &admin::Adopted) -> Vec { + let mut extra = String::new(); + if a.unnamed_blobs > 0 { + extra.push_str(&format!( + ", {} the books don't name (kept)", + a.unnamed_blobs + )); + } + if a.skipped_files > 0 { + extra.push_str(&format!( + ", {} unfinished or hidden files left behind", + a.skipped_files + )); + } + let mut lines = vec![format!( + "Adopted as \"{}\": {} stored files copied, {} of them checked against the books{extra}.", + a.id, a.blob_files, a.blobs_verified + )]; + // A vault is adopted only with its key, so a copied key says a vault + // came too (with its records, perhaps none). + if a.key_copied { + lines.push(format!("TIN vault: {} records and its key.", a.vault_files)); + } + if a.settings_imported > 0 { + lines.push(format!("{} settings moved in.", a.settings_imported)); + } + lines.push(format!( + "Head: event {} ({}).", + a.head.0, + short_hash(&a.head.1) + )); + lines +} + +/// How long `doctor` waits for the books server to answer. +const DOCTOR_WAIT: Duration = Duration::from_secs(5); +/// How many of each log's last lines `doctor` shows. +const DOCTOR_LINES: usize = 12; + +/// The last `n` lines of a log in `run/`, with the root's path taken out. +fn log_tail(run: &Path, name: &str, root: &Path, n: usize) -> Vec { + let Ok(text) = std::fs::read_to_string(run.join(name)) else { + return Vec::new(); + }; + // The root as found, and as it really is (through any link). + let real = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let spellings = [root.display().to_string(), real.display().to_string()]; + let lines: Vec<&str> = text.lines().collect(); + lines[lines.len().saturating_sub(n)..] + .iter() + .map(|l| { + let l = spellings.iter().fold(l.to_string(), |l, s| { + l.replace(s.as_str(), ledger_store::root::THE_ROOT) + }); + // A log line can carry a panic's text: no full TIN is shown. + ledger_core::parties::redact_full_tins(&l).into_owned() + }) + .collect() +} + +/// `wanderbooks doctor`: whether the books are open, who holds them, and +/// what the host and the server last said. It names no folder. +pub fn doctor( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut common = Common::default(); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if common.take(&arg, &mut args)? { + continue; + } + match arg.as_str() { + "--help" | "-h" => { + eprintln!("usage: {prog}"); + eprintln!( + " Says whether the books are open, what holds them, and what went wrong." + ); + return Ok(()); + } + other => { + eprintln!("unknown argument: {other}"); + eprintln!("usage: {prog}"); + return Err(exit(2)); + } + } + } + let root = match ledger_store::root::Root::find(common.root.as_deref()) { + Ok(root) => root, + Err(e) => { + println!("The books' private folder: {e}"); + return Err(exit(1)); + } + }; + println!("The books' private folder: there, and only your account can open it."); + println!("Isolation: same_user (advisory): hidden from agents, not locked against them."); + let run = root.run_dir().map_err(|e| e.to_string())?; + match crate::opener::ask_once_within(&run, Ask::Leases, DOCTOR_WAIT) { + Ok(None) => println!("{}", no_host(crate::opener::server_lock_held(&run))), + Ok(Some(HostMsg::Leases { ready, leases })) => { + if ready { + println!("The books are open."); + // Each book, and why one didn't open. + let socket = run.join(ledger_mcp::local::ADMIN_SOCKET); + // A quick look: a wedged server doesn't hang the doctor. + let listed = admin::call_within(&socket, Request::List, Some(DOCTOR_WAIT)); + if let Err(why) = &listed { + println!(" The books server doesn't answer: {why}"); + } + if let Ok(admin::Response::Books(books)) = listed { + for b in books { + match b.problem { + None => println!(" {} open", b.id), + Some(why) => println!(" {} didn't open: {why}", b.id), + } + } + } + } else { + println!("The books are opening (the books server isn't ready yet)."); + } + if leases.is_empty() { + println!("Nothing holds them: they close soon."); + } else { + println!("Held by:"); + for l in leases { + match l.book { + Some(book) => println!(" {} (pid {}), on {book}", l.kind.label(), l.pid), + None => println!(" {} (pid {})", l.kind.label(), l.pid), + } + } + } + } + Ok(Some(HostMsg::Ending { .. })) => println!("The books are closing."), + Ok(Some(other)) => println!("The books' host answered oddly: {other:?}"), + Err(why) => println!("The books' host can't be asked: {why}"), + } + for (name, what) in [ + (crate::start::HOST_LOG, "The host"), + (crate::host::SERVER_LOG, "The books server"), + ] { + let tail = log_tail(&run, name, root.path(), DOCTOR_LINES); + if !tail.is_empty() { + println!("{what}, last said:"); + for line in tail { + println!(" {line}"); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn close_reads_the_books_state_for_what_it_is() { + let leases = Ok(Some(HostMsg::Leases { + ready: true, + leases: vec![], + })); + assert_eq!(closing(&Ok(None), || false), Closing::Closed); + assert_eq!( + closing(&Ok(None), || true), + Closing::Going, + "a server finishing" + ); + assert_eq!(closing(&leases, || false), Closing::Reopened); + assert_eq!(closing(&Err("no".into()), || false), Closing::Going); + assert_eq!(closing(&Ok(Some(HostMsg::Ready)), || false), Closing::Going); + let now = std::time::Instant::now(); + let later = now + Duration::from_secs(1); + assert!(!past(later, now)); + assert!(!past(now, now), "not yet at the deadline itself"); + assert!(past(now, later)); + } + + #[test] + fn doctor_tells_closing_from_closed() { + assert!(no_host(true).contains("closing")); + assert_eq!(no_host(false), "The books are closed."); + } + + fn adopted() -> admin::Adopted { + admin::Adopted { + id: "old".into(), + source_head: (3, "a".repeat(64)), + head: (4, "0123456789abcdef".repeat(4)), + blob_files: 5, + blobs_verified: 4, + unnamed_blobs: 0, + skipped_files: 0, + vault_files: 0, + key_copied: false, + settings_imported: 0, + settings_ignored: false, + source_lock_created: false, + problem: None, + } + } + + #[test] + fn an_adoption_says_what_it_did_and_only_that() { + let plain = adopted_lines(&adopted()); + assert_eq!( + plain, + [ + "Adopted as \"old\": 5 stored files copied, 4 of them checked against the books.", + "Head: event 4 (0123456789ab).", + ] + ); + let all = adopted_lines(&admin::Adopted { + unnamed_blobs: 1, + skipped_files: 2, + vault_files: 3, + key_copied: true, + settings_imported: 6, + ..adopted() + }); + assert_eq!( + all, + [ + "Adopted as \"old\": 5 stored files copied, 4 of them checked against the books, 1 the books don't name (kept), 2 unfinished or hidden files left behind.", + "TIN vault: 3 records and its key.", + "6 settings moved in.", + "Head: event 4 (0123456789ab).", + ] + ); + // A vault with no records still came, with its key. + let empty = adopted_lines(&admin::Adopted { + key_copied: true, + ..adopted() + }); + assert!(empty.contains(&"TIN vault: 0 records and its key.".to_string())); + } + + #[test] + fn the_log_lines_shown_name_no_folder_and_no_tin() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("root"); + std::fs::create_dir(&root).unwrap(); + let run = root.join("run"); + std::fs::create_dir(&run).unwrap(); + std::fs::write( + run.join("server.log"), + format!( + "old line\nthread panicked at {}/books/acme: mid-write 241-40-4915\nlast\n", + root.display() + ), + ) + .unwrap(); + let tail = log_tail(&run, "server.log", &root, 2); + assert_eq!(tail.len(), 2); + let text = tail.join("\n"); + assert!(!text.contains(&root.display().to_string()), "{text}"); + assert!(!text.contains("241-40-4915"), "{text}"); + assert!(text.contains(ledger_store::root::THE_ROOT), "{text}"); + assert_eq!(tail[1], "last"); + assert!(log_tail(&run, "none.log", &root, 5).is_empty()); + } +} diff --git a/crates/ledger-host/src/helper.rs b/crates/ledger-host/src/helper.rs new file mode 100644 index 0000000..059a10b --- /dev/null +++ b/crates/ledger-host/src/helper.rs @@ -0,0 +1,335 @@ +//! `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, + child: std::sync::Mutex, +} + +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 { + 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 { + 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("restoring isn't built yet".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, +) -> Result<(), Box> { + let mut root: Option = 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(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::(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::(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::(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 { + let mut out: Vec = 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 { + 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(_))); + } + } +} diff --git a/crates/ledger-host/src/host.rs b/crates/ledger-host/src/host.rs new file mode 100644 index 0000000..19060b9 --- /dev/null +++ b/crates/ledger-host/src/host.rs @@ -0,0 +1,1087 @@ +//! `wanderbooks host`: holds the books open while any lease lasts, and +//! closes them when none does (plan 3.9). +//! +//! ```text +//! GOAL: the books are open exactly while something holds them; closing +//! is always clean; one host and one server per root, even in a +//! race; nothing of the host outlives the last holder by more than +//! the close delay. +//! SOURCE: plan/served.md 3.9 ("Who holds them", "Starting the host +//! cleanly", "One host, even in a race", "Closing", "Restarting", +//! "One version at a time"), D-21, D-23. +//! INVARIANTS: +//! 1. One host per root: it holds run/host.lock for its whole life; a +//! host that can't take it within HOST_LOCK_WAIT exits 3, having +//! touched nothing of the host that has it (it may have made run/, +//! and wrote and removed its own probe file). +//! 2. It refuses to run confined (seccomp, or a root it can't write), +//! and closes every descriptor above 2 it was handed. +//! 3. It reads no books: it starts the server from its own executable +//! (/proc/self/exe on Linux, so a replaced file can't mix versions) +//! and closes the books by closing the server's standard input. +//! 4. The books close `close_after` after the last lease ends (or after +//! the start, if none comes); a lease arriving in that time cancels +//! the close. +//! 5. The host never ends a lease without first telling it why. +//! 6. Close (the owner's intent, SIGTERM, SIGINT or SIGHUP): every lease +//! is told closed_by_owner and ended, new ones are turned away +//! (closing); once the holders have let go (they send nothing more), +//! or LEASE_DRAIN has passed, the server is closed: it finishes what +//! is in progress and exits; the socket is removed, and the host +//! exits 0. +//! 7. Restart (its executable's file replaced): run/update.lock is +//! taken first, every lease told restarting, the server closed as +//! in 6, a new host started from the file now on disk; host.lock is +//! let go, and update.lock only once the new host answers (or +//! HOST_LOCK_WAIT has passed), so waiting openers find it. +//! 8. The server stopping on its own: every lease is told server_failed, +//! and the host exits 1. +//! 9. Only this account's connections, and only lease.rs's messages. +//! 10. A closing server is never killed: it finishes what it is doing, +//! however long that takes. +//! 11. The owner's close while restarting is refused (the restart is +//! already under way; close again once it is done). +//! ``` + +use crate::knobs::{FLAG, Knobs}; +use crate::lease::{self, Ask, HostMsg, Intent, LeaseInfo, Reason}; +use ledger_store::root::Root; +use std::collections::BTreeMap; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::Instant; +use tokio_util::task::TaskTracker; + +/// How long a new host waits for another to let go of the lock (one +/// closing, or a probe) before giving up (INVARIANT 1). +const HOST_LOCK_WAIT: Duration = Duration::from_secs(30); +/// How long a probe of another host waits for its answer. +const PROBE_WAIT: Duration = Duration::from_secs(2); +/// How long a connection has to say what it wants. +const HELLO_WAIT: Duration = Duration::from_secs(10); +/// How long holders have to let go of their ended leases (having heard +/// why, so they send no more) before the server is closed anyway. +const LEASE_DRAIN: Duration = Duration::from_secs(2); +/// The line a host writes to its log first. +pub const STARTED: &str = "wanderbooks host: starting"; +/// The server's log, in the root's `run/`. +pub const SERVER_LOG: &str = "server.log"; +/// A log larger than this is moved aside (to `.1`) when the host starts. +const LOG_LIMIT: u64 = 8 * 1024 * 1024; +/// The exit code when another host holds the root. +pub const EXIT_ANOTHER_HOST: i32 = 3; + +/// Close every descriptor above 2 (INVARIANT 2). +fn close_stray_descriptors() { + #[cfg(target_os = "linux")] + { + // SAFETY: close_range only closes descriptors; nothing is open + // yet that this program needs. + if unsafe { libc::syscall(libc::SYS_close_range, 3u32, u32::MAX, 0u32) } == 0 { + return; + } + } + // Elsewhere, or an old kernel: one by one. + // SAFETY: getdtablesize has no preconditions. + let top = unsafe { libc::getdtablesize() }.clamp(3, 65536); + for fd in 3..top { + // SAFETY: closing a descriptor that may not be open is harmless. + unsafe { libc::close(fd) }; + } +} + +/// Whether this process is confined: seccomp in force, or a root it +/// can't write (Landlock) (INVARIANT 2). +fn confined(run: &Path) -> Option<&'static str> { + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + let seccomp = status + .lines() + .find_map(|l| l.strip_prefix("Seccomp:")) + .map(str::trim); + if seccomp.is_some_and(|mode| mode != "0") { + return Some("a seccomp filter"); + } + } + let probe = run.join(format!(".host-probe-{}", std::process::id())); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&probe) + { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + None + } + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + Some("a sandbox that won't let it write the books' folder") + } + Err(_) => None, + } +} + +/// Take run/host.lock, waiting a while for a host that is closing +/// (INVARIANT 1). +fn take_host_lock(run: &Path) -> Result, String> { + take_host_lock_within(run, HOST_LOCK_WAIT) +} + +/// [`take_host_lock`], waiting at most `wait` (tests shorten it). +fn take_host_lock_within(run: &Path, wait: Duration) -> Result, String> { + let path = run.join(lease::HOST_LOCK); + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .mode(0o600) + .open(&path) + .map_err(|e| format!("can't open the host lock: {}", e.kind()))?; + let deadline = std::time::Instant::now() + wait; + loop { + match file.try_lock() { + Ok(()) => return Ok(Some(file)), + // A host that is open and serving: this one isn't needed. + Err(std::fs::TryLockError::WouldBlock) + if matches!( + crate::opener::ask_once_within(run, Ask::Leases, PROBE_WAIT), + Ok(Some(HostMsg::Leases { .. })) + ) => + { + return Ok(None); + } + // One starting or closing: wait for it. + Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Err(std::fs::TryLockError::WouldBlock) => return Ok(None), + Err(std::fs::TryLockError::Error(e)) => { + return Err(format!("can't take the host lock: {}", e.kind())); + } + } + } +} + +/// Move a log larger than LOG_LIMIT aside, then open it for appending. +fn open_log(path: &Path) -> std::io::Result { + open_log_within(path, LOG_LIMIT) +} + +/// [`open_log`], moving aside a log larger than `limit` bytes. +fn open_log_within(path: &Path, limit: u64) -> std::io::Result { + if std::fs::metadata(path).is_ok_and(|m| m.len() > limit) { + let _ = std::fs::rename(path, path.with_extension("log.1")); + } + std::fs::OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(path) +} + +/// The executable the server is started from (INVARIANT 3). +fn children_executable() -> PathBuf { + if cfg!(target_os = "linux") { + PathBuf::from("/proc/self/exe") + } else { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("wanderbooks")) + } +} + +/// The running executable's file identity, and its file's now: they +/// differ once the file was replaced (INVARIANT 7). +fn running_identity() -> Option<(u64, u64)> { + let running = if cfg!(target_os = "linux") { + std::fs::metadata("/proc/self/exe").ok()? + } else { + std::fs::metadata(std::env::current_exe().ok()?).ok()? + }; + Some((running.dev(), running.ino())) +} + +/// The identity of the file at this executable's path, if it is a +/// program (a file half-written or gone doesn't count yet). +fn file_identity() -> Option<(u64, u64)> { + identity_of(&crate::start::this_executable().ok()?) +} + +/// The identity (device, inode) of the program at `path`: a file someone +/// may run; else None. +fn identity_of(path: &Path) -> Option<(u64, u64)> { + let meta = std::fs::metadata(path).ok()?; + (meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino())) +} + +/// Wait at most `wait` for a host to answer on the lease socket: whether +/// one did. +fn wait_for_a_host(run: &Path, wait: Duration) -> bool { + let deadline = std::time::Instant::now() + wait; + loop { + if matches!( + crate::opener::ask_once_within(run, Ask::Leases, PROBE_WAIT), + Ok(Some(HostMsg::Leases { .. })) + ) { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +/// The host's command line. +struct Args { + /// `--root`, if given (tests): passed on to the server; otherwise the + /// server finds the root itself, and no command line names it. + root: Option, + knobs: Knobs, +} + +fn parse_args(prog: &str, args: impl IntoIterator) -> Result, String> { + let mut root = 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!(" Holds the books open while anything holds a lease on it; started by"); + eprintln!(" whatever opens the books first, not by hand."); + return Ok(None); + } + other => return Err(format!("unknown argument: {other}")), + } + } + Ok(Some(Args { root, knobs })) +} + +/// `wanderbooks host`. +pub fn run( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + close_stray_descriptors(); + // The first line of its log: a starter knows from it that the host + // itself ran (start.rs INVARIANT 3). + eprintln!("{STARTED} (pid {})", std::process::id()); + let args = match parse_args(prog, args) { + Ok(Some(args)) => args, + Ok(None) => return Ok(()), + Err(why) => { + eprintln!("{why}"); + eprintln!("usage: {prog} [--root DIR]"); + return Err(Box::new(crate::Exit(2))); + } + }; + let root = Root::find(args.root.as_deref()).map_err(|e| e.to_string())?; + let run = root.run_dir().map_err(|e| e.to_string())?; + if let Some(what) = confined(&run) { + eprintln!("wanderbooks host: refusing to run under {what}"); + return Err(Box::new(crate::Exit(1))); + } + let Some(lock) = take_host_lock(&run)? else { + eprintln!("wanderbooks host: another host has these books open"); + return Err(Box::new(crate::Exit(EXIT_ANOTHER_HOST))); + }; + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let outcome = rt.block_on(Host::new(root, run.clone(), args).serve()); + rt.shutdown_timeout(Duration::from_secs(1)); + let _ = std::fs::remove_file(run.join(lease::LEASE_SOCKET)); + match outcome { + Ok(Ending::Closed) => { + drop(lock); + Ok(()) + } + Ok(Ending::Restart(update)) => { + // INVARIANT 7: the new host, then its lock; openers wait on + // the update lock until the new host answers. + let started = restart(); + drop(lock); + if started.is_ok() { + wait_for_a_host(&run, HOST_LOCK_WAIT); + } + drop(update); + started.map_err(Into::into) + } + Ok(Ending::ServerFailed) => { + drop(lock); + Err(Box::new(crate::Exit(1))) + } + Err(e) => Err(e.into()), + } +} + +/// Start the next host from the file now on disk, with this one's +/// arguments and (already clean) environment (INVARIANT 7). +fn restart() -> Result<(), String> { + use std::os::unix::process::CommandExt; + let exe = crate::start::this_executable()?; + let mut c = std::process::Command::new(exe); + c.args(std::env::args_os().skip(1)) + .current_dir("/") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()); + // SAFETY: setsid is async-signal-safe and touches only the child. + unsafe { + c.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + c.spawn() + .map(drop) + .map_err(|e| format!("can't start the new host: {e}")) +} + +/// How the host's work ended. +enum Ending { + /// Closed (INVARIANTS 4, 6). + Closed, + /// Restarting, holding run/update.lock (INVARIANT 7). + Restart(std::fs::File), + /// The server stopped on its own (INVARIANT 8). + ServerFailed, +} + +/// What the connections and watchers tell the host. +enum Event { + /// A connection asks to hold the books. + Hold { + info: LeaseInfo, + to_holder: mpsc::UnboundedSender, + answer: oneshot::Sender>, + }, + /// A lease's connection ended. + Released(u64), + /// The leases held now. + List(oneshot::Sender), + /// An owner intent (passphrase already checked). + Intent(Intent, oneshot::Sender), + /// The server said it is ready. + ServerReady, + /// The server exited. + ServerExited(std::process::ExitStatus), +} + +/// A held lease. +struct Held { + info: LeaseInfo, + to_holder: mpsc::UnboundedSender, +} + +/// What the host is doing. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Phase { + /// Serving (the server ready or starting). + Open, + /// Closing for this reason; new leases are turned away. + Closing(Reason), +} + +struct Host { + root: Root, + run: PathBuf, + knobs: Knobs, + leases: BTreeMap, + next_lease: u64, + ready: bool, + phase: Phase, + /// The server's standard input: dropping it closes the books. + server_stdin: Option, + server_pid: Option, + update_lock: Option, + /// Leases ended by the host whose holders haven't let go yet. + draining: std::collections::BTreeSet, + /// When the server is closed even if some haven't. + drain_until: Option, + /// Whether the root was given on the command line (tests). + explicit_root: bool, +} + +impl Host { + fn new(root: Root, run: PathBuf, args: Args) -> Self { + Self { + root, + run, + knobs: args.knobs, + leases: BTreeMap::new(), + next_lease: 1, + ready: false, + phase: Phase::Open, + server_stdin: None, + server_pid: None, + update_lock: None, + draining: std::collections::BTreeSet::new(), + drain_until: None, + explicit_root: args.root.is_some(), + } + } + + /// Bind the lease socket, start the server, and serve leases until + /// the books close. + async fn serve(mut self) -> Result { + let listener = bind(&self.run)?; + let (events, mut inbox) = mpsc::unbounded_channel::(); + let connections = TaskTracker::new(); + let mut signals = Vec::new(); + for kind in [ + tokio::signal::unix::SignalKind::terminate(), + tokio::signal::unix::SignalKind::interrupt(), + tokio::signal::unix::SignalKind::hangup(), + ] { + signals.push( + tokio::signal::unix::signal(kind) + .map_err(|e| format!("can't watch for signals: {e}"))?, + ); + } + let (stop_tx, mut stop) = mpsc::unbounded_channel::<()>(); + for mut s in signals { + let stop_tx = stop_tx.clone(); + tokio::spawn(async move { + while s.recv().await.is_some() { + let _ = stop_tx.send(()); + } + }); + } + let started_as = running_identity(); + let mut exe_poll = tokio::time::interval(self.knobs.exe_poll); + exe_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut idle_at = Some(Instant::now() + self.knobs.close_after); + let server_start = tokio::time::sleep(self.knobs.server_delay); + tokio::pin!(server_start); + let mut server_started = false; + loop { + let idle = async { + match idle_at { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } + }; + let drained = async { + match self.drain_until { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } + }; + tokio::select! { + () = &mut server_start, if !server_started => { + server_started = true; + if matches!(self.phase, Phase::Open) + && let Err(why) = self.start_server(events.clone()) + { + // INVARIANT 5: the holders are told why. + eprintln!("wanderbooks host: {why}"); + self.end_leases(Reason::ServerFailed, Some(why)); + self.phase = Phase::Closing(Reason::ServerFailed); + return self.finish(connections, Ending::ServerFailed).await; + } + } + accepted = listener.accept() => match accepted { + Ok((stream, _)) if ledger_mcp::local::peer_is_us(&stream) => { + // The holder's process, as the kernel knows it. + let pid = ledger_mcp::local::peer_pid(&stream); + connections.spawn(connection(stream, pid, events.clone())); + } + Ok(_) => {} + // Out of descriptors and the like: wait, don't spin. + Err(_) => tokio::time::sleep(Duration::from_millis(500)).await, + }, + Some(event) = inbox.recv() => { + match event { + Event::Hold { info, to_holder, answer } => { + if let Phase::Closing(reason) = self.phase { + let _ = to_holder.send(HostMsg::Ending { + // Said as it is: a holder that held before + // stays closed after the owner's close. + reason, + detail: None, + }); + let _ = answer.send(None); + continue; + } + let id = self.next_lease; + self.next_lease += 1; + let _ = to_holder.send(HostMsg::Held { lease: id }); + if self.ready { + let _ = to_holder.send(HostMsg::Ready); + } + eprintln!("wanderbooks host: lease {id} held by {} (pid {})", info.kind.label(), info.pid); + self.leases.insert(id, Held { info: LeaseInfo { id, ..info }, to_holder }); + idle_at = None; + let _ = answer.send(Some(id)); + } + Event::Released(id) => { + if self.leases.remove(&id).is_some() { + eprintln!("wanderbooks host: lease {id} ended"); + } + if self.draining.remove(&id) && self.draining.is_empty() { + self.close_server(); + } + if self.leases.is_empty() && matches!(self.phase, Phase::Open) { + idle_at = Some(Instant::now() + self.knobs.close_after); + } + } + Event::List(answer) => { + // Closing, there are none to list, and a new + // host asking must wait for this one. + let _ = answer.send(match self.phase { + Phase::Open => HostMsg::Leases { + ready: self.ready, + leases: self.leases.values().map(|h| h.info.clone()).collect(), + }, + 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 { + Phase::Closing(Reason::Restarting) => HostMsg::Refused( + "the books are restarting (a new version): close them again in a moment".into(), + ), + _ => HostMsg::Done { + detail: Some("the books are already closing".into()), + }, + }); + } + Event::Intent(intent, answer) => { + let (msg, closes) = self.intent(intent); + let _ = answer.send(msg); + if let Some(reason) = closes { + self.begin_close(reason); + idle_at = None; + if self.server_pid.is_none() { + return self.finish(connections, Ending::Closed).await; + } + } + } + Event::ServerReady => { + self.ready = true; + eprintln!("wanderbooks host: the books are open"); + for held in self.leases.values() { + let _ = held.to_holder.send(HostMsg::Ready); + } + } + Event::ServerExited(status) => { + self.server_pid = None; + self.server_stdin = None; + match self.phase { + Phase::Closing(Reason::Restarting) => { + let lock = self.update_lock.take().expect("taken before restarting"); + return self.finish(connections, Ending::Restart(lock)).await; + } + Phase::Closing(_) => { + return self.finish(connections, Ending::Closed).await; + } + Phase::Open => { + let detail = format!("the books server stopped ({status})"); + eprintln!("wanderbooks host: {detail}"); + self.end_leases(Reason::ServerFailed, Some(detail)); + self.phase = Phase::Closing(Reason::ServerFailed); + return self.finish(connections, Ending::ServerFailed).await; + } + } + } + } + } + () = idle, if matches!(self.phase, Phase::Open) => { + eprintln!("wanderbooks host: nothing holds the books: closing them"); + self.begin_close(Reason::Closing); + idle_at = None; + if self.server_pid.is_none() { + return self.finish(connections, Ending::Closed).await; + } + } + Some(()) = stop.recv() => { + if matches!(self.phase, Phase::Open) { + eprintln!("wanderbooks host: told to stop: closing the books"); + self.begin_close(Reason::ClosedByOwner); + idle_at = None; + if self.server_pid.is_none() { + return self.finish(connections, Ending::Closed).await; + } + } + } + _ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => { + if started_as.is_some() && file_identity().is_some_and(|now| Some(now) != started_as) { + eprintln!("wanderbooks host: a new version is installed: restarting"); + // Taken only if free now: the loop never waits on it + // (a restart still handing over holds it). + let lock = take_update_lock(&self.run); + let lock = match lock { + Ok(lock) => lock, + Err(why) => { + // Not restarting, then: the books stay open + // on this version, and it tries again. + eprintln!("wanderbooks host: can't restart yet: {why}"); + continue; + } + }; + self.update_lock = Some(lock); + self.begin_close(Reason::Restarting); + idle_at = None; + if self.server_pid.is_none() { + let lock = self.update_lock.take().expect("just taken"); + return self.finish(connections, Ending::Restart(lock)).await; + } + } + } + () = drained => self.close_server(), + } + } + } + + /// Start the server, from this host's own executable, watching for + /// its ready line and its exit (INVARIANT 3). + fn start_server(&mut self, events: mpsc::UnboundedSender) -> Result<(), String> { + let log = open_log(&self.run.join(SERVER_LOG)) + .map_err(|e| format!("can't open the server's log: {}", e.kind()))?; + let mut command = tokio::process::Command::new(children_executable()); + command.arg0("wanderbooks").arg("server"); + if self.explicit_root { + command.arg("--root").arg(self.root.path()); + } + let mut child = command + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(log) + .spawn() + .map_err(|e| format!("can't start the books server: {e}"))?; + self.server_stdin = child.stdin.take(); + self.server_pid = child.id(); + let stdout = child.stdout.take().expect("piped"); + let ready = events.clone(); + tokio::spawn(async move { + let mut lines = tokio::io::BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line == ledger_mcp::local::READY { + let _ = ready.send(Event::ServerReady); + } + } + }); + tokio::spawn(async move { + if let Ok(status) = child.wait().await { + let _ = events.send(Event::ServerExited(status)); + } + }); + Ok(()) + } + + /// An owner intent: the answer, and whether it closes the books. + fn intent(&mut self, intent: Intent) -> (HostMsg, Option) { + match intent { + Intent::Close => ( + HostMsg::Done { + detail: Some("the books are closing".into()), + }, + 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("backing up isn't built yet (it comes later in P2)".into()), + None, + ), + Intent::Restore { .. } => ( + HostMsg::Refused("restoring isn't built yet (it comes later in P2)".into()), + None, + ), + Intent::ConsentFormatUpgrade { .. } => ( + HostMsg::Refused("no books are waiting for a format upgrade".into()), + None, + ), + } + } + + /// Tell every lease why it ends, and end it (INVARIANT 5). + fn end_leases(&mut self, reason: Reason, detail: Option) { + for (id, held) in std::mem::take(&mut self.leases) { + self.draining.insert(id); + let _ = held.to_holder.send(HostMsg::Ending { + reason, + detail: detail.clone(), + }); + } + } + + /// Start closing: leases told and ended, new ones turned away, the + /// server's input closed (INVARIANTS 6, 7). + fn begin_close(&mut self, reason: Reason) { + self.phase = Phase::Closing(reason); + self.end_leases(reason, None); + if self.draining.is_empty() { + self.close_server(); + } else { + self.drain_until = Some(Instant::now() + LEASE_DRAIN); + } + } + + /// Close the server's input: it closes the books (INVARIANT 3). + fn close_server(&mut self) { + self.server_stdin = None; + self.drain_until = None; + self.draining.clear(); + } + + /// The last of closing: the connections' last words sent. + async fn finish(self, connections: TaskTracker, ending: Ending) -> Result { + drop(self); + connections.close(); + let _ = tokio::time::timeout(Duration::from_secs(2), connections.wait()).await; + Ok(ending) + } +} + +/// "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 { + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .mode(0o600) + .open(run.join(lease::UPDATE_LOCK)) + .map_err(|e| format!("can't open the update lock: {}", e.kind()))?; + match file.try_lock() { + Ok(()) => Ok(file), + Err(std::fs::TryLockError::WouldBlock) => { + Err("an earlier restart is still handing over".into()) + } + Err(std::fs::TryLockError::Error(e)) => { + Err(format!("can't take the update lock: {}", e.kind())) + } + } +} + +/// Bind run/lease.sock, replacing a stale one (only the host holding the +/// lock gets here). +fn bind(run: &Path) -> Result { + let path = run.join(lease::LEASE_SOCKET); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("can't replace the lease socket: {}", e.kind())), + } + let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET) + .map_err(|e| format!("can't make the lease socket: {e}"))?; + let listener = tokio::net::UnixListener::bind(short.path()) + .map_err(|e| format!("can't make the lease socket: {}", e.kind()))?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("can't make the lease socket private: {}", e.kind()))?; + Ok(listener) +} + +/// One connection: its first line, then a lease or one answer +/// (lease.rs INVARIANT 1). +async fn connection( + stream: tokio::net::UnixStream, + peer_pid: Option, + events: mpsc::UnboundedSender, +) { + let (read, mut write) = stream.into_split(); + let mut reader = tokio::io::BufReader::new(read.take(lease::MAX_LINE as u64 + 1)); + let mut first = String::new(); + let answer = |msg: HostMsg| lease::line(&msg); + let hello = match tokio::time::timeout(HELLO_WAIT, reader.read_line(&mut first)).await { + Ok(Ok(_)) if first.ends_with('\n') => { + lease::parse_hello(&first).map_err(|e| format!("not understood: {e}")) + } + Ok(Ok(_)) => Err("the request is too long, or cut off".into()), + Ok(Err(e)) => Err(format!("couldn't be read: {}", e.kind())), + Err(_) => Err("nothing arrived in time".into()), + }; + let hello = match hello { + Ok(h) if lease::accepts(h.proto) => h, + Ok(_) => { + let _ = write + .write_all( + answer(HostMsg::Refused( + "this program is another version of wanderbooks than the books' host: \ + restart it (for an agent, start a new session)" + .into(), + )) + .as_bytes(), + ) + .await; + return; + } + Err(why) => { + let _ = write + .write_all(answer(HostMsg::Refused(why)).as_bytes()) + .await; + return; + } + }; + match hello.ask { + Ask::Hold { + book: Some(book), .. + } if ledger_store::root::BookId::parse(&book).is_err() => { + let _ = write + .write_all(answer(HostMsg::Refused("that isn't a books' id".into())).as_bytes()) + .await; + } + Ask::Hold { kind, pid, book } => { + let (to_holder, mut from_host) = mpsc::unbounded_channel(); + let (tx, rx) = oneshot::channel(); + let info = LeaseInfo { + id: 0, + kind, + pid: peer_pid.unwrap_or(pid), + book, + }; + if events + .send(Event::Hold { + info, + to_holder, + answer: tx, + }) + .is_err() + { + return; + } + let id = rx.await.ok().flatten(); + let mut sink = [0u8; 256]; + // Past the hello, what a holder sends is only read to see it + // go: no limit on it (lease.rs INVARIANT 6 is for the hello). + let mut inner = reader.into_inner().into_inner(); + let mut ended = false; + loop { + tokio::select! { + msg = from_host.recv() => match msg { + Some(msg) => { + let last = matches!(msg, HostMsg::Ending { .. }); + if write.write_all(answer(msg).as_bytes()).await.is_err() { + break; + } + if last { + ended = true; + break; + } + } + None => break, + }, + read = inner.read(&mut sink), if id.is_some() => match read { + Ok(0) | Err(_) => break, + Ok(_) => {} + }, + } + } + let _ = write.shutdown().await; + // Told why it ends: the lease lasts until the holder lets go + // (it has heard, and sends no more), at most LEASE_DRAIN + // (INVARIANT 6). + if ended && id.is_some() { + let _ = tokio::time::timeout(LEASE_DRAIN, async { + while let Ok(n) = inner.read(&mut sink).await { + if n == 0 { + break; + } + } + }) + .await; + } + if let Some(id) = id { + let _ = events.send(Event::Released(id)); + } + } + Ask::Leases => { + let (tx, rx) = oneshot::channel(); + if events.send(Event::List(tx)).is_ok() + && let Ok(msg) = rx.await + { + let _ = write.write_all(answer(msg).as_bytes()).await; + } + } + Ask::Intent(intent) => { + let msg = match intent.check_passphrase() { + Err(why) => HostMsg::Refused(why), + Ok(()) => { + let (tx, rx) = oneshot::channel(); + if events.send(Event::Intent(intent, tx)).is_err() { + return; + } + match rx.await { + Ok(msg) => msg, + Err(_) => return, + } + } + }; + let _ = write.write_all(answer(msg).as_bytes()).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_logs_limit_is_eight_mebibytes() { + assert_eq!(LOG_LIMIT, 8_388_608); + } + + #[test] + fn a_log_over_its_limit_is_moved_aside_and_one_at_it_kept() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("server.log"); + std::fs::write(&log, "12345").unwrap(); + drop(open_log_within(&log, 5).unwrap()); + assert_eq!( + std::fs::read_to_string(&log).unwrap(), + "12345", + "at the limit: kept" + ); + assert!(!dir.path().join("server.log.1").exists()); + std::fs::write(&log, "123456").unwrap(); + drop(open_log_within(&log, 5).unwrap()); + assert_eq!( + std::fs::read_to_string(dir.path().join("server.log.1")).unwrap(), + "123456" + ); + assert_eq!(std::fs::read_to_string(&log).unwrap(), "", "a new log"); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&log).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn a_folder_the_host_can_write_isnt_a_sandbox() { + let seccomp = std::fs::read_to_string("/proc/self/status") + .unwrap_or_default() + .lines() + .find_map(|l| l.strip_prefix("Seccomp:").map(|m| m.trim() != "0")) + .unwrap_or(false); + let dir = tempfile::tempdir().unwrap(); + if seccomp { + // Tests run under a seccomp filter here (some CI containers): + // the host would say so, whatever the folder. + assert_eq!(confined(dir.path()), Some("a seccomp filter")); + return; + } + assert_eq!(confined(dir.path()), None); + assert_eq!( + std::fs::read_dir(dir.path()).unwrap().count(), + 0, + "its probe went" + ); + // A folder it may not write: a sandbox (unless this is root, who + // may write anywhere). + use std::os::unix::fs::PermissionsExt; + let locked = dir.path().join("locked"); + std::fs::create_dir(&locked).unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).unwrap(); + // SAFETY: geteuid has no preconditions. + if unsafe { libc::geteuid() } != 0 { + assert!(confined(&locked).is_some()); + } + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap(); + // A folder that isn't there: no sign of a sandbox. + assert_eq!(confined(&dir.path().join("gone")), None); + } + + #[test] + fn only_a_program_file_has_an_identity() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("wanderbooks"); + std::fs::write(&file, "x").unwrap(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(identity_of(&file), None, "not one anyone may run"); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o700)).unwrap(); + let first = identity_of(&file).expect("a program"); + // Replaced by another file (as an update does): another identity. + let next = dir.path().join("next"); + std::fs::write(&next, "y").unwrap(); + std::fs::set_permissions(&next, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::rename(&next, &file).unwrap(); + let second = identity_of(&file).unwrap(); + assert_ne!(first, second); + assert_eq!(identity_of(dir.path()), None, "a folder isn't one"); + assert_eq!(identity_of(&dir.path().join("gone")), None); + } + + #[test] + fn a_restart_waits_a_while_for_the_new_host() { + let dir = tempfile::tempdir().unwrap(); + // Nobody answering: waited for, the whole while, then not found. + let started = std::time::Instant::now(); + assert!(!wait_for_a_host(dir.path(), Duration::from_millis(400))); + assert!(started.elapsed() >= Duration::from_millis(400)); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn the_host_lock_is_waited_for_then_taken_or_given_up() { + let dir = tempfile::tempdir().unwrap(); + let run = dir.path(); + // Free: taken at once. + let first = take_host_lock_within(run, Duration::from_millis(100)) + .unwrap() + .expect("a free lock is taken"); + // Held, and nobody answering on the lease socket: waited for until + // the time is up, then not taken. + let started = std::time::Instant::now(); + assert!( + take_host_lock_within(run, Duration::from_millis(400)) + .unwrap() + .is_none() + ); + assert!(started.elapsed() >= Duration::from_millis(400), "it waited"); + assert!(started.elapsed() < Duration::from_secs(5), "and gave up"); + // Let go while another waits: that one takes it. + let releaser = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + drop(first); + }); + let second = take_host_lock_within(run, Duration::from_secs(5)).unwrap(); + assert!(second.is_some(), "taken once let go"); + releaser.join().unwrap(); + } +} diff --git a/crates/ledger-host/src/knobs.rs b/crates/ledger-host/src/knobs.rs new file mode 100644 index 0000000..74c5dc8 --- /dev/null +++ b/crates/ledger-host/src/knobs.rs @@ -0,0 +1,150 @@ +//! The host's and bridge's timings, which tests shorten (`--test-knobs`). +//! +//! Every mode that starts another passes its knobs on, so a test sets +//! them once, on the holder it starts. Nothing else sets them: the +//! defaults are the plan's (3.9). + +use std::time::Duration; + +/// The timings in force. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Knobs { + /// How long the host waits after the last lease ends before it + /// closes the books (plan: 10 s). + pub close_after: Duration, + /// How often the host looks whether its executable was replaced. + pub exe_poll: Duration, + /// The first wait before a holder starts a failed host again; it + /// doubles each time (plan: 1, 2, 4 … 60 s, five tries). + pub backoff: Duration, + /// How long the host waits before starting the server (tests of a + /// slow start only; 0 otherwise). + pub server_delay: Duration, + /// How long the bridge waits for the server before answering + /// `initialize` itself (plan: 5 s). + pub init_wait: Duration, + /// How long the bridge holds a call while the books open (plan: 60 s). + pub queue_wait: Duration, + /// How long a command waits for the books to open. + pub open_wait: Duration, +} + +impl Default for Knobs { + fn default() -> Self { + Self { + close_after: Duration::from_secs(10), + exe_poll: Duration::from_secs(5), + backoff: Duration::from_secs(1), + server_delay: Duration::ZERO, + init_wait: Duration::from_secs(5), + queue_wait: Duration::from_secs(60), + open_wait: Duration::from_secs(120), + } + } +} + +/// The flag that carries them. +pub const FLAG: &str = "--test-knobs"; + +impl Knobs { + /// Read `name=millis,...` (any subset; the rest stay default). + pub fn parse(text: &str) -> Result { + let mut k = Self::default(); + for pair in text.split(',').filter(|p| !p.is_empty()) { + let (name, value) = pair + .split_once('=') + .ok_or_else(|| format!("{FLAG}: {pair:?} isn't name=milliseconds"))?; + let ms: u64 = value + .parse() + .map_err(|_| format!("{FLAG}: {value:?} isn't a number of milliseconds"))?; + let d = Duration::from_millis(ms); + match name { + "close_after" => k.close_after = d, + "exe_poll" => k.exe_poll = d, + "backoff" => k.backoff = d, + "server_delay" => k.server_delay = d, + "init_wait" => k.init_wait = d, + "queue_wait" => k.queue_wait = d, + "open_wait" => k.open_wait = d, + other => return Err(format!("{FLAG}: there is no knob {other:?}")), + } + } + Ok(k) + } + + /// The flag's value that gives these knobs, or `None` for the + /// defaults (then nothing is passed on). + pub fn to_arg(&self) -> Option { + let d = Self::default(); + let mut parts = Vec::new(); + for (name, now, default) in [ + ("close_after", self.close_after, d.close_after), + ("exe_poll", self.exe_poll, d.exe_poll), + ("backoff", self.backoff, d.backoff), + ("server_delay", self.server_delay, d.server_delay), + ("init_wait", self.init_wait, d.init_wait), + ("queue_wait", self.queue_wait, d.queue_wait), + ("open_wait", self.open_wait, d.open_wait), + ] { + if now != default { + parts.push(format!("{name}={}", now.as_millis())); + } + } + (!parts.is_empty()).then(|| parts.join(",")) + } + + /// The arguments that pass these knobs on to another mode. + pub fn args(&self) -> Vec { + match self.to_arg() { + Some(v) => vec![FLAG.to_string(), v], + None => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_defaults_are_the_plans() { + let k = Knobs::default(); + assert_eq!(k.close_after, Duration::from_secs(10)); + assert_eq!(k.backoff, Duration::from_secs(1)); + assert_eq!(k.init_wait, Duration::from_secs(5)); + assert_eq!(k.queue_wait, Duration::from_secs(60)); + assert_eq!(k.exe_poll, Duration::from_secs(5)); + assert_eq!(k.server_delay, Duration::ZERO); + assert_eq!(k.open_wait, Duration::from_secs(120)); + assert_eq!(k.to_arg(), None); + assert!(k.args().is_empty()); + assert_eq!(Knobs::parse("").unwrap(), k); + } + + #[test] + fn knobs_pass_on_exactly() { + let k = Knobs::parse( + "close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70", + ) + .unwrap(); + assert_eq!(k.close_after, Duration::from_millis(500)); + assert_eq!(k.backoff, Duration::from_millis(20)); + assert_eq!(k.server_delay, Duration::from_millis(3000)); + assert_eq!(k.init_wait, Duration::from_millis(100)); + assert_eq!(k.queue_wait, Duration::from_millis(900)); + assert_eq!(k.exe_poll, Duration::from_millis(50)); + assert_eq!(k.open_wait, Duration::from_millis(70)); + let args = k.args(); + assert_eq!(args[0], FLAG); + assert_eq!(Knobs::parse(&args[1]).unwrap(), k); + let one = Knobs::parse("close_after=1").unwrap(); + assert_eq!(one.to_arg().as_deref(), Some("close_after=1")); + } + + #[test] + fn a_bad_knob_is_refused() { + for bad in ["close_after", "close_after=x", "nope=1", "close_after=-1"] { + assert!(Knobs::parse(bad).is_err(), "{bad}"); + } + } +} diff --git a/crates/ledger-host/src/lease.rs b/crates/ledger-host/src/lease.rs new file mode 100644 index 0000000..ed1913a --- /dev/null +++ b/crates/ledger-host/src/lease.rs @@ -0,0 +1,391 @@ +//! The lease socket's messages (`run/lease.sock`, plan 3.9). +//! +//! ```text +//! 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; +//! consent and a replacing restore carry the owner passphrase; +//! "Closing": the host says why on each lease; protocol versions N +//! and N-1). +//! INVARIANTS: +//! 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 staged backup's id, never a path) and +//! consent_format_upgrade; any other request is refused. +//! 3. consent_format_upgrade, and a restore that replaces a book, carry +//! the owner passphrase; without one they are refused before +//! anything is done. +//! 4. Before the host ends a lease it sends the reason; a lease that +//! ends with no reason means the host is gone. +//! 5. Each side accepts its own protocol version and the one before. +//! 6. Lines are at most MAX_LINE bytes. +//! 7. A first line is exactly its version and one ask: anything more +//! (two asks, an unknown key) is refused, never half-read. +//! ``` + +use serde::{Deserialize, Serialize}; + +/// This protocol's version (INVARIANT 5). +pub const PROTO: u32 = 1; +/// The oldest version still accepted (there is none before the first). +pub const OLDEST: u32 = 1; +/// The host's socket, in the root's `run/`. +pub const LEASE_SOCKET: &str = "lease.sock"; +/// Held by the one host for a root. +pub const HOST_LOCK: &str = "host.lock"; +/// Held while the host restarts; openers wait on it. +pub const UPDATE_LOCK: &str = "update.lock"; +/// The longest line either side sends (INVARIANT 6). +pub const MAX_LINE: usize = 64 * 1024; + +/// Whether a message of protocol version `proto` is accepted. +pub fn accepts(proto: u32) -> bool { + (OLDEST..=PROTO).contains(&proto) +} + +/// What kind of holder a lease is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + /// The app (the viewer). + App, + /// An agent session's bridge (`wanderbooks mcp`). + Bridge, + /// A terminal running `wanderbooks serve`. + Serve, + /// A command that needs the server (`wanderbooks books ...`). + Cli, +} + +impl Kind { + /// How a person names it. + pub fn label(self) -> &'static str { + match self { + Kind::App => "the app", + Kind::Bridge => "an agent session", + Kind::Serve => "wanderbooks serve", + Kind::Cli => "a wanderbooks command", + } + } +} + +/// A connection's first line (INVARIANT 1). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Hello { + /// The sender's protocol version. + pub proto: u32, + /// What it asks. + #[serde(flatten)] + pub ask: Ask, +} + +/// What a connection asks. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum Ask { + /// Hold the books open while this connection lasts. + Hold { + /// Who holds them. + kind: Kind, + /// The holder's process. + pid: u32, + /// The book it works on, if one. + #[serde(default)] + book: Option, + }, + /// The leases held now (the app and `wanderbooks doctor` list them). + Leases, + /// One of the owner's intents. + Intent(Intent), +} + +/// The owner's intents: exactly these five (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 staged backup, as its own book or replacing one. + Restore { + /// The staged backup's id (never a path). + staged: String, + /// The book it replaces, if it replaces one. + #[serde(default)] + replace: Option, + /// The owner passphrase: needed to replace (INVARIANT 3). + #[serde(default)] + passphrase: Option, + }, + /// Let the server upgrade a book's journal format. + ConsentFormatUpgrade { + /// Which. + book: String, + /// The owner passphrase (INVARIANT 3). + #[serde(default)] + passphrase: Option, + }, +} + +impl Intent { + /// Refuse an intent that needs the passphrase and has none + /// (INVARIANT 3). + pub fn check_passphrase(&self) -> Result<(), String> { + let missing = |p: &Option| p.as_deref().is_none_or(|p| p.is_empty()); + match self { + Intent::ConsentFormatUpgrade { passphrase, .. } if missing(passphrase) => Err( + "consenting to a format upgrade needs the owner passphrase, typed in the app" + .into(), + ), + Intent::Restore { + replace: Some(_), + passphrase, + .. + } if missing(passphrase) => Err( + "a restore that replaces books needs the owner passphrase, typed in the app".into(), + ), + _ => Ok(()), + } + } +} + +/// Why the host ends a lease (INVARIANT 4). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Reason { + /// The owner closed the books ("Close the books", `wanderbooks + /// close`): reopen only in a new session. + ClosedByOwner, + /// The host is restarting (an update): hold again once it is back. + Restarting, + /// The host is closing because nothing held it, or its restart or + /// start went wrong (never the owner's close, which is + /// `ClosedByOwner`): hold again, and a new host will answer. + Closing, + /// The books server stopped or couldn't start. + ServerFailed, +} + +/// One lease, as listed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LeaseInfo { + /// Its number. + pub id: u64, + /// Who holds it. + pub kind: Kind, + /// The holder's process. + pub pid: u32, + /// The book it works on, if one. + #[serde(default)] + pub book: Option, +} + +/// What the host sends. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum HostMsg { + /// The lease is held; the books are open or opening. + Held { + /// The lease's number. + lease: u64, + }, + /// The books server is ready. + Ready, + /// The lease ends, and why (INVARIANT 4). + Ending { + /// Why. + reason: Reason, + /// More, in plain words (no path). + #[serde(default)] + detail: Option, + }, + /// An intent was carried out. + Done { + /// What came of it, in plain words. + #[serde(default)] + detail: Option, + }, + /// An intent was sent on, and no answer came back: it may or may not + /// have been carried out, in plain words. + Uncertain(String), + /// The leases held now, and whether the books are open yet. + Leases { + /// The books server is ready. + ready: bool, + /// The leases. + leases: Vec, + }, + /// Refused, with why. + Refused(String), +} + +/// A connection's first line, read strictly (INVARIANT 7). +pub fn parse_hello(text: &str) -> Result { + strict_pair(text).and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string())) +} + +/// `text` as a JSON object of exactly two keys, `proto` and one more. +pub(crate) fn strict_pair(text: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(text).map_err(|e| e.to_string())?; + match value.as_object() { + Some(o) if o.len() == 2 && o.contains_key("proto") => Ok(value), + Some(_) => Err("a message is its protocol version and exactly one request".into()), + None => Err("a message is a JSON object".into()), + } +} + +/// One line: the message as JSON and a newline. +pub fn line(message: &T) -> String { + let mut text = serde_json::to_string(message).expect("a lease message is plain data"); + text.push('\n'); + text +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn parse(v: serde_json::Value) -> Result { + serde_json::from_value(v) + } + + #[test] + fn a_hold_names_its_kind_pid_and_book() { + let h = parse(json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}})) + .unwrap(); + assert_eq!( + h.ask, + Ask::Hold { + kind: Kind::Bridge, + pid: 7, + book: Some("acme".into()) + } + ); + assert_eq!( + serde_json::to_value(&h).unwrap(), + json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}}) + ); + } + + #[test] + fn exactly_five_intents_are_understood() { + for intent in [ + json!("close"), + json!({"back_up_now": {"book": "acme"}}), + json!({"new_books": {"id": "acme", "currency": "USD"}}), + json!({"restore": {"staged": "s1"}}), + json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}), + ] { + let h = parse(json!({"proto": 1, "intent": intent})).unwrap(); + assert!(matches!(h.ask, Ask::Intent(_)), "{intent}"); + } + for bad in [ + json!({"proto": 1, "intent": "delete"}), + json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}), + json!({"proto": 1, "intent": {"restore": {"staged": "s1", "path": "/x"}}}), + json!({"proto": 1, "intent": {"run": {"command": "sh"}}}), + json!({"proto": 1, "exec": "sh"}), + json!({"proto": 1}), + ] { + assert!(parse(bad.clone()).is_err(), "{bad}"); + } + } + + #[test] + fn a_hello_is_exactly_its_version_and_one_ask() { + assert!(parse_hello(r#"{"proto":1,"leases":null}"#).is_ok()); + for bad in [ + r#"{"proto":1,"intent":{"back_up_now":{"book":"a"}},"leases":null}"#, + r#"{"proto":1,"leases":null,"extra":1}"#, + r#"{"leases":null,"intent":"close"}"#, + r#"{"proto":1}"#, + r#"[1]"#, + "not json", + ] { + assert!(parse_hello(bad).is_err(), "{bad}"); + } + } + + #[test] + fn consent_and_a_replacing_restore_need_the_passphrase() { + let consent = |p: Option<&str>| Intent::ConsentFormatUpgrade { + book: "acme".into(), + passphrase: p.map(str::to_string), + }; + assert!(consent(None).check_passphrase().is_err()); + assert!(consent(Some("")).check_passphrase().is_err()); + assert!(consent(Some("pw")).check_passphrase().is_ok()); + let restore = |replace: Option<&str>, p: Option<&str>| Intent::Restore { + staged: "s1".into(), + replace: replace.map(str::to_string), + passphrase: p.map(str::to_string), + }; + assert!(restore(None, None).check_passphrase().is_ok()); + assert!(restore(Some("acme"), None).check_passphrase().is_err()); + assert!(restore(Some("acme"), Some("")).check_passphrase().is_err()); + assert!(restore(Some("acme"), Some("pw")).check_passphrase().is_ok()); + for other in [ + Intent::Close, + Intent::BackUpNow { book: "a".into() }, + Intent::NewBooks { + id: "a".into(), + currency: "USD".into(), + }, + ] { + assert!(other.check_passphrase().is_ok()); + } + } + + #[test] + fn versions_this_one_and_the_one_before() { + assert!(accepts(PROTO)); + assert!(!accepts(PROTO + 1)); + assert!(!accepts(0)); + } + + #[test] + fn host_messages_round_trip_as_one_line() { + for m in [ + HostMsg::Held { lease: 3 }, + HostMsg::Ready, + HostMsg::Ending { + reason: Reason::Restarting, + detail: None, + }, + HostMsg::Done { + detail: Some("closed".into()), + }, + HostMsg::Leases { + ready: true, + leases: vec![LeaseInfo { + id: 1, + kind: Kind::Serve, + pid: 9, + book: None, + }], + }, + HostMsg::Refused("no".into()), + HostMsg::Uncertain("maybe".into()), + ] { + let text = line(&m); + assert!(text.ends_with('\n') && !text[..text.len() - 1].contains('\n')); + assert_eq!(serde_json::from_str::(text.trim_end()).unwrap(), m); + } + assert_eq!(Kind::Bridge.label(), "an agent session"); + } +} diff --git a/crates/ledger-host/src/lib.rs b/crates/ledger-host/src/lib.rs new file mode 100644 index 0000000..2a0007e --- /dev/null +++ b/crates/ledger-host/src/lib.rs @@ -0,0 +1,38 @@ +//! The books open while something holds them (plan 3.9, D-21, D-23). +//! +//! Nothing runs in the background: the first holder (the app, an agent's +//! bridge, `wanderbooks serve`, or a command that needs the books) starts +//! 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`). + +#[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; +#[cfg(unix)] +pub mod opener; +#[cfg(unix)] +mod start; + +/// How a mode stops when it can't go on: the reason is already printed, +/// and the number is the process's exit code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Exit(pub i32); + +impl std::fmt::Display for Exit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "exit code {}", self.0) + } +} + +impl std::error::Error for Exit {} diff --git a/crates/ledger-host/src/opener.rs b/crates/ledger-host/src/opener.rs new file mode 100644 index 0000000..3cb7a5e --- /dev/null +++ b/crates/ledger-host/src/opener.rs @@ -0,0 +1,512 @@ +//! Holding the books: how every holder takes its lease (plan 3.9). +//! +//! ```text +//! GOAL: a holder gets its lease on the one host, starting one when none +//! answers, even when several holders start at once; across a +//! restart it simply holds again; a host that keeps failing is +//! given up on, plainly, instead of retried forever. +//! SOURCE: plan/served.md 3.9 ("One host, even in a race", "Closing": +//! backoff 1, 2, 4 … 60 s, at most five tries; "Restarting": +//! openers wait on update.lock); tests host_respawns, +//! respawn_backs_off. +//! 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; +//! 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 +//! died before taking its lock), a host that doesn't answer, and a +//! lease that ends with server_failed or with no reason at all, are +//! failures: the holder tries again after knobs.backoff, doubling +//! each time (at most 60 s), MAX_TRIES times; the failure after that +//! gives up, naming `wanderbooks doctor`. Books that stayed open for +//! STABLE clear the count; a server that fails soon after opening +//! doesn't. +//! 4. The host's end of the socket must be this account. +//! 5. A holder that has held before, turned away because the owner +//! closed the books, stays closed (HoldError::ClosedByOwner); a new +//! holder waits for a new host. +//! ``` + +use crate::knobs::Knobs; +use crate::lease::{self, Ask, Hello, HostMsg, Kind, Reason}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; +use tokio::time::Instant; + +/// How long a started host has to take a lease (longer than a new host +/// waits for an old one's lock). +const START_WAIT: Duration = Duration::from_secs(45); +/// How long a started host has to take its lock: after that, no host +/// holding it means the one started has died. +const LOCK_WAIT: Duration = Duration::from_secs(2); +/// How long the host has to answer a hello. +const ANSWER_WAIT: Duration = Duration::from_secs(10); +/// Tries after a failure before giving up (INVARIANT 3). +pub const MAX_TRIES: u32 = 5; +/// How long the books must stay open for earlier failures to be +/// forgotten (INVARIANT 3). +const STABLE: Duration = Duration::from_secs(60); +/// The longest wait between tries. +const MAX_BACKOFF: Duration = Duration::from_secs(60); + +/// What a holder says when the host keeps failing. +pub const KEEPS_FAILING: &str = + "the books server keeps failing to start: run `wanderbooks doctor` to see why"; + +/// Why no lease was taken. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HoldError { + /// The owner closed the books, and this holder had held them + /// (INVARIANT 5). + ClosedByOwner, + /// Anything else, in plain words. + Failed(String), +} + +impl std::fmt::Display for HoldError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HoldError::ClosedByOwner => write!(f, "the owner closed the books"), + HoldError::Failed(why) => f.write_str(why), + } + } +} + +impl From for HoldError { + fn from(why: String) -> Self { + HoldError::Failed(why) + } +} + +impl From<&str> for HoldError { + fn from(why: &str) -> Self { + HoldError::Failed(why.to_string()) + } +} + +/// 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), +} + +/// Takes leases for one holder. +pub struct Opener { + root: Option, + run: PathBuf, + knobs: Knobs, + starter: Starter, + kind: Kind, + book: Option, + failures: u32, + /// When the books last opened on a lease. + opened_at: Option, + /// Whether this holder has held a lease (INVARIANT 5). + has_held: bool, +} + +/// A held lease: the books stay open while it lives. +pub struct Lease { + /// Its number. + pub id: u64, + lines: tokio::io::Lines>>, + _write: tokio::net::unix::OwnedWriteHalf, +} + +impl Lease { + /// The host's next word: `Ready`, or `Ending` with why; `None` when + /// the host is gone without a word. + pub async fn next(&mut self) -> Option { + loop { + match self.lines.next_line().await { + Ok(Some(line)) => match serde_json::from_str::(&line) { + Ok(msg) => return Some(msg), + Err(_) => continue, + }, + _ => return None, + } + } + } +} + +impl Opener { + /// An opener for the root (`root`, else the one this account's host + /// finds), holding as `kind` for `book`. + pub fn new( + root: Option<&Path>, + knobs: Knobs, + starter: Starter, + kind: Kind, + book: Option, + ) -> Result { + let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?; + let run = found.run_dir().map_err(|e| e.to_string())?; + Ok(Self { + root: root.map(|_| found.path().to_path_buf()), + run, + knobs, + starter, + kind, + book, + failures: 0, + opened_at: None, + has_held: false, + }) + } + + /// The timings in force. + pub fn knobs(&self) -> Knobs { + self.knobs + } + + /// The root's `run/` folder. + pub fn run_dir(&self) -> &Path { + &self.run + } + + /// The books opened on a lease (INVARIANT 3). + pub fn opened(&mut self) { + self.opened_at = Some(std::time::Instant::now()); + } + + /// Count a failure and wait before the next try, or give up + /// (INVARIANT 3). + pub async fn failed(&mut self) -> Result<(), String> { + if self + .opened_at + .take() + .is_some_and(|at| at.elapsed() >= STABLE) + { + self.failures = 0; + } + self.failures += 1; + if self.failures > MAX_TRIES { + return Err(KEEPS_FAILING.into()); + } + tokio::time::sleep(backoff(self.knobs.backoff, self.failures)).await; + Ok(()) + } + + /// Take a lease, starting a host if none answers (INVARIANTS 1-4). + pub async fn hold(&mut self) -> Result { + let mut started: Option = None; + loop { + wait_for_restart(&self.run).await; + let short = ledger_store::root::SocketPath::new(&self.run, lease::LEASE_SOCKET) + .map_err(|e| format!("can't reach the books' host: {e}"))?; + match tokio::net::UnixStream::connect(short.path()).await { + Ok(stream) => { + if !ledger_mcp::local::peer_is_us(&stream) { + return Err( + "the books' host socket belongs to another account: not used".into(), + ); + } + match self.ask(stream).await? { + Asked::Held(lease) => { + self.has_held = true; + return Ok(lease); + } + Asked::OwnerClosed if self.has_held => { + return Err(HoldError::ClosedByOwner); + } + Asked::OwnerClosed => { + tokio::time::sleep(Duration::from_millis(100)).await; + } + Asked::Again => tokio::time::sleep(Duration::from_millis(100)).await, + Asked::Failed => { + started = None; + self.failed().await?; + } + } + } + // No host answering, or one going away as we came. + Err(e) if no_host(&e) => { + // A host holding the lock is starting or closing: wait + // 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). + None if !held => match self.start().await { + Ok(()) => started = Some(Instant::now()), + Err(_) => self.failed().await?, + }, + None => {} + // The one started died (no host holds the lock), + // or never answered. + Some(at) + if (!held && at.elapsed() > LOCK_WAIT) || at.elapsed() > START_WAIT => + { + started = None; + self.failed().await?; + } + Some(_) => {} + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(e) => { + return Err(format!("can't reach the books' host: {}", e.kind()).into()); + } + } + } + } + + /// Start a host, through this holder's starter. + 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())? + } + } + } + + /// Say hello on a connection and read the host's first word. + async fn ask(&self, stream: tokio::net::UnixStream) -> Result { + let (read, mut write) = stream.into_split(); + let hello = Hello { + proto: lease::PROTO, + ask: Ask::Hold { + kind: self.kind, + pid: std::process::id(), + book: self.book.clone(), + }, + }; + if write + .write_all(lease::line(&hello).as_bytes()) + .await + .is_err() + { + return Ok(Asked::Again); + } + let mut lines = tokio::io::BufReader::new(read.take(lease::MAX_LINE as u64 * 4)).lines(); + let first = match tokio::time::timeout(ANSWER_WAIT, lines.next_line()).await { + Ok(Ok(Some(line))) => serde_json::from_str::(&line) + .map_err(|e| format!("the books' host answered oddly: {e}"))?, + // Gone mid-hello (closing, or a crash): try again. + Ok(_) => return Ok(Asked::Again), + // Not answering: a failure, tried again after a wait. + Err(_) => return Ok(Asked::Failed), + }; + Ok(match first { + HostMsg::Held { lease } => Asked::Held(Lease { + id: lease, + lines, + _write: write, + }), + HostMsg::Ending { + reason: Reason::ServerFailed, + .. + } => Asked::Failed, + HostMsg::Ending { + reason: Reason::ClosedByOwner, + .. + } => Asked::OwnerClosed, + HostMsg::Ending { .. } => Asked::Again, + HostMsg::Refused(why) => return Err(why), + other => return Err(format!("the books' host answered oddly: {other:?}")), + }) + } +} + +/// What a hello came to. +enum Asked { + Held(Lease), + Again, + Failed, + /// Turned away: the owner is closing the books (INVARIANT 5). + OwnerClosed, +} + +/// The wait before try `n` (1-based): `base`, doubling, at most 60 s. +pub fn backoff(base: Duration, n: u32) -> Duration { + let factor = 1u32 << n.saturating_sub(1).min(16); + base.saturating_mul(factor).min(MAX_BACKOFF) +} + +/// Whether a connect error means no host is answering (none there, or +/// one closing as we came), rather than something wrong. +fn no_host(e: &std::io::Error) -> bool { + matches!( + e.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::BrokenPipe + ) +} + +/// Whether a books server holds run/server.lock now (one finishing after +/// its host is gone included). +pub fn server_lock_held(run: &Path) -> bool { + match std::fs::File::open(run.join(ledger_mcp::local::SERVER_LOCK)) { + Ok(file) => matches!( + file.try_lock_shared(), + Err(std::fs::TryLockError::WouldBlock) + ), + Err(_) => false, + } +} + +/// Whether a host holds run/host.lock now (starting, serving or closing). +pub fn host_lock_held(run: &Path) -> bool { + match std::fs::File::open(run.join(lease::HOST_LOCK)) { + Ok(file) => matches!( + file.try_lock_shared(), + Err(std::fs::TryLockError::WouldBlock) + ), + Err(_) => false, + } +} + +/// Wait while a restart holds run/update.lock (INVARIANT 1). +async fn wait_for_restart(run: &Path) { + // Looked at, not waited on in a thread: a holder that gives up while + // a restart runs is never stuck behind it. + let path = run.join(lease::UPDATE_LOCK); + loop { + // No lock file: no restart has ever happened, so none is on. + let restarting = std::fs::File::open(&path) + .is_ok_and(|f| matches!(f.try_lock_shared(), Err(std::fs::TryLockError::WouldBlock))); + if !restarting { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// Ask the host one thing on its own connection (leases, an intent): +/// `Ok(None)` when no host is running. +pub fn ask_once(run: &Path, ask: Ask) -> Result, String> { + ask_once_within(run, ask, Duration::from_secs(120)) +} + +/// [`ask_once`], waiting at most `wait` for the answer (a probe). +pub fn ask_once_within(run: &Path, ask: Ask, wait: Duration) -> Result, String> { + use std::io::{BufRead, Write}; + let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET) + .map_err(|e| format!("can't reach the books' host: {e}"))?; + let stream = match std::os::unix::net::UnixStream::connect(short.path()) { + Ok(s) => s, + Err(e) if no_host(&e) => return Ok(None), + Err(e) => return Err(format!("can't reach the books' host: {}", e.kind())), + }; + if !ledger_mcp::local::peer_is_us(&stream) { + return Err("the books' host socket belongs to another account: not used".into()); + } + stream + .set_read_timeout(Some(wait)) + .map_err(|e| e.to_string())?; + let not_answering = |e: std::io::Error| match e.kind() { + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => { + "the books' host isn't answering".to_string() + } + kind => format!("the books' host didn't answer: {kind}"), + }; + (&stream) + .write_all( + lease::line(&Hello { + proto: lease::PROTO, + ask, + }) + .as_bytes(), + ) + .map_err(|e| format!("can't reach the books' host: {}", e.kind()))?; + let mut answer = String::new(); + std::io::BufReader::new(std::io::Read::take(&stream, lease::MAX_LINE as u64 * 4)) + .read_line(&mut answer) + .map_err(not_answering)?; + if answer.is_empty() { + return Ok(None); + } + serde_json::from_str(answer.trim_end()) + .map(Some) + .map_err(|e| format!("the books' host answered oddly: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_wait_doubles_from_the_base_up_to_a_minute() { + let s = Duration::from_secs; + let waits: Vec = (1..=7).map(|n| backoff(s(1), n)).collect(); + assert_eq!(waits, [s(1), s(2), s(4), s(8), s(16), s(32), s(60)]); + assert_eq!(backoff(s(1), 0), s(1)); + assert_eq!(backoff(s(1), 400), s(60)); + assert_eq!( + backoff(Duration::from_millis(10), 3), + Duration::from_millis(40) + ); + } + + #[tokio::test] + async fn after_five_failures_in_a_row_it_gives_up() { + let dir = tempfile::tempdir().unwrap(); + let mut o = Opener { + root: None, + run: dir.path().to_path_buf(), + knobs: Knobs { + backoff: Duration::from_millis(1), + ..Knobs::default() + }, + starter: Starter::Direct, + kind: Kind::Cli, + book: None, + failures: 0, + opened_at: None, + has_held: false, + }; + for _ in 0..MAX_TRIES { + o.failed().await.unwrap(); + } + assert_eq!(o.failed().await.unwrap_err(), KEEPS_FAILING); + // Opening briefly doesn't clear the count; staying open does. + o.opened(); + assert!(o.failed().await.is_err(), "a brief open cleared the count"); + o.opened_at = Some(std::time::Instant::now() - STABLE); + assert!(o.failed().await.is_ok(), "staying open clears the count"); + assert_eq!(o.failures, 1); + } + + #[test] + fn a_host_going_away_is_no_host() { + use std::io::{Error, ErrorKind}; + for kind in [ + ErrorKind::NotFound, + ErrorKind::ConnectionRefused, + ErrorKind::ConnectionReset, + ErrorKind::ConnectionAborted, + ErrorKind::BrokenPipe, + ] { + assert!(no_host(&Error::from(kind)), "{kind:?}"); + } + for kind in [ErrorKind::PermissionDenied, ErrorKind::InvalidInput] { + assert!(!no_host(&Error::from(kind)), "{kind:?}"); + } + } + + #[test] + fn no_host_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(ask_once(dir.path(), Ask::Leases).unwrap(), None); + } +} diff --git a/crates/ledger-host/src/start.rs b/crates/ledger-host/src/start.rs new file mode 100644 index 0000000..b15b6a1 --- /dev/null +++ b/crates/ledger-host/src/start.rs @@ -0,0 +1,378 @@ +//! Starting the host cleanly (plan 3.9, "Starting the host cleanly"). +//! +//! ```text +//! GOAL: the host never inherits anything from whoever opened the books: +//! not their environment, descriptors, session, umask, or the app's +//! scope (which would take the host down with the app). +//! SOURCE: plan/served.md 3.9; test host_starts_clean, host_survives_app_scope. +//! INVARIANTS: +//! 1. The host's environment is a fixed list: PATH (fixed), HOME (from +//! the account's record, never the opener's), and XDG_RUNTIME_DIR +//! and DBUS_SESSION_BUS_ADDRESS only when they point at this +//! account's own private runtime folder. Nothing else passes (a +//! scope's own identifiers, such as INVOCATION_ID, are systemd's). +//! 2. Its standard input and output are null, its errors go to +//! run/host.log (0600); it starts a new session with umask 077. Every +//! other descriptor is closed (std opens them close-on-exec, and the +//! host closes any stray one at start). +//! 3. Under a systemd app scope it starts through a transient +//! `systemd-run --user --scope` (nothing installed or kept); if the +//! scope couldn't be made (systemd-run failed without the host ever +//! writing its first line), or there is no such scope, directly. A +//! host that ran and stopped is never started a second time. +//! 4. It runs this executable's file as it is on disk now. +//! 5. The opener doesn't wait for it, and reaps it when it ends. +//! ``` + +use crate::knobs::Knobs; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// The host's PATH (INVARIANT 1). +const PATH: &str = "/usr/local/bin:/usr/bin:/bin"; + +/// Where systemd-run may be (never looked up on the opener's PATH). +const SYSTEMD_RUN: [&str; 2] = ["/usr/bin/systemd-run", "/bin/systemd-run"]; + +/// The host's log, in the root's `run/`. +pub const HOST_LOG: &str = "host.log"; +/// A log larger than this is moved aside when a host starts. +const LOG_LIMIT: u64 = 8 * 1024 * 1024; + +/// This executable's file as it is on disk now (INVARIANT 4): on Linux a +/// replaced file reads as "… (deleted)"; the path, not the old file, is +/// what a new host runs. +pub fn this_executable() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?; + let text = exe.to_string_lossy(); + if let Some(path) = text.strip_suffix(" (deleted)") + && !exe.exists() + { + return Ok(PathBuf::from(path)); + } + Ok(exe) +} + +/// This account's user ID. +fn own_uid() -> u32 { + // SAFETY: geteuid has no preconditions and can't fail. + unsafe { libc::geteuid() } +} + +/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private +/// runtime folder: absolute, a real folder (no link), owned by this +/// account, and closed to everyone else. +pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option { + // Written plainly (no trailing slash, no `.`), as a bus address + // names it. + let dir: PathBuf = PathBuf::from(value?).components().collect(); + if !dir.is_absolute() { + return None; + } + let meta = std::fs::symlink_metadata(&dir).ok()?; + (meta.is_dir() && meta.uid() == own_uid() && meta.mode() & 0o077 == 0).then_some(dir) +} + +/// `DBUS_SESSION_BUS_ADDRESS` from `value`, if it is the bus in `runtime` +/// (`unix:path=/bus`, perhaps with a guid after a comma). +pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option { + let value = value?.to_str()?; + let want = format!("unix:path={}/bus", runtime.display()); + let rest = value.strip_prefix(&want)?; + (rest.is_empty() || rest.starts_with(',')).then(|| value.to_string()) +} + +/// This account's runtime folder: the one named in the environment if it +/// is ours and private; else, when the opener's environment has none (a +/// client that scrubs it, as Codex does), the system's for this account, +/// `/run/user/`, under the same checks. +fn runtime_dir() -> Option { + // The one named, if it passes; else (none named, or one that isn't + // ours or private) the system's for this account. + std::env::var_os("XDG_RUNTIME_DIR") + .and_then(|named| own_runtime_dir(Some(&named))) + .or_else(|| own_runtime_dir(Some(format!("/run/user/{}", own_uid()).as_ref()))) +} + +/// The session bus in `runtime`: the one named in the environment if it +/// is there, else its usual socket there, if one exists. +fn session_bus(runtime: &Path) -> Option { + match std::env::var_os("DBUS_SESSION_BUS_ADDRESS") { + Some(named) => own_bus(Some(&named), runtime), + None => { + use std::os::unix::fs::FileTypeExt; + let socket = runtime.join("bus"); + std::fs::symlink_metadata(&socket) + .is_ok_and(|m| m.file_type().is_socket() && m.uid() == own_uid()) + .then(|| format!("unix:path={}", socket.display())) + } + } +} + +/// The host's environment (INVARIANT 1). +fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> { + let mut env = vec![ + ("PATH".to_string(), PATH.into()), + ("HOME".to_string(), home.as_os_str().to_os_string()), + ]; + if let Some(runtime) = runtime_dir() { + if let Some(bus) = session_bus(&runtime) { + env.push(("DBUS_SESSION_BUS_ADDRESS".into(), bus.into())); + } + env.push(("XDG_RUNTIME_DIR".into(), runtime.into_os_string())); + } + env +} + +/// Whether this process sits in a unit under this account's user manager +/// (an app's scope or service, as uwsm makes for every app), whose stop +/// would take the host with it; going by the unified cgroup line of +/// `/proc/self/cgroup`. The manager's own `init.scope` is not such a +/// unit. +pub fn in_app_scope(cgroup: &str, uid: u32) -> bool { + let manager = format!("/user@{uid}.service/"); + cgroup.lines().any(|line| { + line.strip_prefix("0::").is_some_and(|path| { + path.split_once(manager.as_str()) + .is_some_and(|(_, unit)| !unit.is_empty() && unit != "init.scope") + }) + }) +} + +/// systemd-run, if this process is in an app scope and the user manager +/// can be reached (INVARIANT 3). +fn systemd_run() -> Option<&'static str> { + let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?; + if !in_app_scope(&cgroup, own_uid()) { + return None; + } + // systemd-run reaches the user manager through the runtime folder, + // which host_env gives it even when the opener's environment didn't. + runtime_dir()?; + SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file()) +} + +/// Start a host for the root (`root`, else the one the host finds +/// itself), with these knobs (INVARIANTS 1-5). +pub fn start_host(root: Option<&Path>, knobs: &Knobs) -> Result<(), String> { + let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?; + let run = found.run_dir().map_err(|e| e.to_string())?; + let home = ledger_store::root::account_home().map_err(|e| e.to_string())?; + let exe = this_executable()?; + let mut args: Vec = vec!["host".into()]; + // An explicit root goes on as found (absolute): the host starts in /. + if root.is_some() { + args.push("--root".into()); + args.push(found.path().as_os_str().to_os_string()); + } + args.extend(knobs.args().into_iter().map(Into::into)); + let log = || { + let path = run.join(HOST_LOG); + // A large log is moved aside first (to `.1`), never from under a + // host writing it. + if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) + && !crate::opener::host_lock_held(&run) + { + let _ = std::fs::rename(&path, path.with_extension("log.1")); + } + std::fs::OpenOptions::new() + .create(true) + .read(true) + .append(true) + .mode(0o600) + .open(&path) + .map_err(|e| format!("can't open the host's log: {}", e.kind())) + }; + // Through systemd-run (`Some`), or directly. + let command = |through: Option<&str>, log: std::fs::File| -> Result { + let mut c = match through { + Some(systemd_run) => { + let mut c = Command::new(systemd_run); + c.args(["--user", "--scope", "--quiet", "--collect", "--"]) + .arg(&exe); + c + } + None => Command::new(&exe), + }; + c.args(&args) + .env_clear() + .envs(host_env(&home)) + .current_dir("/") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log); + // SAFETY: setsid and umask are async-signal-safe and touch only + // the child. + unsafe { + c.pre_exec(|| { + libc::setsid(); + libc::umask(0o077); + Ok(()) + }); + } + Ok(c) + }; + if let Some(systemd_run) = systemd_run() { + // The log as this start opened it, and where it stands now: what + // the host writes after this is read back from that same file, + // even if another opener moves the log aside meanwhile. + let file = log()?; + let from = file.metadata().map_or(0, |m| m.len()); + let watch = file + .try_clone() + .map_err(|e| format!("can't open the host's log: {}", e.kind()))?; + if let Ok(mut child) = command(Some(systemd_run), file)?.spawn() + && scope_made(&mut child, &watch, from) + { + reap(child); + return Ok(()); + } + } + let child = command(None, log()?)? + .spawn() + .map_err(|e| format!("can't start the books' host: {e}"))?; + reap(child); + Ok(()) +} + +/// How long systemd-run has to make its scope and run the host. +const SCOPE_WAIT: Duration = Duration::from_secs(2); + +/// Whether systemd-run made its scope and ran the host (INVARIANT 3): the +/// host's first line is in its log after `from`, or systemd-run is still +/// running when SCOPE_WAIT is up (it became the host). Only systemd-run +/// failing with no host line means it didn't: then the host is started +/// directly. (systemd-run's own complaints go to the same log, so the +/// log growing proves nothing; the host's line does.) +fn scope_made(child: &mut std::process::Child, log: &std::fs::File, from: u64) -> bool { + let host_ran = || { + use std::os::unix::fs::FileExt; + let len = log.metadata().map_or(0, |m| m.len()); + let mut tail = vec![0u8; len.saturating_sub(from).min(64 * 1024) as usize]; + log.read_exact_at(&mut tail, from).is_ok() + && String::from_utf8_lossy(&tail).contains(crate::host::STARTED) + }; + let deadline = Instant::now() + SCOPE_WAIT; + loop { + if host_ran() { + return true; + } + match child.try_wait() { + Ok(Some(status)) => return status.success() || host_ran(), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + _ => return true, + } + } +} + +/// Reap a started host when it ends, without waiting for it (INVARIANT 5). +fn reap(mut child: std::process::Child) { + let _ = std::thread::Builder::new() + .name("reap-host".into()) + .spawn(move || { + let _ = child.wait(); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn an_app_scope_is_known_by_its_unified_cgroup() { + let uwsm = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-Hyprland-kitty-1234.scope\n"; + assert!(in_app_scope(uwsm, 1000)); + assert!(!in_app_scope(uwsm, 1001), "another account's manager"); + // An app run as a service unit (uwsm's app-…@….service) too. + assert!(in_app_scope( + "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-foo@x.service\n", + 1000 + )); + for not in [ + "0::/user.slice/user-1000.slice/session-2.scope\n", + "0::/user.slice/user-1000.slice/user@1000.service/init.scope\n", + "0::/user.slice/user-1000.slice/user@1000.service/\n", + "0::/\n", + "1:name=systemd:/user.slice/user-1000.slice/user@1000.service/app.slice/x.scope\n", + "", + ] { + assert!(!in_app_scope(not, 1000), "{not}"); + } + } + + #[test] + fn only_our_own_private_runtime_folder_passes() { + let dir = tempfile::tempdir().unwrap(); + let rt = dir.path().join("rt"); + std::fs::create_dir(&rt).unwrap(); + std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(own_runtime_dir(Some(rt.as_os_str())), Some(rt.clone())); + std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o750)).unwrap(); + assert_eq!( + own_runtime_dir(Some(rt.as_os_str())), + None, + "group can enter" + ); + std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap(); + let link = dir.path().join("link"); + std::os::unix::fs::symlink(&rt, &link).unwrap(); + assert_eq!(own_runtime_dir(Some(link.as_os_str())), None, "a link"); + assert_eq!(own_runtime_dir(Some("relative/rt".as_ref())), None); + assert_eq!( + own_runtime_dir(Some(dir.path().join("none").as_os_str())), + None + ); + assert_eq!(own_runtime_dir(None), None); + // Owned by root, not us. + if own_uid() != 0 { + assert_eq!(own_runtime_dir(Some("/".as_ref())), None); + } + } + + #[test] + fn only_the_bus_in_our_runtime_folder_passes() { + let rt = Path::new("/run/user/1000"); + assert_eq!( + own_bus(Some("unix:path=/run/user/1000/bus".as_ref()), rt).as_deref(), + Some("unix:path=/run/user/1000/bus") + ); + assert!(own_bus(Some("unix:path=/run/user/1000/bus,guid=ab".as_ref()), rt).is_some()); + for bad in [ + "unix:path=/tmp/evil/bus", + "unix:path=/run/user/1000/bus2", + "unix:path=/run/user/1000/busx,guid=1", + "tcp:host=evil.example,port=1", + "unix:abstract=/run/user/1000/bus", + ] { + assert_eq!(own_bus(Some(bad.as_ref()), rt), None, "{bad}"); + } + assert_eq!(own_bus(None, rt), None); + } + + #[test] + fn the_host_environment_carries_nothing_else() { + let env = host_env(Path::new("/home/someone")); + let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(&names[..2], ["PATH", "HOME"]); + for n in &names[2..] { + assert!( + ["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].contains(n), + "{n}" + ); + } + assert_eq!(env[0].1, PATH); + assert_eq!(env[1].1, "/home/someone"); + } + + #[test] + fn this_executable_is_a_file_on_disk() { + let exe = this_executable().unwrap(); + assert!(exe.is_file(), "{exe:?}"); + } +} diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index ac6808b..f0ed7f6 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -24,13 +24,15 @@ mime_guess = "2" rust_decimal = "1" rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] } axum = "0.8" +http-body-util = "0.1" tower = "0.5" schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = "0.4" thiserror = "2" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] } +tokio-util = { version = "0.7", features = ["rt"] } zip = "9.0.1" toml = "1" # The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped diff --git a/crates/ledger-mcp/src/admin.rs b/crates/ledger-mcp/src/admin.rs new file mode 100644 index 0000000..76560ce --- /dev/null +++ b/crates/ledger-mcp/src/admin.rs @@ -0,0 +1,369 @@ +//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.9): how the +//! command line asks the books server to list, create and adopt books. +//! +//! ```text +//! GOAL: the command line makes and adopts books without ever touching +//! the data root itself: the server, its sole writer, does it. +//! SOURCE: plan/served.md 3.1 (the CLI "talks to run/admin.sock ... it can +//! create or adopt books"), 5.5 (adoption), 3.9 (protocol versions +//! N and N-1). +//! INVARIANTS: +//! 1. One request per connection, one JSON line each way, at most +//! MAX_LINE bytes; anything else is refused with a plain reason. +//! 2. A request carries its protocol version; the server accepts this +//! version and the one before, and refuses others with a message +//! that says to restart. +//! 3. No answer names the data root or a path in it, nor echoes a path +//! the caller gave (which could be the root under another name). +//! 4. Adopting is two steps: without `confirmed` the answer is a +//! preview, and nothing is written; `confirmed: true` adopts, and +//! only books still at the head the preview read (`expect_head`). +//! 5. Nothing in a settings file is dropped silently: entries the books +//! don't know are refused unless `skip_unknown`, and its notes and +//! comments are in the preview. +//! ``` + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// This protocol's version (INVARIANT 2). +pub const PROTO: u32 = 1; + +/// The oldest version this side still accepts (INVARIANT 2: this one and +/// the one before; there is none before the first). +pub const OLDEST: u32 = 1; + +/// Whether a request of protocol version `proto` is accepted. +pub fn accepts(proto: u32) -> bool { + (OLDEST..=PROTO).contains(&proto) +} + +/// The longest line either side sends, its newline not counted +/// (INVARIANT 1): settings texts ride in an adopt request. +pub const MAX_LINE: usize = 4 * 1024 * 1024; + +/// A request, with its protocol version. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Envelope { + /// The sender's protocol version. + pub proto: u32, + /// What is asked. + #[serde(flatten)] + pub request: Request, +} + +/// What the command line can ask. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +pub enum Request { + /// The books the server holds. + List, + /// Start new, empty books. + Create { + /// The new books' id. + id: String, + /// Their base currency (ISO 4217), fixed for good. + currency: String, + }, + /// Adopt existing books (plan 5.5). + Adopt { + /// The new books' id. + id: String, + /// The data dir to adopt (`journal.jsonl`, `blobs/`). + source: PathBuf, + /// The settings file's text, for books that keep no settings. + #[serde(default)] + settings_toml: Option, + /// `config.json`'s text, for its receipt policy. + #[serde(default)] + config_json: Option, + /// The source's TIN vault folder. + #[serde(default)] + vault_dir: Option, + /// The v1 vault key file. + #[serde(default)] + vault_key: Option, + /// Adopt (true), or only preview what would be moved in. + #[serde(default)] + confirmed: bool, + /// The head the preview read: adopted only if still so. + #[serde(default)] + expect_head: Option<(u64, String)>, + /// Leave out settings-file entries the books don't know. + #[serde(default)] + skip_unknown: bool, + }, +} + +/// One book, as listed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookStatus { + /// Its id. + pub id: String, + /// Whether it is open and served. + pub open: bool, + /// Why it couldn't be opened, if it couldn't. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub problem: Option, +} + +/// A setting an adoption would move in. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SettingChange { + /// Its key. + pub key: String, + /// Its value, as JSON (`None`: the setting is cleared). + pub value: Option, +} + +/// What an unconfirmed adoption would do (INVARIANT 4). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdoptPreview { + /// The source's last event, `(seq, hash)`, as read now. + pub source_head: (u64, String), + /// The source already keeps its own settings: none would be moved in. + pub keeps_settings: bool, + /// The settings that would be moved in. + pub settings: Vec, + /// What the settings file says that isn't a books setting. + pub notes: Vec, + /// Entries in it the books don't know (not moved in). + pub unknown: Vec, + /// The settings file's comments, in order (they aren't settings: keep + /// what matters as a convention or a decision). + pub comments: Vec, + /// The vault's records, each checked to open with the key given. + pub vault_records: usize, +} + +/// What an adoption did. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Adopted { + /// The new books' id. + pub id: String, + /// The source's head, verified before copying. + pub source_head: (u64, String), + /// The adopted books' head. + pub head: (u64, String), + /// Stored files copied. + pub blob_files: u64, + /// Stored files the books name, re-hashed through the store. + pub blobs_verified: u64, + /// Stored files copied that the books don't name. + pub unnamed_blobs: u64, + /// Files left behind among the stored ones (unfinished writes, hidden + /// files). + pub skipped_files: u64, + /// Vault records copied. + pub vault_files: u64, + /// Whether the v1 key was copied. + pub key_copied: bool, + /// Settings moved in. + pub settings_imported: usize, + /// Settings were given, but the books keep their own. + pub settings_ignored: bool, + /// The source had no lock file, so one was made beside its journal. + pub source_lock_created: bool, + /// Why the adopted books didn't open, if they didn't (they are kept; + /// closing the books and opening them again tries again). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub problem: Option, +} + +/// The server's answer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub enum Response { + /// The books held. + Books(Vec), + /// New books made. + Created { + /// Their id. + id: String, + /// Why they didn't open, if they didn't (they are kept). + #[serde(default, skip_serializing_if = "Option::is_none")] + problem: Option, + }, + /// What an adoption would do. + Preview(AdoptPreview), + /// Books adopted and open. + Adopted(Adopted), + /// Refused, with why (no path in the data root: INVARIANT 3). + Refused(String), +} + +/// Why an admin call has no answer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CallError { + /// The request never reached the server: nothing was done. + NotSent(String), + /// It was sent, and no answer came: it may or may not have been done. + Unknown(String), +} + +impl std::fmt::Display for CallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CallError::NotSent(why) | CallError::Unknown(why) => f.write_str(why), + } + } +} + +/// Ask the server on `socket` (the root's `run/admin.sock`), waiting as +/// long as it takes (a create or an adoption). +#[cfg(unix)] +pub fn call(socket: &std::path::Path, request: Request) -> Result { + call_within(socket, request, None) +} + +/// [`call`], waiting at most `wait` for the answer (a quick look, such +/// as `doctor`'s). +#[cfg(unix)] +pub fn call_within( + socket: &std::path::Path, + request: Request, + wait: Option, +) -> Result { + use std::io::{BufRead, Write}; + let not_sent = |why: String| CallError::NotSent(why); + // A list changes nothing: its only question is whether it answered. + let reads_only = matches!(request, Request::List); + let unknown = move |why: String| { + if reads_only { + CallError::Unknown(why) + } else { + CallError::Unknown(format!( + "{why}, so it may or may not have been done: `wanderbooks books list` shows the books there are" + )) + } + }; + let quiet = |e: std::io::Error| match e.kind() { + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => { + "the books server isn't answering".to_string() + } + kind => format!("the books server didn't answer ({kind})"), + }; + let (dir, name) = ( + socket.parent().unwrap_or(std::path::Path::new(".")), + socket + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(), + ); + let short = ledger_store::root::SocketPath::new(dir, name) + .map_err(|e| not_sent(format!("can't reach the books server: {e}")))?; + let stream = + std::os::unix::net::UnixStream::connect(short.path()).map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => { + not_sent("the books server isn't running".to_string()) + } + _ => not_sent(format!("can't reach the books server: {}", e.kind())), + })?; + crate::local::check_peer_std(&stream).map_err(not_sent)?; + stream + .set_read_timeout(wait) + .map_err(|e| not_sent(e.to_string()))?; + let mut line = serde_json::to_string(&Envelope { + proto: PROTO, + request, + }) + .map_err(|e| not_sent(e.to_string()))?; + line.push('\n'); + // A write cut part way may still have reached the server whole. + (&stream).write_all(line.as_bytes()).map_err(|e| { + unknown(format!( + "the books server stopped taking the request ({})", + e.kind() + )) + })?; + let mut answer = String::new(); + std::io::BufReader::new(std::io::Read::take(&stream, MAX_LINE as u64 + 1)) + .read_line(&mut answer) + .map_err(|e| unknown(quiet(e)))?; + if answer.is_empty() { + return Err(unknown("the books server stopped before answering".into())); + } + serde_json::from_str(answer.trim_end()) + .map_err(|e| unknown(format!("the books server's answer isn't understood ({e})"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn requests_are_one_flat_object_with_their_version() { + let line = serde_json::to_value(Envelope { + proto: PROTO, + request: Request::Create { + id: "acme".into(), + currency: "USD".into(), + }, + }) + .unwrap(); + assert_eq!( + line, + json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD"}) + ); + let back: Envelope = serde_json::from_value(line).unwrap(); + assert_eq!(back.proto, 1); + let list: Envelope = serde_json::from_value(json!({"proto": 1, "op": "list"})).unwrap(); + assert_eq!(list.request, Request::List); + } + + #[test] + fn an_adopt_request_is_a_preview_unless_confirmed() { + let r: Envelope = serde_json::from_value( + json!({"proto": 1, "op": "adopt", "id": "acme", "source": "/x/books-data"}), + ) + .unwrap(); + match r.request { + Request::Adopt { + confirmed, + settings_toml, + vault_dir, + expect_head, + skip_unknown, + .. + } => { + assert!(!confirmed && !skip_unknown); + assert!(settings_toml.is_none() && vault_dir.is_none() && expect_head.is_none()); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn unknown_operations_and_fields_are_refused() { + for bad in [ + json!({"proto": 1, "op": "delete", "id": "acme"}), + json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD", "path": "/x"}), + json!({"proto": 1}), + ] { + assert!( + serde_json::from_value::(bad.clone()).is_err(), + "{bad}" + ); + } + } + + #[test] + fn answers_round_trip() { + for r in [ + Response::Books(vec![BookStatus { + id: "acme".into(), + open: false, + problem: Some("damaged".into()), + }]), + Response::Created { + id: "acme".into(), + problem: None, + }, + Response::Refused("no".into()), + ] { + let text = serde_json::to_string(&r).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), r); + } + } +} diff --git a/crates/ledger-mcp/src/lib.rs b/crates/ledger-mcp/src/lib.rs index 3fd3b5d..d0a5c6d 100644 --- a/crates/ledger-mcp/src/lib.rs +++ b/crates/ledger-mcp/src/lib.rs @@ -18,8 +18,12 @@ //! `/`, the home folder, or holds the books, the vault or a key is refused //! at startup. +pub mod admin; mod handbook; +#[cfg(unix)] +pub mod local; mod local_files; +pub mod opening; mod policy; mod server; mod settings; @@ -34,6 +38,7 @@ use policy::ReceiptPolicy; use rmcp::ServiceExt; use serde::Deserialize; use server::BooksServer; +pub use server::{NOT_RUN_CLOSING, NOT_RUN_CLOSING_CODE}; use std::path::PathBuf; /// Server configuration, JSON. All fields optional. @@ -138,6 +143,14 @@ pub fn run( } } } + // Books in the data root are the books server's alone (plan 3.1): + // a second writer beside it is refused before anything is opened. + if ledger_store::root::inside_a_root(&data_dir) { + eprintln!( + "ledger-mcp: those books are in the books' private folder, which only the books server opens: connect to them with `wanderbooks mcp --book `" + ); + return Err(Box::new(Exit(2))); + } let import_roots = if import_roots.is_empty() { ImportRoots::defaults() } else { @@ -221,7 +234,12 @@ pub fn run( // vault or a key (local_files.rs INVARIANT 0): a named one stops the // server here, a default one is dropped. let settings_now = live.lock().unwrap_or_else(|e| e.into_inner()).current(); - let import_roots = match server::guard_import_roots(&store, &settings_now, import_roots) { + let import_roots = match server::guard_import_roots( + &store, + &settings_now, + &server::Layout::BooksDir, + import_roots, + ) { Ok(roots) => roots, Err(why) => { eprintln!("ledger-mcp: {why}"); diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs new file mode 100644 index 0000000..e6a3fea --- /dev/null +++ b/crates/ledger-mcp/src/local.rs @@ -0,0 +1,1122 @@ +//! `wanderbooks server`: every book in the data root, served on local +//! sockets (plan 3.1, 3.9). The host starts it; nothing else should. +//! +//! ```text +//! GOAL: one process, the books' sole writer, serves every book in the +//! data root to this account alone, over sockets in the root's +//! private `run/` folder, and lets go of every book cleanly. +//! SOURCE: plan/served.md 3.1 (the server's sockets; the admin socket +//! creates and adopts books and can't read them), 3.4 (data +//! layout, run/books.lock), 3.9 (local channels, peer checks, +//! closing: "no new requests, the one in progress finishes, every +//! writer lock is released"), D-21 and D-23 (no service: the host +//! starts and stops this process). +//! INVARIANTS: +//! 1. One server per root: it holds `run/server.lock` from before it +//! opens anything until it exits; a second server waits for the lock +//! (an old server still finishing after its host died), touching +//! nothing, and exits if told to close first. The sockets are +//! replaced only under that lock. +//! 2. Only this account: a connection to either socket whose peer is +//! another user ID is dropped before a byte is read. +//! 3. Every book is opened and verified (rebuilding a drifted database) +//! before the server says it is ready; one that won't open is +//! reported with why, on its route and in the list, and the others +//! are served. +//! 4. Each book has one store, shared by every session: one writer. +//! 5. The ready line (stdout, once) comes only after both sockets are +//! bound and every book has been tried. +//! 6. Closing (stdin reaches its end, or SIGTERM, SIGINT or SIGHUP): new +//! requests are refused, every request in progress finishes (admin +//! ones included), then the sessions end, the sockets are removed, +//! and the process exits 0, releasing every lock. +//! 7. Nothing this process sends a client names the data root. +//! ``` + +use crate::admin::{self, Request, Response}; +use crate::local_files::ImportRoots; +use crate::policy::ReceiptPolicy; +use crate::server::{BooksServer, Layout, SharedStore}; +use crate::settings::Live; +use ledger_store::Store; +use ledger_store::root::{BookId, Root, RootError}; +use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, +}; +use std::collections::BTreeMap; +use std::os::fd::AsRawFd; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; + +/// The server's MCP socket, in the root's `run/`. +pub const MCP_SOCKET: &str = "mcp.sock"; +/// The server's admin socket, in the root's `run/`. +pub const ADMIN_SOCKET: &str = "admin.sock"; +/// Held by the one server for a root (INVARIANT 1). +pub const SERVER_LOCK: &str = "server.lock"; +/// What the server prints on stdout when it is ready (INVARIANT 5). +pub const READY: &str = "ready"; + +/// How large a request may be. Receipts still ride inside tool calls as +/// base64 until uploads have their own route (P2 slice 5, which brings +/// this down to 1 MiB). +pub const MAX_BODY: usize = 64 * 1024 * 1024; + +/// How long the admin socket waits for a request to arrive. +const ADMIN_READ_TIMEOUT: Duration = Duration::from_secs(30); + +use ledger_store::root::THE_ROOT; + +/// The header on a request refused before it could run (books that +/// didn't open, books that don't exist): a client knows it didn't run. +pub const REFUSED_HEADER: &str = "x-wanderbooks-refused"; + +type Service = StreamableHttpService; + +/// A book the server holds. +#[derive(Clone)] +enum Book { + /// Open and served. + Open { + /// Its MCP service. + service: Service, + /// Its store, closed when the server closes (INVARIANT 6). + store: Arc, + }, + /// Not opened, with why (INVARIANT 3). + Failed(String), +} + +impl Book { + /// Why it isn't open, if it isn't. + fn problem(&self) -> Option { + match self { + Book::Open { .. } => None, + Book::Failed(why) => Some(why.clone()), + } + } +} + +/// Every book the server holds, and what serving them needs. +struct Books { + root: Root, + import_roots: ImportRoots, + held: RwLock>, + /// Ends every MCP session (INVARIANT 6, last). + sessions: CancellationToken, + /// Set when the server starts closing: new requests are refused. + closing: CancellationToken, + /// MCP requests in progress (not counting event streams). + in_flight: AtomicUsize, + /// When the last MCP request arrived (milliseconds since the server + /// started): closing lingers until none has for a while. + last_arrival: std::sync::atomic::AtomicU64, + /// When the server started, for `last_arrival`. + started: std::time::Instant, + /// Woken when one finishes. + finished: tokio::sync::Notify, +} + +impl Books { + /// Take the root's path out of a message (INVARIANT 7). + fn hide(&self, text: impl Into) -> String { + let mut text = text.into(); + let path = self.root.path(); + let real = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + for p in [path, real.as_path()] { + text = text.replace(&p.display().to_string(), THE_ROOT); + } + text + } + + /// Open the book `id` from disk: served, or failed with why. + fn open(&self, id: &BookId) -> Book { + let opened = match Store::open(&self.root.book_dir(id)) { + Ok(store) => self.serve(store), + Err(ledger_store::StoreError::Locked(_)) => { + Err("another program has these books open".to_string()) + } + Err(e) => Err(self.hide(e.to_string())), + }; + opened.unwrap_or_else(|why| { + eprintln!("wanderbooks server: the books {id} were not opened: {why}"); + Book::Failed(why) + }) + } + + /// Verify an open store and build its service (INVARIANTS 3, 4). + fn serve(&self, mut store: Store) -> Result { + let report = store + .verify_and_heal() + .map_err(|e| format!("they don't verify: {}", self.hide(e.to_string())))?; + if let Some(why) = &report.healed { + eprintln!( + "wanderbooks server: rebuilt a database from its journal ({})", + self.hide(why.clone()) + ); + } + let mut live = Live::own(); + live.sync(store.ledger().settings(), store.base_currency().code()); + let layout = Layout::Root(self.root.path().to_path_buf()); + let roots = crate::server::guard_import_roots( + &store, + &live.current(), + &layout, + self.import_roots.clone(), + ) + .map_err(|e| self.hide(e))?; + let live = Arc::new(Mutex::new(live)); + let store = SharedStore::new(store); + let shared = store.clone(); + let config = StreamableHttpServerConfig::default() + .with_json_response(true) + .with_legacy_session_mode(true) + .with_max_request_body_bytes(MAX_BODY) + .enforce_origin_validation() + .with_cancellation_token(self.sessions.child_token()); + let service = StreamableHttpService::new( + move || { + Ok( + BooksServer::new_shared(store.clone(), ReceiptPolicy::default()) + .with_live_settings(live.clone()) + .with_import_roots(roots.clone()) + .with_layout(layout.clone()), + ) + }, + Arc::new(sessions()), + config, + ); + Ok(Book::Open { + service, + store: shared, + }) + } + + /// Every open book's store. + fn stores(&self) -> Vec> { + self.held() + .values() + .filter_map(|b| match b { + Book::Open { store, .. } => Some(store.clone()), + Book::Failed(_) => None, + }) + .collect() + } + + fn held(&self) -> std::sync::RwLockReadGuard<'_, BTreeMap> { + self.held.read().unwrap_or_else(|e| e.into_inner()) + } + + fn hold(&self, id: &BookId, book: Book) { + self.held + .write() + .unwrap_or_else(|e| e.into_inner()) + .insert(id.to_string(), book); + } + + /// Answer an admin request (blocking: it runs on its own thread). + fn admin(&self, request: Request) -> Response { + let refused = |e: RootError| Response::Refused(self.hide(e.to_string())); + match request { + Request::List => Response::Books( + self.held() + .iter() + .map(|(id, book)| admin::BookStatus { + id: id.clone(), + open: matches!(book, Book::Open { .. }), + problem: match book { + Book::Open { .. } => None, + Book::Failed(why) => Some(why.clone()), + }, + }) + .collect(), + ), + Request::Create { id, currency } => { + let id = match BookId::parse(&id) { + Ok(id) => id, + Err(e) => return refused(e), + }; + let Ok(currency) = ledger_core::Currency::new(currency.trim()) else { + return Response::Refused( + "that isn't a currency: give its three-letter ISO code, such as USD".into(), + ); + }; + if self.held().contains_key(id.as_str()) { + return refused(RootError::Exists(id.to_string())); + } + match self.root.create_book(&id, currency) { + Ok(store) => { + // Made: kept whether or not it opens (INVARIANT 3). + let book = self.serve(store).unwrap_or_else(Book::Failed); + let problem = book.problem(); + self.hold(&id, book); + Response::Created { + id: id.to_string(), + problem, + } + } + Err(e) => refused(e), + } + } + Request::Adopt { + id, + source, + settings_toml, + config_json, + vault_dir, + vault_key, + confirmed, + expect_head, + skip_unknown, + } => { + let id = match BookId::parse(&id) { + Ok(id) => id, + Err(e) => return refused(e), + }; + if self.held().contains_key(id.as_str()) { + return refused(RootError::Exists(id.to_string())); + } + let found = match self.root.adopt_preview(&source) { + Ok(found) => found, + Err(e) => return refused(e), + }; + if vault_dir.is_some() != vault_key.is_some() { + return Response::Refused( + "a vault comes with its key: give both the vault folder and its key file, or neither" + .into(), + ); + } + if confirmed && expect_head.is_none() { + // admin INVARIANT 4: a confirm names what it confirms. + return Response::Refused( + "an adoption is confirmed against the preview's head: preview first".into(), + ); + } + let import = match (&settings_toml, found.keeps_settings) { + (Some(toml), false) => { + // The key the file names matters only with a vault + // to open. + let key = vault_dir.as_ref().and(vault_key.as_deref()); + match settings_to_move( + toml, + config_json.as_deref(), + found.currency.code(), + vault_dir.is_some(), + key, + ) { + Ok(import) => Some(import), + Err(why) => return Response::Refused(why), + } + } + (None, false) => { + return Response::Refused( + "these books keep no settings of their own yet: give their settings file (--toml), and its settings are moved in as they are adopted" + .into(), + ); + } + _ => None, + }; + let vault_records = match (&vault_dir, &vault_key) { + (Some(dir), Some(key)) => match vault_opens(dir, key) { + Ok(n) => n, + Err(why) => return Response::Refused(why), + }, + _ => 0, + }; + if !confirmed { + let i = import.unwrap_or_default(); + return Response::Preview(admin::AdoptPreview { + source_head: found.head, + keeps_settings: found.keeps_settings, + settings: i + .changes + .iter() + .map(|c| admin::SettingChange { + key: c.key.clone(), + value: c.value.clone(), + }) + .collect(), + notes: i.notes, + unknown: i.unknown, + comments: i.comments, + vault_records, + }); + } + // admin INVARIANT 5: nothing dropped unseen. + if let Some(i) = &import + && !i.unknown.is_empty() + && !skip_unknown + { + return Response::Refused(format!( + "the settings file has entries the books don't know, which wouldn't move in: {}. Take them out, or adopt with --skip-unknown to leave them behind", + i.unknown.join(", ") + )); + } + let options = ledger_store::root::AdoptOptions { + settings: match import { + Some(i) => { + let source = moved_in_at_adoption(&i.changes); + Some((i.changes, source)) + } + // Given for books that keep their own: passed on so + // the report says they weren't used. + None if settings_toml.is_some() => { + Some((Vec::new(), moved_in_at_adoption(&[]))) + } + None => None, + }, + vault_dir, + vault_key, + expect_head, + }; + match self.root.adopt(&source, &id, &options) { + Ok(r) => { + // Adopted: kept whether or not it opens. + let book = self.open(&id); + let problem = book.problem(); + self.hold(&id, book); + Response::Adopted(admin::Adopted { + id: r.id.to_string(), + source_head: r.source_head, + head: r.head, + blob_files: r.blob_files, + blobs_verified: r.blobs_verified, + unnamed_blobs: r.unnamed_blobs, + skipped_files: r.skipped_files, + vault_files: r.vault_files, + key_copied: r.key_copied, + settings_imported: r.settings_imported, + settings_ignored: r.settings_ignored, + source_lock_created: r.source_lock_created, + problem, + }) + } + Err(e) => refused(e), + } + } + } + } +} + +/// The settings a settings file moves in at adoption, checked as +/// `books.import_settings` checks them: they parse, they apply, and the +/// vault key file it names (which the books don't keep) is the one given. +fn settings_to_move( + toml: &str, + config_json: Option<&str>, + currency: &str, + with_vault: bool, + vault_key: Option<&Path>, +) -> Result { + let import = ledger_config::codec::import( + toml, + config_json, + &ledger_core::settings::Settings::default(), + currency, + ) + .map_err(|e| format!("the settings file can't be moved in: {e}"))?; + let mut after = ledger_core::settings::Settings::default(); + after + .apply( + &import.changes, + &moved_in_at_adoption(&import.changes), + u64::MAX, + ) + .map_err(|e| format!("the settings file can't be moved in: {e}"))?; + if let Some(named) = import.vault_key_file.as_ref().filter(|_| with_vault) { + let same = vault_key.is_some_and(|given| { + match (std::fs::canonicalize(named), std::fs::canonicalize(given)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + }); + if !same { + return Err( + "the settings file names the TIN vault's key file ([vault] key_file), which the books don't keep: adopt with --vault-key naming that same file, or the vault wouldn't open" + .into(), + ); + } + } + Ok(import) +} + +/// Check that `key` opens every record in the vault folder `dir`; how +/// many there are. +fn vault_opens(dir: &Path, key: &Path) -> Result { + let vault = ledger_vault::Vault::open(dir, key, false).map_err(|_| { + "the vault key given isn't one this vault can use (64 hex digits): nothing was adopted" + .to_string() + })?; + let entries = std::fs::read_dir(dir) + .map_err(|e| format!("the vault folder given can't be read: {}", e.kind()))?; + let mut records = 0; + for entry in entries { + let entry = entry.map_err(|e| format!("the vault folder can't be read: {}", e.kind()))?; + let name = entry.file_name().to_string_lossy().into_owned(); + let Some(stem) = name.strip_suffix(".bin") else { + continue; + }; + let opened = match stem.split_once('-') { + Some(("tin", n)) => n.parse().ok().map(|n| vault.tin(n).map(|_| ())), + Some(("w9", n)) => n.parse().ok().map(|n| vault.w9(n).map(|_| ())), + _ => None, + }; + match opened { + Some(Ok(())) => records += 1, + Some(Err(_)) => { + return Err( + "the vault key given doesn't open the vault's records: give the key they were sealed with; nothing was adopted" + .into(), + ); + } + None => {} + } + } + Ok(records) +} + +/// How long a session may go without a message before it ends: far +/// longer than any call (rmcp's idle timer doesn't count a call in +/// progress, so a call longer than it would lose its answer while its work +/// went on). It only bounds sessions whose client vanished without ending +/// them; the bridge ends its own, even when signalled. +const SESSION_IDLE: Duration = Duration::from_secs(24 * 60 * 60); + +/// The book's MCP sessions (see SESSION_IDLE). +fn sessions() -> LocalSessionManager { + let mut sessions = LocalSessionManager::default(); + sessions.session_config.keep_alive = Some(SESSION_IDLE); + sessions +} + +/// Who and when, for settings moved in from a file at adoption: dated in +/// the company's time zone when the file sets one. +fn moved_in_at_adoption( + changes: &[ledger_core::settings::Change], +) -> ledger_core::settings::Source { + let zone = changes + .iter() + .find(|c| c.key == "company.timezone") + .and_then(|c| c.value.as_ref()) + .and_then(|v| v.as_str()) + .map(str::to_string); + ledger_core::settings::Source { + decided_by: "the settings file".into(), + decided_on: ledger_core::settings::today_in(zone.as_deref()), + citation: None, + note: Some( + "moved in by the owner when the books were adopted (wanderbooks books adopt)".into(), + ), + } +} + +/// The user ID at the other end of a Unix socket. +pub fn peer_uid(fd: &impl AsRawFd) -> std::io::Result { + let fd = fd.as_raw_fd(); + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let mut cred = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `cred` and `len` are valid for writes of the sizes given. + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut cred as *mut libc::ucred).cast(), + &mut len, + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()); + } + if len as usize != std::mem::size_of::() { + return Err(std::io::Error::other( + "the peer's credentials came back short", + )); + } + Ok(cred.uid) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + let mut uid: libc::uid_t = 0; + let mut gid: libc::gid_t = 0; + // SAFETY: `uid` and `gid` are valid for writes. + if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(uid) + } +} + +/// The process at the other end of a Unix socket, where the system says +/// (Linux); `None` elsewhere or if it can't be read. +pub fn peer_pid(fd: &impl AsRawFd) -> Option { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let mut cred = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `cred` and `len` are valid for writes of the sizes given. + let rc = unsafe { + libc::getsockopt( + fd.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut cred as *mut libc::ucred).cast(), + &mut len, + ) + }; + (rc == 0 && len as usize == std::mem::size_of::() && cred.pid > 0) + .then_some(cred.pid as u32) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + let _ = fd; + None + } +} + +/// This process's user ID. +fn own_uid() -> u32 { + // SAFETY: geteuid has no preconditions and can't fail. + unsafe { libc::geteuid() } +} + +/// Whether the other end of a socket is this account (INVARIANT 2). +pub fn peer_is_us(fd: &impl AsRawFd) -> bool { + peer_uid(fd).is_ok_and(|uid| uid == own_uid()) +} + +/// The client's check of the server (plan 3.9: both ends check). +pub(crate) fn check_peer_std(stream: &std::os::unix::net::UnixStream) -> Result<(), String> { + if peer_is_us(stream) { + Ok(()) + } else { + Err("the books server's socket belongs to another account: not used".into()) + } +} + +/// A Unix listener that hands on only this account's connections +/// (INVARIANT 2). +struct OwnerOnly(tokio::net::UnixListener); + +impl OwnerOnly { + async fn next(&mut self) -> tokio::net::UnixStream { + loop { + match self.0.accept().await { + Ok((stream, _)) if peer_is_us(&stream) => return stream, + Ok((stream, _)) => { + eprintln!("wanderbooks server: refused a connection from another account"); + drop(stream); + } + Err(e) => { + // Out of descriptors and the like: wait, as axum does, + // rather than spin. A peer that hung up is no matter. + if !matches!( + e.kind(), + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionRefused + ) { + eprintln!("wanderbooks server: accept failed: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + } + } +} + +impl axum::serve::Listener for OwnerOnly { + type Io = tokio::net::UnixStream; + type Addr = tokio::net::unix::SocketAddr; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + loop { + let stream = self.next().await; + if let Ok(addr) = stream.peer_addr() { + return (stream, addr); + } + } + } + + fn local_addr(&self) -> std::io::Result { + self.0.local_addr() + } +} + +/// Counts an MCP request in progress, from its arrival until its answer's +/// body is dropped (sent whole, or its client gone): it rides in that +/// body, which a session fills later. Whether a tool is still running is +/// counted by its store (SharedStore::close), not here. +struct InFlight(Arc); + +impl InFlight { + fn new(books: &Arc) -> Self { + books.in_flight.fetch_add(1, Ordering::SeqCst); + Self(books.clone()) + } +} + +impl Drop for InFlight { + fn drop(&mut self) { + self.0.in_flight.fetch_sub(1, Ordering::SeqCst); + self.0.finished.notify_waiters(); + } +} + +/// `/b/{book}/mcp`: the book's MCP service. +async fn mcp( + axum::extract::State(books): axum::extract::State>, + axum::extract::Path(book): axum::extract::Path, + request: axum::extract::Request, +) -> axum::response::Response { + use axum::response::IntoResponse; + use tower::ServiceExt; + // Counted before the closing check, so closing never misses it. + let counted = (request.method() != axum::http::Method::GET).then(|| InFlight::new(&books)); + books + .last_arrival + .store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst); + if books.closing.is_cancelled() { + return ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "the books are closing: this request wasn't run", + ) + .into_response(); + } + let found = books.held().get(&book).cloned(); + match found { + Some(Book::Open { service, .. }) => match service.oneshot(request).await { + Ok(response) => response.map(|body| { + use http_body_util::BodyExt; + axum::body::Body::new(body.map_frame(move |frame| { + let _held = &counted; + frame + })) + }), + Err(never) => match never {}, + }, + Some(Book::Failed(why)) => ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + [(REFUSED_HEADER, "not-open")], + format!("the books {book} didn't open: {why}"), + ) + .into_response(), + None => ( + axum::http::StatusCode::NOT_FOUND, + [(REFUSED_HEADER, "no-such-books")], + format!( + "there are no books called {:?}: `wanderbooks books list` lists them", + book.chars().take(64).collect::() + ), + ) + .into_response(), + } +} + +/// Anything but `/b/{book}/mcp`. +async fn not_found() -> (axum::http::StatusCode, &'static str) { + (axum::http::StatusCode::NOT_FOUND, "not found") +} + +/// One admin connection: one request, one answer (admin INVARIANT 1). +async fn serve_admin(stream: tokio::net::UnixStream, books: Arc) { + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; + let (read, mut write) = stream.into_split(); + let mut reader = tokio::io::BufReader::new(read.take(admin::MAX_LINE as u64 + 1)); + let mut line = String::new(); + // A request still arriving when closing begins is turned away. + let read = tokio::select! { + read = tokio::time::timeout(ADMIN_READ_TIMEOUT, reader.read_line(&mut line)) => Some(read), + () = books.closing.cancelled() => None, + }; + let answer = match read { + None => Response::Refused("the books are closing: nothing was done".into()), + Some(Err(_)) => Response::Refused("no request arrived in time".into()), + Some(Ok(Err(e))) => { + Response::Refused(format!("the request couldn't be read: {}", e.kind())) + } + Some(Ok(Ok(_))) if !line.ends_with('\n') => { + Response::Refused("the request is too long, or cut off".into()) + } + Some(Ok(Ok(_))) => match serde_json::from_str::(&line) { + Err(e) => Response::Refused(format!("the request isn't understood: {e}")), + Ok(envelope) if !admin::accepts(envelope.proto) => Response::Refused( + "this command is from another version of wanderbooks than the books server: \ + close the books (`wanderbooks close`) and try again" + .into(), + ), + Ok(_) if books.closing.is_cancelled() => { + Response::Refused("the books are closing: nothing was done".into()) + } + Ok(envelope) => { + let books = books.clone(); + tokio::task::spawn_blocking(move || books.admin(envelope.request)) + .await + .unwrap_or_else(|_| { + Response::Refused( + "the books server hit an internal error doing that".into(), + ) + }) + } + }, + }; + let mut text = serde_json::to_string(&answer).unwrap_or_else(|_| { + r#"{"refused":"the books server couldn't write its answer"}"#.to_string() + }); + text.push('\n'); + let _ = write.write_all(text.as_bytes()).await; + let _ = write.shutdown().await; +} + +/// Bind a socket in `run/`, replacing a stale one (INVARIANT 1: only the +/// server holding the lock gets here). +fn bind(run: &Path, name: &str) -> std::io::Result { + let path = run.join(name); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + let short = ledger_store::root::SocketPath::new(run, name)?; + let listener = tokio::net::UnixListener::bind(short.path())?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + Ok(listener) +} + +fn print_usage(prog: &str) { + eprintln!("usage: {prog} [--root DIR] [--import-root DIR]..."); + eprintln!(" The books server for the data root: started by the host, not by hand."); + eprintln!(" It runs until its standard input ends, or SIGTERM, SIGINT or SIGHUP."); +} + +/// `wanderbooks server`: serve every book in the data root until told to +/// close (INVARIANT 6). +pub fn run( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut root: Option = None; + let mut import_roots: Vec = Vec::new(); + 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")?)), + "--import-root" => import_roots.push(PathBuf::from( + args.next().ok_or("--import-root needs a folder")?, + )), + "--help" | "-h" => { + print_usage(prog); + return Ok(()); + } + other => { + eprintln!("unknown argument: {other}"); + print_usage(prog); + return Err(Box::new(crate::Exit(2))); + } + } + } + let root = match Root::find(root.as_deref()) { + Ok(root) => root, + Err(e) => { + eprintln!("wanderbooks server: {e}"); + return Err(Box::new(crate::Exit(2))); + } + }; + let run = root.run_dir().map_err(|e| e.to_string())?; + // INVARIANT 1: the lock first, before anything else is opened. + let lock_path = run.join(SERVER_LOCK); + let lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .map_err(|e| format!("can't open the server lock: {}", e.kind()))?; + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + // INVARIANT 6: told to close at any point (a long wait or rebuild + // included), the server closes cleanly as soon as it can. + let stops = Stops::default(); + { + let _in_runtime = rt.enter(); + watch_for_close(&stops)?; + } + // INVARIANT 1: a server still finishing (its host gone) has the lock; + // this one waits for it, touching nothing, until told to close. + let mut said = false; + loop { + match lock.try_lock() { + Ok(()) => break, + Err(std::fs::TryLockError::WouldBlock) => { + if stops.close.is_cancelled() { + return Ok(()); + } + if !said { + eprintln!( + "wanderbooks server: another books server has these books open; waiting for it to finish" + ); + said = true; + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(std::fs::TryLockError::Error(e)) => { + return Err(format!("can't take the server lock: {}", e.kind()).into()); + } + } + } + { + // Only once it is ours: a refused server touches nothing. + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("can't make the server lock private: {}", e.kind()))?; + } + let import_roots = if import_roots.is_empty() { + ImportRoots::defaults() + } else { + ImportRoots::new(import_roots) + }; + let books = Arc::new(Books { + root: root.clone(), + import_roots, + held: RwLock::new(BTreeMap::new()), + sessions: CancellationToken::new(), + closing: CancellationToken::new(), + in_flight: AtomicUsize::new(0), + finished: tokio::sync::Notify::new(), + last_arrival: std::sync::atomic::AtomicU64::new(0), + started: std::time::Instant::now(), + }); + // INVARIANT 3: every book tried before ready. + for id in root.list_books().map_err(|e| e.to_string())? { + let book = books.open(&id); + books.hold(&id, book); + } + let served = if stops.close.is_cancelled() { + eprintln!("wanderbooks server: told to close while opening: closed"); + Ok(()) + } else { + rt.block_on(serve(books.clone(), &run, &stops)) + }; + // No request is running now (serve waited for each); the stores go + // before the lock, and the process exit would release both. + books + .held + .write() + .unwrap_or_else(|e| e.into_inner()) + .clear(); + rt.shutdown_timeout(Duration::from_secs(1)); + for name in [MCP_SOCKET, ADMIN_SOCKET] { + let _ = std::fs::remove_file(run.join(name)); + } + drop(lock); + served +} + +/// How long, once every running request has finished, the server still +/// waits for answers on their way out (or requests still arriving). +const ANSWER_GRACE: Duration = Duration::from_secs(10); +/// How long the door stays open, refusing, after the last request came. +const LINGER: Duration = Duration::from_millis(500); +/// The longest it stays open so. +const LINGER_MAX: Duration = Duration::from_secs(3); +/// How long the HTTP side then has to end before it is dropped. +const HTTP_GRACE: Duration = Duration::from_secs(5); + +/// Being told to close: once (`close`), and again (`hurry`: stop waiting +/// for answers on their way out; never for a request still running). +#[derive(Default)] +struct Stops { + close: CancellationToken, + hurry: CancellationToken, +} + +/// Bind, say ready, serve, and close (INVARIANTS 5, 6). +async fn serve( + books: Arc, + run: &Path, + stops: &Stops, +) -> Result<(), Box> { + let mcp_listener = OwnerOnly(bind(run, MCP_SOCKET)?); + let mut admin_listener = OwnerOnly(bind(run, ADMIN_SOCKET)?); + let stop = stops.close.clone(); + { + use std::io::Write; + let mut out = std::io::stdout().lock(); + writeln!(out, "{READY}")?; + out.flush()?; + } + let held: Vec = books.held().keys().cloned().collect(); + eprintln!("wanderbooks server: ready, holding {} books", held.len()); + + let app = axum::Router::new() + .route("/b/{book}/mcp", axum::routing::any(mcp)) + .fallback(not_found) + .with_state(books.clone()); + // The HTTP side keeps answering (refusing, once closing) until the + // close is done: a client is never cut off mid-request by it. + let shut = CancellationToken::new(); + let graceful = shut.clone(); + let mut http = tokio::spawn(async move { + axum::serve(mcp_listener, app) + .with_graceful_shutdown(async move { graceful.cancelled().await }) + .await + }); + let admin_jobs = TaskTracker::new(); + loop { + tokio::select! { + _ = stop.cancelled() => break, + stream = admin_listener.next() => { + admin_jobs.spawn(serve_admin(stream, books.clone())); + } + } + } + drop(admin_listener); + eprintln!("wanderbooks server: closing"); + // INVARIANT 6. New requests are refused from here on. + books.closing.cancel(); + // Admin work under way (a create, an adoption) finishes. + admin_jobs.close(); + admin_jobs.wait().await; + // Every request already running finishes its work, however long it + // takes and whether or not its client is still there: a store is + // never let go of mid-write (SharedStore INVARIANT 3). + for store in books.stores() { + store.close().await; + } + // Answers on their way out are sent, for a while: a client sending + // a request it never finishes doesn't hold the books open. + let answers = async { + loop { + let notified = books.finished.notified(); + if books.in_flight.load(Ordering::SeqCst) == 0 { + break; + } + notified.await; + } + }; + tokio::select! { + () = answers => {} + () = tokio::time::sleep(ANSWER_GRACE) => { + eprintln!("wanderbooks server: a client didn't finish its request; closing without it"); + } + () = stops.hurry.cancelled() => {} + } + books.sessions.cancel(); + // A call that set out just before its client heard of the close still + // meets a refusal, never a connection cut mid-way (which it couldn't + // tell from a call that ran): the door stays open, refusing, until + // none has come for LINGER. + let linger = async { + let deadline = tokio::time::Instant::now() + LINGER_MAX; + loop { + let since = (books.started.elapsed().as_millis() as u64) + .saturating_sub(books.last_arrival.load(Ordering::SeqCst)); + let quiet = Duration::from_millis(since); + if quiet >= LINGER || tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(LINGER - quiet).await; + } + }; + tokio::select! { + () = linger => {} + () = stops.hurry.cancelled() => {} + } + shut.cancel(); + match tokio::time::timeout(HTTP_GRACE, &mut http).await { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(e))) => eprintln!("wanderbooks server: {e}"), + Ok(Err(e)) => eprintln!("wanderbooks server: {e}"), + Err(_) => http.abort(), + } + eprintln!("wanderbooks server: closed"); + Ok(()) +} + +/// Close when standard input ends (the host let go, or died) or on +/// SIGTERM, SIGINT or SIGHUP; hurry on the next signal. Runs inside the +/// runtime, before the books are opened. +fn watch_for_close(stops: &Stops) -> std::io::Result<()> { + use tokio::signal::unix::{SignalKind, signal}; + let on_eof = stops.close.clone(); + // A plain thread: a blocked read must never hold up the runtime's end. + std::thread::Builder::new() + .name("stdin-watch".into()) + .spawn(move || { + use std::io::Read; + let mut sink = [0u8; 256]; + let mut stdin = std::io::stdin(); + loop { + match stdin.read(&mut sink) { + Ok(0) => break, + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => break, + } + } + on_eof.cancel(); + })?; + for kind in [ + SignalKind::terminate(), + SignalKind::interrupt(), + SignalKind::hangup(), + ] { + let mut signals = signal(kind)?; + let (close, hurry) = (stops.close.clone(), stops.hurry.clone()); + tokio::spawn(async move { + while signals.recv().await.is_some() { + if close.is_cancelled() { + hurry.cancel(); + } else { + close.cancel(); + } + } + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_socket_pair_is_this_account_at_both_ends() { + let (a, b) = std::os::unix::net::UnixStream::pair().unwrap(); + assert_eq!(peer_uid(&a).unwrap(), own_uid()); + assert!(peer_is_us(&b)); + assert!(check_peer_std(&a).is_ok()); + } + + #[test] + fn a_socket_pair_names_this_process() { + let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap(); + if cfg!(target_os = "linux") { + assert_eq!(peer_pid(&a), Some(std::process::id())); + } + let file = std::fs::File::open("/dev/null").unwrap(); + assert_eq!(peer_pid(&file), None); + } + + #[test] + fn a_descriptor_that_isnt_a_socket_is_never_taken_for_us() { + let file = std::fs::File::open("/dev/null").unwrap(); + assert!(peer_uid(&file).is_err()); + assert!(!peer_is_us(&file)); + } +} diff --git a/crates/ledger-mcp/src/opening.rs b/crates/ledger-mcp/src/opening.rs new file mode 100644 index 0000000..3f67e5f --- /dev/null +++ b/crates/ledger-mcp/src/opening.rs @@ -0,0 +1,89 @@ +//! What the agents' bridge answers `initialize` with while the books are +//! still opening (plan 3.9, "The bridge and slow starts"). +//! +//! ```text +//! GOAL: an agent whose session starts while the books are closed gets +//! its `initialize` answered within its client's startup timeout, +//! with capabilities that are the server's own. +//! SOURCE: plan/served.md 3.9 (the bridge answers initialize itself after +//! 5 s, "from the tool list built into the same executable and a +//! short generic text that points at books.start"). +//! INVARIANTS: +//! 1. The capabilities are the server's (one function builds both). +//! 2. The protocol version is the one asked for when this build knows +//! it and it has an initialize handshake, else the newest that does. +//! 3. The text names no book and no folder: it points at books.start. +//! ``` + +use rmcp::model::{Implementation, ProtocolVersion, ServerCapabilities, ServerConfig}; + +/// The text an agent gets while the books open (INVARIANT 3). +pub const OPENING_TEXT: &str = "wanderbooks: double-entry books, opening now. Call books.start first: it returns these books' rules, conventions and open items, and every procedure is in books.handbook. Calls wait while the books open."; + +/// The server's capabilities (INVARIANT 1). +pub(crate) fn capabilities() -> ServerCapabilities { + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build() +} + +/// The `initialize` result the bridge gives while the books open, for a +/// client that asked for protocol version `requested`. +pub fn opening_initialize(requested: Option<&str>) -> serde_json::Value { + let version = requested + .and_then(|asked| { + ProtocolVersion::KNOWN_VERSIONS + .iter() + .find(|v| v.as_str() == asked && v.has_initialize()) + }) + .cloned() + .unwrap_or(ProtocolVersion::LATEST_WITH_INITIALIZE); + let mut info = ServerConfig::new(capabilities()) + .with_instructions(OPENING_TEXT) + .with_server_info(Implementation::new( + "wanderbooks", + env!("CARGO_PKG_VERSION"), + )); + info.protocol_version = version; + serde_json::to_value(info).expect("an initialize result is plain data") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_version_asked_for_is_kept_when_known() { + for v in ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] { + assert_eq!(opening_initialize(Some(v))["protocolVersion"], v); + } + let newest = ProtocolVersion::LATEST_WITH_INITIALIZE.as_str().to_string(); + for v in [ + Some("1999-01-01"), + Some("2026-07-28"), + Some("nonsense"), + None, + ] { + assert_eq!( + opening_initialize(v)["protocolVersion"], + newest.as_str(), + "{v:?}" + ); + } + } + + #[test] + fn it_names_the_server_and_points_at_books_start() { + let r = opening_initialize(None); + assert_eq!(r["serverInfo"]["name"], "wanderbooks"); + assert_eq!(r["serverInfo"]["version"], env!("CARGO_PKG_VERSION")); + assert!(r["instructions"].as_str().unwrap().contains("books.start")); + assert_eq!( + r["capabilities"], + serde_json::to_value(capabilities()).unwrap() + ); + assert!(r["capabilities"]["tools"].is_object()); + assert!(r["capabilities"]["resources"].is_object()); + } +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index a90ad4f..eb16bd8 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -50,7 +50,7 @@ use ledger_store::Store; use rmcp::handler::server::wrapper::{Json, Parameters}; use rmcp::model::{ Implementation, ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse, - ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig, + ReadResourceResult, Resource, ResourceContents, ServerConfig, }; use rmcp::service::{RequestContext, RoleServer}; use rmcp::{ServerHandler, tool, tool_handler, tool_router}; @@ -70,6 +70,53 @@ pub struct BooksServer { store: Arc, settings: Arc>, import_roots: Arc, + layout: Arc, +} + +/// Where a book's own files live around its data dir. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Layout { + /// A books dir, as before P2: the data dir is `books-data/` in a + /// project folder (the books dir), which holds `vault/`, the settings + /// file and perhaps git; the vault's key lives outside it. + BooksDir, + /// A book in the data root (plan 3.4): the data dir is `books//`, + /// holding its own `vault/` and its v1 key in `keys/legacy.key`. The + /// root (the path given) plays the books dir's part: nothing in it is + /// ever read or written through a path a tool names. + Root(std::path::PathBuf), +} + +impl Layout { + /// What a tool never reads or writes through a path: the books dir, + /// or the whole data root. + fn books_dir(&self, store: &Store) -> std::path::PathBuf { + match self { + Layout::BooksDir => books_dir(store), + Layout::Root(root) => root.clone(), + } + } + + /// The TIN vault's folder. + fn vault_dir(&self, store: &Store) -> std::path::PathBuf { + match self { + Layout::BooksDir => books_dir(store).join("vault"), + Layout::Root(_) => store.data_dir().join("vault"), + } + } + + /// The vault's key file: in the data root always the book's own + /// `keys/legacy.key`, whatever a setting says; in a books dir the one + /// in force (`[vault] key_file`, else the default outside the books). + fn key_file(&self, store: &Store, settings: &Settings) -> std::path::PathBuf { + match self { + Layout::BooksDir => settings.key_file(), + Layout::Root(_) => store + .data_dir() + .join(ledger_store::root::KEYS_DIR) + .join(ledger_store::root::LEGACY_KEY_FILE), + } + } } /// The one store every session shares, and whether it may still be used. @@ -86,10 +133,20 @@ pub struct BooksServer { /// store), `stopped` is set and never cleared in this process. /// 2. While `stopped`, every request is refused with STOPPED, before /// it touches the store; a restart re-verifies the journal. +/// 3. Once `close` begins, no request starts running (each is refused +/// with NOT_RUN_CLOSING, untouched), and `close` returns only when +/// every request that did start has finished its blocking work, +/// whether or not anyone still waits for its answer. /// ``` pub struct SharedStore { store: tokio::sync::Mutex, stopped: std::sync::atomic::AtomicBool, + /// Closing: no new request runs (INVARIANT 3). + closing: std::sync::atomic::AtomicBool, + /// Requests running (from before their blocking task to its end). + running: std::sync::atomic::AtomicUsize, + /// Woken whenever one ends. + ended: tokio::sync::Notify, /// The books' base currency, fixed at their creation: read without /// the lock (the connect text names it). currency: Currency, @@ -102,9 +159,31 @@ impl SharedStore { currency: store.base_currency(), store: tokio::sync::Mutex::new(store), stopped: std::sync::atomic::AtomicBool::new(false), + closing: std::sync::atomic::AtomicBool::new(false), + running: std::sync::atomic::AtomicUsize::new(0), + ended: tokio::sync::Notify::new(), }) } + /// Stop new requests from running (INVARIANT 3), and wait until every + /// one already running has ended: its blocking task finished, even if + /// its client hung up. + pub async fn close(&self) { + self.closing + .store(true, std::sync::atomic::Ordering::SeqCst); + loop { + let ended = self.ended.notified(); + if self.running.load(std::sync::atomic::Ordering::SeqCst) == 0 { + return; + } + ended.await; + } + } + + fn closing(&self) -> bool { + self.closing.load(std::sync::atomic::Ordering::SeqCst) + } + fn stopped(&self) -> bool { self.stopped.load(std::sync::atomic::Ordering::SeqCst) } @@ -179,7 +258,15 @@ where if shared.stopped() { return Err(rmcp::ErrorData::internal_error(STOPPED, None)); } + // SharedStore INVARIANT 3: counted before the closing check, so a + // close never misses it; the count rides in the blocking task, which + // runs to its end even when this future is dropped. + let running = Running::new(shared); + if shared.closing() { + return Err(not_run_closing()); + } let joined = tokio::task::spawn_blocking(move || { + let _running = running; struct Unpark(std::thread::Thread); impl std::task::Wake for Unpark { fn wake(self: Arc) { @@ -205,6 +292,43 @@ where }) } +/// A request running, counted in its store (SharedStore INVARIANT 3). +struct Running(Arc); + +impl Running { + fn new(shared: &Arc) -> Self { + shared + .running + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Self(shared.clone()) + } +} + +impl Drop for Running { + fn drop(&mut self) { + self.0 + .running + .fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + self.0.ended.notify_waiters(); + } +} + +/// The JSON-RPC error code of a request refused because the books are +/// closing: it didn't run, so a client may send it again once they are +/// open (the agents' bridge does). +pub const NOT_RUN_CLOSING_CODE: i32 = -32050; + +/// What a request refused while closing is told. +pub const NOT_RUN_CLOSING: &str = "the books are closing: this call wasn't run"; + +fn not_run_closing() -> rmcp::ErrorData { + rmcp::ErrorData::new( + rmcp::model::ErrorCode(NOT_RUN_CLOSING_CODE), + NOT_RUN_CLOSING, + None, + ) +} + #[cfg(test)] pub use crate::settings::CashRoles; @@ -403,9 +527,17 @@ impl BooksServer { ..Settings::default() }))), import_roots: Arc::new(ImportRoots::defaults()), + layout: Arc::new(Layout::BooksDir), } } + /// Serve a book kept in the data root (`Layout::Root`) rather than + /// in a books dir. + pub fn with_layout(mut self, layout: Layout) -> Self { + self.layout = Arc::new(layout); + self + } + /// The folders `books.import_local_file` may read from (local_files.rs). pub fn with_import_roots(mut self, roots: ImportRoots) -> Self { self.import_roots = Arc::new(roots); @@ -727,9 +859,10 @@ impl BooksServer { Ok(out) } - /// The TIN vault's directory: `vault/` in the books dir (git-ignored). - fn vault_dir(store: &Store) -> std::path::PathBuf { - books_dir(store).join("vault") + /// The TIN vault's directory: `vault/` in the books dir (git-ignored), + /// or in the book's own folder in the data root. + fn vault_dir(&self, store: &Store) -> std::path::PathBuf { + self.layout.vault_dir(store) } /// What is never read through a path, even inside an import root @@ -739,7 +872,7 @@ impl BooksServer { /// the rest of the books dir. fn protected(&self, store: &Store) -> Protected { Protected::of( - &places(store, &self.settings()), + &places(store, &self.settings(), &self.layout), &self.import_roots.usable(), ) } @@ -757,7 +890,7 @@ impl BooksServer { file_name: &str, ) -> Result<(), String> { tin_files::refuse( - &Self::vault_dir(store), + &self.vault_dir(store), bytes, mime_type, file_name, @@ -809,7 +942,7 @@ impl BooksServer { /// the books (outside_books) and outside every import root, so it can /// never be filed back in as a receipt and read out again. fn tin_file_dest(&self, store: &Store, path: &str) -> Result { - let dest = outside_books(store, path)?; + let dest = outside_books(&self.layout.books_dir(store), path)?; self.outside_import_roots(&dest)?; Ok(dest) } @@ -854,8 +987,17 @@ impl BooksServer { /// Record a file read or written with full TINs (tin_files.rs /// INVARIANT 2), before it is written out: every session, and every /// later server for these books, refuses it. - fn wrote_tins(store: &Store, bytes: &[u8]) -> Result<(), String> { - tin_files::record(&Self::vault_dir(store), bytes) + fn wrote_tins(&self, store: &Store, bytes: &[u8]) -> Result<(), String> { + // The record goes in the vault's folder: in the data root, made + // private first, as the vault itself makes it. + if let Layout::Root(_) = *self.layout { + ledger_store::root::private_book_folder( + store.data_dir(), + ledger_store::root::VAULT_DIR, + ) + .map_err(|e| format!("the TIN vault can't be written: {e}"))?; + } + tin_files::record(&self.vault_dir(store), bytes) } /// Open the TIN vault with the key file in force (`[vault] key_file`, @@ -865,13 +1007,23 @@ impl BooksServer { /// records it unlocks. Storing into books kept in git is refused /// unless their `.gitignore` lists `vault/`. fn open_vault(&self, store: &Store, create: bool) -> Result { - let key = self.settings().key_file(); - if is_inside(&books_dir(store), &key) { + let key = self.layout.key_file(store, &self.settings()); + // In the data root the key is the book's own `keys/legacy.key`; + // its folder and the vault's are made private when a TIN is first + // stored. + if let Layout::Root(_) = *self.layout { + if create { + for folder in [ledger_store::root::KEYS_DIR, "vault"] { + ledger_store::root::private_book_folder(store.data_dir(), folder) + .map_err(|e| format!("the TIN vault can't store anything yet: {e}"))?; + } + } + } else if is_inside(&books_dir(store), &key) { return Err(KEY_IN_BOOKS.to_string()); } // Writing: a git repository must ignore the vault, or its records // would be committed (and could never be purged). - let books = books_dir(store); + let books = self.layout.books_dir(store); if create && books.join(".git").exists() { let ignored = std::fs::read_to_string(books.join(".gitignore")) .unwrap_or_default() @@ -882,7 +1034,7 @@ impl BooksServer { } } // No message names the key file or the books' folders. - ledger_vault::Vault::open(&Self::vault_dir(store), &key, create).map_err(|e| match e { + ledger_vault::Vault::open(&self.vault_dir(store), &key, create).map_err(|e| match e { ledger_vault::Error::NoKey(_) => "the TIN vault is locked: its key file is missing (restore it from the human's password manager, or set [vault] key_file in the settings)".to_string(), ledger_vault::Error::BadKey(_) => "the TIN vault's key file is not a vault key (64 hex digits): restore it from the human's password manager".to_string(), ledger_vault::Error::Io(e) => format!("the TIN vault can't be read: {}", e.kind()), @@ -1732,13 +1884,23 @@ fn known_tin_last4(store: &Store) -> Vec { /// Where these books keep what an import must never read /// (local_files.rs): the data dir, the books dir, the vault, the key file /// in force and the default one, and the home folder. -fn places(store: &Store, settings: &Settings) -> Places { +fn places(store: &Store, settings: &Settings, layout: &Layout) -> Places { Places { data_dir: store.data_dir().to_path_buf(), - books_dir: books_dir(store), - vault_dir: BooksServer::vault_dir(store), - key_files: vec![settings.key_file(), ledger_vault::default_key_file()], - home: std::env::var_os("HOME").map(std::path::PathBuf::from), + books_dir: layout.books_dir(store), + vault_dir: layout.vault_dir(store), + key_files: vec![ + layout.key_file(store, settings), + settings.key_file(), + ledger_vault::default_key_file(), + ], + // In the data root the home comes from the account's record, as + // the root's own does; never only from the environment. + home: match layout { + Layout::Root(_) => ledger_store::root::account_home().ok(), + Layout::BooksDir => None, + } + .or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from)), } } @@ -1750,9 +1912,10 @@ fn places(store: &Store, settings: &Settings) -> Places { pub fn guard_import_roots( store: &Store, settings: &Settings, + layout: &Layout, roots: ImportRoots, ) -> Result { - let places = places(store, settings); + let places = places(store, settings, layout); let explicit = roots.explicit(); let (roots, refused) = roots.guard(places.home.as_deref(), &places.keep_out()); if let Some((root, why)) = refused.first() { @@ -1775,12 +1938,12 @@ fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool { /// Where a tool may write a file holding a full TIN: a new file, in a /// folder that exists, outside the books dir (which git commits). -fn outside_books(store: &Store, path: &str) -> Result { +fn outside_books(books: &std::path::Path, path: &str) -> Result { let p = std::path::PathBuf::from(path.trim()); if path.trim().is_empty() { return Err("give a path to write to".into()); } - if is_inside(&books_dir(store), &p) { + if is_inside(books, &p) { return Err(INSIDE_BOOKS.to_string()); } match p.parent() { @@ -1804,7 +1967,7 @@ const EXISTS: &str = "a file is there already: name a new file"; /// folder. A new file, in a folder that exists; with TINs, outside the /// books dir (which git commits, `exports/` included). fn package_dest( - store: &Store, + books: &std::path::Path, to: &str, file_name: &str, tins: bool, @@ -1827,7 +1990,7 @@ fn package_dest( )); }; if tins { - return outside_books(store, &dest.to_string_lossy()).map_err(|e| { + return outside_books(books, &dest.to_string_lossy()).map_err(|e| { format!("{e} (the CPA package holds full TINs; save it outside the books)") }); } @@ -4880,7 +5043,7 @@ impl BooksServer { let dest = if p.include_tins { self.tin_file_dest(&store, path)? } else { - outside_books(&store, path)? + outside_books(&self.layout.books_dir(&store), path)? }; let mut csv = String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4"); @@ -4907,7 +5070,7 @@ impl BooksServer { csv.push('\n'); } if p.include_tins { - Self::wrote_tins(&store, csv.as_bytes())?; + self.wrote_tins(&store, csv.as_bytes())?; } write_private_new(&dest, csv.as_bytes())?; Some(dest.display().to_string()) @@ -6967,7 +7130,7 @@ impl BooksServer { .as_ref() .is_some_and(|t| party_tin.as_ref().is_some_and(|pt| pt.last4 != t.last4())); let mut tin_facts: Option = None; - let vault_dir = Self::vault_dir(&store); + let vault_dir = self.vault_dir(&store); let (held_tin, held_w9) = ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; let facts = match facts { @@ -7028,7 +7191,7 @@ impl BooksServer { // never filed into the books (tin_files.rs INVARIANT // 1). Only now: every check has passed. if let Some(w) = &w9_file { - Self::wrote_tins(&store, &w.bytes)?; + self.wrote_tins(&store, &w.bytes)?; } let tin_hash = match &tin { Some(t) => Some(vault.put_tin(id, t).map_err(|e| e.to_string())?), @@ -7044,10 +7207,14 @@ impl BooksServer { None => w9_hash, } } else if held_tin { - ledger_vault::Vault::open(&vault_dir, &self.settings().key_file(), false) - .ok() - .and_then(|v| v.record_hash(id).ok().flatten()) - .or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone())) + ledger_vault::Vault::open( + &vault_dir, + &self.layout.key_file(&store, &self.settings()), + false, + ) + .ok() + .and_then(|v| v.record_hash(id).ok().flatten()) + .or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone())) } else if held_w9 { existing.as_ref().and_then(|e| e.vault_record.clone()) } else { @@ -7215,7 +7382,7 @@ impl BooksServer { "the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_file_path" ) })?; - Self::wrote_tins(&store, &file.bytes)?; + self.wrote_tins(&store, &file.bytes)?; write_private_new(&dest, &file.bytes)?; Ok(Json(GetW9Out { party_id: id, @@ -7270,7 +7437,7 @@ impl BooksServer { let mut store = self.lock()?; let id = resolve_party(&store, &p.party)?; let party = store.ledger().get_party(id).expect("resolved").clone(); - let vault_dir = Self::vault_dir(&store); + let vault_dir = self.vault_dir(&store); let (held_tin, held_w9) = ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some()) @@ -8279,7 +8446,15 @@ impl BooksServer { let dest = p .save_to .as_deref() - .map(|to| package_dest(&store, to, &file_name, p.include_tins)) + .map(|to| { + package_dest( + &self.layout.books_dir(&store), + to, + &file_name, + // In the data root nothing is ever written, TINs or not. + p.include_tins || matches!(*self.layout, Layout::Root(_)), + ) + }) .transpose()?; if p.include_tins && let Some(d) = &dest @@ -8653,7 +8828,7 @@ impl BooksServer { files.push(k1); } let seq = store.journal_seq(); - let vault_dir = Self::vault_dir(&store); + let vault_dir = self.vault_dir(&store); drop(store); files.push(( "general-journal.iif".into(), @@ -9385,6 +9560,11 @@ impl ServerHandler for BooksServer { request: rmcp::model::CallToolRequestParams, context: RequestContext, ) -> Result { + // Closing: refused as a protocol error, so a client knows it + // didn't run (SharedStore INVARIANT 3). + if self.store.closing() { + return Err(not_run_closing()); + } // Pick up an edited settings file before the tool runs. self.live().refresh(); let timezone = self.settings().timezone.clone(); @@ -9402,6 +9582,9 @@ impl ServerHandler for BooksServer { // plain refusal, redacted like every other. let result = match result { Ok(out) => out, + // Not run because the books are closing: a protocol error, so + // the client knows (SharedStore INVARIANT 3). + Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => return Err(e), Err(e) => Ok(rmcp::model::CallToolResponse::Complete( rmcp::model::CallToolResult::error(vec![rmcp::model::ContentBlock::text( e.message.to_string(), @@ -9421,19 +9604,14 @@ impl ServerHandler for BooksServer { .sync(store.ledger().settings(), self.store.currency.code()); } self.live().refresh(); - ServerConfig::new( - ServerCapabilities::builder() - .enable_tools() - .enable_resources() - .build(), - ) - // The rules in brief, with the policy these books actually use; - // the rest is in the handbook. - .with_instructions(handbook::connect_text(&self.settings(), self.currency())) - .with_server_info(Implementation::new( - "wanderbooks", - env!("CARGO_PKG_VERSION"), - )) + ServerConfig::new(crate::opening::capabilities()) + // The rules in brief, with the policy these books actually use; + // the rest is in the handbook. + .with_instructions(handbook::connect_text(&self.settings(), self.currency())) + .with_server_info(Implementation::new( + "wanderbooks", + env!("CARGO_PKG_VERSION"), + )) } async fn list_resources( diff --git a/crates/ledger-mcp/src/settings.rs b/crates/ledger-mcp/src/settings.rs index db1bfc9..339f217 100644 --- a/crates/ledger-mcp/src/settings.rs +++ b/crates/ledger-mcp/src/settings.rs @@ -30,6 +30,9 @@ //! base currency, and a vault key file given on the command line are //! fixed when the server starts (the key file given wins over any //! file's [vault] key_file). +//! 6. A book in the data root (plan 3.4) has no settings file at all: +//! its settings are its own, the defaults until the first is set, +//! and no file is ever read for it. //! ``` use crate::policy::ReceiptPolicy; @@ -178,6 +181,9 @@ pub struct Live { /// Whether the settings files may be read: false once the books keep /// their own (INVARIANT 1). read_files: bool, + /// A book in the data root: its settings are only ever its own + /// (INVARIANT 6). + own: bool, } /// What books.settings reports about the files. @@ -212,6 +218,26 @@ impl Live { journal_seq: None, vault_key_file: None, read_files: false, + own: false, + } + } + + /// For a book in the data root (INVARIANT 6): no settings file, now + /// or later; `sync` brings the books' settings in once they hold any. + pub fn own() -> Self { + Self { + current: Arc::new(Settings { + journaled: true, + ..Settings::default() + }), + source: None, + seen: (None, None), + error: None, + loads: 0, + journal_seq: None, + vault_key_file: None, + read_files: false, + own: true, } } @@ -229,6 +255,7 @@ impl Live { journal_seq: None, vault_key_file: None, read_files: true, + own: false, }) } @@ -249,6 +276,7 @@ impl Live { journal_seq: None, vault_key_file: None, read_files: false, + own: false, } } @@ -296,7 +324,7 @@ impl Live { /// Whether the settings in force are the books' own. pub fn is_journaled(&self) -> bool { - self.journal_seq.is_some() || (self.source.is_some() && !self.read_files) + self.own || self.journal_seq.is_some() || (self.source.is_some() && !self.read_files) } /// The settings in force. diff --git a/crates/ledger-mcp/src/tools/kept.rs b/crates/ledger-mcp/src/tools/kept.rs index 553d72e..1120d79 100644 --- a/crates/ledger-mcp/src/tools/kept.rs +++ b/crates/ledger-mcp/src/tools/kept.rs @@ -1166,7 +1166,9 @@ impl BooksServer { async fn export_settings(&self) -> Result, String> { let store = self.lock()?; let s = store.ledger().settings(); - if !s.is_journaled() { + // Books in the data root read no file: theirs are the defaults + // until the first is set. + if !s.is_journaled() && !self.live().status().journaled { return Err( "these books still read their settings file, which is the copy: move it in with books.import_settings first" .into(), diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index eb05ee8..148d32f 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -58,13 +58,19 @@ pub const BOOKS_DIR: &str = "books"; pub const RUN_DIR: &str = "run"; /// Old copies moved aside at adoption, never deleted (plan 5.5). pub const LEGACY_DIR: &str = "legacy"; +/// A book's TIN vault folder, in its own folder. +pub const VAULT_DIR: &str = "vault"; +/// A book's key folder, in its own folder. +pub const KEYS_DIR: &str = "keys"; +/// The v1 vault key, in a book's `keys/` (P2 to P7). +pub const LEGACY_KEY_FILE: &str = "legacy.key"; /// Held while books are created or adopted, so only one is made at a time. const BOOKS_LOCK: &str = "books.lock"; /// Staging folders under `books/` start so; none is a valid book id. const STAGING_PREFIX: &str = ".adopt-"; /// How the root is named in messages (INVARIANT 6). -const THE_ROOT: &str = "the books' private folder"; +pub const THE_ROOT: &str = "the books' private folder"; /// Why a root or book operation was refused. No message names the root or /// a path inside it (INVARIANT 6). @@ -244,6 +250,91 @@ pub fn default_root(home: &Path) -> PathBuf { platform_data_dir(home).join(ROOT_NAME) } +/// Whether `path` is a data root or inside one (any folder above it, or +/// it, holds the marker `setup` leaves). Books there belong to the books +/// server alone (plan 3.1: `dev-stdio` refuses them). +pub fn inside_a_root(path: &Path) -> bool { + // From here (a path that is already absolute stays as it is). + let path = std::env::current_dir() + .map(|here| here.join(path)) + .unwrap_or_else(|_| path.to_path_buf()); + // Where the deepest folder that exists really is: a folder not made + // yet holds no marker, and a link counts as where it leads. + let Some(existing) = path.ancestors().find(|dir| dir.exists()) else { + return false; + }; + let real = std::fs::canonicalize(existing).unwrap_or_else(|_| existing.to_path_buf()); + real.ancestors().any(|dir| dir.join(MARKER_FILE).is_file()) +} + +/// A folder directly in a book's own folder (`keys/`, `vault/`): made +/// 0700 if missing, made 0700 again if it is ours but others could enter +/// it; refused if it is a symlink or belongs to another user. The error +/// names no path (INVARIANT 6). +pub fn private_book_folder(book_dir: &Path, name: &str) -> Result { + let dir = book_dir.join(name); + match std::fs::symlink_metadata(&dir) { + Ok(meta) => { + let open = check_private_dir(&meta, true, "a folder inside the books")?; + tighten_if(&dir, open)?; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + make_private_dir(&dir)?; + sync_dir(book_dir)?; + } + Err(e) => return Err(e.into()), + } + Ok(dir) +} + +/// The longest socket path used as it is: a Unix socket's address holds +/// 104 bytes on macOS and 108 on Linux, the terminating zero included. +const SOCKET_PATH_MAX: usize = 100; + +/// The path to a socket named `name` in `dir`, short enough for the +/// system: the path itself when it fits; else, on Linux, the same place +/// reached through `/proc/self/fd` and a handle on `dir` kept open while +/// this lives (it is good only in this process). Elsewhere a path too +/// long is refused, saying so plainly (no path named). +#[derive(Debug)] +pub struct SocketPath { + path: PathBuf, + _dir: Option, +} + +impl SocketPath { + /// The socket `name` in `dir`. + pub fn new(dir: &Path, name: &str) -> std::io::Result { + let full = dir.join(name); + if full.as_os_str().len() <= SOCKET_PATH_MAX { + return Ok(Self { + path: full, + _dir: None, + }); + } + #[cfg(target_os = "linux")] + { + use std::os::fd::AsRawFd; + let handle = std::fs::File::open(dir)?; + let path = PathBuf::from(format!("/proc/self/fd/{}/{name}", handle.as_raw_fd())); + Ok(Self { + path, + _dir: Some(handle), + }) + } + #[cfg(not(target_os = "linux"))] + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the books' private folder's path is too long for its sockets: choose a shorter place with `wanderbooks setup --root`", + )) + } + + /// The path to bind or connect to. + pub fn path(&self) -> &Path { + &self.path + } +} + /// The data root. #[derive(Clone, Debug)] pub struct Root { @@ -422,6 +513,14 @@ impl Root { &self.path } + /// The root's `run/` folder (its sockets and locks), made 0700 if + /// missing and refused if it isn't private. + pub fn run_dir(&self) -> Result { + self.private_subdir(RUN_DIR, false) + .map(|(dir, _)| dir) + .map_err(|e| self.hide(e)) + } + /// The folder a book lives in. pub fn book_dir(&self, id: &BookId) -> PathBuf { self.path.join(BOOKS_DIR).join(id.as_str()) @@ -562,22 +661,7 @@ impl Root { if std::fs::symlink_metadata(&dest).is_ok() { return Err(RootError::Exists(id.to_string())); } - let source = std::fs::canonicalize(source).map_err(|e| { - RootError::Adopt(format!("can't read the books at {}: {e}", source.display())) - })?; - let root = std::fs::canonicalize(&self.path)?; - if source.starts_with(&root) || root.starts_with(&source) { - return Err(RootError::Adopt(format!( - "those books are inside {THE_ROOT} (or hold it): adopt books from outside it" - ))); - } - let journal_path = source.join(JOURNAL_FILE); - if !journal_path.is_file() { - return Err(RootError::Adopt(format!( - "there are no books at {}: it has no {JOURNAL_FILE}", - source.display() - ))); - } + let source = self.adoptable(source)?; for (what, path) in [ ("vault folder", &options.vault_dir), ("vault key", &options.vault_key), @@ -586,8 +670,7 @@ impl Root { && !path.exists() { return Err(RootError::Adopt(format!( - "the {what} given, {}, isn't there: nothing was adopted", - path.display() + "the {what} given isn't there: nothing was adopted" ))); } } @@ -595,16 +678,17 @@ impl Root { // writes it while it is copied. let (_source_lock, source_lock_created) = lock_source(&source)?; // Step 2: verify the chain and record the head. - let raw = std::fs::read(&journal_path)?; - if raw.last() != Some(&b'\n') { - return Err(RootError::Adopt( - "the source's last journal line is cut off: open the books once with the server \ - that wrote them (it repairs a cut-off line), then adopt them" - .into(), - )); - } - let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?; + let (raw, lines) = read_source_journal(&source)?; let head = head_of(&lines); + if let Some(expected) = &options.expect_head + && *expected != head + { + return Err(RootError::Adopt(format!( + "those books changed since the preview (they are now at event {}): nothing was \ + adopted; preview them again", + head.0 + ))); + } let keeps_settings = lines.iter().any(|l| l.line_type == "settings.set"); if !keeps_settings && options.settings.is_none() { return Err(RootError::Adopt( @@ -634,14 +718,14 @@ impl Root { let copied = copy_blobs(&source.join(blob_dir), &staging.join(blob_dir))?; // Step 5: the vault's records and the v1 key. let vault_files = match &options.vault_dir { - Some(dir) => copy_flat_private(dir, &staging.join("vault"))?, + Some(dir) => copy_flat_private(dir, &staging.join(VAULT_DIR))?, None => 0, }; let key_copied = match &options.vault_key { Some(key) => { - let keys = staging.join("keys"); + let keys = staging.join(KEYS_DIR); make_private_dir(&keys)?; - write_private_file(&keys.join("legacy.key"), &std::fs::read(key)?)?; + write_private_file(&keys.join(LEGACY_KEY_FILE), &std::fs::read(key)?)?; sync_dir(&keys)?; true } @@ -708,6 +792,79 @@ impl Root { } } +impl Root { + /// The source of an adoption, made absolute: books outside the root + /// (and not holding it) with a journal. + fn adoptable(&self, source: &Path) -> Result { + let source = std::fs::canonicalize(source) + .map_err(|e| RootError::Adopt(format!("can't read the books given: {}", e.kind())))?; + let root = std::fs::canonicalize(&self.path)?; + if source.starts_with(&root) || root.starts_with(&source) { + return Err(RootError::Adopt(format!( + "those books are inside {THE_ROOT} (or hold it): adopt books from outside it" + ))); + } + if !source.join(JOURNAL_FILE).is_file() { + return Err(RootError::Adopt(format!( + "there are no books there: it has no {JOURNAL_FILE}" + ))); + } + Ok(source) + } + + /// What adopting `source` would start from: its head, its base + /// currency, and whether it keeps its own settings, read under the + /// checks [`Self::adopt`] makes, writing nothing (not even a lock + /// file). The adoption itself reads it all again under the lock. + pub fn adopt_preview(&self, source: &Path) -> Result { + (|| { + let source = self.adoptable(source)?; + // Held for writing now: adoption would be refused, so is this. + if let Ok(lock) = std::fs::File::open(source.join(crate::store::LOCK_FILE)) + && let Err(std::fs::TryLockError::WouldBlock) = lock.try_lock_shared() + { + return Err(RootError::from(StoreError::Locked( + "another wanderbooks process has those books open for writing (an agent \ + session, or its MCP server); close it first" + .into(), + ))); + } + let (_, lines) = read_source_journal(&source)?; + Ok(SourcePreview { + head: head_of(&lines), + currency: crate::store::journal_currency(&lines)?, + keeps_settings: lines.iter().any(|l| l.line_type == "settings.set"), + }) + })() + .map_err(|e| self.hide(e)) + } +} + +/// The source's journal, whole and verified: its bytes and lines. +fn read_source_journal(source: &Path) -> Result<(Vec, Vec), RootError> { + let raw = std::fs::read(source.join(JOURNAL_FILE))?; + if raw.last() != Some(&b'\n') { + return Err(RootError::Adopt( + "the source's last journal line is cut off: open the books once with the server \ + that wrote them (it repairs a cut-off line), then adopt them" + .into(), + )); + } + let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?; + Ok((raw, lines)) +} + +/// What [`Root::adopt_preview`] found. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SourcePreview { + /// The source's head, `(seq, hash)`. + pub head: (u64, String), + /// Its base currency. + pub currency: Currency, + /// Whether it keeps its own settings. + pub keeps_settings: bool, +} + /// Options for [`Root::adopt`]. #[derive(Clone, Debug, Default)] pub struct AdoptOptions { @@ -725,6 +882,9 @@ pub struct AdoptOptions { /// The v1 vault key file: copied to the book's `keys/legacy.key`. /// Given, it must exist. pub vault_key: Option, + /// The head the source must have, `(seq, hash)`, as a preview read + /// it: if the books changed since, nothing is adopted. + pub expect_head: Option<(u64, String)>, } /// What [`Root::adopt`] did. @@ -779,11 +939,11 @@ fn lock_source(source: &Path) -> Result<(std::fs::File, bool), RootError> { match file.try_lock() { Ok(()) => {} Err(std::fs::TryLockError::WouldBlock) => { - return Err(RootError::from(StoreError::Locked(format!( - "another wanderbooks process has the books in {} open for writing (an agent \ - session, or its MCP server); close it first", - source.display() - )))); + return Err(RootError::from(StoreError::Locked( + "another wanderbooks process has those books open for writing (an agent \ + session, or its MCP server); close it first" + .into(), + ))); } Err(std::fs::TryLockError::Error(e)) => return Err(e.into()), } diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index dad2564..53e3bfa 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -6235,7 +6235,7 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result { /// The base currency of a journal: its first line's, which must be a /// sound `store.created` ([`created_currency`]). -fn journal_currency(lines: &[JournalLine]) -> Result { +pub(crate) fn journal_currency(lines: &[JournalLine]) -> Result { let first = lines .first() .ok_or_else(|| StoreError::JournalCorrupt("journal has no store.created line".into()))?; diff --git a/crates/ledger-store/src/viewer.rs b/crates/ledger-store/src/viewer.rs index 7a961fb..a676875 100644 --- a/crates/ledger-store/src/viewer.rs +++ b/crates/ledger-store/src/viewer.rs @@ -112,7 +112,7 @@ pub fn command_path(data_dir: &Path) -> PathBuf { pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result { let path = command_path(data_dir); if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; + private_folder(dir)?; } let seq = read(data_dir).map_or(1, |c| c.seq + 1); let cmd = ViewerCommand { @@ -129,6 +129,42 @@ pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if let Some(parent) = dir.parent() { + std::fs::create_dir_all(parent)?; + } + match std::fs::symlink_metadata(dir) { + Ok(meta) => { + // SAFETY: geteuid has no preconditions and can't fail. + let me = unsafe { libc::geteuid() }; + if !meta.is_dir() || meta.uid() != me { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "the viewer's request folder isn't this account's own", + )); + } + // Private again, whatever it was. + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir(dir)?; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; + } + Err(e) => return Err(e), + } + Ok(()) + } + #[cfg(not(unix))] + std::fs::create_dir_all(dir) +} + /// The latest request for `data_dir`, if any. pub fn read(data_dir: &Path) -> Option { let bytes = std::fs::read(command_path(data_dir)).ok()?; @@ -170,6 +206,26 @@ mod tests { ); // Same books, same file; other books, another. assert_eq!(command_path(&dir), command_path(&dir.join("."))); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let base = std::env::temp_dir().join(format!("wb-viewer-priv-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + // Made private; made private again. + let folder = base.join("wanderbooks"); + private_folder(&folder).unwrap(); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&folder), 0o700); + std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap(); + private_folder(&folder).unwrap(); + assert_eq!(mode(&folder), 0o700); + // A link planted in its place is refused. + let link = base.join("planted"); + std::os::unix::fs::symlink(&base, &link).unwrap(); + assert!(private_folder(&link).is_err()); + let _ = std::fs::remove_dir_all(&base); + } assert_ne!(command_path(&dir), command_path(&std::env::temp_dir())); let _ = std::fs::remove_file(command_path(&dir)); } diff --git a/crates/ledger-store/tests/root.rs b/crates/ledger-store/tests/root.rs index a057702..35a7ec7 100644 --- a/crates/ledger-store/tests/root.rs +++ b/crates/ledger-store/tests/root.rs @@ -316,6 +316,8 @@ fn adopted_books_are_the_same_books_and_the_source_is_untouched() { settings: None, vault_dir: Some(vault.clone()), vault_key: Some(key.clone()), + // As a preview read it: the same head, so it goes ahead. + expect_head: Some(source_head.clone()), }, ) .unwrap(); @@ -724,8 +726,16 @@ fn adoption_leaves_a_read_only_source_as_it_was() { drop(source_books(old.path(), true)); let before = snapshot_files(old.path()); chmod_tree(old.path(), 0o500, 0o400); + // Writable again however the test ends, so its folder can go. + struct Writable<'a>(&'a Path); + impl Drop for Writable<'_> { + fn drop(&mut self) { + chmod_tree(self.0, 0o700, 0o600); + } + } + let writable = Writable(old.path()); let report = root.adopt(old.path(), &id("acme"), &AdoptOptions::default()); - chmod_tree(old.path(), 0o700, 0o600); + drop(writable); let report = report.unwrap(); assert!(!report.source_lock_created); assert_eq!(snapshot_files(old.path()), before, "the lock file included"); @@ -1099,3 +1109,178 @@ fn errors_other_than_not_found_are_never_taken_for_it() { std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o600)).unwrap(); assert!(root.list_books().unwrap().is_empty()); } + +#[test] +fn an_adoption_preview_reads_the_source_and_writes_nothing() { + let home = tmp("preview"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("preview-src"); + let s = source_books(old.path(), false); + let head = s.head(); + drop(s); + // No lock file beside the journal: the preview must not make one. + let _ = std::fs::remove_file(old.path().join("wanderbooks.lock")); + let before = snapshot_files(old.path()); + let found = root.adopt_preview(old.path()).unwrap(); + assert_eq!(found.head, head); + assert_eq!(found.currency, Currency::USD); + assert!(!found.keeps_settings); + assert_eq!( + snapshot_files(old.path()), + before, + "the preview wrote to the source" + ); + assert!(root.list_books().unwrap().is_empty()); + // Books that keep their own settings say so. + let kept = tmp("preview-kept"); + drop(source_books(kept.path(), true)); + assert!(root.adopt_preview(kept.path()).unwrap().keeps_settings); + // The preview refuses what adoption refuses, naming no root. + let book = root + .create_book(&BookId::parse("acme").unwrap(), Currency::USD) + .unwrap(); + drop(book); + let root_text = root.path().display().to_string(); + for bad in [ + root.book_dir(&BookId::parse("acme").unwrap()), + root.path().to_path_buf(), + home.path().join("missing"), + home.path().to_path_buf(), + ] { + let e = root.adopt_preview(&bad).unwrap_err(); + assert!(matches!(e, RootError::Adopt(_)), "{bad:?}: {e:?}"); + assert!(!e.to_string().contains(&root_text), "{e}"); + } + // A cut-off last line is refused, as adoption refuses it. + let cut = tmp("preview-cut"); + drop(source_books(cut.path(), false)); + let journal = cut.path().join(JOURNAL_FILE); + let mut raw = std::fs::read(&journal).unwrap(); + raw.pop(); + std::fs::write(&journal, raw).unwrap(); + assert!( + matches!(root.adopt_preview(cut.path()), Err(RootError::Adopt(why)) if why.contains("cut off")) + ); +} + +#[test] +fn a_book_folder_is_made_private_and_a_shared_one_refused() { + let home = tmp("bookfolder"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + drop( + root.create_book(&BookId::parse("acme").unwrap(), Currency::USD) + .unwrap(), + ); + let book = root.book_dir(&BookId::parse("acme").unwrap()); + let keys = ledger_store::root::private_book_folder(&book, "keys").unwrap(); + assert_eq!(keys, book.join("keys")); + assert_eq!(mode(&keys), 0o700); + // Again: already there, fine. + assert_eq!( + ledger_store::root::private_book_folder(&book, "keys").unwrap(), + keys + ); + // Ours, but others could enter it: made private again. + std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o755)).unwrap(); + ledger_store::root::private_book_folder(&book, "keys").unwrap(); + assert_eq!(mode(&keys), 0o700); + std::os::unix::fs::symlink(home.path(), book.join("vault")).unwrap(); + assert!(ledger_store::root::private_book_folder(&book, "vault").is_err()); +} + +#[test] +fn the_run_folder_is_private() { + let home = tmp("run"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let run = root.run_dir().unwrap(); + assert_eq!(run, root.path().join("run")); + assert_eq!(mode(&run), 0o700); + std::fs::remove_dir(&run).unwrap(); + assert_eq!(mode(&root.run_dir().unwrap()), 0o700, "remade private"); + std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(root.run_dir(), Err(RootError::Unsafe(_)))); +} + +#[test] +fn inside_a_root_is_known_by_its_marker() { + let home = tmp("inside"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + use ledger_store::root::inside_a_root; + assert!(inside_a_root(root.path())); + assert!(inside_a_root(&root.path().join("books/acme"))); + assert!(inside_a_root(&root.path().join("books/not/yet/made"))); + assert!(!inside_a_root(home.path())); + // A path relative to where the process runs is taken from there. + let here = std::env::current_dir().unwrap(); + let up = "../".repeat(here.components().count() - 1); + let relative = PathBuf::from(format!( + "{up}{}", + root.path() + .join("books/acme") + .display() + .to_string() + .trim_start_matches('/') + )); + assert!(relative.is_relative()); + assert!(inside_a_root(&relative)); + let outside = PathBuf::from(format!( + "{up}{}", + home.path().display().to_string().trim_start_matches('/') + )); + assert!(!inside_a_root(&outside)); + assert!(!inside_a_root(&home.path().join("elsewhere"))); + // Through a link, too: it is where the path leads that counts. + let link = home.path().join("link"); + std::os::unix::fs::symlink(root.path().join("books"), &link).unwrap(); + assert!(inside_a_root(&link)); + assert!(!root.path().join(MARKER_FILE).is_dir()); +} + +#[test] +fn books_that_changed_since_the_preview_are_not_adopted() { + let home = tmp("expect"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("expect-src"); + drop(source_books(old.path(), true)); + let previewed = root.adopt_preview(old.path()).unwrap().head; + // The old server writes one more event after the preview. + let mut s = Store::open(old.path()).unwrap(); + s.add_account("6100", "Rent", AccountType::Expense, None, false) + .unwrap(); + let now = s.head(); + // While it holds them, a preview is refused as adoption would be. + assert!(matches!( + root.adopt_preview(old.path()), + Err(RootError::Store(_, StoreError::Locked(_))) + )); + drop(s); + let before = snapshot_files(old.path()); + let e = root + .adopt( + old.path(), + &id("acme"), + &AdoptOptions { + expect_head: Some(previewed), + ..AdoptOptions::default() + }, + ) + .unwrap_err(); + assert!( + matches!(&e, RootError::Adopt(why) if why.contains("changed since the preview")), + "{e:?}" + ); + assert!(root.list_books().unwrap().is_empty()); + assert_eq!(snapshot_files(old.path()), before); + // Previewed again, it goes ahead. + let report = root + .adopt( + old.path(), + &id("acme"), + &AdoptOptions { + expect_head: Some(now.clone()), + ..AdoptOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.source_head, now); +} diff --git a/plan/served.md b/plan/served.md index 937eeee..7c6b29d 100644 --- a/plan/served.md +++ b/plan/served.md @@ -297,13 +297,17 @@ The books are open while something holds them; there is no background service. T - `run/mcp.sock`: served by the **server**, MCP for the bridge. Local agents never use TCP, so no other account on the machine can squat a port and catch the token or the traffic. Both ends check the peer's user ID (`SO_PEERCRED`/`getpeereid`, the pipe's SID). TCP is off by default. The owner can turn it on (`server.toml`, or `wanderbooks serve --http`) for a client that needs HTTP; it then binds 127.0.0.1:7411, a failed bind is reported by `doctor` and is not fatal, and such clients hold no lease, so the books must already be open. - `run/admin.sock`: served by the server, admin only (create, adopt, tokens), as in 3.1. Neither the viewer nor the bridge opens it. -**Starting the host cleanly.** Every mode that confines itself (the viewer, the bridge) first starts a small unconfined **helper** (`wanderbooks helper`, spawned as its own process over an inherited socket pair before any thread, window or Cocoa start; never a fork of a running GUI), then confines itself. The helper accepts a fixed message set and nothing else: `start_host`; `open_with` an attachment id (the helper itself copies that blob, read-only, into a 0700 per-user directory, `$XDG_RUNTIME_DIR/wanderbooks-open/` or its per-system equivalent, swept when the helper exits, and hands it to the system's default app; it never accepts a path or a command line); `stage_backup`, whose file the helper asks the owner for in its own file dialog (never a path from the viewer), copying it into the data root's `uploads/` and returning a staged id for the `restore` intent; and `start_updater`, with no arguments. Only the helper starts the host, by a clean re-exec: environment scrubbed to a fixed allow-list (keeping `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS`, which `systemd-run --user` needs, only after checking they point at the host's own user's runtime directory), stdin, stdout and stderr to null (a log file under `run/`), a new session (`setsid`), a fixed umask. On Linux under systemd (uwsm on Omarchy puts every app in its own scope, which would take the host down with the app) it re-execs through a transient `systemd-run --user --scope`, which installs nothing, persists nothing, and disappears with the host; elsewhere `setsid` alone. On Windows, `CREATE_BREAKAWAY_FROM_JOB` where the job allows it, and `DETACHED_PROCESS`. The host resolves the data root itself from the user's account (`getpwuid` home, or the known folder on Windows), never from the opener's environment, unless `wanderbooks setup` recorded another root. It refuses to run if it finds itself confined (seccomp in `/proc/self/status`, a Landlock write probe). +**The server's process (P2 slice 3).** The server is the hidden mode `wanderbooks server`, started by the host with a pipe on its standard input; it runs until that input ends (the host let go of it, or died) or it gets SIGTERM, SIGINT or SIGHUP (watched from before the books are opened). It takes `run/server.lock` before anything else (a second server waits for it, touching nothing, and leaves if told to close first), opens and verifies every book (a book that won't open is listed with why, and the others are served), binds both sockets, and only then prints `ready` on its standard output. `run/mcp.sock` speaks Streamable HTTP over the Unix socket, `/b/{id}/mcp` per book, so local and remote share one code path (3.2); `run/admin.sock` takes one JSON line and answers one (`list`, `create`, `adopt`). A socket path longer than the system allows is reached through `/proc/self/fd` on Linux. Closing: new requests are refused, unrun (HTTP 503 at the door, or, for a call already past it, JSON-RPC error -32050 "closing: this call wasn't run"; the bridge sends either again once the books are back); every request already running finishes its work, however long, whether or not its client is still there; answers on their way out get 10 s more (a second signal skips that wait, never the first); then the sessions end and the sockets go. Until uploads have their own route (slice 5) a request may be 64 MiB, since receipts still ride in tool calls. A book in the root keeps its vault in its own `vault/` and its v1 key in its own `keys/legacy.key` (0700 and 0600, made private whenever first written), and no tool writes a file anywhere in the root. Adoption is a preview, then a confirm tied to the head the preview read; entries of a settings file the books don't know are refused unless skipped, its comments are shown, the vault key it names must be the one given, and the key must open every vault record. Open for slice 8: under Landlock the server can't read an adoption's source outside the root, so adoption needs another way in by then. + +**The host's process (P2 slice 3).** `wanderbooks host` closes every descriptor above 2 it was handed, refuses to run confined, takes `run/host.lock` (waiting up to 30 s for a host that is closing; one that finds a live host exits at once), binds `run/lease.sock`, and starts the server from `/proc/self/exe`, naming the root on its command line only when the host itself was given one (tests): nothing in a process list names it. Ending leases, it tells each holder why and closes the server only once they have let go (at most 2 s), so holders stop sending before it closes; a call already on its way is refused at the door, unrun, and sent again once the books are back. A closing server is never killed, however long its last call runs; a new server waits for an old one still finishing (its host killed) rather than fail. A restart begins when the new version appears, not at a quiet moment: the server first finishes every call in progress (none is cut off), and calls arriving meanwhile wait for the new server (up to 60 s); waiting for idle comes with jobs (`books.job`), which are what can run long. On a restart the old host hands over: it lets go of `host.lock` and keeps `update.lock` until the new host answers, so waiting holders find it. A holder starts a host only when none holds the lock; one that died at once is noticed within 2 s; a server that fails soon after opening counts as failing (only 60 s open clears the count). Lease records carry the holder's process as the kernel reports it. The lease reasons are `closed_by_owner`, `restarting`, `closing` (the host is closing because nothing held it: hold again, a new host will answer; the owner's close, by the menu, `wanderbooks close` or a signal to the host, is always `closed_by_owner`, and a holder that had held stays closed) and `server_failed`. The bridge never sends a call the agent cancelled before it was sent (MCP owes no answer to a cancelled request), answers a ping itself while the books open, refuses JSON-RPC batches request by request, passes no client offer it can't carry back (sampling, elicitation, roots), reports a call answered with nothing as possibly run, and carries the per-request protocol headers of the 2026-07-28 era. Sessions on the server time out only after 24 h without a message (rmcp's idle timer ignores a call in progress, so a shorter one could cut a long call off); the bridge ends its own when its stdin closes or it is signalled. `wanderbooks doctor` (a first form, completed in slice 8) says whether the books are open, what holds them, and the host's and server's last log lines, naming no folder. Tests shorten the timings with a hidden `--test-knobs` flag, passed down from holder to helper to host. + +**Starting the host cleanly.** Every mode that confines itself (the viewer, the bridge) first starts a small unconfined **helper** (`wanderbooks helper`, spawned as its own process over an inherited socket pair before any thread, window or Cocoa start; never a fork of a running GUI), then confines itself. The helper accepts a fixed message set and nothing else: `start_host`; `open_with` an attachment id (the helper itself copies that blob, read-only, into a 0700 per-user directory, `$XDG_RUNTIME_DIR/wanderbooks-open/` or its per-system equivalent, swept when the helper exits, and hands it to the system's default app; it never accepts a path or a command line); `stage_backup`, whose file the helper asks the owner for in its own file dialog (never a path from the viewer), copying it into the data root's `uploads/` and returning a staged id for the `restore` intent; and `start_updater`, with no arguments. A confined mode starts the host only through its helper; unconfined holders (the CLI commands, `serve`) use the same clean start directly, and a restarting host starts its successor itself, already clean, in its own session and scope (P2 slice 3). The clean start is a re-exec: environment scrubbed to a fixed allow-list (keeping `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS`, which `systemd-run --user` needs, only after checking they point at the host's own user's runtime directory), stdin, stdout and stderr to null (a log file under `run/`), a new session (`setsid`), a fixed umask. On Linux under systemd (uwsm on Omarchy puts every app in its own scope, which would take the host down with the app) it re-execs through a transient `systemd-run --user --scope`, which installs nothing, persists nothing, and disappears with the host; elsewhere `setsid` alone. On Windows, `CREATE_BREAKAWAY_FROM_JOB` where the job allows it, and `DETACHED_PROCESS`. The host resolves the data root itself from the user's account (`getpwuid` home, or the known folder on Windows), never from the opener's environment, unless `wanderbooks setup` recorded another root. It refuses to run if it finds itself confined (seccomp in `/proc/self/status`, a Landlock write probe). **One host, even in a race.** The host takes `run/host.lock` (an exclusive flock) before anything else; a second host exits and its opener attaches. The host starts the server, which takes every book's writer lock, and only then the archive process, so two archive pushers never meet on one `archive.git`. An opener that finds no live host and an unlocked `host.lock` starts one; one that finds a host shutting down (the lease socket refuses during shutdown) waits on `host.lock`, then starts a new one. -**Closing.** When the last lease ends, the host waits 10 s; a lease arriving in that time cancels the close. Then: no new requests, the one in progress finishes, every writer lock is released, the archive process finishes its push, and the host exits. "Close the books" (the app's menu, `wanderbooks close`) ends every lease at once; a bridge then tells its agent the owner closed the books and reopens them only in a new session. Before it ends leases the host says why on each one (`closed_by_owner`, `restarting`, `idle_close`). A holder that sees the connection end with no reason (a crash, a kill) starts a new host and re-takes its lease, with backoff (1, 2, 4 … 60 s, at most five tries); a host that keeps failing to start (a damaged journal, a panic) becomes a Needs-attention and `doctor` item, and a bridge answers its agent with a clear error instead of retrying forever. `host_respawns` and `respawn_backs_off` test it. +**Closing.** When the last lease ends, the host waits 10 s; a lease arriving in that time cancels the close. Then: no new requests, the one in progress finishes, every writer lock is released, the archive process finishes its push, and the host exits. "Close the books" (the app's menu, `wanderbooks close`) ends every lease at once; a bridge then tells its agent the owner closed the books and reopens them only in a new session. Before it ends leases the host says why on each one (`closed_by_owner`, `restarting`, `closing`, `server_failed`; P2 slice 3). A holder that sees the connection end with no reason (a crash, a kill) starts a new host and re-takes its lease, with backoff (1, 2, 4 … 60 s, at most five tries); a host that keeps failing to start (a damaged journal, a panic) becomes a Needs-attention and `doctor` item, and a bridge answers its agent with a clear error instead of retrying forever. `host_respawns` and `respawn_backs_off` test it. -**Restarting** (an update, or the executable replaced by a package manager) is not a close. What lives between calls survives it: upload grants, download links, `/h/` sessions and finished job results are kept in `run/state/` (0600, the session key with them), and a running job delays the restart until it finishes. The host takes `run/update.lock`, closes the books as above but keeps the lease socket's holders informed, swaps (or, for a package-managed install, finds the new executable already in place), and restarts from the new executable. Openers that arrive meanwhile wait on `update.lock`. Bridges reconnect and re-initialize upstream without the agent noticing. A package-managed upgrade is noticed by the host (its executable's inode changed) and the restart happens the next time no request is in progress. +**Restarting** (an update, or the executable replaced by a package manager) is not a close. What lives between calls survives it: upload grants, download links, `/h/` sessions and finished job results are kept in `run/state/` (0600, the session key with them), and a running job delays the restart until it finishes (jobs, `books.job`, come later; until then a restart begins at once and the server finishes every call in progress first, P2 slice 3). The host takes `run/update.lock`, closes the books as above but keeps the lease socket's holders informed, swaps (or, for a package-managed install, finds the new executable already in place), and restarts from the new executable. Openers that arrive meanwhile wait on `update.lock`. Bridges reconnect and re-initialize upstream without the agent noticing. A package-managed upgrade is noticed by the host (its executable's inode changed) and restarts it as above. **One version at a time.** Children (server, archive, extractor) are started from the executable the host itself is running (`/proc/self/exe` or `fexecve` on Linux), never by path, so a swap mid-run can't mix versions. Where that isn't available (macOS, Windows) every child, bridge and lease handshake carries the version, and a mismatch triggers a restart. The lease, helper and bridge protocols carry protocol versions (not release versions, since a staged update can skip releases); each accepts its current version and the one before, and outside that range refuses with a plain message ("restart this agent session").