merge: U0e, tokens in a file under the data root, the OS keyring removed (branch U0e, d8816f9e; D-63)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 13:32:25 -06:00
commit 75882c5e07
24 files changed
+352 -951

No files matched your search

-2
View File
@@ -70,8 +70,6 @@ jobs:
run: cargo build --workspace --all-targets run: cargo build --workspace --all-targets
- name: Tests - name: Tests
run: cargo test --workspace 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 - name: What the build used
if: always() if: always()
shell: bash shell: bash
+7 -12
View File
@@ -7,10 +7,8 @@
# job sets it, so a skip here is a failure's cousin: the job only goes # job sets it, so a skip here is a failure's cousin: the job only goes
# green when both tests ran. # green when both tests ran.
# #
# The token is minted with the test knob `file_keyring=1` (a file under # The token is minted as on a real server: `token create` keeps it in a
# the root's run/, 0600, ledger-host tokens.rs INVARIANT 3), because a # 0600 file under the root's run/ (ledger-host tokens.rs INVARIANT 3).
# runner has no Secret Service; deploy/README.md says what a real server
# does instead.
name: two-user name: two-user
on: on:
push: push:
@@ -93,16 +91,13 @@ jobs:
as_books "$WB" books adopt /tmp/source --root /srv/wanderbooks --id acme --yes as_books "$WB" books adopt /tmp/source --root /srv/wanderbooks --id acme --yes
as_books "$WB" books create tunnel --root /srv/wanderbooks as_books "$WB" books create tunnel --root /srv/wanderbooks
# Tokens for the agent account (the test), kept in the root's # Tokens for the agent account (the test), kept in the root's run/.
# run/ by the test knob; a real server uses its OS keyring. as_books "$WB" token create --root /srv/wanderbooks --book acme --name ci >/dev/null
as_books "$WB" token create --root /srv/wanderbooks --book acme --name ci \
--test-knobs file_keyring=1 >/dev/null
TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book acme \ TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book acme \
--name ci --test-knobs file_keyring=1) --name ci)
as_books "$WB" token create --root /srv/wanderbooks --book tunnel --name ci \ as_books "$WB" token create --root /srv/wanderbooks --book tunnel --name ci >/dev/null
--test-knobs file_keyring=1 >/dev/null
WRITE_TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book tunnel \ 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. # The check, as the agent account, over the loopback door.
WB_TWO_USER=1 \ WB_TWO_USER=1 \
Generated
-101
View File
@@ -249,17 +249,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 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]] [[package]]
name = "approx" name = "approx"
version = "0.5.1" version = "0.5.1"
@@ -740,15 +729,6 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "block2" name = "block2"
version = "0.5.1" version = "0.5.1"
@@ -888,12 +868,6 @@ dependencies = [
"syn 3.0.7", "syn 3.0.7",
] ]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "byteorder-lite" name = "byteorder-lite"
version = "0.1.0" version = "0.1.0"
@@ -971,15 +945,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.7.0" version = "1.7.0"
@@ -3046,15 +3011,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
dependencies = [
"hmac",
]
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.13.0" version = "0.13.0"
@@ -3514,7 +3470,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [ dependencies = [
"block-padding",
"hybrid-array", "hybrid-array",
] ]
@@ -3764,15 +3719,6 @@ dependencies = [
"mutate_once", "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]] [[package]]
name = "khronos-egl" name = "khronos-egl"
version = "6.0.0" version = "6.0.0"
@@ -3981,9 +3927,7 @@ dependencies = [
name = "ledger-host" name = "ledger-host"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apple-native-keyring-store",
"base64 0.22.1", "base64 0.22.1",
"keyring-core",
"ledger-core", "ledger-core",
"ledger-mcp", "ledger-mcp",
"ledger-store", "ledger-store",
@@ -3996,8 +3940,6 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-util", "tokio-util",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
"zeroize", "zeroize",
] ]
@@ -6369,25 +6311,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@@ -8751,19 +8674,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-numerics" name = "windows-numerics"
version = "0.3.1" version = "0.3.1"
@@ -9243,17 +9153,6 @@ dependencies = [
"zvariant", "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]] [[package]]
name = "zbus_macros" name = "zbus_macros"
version = "5.19.0" version = "5.19.0"
+4 -9
View File
@@ -67,11 +67,9 @@ Sheet is frozen as of 2026-10-11. The remaining work, its order and its
hand-off are `plan/served.md` section 8.0; the destination is one hand-off are `plan/served.md` section 8.0; the destination is one
installed app for Mac, Windows and Linux (P11), then the hosted service installed app for Mac, Windows and Linux (P11), then the hosted service
the plan describes and does not build. Today the agent path runs on the plan describes and does not build. Today the agent path runs on
Linux: tokens live in the OS keyring (the Secret Service on Linux, the Linux and macOS: tokens live in a 0600 file under the private data root
login Keychain on macOS, the Credential Manager on Windows; the last two (D-63); the rest of the host is Unix-only, so Windows is WSL2 only until
are written and compiled by the CI matrix, P11a step 0, and not yet its sockets and data root are ported.
proven on a machine of ours); the rest of the host is Unix-only, so
Windows is WSL2 only until its sockets and data root are ported.
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
[`plan/roadmap.md`](plan/roadmap.md) for where it's going. [`plan/roadmap.md`](plan/roadmap.md) for where it's going.
@@ -90,10 +88,7 @@ them; nothing needs sudo):
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
``` ```
(Linux today. On macOS the same line installs and sets up, and the (Linux and macOS. On Windows run it inside WSL2.)
viewer runs; connecting an agent uses the login Keychain, new with
P11a step 0 and proven only by the CI matrix so far. On Windows run it
inside WSL2.)
Already installed, three commands: `wanderbooks setup`, `wanderbooks books Already installed, three commands: `wanderbooks setup`, `wanderbooks books
create acme`, `wanderbooks connect claude --book acme` (or `connect codex`). create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
+1 -1
View File
@@ -16,7 +16,7 @@
//! the test temp dir finds it), made by `wanderbooks setup --home`; //! the test temp dir finds it), made by `wanderbooks setup --home`;
//! its server runs with an empty environment but PATH, and HOME and //! 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 //! 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. //! 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 //! 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 //! as an agent puts them there: the changes ledger-config's import
+3 -3
View File
@@ -27,9 +27,9 @@ use std::process::{Child, Command, Stdio};
use std::sync::mpsc; use std::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Short timings for tests, and agents' tokens kept in files in the test /// Short timings for tests.
/// root, not this computer's keyring. const KNOBS: &str =
const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,file_keyring=1,backup_quiet=300"; "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
/// The owner passphrase the tests set. /// The owner passphrase the tests set.
const PASS: &str = "correct horse battery"; const PASS: &str = "correct horse battery";
+39 -51
View File
@@ -17,9 +17,9 @@ use std::process::{Child, Command, Stdio};
use std::sync::mpsc; use std::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Short timings for tests (the defaults are the plan's), and agents' /// Short timings for tests (the defaults are the plan's).
/// tokens kept in files in the test root, not this computer's keyring. const KNOBS: &str =
const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,file_keyring=1,backup_quiet=300"; "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 /// A home with a data root, and the program the tests run (possibly a
/// copy, for restarts). /// copy, for restarts).
@@ -653,7 +653,7 @@ fn the_bridge_answers_initialize_itself_during_a_slow_start() {
"--book", "--book",
"acme", "acme",
"--test-knobs", "--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 mut a = Agent::from(c);
let started = Instant::now(); let started = Instant::now();
@@ -722,7 +722,7 @@ fn the_bridge_gives_up_on_books_that_never_open() {
"--book", "--book",
"acme", "acme",
"--test-knobs", "--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); let mut a = Agent::from(c);
a.initialize(); a.initialize();
@@ -1323,7 +1323,7 @@ fn a_call_cancelled_at_once_is_never_made() {
"--book", "--book",
"acme", "acme",
"--test-knobs", "--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); let mut a = Agent::from(c);
a.initialize(); a.initialize();
@@ -1393,7 +1393,7 @@ fn a_signalled_bridge_goes_at_once() {
"--book", "--book",
"acme", "acme",
"--test-knobs", "--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); let mut a = Agent::from(c);
a.initialize(); a.initialize();
@@ -1831,7 +1831,7 @@ fn books_made_mid_session_and_bad_requests() {
"--book", "--book",
"acme", "acme",
"--test-knobs", "--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); let mut d = Agent::from(c);
d.initialize(); 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. // A read-only token, made: the same session uses it from now on.
let (code, made, _) = token(&["create", "--book", "acme", "--scopes", "read"]); let (code, made, _) = token(&["create", "--book", "acme", "--scopes", "read"]);
assert_eq!(code, Some(0), "{made}"); 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!( assert!(
made.contains("books:read") && !made.contains("wb_"), made.contains("books:read") && !made.contains("wb_"),
"{made}" "{made}"
@@ -2124,7 +2127,7 @@ fn the_token_commands_and_the_bridge() {
assert!(err.contains("needs books:write"), "{err}"); assert!(err.contains("needs books:write"), "{err}");
// A name taken; replaced on request, and the running session takes // 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 // 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"]); let (code, _, err) = token(&["create", "--book", "acme"]);
assert_eq!(code, Some(1)); assert_eq!(code, Some(1));
assert!(err.contains("already have a token"), "{err}"); 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.trim().starts_with("wb_") && printed.trim().len() == 67,
"{printed}" "{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"]); let (code, said, _) = token(&["revoke", "--book", "acme"]);
assert_eq!(code, Some(0), "{said}"); assert_eq!(code, Some(0), "{said}");
assert!(b.call("books.chart", json!({})).is_err()); assert!(b.call("books.chart", json!({})).is_err());
@@ -2810,13 +2813,12 @@ fn the_viewers_hold_new_books_and_close_the_books() {
} }
#[test] #[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; use std::os::unix::fs::PermissionsExt;
let home = Home::new("connect-lost"); let home = Home::new("connect-lost");
home.create("acme"); home.create("acme");
// The server keeps the books' agent token; this computer's keyring // The server keeps the books' agent token; its token file is gone.
// lost it. std::fs::remove_dir_all(home.run_dir().join("tokens")).unwrap();
std::fs::remove_dir_all(home.run_dir().join("test-keyring")).unwrap();
let (bin, _log) = fake_agent(&home, "claude"); let (bin, _log) = fake_agent(&home, "claude");
let connect = |path: &Path| { let connect = |path: &Path| {
home.cmd("connect") 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 /// tokens.rs INVARIANT 4, commands.rs INVARIANT 4, bridge.rs INVARIANT
/// 11: `token check` says whether the keyring's token is the books' live /// 11: `token check` says whether the token file's token is the books'
/// one, never its text; a second keyring item for one token is said, and /// live one, never its text; an unreadable token file is said, by the
/// `create --replace` keeps one again; a token the keyring can't keep is /// check and by a bridge, never "no token"; and a token the file can't
/// revoked again; and a bridge that can't read the keyring says so, not /// keep is revoked again.
/// "make a token".
#[test] #[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 home = Home::new("tokencheck");
let out = home.books(&["create", "acme"]); let out = home.books(&["create", "acme"]);
assert!(out.status.success(), "{out:?}"); 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(); let (code, said, _) = check();
assert_eq!(code, Some(0), "{said}"); assert_eq!(code, Some(0), "{said}");
assert!(said.contains("is the live one"), "{said}"); assert!(said.contains("is the live one"), "{said}");
// The one item kept, in the test keyring's default collection. // The one file kept, mode 0600.
let keyring = home.run_dir().join("test-keyring"); let folder = home.run_dir().join("tokens");
let item = std::fs::read_dir(keyring.join("default")) let item = std::fs::read_dir(&folder)
.unwrap() .unwrap()
.map(|e| e.unwrap().path()) .map(|e| e.unwrap().path())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(item.len(), 1); assert_eq!(item.len(), 1);
let item = item[0].clone(); 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. use std::os::unix::fs::PermissionsExt;
let login = keyring.join("login"); let mode = std::fs::metadata(&item).unwrap().permissions().mode();
std::fs::create_dir_all(&login).unwrap(); assert_eq!(mode & 0o777, 0o600);
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}"
);
let mut a = Agent::start(&home, "acme"); let mut a = Agent::start(&home, "acme");
a.initialize(); 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(); a.call("books.start", json!({})).unwrap();
assert!(a.end().is_some()); 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 old = std::fs::read_to_string(&item).unwrap();
let (code, _, _) = token(&["create", "--book", "acme", "--replace"]); let (code, _, _) = token(&["create", "--book", "acme", "--replace"]);
assert_eq!(code, Some(0)); 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.contains("isn't the live one") && said.contains("--replace"),
"{said}" "{said}"
); );
// A keyring that can't be reached: a bridge with no token says so, // A token file that can't be read (a folder in its place): a bridge
// never "make a token"; and a token made then is revoked again. // with no token says so, never "make a token"; and a token made then
// is revoked again.
std::fs::remove_file(&item).unwrap(); std::fs::remove_file(&item).unwrap();
let unreachable = keyring.join("unreachable"); std::fs::create_dir(&item).unwrap();
std::fs::write(&unreachable, "").unwrap();
let mut b = Agent::start(&home, "acme"); let mut b = Agent::start(&home, "acme");
b.initialize(); b.initialize();
let err = b.call("books.start", json!({})).unwrap_err(); let err = b.call("books.start", json!({})).unwrap_err();
assert!( 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}" "{err}"
); );
assert!(!err.contains("token create"), "{err}"); assert!(!err.contains("token create"), "{err}");
assert!(b.end().is_some()); assert!(b.end().is_some());
let (code, _, err) = check(); let (code, _, err) = check();
assert_eq!(code, Some(1)); 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"]); let (code, _, err) = token(&["create", "--book", "acme", "--replace"]);
assert_eq!(code, Some(1)); assert_eq!(code, Some(1));
assert!( assert!(
err.contains("couldn't keep it") && err.contains("revoked again"), err.contains("couldn't keep it") && err.contains("revoked again"),
"{err}" "{err}"
); );
std::fs::remove_file(&unreachable).unwrap(); std::fs::remove_dir(&item).unwrap();
let (_, listed, _) = token(&["list"]); let (_, listed, _) = token(&["list"]);
assert!(listed.contains("No tokens yet"), "{listed}"); assert!(listed.contains("No tokens yet"), "{listed}");
} }
+1 -1
View File
@@ -339,7 +339,7 @@ impl OwnerMenu {
if ui if ui
.button(format!("{} Connect {}", icon::PLUGS_CONNECTED, agent.label())) .button(format!("{} Connect {}", icon::PLUGS_CONNECTED, agent.label()))
.on_hover_text(format!( .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() agent.label()
)) ))
.clicked() .clicked()
-13
View File
@@ -18,10 +18,6 @@ base64 = "0.22"
# `wanderbooks download` checks what arrived against its Repr-Digest. # `wanderbooks download` checks what arrived against its Repr-Digest.
sha2 = "0.10" sha2 = "0.10"
tokio-util = { version = "0.7", features = ["rt"] } 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. # The owner passphrase is typed at the terminal, unechoed.
rpassword = "7" rpassword = "7"
# Token texts and passphrases are wiped from memory when dropped. # Token texts and passphrases are wiped from memory when dropped.
@@ -30,15 +26,6 @@ zeroize = "1"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" 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] [dev-dependencies]
tempfile = "3" tempfile = "3"
+77 -104
View File
@@ -42,17 +42,16 @@
//! are not passed on, in initialize or in a request's own metadata. //! 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 //! 10. Ended by its stdin or by a signal, the bridge ends its upstream
//! session. //! session.
//! 11. Every request upstream carries its token, read from the keyring //! 11. Every request upstream carries its token, read from the token
//! (tokens.rs) before each new session (even with one held: one the //! file (tokens.rs) before each new session (even with one held: one
//! keyring no longer gives is said on stderr, and the keyring's used) //! 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 //! and before each request until it is found, and again when the
//! door refuses it (the call then sent once more if another was //! 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 //! found), so a token made or replaced mid-session is used from the
//! next call. Without one, calls are answered with the server's //! next call. Without one, calls are answered with the server's
//! refusal, which says how the human makes one; but when the keyring //! refusal, which says how the human makes one; but when the token
//! couldn't be read (locked, unreachable, or holding several items //! file is there and couldn't be read, with that instead: the token
//! for the token), with that instead: the token is not said to be //! is not said to be missing. The token is never written to the agent or to stderr.
//! missing. The token is never written to the agent or to stderr.
//! ``` //! ```
use crate::knobs::{FLAG, Knobs}; use crate::knobs::{FLAG, Knobs};
@@ -143,13 +142,13 @@ struct Bridge {
book: String, book: String,
/// The name of the token it uses (tokens.rs). /// The name of the token it uses (tokens.rs).
token_name: String, 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<Option<String>>, token: Mutex<Option<String>>,
/// Where it is kept: its account, and the root's `run/`. /// Where it is kept: its account, and the root's `run/`.
keyring: std::sync::OnceLock<(crate::tokens::Account, PathBuf)>, token_file: std::sync::OnceLock<(crate::tokens::Account, PathBuf)>,
/// Why the keyring last couldn't be read, if it couldn't (INVARIANT /// Why the token file last couldn't be read, if it couldn't (INVARIANT
/// 11). /// 11).
keyring_trouble: Mutex<Option<crate::tokens::KeyringError>>, token_file_trouble: Mutex<Option<crate::tokens::TokenFileError>>,
http: reqwest::Client, http: reqwest::Client,
knobs: Knobs, knobs: Knobs,
to_agent: mpsc::UnboundedSender<Value>, to_agent: mpsc::UnboundedSender<Value>,
@@ -179,9 +178,9 @@ enum Owner {
/// before it counts as not answering. /// before it counts as not answering.
const UPSTREAM_WAIT: Duration = Duration::from_secs(10); 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. /// 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. /// Where one of the agent's requests stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -339,9 +338,9 @@ impl Bridge {
format!("http://localhost/b/{}/mcp", self.book) 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 /// 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. /// then tells the agent what to do.
async fn find_token(&self) { async fn find_token(&self) {
if self if self
@@ -362,19 +361,19 @@ impl Bridge {
.is_some() .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 /// token other than the one held was found (one made since, or one
/// that replaced it). /// that replaced it).
async fn read_token(&self) -> bool { async fn read_token(&self) -> bool {
self.read_token_within(self.knobs.queue_wait).await 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 { 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; return false;
}; };
let keeper = crate::tokens::keeper(&run_dir, &self.knobs); let keeper = crate::tokens::keeper(&run_dir);
let read = tokio::time::timeout( let read = tokio::time::timeout(
wait, wait,
tokio::task::spawn_blocking(move || keeper.get(&account)), tokio::task::spawn_blocking(move || keeper.get(&account)),
@@ -382,13 +381,13 @@ impl Bridge {
.await; .await;
let read = match read { let read = match read {
Ok(Ok(read)) => read, Ok(Ok(read)) => read,
Ok(Err(_)) | Err(_) => Err(crate::tokens::KeyringError::Unavailable( Ok(Err(_)) | Err(_) => Err(crate::tokens::TokenFileError::new(
"it didn't give the token in time: is it locked?".into(), "the token file didn't give the token in time",
)), )),
}; };
let trouble = read.as_ref().err().cloned(); let trouble = read.as_ref().err().cloned();
*self *self
.keyring_trouble .token_file_trouble
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()) = trouble; .unwrap_or_else(|e| e.into_inner()) = trouble;
match read { match read {
@@ -397,7 +396,7 @@ impl Bridge {
let new = held.as_deref() != Some(token.as_str()); let new = held.as_deref() != Some(token.as_str());
if new && held.is_some() { if new && held.is_some() {
eprintln!( 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); *held = Some(token);
@@ -412,24 +411,21 @@ impl Bridge {
} }
/// What the agent is told instead of the door's "make a token" when /// 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). /// 11).
fn keyring_refusal(&self) -> Option<String> { fn token_file_refusal(&self) -> Option<String> {
if self.holds_token() { if self.holds_token() {
return None; return None;
} }
let trouble = self let trouble = self
.keyring_trouble .token_file_trouble
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()) .unwrap_or_else(|e| e.into_inner())
.clone()?; .clone()?;
Some(match trouble { Some(format!(
crate::tokens::KeyringError::Unavailable(why) => 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).",
"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
self.book, self.book ))
),
duplicates => duplicates.to_string(),
})
} }
/// A request upstream, carrying the token (INVARIANT 11). /// A request upstream, carrying the token (INVARIANT 11).
@@ -464,7 +460,7 @@ impl Bridge {
self.find_token().await; self.find_token().await;
let (sent, unauthorized) = self.send_once(message, up).await; let (sent, unauthorized) = self.send_once(message, up).await;
// Refused at the door for its token (not run): one made or // 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 // 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 // the call waits for a new one (opened with the new token); with
// none it is sent once more at once. // none it is sent once more at once.
@@ -474,9 +470,9 @@ impl Bridge {
} }
return self.send_once(message, up).await.0; 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). // 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); return Sent::Refused(why);
} }
sent sent
@@ -583,10 +579,10 @@ impl Bridge {
epoch: u64, epoch: u64,
) -> Result<Upstream, Option<String>> { ) -> Result<Upstream, Option<String>> {
// Read again before every session, even with a token held // Read again before every session, even with a token held
// (INVARIANT 11): a held one the keyring no longer gives isn't // (INVARIANT 11): a held one the token file no longer gives isn't
// kept on in silence. A slow keyring leaves the one held in use. // kept on in silence. A slow token file leaves the one held in use.
if self.holds_token() { 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; .await;
} else { } else {
self.read_token().await; self.read_token().await;
@@ -601,12 +597,12 @@ impl Bridge {
}; };
let mut response = post().await.map_err(|_| None)?; let mut response = post().await.map_err(|_| None)?;
// Refused for its token: once more with one made or replaced // 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. // read, that is said instead of the door's words.
if response.status() == reqwest::StatusCode::UNAUTHORIZED { if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if self.read_token().await { if self.read_token().await {
response = post().await.map_err(|_| None)?; 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)); return Err(Some(why));
} }
} }
@@ -918,7 +914,7 @@ impl Bridge {
let run_dir = opener.run_dir().to_path_buf(); let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&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 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; let mut epoch = 0u64;
loop { loop {
let mut lease = match opener.hold().await { 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!(" 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!(" 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!(" 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`. /// `wanderbooks mcp --book ID`.
@@ -1340,8 +1336,8 @@ pub fn run(
book, book,
token_name, token_name,
token: Mutex::new(None), token: Mutex::new(None),
keyring: std::sync::OnceLock::new(), token_file: std::sync::OnceLock::new(),
keyring_trouble: Mutex::new(None), token_file_trouble: Mutex::new(None),
http, http,
knobs, knobs,
to_agent, to_agent,
@@ -1683,11 +1679,11 @@ mod against_a_server {
let (link, _) = watch::channel(Link::Opening); let (link, _) = watch::channel(Link::Opening);
let bridge = Arc::new(Bridge { let bridge = Arc::new(Bridge {
book: "acme".into(), 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_name: crate::tokens::DEFAULT_NAME.into(),
token: Mutex::new(Some("wb_test".into())), token: Mutex::new(Some("wb_test".into())),
keyring: std::sync::OnceLock::new(), token_file: std::sync::OnceLock::new(),
keyring_trouble: Mutex::new(None), token_file_trouble: Mutex::new(None),
http, http,
knobs, knobs,
to_agent, to_agent,
@@ -1899,23 +1895,21 @@ mod against_a_server {
assert!(none.session.is_none() && none.init.is_none()); assert!(none.session.is_none() && none.init.is_none());
} }
/// A bridge whose token is kept in a test keyring in `dir` (none held /// A bridge whose token is kept in a token file under `dir` (none
/// yet), and that keyring's folder. /// held yet), and the keeper of that file.
fn bridge_with_keyring( fn bridge_with_token_file(
socket: &Path, socket: &Path,
dir: &Path, dir: &Path,
) -> (Arc<Bridge>, mpsc::UnboundedReceiver<Value>, PathBuf) { ) -> (
let knobs = Knobs { Arc<Bridge>,
file_keyring: true, mpsc::UnboundedReceiver<Value>,
..knobs(1000, 1000) crate::tokens::Keeper,
}; ) {
let (b, rx) = bridge_at(socket, knobs); let (b, rx) = bridge_at(socket, knobs(1000, 1000));
*b.token.lock().unwrap() = None; *b.token.lock().unwrap() = None;
let account = crate::tokens::Account::new(dir, "acme", crate::tokens::DEFAULT_NAME); let account = crate::tokens::Account::new(dir, "acme", crate::tokens::DEFAULT_NAME);
b.keyring.set((account, dir.to_path_buf())).unwrap(); b.token_file.set((account, dir.to_path_buf())).unwrap();
let keyring = dir.join("test-keyring"); (b, rx, crate::tokens::keeper(dir))
std::fs::create_dir_all(&keyring).unwrap();
(b, rx, keyring)
} }
/// The door's refusal of a request with no token. /// 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, /// INVARIANT 11: with no token because the token file is there and
/// the agent is told that, never to make a token; with a keyring that /// couldn't be read, the agent is told that, never to make a token;
/// simply has none, the door's words. /// with a token file that simply isn't there, the door's words.
#[tokio::test] #[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 server = Server::start();
let dir = tempfile::tempdir().unwrap(); 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 call: Value = serde_json::from_str(CALL).unwrap();
let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}}); let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}});
let unreachable = keyring.join(crate::tokens::TEST_UNREACHABLE); // A directory where the token file belongs: unreadable.
std::fs::write(&unreachable, "").unwrap(); let file = keeper.file(&account);
std::fs::create_dir_all(&file).unwrap();
server.script([no_token_at_the_door(), no_token_at_the_door()]); server.script([no_token_at_the_door(), no_token_at_the_door()]);
let said = what(&b.send(&call, &up(1)).await); let said = what(&b.send(&call, &up(1)).await);
assert!( 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 itself is fine")
&& said.contains("token check --book acme"), && said.contains("token check --book acme"),
"{said}" "{said}"
@@ -1963,41 +1959,22 @@ mod against_a_server {
.all(|s| !s.headers.contains_key("authorization")), .all(|s| !s.headers.contains_key("authorization")),
"no bearer was sent" "no bearer was sent"
); );
// Two items for the token: that, and how to keep one again. // No file at all: the door's own words.
std::fs::remove_file(&unreachable).unwrap(); std::fs::remove_dir_all(&file).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();
server.script([no_token_at_the_door()]); server.script([no_token_at_the_door()]);
let said = what(&b.send(&call, &up(1)).await); let said = what(&b.send(&call, &up(1)).await);
assert!(said.contains("wanderbooks token create"), "{said}"); assert!(said.contains("wanderbooks token create"), "{said}");
} }
/// INVARIANT 11: before each new session the keyring is read again, /// INVARIANT 11: before each new session the token file is read
/// even with a token held, and the keyring's token is the one used. /// again, even with a token held, and the file's token is the one used.
#[tokio::test] #[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 server = Server::start();
let dir = tempfile::tempdir().unwrap(); 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()); *b.token.lock().unwrap() = Some("wb_held".into());
let account = b.keyring.get().unwrap().0.clone(); let account = b.token_file.get().unwrap().0.clone();
let keeper = crate::tokens::keeper(dir.path(), &b.knobs);
keeper.set(&account, "wb_kept").unwrap(); keeper.set(&account, "wb_kept").unwrap();
let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}}); let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}});
let result = json!({"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, let result = json!({"protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
@@ -2021,18 +1998,14 @@ mod against_a_server {
.collect() .collect()
}; };
assert_eq!(bearers(&server), ["Bearer wb_kept", "Bearer wb_kept"]); assert_eq!(bearers(&server), ["Bearer wb_kept", "Bearer wb_kept"]);
// A keyring that can't be read then: the token held goes on. // A token file that can't be read then: the token held goes on.
std::fs::write( let file = keeper.file(&account);
dir.path() std::fs::remove_file(&file).unwrap();
.join("test-keyring") std::fs::create_dir_all(&file).unwrap();
.join(crate::tokens::TEST_UNREACHABLE),
"",
)
.unwrap();
server.script(opened()); server.script(opened());
b.initialize_upstream(&params, 2).await.unwrap(); b.initialize_upstream(&params, 2).await.unwrap();
assert_eq!(bearers(&server)[2..], ["Bearer wb_kept", "Bearer wb_kept"]); 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] #[tokio::test]
+33 -35
View File
@@ -22,14 +22,15 @@
//! 3. Adoption and restoring are previewed first and go ahead only on a //! 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 //! yes at the terminal, or --yes; a vault beside the books must be
//! named (or --no-vault given): it is never silently left behind. //! 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 //! 4. A token made is kept in its token file (tokens.rs), and read back
//! kept, before anything is said of it, and its text is printed only //! from it as kept, before anything is said of it, and its text is
//! by `token print`; one that can't be kept (or read back) is revoked //! printed only by `token print`; one that can't be kept (or read
//! again. A token revoked is forgotten by the keyring too. `token //! back) is revoked again. A token revoked is forgotten by the token
//! check` says whether the keyring's token is the books' live one, //! file too. `token check` says whether the file's token is the
//! by its SHA-256 alone, never its text. `connect` makes the books' agent token only when the keyring //! books' live one, by its SHA-256 alone, never its text. `connect`
//! has none, replacing the server's (no one could be using it: the //! makes the books' agent token only when there is no token file for
//! bridge reads its token from this keyring). //! 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; //! 5. The viewer's hold lets go only when the owner closes the books;
//! books that stop, or won't open, are tried again. //! 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 //! 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<Response, String> {
} }
/// [`with_server`], with the root's `run/` folder too (where the /// [`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> { fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBuf), String> {
let rt = tokio::runtime::Builder::new_multi_thread() let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
@@ -1127,7 +1128,7 @@ pub fn connect(
"--help" | "-h" => { "--help" | "-h" => {
eprintln!("usage: {prog} claude|codex --book ID"); eprintln!("usage: {prog} claude|codex --book ID");
eprintln!( 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(()); return Ok(());
} }
@@ -1160,7 +1161,7 @@ pub fn connect(
} }
/// Let the agent `agent` ("claude" or "codex") reach the books `book`: /// 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 <book>` added to the agent's own list as /// mcp --book <book>` added to the agent's own list as
/// `wanderbooks-<book>`. What was done, a line each. /// `wanderbooks-<book>`. What was done, a line each.
pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<String>, String> { pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<String>, String> {
@@ -1207,22 +1208,19 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)") format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)")
})?; })?;
let mut lines = Vec::new(); let mut lines = Vec::new();
// The token: kept in the keyring, or made now. // The token: kept in its file, or made now.
let found = let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?; ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?; let run = found.run_dir().map_err(|e| e.to_string())?;
let account = crate::tokens::Account::new(found.path(), &book, crate::tokens::DEFAULT_NAME); let account = crate::tokens::Account::new(found.path(), &book, crate::tokens::DEFAULT_NAME);
if crate::tokens::keeper(&run, &common.knobs) if crate::tokens::keeper(&run).get(&account)?.is_none() {
.get(&account)?
.is_none()
{
let scopes = ledger_store::auth::Scope::ALL let scopes = ledger_store::auth::Scope::ALL
.iter() .iter()
.map(|s| s.as_str().to_string()) .map(|s| s.as_str().to_string())
.collect(); .collect();
make_token(common, &book, crate::tokens::DEFAULT_NAME, scopes, true)?; make_token(common, &book, crate::tokens::DEFAULT_NAME, scopes, true)?;
lines.push(format!( lines.push(format!(
"Made the agent token of the books {book}, kept in this computer's keyring." "Made the agent token of the books {book}, kept in a token file in the data root."
)); ));
} }
// The agent's own list: the books' entry made anew. // The agent's own list: the books' entry made anew.
@@ -1291,8 +1289,8 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
Ok(lines) Ok(lines)
} }
/// Make the token `name` for `book` (the server does) and keep it in this /// Make the token `name` for `book` (the server does) and keep it in its
/// computer's keyring (INVARIANT 4: kept first, or taken back). /// token file (INVARIANT 4: kept first, or taken back).
fn make_token( fn make_token(
common: &Common, common: &Common,
book: &str, book: &str,
@@ -1315,7 +1313,7 @@ fn make_token(
let root = run_dir.parent().unwrap_or(&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); let account = crate::tokens::Account::new(&root, book, name);
// Kept, and read back as kept: else taken back (INVARIANT 4). // Kept, and read back as kept: else taken back (INVARIANT 4).
let keeper = crate::tokens::keeper(&run_dir, &common.knobs); let keeper = crate::tokens::keeper(&run_dir);
let kept = keeper let kept = keeper
.set(&account, &text) .set(&account, &text)
.map_err(String::from) .map_err(String::from)
@@ -1345,7 +1343,7 @@ fn make_token(
"" ""
}; };
return Err(format!( return Err(format!(
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}" "the token was made, but its token file couldn't keep it ({why}), {after}.{replaced}"
)); ));
} }
Ok(info) Ok(info)
@@ -2047,8 +2045,8 @@ fn token_usage(prog: &str) {
eprintln!(" {prog} revoke --book ID [--name NAME]"); eprintln!(" {prog} revoke --book ID [--name NAME]");
eprintln!(" {prog} check --book ID [--name NAME]"); eprintln!(" {prog} check --book ID [--name NAME]");
eprintln!(" {prog} print --book ID [--name NAME]"); eprintln!(" {prog} print --book ID [--name NAME]");
eprintln!(" create make an agent's token for the books ID and keep it in this"); eprintln!(" create make an agent's token for the books ID and keep it in its");
eprintln!(" computer's keyring, where `wanderbooks mcp --book ID` finds it"); eprintln!(" token file, where `wanderbooks mcp --book ID` finds it");
eprintln!(" (NAME: agent unless given; LIST: read,write,post,admin unless"); eprintln!(" (NAME: agent unless given; LIST: read,write,post,admin unless");
eprintln!(" given; --replace ends a token of the same name)"); eprintln!(" given; --replace ends a token of the same name)");
eprintln!(" list the tokens there are (never their text)"); eprintln!(" list the tokens there are (never their text)");
@@ -2129,7 +2127,7 @@ pub fn token(
let book = need_book()?; let book = need_book()?;
let info = make_token(&common, &book, &name, scopes, replace).map_err(fail)?; let info = make_token(&common, &book, &name, scopes, replace).map_err(fail)?;
println!( println!(
"Made the token {:?} for the books {} ({}), kept in this computer's keyring.", "Made the token {:?} for the books {} ({}), kept in a token file in the data root.",
info.name, info.name,
info.book, info.book,
info.scopes.join(", ") info.scopes.join(", ")
@@ -2177,11 +2175,11 @@ pub fn token(
Response::TokenRevoked(t) => { Response::TokenRevoked(t) => {
let root = run_dir.parent().unwrap_or(&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); 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); println!("Revoked the token {:?} for the books {}.", t.name, t.book);
if let Err(why) = forgot { if let Err(why) = forgot {
eprintln!( 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(()) Ok(())
@@ -2198,11 +2196,11 @@ pub fn token(
let root = run_dir.parent().unwrap_or(&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); let account = crate::tokens::Account::new(&root, &book, &name);
// Its SHA-256 alone leaves here (INVARIANT 4). // 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) .get(&account)
.map_err(|e| { .map_err(|e| {
fail(format!( 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| { .map(|text| {
@@ -2232,20 +2230,20 @@ pub fn token(
let said = match (kept.is_some(), live, matches) { let said = match (kept.is_some(), live, matches) {
(true, Some(t), true) => { (true, Some(t), true) => {
println!( 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 t.created
); );
return Ok(()); return Ok(());
} }
(true, Some(t), false) => format!( (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 t.created
), ),
(true, None, _) => format!( (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!( (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 t.created
), ),
(false, None, _) => format!( (false, None, _) => format!(
@@ -2262,7 +2260,7 @@ pub fn token(
let run_dir = opener.run_dir().to_path_buf(); let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&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); 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)) => { Ok(Some(text)) => {
println!("{text}"); println!("{text}");
Ok(()) Ok(())
@@ -2361,7 +2359,7 @@ pub fn passphrase(
} }
/// A request to a book over the server's socket, with the agent's token /// 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. /// headers wanted, and its body.
fn on_the_socket( fn on_the_socket(
common: &Common, common: &Common,
@@ -2389,7 +2387,7 @@ fn on_the_socket(
let run_dir = opener.run_dir().to_path_buf(); let run_dir = opener.run_dir().to_path_buf();
let root = run_dir.parent().unwrap_or(&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 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)) let token = tokio::task::spawn_blocking(move || keeper.get(&account))
.await .await
.map_err(|e| e.to_string())?? .map_err(|e| e.to_string())??
+1 -20
View File
@@ -27,9 +27,6 @@ pub struct Knobs {
pub queue_wait: Duration, pub queue_wait: Duration,
/// How long a command waits for the books to open. /// How long a command waits for the books to open.
pub open_wait: Duration, 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 /// How long the books stay unwritten before the server backs them up
/// (plan 3.7: 30 s), passed to the server as `--backup-quiet`. /// (plan 3.7: 30 s), passed to the server as `--backup-quiet`.
pub backup_quiet: Duration, pub backup_quiet: Duration,
@@ -48,7 +45,6 @@ impl Default for Knobs {
init_wait: Duration::from_secs(5), init_wait: Duration::from_secs(5),
queue_wait: Duration::from_secs(60), queue_wait: Duration::from_secs(60),
open_wait: Duration::from_secs(120), open_wait: Duration::from_secs(120),
file_keyring: false,
backup_quiet: Duration::from_secs(30), backup_quiet: Duration::from_secs(30),
drill_first: Duration::from_secs(60), drill_first: Duration::from_secs(60),
} }
@@ -66,14 +62,6 @@ impl Knobs {
let (name, value) = pair let (name, value) = pair
.split_once('=') .split_once('=')
.ok_or_else(|| format!("{FLAG}: {pair:?} isn't name=milliseconds"))?; .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 let ms: u64 = value
.parse() .parse()
.map_err(|_| format!("{FLAG}: {value:?} isn't a number of milliseconds"))?; .map_err(|_| format!("{FLAG}: {value:?} isn't a number of milliseconds"))?;
@@ -114,9 +102,6 @@ impl Knobs {
parts.push(format!("{name}={}", now.as_millis())); parts.push(format!("{name}={}", now.as_millis()));
} }
} }
if self.file_keyring {
parts.push("file_keyring=1".into());
}
(!parts.is_empty()).then(|| parts.join(",")) (!parts.is_empty()).then(|| parts.join(","))
} }
@@ -168,10 +153,6 @@ mod tests {
let args = k.args(); let args = k.args();
assert_eq!(args[0], FLAG); assert_eq!(args[0], FLAG);
assert_eq!(Knobs::parse(&args[1]).unwrap(), k); 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(); let one = Knobs::parse("close_after=1").unwrap();
assert_eq!(one.to_arg().as_deref(), Some("close_after=1")); assert_eq!(one.to_arg().as_deref(), Some("close_after=1"));
} }
@@ -183,7 +164,7 @@ mod tests {
"close_after=x", "close_after=x",
"nope=1", "nope=1",
"close_after=-1", "close_after=-1",
"file_keyring=yes", "token_file=1",
] { ] {
assert!(Knobs::parse(bad).is_err(), "{bad}"); assert!(Knobs::parse(bad).is_err(), "{bad}");
} }
+1 -1
View File
@@ -8,7 +8,7 @@
//! [`host::run`] (`wanderbooks host`), [`bridge::run`] (`wanderbooks //! [`host::run`] (`wanderbooks host`), [`bridge::run`] (`wanderbooks
//! mcp`), and the owner's commands in [`commands`] (`serve`, `close`, //! mcp`), and the owner's commands in [`commands`] (`serve`, `close`,
//! `books`, `token`, `connect`, `doctor`, `passphrase`, and what the //! `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`]). //! ([`tokens`]).
#[cfg(unix)] #[cfg(unix)]
+115 -552
View File
@@ -1,90 +1,65 @@
//! Where an agent's token is kept on this computer: the OS keyring (plan //! Where an agent's token is kept on this computer: a file in the data
//! 3.3, 3.5, D-23), read by the bridge, so no agent's configuration //! root's `run/` folder, read by the bridge, so no agent's configuration
//! holds it. //! 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 //! ```text
//! GOAL: `wanderbooks token create` keeps the token it was shown where //! GOAL: `wanderbooks token create` keeps the token it was shown where
//! the bridge finds it, and nowhere an agent's configuration or the //! the bridge finds it, and nowhere an agent's configuration or the
//! screen would show it. //! screen would show it.
//! SOURCE: plan 3.3 ("kept in the OS keyring, minted by wanderbooks //! SOURCE: plan 3.3 and D-63 ("a 0600 file under the 0700 data root, on
//! connect, revocable"), 2.1 ("Local token: OS keyring, read by //! every OS"), 2.1 ("Local token ... read by the bridge"), 3.5
//! the bridge"), 3.5 ("plaintext in the user's keyring, which any //! (any process running as that user can read it: advisory
//! process running as that user can read (advisory locally)"). //! locally, I-1).
//! INVARIANTS: //! INVARIANTS:
//! 1. A token is kept under the service "wanderbooks" and an account //! 1. A token is kept in a file named by a hash of an account naming
//! naming the root (a hash of its path, never the path), the book //! the root (a hash of its path, never the path), the book and the
//! and the token's name, so two roots or two books never share one. //! 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 //! 2. Nothing here prints or logs a token's text, and no error repeats
//! it. //! it.
//! 3. The keyring stands in for nothing else: with no keyring the //! 3. The file is in the root's `run/tokens/`, mode 0600 on Unix, and
//! command says so; only the test knob `file_keyring` keeps tokens //! written atomically: a temporary file in the same folder, made
//! in files instead (in the root's `run/`, 0600). //! 0600 before any byte is written, then renamed over the old one,
//! 4. One account, one item: keeping a token first removes every item //! so a reader sees the old token or the new one, never half.
//! kept for its account in any collection, then makes one and reads //! 4. A token file that is there but can't be read is an error, never
//! it back (else it is refused); reading finds the items in every //! "no token"; only a missing file is no token.
//! 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".
//! ``` //! ```
use crate::knobs::Knobs;
use std::path::{Path, PathBuf}; 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. /// A token's name when none is given.
pub const DEFAULT_NAME: &str = ledger_store::auth::DEFAULT_NAME; 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum KeyringError { pub struct TokenFileError(String);
/// 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,
},
}
impl std::fmt::Display for KeyringError { impl TokenFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { /// An error with `why`, which never holds a token's text.
match self { pub(crate) fn new(why: &str) -> Self {
Self::Unavailable(why) => f.write_str(why), Self(why.to_string())
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 From<KeyringError> for String { impl std::fmt::Display for TokenFileError {
fn from(e: KeyringError) -> Self { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<TokenFileError> for String {
fn from(e: TokenFileError) -> Self {
e.to_string() e.to_string()
} }
} }
/// A place tokens are kept. fn refused(e: std::io::Error) -> TokenFileError {
pub trait Keeper: Send + Sync { TokenFileError(format!("the token file refused: {}", e.kind()))
/// The token kept for `account`, if any (INVARIANT 4).
fn get(&self, account: &Account) -> Result<Option<String>, 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>;
} }
/// Whose token: a root's book's token of a name (INVARIANT 1). /// Whose token: a root's book's token of a name (INVARIANT 1).
@@ -107,369 +82,52 @@ impl Account {
} }
} }
/// The keyring's account name: `<root tag>/<book>/<name>`. /// The account's name: `<root tag>/<book>/<name>`.
pub fn key(&self) -> String { pub fn key(&self) -> String {
format!("{}/{}/{}", self.root_tag, self.book, self.name) format!("{}/{}/{}", self.root_tag, self.book, self.name)
} }
/// What the keyring's own window calls it. fn file_name(&self) -> String {
fn label(&self) -> String { ledger_store::journal::sha256_hex(self.key().as_bytes())
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(),
}
} }
} }
/// The keeper in force: the OS keyring, or files for tests (INVARIANT 3). /// The place tokens are kept: files in a root's `run/` folder.
pub fn keeper(run_dir: &Path, knobs: &Knobs) -> Box<dyn Keeper> { pub struct Keeper(PathBuf);
if knobs.file_keyring {
Box::new(Files(run_dir.join("test-keyring"))) /// The keeper for the root whose `run/` folder is `run_dir`.
} else { pub fn keeper(run_dir: &Path) -> Keeper {
Box::new(Os) Keeper(run_dir.join("tokens"))
}
} }
/// Items kept under an account, perhaps in several collections: the impl Keeper {
/// keyring's or the test files' (INVARIANT 4 is kept once, over these). /// Where `account`'s token file is.
trait Items { pub(crate) fn file(&self, account: &Account) -> PathBuf {
/// One item. self.0.join(account.file_name())
type Item;
/// Every item kept for `account`, in any collection.
fn find(&self, account: &Account) -> Result<Vec<Self::Item>, String>;
/// An item's text.
fn read(&self, item: &Self::Item) -> Result<String, String>;
/// 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<I: Items>(items: &I, account: &Account) -> Result<Option<String>, 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)),
} }
}
/// Keep `text` as the one item for `account`, read back (INVARIANT 4). /// The token kept for `account`, if any (INVARIANT 4).
fn set_one<I: Items>(items: &I, account: &Account, text: &str) -> Result<(), KeyringError> { pub fn get(&self, account: &Account) -> Result<Option<String>, TokenFileError> {
delete_all(items, account)?; match std::fs::read_to_string(self.file(account)) {
items Ok(text) => Ok(Some(text)),
.make(account, text) Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
.map_err(KeyringError::Unavailable)?; Err(e) => Err(refused(e)),
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<I: Items>(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<String, String> {
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<Store>);
impl Service {
pub(super) fn reach() -> Result<Self, String> {
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?")
})
} }
} }
impl Items for Service { /// Keep `text` for `account`, replacing any (INVARIANT 3).
type Item = keyring_core::Entry; pub fn set(&self, account: &Account, text: &str) -> Result<(), TokenFileError> {
fn find(&self, account: &Account) -> Result<Vec<Self::Item>, 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<String, String> {
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<Store>);
impl Service {
pub(super) fn reach() -> Result<Self, String> {
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<Vec<Self::Item>, 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<String, String> {
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<Store>);
impl Service {
pub(super) fn reach() -> Result<Self, String> {
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<Vec<Self::Item>, 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<String, String> {
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<Option<String>, 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<Option<String>, 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<Vec<PathBuf>, 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<String, String> {
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> {
use std::io::Write; use std::io::Write;
let fail = |e: std::io::Error| format!("the test keyring refused: {}", e.kind()); std::fs::create_dir_all(&self.0).map_err(refused)?;
let dir = self.0.join(TEST_DEFAULT); #[cfg(unix)]
std::fs::create_dir_all(&dir).map_err(fail)?; {
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(); let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true); options.write(true).create_new(true);
#[cfg(unix)] #[cfg(unix)]
@@ -477,24 +135,24 @@ impl Items for Files {
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600); options.mode(0o600);
} }
options let written = options
.open(dir.join(Self::file_name(account))) .open(&tmp)
.and_then(|mut f| f.write_all(text.as_bytes())) .and_then(|mut f| f.write_all(text.as_bytes()).and_then(|()| f.sync_all()))
.map_err(fail) .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));
impl Keeper for Files { }
fn get(&self, account: &Account) -> Result<Option<String>, KeyringError> { Ok(())
get_one(self, account)
} }
fn set(&self, account: &Account, text: &str) -> Result<(), KeyringError> { /// Forget `account`'s token (no matter if there is none).
set_one(self, account, text) pub fn delete(&self, account: &Account) -> Result<(), TokenFileError> {
} match std::fs::remove_file(self.file(account)) {
Ok(()) => Ok(()),
fn delete(&self, account: &Account) -> Result<(), KeyringError> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
delete_all(self, account) 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(other.path(), "acme", "agent").key(), key);
assert_ne!(Account::new(dir.path(), "beta", "agent").key(), key); assert_ne!(Account::new(dir.path(), "beta", "agent").key(), key);
assert_ne!(Account::new(dir.path(), "acme", "codex").key(), key); assert_ne!(Account::new(dir.path(), "acme", "codex").key(), key);
assert!(!a.label().contains('/'));
} }
#[test] #[test]
fn the_test_keyring_keeps_and_forgets() { fn the_token_file_keeps_and_forgets() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let knobs = Knobs { let k = keeper(dir.path());
file_keyring: true,
..Knobs::default()
};
let k = keeper(dir.path(), &knobs);
let a = Account::new(dir.path(), "acme", "agent"); let a = Account::new(dir.path(), "acme", "agent");
assert_eq!(k.get(&a).unwrap(), None); assert_eq!(k.get(&a).unwrap(), None);
k.set(&a, "wb_one").unwrap(); k.set(&a, "wb_one").unwrap();
@@ -539,135 +192,45 @@ mod tests {
k.delete(&a).unwrap(); k.delete(&a).unwrap();
k.delete(&a).unwrap(); k.delete(&a).unwrap();
assert_eq!(k.get(&a).unwrap(), None); 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)] #[cfg(unix)]
{ {
k.set(&a, "wb_three").unwrap();
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let files: Vec<_> = let mode = files[0].metadata().unwrap().permissions().mode();
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();
assert_eq!(mode & 0o777, 0o600); 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). #[test]
fn two_collections() -> (tempfile::TempDir, Files, Account, PathBuf) { fn an_unreadable_token_file_is_an_error_never_no_token() {
let dir = tempfile::tempdir().unwrap(); 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 a = Account::new(dir.path(), "acme", "agent");
let other = files.0.join("login"); // A directory where the file belongs.
std::fs::create_dir_all(&other).unwrap(); std::fs::create_dir_all(dir.path().join("tokens").join(a.file_name())).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();
let err = k.get(&a).unwrap_err(); let err = k.get(&a).unwrap_err();
assert_eq!(err, a.duplicates(2)); assert!(err.to_string().contains("token file"), "{err}");
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<Vec<()>, String> {
Ok(vec![()])
}
fn read(&self, _: &()) -> Result<String, String> {
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("wb_")); assert!(!err.to_string().contains("wb_"));
} assert!(k.set(&a, "wb_new").is_err());
assert!(k.delete(&a).is_err());
/// 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();
} }
} }
+1 -1
View File
@@ -144,7 +144,7 @@ pub enum Request {
}, },
/// The live tokens. /// The live tokens.
TokenList, 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 /// live one of its books and name: asked by its SHA-256 alone, never
/// its text (INVARIANT 6). /// its text (INVARIANT 6).
TokenCheck { TokenCheck {
+1 -1
View File
@@ -255,7 +255,7 @@ impl Books {
let Some(presented) = presented else { let Some(presented) = presented else {
return Err(( return Err((
crate::access::UNAUTHORIZED, 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(), .into(),
)); ));
}; };
+8 -23
View File
@@ -123,27 +123,12 @@ sudo -u wanderbooks -H /usr/local/bin/wanderbooks token print \
--root /srv/wanderbooks --book acme --name laptop --root /srv/wanderbooks --book acme --name laptop
``` ```
`token create` keeps the text in the server account's OS keyring and `token create` keeps the text in a file in the server account's private
`token print` shows it once; copy it to that client and nowhere else. data root (mode 0600, readable by that account alone) and `token print`
Revoke it with `token revoke --book acme --name laptop` when a client is shows it once; copy it to that client and nowhere else. It works on a
lost. headless machine as it is. The server checks the token's SHA-256 from the
books. Revoke it with `token revoke --book acme --name laptop` when a
A headless machine has no Secret Service by default, and `token create` client is lost.
refuses to mint a token it cannot keep. Run the two commands inside a
session that has one, for example:
```sh
sudo -u wanderbooks -H dbus-run-session -- sh -c '
gnome-keyring-daemon --unlock --components=secrets >/dev/null
/usr/local/bin/wanderbooks token create --root /srv/wanderbooks --book acme --name laptop
/usr/local/bin/wanderbooks token print --root /srv/wanderbooks --book acme --name laptop
'
```
The keyring lives only for that session, which is enough: the text is
copied to the laptop, and the server checks the token's SHA-256 from the
books, not from the keyring. (Making a token without keeping it anywhere
is not a command; if your machine has a persistent Secret Service, use it.)
## 6. TLS and the door ## 6. TLS and the door
@@ -201,8 +186,8 @@ export WANDERBOOKS_ACME_TOKEN='<the token from step 5>'
``` ```
The plan's local rule is that no client configuration holds a token (the The plan's local rule is that no client configuration holds a token (the
bridge reads the keyring). For a remote machine there is no command yet bridge reads its token file). For a remote machine there is no command yet
that keeps a remote token in the client's keyring, so this recipe puts it that keeps a remote token in the client's own token file, so this recipe puts it
in Claude's configuration (mode 0600) or in Codex's environment. That in Claude's configuration (mode 0600) or in Codex's environment. That
gap is recorded in `plan/served.md` 8.2 P9a's As-built block. gap is recorded in `plan/served.md` 8.2 P9a's As-built block.
+1 -1
View File
@@ -26,7 +26,7 @@ Type=simple
User=wanderbooks User=wanderbooks
Group=wanderbooks Group=wanderbooks
DynamicUser=no DynamicUser=no
# The account's own home (the keyring, when one runs; the recorded root). # The account's own home (the recorded root).
StateDirectory=wanderbooks StateDirectory=wanderbooks
Environment=HOME=/var/lib/wanderbooks Environment=HOME=/var/lib/wanderbooks
WorkingDirectory=/ WorkingDirectory=/
+1 -1
View File
@@ -36,7 +36,7 @@ the specification in `bookspec/` (which never depends on the engine).
| `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels) out | `ledger-core`, `ledger-reader` | | `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels) out | `ledger-core`, `ledger-reader` |
| `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` | | `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` |
| `ledger-mcp` | the books server: the MCP tools (122), resources, the handbook, scopes, the door and the access log, backups' scheduling | all of the above | | `ledger-mcp` | the books server: the MCP tools (122), resources, the handbook, scopes, the door and the access log, backups' scheduling | all of the above |
| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in the OS keyring (Secret Service, macOS Keychain, Windows Credential Manager), the owner's commands, uploads and downloads | `ledger-store`, `ledger-mcp`, `ledger-config` | | `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads | `ledger-store`, `ledger-mcp`, `ledger-config` |
| `ledger-gui` | the viewer: read-only, live, egui; the Books menu hands work to the server | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-package`, `ledger-vault`, `ledger-host` | | `ledger-gui` | the viewer: read-only, live, egui; the Books menu hands work to the server | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-package`, `ledger-vault`, `ledger-host` |
| `ledger-cli` | `wanderbooks`, the one executable: every mode (`serve`, `server`, `host`, `mcp`, `gui`, `setup`, `books`, `token`, `connect`, `doctor`, …) and the over-the-wire test suites | everything | | `ledger-cli` | `wanderbooks`, the one executable: every mode (`serve`, `server`, `host`, `mcp`, `gui`, `setup`, `books`, `token`, `connect`, `doctor`, …) and the over-the-wire test suites | everything |
| `bookspec/checker`, `bookspec/runner` | the example schema and parser, the rule catalogue, the `Engine` trait; the per-rule runner | nothing of ours (the adapter in `ledger-core`'s tests depends on them) | | `bookspec/checker`, `bookspec/runner` | the example schema and parser, the rule catalogue, the `Engine` trait; the per-rule runner | nothing of ours (the adapter in `ledger-core`'s tests depends on them) |
+4 -5
View File
@@ -21,11 +21,10 @@ table is filled by the person who runs it.
## What step 0 found about the platforms (read before the macOS and Windows rows) ## What step 0 found about the platforms (read before the macOS and Windows rows)
The agent path was Linux-only because of the keyring. Step 0 adds the The agent path was Linux-only because of the keyring; D-63 removed the
macOS login Keychain and the Windows Credential Manager behind keyring on every OS (tokens are 0600 files under the data root, unit U0e),
`ledger-host/src/tokens.rs::Keeper`; CI (`portable` job) is the evidence. so nothing about tokens is OS-specific now. The Windows host is a port,
Beyond the keyring, the Windows host is a port, not a `cfg` gap. Today not a `cfg` gap. Today Unix-only, found by grep (2026-10-29):
Unix-only, found by grep (2026-10-29):
- `ledger-host`: `bridge`, `host`, `start`, `opener` and others are - `ledger-host`: `bridge`, `host`, `start`, `opener` and others are
`#[cfg(unix)]` modules; `host.rs`/`start.rs` use `setsid`, `umask`, `#[cfg(unix)]` modules; `host.rs`/`start.rs` use `setsid`, `umask`,
+49 -4
View File
@@ -494,7 +494,9 @@ recorded once with refusals, a damaged token log refusing every agent),
`open_while_held.rs` (the bridge picking up a token made mid-session), `open_while_held.rs` (the bridge picking up a token made mid-session),
`setup.rs` (the passphrase prompts, at a real terminal). `setup.rs` (the passphrase prompts, at a real terminal).
**One keyring item per token (2026-10-22).** On the live books a **One keyring item per token (2026-10-22; superseded by U0e, D-63: there
is no keyring, so no second item and no locked or unreachable one, and
the tests named below were replaced, see "U0e" at the end).** On the live books a
reconnected bridge got no token from a keyring that couldn't be read, reconnected bridge got no token from a keyring that couldn't be read,
was told to "make a token", and after `token create --replace` ran on a was told to "make a token", and after `token create --replace` ran on a
token the keyring no longer gave while `download` read a revoked one. token the keyring no longer gave while `download` read a revoked one.
@@ -3086,14 +3088,14 @@ loopback door (`--http`) for Caddy or a tunnel.
the repository's `p2-served` fixture into a desktop root at this version, the repository's `p2-served` fixture into a desktop root at this version,
copies the book as the transport (D-31), adopts it into copies the book as the transport (D-31), adopts it into
`/srv/wanderbooks` behind `--isolation enforced --http`, mints a token `/srv/wanderbooks` behind `--isolation enforced --http`, mints a token
with the `file_keyring` test knob (a runner has no Secret Service), and with `token create` as on a real server (the knob that once kept it in a
runs the test as the runner. `deploy/README.md` is the same recipe by file is gone, U0e: D-63), and runs the test as the runner. `deploy/README.md` is the same recipe by
hand; the job does not install the systemd unit (it drives `serve` with hand; the job does not install the systemd unit (it drives `serve` with
the same flags), so the unit's lines are checked by the test's file read the same flags), so the unit's lines are checked by the test's file read
and by review, not executed as a unit. and by review, not executed as a unit.
- **Not covered:** the recipe walked on a clean VM by someone other than - **Not covered:** the recipe walked on a clean VM by someone other than
its author (Done when 2), a real TLS tunnel (Tailscale Funnel or Caddy) its author (Done when 2), a real TLS tunnel (Tailscale Funnel or Caddy)
and a real Secret Service on a headless server. Those are the owner's and a headless server of the owner's own. Those are the owner's
and the EM's checks; the As-built block says so. and the EM's checks; the As-built block says so.
- **Docs:** `docs/architecture.md` "Self-host", `docs/mcp-interface.md` - **Docs:** `docs/architecture.md` "Self-host", `docs/mcp-interface.md`
(`books.start`'s two `isolation` values), `docs/parity.md` (no semantic (`books.start`'s two `isolation` values), `docs/parity.md` (no semantic
@@ -3533,6 +3535,11 @@ arms, the 8 KiB cut, the 64 KiB hand-over threshold and `page - 1`.
## P11a step 0: keyring stores for macOS and Windows (2026-10-29) ## P11a step 0: keyring stores for macOS and Windows (2026-10-29)
*Superseded the same day by D-63 (unit U0e, below): the `file_keyring`
knob, the `Items` trait, the OS stores, `os_keyring_keeps_and_forgets`
and the `portable` job's step that ran it are gone; the tests named here
no longer exist.*
No new test body: the keeper's laws are tested once, over the `Items` No new test body: the keeper's laws are tested once, over the `Items`
trait, through the `file_keyring` knob (`the_test_keyring_keeps_and_forgets`, trait, through the `file_keyring` knob (`the_test_keyring_keeps_and_forgets`,
`two_items_for_one_token_are_said_never_one_of_them_given`, `two_items_for_one_token_are_said_never_one_of_them_given`,
@@ -3549,3 +3556,41 @@ Secret Service run needs a desktop): the CI run is the evidence.
**For P12 (mutation targets, none run, D-33):** `tokens.rs`'s `os` modules **For P12 (mutation targets, none run, D-33):** `tokens.rs`'s `os` modules
are not reachable by mutation testing (they need the real OS store); the are not reachable by mutation testing (they need the real OS store); the
`Items` logic they share is, through the file store. `Items` logic they share is, through the file store.
## U0e: tokens in a file, the OS keyring removed (2026-10-29, D-63)
An agent's token is a 0600 file under the root's `run/tokens/`, on every
OS, written atomically (a temporary file made 0600 before any byte is
written, synced, then renamed over the old one). Tests, all in
`crates/ledger-host/src/tokens.rs` unless noted:
`an_account_names_the_root_by_hash_and_the_book_and_token` (kept);
`the_token_file_keeps_and_forgets` (was `the_test_keyring_keeps_and_forgets`,
no knob); `a_token_file_is_owner_only` (new: mode 0600, folder 0700, a
second create for the same name replaces the first, one file and no
temporary left); `an_unreadable_token_file_is_an_error_never_no_token`
(new: a folder in the file's place; replaces
`a_locked_or_unreachable_keyring_is_an_error_never_no_token`). Deleted
because the case cannot exist with one file per token:
`two_items_for_one_token_are_said_never_one_of_them_given`,
`a_token_kept_is_read_back_or_refused` (the read-back is still done by
`commands.rs::make_token`, and the CLI test below covers a token the file
cannot keep) and the ignored `os_keyring_keeps_and_forgets`.
`bridge.rs`: `a_token_file_that_cant_be_read_is_said_never_make_a_token`
and `each_new_session_reads_the_token_file_again` (renamed from the
keyring versions; the unreadable case is a folder in the file's place).
`crates/ledger-cli/tests/open_while_held.rs`:
`token_check_says_where_the_token_file_stands_and_an_unreadable_one_is_said`
(was `token_check_one_item_per_token_and_a_keyring_that_cant_be_read`; the
second-item parts are gone, the file's mode is asserted) and
`connect_makes_anew_a_token_whose_file_is_gone_and_says_when_an_agent_refuses`
(renamed). `knobs.rs`: `file_keyring` is now refused like any unknown knob.
`two-user.yml` mints its tokens with no knob (provable only on GitHub).
**For P12 (mutation targets, none run, D-33):** `tokens.rs`'s `Keeper::set`
(the temporary file's mode and the rename: a mutant that writes the final
file directly or with a wider mode must die on `a_token_file_is_owner_only`),
`Keeper::get`'s `NotFound` arm (a mutant that turns any error into "no
token" must die on `an_unreadable_token_file_is_an_error_never_no_token`),
`Keeper::delete`'s `NotFound` arm, and `Account::key`'s three parts
(`an_account_names_the_root_by_hash_and_the_book_and_token`);
`bridge.rs::token_file_refusal`'s `holds_token` guard.
+3 -8
View File
@@ -3,11 +3,8 @@
# #
# curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme # curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
# #
# Linux, and macOS with one gap: agents' tokens live in the OS keyring and # Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the viewer
# only the Linux store exists until the macOS app (plan P11a), so on macOS # opens as a normal window on Windows 11.
# this installs, sets up and starts the books but cannot connect an agent
# yet (it says so instead of failing). On Windows, run it inside WSL2
# (Ubuntu): the viewer opens as a normal window on Windows 11.
# #
# Installs Rust if needed, then the one `wanderbooks` program (into # Installs Rust if needed, then the one `wanderbooks` program (into
# ~/.cargo/bin), then makes the books' private folder (`wanderbooks setup`). # ~/.cargo/bin), then makes the books' private folder (`wanderbooks setup`).
@@ -79,9 +76,7 @@ if [ $# -gt 0 ]; then
if ! "$wb" books list | cut -d' ' -f1 | grep -qx "$1"; then if ! "$wb" books list | cut -d' ' -f1 | grep -qx "$1"; then
"$wb" books create "$1" "$wb" books create "$1"
fi fi
if [ "$os" = macos ]; then if command -v claude >/dev/null 2>&1; then
say "Done. Your books \"$1\" exist and 'wanderbooks gui $1' shows them. Connecting Claude Code waits for the macOS keyring store (the app, plan P11a); until then agents connect from Linux."
elif command -v claude >/dev/null 2>&1; then
"$wb" connect claude --book "$1" "$wb" connect claude --book "$1"
say "Done. Start Claude Code and say hello: your books \"$1\" are ready." say "Done. Start Claude Code and say hello: your books \"$1\" are ready."
else else
+1 -1
View File
@@ -1316,7 +1316,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
5. **Files:** owns `crates/ledger-host/src/tokens.rs`; touches `knobs.rs`, `commands.rs`, `bridge.rs`, `crates/ledger-host/Cargo.toml`, `Cargo.lock`, the two CLI tests, `.github/workflows/two-user.yml`, `deploy/README.md`, `install.sh`, `README.md`, `site/index.html`, `docs/packaging.md`, `docs/testing.md`, `docs/architecture.md`. 5. **Files:** owns `crates/ledger-host/src/tokens.rs`; touches `knobs.rs`, `commands.rs`, `bridge.rs`, `crates/ledger-host/Cargo.toml`, `Cargo.lock`, the two CLI tests, `.github/workflows/two-user.yml`, `deploy/README.md`, `install.sh`, `README.md`, `site/index.html`, `docs/packaging.md`, `docs/testing.md`, `docs/architecture.md`.
6. **Tests and proofs:** the existing keeper tests in `tokens.rs` run against the file store (they already do, through the knob: they lose the knob); `a_token_file_is_owner_only` (new, `tokens.rs`: mode 0600 on Unix, created atomically, a second `create` for the same name replaces the first); the bridge and `open_while_held` tests unchanged in what they assert. No kernel, Kani or Lean change. 6. **Tests and proofs:** the existing keeper tests in `tokens.rs` run against the file store (they already do, through the knob: they lose the knob); `a_token_file_is_owner_only` (new, `tokens.rs`: mode 0600 on Unix, created atomically, a second `create` for the same name replaces the first); the bridge and `open_while_held` tests unchanged in what they assert. No kernel, Kani or Lean change.
7. **Done when:** (1) `grep -ri "keyring\|keychain\|secret service\|credential manager" crates/ deploy/ install.sh README.md site/ .github/` finds only the history notes this unit leaves (one sentence in `tokens.rs`'s module doc saying what was removed and why, D-63); (2) `cargo tree -p ledger-host -e normal | grep -i keyring` prints nothing; (3) the named tests pass; (4) `two-user.yml` runs with no knob (the EM sees it green on GitHub); (5) `cargo build --workspace --all-targets` green. 7. **Done when:** (1) `grep -ri "keyring\|keychain\|secret service\|credential manager" crates/ deploy/ install.sh README.md site/ .github/` finds only the history notes this unit leaves (one sentence in `tokens.rs`'s module doc saying what was removed and why, D-63); (2) `cargo tree -p ledger-host -e normal | grep -i keyring` prints nothing; (3) the named tests pass; (4) `two-user.yml` runs with no knob (the EM sees it green on GitHub); (5) `cargo build --workspace --all-targets` green.
8. **As built:** none yet. 8. **As built:** (2026-10-29, branch `U0e`, EM-approved Option A after a stop) Build steps 1-3 done, with these departures. (1) The unit said the keeper tests and the bridge and `open_while_held` tests were "unchanged in what they assert"; they were not, because several asserted keyring-only states (a second item in another collection, a locked or unreachable keyring), which one file per token cannot have. `tokens.rs` is now direct file get/set/delete: the `Keeper` trait, `Items`, `get_one`/`set_one`/`delete_all`, `KeyringError::Duplicates`, `TEST_LOCKED`/`TEST_UNREACHABLE`/`TEST_DEFAULT` and the `login` collection are gone; `TokenFileError` is one message. The file is `run/tokens/<sha256 of the account key>` (the knob's `run/test-keyring/default/` was renamed), the folder 0700, the file made 0600 as a temporary beside it, synced and renamed. (2) Tests: `an_unreadable_token_file_is_an_error_never_no_token` (a folder in the file's place) replaces the locked/unreachable test; `a_token_file_is_owner_only` is new; `two_items_for_one_token_are_said_never_one_of_them_given` and `a_token_kept_is_read_back_or_refused` are deleted (case cannot exist; `make_token` still reads back); `os_keyring_keeps_and_forgets` is deleted; bridge tests renamed to `a_token_file_that_cant_be_read_is_said_never_make_a_token` and `each_new_session_reads_the_token_file_again`; in `open_while_held.rs`, `token_check_one_item_per_token_and_a_keyring_that_cant_be_read` became `token_check_says_where_the_token_file_stands_and_an_unreadable_one_is_said` (second-item and unreachable-marker parts deleted, 0600 mode asserted, unreadable = a folder in the file's place) and `connect_makes_anew_a_token_the_keyring_lost_and_says_when_an_agent_refuses` became `connect_makes_anew_a_token_whose_file_is_gone_and_says_when_an_agent_refuses`. (3) User-facing strings say "the token file in the data root" (no path), in `commands.rs`, `bridge.rs`, `ledger-gui/src/owner.rs`, `ledger-mcp/src/local.rs`, `admin.rs`. (4) Also changed beyond the unit's list: `.github/workflows/check.yml` (the `os_keyring` step of the portable job deleted), `deploy/wanderbooks-server.service` (a comment). (5) `start.rs` and the `DBUS_SESSION_BUS_ADDRESS` scrub and its tests stay: they keep a bridge session from inheriting a D-Bus session for any reason, not only the keyring. (6) Existing Linux books whose tokens were in the Secret Service lose them (nothing migrates): `wanderbooks connect` or `token create --replace` makes them anew. (7) `two-user.yml` has no knob; only GitHub can prove it. Checks: fmt, clippy (`ledger-host`, `ledger-cli`), `cargo test -p ledger-host`, `open_while_held`, `format_upgrade`, workspace build.
#### P4b S4: `documents_v2` #### P4b S4: `documents_v2`
+1 -1
View File
@@ -292,7 +292,7 @@ footer { padding: 72px 0 48px; color: var(--faint); font-size: 14px; display: fl
<p class="sub">The one line at the top does everything: installs Rust if you don't have it, builds wanderbooks, makes a private folder for your books, starts them, and connects Claude Code. Nothing needs administrator rights, and nothing keeps running in the background once you're done.</p> <p class="sub">The one line at the top does everything: installs Rust if you don't have it, builds wanderbooks, makes a private folder for your books, starts them, and connects Claude Code. Nothing needs administrator rights, and nothing keeps running in the background once you're done.</p>
<div class="grid"> <div class="grid">
<div class="card"><h3>Linux</h3><p>Needs a C compiler and git (<code class="inline">build-essential</code> on Debian and Ubuntu). The installer tells you if anything's missing.</p></div> <div class="card"><h3>Linux</h3><p>Needs a C compiler and git (<code class="inline">build-essential</code> on Debian and Ubuntu). The installer tells you if anything's missing.</p></div>
<div class="card"><h3>macOS</h3><p>Needs Apple's command line tools: <code class="inline">xcode-select --install</code>. The install, your books and the viewer work today; connecting Claude Code or Codex waits for the macOS keyring store, which comes with the app (soon).</p></div> <div class="card"><h3>macOS</h3><p>Needs Apple's command line tools: <code class="inline">xcode-select --install</code>. The install, your books, the viewer and connecting Claude Code or Codex work today.</p></div>
<div class="card"><h3>Windows</h3><p>Run it inside WSL2 (<code class="inline">wsl --install</code>, then Ubuntu). On Windows 11 the viewer opens as a normal window. Native Windows is on the way.</p></div> <div class="card"><h3>Windows</h3><p>Run it inside WSL2 (<code class="inline">wsl --install</code>, then Ubuntu). On Windows 11 the viewer opens as a normal window. Native Windows is on the way.</p></div>
</div> </div>
<p class="note" style="margin-top:24px">Already installed? <code class="inline">wanderbooks books create another</code>, then <code class="inline">wanderbooks connect claude --book another</code>. <code class="inline">wanderbooks gui</code> opens the viewer; its Books menu backs up, restores, starts new books and closes them.</p> <p class="note" style="margin-top:24px">Already installed? <code class="inline">wanderbooks books create another</code>, then <code class="inline">wanderbooks connect claude --book another</code>. <code class="inline">wanderbooks gui</code> opens the viewer; its Books menu backs up, restores, starts new books and closes them.</p>