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

+231 -20
View File
@@ -40,12 +40,16 @@
//! 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 and 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. The token is never written to the agent or to stderr.
//! (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)
//! 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.
//! ```
use crate::knobs::{FLAG, Knobs};
@@ -140,6 +144,9 @@ struct Bridge {
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
/// 11).
keyring_trouble: Mutex<Option<crate::tokens::KeyringError>>,
http: reqwest::Client,
knobs: Knobs,
to_agent: mpsc::UnboundedSender<Value>,
@@ -169,6 +176,10 @@ 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
/// is already held (INVARIANT 11): the one held is used if it is slow.
const KEYRING_RECHECK_WAIT: Duration = Duration::from_secs(2);
/// Where one of the agent's requests stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pending {
@@ -340,40 +351,84 @@ impl Bridge {
}
}
/// Whether a token is held.
fn holds_token(&self) -> bool {
self.token
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
}
/// Read the token from the keyring, 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.
async fn read_token_within(&self, wait: Duration) -> bool {
let Some((account, run_dir)) = self.keyring.get().cloned() else {
return false;
};
let keeper = crate::tokens::keeper(&run_dir, &self.knobs);
let read = tokio::time::timeout(
self.knobs.queue_wait,
wait,
tokio::task::spawn_blocking(move || keeper.get(&account)),
)
.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(),
)),
};
let trouble = read.as_ref().err().cloned();
*self
.keyring_trouble
.lock()
.unwrap_or_else(|e| e.into_inner()) = trouble;
match read {
Ok(Ok(Ok(Some(token)))) => {
Ok(Some(token)) => {
let mut held = self.token.lock().unwrap_or_else(|e| e.into_inner());
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"
);
}
*held = Some(token);
new
}
Ok(Ok(Ok(None))) => false,
Ok(Ok(Err(why))) => {
Ok(None) => false,
Err(why) => {
eprintln!("wanderbooks mcp: the token for these books couldn't be read: {why}");
false
}
Ok(Err(_)) | Err(_) => {
eprintln!(
"wanderbooks mcp: the keyring didn't give the token for these books in time: is it locked?"
);
false
}
}
}
/// 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
/// 11).
fn keyring_refusal(&self) -> Option<String> {
if self.holds_token() {
return None;
}
let trouble = self
.keyring_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(),
})
}
/// A request upstream, carrying the token (INVARIANT 11).
fn upstream(&self, method: reqwest::Method) -> reqwest::RequestBuilder {
let req = self.http.request(method, self.url());
@@ -416,6 +471,11 @@ impl Bridge {
}
return self.send_once(message, up).await.0;
}
// No token, for a keyring that couldn't be read: that is said,
// not "make one" (INVARIANT 11).
if unauthorized && let Some(why) = self.keyring_refusal() {
return Sent::Refused(why);
}
sent
}
@@ -519,7 +579,15 @@ impl Bridge {
params: &Value,
epoch: u64,
) -> Result<Upstream, Option<String>> {
self.find_token().await;
// 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.
if self.holds_token() {
self.read_token_within(KEYRING_RECHECK_WAIT.min(self.knobs.queue_wait))
.await;
} else {
self.read_token().await;
}
let request = json!({"jsonrpc": "2.0", "id": self.own_id(), "method": "initialize", "params": params});
let post = || {
self.upstream(reqwest::Method::POST)
@@ -530,9 +598,14 @@ impl Bridge {
};
let mut response = post().await.map_err(|_| None)?;
// Refused for its token: once more with one made or replaced
// since (INVARIANT 11).
if response.status() == reqwest::StatusCode::UNAUTHORIZED && self.read_token().await {
response = post().await.map_err(|_| None)?;
// since (INVARIANT 11); with none, for a keyring 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() {
return Err(Some(why));
}
}
let session = response
.headers()
@@ -1265,6 +1338,7 @@ pub fn run(
token_name,
token: Mutex::new(None),
keyring: std::sync::OnceLock::new(),
keyring_trouble: Mutex::new(None),
http,
knobs,
to_agent,
@@ -1610,6 +1684,7 @@ mod against_a_server {
token_name: crate::tokens::DEFAULT_NAME.into(),
token: Mutex::new(Some("wb_test".into())),
keyring: std::sync::OnceLock::new(),
keyring_trouble: Mutex::new(None),
http,
knobs,
to_agent,
@@ -1821,6 +1896,142 @@ 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(
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);
*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)
}
/// The door's refusal of a request with no token.
fn no_token_at_the_door() -> Reply {
Reply {
status: 401,
headers: vec![(ledger_mcp::local::REFUSED_HEADER, "unauthorized".into())],
body: "these books need a token: the human makes one with `wanderbooks token create`"
.into(),
cut: false,
}
}
/// 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.
#[tokio::test]
async fn a_keyring_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 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();
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 itself is fine")
&& said.contains("token check --book acme"),
"{said}"
);
assert!(!said.contains("token create"), "{said}");
let refused = b
.initialize_upstream(&params, 1)
.await
.unwrap_err()
.unwrap();
assert!(refused.contains("token itself is fine"), "{refused}");
assert!(
server
.seen()
.iter()
.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();
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.
#[tokio::test]
async fn each_new_session_reads_the_keyring_again() {
let server = Server::start();
let dir = tempfile::tempdir().unwrap();
let (b, _rx, _keyring) = bridge_with_keyring(&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);
keeper.set(&account, "wb_kept").unwrap();
let params = json!({"protocolVersion": "2025-06-18", "capabilities": {}});
let result = json!({"protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
"serverInfo": {"name": "wanderbooks", "version": "0"}});
let opened = || {
[
json_reply(
200,
json!({"jsonrpc": "2.0", "id": "x", "result": result.clone()}),
),
reply(202, "text/plain", ""),
]
};
server.script(opened());
b.initialize_upstream(&params, 1).await.unwrap();
let bearers = |server: &Server| -> Vec<String> {
server
.seen()
.iter()
.filter_map(|s| s.headers.get("authorization").cloned())
.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();
server.script(opened());
b.initialize_upstream(&params, 2).await.unwrap();
assert_eq!(bearers(&server)[2..], ["Bearer wb_kept", "Bearer wb_kept"]);
assert!(b.keyring_refusal().is_none(), "a token is held");
}
#[tokio::test]
async fn the_tool_list_is_read_from_its_answer() {
let server = Server::start();