From d703b6891100f1a68819e53eb1cbc411ea7fafc0 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 13:31:42 -0600 Subject: [PATCH] refactor(host): tokens live in a file under the data root (U0e) D-63: the 0700 data root is the trust boundary already (I-1: hidden, not locked), so the OS keyring added no wall and cost headless servers a D-Bus session, CI a knob and every OS its own store. The file keeper is the only store: run/tokens/, 0600, written atomically. The three keyring stores, the file_keyring knob and their tests are removed; an unreadable token file is an error, never no token. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .github/workflows/check.yml | 2 - .github/workflows/two-user.yml | 19 +- Cargo.lock | 101 ---- crates/ledger-cli/tests/common/mod.rs | 2 +- crates/ledger-cli/tests/format_upgrade.rs | 6 +- crates/ledger-cli/tests/open_while_held.rs | 90 ++- crates/ledger-gui/src/owner.rs | 2 +- crates/ledger-host/Cargo.toml | 13 - crates/ledger-host/src/bridge.rs | 181 +++--- crates/ledger-host/src/commands.rs | 68 +-- crates/ledger-host/src/knobs.rs | 21 +- crates/ledger-host/src/lib.rs | 2 +- crates/ledger-host/src/tokens.rs | 667 ++++----------------- crates/ledger-mcp/src/admin.rs | 2 +- crates/ledger-mcp/src/local.rs | 2 +- 15 files changed, 280 insertions(+), 898 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2c653dd..299ee16 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -70,8 +70,6 @@ jobs: run: cargo build --workspace --all-targets - name: Tests run: cargo test --workspace - - name: The real OS keyring keeps and forgets a token - run: cargo test -p ledger-host --lib -- --ignored os_keyring - name: What the build used if: always() shell: bash diff --git a/.github/workflows/two-user.yml b/.github/workflows/two-user.yml index ceb000b..6ba5b1c 100644 --- a/.github/workflows/two-user.yml +++ b/.github/workflows/two-user.yml @@ -7,10 +7,8 @@ # job sets it, so a skip here is a failure's cousin: the job only goes # green when both tests ran. # -# The token is minted with the test knob `file_keyring=1` (a file under -# the root's run/, 0600, ledger-host tokens.rs INVARIANT 3), because a -# runner has no Secret Service; deploy/README.md says what a real server -# does instead. +# The token is minted as on a real server: `token create` keeps it in a +# 0600 file under the root's run/ (ledger-host tokens.rs INVARIANT 3). name: two-user on: push: @@ -93,16 +91,13 @@ jobs: as_books "$WB" books adopt /tmp/source --root /srv/wanderbooks --id acme --yes as_books "$WB" books create tunnel --root /srv/wanderbooks - # Tokens for the agent account (the test), kept in the root's - # run/ by the test knob; a real server uses its OS keyring. - as_books "$WB" token create --root /srv/wanderbooks --book acme --name ci \ - --test-knobs file_keyring=1 >/dev/null + # Tokens for the agent account (the test), kept in the root's run/. + as_books "$WB" token create --root /srv/wanderbooks --book acme --name ci >/dev/null TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book acme \ - --name ci --test-knobs file_keyring=1) - as_books "$WB" token create --root /srv/wanderbooks --book tunnel --name ci \ - --test-knobs file_keyring=1 >/dev/null + --name ci) + as_books "$WB" token create --root /srv/wanderbooks --book tunnel --name ci >/dev/null WRITE_TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book tunnel \ - --name ci --test-knobs file_keyring=1) + --name ci) # The check, as the agent account, over the loopback door. WB_TWO_USER=1 \ diff --git a/Cargo.lock b/Cargo.lock index 9cc5845..8ea4bb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,17 +249,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "apple-native-keyring-store" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" -dependencies = [ - "keyring-core", - "log", - "security-framework", -] - [[package]] name = "approx" version = "0.5.1" @@ -740,15 +729,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "block-padding" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" -dependencies = [ - "hybrid-array", -] - [[package]] name = "block2" version = "0.5.1" @@ -888,12 +868,6 @@ dependencies = [ "syn 3.0.7", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "byteorder-lite" version = "0.1.0" @@ -971,15 +945,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" -[[package]] -name = "cbc" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.7.0" @@ -3046,15 +3011,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" -dependencies = [ - "hmac", -] - [[package]] name = "hmac" version = "0.13.0" @@ -3514,7 +3470,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding", "hybrid-array", ] @@ -3764,15 +3719,6 @@ dependencies = [ "mutate_once", ] -[[package]] -name = "keyring-core" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" -dependencies = [ - "log", -] - [[package]] name = "khronos-egl" version = "6.0.0" @@ -3981,9 +3927,7 @@ dependencies = [ name = "ledger-host" version = "0.1.0" dependencies = [ - "apple-native-keyring-store", "base64 0.22.1", - "keyring-core", "ledger-core", "ledger-mcp", "ledger-store", @@ -3996,8 +3940,6 @@ dependencies = [ "tempfile", "tokio", "tokio-util", - "windows-native-keyring-store", - "zbus-secret-service-keyring-store", "zeroize", ] @@ -6369,25 +6311,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "secret-service" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" -dependencies = [ - "aes", - "cbc", - "futures-util", - "getrandom 0.4.3", - "hkdf", - "hybrid-array", - "num", - "once_cell", - "serde", - "sha2 0.11.0", - "zbus", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -8751,19 +8674,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-native-keyring-store" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" -dependencies = [ - "byteorder", - "keyring-core", - "regex", - "windows-sys 0.61.2", - "zeroize", -] - [[package]] name = "windows-numerics" version = "0.3.1" @@ -9243,17 +9153,6 @@ dependencies = [ "zvariant", ] -[[package]] -name = "zbus-secret-service-keyring-store" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" -dependencies = [ - "keyring-core", - "secret-service", - "zbus", -] - [[package]] name = "zbus_macros" version = "5.19.0" diff --git a/crates/ledger-cli/tests/common/mod.rs b/crates/ledger-cli/tests/common/mod.rs index bfc181a..432c226 100644 --- a/crates/ledger-cli/tests/common/mod.rs +++ b/crates/ledger-cli/tests/common/mod.rs @@ -16,7 +16,7 @@ //! the test temp dir finds it), made by `wanderbooks setup --home`; //! its server runs with an empty environment but PATH, and HOME and //! XDG_CONFIG_HOME inside the home, on that home's root alone: never -//! this account's root, keyring, or sockets. +//! this account's root, tokens, or sockets. //! 2. A server is ended by its own child's PID, never by a search. //! 3. Settings a test gives as a settings file's text go into the books //! as an agent puts them there: the changes ledger-config's import diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index e68d865..ac4bfd5 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -27,9 +27,9 @@ use std::process::{Child, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -/// Short timings for tests, and agents' tokens kept in files in the test -/// root, not this computer's keyring. -const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,file_keyring=1,backup_quiet=300"; +/// Short timings for tests. +const KNOBS: &str = + "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300"; /// The owner passphrase the tests set. const PASS: &str = "correct horse battery"; diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs index fde2a5e..417f8cd 100644 --- a/crates/ledger-cli/tests/open_while_held.rs +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -17,9 +17,9 @@ 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), and agents' -/// tokens kept in files in the test root, not this computer's keyring. -const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,file_keyring=1,backup_quiet=300"; +/// 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,backup_quiet=300"; /// A home with a data root, and the program the tests run (possibly a /// copy, for restarts). @@ -653,7 +653,7 @@ fn the_bridge_answers_initialize_itself_during_a_slow_start() { "--book", "acme", "--test-knobs", - "close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000,file_keyring=1", + "close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000", ]); let mut a = Agent::from(c); let started = Instant::now(); @@ -722,7 +722,7 @@ fn the_bridge_gives_up_on_books_that_never_open() { "--book", "acme", "--test-knobs", - "close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000,file_keyring=1", + "close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000", ]); let mut a = Agent::from(c); a.initialize(); @@ -1323,7 +1323,7 @@ fn a_call_cancelled_at_once_is_never_made() { "--book", "acme", "--test-knobs", - "close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000,file_keyring=1", + "close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000", ]); let mut a = Agent::from(c); a.initialize(); @@ -1393,7 +1393,7 @@ fn a_signalled_bridge_goes_at_once() { "--book", "acme", "--test-knobs", - "close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000,file_keyring=1", + "close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000", ]); let mut a = Agent::from(c); a.initialize(); @@ -1831,7 +1831,7 @@ fn books_made_mid_session_and_bad_requests() { "--book", "acme", "--test-knobs", - "close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000,file_keyring=1", + "close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000", ]); let mut d = Agent::from(c); d.initialize(); @@ -2108,7 +2108,10 @@ fn the_token_commands_and_the_bridge() { // A read-only token, made: the same session uses it from now on. let (code, made, _) = token(&["create", "--book", "acme", "--scopes", "read"]); assert_eq!(code, Some(0), "{made}"); - assert!(made.contains("kept in this computer's keyring"), "{made}"); + assert!( + made.contains("kept in a token file in the data root"), + "{made}" + ); assert!( made.contains("books:read") && !made.contains("wb_"), "{made}" @@ -2124,7 +2127,7 @@ fn the_token_commands_and_the_bridge() { assert!(err.contains("needs books:write"), "{err}"); // A name taken; replaced on request, and the running session takes // the new token at its next call (the old one refused at the door, the - // keyring read again: review of slice 4). + // token file read again: review of slice 4). let (code, _, err) = token(&["create", "--book", "acme"]); assert_eq!(code, Some(1)); assert!(err.contains("already have a token"), "{err}"); @@ -2161,7 +2164,7 @@ fn the_token_commands_and_the_bridge() { printed.trim().starts_with("wb_") && printed.trim().len() == 67, "{printed}" ); - // Revoked: the next call is refused, and the keyring forgets it. + // Revoked: the next call is refused, and the token file forgets it. let (code, said, _) = token(&["revoke", "--book", "acme"]); assert_eq!(code, Some(0), "{said}"); assert!(b.call("books.chart", json!({})).is_err()); @@ -2810,13 +2813,12 @@ fn the_viewers_hold_new_books_and_close_the_books() { } #[test] -fn connect_makes_anew_a_token_the_keyring_lost_and_says_when_an_agent_refuses() { +fn connect_makes_anew_a_token_whose_file_is_gone_and_says_when_an_agent_refuses() { use std::os::unix::fs::PermissionsExt; let home = Home::new("connect-lost"); home.create("acme"); - // The server keeps the books' agent token; this computer's keyring - // lost it. - std::fs::remove_dir_all(home.run_dir().join("test-keyring")).unwrap(); + // The server keeps the books' agent token; its token file is gone. + std::fs::remove_dir_all(home.run_dir().join("tokens")).unwrap(); let (bin, _log) = fake_agent(&home, "claude"); let connect = |path: &Path| { home.cmd("connect") @@ -2852,13 +2854,12 @@ fn connect_makes_anew_a_token_the_keyring_lost_and_says_when_an_agent_refuses() } /// tokens.rs INVARIANT 4, commands.rs INVARIANT 4, bridge.rs INVARIANT -/// 11: `token check` says whether the keyring's token is the books' live -/// one, never its text; a second keyring item for one token is said, and -/// `create --replace` keeps one again; a token the keyring can't keep is -/// revoked again; and a bridge that can't read the keyring says so, not -/// "make a token". +/// 11: `token check` says whether the token file's token is the books' +/// live one, never its text; an unreadable token file is said, by the +/// check and by a bridge, never "no token"; and a token the file can't +/// keep is revoked again. #[test] -fn token_check_one_item_per_token_and_a_keyring_that_cant_be_read() { +fn token_check_says_where_the_token_file_stands_and_an_unreadable_one_is_said() { let home = Home::new("tokencheck"); let out = home.books(&["create", "acme"]); assert!(out.status.success(), "{out:?}"); @@ -2889,38 +2890,24 @@ fn token_check_one_item_per_token_and_a_keyring_that_cant_be_read() { let (code, said, _) = check(); assert_eq!(code, Some(0), "{said}"); assert!(said.contains("is the live one"), "{said}"); - // The one item kept, in the test keyring's default collection. - let keyring = home.run_dir().join("test-keyring"); - let item = std::fs::read_dir(keyring.join("default")) + // The one file kept, mode 0600. + let folder = home.run_dir().join("tokens"); + let item = std::fs::read_dir(&folder) .unwrap() .map(|e| e.unwrap().path()) .collect::>(); assert_eq!(item.len(), 1); let item = item[0].clone(); - let file_name = item.file_name().unwrap().to_owned(); - // A second item for it, in another collection: said, none used. - let login = keyring.join("login"); - std::fs::create_dir_all(&login).unwrap(); - std::fs::write(login.join(&file_name), "wb_stale").unwrap(); - let (code, _, err) = check(); - assert_eq!(code, Some(1)); - assert!( - err.contains("2 items") && err.contains("token create --book acme --replace"), - "{err}" - ); + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&item).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } let mut a = Agent::start(&home, "acme"); a.initialize(); - let err = a.call("books.start", json!({})).unwrap_err(); - assert!(err.contains("2 items"), "{err}"); - // Kept again: one item, the live one, and the session goes on. - let (code, _, err) = token(&["create", "--book", "acme", "--replace"]); - assert_eq!(code, Some(0), "{err}"); - assert!(!login.join(&file_name).exists()); - let (code, said, _) = check(); - assert_eq!(code, Some(0), "{said}"); a.call("books.start", json!({})).unwrap(); assert!(a.end().is_some()); - // The keyring holding a token replaced since: not the live one. + // The file holding a token replaced since: not the live one. let old = std::fs::read_to_string(&item).unwrap(); let (code, _, _) = token(&["create", "--book", "acme", "--replace"]); assert_eq!(code, Some(0)); @@ -2931,30 +2918,31 @@ fn token_check_one_item_per_token_and_a_keyring_that_cant_be_read() { said.contains("isn't the live one") && said.contains("--replace"), "{said}" ); - // A keyring that can't be reached: a bridge with no token says so, - // never "make a token"; and a token made then is revoked again. + // A token file that can't be read (a folder in its place): a bridge + // with no token says so, never "make a token"; and a token made then + // is revoked again. std::fs::remove_file(&item).unwrap(); - let unreachable = keyring.join("unreachable"); - std::fs::write(&unreachable, "").unwrap(); + std::fs::create_dir(&item).unwrap(); let mut b = Agent::start(&home, "acme"); b.initialize(); let err = b.call("books.start", json!({})).unwrap_err(); assert!( - err.contains("keyring is locked or unreachable") && err.contains("token itself is fine"), + err.contains("token file in the data root couldn't be read") + && err.contains("token itself is fine"), "{err}" ); assert!(!err.contains("token create"), "{err}"); assert!(b.end().is_some()); let (code, _, err) = check(); assert_eq!(code, Some(1)); - assert!(err.contains("can't be reached"), "{err}"); + assert!(err.contains("token file couldn't give"), "{err}"); let (code, _, err) = token(&["create", "--book", "acme", "--replace"]); assert_eq!(code, Some(1)); assert!( err.contains("couldn't keep it") && err.contains("revoked again"), "{err}" ); - std::fs::remove_file(&unreachable).unwrap(); + std::fs::remove_dir(&item).unwrap(); let (_, listed, _) = token(&["list"]); assert!(listed.contains("No tokens yet"), "{listed}"); } diff --git a/crates/ledger-gui/src/owner.rs b/crates/ledger-gui/src/owner.rs index 2238b1d..bee5031 100644 --- a/crates/ledger-gui/src/owner.rs +++ b/crates/ledger-gui/src/owner.rs @@ -339,7 +339,7 @@ impl OwnerMenu { if ui .button(format!("{} Connect {}", icon::PLUGS_CONNECTED, agent.label())) .on_hover_text(format!( - "Let {} reach these books (it keeps their token in this computer's keyring)", + "Let {} reach these books (it keeps their token in a file in the data root)", agent.label() )) .clicked() diff --git a/crates/ledger-host/Cargo.toml b/crates/ledger-host/Cargo.toml index a90562b..8c677cd 100644 --- a/crates/ledger-host/Cargo.toml +++ b/crates/ledger-host/Cargo.toml @@ -18,10 +18,6 @@ base64 = "0.22" # `wanderbooks download` checks what arrived against its Repr-Digest. sha2 = "0.10" tokio-util = { version = "0.7", features = ["rt"] } -# Agents' tokens are kept in the OS keyring (plan 3.3, 3.5): the Secret -# Service on Linux, the login Keychain on macOS, the Credential Manager on -# Windows (P11a step 0). -keyring-core = "1" # The owner passphrase is typed at the terminal, unechoed. rpassword = "7" # Token texts and passphrases are wiped from memory when dropped. @@ -30,15 +26,6 @@ zeroize = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" -[target.'cfg(target_os = "linux")'.dependencies] -zbus-secret-service-keyring-store = { version = "1", features = ["rt-async-io-crypto-rust"] } - -[target.'cfg(target_os = "macos")'.dependencies] -apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] } - -[target.'cfg(windows)'.dependencies] -windows-native-keyring-store = "1.1.0" - [dev-dependencies] tempfile = "3" diff --git a/crates/ledger-host/src/bridge.rs b/crates/ledger-host/src/bridge.rs index e007a47..cf9d013 100644 --- a/crates/ledger-host/src/bridge.rs +++ b/crates/ledger-host/src/bridge.rs @@ -42,17 +42,16 @@ //! 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. -//! 11. Every request upstream carries its token, read from the keyring -//! (tokens.rs) before each new session (even with one held: one the -//! keyring no longer gives is said on stderr, and the keyring's used) +//! 11. Every request upstream carries its token, read from the token +//! file (tokens.rs) before each new session (even with one held: one +//! the file no longer gives is said on stderr, and the file's used) //! and before each request until it is found, and again when the //! door refuses it (the call then sent once more if another was //! found), so a token made or replaced mid-session is used from the //! next call. Without one, calls are answered with the server's -//! refusal, which says how the human makes one; but when the keyring -//! couldn't be read (locked, unreachable, or holding several items -//! for the token), with that instead: the token is not said to be -//! missing. The token is never written to the agent or to stderr. +//! refusal, which says how the human makes one; but when the token +//! file is there and couldn't be read, with that instead: the token +//! is not said to be missing. The token is never written to the agent or to stderr. //! ``` use crate::knobs::{FLAG, Knobs}; @@ -143,13 +142,13 @@ struct Bridge { book: String, /// The name of the token it uses (tokens.rs). token_name: String, - /// The token's text, once read from the keyring (INVARIANT 11). + /// The token's text, once read from the token file (INVARIANT 11). token: Mutex>, /// Where it is kept: its account, and the root's `run/`. - keyring: std::sync::OnceLock<(crate::tokens::Account, PathBuf)>, - /// Why the keyring last couldn't be read, if it couldn't (INVARIANT + token_file: std::sync::OnceLock<(crate::tokens::Account, PathBuf)>, + /// Why the token file last couldn't be read, if it couldn't (INVARIANT /// 11). - keyring_trouble: Mutex>, + token_file_trouble: Mutex>, http: reqwest::Client, knobs: Knobs, to_agent: mpsc::UnboundedSender, @@ -179,9 +178,9 @@ enum Owner { /// before it counts as not answering. const UPSTREAM_WAIT: Duration = Duration::from_secs(10); -/// How long the keyring is waited for before a new session when a token +/// How long the token file is waited for before a new session when a token /// is already held (INVARIANT 11): the one held is used if it is slow. -const KEYRING_RECHECK_WAIT: Duration = Duration::from_secs(2); +const TOKEN_FILE_RECHECK_WAIT: Duration = Duration::from_secs(2); /// Where one of the agent's requests stands. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -339,9 +338,9 @@ impl Bridge { format!("http://localhost/b/{}/mcp", self.book) } - /// Read the token from the keyring if it isn't known yet (INVARIANT + /// Read the token from the token file if it isn't known yet (INVARIANT /// 11): a token made after the session began is found on the next - /// try. A keyring that fails is said on stderr; the server's refusal + /// try. A token file that fails is said on stderr; the server's refusal /// then tells the agent what to do. async fn find_token(&self) { if self @@ -362,19 +361,19 @@ impl Bridge { .is_some() } - /// Read the token from the keyring, keeping it if found: whether a + /// Read the token from the token file, keeping it if found: whether a /// token other than the one held was found (one made since, or one /// that replaced it). async fn read_token(&self) -> bool { self.read_token_within(self.knobs.queue_wait).await } - /// [`Bridge::read_token`], waiting at most `wait` for the keyring. + /// [`Bridge::read_token`], waiting at most `wait` for the file. async fn read_token_within(&self, wait: Duration) -> bool { - let Some((account, run_dir)) = self.keyring.get().cloned() else { + let Some((account, run_dir)) = self.token_file.get().cloned() else { return false; }; - let keeper = crate::tokens::keeper(&run_dir, &self.knobs); + let keeper = crate::tokens::keeper(&run_dir); let read = tokio::time::timeout( wait, tokio::task::spawn_blocking(move || keeper.get(&account)), @@ -382,13 +381,13 @@ impl Bridge { .await; let read = match read { Ok(Ok(read)) => read, - Ok(Err(_)) | Err(_) => Err(crate::tokens::KeyringError::Unavailable( - "it didn't give the token in time: is it locked?".into(), + Ok(Err(_)) | Err(_) => Err(crate::tokens::TokenFileError::new( + "the token file didn't give the token in time", )), }; let trouble = read.as_ref().err().cloned(); *self - .keyring_trouble + .token_file_trouble .lock() .unwrap_or_else(|e| e.into_inner()) = trouble; match read { @@ -397,7 +396,7 @@ impl Bridge { let new = held.as_deref() != Some(token.as_str()); if new && held.is_some() { eprintln!( - "wanderbooks mcp: the keyring gives another token for these books than the one held: using the keyring's" + "wanderbooks mcp: the token file gives another token for these books than the one held: using the file's" ); } *held = Some(token); @@ -412,24 +411,21 @@ impl Bridge { } /// What the agent is told instead of the door's "make a token" when - /// no token is held because the keyring couldn't be read (INVARIANT + /// no token is held because the token file couldn't be read (INVARIANT /// 11). - fn keyring_refusal(&self) -> Option { + fn token_file_refusal(&self) -> Option { if self.holds_token() { return None; } let trouble = self - .keyring_trouble + .token_file_trouble .lock() .unwrap_or_else(|e| e.into_inner()) .clone()?; - Some(match trouble { - crate::tokens::KeyringError::Unavailable(why) => format!( - "this computer's keyring is locked or unreachable, so the token for the books {} couldn't be read ({why}). The token itself is fine: unlock the keyring (or log in again) and try once more; don't make a new one (`wanderbooks token check --book {}` says where it stands).", - self.book, self.book - ), - duplicates => duplicates.to_string(), - }) + Some(format!( + "the token file in the data root couldn't be read, so the token for the books {} couldn't be used ({trouble}). The token itself is fine: mend what stops the file being read and try once more; don't make a new one (`wanderbooks token check --book {}` says where it stands).", + self.book, self.book + )) } /// A request upstream, carrying the token (INVARIANT 11). @@ -464,7 +460,7 @@ impl Bridge { self.find_token().await; let (sent, unauthorized) = self.send_once(message, up).await; // Refused at the door for its token (not run): one made or - // replaced since is read from the keyring (INVARIANT 11). A + // replaced since is read from the token file (INVARIANT 11). A // session belongs to the token that opened it, so with a session // the call waits for a new one (opened with the new token); with // none it is sent once more at once. @@ -474,9 +470,9 @@ impl Bridge { } return self.send_once(message, up).await.0; } - // No token, for a keyring that couldn't be read: that is said, + // No token, for a token file that couldn't be read: that is said, // not "make one" (INVARIANT 11). - if unauthorized && let Some(why) = self.keyring_refusal() { + if unauthorized && let Some(why) = self.token_file_refusal() { return Sent::Refused(why); } sent @@ -583,10 +579,10 @@ impl Bridge { epoch: u64, ) -> Result> { // Read again before every session, even with a token held - // (INVARIANT 11): a held one the keyring no longer gives isn't - // kept on in silence. A slow keyring leaves the one held in use. + // (INVARIANT 11): a held one the token file no longer gives isn't + // kept on in silence. A slow token file leaves the one held in use. if self.holds_token() { - self.read_token_within(KEYRING_RECHECK_WAIT.min(self.knobs.queue_wait)) + self.read_token_within(TOKEN_FILE_RECHECK_WAIT.min(self.knobs.queue_wait)) .await; } else { self.read_token().await; @@ -601,12 +597,12 @@ impl Bridge { }; let mut response = post().await.map_err(|_| None)?; // Refused for its token: once more with one made or replaced - // since (INVARIANT 11); with none, for a keyring that couldn't be + // since (INVARIANT 11); with none, for a token file that couldn't be // read, that is said instead of the door's words. if response.status() == reqwest::StatusCode::UNAUTHORIZED { if self.read_token().await { response = post().await.map_err(|_| None)?; - } else if let Some(why) = self.keyring_refusal() { + } else if let Some(why) = self.token_file_refusal() { return Err(Some(why)); } } @@ -918,7 +914,7 @@ impl Bridge { let run_dir = opener.run_dir().to_path_buf(); let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); let account = crate::tokens::Account::new(&root, &self.book, &self.token_name); - let _ = self.keyring.set((account, run_dir)); + let _ = self.token_file.set((account, run_dir)); let mut epoch = 0u64; loop { let mut lease = match opener.hold().await { @@ -1279,7 +1275,7 @@ fn usage(prog: &str) { 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. It uses the"); eprintln!(" token NAME (default: agent) that `wanderbooks token create` kept in"); - eprintln!(" this computer's keyring."); + eprintln!(" a file in the data root."); } /// `wanderbooks mcp --book ID`. @@ -1340,8 +1336,8 @@ pub fn run( book, token_name, token: Mutex::new(None), - keyring: std::sync::OnceLock::new(), - keyring_trouble: Mutex::new(None), + token_file: std::sync::OnceLock::new(), + token_file_trouble: Mutex::new(None), http, knobs, to_agent, @@ -1683,11 +1679,11 @@ mod against_a_server { let (link, _) = watch::channel(Link::Opening); let bridge = Arc::new(Bridge { book: "acme".into(), - // A token the fake server doesn't check; no keyring is read. + // A token the fake server doesn't check; no token file is read. token_name: crate::tokens::DEFAULT_NAME.into(), token: Mutex::new(Some("wb_test".into())), - keyring: std::sync::OnceLock::new(), - keyring_trouble: Mutex::new(None), + token_file: std::sync::OnceLock::new(), + token_file_trouble: Mutex::new(None), http, knobs, to_agent, @@ -1899,23 +1895,21 @@ mod against_a_server { assert!(none.session.is_none() && none.init.is_none()); } - /// A bridge whose token is kept in a test keyring in `dir` (none held - /// yet), and that keyring's folder. - fn bridge_with_keyring( + /// A bridge whose token is kept in a token file under `dir` (none + /// held yet), and the keeper of that file. + fn bridge_with_token_file( socket: &Path, dir: &Path, - ) -> (Arc, mpsc::UnboundedReceiver, PathBuf) { - let knobs = Knobs { - file_keyring: true, - ..knobs(1000, 1000) - }; - let (b, rx) = bridge_at(socket, knobs); + ) -> ( + Arc, + mpsc::UnboundedReceiver, + crate::tokens::Keeper, + ) { + let (b, rx) = bridge_at(socket, knobs(1000, 1000)); *b.token.lock().unwrap() = None; let account = crate::tokens::Account::new(dir, "acme", crate::tokens::DEFAULT_NAME); - b.keyring.set((account, dir.to_path_buf())).unwrap(); - let keyring = dir.join("test-keyring"); - std::fs::create_dir_all(&keyring).unwrap(); - (b, rx, keyring) + b.token_file.set((account, dir.to_path_buf())).unwrap(); + (b, rx, crate::tokens::keeper(dir)) } /// The door's refusal of a request with no token. @@ -1929,22 +1923,24 @@ mod against_a_server { } } - /// INVARIANT 11: with no token because the keyring couldn't be read, - /// the agent is told that, never to make a token; with a keyring that - /// simply has none, the door's words. + /// INVARIANT 11: with no token because the token file is there and + /// couldn't be read, the agent is told that, never to make a token; + /// with a token file that simply isn't there, the door's words. #[tokio::test] - async fn a_keyring_that_cant_be_read_is_said_never_make_a_token() { + async fn a_token_file_that_cant_be_read_is_said_never_make_a_token() { let server = Server::start(); let dir = tempfile::tempdir().unwrap(); - let (b, _rx, keyring) = bridge_with_keyring(&server.socket, dir.path()); + let (b, _rx, keeper) = bridge_with_token_file(&server.socket, dir.path()); + let account = b.token_file.get().unwrap().0.clone(); let call: Value = serde_json::from_str(CALL).unwrap(); let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}}); - let unreachable = keyring.join(crate::tokens::TEST_UNREACHABLE); - std::fs::write(&unreachable, "").unwrap(); + // A directory where the token file belongs: unreadable. + let file = keeper.file(&account); + std::fs::create_dir_all(&file).unwrap(); server.script([no_token_at_the_door(), no_token_at_the_door()]); let said = what(&b.send(&call, &up(1)).await); assert!( - said.contains("keyring is locked or unreachable") + said.contains("token file in the data root couldn't be read") && said.contains("token itself is fine") && said.contains("token check --book acme"), "{said}" @@ -1963,41 +1959,22 @@ mod against_a_server { .all(|s| !s.headers.contains_key("authorization")), "no bearer was sent" ); - // Two items for the token: that, and how to keep one again. - std::fs::remove_file(&unreachable).unwrap(); - let account = b.keyring.get().unwrap().0.clone(); - crate::tokens::keeper(dir.path(), &b.knobs) - .set(&account, "wb_one") - .unwrap(); - let other = keyring.join("login"); - std::fs::create_dir_all(&other).unwrap(); - for entry in std::fs::read_dir(keyring.join(crate::tokens::TEST_DEFAULT)).unwrap() { - std::fs::write(other.join(entry.unwrap().file_name()), "wb_two").unwrap(); - } - server.script([no_token_at_the_door()]); - let said = what(&b.send(&call, &up(1)).await); - assert!( - said.contains("2 items") && said.contains("--replace"), - "{said}" - ); - assert!(!said.contains("wb_"), "{said}"); - // A keyring that has none: the door's own words. - std::fs::remove_dir_all(&keyring).unwrap(); + // No file at all: the door's own words. + std::fs::remove_dir_all(&file).unwrap(); server.script([no_token_at_the_door()]); let said = what(&b.send(&call, &up(1)).await); assert!(said.contains("wanderbooks token create"), "{said}"); } - /// INVARIANT 11: before each new session the keyring is read again, - /// even with a token held, and the keyring's token is the one used. + /// INVARIANT 11: before each new session the token file is read + /// again, even with a token held, and the file's token is the one used. #[tokio::test] - async fn each_new_session_reads_the_keyring_again() { + async fn each_new_session_reads_the_token_file_again() { let server = Server::start(); let dir = tempfile::tempdir().unwrap(); - let (b, _rx, _keyring) = bridge_with_keyring(&server.socket, dir.path()); + let (b, _rx, keeper) = bridge_with_token_file(&server.socket, dir.path()); *b.token.lock().unwrap() = Some("wb_held".into()); - let account = b.keyring.get().unwrap().0.clone(); - let keeper = crate::tokens::keeper(dir.path(), &b.knobs); + let account = b.token_file.get().unwrap().0.clone(); keeper.set(&account, "wb_kept").unwrap(); let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}}); let result = json!({"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, @@ -2021,18 +1998,14 @@ mod against_a_server { .collect() }; assert_eq!(bearers(&server), ["Bearer wb_kept", "Bearer wb_kept"]); - // A keyring that can't be read then: the token held goes on. - std::fs::write( - dir.path() - .join("test-keyring") - .join(crate::tokens::TEST_UNREACHABLE), - "", - ) - .unwrap(); + // A token file that can't be read then: the token held goes on. + let file = keeper.file(&account); + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir_all(&file).unwrap(); server.script(opened()); b.initialize_upstream(¶ms, 2).await.unwrap(); assert_eq!(bearers(&server)[2..], ["Bearer wb_kept", "Bearer wb_kept"]); - assert!(b.keyring_refusal().is_none(), "a token is held"); + assert!(b.token_file_refusal().is_none(), "a token is held"); } #[tokio::test] diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index 08f8a54..e08056d 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -22,14 +22,15 @@ //! 3. Adoption and restoring are previewed first and go 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. -//! 4. A token made is kept in the keyring, and read back from it as -//! kept, before anything is said of it, and its text is printed only -//! by `token print`; one that can't be kept (or read back) is revoked -//! again. A token revoked is forgotten by the keyring too. `token -//! check` says whether the keyring's token is the books' live one, -//! by its SHA-256 alone, never its text. `connect` makes the books' agent token only when the keyring -//! has none, replacing the server's (no one could be using it: the -//! bridge reads its token from this keyring). +//! 4. A token made is kept in its token file (tokens.rs), and read back +//! from it as kept, before anything is said of it, and its text is +//! printed only by `token print`; one that can't be kept (or read +//! back) is revoked again. A token revoked is forgotten by the token +//! file too. `token check` says whether the file's token is the +//! books' live one, by its SHA-256 alone, never its text. `connect` +//! makes the books' agent token only when there is no token file for +//! it, replacing the server's (no one could be using it: the bridge +//! reads its token from this file). //! 5. The viewer's hold lets go only when the owner closes the books; //! books that stop, or won't open, are tried again. //! 6. Books waiting for a format upgrade (plan 3.9, D-39): the owner's @@ -150,7 +151,7 @@ fn with_server(common: &Common, request: Request) -> Result { } /// [`with_server`], with the root's `run/` folder too (where the -/// keyring's test stand-in lives). +/// token files live). fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBuf), String> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -1127,7 +1128,7 @@ pub fn connect( "--help" | "-h" => { eprintln!("usage: {prog} claude|codex --book ID"); eprintln!( - " Lets Claude Code or Codex reach the books: keeps their token in this computer's keyring and adds the books to the agent's own list." + " Lets Claude Code or Codex reach the books: keeps their token in a file in the data root and adds the books to the agent's own list." ); return Ok(()); } @@ -1160,7 +1161,7 @@ pub fn connect( } /// Let the agent `agent` ("claude" or "codex") reach the books `book`: -/// their token kept in the keyring (made if none is), and `wanderbooks +/// their token kept in its token file (made if none is), and `wanderbooks /// mcp --book ` added to the agent's own list as /// `wanderbooks-`. What was done, a line each. pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result, String> { @@ -1207,22 +1208,19 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result Result { let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); let account = crate::tokens::Account::new(&root, &book, &name); - let forgot = crate::tokens::keeper(&run_dir, &common.knobs).delete(&account); + let forgot = crate::tokens::keeper(&run_dir).delete(&account); println!("Revoked the token {:?} for the books {}.", t.name, t.book); if let Err(why) = forgot { eprintln!( - "warning: this computer's keyring still holds its text ({why}); it opens nothing now" + "warning: its token file still holds its text ({why}); it opens nothing now" ); } Ok(()) @@ -2198,11 +2196,11 @@ pub fn token( let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); let account = crate::tokens::Account::new(&root, &book, &name); // Its SHA-256 alone leaves here (INVARIANT 4). - let kept = crate::tokens::keeper(&run_dir, &common.knobs) + let kept = crate::tokens::keeper(&run_dir) .get(&account) .map_err(|e| { fail(format!( - "this computer's keyring couldn't give the token {name:?} of the books {book}: {e}" + "the token file couldn't give the token {name:?} of the books {book}: {e}" )) })? .map(|text| { @@ -2232,20 +2230,20 @@ pub fn token( let said = match (kept.is_some(), live, matches) { (true, Some(t), true) => { println!( - "The token {name:?} of the books {book} kept in this computer's keyring is the live one (made {}).", + "The token {name:?} of the books {book} kept in its token file is the live one (made {}).", t.created ); return Ok(()); } (true, Some(t), false) => format!( - "This computer's keyring holds a token {name:?} for the books {book} that isn't the live one (made {}): {remake} makes one and keeps it here; then reconnect the agent.", + "The token file holds a token {name:?} for the books {book} that isn't the live one (made {}): {remake} makes one and keeps it here; then reconnect the agent.", t.created ), (true, None, _) => format!( - "This computer's keyring holds a token {name:?} for the books {book} that they no longer have (it was revoked): `wanderbooks token create --book {book}{named}` makes one." + "The token file holds a token {name:?} for the books {book} that they no longer have (it was revoked): `wanderbooks token create --book {book}{named}` makes one." ), (false, Some(t), _) => format!( - "This computer's keyring keeps no token {name:?} for the books {book}, though they have a live one (made {}): {remake} makes one kept here.", + "The token file keeps no token {name:?} for the books {book}, though they have a live one (made {}): {remake} makes one kept here.", t.created ), (false, None, _) => format!( @@ -2262,7 +2260,7 @@ pub fn token( let run_dir = opener.run_dir().to_path_buf(); let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); let account = crate::tokens::Account::new(&root, &book, &name); - match crate::tokens::keeper(&run_dir, &common.knobs).get(&account) { + match crate::tokens::keeper(&run_dir).get(&account) { Ok(Some(text)) => { println!("{text}"); Ok(()) @@ -2361,7 +2359,7 @@ pub fn passphrase( } /// A request to a book over the server's socket, with the agent's token -/// from the keyring, the books held open meanwhile: its status, the +/// from the token file, the books held open meanwhile: its status, the /// headers wanted, and its body. fn on_the_socket( common: &Common, @@ -2389,7 +2387,7 @@ fn on_the_socket( let run_dir = opener.run_dir().to_path_buf(); let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); let account = crate::tokens::Account::new(&root, book, token_name); - let keeper = crate::tokens::keeper(&run_dir, &common.knobs); + let keeper = crate::tokens::keeper(&run_dir); let token = tokio::task::spawn_blocking(move || keeper.get(&account)) .await .map_err(|e| e.to_string())?? diff --git a/crates/ledger-host/src/knobs.rs b/crates/ledger-host/src/knobs.rs index 80c2b1f..04f24ad 100644 --- a/crates/ledger-host/src/knobs.rs +++ b/crates/ledger-host/src/knobs.rs @@ -27,9 +27,6 @@ pub struct Knobs { pub queue_wait: Duration, /// How long a command waits for the books to open. pub open_wait: Duration, - /// Keep agents' tokens in files in the root's `run/`, not the OS - /// keyring (tests: tokens.rs INVARIANT 3). `file_keyring=1`. - pub file_keyring: bool, /// How long the books stay unwritten before the server backs them up /// (plan 3.7: 30 s), passed to the server as `--backup-quiet`. pub backup_quiet: Duration, @@ -48,7 +45,6 @@ impl Default for Knobs { init_wait: Duration::from_secs(5), queue_wait: Duration::from_secs(60), open_wait: Duration::from_secs(120), - file_keyring: false, backup_quiet: Duration::from_secs(30), drill_first: Duration::from_secs(60), } @@ -66,14 +62,6 @@ impl Knobs { let (name, value) = pair .split_once('=') .ok_or_else(|| format!("{FLAG}: {pair:?} isn't name=milliseconds"))?; - if name == "file_keyring" { - k.file_keyring = match value { - "1" => true, - "0" => false, - _ => return Err(format!("{FLAG}: file_keyring is 1 or 0")), - }; - continue; - } let ms: u64 = value .parse() .map_err(|_| format!("{FLAG}: {value:?} isn't a number of milliseconds"))?; @@ -114,9 +102,6 @@ impl Knobs { parts.push(format!("{name}={}", now.as_millis())); } } - if self.file_keyring { - parts.push("file_keyring=1".into()); - } (!parts.is_empty()).then(|| parts.join(",")) } @@ -168,10 +153,6 @@ mod tests { let args = k.args(); assert_eq!(args[0], FLAG); assert_eq!(Knobs::parse(&args[1]).unwrap(), k); - let keyring = Knobs::parse("file_keyring=1,backoff=5").unwrap(); - assert!(keyring.file_keyring); - assert_eq!(Knobs::parse(&keyring.args()[1]).unwrap(), keyring); - assert!(!Knobs::parse("file_keyring=0").unwrap().file_keyring); let one = Knobs::parse("close_after=1").unwrap(); assert_eq!(one.to_arg().as_deref(), Some("close_after=1")); } @@ -183,7 +164,7 @@ mod tests { "close_after=x", "nope=1", "close_after=-1", - "file_keyring=yes", + "token_file=1", ] { assert!(Knobs::parse(bad).is_err(), "{bad}"); } diff --git a/crates/ledger-host/src/lib.rs b/crates/ledger-host/src/lib.rs index ea1e881..cb4f568 100644 --- a/crates/ledger-host/src/lib.rs +++ b/crates/ledger-host/src/lib.rs @@ -8,7 +8,7 @@ //! [`host::run`] (`wanderbooks host`), [`bridge::run`] (`wanderbooks //! mcp`), and the owner's commands in [`commands`] (`serve`, `close`, //! `books`, `token`, `connect`, `doctor`, `passphrase`, and what the -//! viewer's Books menu does). Agents' tokens are kept in the OS keyring +//! viewer's Books menu does). Agents' tokens are kept in files under the data root //! ([`tokens`]). #[cfg(unix)] diff --git a/crates/ledger-host/src/tokens.rs b/crates/ledger-host/src/tokens.rs index 3585c02..6bb90c4 100644 --- a/crates/ledger-host/src/tokens.rs +++ b/crates/ledger-host/src/tokens.rs @@ -1,90 +1,65 @@ -//! Where an agent's token is kept on this computer: the OS keyring (plan -//! 3.3, 3.5, D-23), read by the bridge, so no agent's configuration +//! Where an agent's token is kept on this computer: a file in the data +//! root's `run/` folder, read by the bridge, so no agent's configuration //! holds it. //! +//! Removed (D-63): the OS keyring stores (Secret Service, macOS Keychain, +//! Windows Credential Manager) and the `file_keyring` test knob, because +//! the 0700 data root is the trust boundary already (I-1: hidden, not +//! locked), so a keyring added no wall and cost headless servers a D-Bus +//! session and every OS its own store. +//! //! ```text //! GOAL: `wanderbooks token create` keeps the token it was shown where //! the bridge finds it, and nowhere an agent's configuration or the //! screen would show it. -//! SOURCE: plan 3.3 ("kept in the OS keyring, minted by wanderbooks -//! connect, revocable"), 2.1 ("Local token: OS keyring, read by -//! the bridge"), 3.5 ("plaintext in the user's keyring, which any -//! process running as that user can read (advisory locally)"). +//! SOURCE: plan 3.3 and D-63 ("a 0600 file under the 0700 data root, on +//! every OS"), 2.1 ("Local token ... read by the bridge"), 3.5 +//! (any process running as that user can read it: advisory +//! locally, I-1). //! INVARIANTS: -//! 1. A token is kept under the service "wanderbooks" and an account -//! naming the root (a hash of its path, never the path), the book -//! and the token's name, so two roots or two books never share one. +//! 1. A token is kept in a file named by a hash of an account naming +//! the root (a hash of its path, never the path), the book and the +//! token's name, so two roots or two books never share one. //! 2. Nothing here prints or logs a token's text, and no error repeats //! it. -//! 3. The keyring stands in for nothing else: with no keyring the -//! command says so; only the test knob `file_keyring` keeps tokens -//! in files instead (in the root's `run/`, 0600). -//! 4. One account, one item: keeping a token first removes every item -//! kept for its account in any collection, then makes one and reads -//! it back (else it is refused); reading finds the items in every -//! collection and, finding several, says so instead of giving one -//! of them. A keyring that is locked or can't be reached is an -//! error (KeyringError::Unavailable), never "no token". +//! 3. The file is in the root's `run/tokens/`, mode 0600 on Unix, and +//! written atomically: a temporary file in the same folder, made +//! 0600 before any byte is written, then renamed over the old one, +//! so a reader sees the old token or the new one, never half. +//! 4. A token file that is there but can't be read is an error, never +//! "no token"; only a missing file is no token. //! ``` -use crate::knobs::Knobs; use std::path::{Path, PathBuf}; -/// The keyring service tokens are kept under (INVARIANT 1). -pub const SERVICE: &str = "wanderbooks"; /// A token's name when none is given. pub const DEFAULT_NAME: &str = ledger_store::auth::DEFAULT_NAME; -/// Why the keyring gave no answer (INVARIANT 4). +/// Why the token file couldn't be read, kept or removed (INVARIANT 4). #[derive(Clone, Debug, PartialEq, Eq)] -pub enum KeyringError { - /// Locked, unreachable, or it refused, with why: a token kept there - /// is untouched. - Unavailable(String), - /// Several items for one token, where one belongs. - Duplicates { - /// How many. - count: usize, - /// The books. - book: String, - /// The token's name. - name: String, - }, -} +pub struct TokenFileError(String); -impl std::fmt::Display for KeyringError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Unavailable(why) => f.write_str(why), - Self::Duplicates { count, book, name } => { - let named = if name == DEFAULT_NAME { - String::new() - } else { - format!(" --name {name}") - }; - write!( - f, - "this computer's keyring holds {count} items for the token {name:?} of the books {book}, where one belongs, so none of them is used: `wanderbooks token create --book {book}{named} --replace` keeps one again" - ) - } - } +impl TokenFileError { + /// An error with `why`, which never holds a token's text. + pub(crate) fn new(why: &str) -> Self { + Self(why.to_string()) } } -impl From for String { - fn from(e: KeyringError) -> Self { +impl std::fmt::Display for TokenFileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for String { + fn from(e: TokenFileError) -> Self { e.to_string() } } -/// A place tokens are kept. -pub trait Keeper: Send + Sync { - /// The token kept for `account`, if any (INVARIANT 4). - fn get(&self, account: &Account) -> Result, KeyringError>; - /// Keep `text` for `account`, replacing any (INVARIANT 4). - fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError>; - /// Forget `account`'s token (no matter if there is none). - fn delete(&self, account: &Account) -> Result<(), KeyringError>; +fn refused(e: std::io::Error) -> TokenFileError { + TokenFileError(format!("the token file refused: {}", e.kind())) } /// Whose token: a root's book's token of a name (INVARIANT 1). @@ -107,369 +82,52 @@ impl Account { } } - /// The keyring's account name: `//`. + /// The account's name: `//`. pub fn key(&self) -> String { format!("{}/{}/{}", self.root_tag, self.book, self.name) } - /// What the keyring's own window calls it. - fn label(&self) -> String { - format!("wanderbooks token: the books {} ({})", self.book, self.name) - } - - fn duplicates(&self, count: usize) -> KeyringError { - KeyringError::Duplicates { - count, - book: self.book.clone(), - name: self.name.clone(), - } + fn file_name(&self) -> String { + ledger_store::journal::sha256_hex(self.key().as_bytes()) } } -/// The keeper in force: the OS keyring, or files for tests (INVARIANT 3). -pub fn keeper(run_dir: &Path, knobs: &Knobs) -> Box { - if knobs.file_keyring { - Box::new(Files(run_dir.join("test-keyring"))) - } else { - Box::new(Os) - } +/// The place tokens are kept: files in a root's `run/` folder. +pub struct Keeper(PathBuf); + +/// The keeper for the root whose `run/` folder is `run_dir`. +pub fn keeper(run_dir: &Path) -> Keeper { + Keeper(run_dir.join("tokens")) } -/// Items kept under an account, perhaps in several collections: the -/// keyring's or the test files' (INVARIANT 4 is kept once, over these). -trait Items { - /// One item. - type Item; - /// Every item kept for `account`, in any collection. - fn find(&self, account: &Account) -> Result, String>; - /// An item's text. - fn read(&self, item: &Self::Item) -> Result; - /// Remove an item. - fn remove(&self, item: Self::Item) -> Result<(), String>; - /// Make an item for `account` in the default collection. - fn make(&self, account: &Account, text: &str) -> Result<(), String>; -} - -/// The one token kept for `account` (INVARIANT 4). -fn get_one(items: &I, account: &Account) -> Result, KeyringError> { - let mut found = items.find(account).map_err(KeyringError::Unavailable)?; - match found.len() { - 0 => Ok(None), - 1 => items - .read(&found.remove(0)) - .map(Some) - .map_err(KeyringError::Unavailable), - n => Err(account.duplicates(n)), +impl Keeper { + /// Where `account`'s token file is. + pub(crate) fn file(&self, account: &Account) -> PathBuf { + self.0.join(account.file_name()) } -} -/// Keep `text` as the one item for `account`, read back (INVARIANT 4). -fn set_one(items: &I, account: &Account, text: &str) -> Result<(), KeyringError> { - delete_all(items, account)?; - items - .make(account, text) - .map_err(KeyringError::Unavailable)?; - match get_one(items, account)? { - Some(back) if back == text => Ok(()), - _ => Err(KeyringError::Unavailable( - "it didn't give back the token it was given to keep".into(), - )), - } -} - -/// Remove every item kept for `account`. -fn delete_all(items: &I, account: &Account) -> Result<(), KeyringError> { - for item in items.find(account).map_err(KeyringError::Unavailable)? { - items.remove(item).map_err(KeyringError::Unavailable)?; - } - Ok(()) -} - -/// The OS keyring. -struct Os; - -/// What a keyring error says (INVARIANT 4: locked is said as such). -#[cfg(any(target_os = "linux", target_os = "macos", windows))] -fn refused(e: keyring_core::Error) -> String { - match e { - keyring_core::Error::NoStorageAccess(_) => { - format!("this computer's keyring is locked, or wouldn't let it be read ({e})") - } - e => format!("the keyring refused: {e}"), - } -} - -/// Make an item for `account` in a store's default collection. -#[cfg(any(target_os = "linux", target_os = "macos", windows))] -fn make_in( - store: &dyn keyring_core::api::CredentialStoreApi, - account: &Account, - text: &str, -) -> Result<(), String> { - let label = account.label(); - let modifiers = std::collections::HashMap::from([("label", label.as_str())]); - // Only the Secret Service takes a label; the other stores refuse - // modifiers they don't know, so they are built without. - let modifiers = cfg!(target_os = "linux").then_some(&modifiers); - store - .build(SERVICE, &account.key(), modifiers) - .and_then(|entry| entry.set_password(text)) - .map_err(refused) -} - -/// An item's text, and its removal, the same on every store. -#[cfg(any(target_os = "linux", target_os = "macos", windows))] -fn read_item(item: &keyring_core::Entry) -> Result { - item.get_password().map_err(refused) -} - -#[cfg(any(target_os = "linux", target_os = "macos", windows))] -fn remove_item(item: keyring_core::Entry) -> Result<(), String> { - match item.delete_credential() { - Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), - Err(e) => Err(refused(e)), - } -} - -#[cfg(target_os = "linux")] -mod os { - use super::{Account, Items, SERVICE}; - use keyring_core::api::CredentialStoreApi; - use std::collections::HashMap; - use std::sync::Arc; - use zbus_secret_service_keyring_store::Store; - - /// The Secret Service, reached for one keeper call. - pub(super) struct Service(Arc); - - impl Service { - pub(super) fn reach() -> Result { - Store::new().map(Self).map_err(|e| { - format!("this computer's keyring can't be reached ({e}): is a Secret Service (GNOME Keyring, KWallet) running and unlocked?") - }) + /// The token kept for `account`, if any (INVARIANT 4). + pub fn get(&self, account: &Account) -> Result, TokenFileError> { + match std::fs::read_to_string(self.file(account)) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(refused(e)), } } - impl Items for Service { - type Item = keyring_core::Entry; - - fn find(&self, account: &Account) -> Result, String> { - let key = account.key(); - let spec = HashMap::from([("service", SERVICE), ("username", key.as_str())]); - self.0.search(&spec).map_err(super::refused) - } - - fn read(&self, item: &Self::Item) -> Result { - super::read_item(item) - } - - fn remove(&self, item: Self::Item) -> Result<(), String> { - super::remove_item(item) - } - - fn make(&self, account: &Account, text: &str) -> Result<(), String> { - super::make_in(&*self.0, account, text) - } - } -} - -/// The macOS login Keychain (plan 3.3, D-56): one generic password per -/// account, under the service "wanderbooks". -#[cfg(target_os = "macos")] -mod os { - use super::{Account, Items, SERVICE}; - use apple_native_keyring_store::keychain::Store; - use keyring_core::api::CredentialStoreApi; - use std::collections::HashMap; - use std::sync::Arc; - - /// The login keychain, reached for one keeper call. - pub(super) struct Service(Arc); - - impl Service { - pub(super) fn reach() -> Result { - Store::new().map(Self).map_err(|e| { - format!( - "this Mac's keychain can't be reached ({e}): is the login keychain unlocked?" - ) - }) - } - } - - impl Items for Service { - type Item = keyring_core::Entry; - - fn find(&self, account: &Account) -> Result, String> { - let key = account.key(); - let spec = HashMap::from([("service", SERVICE), ("user", key.as_str())]); - self.0.search(&spec).map_err(super::refused) - } - - fn read(&self, item: &Self::Item) -> Result { - super::read_item(item) - } - - fn remove(&self, item: Self::Item) -> Result<(), String> { - super::remove_item(item) - } - - fn make(&self, account: &Account, text: &str) -> Result<(), String> { - super::make_in(&*self.0, account, text) - } - } -} - -/// The Windows Credential Manager: one generic credential per account -/// (its target name is unique, so a second item for an account cannot -/// exist; INVARIANT 4 holds by construction and is still checked). -#[cfg(windows)] -mod os { - use super::{Account, Items, SERVICE}; - use keyring_core::api::CredentialStoreApi; - use std::sync::Arc; - use windows_native_keyring_store::Store; - - /// The Credential Manager, reached for one keeper call. - pub(super) struct Service(Arc); - - impl Service { - pub(super) fn reach() -> Result { - Store::new() - .map(Self) - .map_err(|e| format!("this computer's Credential Manager can't be reached ({e})")) - } - } - - impl Items for Service { - type Item = keyring_core::Entry; - - /// The credential for the account, if one is there: its target - /// name is the account, so a probe finds it or finds none. - fn find(&self, account: &Account) -> Result, String> { - let entry = self - .0 - .build(SERVICE, &account.key(), None) - .map_err(super::refused)?; - match entry.get_password() { - Ok(_) => Ok(vec![entry]), - Err(keyring_core::Error::NoEntry) => Ok(Vec::new()), - Err(e) => Err(super::refused(e)), - } - } - - fn read(&self, item: &Self::Item) -> Result { - super::read_item(item) - } - - fn remove(&self, item: Self::Item) -> Result<(), String> { - super::remove_item(item) - } - - fn make(&self, account: &Account, text: &str) -> Result<(), String> { - super::make_in(&*self.0, account, text) - } - } -} - -#[cfg(any(target_os = "linux", target_os = "macos", windows))] -impl Keeper for Os { - fn get(&self, account: &Account) -> Result, KeyringError> { - let service = os::Service::reach().map_err(KeyringError::Unavailable)?; - get_one(&service, account) - } - - fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError> { - let service = os::Service::reach().map_err(KeyringError::Unavailable)?; - set_one(&service, account, text) - } - - fn delete(&self, account: &Account) -> Result<(), KeyringError> { - let service = os::Service::reach().map_err(KeyringError::Unavailable)?; - delete_all(&service, account) - } -} - -/// Any other OS has no keyring store yet. -#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] -impl Keeper for Os { - fn get(&self, _: &Account) -> Result, KeyringError> { - Err(KeyringError::Unavailable(NOT_YET.into())) - } - fn set(&self, _: &Account, _: &str) -> Result<(), KeyringError> { - Err(KeyringError::Unavailable(NOT_YET.into())) - } - fn delete(&self, _: &Account) -> Result<(), KeyringError> { - Err(KeyringError::Unavailable(NOT_YET.into())) - } -} - -#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] -const NOT_YET: &str = "agents' tokens are kept in the keyring on Linux, macOS and Windows only"; - -/// Files, for tests (INVARIANT 3): a folder per collection (`default/` -/// where tokens are kept), a file a token. A collection holding a file -/// `locked` can't be read; the keyring holding a file `unreachable` -/// can't be reached. So the tests can lock it, or plant a second item. -struct Files(PathBuf); - -/// The test keyring's default collection. -pub const TEST_DEFAULT: &str = "default"; -/// In a test collection: its items can't be read. -pub const TEST_LOCKED: &str = "locked"; -/// In the test keyring: it can't be reached. -pub const TEST_UNREACHABLE: &str = "unreachable"; - -impl Files { - fn file_name(account: &Account) -> String { - ledger_store::journal::sha256_hex(account.key().as_bytes()) - } -} - -impl Items for Files { - type Item = PathBuf; - - fn find(&self, account: &Account) -> Result, String> { - let fail = |e: std::io::Error| format!("the test keyring refused: {}", e.kind()); - if self.0.join(TEST_UNREACHABLE).exists() { - return Err("the test keyring can't be reached".into()); - } - let collections = match std::fs::read_dir(&self.0) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(fail(e)), - }; - let name = Self::file_name(account); - let mut found = Vec::new(); - for c in collections { - let file = c.map_err(fail)?.path().join(&name); - if file.is_file() { - found.push(file); - } - } - found.sort(); - Ok(found) - } - - fn read(&self, item: &PathBuf) -> Result { - if item.with_file_name(TEST_LOCKED).exists() { - return Err("the test keyring is locked".into()); - } - std::fs::read_to_string(item).map_err(|e| format!("the test keyring refused: {}", e.kind())) - } - - fn remove(&self, item: PathBuf) -> Result<(), String> { - match std::fs::remove_file(item) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(format!("the test keyring refused: {}", e.kind())), - } - } - - fn make(&self, account: &Account, text: &str) -> Result<(), String> { + /// Keep `text` for `account`, replacing any (INVARIANT 3). + pub fn set(&self, account: &Account, text: &str) -> Result<(), TokenFileError> { use std::io::Write; - let fail = |e: std::io::Error| format!("the test keyring refused: {}", e.kind()); - let dir = self.0.join(TEST_DEFAULT); - std::fs::create_dir_all(&dir).map_err(fail)?; + std::fs::create_dir_all(&self.0).map_err(refused)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700)) + .map_err(refused)?; + } + let name = account.file_name(); + let tmp = self.0.join(format!("{name}.tmp{}", std::process::id())); + let _ = std::fs::remove_file(&tmp); let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -477,24 +135,24 @@ impl Items for Files { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } - options - .open(dir.join(Self::file_name(account))) - .and_then(|mut f| f.write_all(text.as_bytes())) - .map_err(fail) - } -} - -impl Keeper for Files { - fn get(&self, account: &Account) -> Result, KeyringError> { - get_one(self, account) + let written = options + .open(&tmp) + .and_then(|mut f| f.write_all(text.as_bytes()).and_then(|()| f.sync_all())) + .and_then(|()| std::fs::rename(&tmp, self.0.join(&name))); + if let Err(e) = written { + let _ = std::fs::remove_file(&tmp); + return Err(refused(e)); + } + Ok(()) } - fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError> { - set_one(self, account, text) - } - - fn delete(&self, account: &Account) -> Result<(), KeyringError> { - delete_all(self, account) + /// Forget `account`'s token (no matter if there is none). + pub fn delete(&self, account: &Account) -> Result<(), TokenFileError> { + match std::fs::remove_file(self.file(account)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(refused(e)), + } } } @@ -517,17 +175,12 @@ mod tests { assert_ne!(Account::new(other.path(), "acme", "agent").key(), key); assert_ne!(Account::new(dir.path(), "beta", "agent").key(), key); assert_ne!(Account::new(dir.path(), "acme", "codex").key(), key); - assert!(!a.label().contains('/')); } #[test] - fn the_test_keyring_keeps_and_forgets() { + fn the_token_file_keeps_and_forgets() { let dir = tempfile::tempdir().unwrap(); - let knobs = Knobs { - file_keyring: true, - ..Knobs::default() - }; - let k = keeper(dir.path(), &knobs); + let k = keeper(dir.path()); let a = Account::new(dir.path(), "acme", "agent"); assert_eq!(k.get(&a).unwrap(), None); k.set(&a, "wb_one").unwrap(); @@ -539,135 +192,45 @@ mod tests { k.delete(&a).unwrap(); k.delete(&a).unwrap(); assert_eq!(k.get(&a).unwrap(), None); + } + + #[test] + fn a_token_file_is_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let k = keeper(dir.path()); + let a = Account::new(dir.path(), "acme", "agent"); + k.set(&a, "wb_first").unwrap(); + // A second create for the same name replaces the first; one file + // remains, and no temporary file is left behind. + k.set(&a, "wb_second").unwrap(); + assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_second")); + let files: Vec<_> = std::fs::read_dir(dir.path().join("tokens")) + .unwrap() + .map(|e| e.unwrap()) + .collect(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].file_name().to_string_lossy(), a.file_name()); #[cfg(unix)] { - k.set(&a, "wb_three").unwrap(); use std::os::unix::fs::PermissionsExt; - let files: Vec<_> = - std::fs::read_dir(dir.path().join("test-keyring").join(TEST_DEFAULT)) - .unwrap() - .collect(); - assert_eq!(files.len(), 1); - let mode = files[0] - .as_ref() - .unwrap() - .metadata() - .unwrap() - .permissions() - .mode(); + let mode = files[0].metadata().unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600); + let folder = dir.path().join("tokens").metadata().unwrap(); + assert_eq!(folder.permissions().mode() & 0o777, 0o700); } } - /// The test keyring with a second collection (INVARIANT 4). - fn two_collections() -> (tempfile::TempDir, Files, Account, PathBuf) { + #[test] + fn an_unreadable_token_file_is_an_error_never_no_token() { let dir = tempfile::tempdir().unwrap(); - let files = Files(dir.path().join("test-keyring")); + let k = keeper(dir.path()); let a = Account::new(dir.path(), "acme", "agent"); - let other = files.0.join("login"); - std::fs::create_dir_all(&other).unwrap(); - (dir, files, a, other) - } - - #[test] - fn two_items_for_one_token_are_said_never_one_of_them_given() { - let (_dir, k, a, other) = two_collections(); - k.set(&a, "wb_new").unwrap(); - std::fs::write(other.join(Files::file_name(&a)), "wb_old").unwrap(); + // A directory where the file belongs. + std::fs::create_dir_all(dir.path().join("tokens").join(a.file_name())).unwrap(); let err = k.get(&a).unwrap_err(); - assert_eq!(err, a.duplicates(2)); - let said = err.to_string(); - assert!( - said.contains("2 items") && said.contains("token create --book acme --replace"), - "{said}" - ); - assert!(!said.contains("wb_"), "{said}"); - // Kept again: every item goes first, and one is left. - k.set(&a, "wb_newer").unwrap(); - assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_newer")); - assert!(!other.join(Files::file_name(&a)).exists()); - // Forgotten in every collection. - std::fs::write(other.join(Files::file_name(&a)), "wb_old").unwrap(); - k.delete(&a).unwrap(); - assert_eq!(k.get(&a).unwrap(), None); - // Another token's name is said in the repair. - let b = Account::new(_dir.path(), "acme", "codex"); - assert!( - b.duplicates(3) - .to_string() - .contains("--name codex --replace") - ); - } - - #[test] - fn a_locked_or_unreachable_keyring_is_an_error_never_no_token() { - let (_dir, k, a, other) = two_collections(); - // The only item, in a locked collection. - std::fs::write(other.join(Files::file_name(&a)), "wb_old").unwrap(); - std::fs::write(other.join(TEST_LOCKED), "").unwrap(); - let err = k.get(&a).unwrap_err(); - assert!(matches!(err, KeyringError::Unavailable(_)), "{err:?}"); - std::fs::remove_file(other.join(TEST_LOCKED)).unwrap(); - assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_old")); - std::fs::write(k.0.join(TEST_UNREACHABLE), "").unwrap(); - for err in [ - k.get(&a).unwrap_err(), - k.set(&a, "wb_new").unwrap_err(), - k.delete(&a).unwrap_err(), - ] { - assert!(matches!(err, KeyringError::Unavailable(_)), "{err:?}"); - } - std::fs::remove_file(k.0.join(TEST_UNREACHABLE)).unwrap(); - assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_old"), "untouched"); - } - - /// A keyring that keeps something other than it was given. - struct Forgetful; - - impl Items for Forgetful { - type Item = (); - fn find(&self, _: &Account) -> Result, String> { - Ok(vec![()]) - } - fn read(&self, _: &()) -> Result { - Ok("wb_someone_elses".into()) - } - fn remove(&self, _: ()) -> Result<(), String> { - Ok(()) - } - fn make(&self, _: &Account, _: &str) -> Result<(), String> { - Ok(()) - } - } - - #[test] - fn a_token_kept_is_read_back_or_refused() { - let dir = tempfile::tempdir().unwrap(); - let a = Account::new(dir.path(), "acme", "agent"); - let err = set_one(&Forgetful, &a, "wb_mine").unwrap_err(); - assert!( - matches!(&err, KeyringError::Unavailable(w) if w.contains("didn't give back")), - "{err:?}" - ); + assert!(err.to_string().contains("token file"), "{err}"); assert!(!err.to_string().contains("wb_")); - } - - /// The real keyring, on a desktop with one (`cargo test -p - /// ledger-host -- --ignored os_keyring`): kept, read, replaced and - /// forgotten, under a service no one else uses. - #[cfg(any(target_os = "linux", target_os = "macos", windows))] - #[test] - #[ignore] - fn os_keyring_keeps_and_forgets() { - let dir = tempfile::tempdir().unwrap(); - let a = Account::new(dir.path(), "ignored-test", "agent"); - let k = Os; - k.set(&a, "wb_test_one").unwrap(); - assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_test_one")); - k.set(&a, "wb_test_two").unwrap(); - assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_test_two")); - k.delete(&a).unwrap(); - assert_eq!(k.get(&a).unwrap(), None); - k.delete(&a).unwrap(); + assert!(k.set(&a, "wb_new").is_err()); + assert!(k.delete(&a).is_err()); } } diff --git a/crates/ledger-mcp/src/admin.rs b/crates/ledger-mcp/src/admin.rs index 0adb04b..a5cbe12 100644 --- a/crates/ledger-mcp/src/admin.rs +++ b/crates/ledger-mcp/src/admin.rs @@ -144,7 +144,7 @@ pub enum Request { }, /// The live tokens. TokenList, - /// Whether a token kept elsewhere (this computer's keyring) is the + /// Whether a token kept elsewhere (its token file) is the /// live one of its books and name: asked by its SHA-256 alone, never /// its text (INVARIANT 6). TokenCheck { diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index a57cfb5..4511b2e 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -255,7 +255,7 @@ impl Books { let Some(presented) = presented else { return Err(( crate::access::UNAUTHORIZED, - "these books need a token, and this request carried none: the human makes one with `wanderbooks token create` (if one was made, `wanderbooks token check` says whether this computer's keyring still gives it)" + "these books need a token, and this request carried none: the human makes one with `wanderbooks token create` (if one was made, `wanderbooks token check` says whether its token file still gives it)" .into(), )); };