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