Tokens: one keyring item per token, read back; the bridge says a locked keyring; token check
check / check (push) Canceled after 0s
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:
9 files changed
+871
-95
No files matched your search
@@ -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(¶ms, 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(¶ms, 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(¶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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_tool_list_is_read_from_its_answer() {
|
||||
let server = Server::start();
|
||||
|
||||
Reference in new issue
Block a user