Tokens: one keyring item per token, read back; the bridge says a locked keyring; token check
check / check (push) Canceled after 0s

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` kept on with the new token in memory while
`download` read a revoked one from the keyring.

- tokens.rs INVARIANT 4: keeping a token removes every item for its
  account in every collection, makes one and reads it back; reading finds
  every item and, finding several, refuses with how to keep one again; a
  locked or unreachable keyring is KeyringError::Unavailable, never
  "no token". The logic is shared by the OS keyring and the test files
  (now a folder per collection a test can lock, make unreachable, or
  plant a second item in).
- make_token reads the token back before saying anything, or revokes it.
- The bridge tells the agent the keyring is locked or unreachable and the
  token is fine (or that it holds several items), never to make a token;
  it reads the keyring again before every new session, even with a token
  held.
- `wanderbooks token check --book ID [--name N]`: whether the token kept
  here is the books' live one, asked by its SHA-256 (admin op
  token_check), never its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-22 04:17:22 -06:00
1 parent 321b94562a
commit d24653558c
9 files changed
+871 -95

No files matched your search

+86 -7
View File
@@ -22,10 +22,12 @@
//! 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 before anything is said of it,
//! and its text is printed only by `token print`; one that can't be
//! kept is revoked again. A token revoked is forgotten by the keyring
//! too. `connect` makes the books' agent token only when the keyring
//! 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).
//! 5. The viewer's hold lets go only when the owner closes the books;
@@ -1238,7 +1240,16 @@ fn make_token(
};
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
let account = crate::tokens::Account::new(&root, book, name);
let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text);
// Kept, and read back as kept: else taken back (INVARIANT 4).
let keeper = crate::tokens::keeper(&run_dir, &common.knobs);
let kept = keeper
.set(&account, &text)
.map_err(String::from)
.and_then(|()| match keeper.get(&account) {
Ok(Some(back)) if back == *text => Ok(()),
Ok(_) => Err("it didn't give back the token it was given to keep".to_string()),
Err(e) => Err(e.to_string()),
});
drop(text);
if let Err(why) = kept {
let undone = with_server(
@@ -1960,6 +1971,7 @@ fn token_usage(prog: &str) {
eprintln!("usage: {prog} create --book ID [--name NAME] [--scopes LIST] [--replace]");
eprintln!(" {prog} list");
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");
@@ -1967,10 +1979,12 @@ fn token_usage(prog: &str) {
eprintln!(" given; --replace ends a token of the same name)");
eprintln!(" list the tokens there are (never their text)");
eprintln!(" revoke end a token, at once, and forget it here");
eprintln!(" check whether the token kept here is the books' live one (never");
eprintln!(" its text: exit 0 if it is)");
eprintln!(" print the token's text, for a client that sends it itself");
}
/// `wanderbooks token create|list|revoke|print`.
/// `wanderbooks token create|list|revoke|check|print`.
pub fn token(
prog: &str,
args: impl IntoIterator<Item = String>,
@@ -2102,6 +2116,71 @@ pub fn token(
other => Err(fail(format!("the books server answered oddly: {other:?}"))),
}
}
["check"] => {
let book = need_book()?;
let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)
.map_err(|e| fail(e.to_string()))?;
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);
// Its SHA-256 alone leaves here (INVARIANT 4).
let kept = crate::tokens::keeper(&run_dir, &common.knobs)
.get(&account)
.map_err(|e| {
fail(format!(
"this computer's keyring couldn't give the token {name:?} of the books {book}: {e}"
))
})?
.map(|text| {
let text = zeroize::Zeroizing::new(text);
ledger_store::journal::sha256_hex(text.as_bytes())
});
let request = Request::TokenCheck {
book: book.clone(),
name: name.clone(),
sha256: kept.clone().unwrap_or_default(),
};
let (live, matches) = match with_server(&common, request).map_err(fail)? {
Response::TokenChecked { live, matches } => (live, matches),
Response::Refused(why) => {
return Err(fail(format!(
"the books server didn't take the check (one older than this command, perhaps): {why}"
)));
}
other => return Err(fail(format!("the books server answered oddly: {other:?}"))),
};
let named = if name == crate::tokens::DEFAULT_NAME {
String::new()
} else {
format!(" --name {name}")
};
let remake = format!("`wanderbooks token create --book {book}{named} --replace`");
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 {}).",
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.",
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."
),
(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.",
t.created
),
(false, None, _) => format!(
"There is no token {name:?} for the books {book}: `wanderbooks token create --book {book}{named}` makes one."
),
};
println!("{said}");
Err(exit(1))
}
["print"] => {
let book = need_book()?;
let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)
@@ -2117,7 +2196,7 @@ pub fn token(
Ok(None) => Err(fail(format!(
"this computer keeps no token {name:?} for the books {book}: `wanderbooks token create --book {book}` makes one"
))),
Err(why) => Err(fail(why)),
Err(why) => Err(fail(why.into())),
}
}
_ => {