agentmux_srv/backend/storage/muxbus.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use rusqlite::params;
5use serde::{Deserialize, Serialize};
6
7use super::error::StoreError;
8use super::store::Store;
9use crate::identity::secret_store;
10
11/// The host-wide keychain namespace: the `stable` channel's (and any
12/// non-isolated channel's) MuxBus tokens. Same string as the broker's
13/// credential id, `crate::muxbus::CREDENTIAL_ID`.
14const GLOBAL_KEYCHAIN_NS: &str = crate::muxbus::CREDENTIAL_ID;
15
16/// The channel whose own MuxBus sign-in this process uses. A channel is the
17/// unit of sign-in, not the host: every channel but `stable` (the installed
18/// releases, which have always used the host-wide set) signs in on its own, so
19/// two channels on one machine can be signed in at once, to the same account
20/// or to different ones, and one's refresh, sign-in or sign-out never touches
21/// the other's (SPEC_MUXBUS_SIGN_IN_PER_CHANNEL_2026_10_08.md).
22fn sign_in_channel(channel: Option<&str>) -> Option<&str> {
23 channel.filter(|c| !c.is_empty() && *c != "stable")
24}
25
26/// The keychain namespace this process's MuxBus tokens live under.
27fn keychain_namespace() -> String {
28 namespace_for(std::env::var("AGENTMUX_CHANNEL").ok().as_deref())
29}
30
31fn namespace_for(channel: Option<&str>) -> String {
32 match sign_in_channel(channel) {
33 Some(ch) => format!("muxbus:channel:{ch}"),
34 None => GLOBAL_KEYCHAIN_NS.to_string(),
35 }
36}
37
38/// Whether the store this process reads is already this channel's own file
39/// (isolated auth with an instance dir: `registry::paths::resolve_shared_store_path`),
40/// where the channel needs no per-row scoping.
41fn store_is_channel_local() -> bool {
42 agentmux_common::isolated_auth_enabled()
43 && std::env::var_os("AGENTMUX_INSTANCE_DIR").is_some_and(|d| !d.is_empty())
44}
45
46/// The `db_muxbus_credentials` row id: `global` for `stable` and for a store
47/// that is already the channel's own, `channel:<ch>` for a channel's row in the
48/// store channels share.
49fn row_id_for(channel: Option<&str>, own_store: bool) -> String {
50 match sign_in_channel(channel) {
51 Some(ch) if !own_store => format!("channel:{ch}"),
52 _ => "global".to_string(),
53 }
54}
55
56fn current_row_id() -> String {
57 row_id_for(std::env::var("AGENTMUX_CHANNEL").ok().as_deref(), store_is_channel_local())
58}
59
60/// Prefix for this channel's rows in `db_agent_credentials`, which is keyed by
61/// agent id alone: empty for `stable` and for the channel's own store. An agent
62/// id is `[A-Za-z0-9_-]`, so the `/` can't collide with one.
63fn agent_credential_prefix_for(channel: Option<&str>, own_store: bool) -> String {
64 match sign_in_channel(channel) {
65 Some(ch) if !own_store => format!("{ch}/"),
66 _ => String::new(),
67 }
68}
69
70pub(super) fn current_agent_credential_prefix() -> String {
71 agent_credential_prefix_for(std::env::var("AGENTMUX_CHANNEL").ok().as_deref(), store_is_channel_local())
72}
73
74// ---- Cross-process lock ----------------------------------------------------
75//
76// `muxbus_save_lock` only serializes threads of ONE process, but the keychain
77// is host-wide and a Windows save is about a dozen writes. Two processes
78// interleaving left a torn set that reads treated as signed out. An OS file
79// lock makes save, load and clear one critical section across processes.
80
81/// How long a save, load or clear waits for another process. Past it the
82/// operation fails as transient (a load is retried, a sign-in reports an
83/// error) instead of running unlocked, which is what tore sign-ins.
84const XPROC_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
85
86/// A held OS advisory lock; released when dropped (the handle closes).
87pub(crate) struct CrossProcessGuard {
88 _file: std::fs::File,
89}
90
91/// Take the lock at `path`, polling until `wait` has passed. `None` when it
92/// couldn't be taken: another process held it the whole time, or the file
93/// couldn't be opened.
94pub(super) fn lock_file(path: &std::path::Path, wait: std::time::Duration) -> Option<CrossProcessGuard> {
95 if let Some(dir) = path.parent() {
96 let _ = std::fs::create_dir_all(dir);
97 }
98 // Never deleted: unlinking a locked file lets a waiter lock a different
99 // inode than a newcomer (registry/leases.rs `CriticalSection`).
100 let file = std::fs::OpenOptions::new().create(true).write(true).open(path).ok()?;
101 let deadline = std::time::Instant::now() + wait;
102 loop {
103 match crate::registry::try_lock_exclusive(&file) {
104 Ok(true) => return Some(CrossProcessGuard { _file: file }),
105 Ok(false) if std::time::Instant::now() < deadline => {
106 std::thread::sleep(std::time::Duration::from_millis(25));
107 }
108 Ok(false) | Err(_) => return None,
109 }
110 }
111}
112
113/// The lock for keychain namespace `ns`, under `root`'s `muxbus-locks`. With
114/// no shared root (some test setups) there is nowhere to put it: `Ok(None)`.
115fn lock_in(
116 root: Option<std::path::PathBuf>,
117 ns: &str,
118 wait: std::time::Duration,
119) -> Result<Option<CrossProcessGuard>, StoreError> {
120 let Some(root) = root else { return Ok(None) };
121 let path = root.join("muxbus-locks").join(format!("{}.lock", ns.replace(':', "_")));
122 lock_file(&path, wait).map(Some).ok_or_else(|| {
123 tracing::warn!(namespace = %ns, "muxbus: couldn't take the keychain lock within {wait:?}");
124 StoreError::Other(format!("muxbus: couldn't take the keychain lock for {ns} within {wait:?}; try again"))
125 })
126}
127
128fn lock_namespace(ns: &str) -> Result<Option<CrossProcessGuard>, StoreError> {
129 lock_in(crate::registry::resolve_global_shared_root(), ns, XPROC_LOCK_WAIT)
130}
131
132/// How long a refresh waits for another process's refresh of the same
133/// sign-in: long enough to cover a slow token request.
134const REFRESH_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
135
136/// Single-entry key holding all three tokens as one JSON blob. Two different
137/// roles depending on platform (see `muxbus_load_tokens` / `muxbus_save`):
138///
139/// - **Windows**: legacy (pre-2026-08-03) only. Windows Credential Manager
140/// caps a single entry's blob at 2560 bytes (`CRED_MAX_CREDENTIAL_BLOB_SIZE`)
141/// — a Cognito `id_token` alone can approach that, and combined with
142/// `access_token`+`refresh_token` reliably exceeds it (see
143/// `docs/specs/PLAN_MUXBUS_KEYCHAIN_WINDOWS_BLOB_LIMIT_2026_08_03.md`). Kept
144/// only as a one-time migration source there; every Windows write goes
145/// through the chunked per-field layout below instead.
146/// - **macOS/Linux**: the CURRENT, preferred format. These platforms have no
147/// comparable blob-size cap, so the chunked layout's only purpose there
148/// was to be platform-uniform with Windows — but each of its ~12 separate
149/// keychain entries is its own OS-level access-consent decision, and macOS
150/// Keychain prompts per-entry until each is individually trusted. Granting
151/// "Always Allow" on one entry just surfaced the next entry's own prompt,
152/// making the whole flow look broken (retro-macos-muxbus-keychain-prompt-
153/// storm-2026-08-19.md). One combined blob means one prompt, that one
154/// "Always Allow" click actually sticks.
155/// The blob lives at the namespace itself (`muxbus:global` for the host-wide
156/// set), the split fields under it (`<ns>:access`, ...).
157fn blob_key(ns: &str) -> String {
158 ns.to_string()
159}
160
161const FIELD_ACCESS: &str = "access";
162const FIELD_REFRESH: &str = "refresh";
163const FIELD_ID: &str = "id";
164
165fn field_key(ns: &str, field: &str) -> String {
166 format!("{ns}:{field}")
167}
168
169/// A first fix attempt (splitting the combined blob into one keychain entry
170/// per field) turned out NOT to be sufficient: live-tested against a real
171/// Cognito login, the exact same "Attribute 'password' is longer than
172/// platform limit of 2560 chars" error still fired — a single token field
173/// can itself exceed the cap depending on the app client's token content
174/// (custom claims, refresh token length), not just the three combined. So
175/// each field is further chunked into as many entries as it takes, tracked
176/// by an explicit `<field>:count` entry (`write_chunked_field` /
177/// `read_chunked_field` below) — this holds regardless of any individual
178/// token's real-world size, instead of relying on an assumption about it.
179///
180/// A second live test with a 1800-char budget hit the SAME error again —
181/// the `keyring` crate's Windows backend checks
182/// `password.encode_utf16().count() * 2 > CRED_MAX_CREDENTIAL_BLOB_SIZE`
183/// (2560 *bytes*), but its own error message reports that raw byte count as
184/// if it were a char limit ("longer than platform limit of 2560 chars").
185/// Since Windows stores the value as UTF-16 (2 bytes/char), the real
186/// character budget is `CRED_MAX_CREDENTIAL_BLOB_SIZE / 2` = 1280, not 2560
187/// — `keyring-2.3.3/src/windows.rs:182` vs. its own error text at
188/// that crate's `error.rs` (its `TooLong` message). 1800 was 40% over that
189/// real limit. Budget well under 1280 here, not just under the misleading
190/// 2560 the error text implies.
191const MAX_CHUNK_LEN: usize = 1000;
192
193fn chunk_key(field_key: &str, index: usize) -> String {
194 format!("{field_key}:{index}")
195}
196
197fn count_key(field_key: &str) -> String {
198 format!("{field_key}:count")
199}
200
201fn generation_key(field_key: &str) -> String {
202 format!("{field_key}:gen")
203}
204
205/// A per-write identifier stamped onto all three fields by a single
206/// `write_split_tokens` call, so `read_split_tokens` can detect a torn
207/// write ACROSS a process crash — not the concurrent-read-in-one-process
208/// case `muxbus_load_impl`'s lock already covers, but a kill signal
209/// landing mid-write, between one field's `write_chunked_field` call
210/// completing and the next one starting (reagent P2: each field is
211/// individually self-consistent, per `write_chunked_field`'s own
212/// chunk+rollback atomicity, but with no lock surviving the process
213/// there's nothing to stop the OLD stale value for the not-yet-reached
214/// field from still being present when the other two ARE the fresh ones —
215/// silently reconstructing a token set that pairs pieces from two
216/// different login sessions). High-resolution wall-clock nanos is unique
217/// enough for this purpose in practice; a formal UUID would work too but
218/// adds a dependency for no real benefit here.
219fn new_generation() -> String {
220 std::time::SystemTime::now()
221 .duration_since(std::time::UNIX_EPOCH)
222 .unwrap_or_default()
223 .as_nanos()
224 .to_string()
225}
226
227/// Pure chunking split — no keychain I/O — so the boundary math is testable
228/// without touching the real OS keychain. A token's bytes are always ASCII
229/// in practice (JWTs / opaque base64url strings), so splitting on byte
230/// boundaries never lands mid-character; `unwrap_or("")` is a defensive
231/// fallback only, not an expected path.
232fn chunk_value(value: &str) -> Vec<&str> {
233 if value.is_empty() {
234 return vec![""];
235 }
236 value
237 .as_bytes()
238 .chunks(MAX_CHUNK_LEN)
239 .map(|c| std::str::from_utf8(c).unwrap_or(""))
240 .collect()
241}
242
243/// A previous, longer value at a field could in principle need many chunks;
244/// no real Cognito token comes remotely close (`MAX_PLAUSIBLE_CHUNKS *
245/// MAX_CHUNK_LEN` = 32,000 chars). Used only as `muxbus_clear`'s fallback
246/// deletion bound when the real count can't be read (see its call site) —
247/// `secret_store::delete` on a non-existent entry is a no-op success, so
248/// scanning past the real count there is harmless, just wasted calls.
249const MAX_PLAUSIBLE_CHUNKS: usize = 32;
250
251/// `Ok(0)` means the `:count` entry genuinely doesn't exist (field never
252/// written under the chunked layout). `Err` means the read itself failed —
253/// reagent P1: a prior version of this collapsed both into the same `0`,
254/// which `muxbus_clear` used to bound its per-field deletion loop
255/// (`for i in 0..count`) — a transient read failure made that loop delete
256/// NOTHING, while the field's `:count` key was still deleted unconditionally
257/// right after, so logout appeared to succeed while the real token chunks
258/// were silently orphaned in the OS keychain. Callers must not treat `Err`
259/// as "0 chunks."
260fn read_chunk_count(field_key: &str) -> Result<usize, StoreError> {
261 match secret_store::get_optional(&count_key(field_key)) {
262 Ok(Some(v)) => v
263 .parse::<usize>()
264 .map_err(|e| StoreError::Other(format!("muxbus: corrupted chunk count for {field_key}: {e}"))),
265 Ok(None) => Ok(0),
266 Err(e) => Err(StoreError::Other(format!("muxbus: keychain read failed: {e}"))),
267 }
268}
269
270#[derive(Debug, Clone, Default)]
271pub struct MuxBusCredentials {
272 pub cognito_domain: String,
273 pub client_id: String,
274 pub access_token: String,
275 pub refresh_token: String,
276 pub id_token: String,
277 pub expires_at: i64,
278 pub user_email: String,
279 pub user_sub: String,
280}
281
282/// The actual secret material. Every field is written to its own keychain
283/// entry (`field_key`); this struct only groups them for callers and for
284/// deserializing the legacy combined-blob format during migration.
285/// Everything else on `MuxBusCredentials` is non-secret metadata and stays
286/// in SQLite, matching how `SecretRef::Keychain` accounts already split
287/// pointer-metadata (DB) from plaintext (keychain) elsewhere in this codebase.
288#[derive(Serialize, Deserialize, Default, Clone)]
289struct MuxBusTokens {
290 access_token: String,
291 refresh_token: String,
292 id_token: String,
293}
294
295/// On-disk JSON shape for the single-blob format (macOS/Linux's preferred
296/// layout, `write_single_blob`/`blob_key`). Wraps
297/// `MuxBusTokens` with a generation stamp — the SAME `new_generation()`
298/// nanosecond-timestamp scheme the chunked layout already uses for its own
299/// cross-field torn-write check — so a blob and split entries found
300/// coexisting (`muxbus_load_tokens`'s reconciliation) can be compared for
301/// actual freshness instead of assuming a fixed direction is always right.
302///
303/// reagent P1 on PR #2665: an earlier version of the reconciliation logic
304/// hardcoded "split is always fresher," which was true for the ONE
305/// historical case that motivated it (an old pre-fix blob→split migration
306/// whose blob delete failed) but breaks once THIS fix's own self-heal can
307/// also leave stale split leftovers behind a newer blob (if
308/// `delete_split_tokens` partially fails, then a later muxbus_save — which
309/// on macOS/Linux only ever writes the blob — updates the blob but not the
310/// stale split entries). An actual freshness comparison handles both
311/// directions correctly instead of guessing.
312///
313/// `#[serde(default)]` on `generation` means a genuinely old, pre-fix
314/// legacy blob (written before this stamp existed) deserializes with an
315/// empty generation, which reads as timestamp `0` — always losing a
316/// freshness comparison against any split entry's real generation stamp,
317/// which is exactly the right outcome for that original historical case.
318#[derive(Serialize, Deserialize, Default)]
319struct MuxBusBlob {
320 #[serde(flatten)]
321 tokens: MuxBusTokens,
322 #[serde(default)]
323 generation: String,
324}
325
326/// Parse a `new_generation()`-style nanosecond-timestamp string into a
327/// comparable number. An empty or corrupted value reads as `0` — the
328/// oldest possible timestamp — so it always loses a freshness comparison
329/// rather than erroring a read that would otherwise succeed.
330fn generation_as_number(generation: &str) -> u128 {
331 generation.parse().unwrap_or(0)
332}
333
334/// What a keychain entry held immediately before a write to it, so a later
335/// failure (the SQL write in `muxbus_save`, or a sibling field's write in
336/// `write_split_tokens`) can be rolled back to that exact prior state.
337/// Three-way, not a bool: reagent P1 on #2260 caught a first version of this
338/// that collapsed `Ok(None)` ("genuinely no prior entry") and `Err` ("the
339/// read itself failed, prior state UNKNOWN") into the same `None`, so an
340/// unrelated transient read failure would make the rollback branch delete a
341/// real, valid, previously-stored credential it simply couldn't read —
342/// turning a transient glitch into a forced full re-login. `Unknown` gets
343/// neither restore nor delete: we truly don't know what was there, so the
344/// only safe move is to leave whatever the write just wrote and log loudly.
345enum PriorKeychainState {
346 Existed(zeroize::Zeroizing<String>),
347 Absent,
348 Unknown,
349}
350
351impl PriorKeychainState {
352 fn capture(key: &str) -> Self {
353 match secret_store::get_optional(key) {
354 Ok(Some(v)) => PriorKeychainState::Existed(v),
355 Ok(None) => PriorKeychainState::Absent,
356 Err(_) => PriorKeychainState::Unknown,
357 }
358 }
359
360 fn restore(&self, key: &str) -> Result<(), String> {
361 match self {
362 PriorKeychainState::Existed(old) => secret_store::put(key, old.as_str()),
363 PriorKeychainState::Absent => secret_store::delete(key),
364 PriorKeychainState::Unknown => Ok(()),
365 }
366 }
367}
368
369/// Write `value` to `field_key` as one or more chunk entries (each under
370/// `MAX_CHUNK_LEN`) plus a `:count` entry, clearing any stale trailing
371/// chunks left over from a previous, longer value at this key, and stamps
372/// `generation` (same value across all three fields for one
373/// `write_split_tokens` call — see `new_generation`'s doc comment) on this
374/// field's `:gen` entry. Rolls back everything this call touched if any
375/// single write/delete fails partway through.
376///
377/// Write order matters here beyond just "rollback on failure" (reagent P1:
378/// a prior version wrote chunks, then cleared stale trailing chunks, then
379/// updated `:count`, then `:gen`, in that order — a process CRASH between
380/// the chunk-write and the trailing-chunk-delete left `:count` still
381/// pointing at the OLD larger count while the leading chunks already held
382/// NEW content; on restart `read_chunked_field` would read exactly
383/// `old_count` chunks — none technically missing, so no error — silently
384/// reconstructing a spliced new-prefix/old-suffix value that's neither the
385/// old nor the new token, with `:gen` untouched so `read_split_tokens`'s
386/// cross-field generation check never even saw a mismatch to catch). This
387/// version deletes `:gen` FIRST, before touching any chunk content, and
388/// writes the fresh `generation` value LAST, only once chunks + `:count`
389/// are fully consistent — `read_chunked_field` already treats a missing
390/// `:gen` on an otherwise-populated field as an error, so a crash ANYWHERE
391/// in between now makes this field unambiguously detectable as torn,
392/// rather than silently reconstructible.
393///
394/// On success, returns the PRE-call state of every entry touched (`:gen`,
395/// chunks, `:count`), so a caller doing more work of its own afterward
396/// (`write_split_tokens` covering a sibling field, or `muxbus_save`'s SQL
397/// write) can roll all of it back together if that later step fails too.
398fn write_chunked_field(
399 field_key: &str,
400 value: &str,
401 generation: &str,
402) -> Result<Vec<(String, PriorKeychainState)>, StoreError> {
403 let new_chunks = chunk_value(value);
404 let new_count = new_chunks.len();
405 // Propagate a genuine read failure rather than assuming 0 — silently
406 // treating "couldn't read the old count" as "there were no stale
407 // trailing chunks" would skip cleaning up real leftover chunk data from
408 // a previous, longer value (same class of bug as the read_chunk_count
409 // doc comment describes for muxbus_clear).
410 let old_count = read_chunk_count(field_key)?;
411
412 let mut priors: Vec<(String, PriorKeychainState)> = Vec::with_capacity(new_count.max(old_count) + 2);
413 let rollback = |priors: &[(String, PriorKeychainState)]| {
414 for (key, prior) in priors {
415 if let Err(re) = prior.restore(key) {
416 tracing::warn!(
417 error = %re,
418 key = %key,
419 "muxbus: rollback after a partial chunked-field write failure also failed \
420 for this entry — keychain may now hold a stale value for it"
421 );
422 }
423 }
424 };
425
426 // Invalidate this field before touching any chunk content — see the
427 // doc comment above for why this specific ordering is load-bearing.
428 // The captured `gen_prior` (this field's TRUE pre-call `:gen` state) is
429 // what a later rollback restores to, whether the failure happens here,
430 // mid-chunk-write, or in the final `:gen` write at the bottom of this
431 // function — there is deliberately only ONE priors entry for this key.
432 let gk = generation_key(field_key);
433 let gen_prior = PriorKeychainState::capture(&gk);
434 if let Err(e) = secret_store::delete(&gk) {
435 return Err(StoreError::Other(format!("muxbus: keychain write failed: {e}")));
436 }
437 priors.push((gk.clone(), gen_prior));
438
439 for (i, chunk) in new_chunks.iter().enumerate() {
440 let key = chunk_key(field_key, i);
441 let prior = PriorKeychainState::capture(&key);
442 if let Err(e) = secret_store::put(&key, chunk) {
443 rollback(&priors);
444 return Err(StoreError::Other(format!("muxbus: keychain write failed: {e}")));
445 }
446 priors.push((key, prior));
447 }
448
449 // A previous, longer value left trailing chunks beyond the new count —
450 // clear them, or a future read would append stale old data past the
451 // new count boundary... except reads stop at `count`, so leftover
452 // chunks are actually inert. Clear them anyway: cheap, and avoids ever
453 // depending on that "reads ignore trailing chunks" invariant holding.
454 for i in new_count..old_count {
455 let key = chunk_key(field_key, i);
456 let prior = PriorKeychainState::capture(&key);
457 if let Err(e) = secret_store::delete(&key) {
458 rollback(&priors);
459 return Err(StoreError::Other(format!("muxbus: keychain write failed: {e}")));
460 }
461 priors.push((key, prior));
462 }
463
464 let ck = count_key(field_key);
465 let prior = PriorKeychainState::capture(&ck);
466 if let Err(e) = secret_store::put(&ck, &new_count.to_string()) {
467 rollback(&priors);
468 return Err(StoreError::Other(format!("muxbus: keychain write failed: {e}")));
469 }
470 priors.push((ck, prior));
471
472 // Write the fresh generation LAST — only once chunks + :count are fully
473 // consistent. This is what makes the field valid (readable) again; the
474 // `gk` rollback entry already pushed above (this field's true pre-call
475 // state) is reused if THIS write itself fails, not a fresh capture —
476 // capturing now would wrongly record "Absent" (we deleted it ourselves
477 // above) as what to roll back to.
478 if let Err(e) = secret_store::put(&gk, generation) {
479 rollback(&priors);
480 return Err(StoreError::Other(format!("muxbus: keychain write failed: {e}")));
481 }
482
483 Ok(priors)
484}
485
486/// Read `field_key`'s value + generation stamp back from its chunk entries.
487/// `Ok(None)` means no `:count` entry exists yet (this field was never
488/// written under the chunked layout — caller falls through to the
489/// legacy-blob / legacy-plaintext sources). `Err` means a read genuinely
490/// failed, or the chunk state is internally inconsistent (a chunk went
491/// missing between the count write and now) — not just "no entry". A
492/// missing `:gen` entry on an otherwise-complete field is treated as a read
493/// failure too, not defaulted to some sentinel — see `read_split_tokens`,
494/// which needs a REAL generation to compare across fields, not a value
495/// that would spuriously "match" another field's own missing generation.
496/// Why `read_chunked_field` has no value: the field is mid-write or was
497/// interrupted (`Yes`, a torn read to retry), or the keychain failed (`No`).
498enum FieldIncomplete {
499 Yes,
500 No(StoreError),
501}
502
503fn read_chunked_field(field_key: &str) -> Result<Option<(String, String)>, FieldIncomplete> {
504 let failed = |e| FieldIncomplete::No(StoreError::Other(format!("muxbus: keychain read failed: {e}")));
505 let count = match secret_store::get_optional(&count_key(field_key)) {
506 Ok(Some(v)) => v.parse::<usize>().map_err(|e| {
507 FieldIncomplete::No(StoreError::Other(format!("muxbus: corrupted chunk count for {field_key}: {e}")))
508 })?,
509 Ok(None) => return Ok(None),
510 Err(e) => return Err(failed(e)),
511 };
512 let mut value = String::new();
513 for i in 0..count {
514 match secret_store::get_optional(&chunk_key(field_key, i)) {
515 Ok(Some(chunk)) => value.push_str(&chunk),
516 Ok(None) => return Err(FieldIncomplete::Yes),
517 Err(e) => return Err(failed(e)),
518 }
519 }
520 let generation = match secret_store::get_optional(&generation_key(field_key)) {
521 Ok(Some(g)) => g.to_string(),
522 Ok(None) => return Err(FieldIncomplete::Yes),
523 Err(e) => return Err(failed(e)),
524 };
525 Ok(Some((value, generation)))
526}
527
528/// Write all three tokens as one combined JSON blob under a single keychain
529/// entry (`blob_key` — the CURRENT preferred format on
530/// macOS/Linux, see that constant's doc comment). One `SecItemAdd`/
531/// `SecItemUpdate` call is inherently atomic at the OS level, so this needs
532/// none of `write_split_tokens`'s cross-field generation-stamp/rollback
533/// machinery — there's only one field. Returns the pre-call state wrapped in
534/// the same `Vec<(String, PriorKeychainState)>` shape `write_split_tokens`
535/// returns, so `muxbus_save`'s SQL-failure rollback branch works unchanged
536/// regardless of which one ran.
537fn write_single_blob(ns: &str, tokens: &MuxBusTokens) -> Result<Vec<(String, PriorKeychainState)>, StoreError> {
538 let key = blob_key(ns);
539 let prior = PriorKeychainState::capture(&key);
540 // Always stamps a FRESH generation, even when the caller is re-writing
541 // the same token values (e.g. reconciling a coexisting blob + split
542 // layout in `muxbus_load_tokens` — see `MuxBusBlob`'s doc comment) —
543 // "written just now" is itself real freshness information for the next
544 // comparison, regardless of whether the underlying secret changed.
545 let value = MuxBusBlob {
546 tokens: tokens.clone(),
547 generation: new_generation(),
548 };
549 let blob = serde_json::to_string(&value)
550 .map_err(|e| StoreError::Other(format!("muxbus: failed to serialize tokens: {e}")))?;
551 if let Err(e) = secret_store::put(&key, &blob) {
552 return Err(StoreError::Other(format!("muxbus: keychain write failed: {e}")));
553 }
554 Ok(vec![(key, prior)])
555}
556
557/// Delete every entry the chunked per-field layout could have written
558/// (`FIELD_ACCESS`/`FIELD_REFRESH`/`FIELD_ID`'s chunks + `:count` + `:gen`).
559/// Shared by `muxbus_clear` (unconditional logout cleanup, any platform) and
560/// the macOS/Linux migration path in `muxbus_load_tokens` (collapsing a
561/// pre-fix install's chunked entries into the single-blob format — see
562/// `blob_key`'s doc comment). Best-effort: `secret_store::delete`
563/// on a non-existent entry is a no-op success, and a real delete failure here
564/// just leaves an orphaned, unreadable-without-the-others chunk behind rather
565/// than losing anything live.
566fn delete_split_tokens(ns: &str) {
567 for field in [FIELD_ACCESS, FIELD_REFRESH, FIELD_ID] {
568 let fk = field_key(ns, field);
569 // A count-read failure must NOT be treated as "0 chunks" (see
570 // read_chunk_count's doc comment) — that would delete nothing here
571 // while still unconditionally deleting the `:count` key below,
572 // orphaning the real token chunks in the OS keychain. Fall back to
573 // scanning a generous bound instead: `secret_store::delete` on a
574 // non-existent entry is a no-op success, so deleting past the real
575 // count is harmless — it guarantees actual cleanup even when the
576 // count itself is unreadable.
577 let count = match read_chunk_count(&fk) {
578 Ok(n) => n,
579 Err(e) => {
580 tracing::warn!(
581 error = %e,
582 field = %fk,
583 "muxbus: couldn't read this field's chunk count while deleting split \
584 entries — falling back to a bounded scan so real token chunks still get \
585 deleted"
586 );
587 MAX_PLAUSIBLE_CHUNKS
588 }
589 };
590 for i in 0..count {
591 let _ = secret_store::delete(&chunk_key(&fk, i));
592 }
593 let _ = secret_store::delete(&count_key(&fk));
594 let _ = secret_store::delete(&generation_key(&fk));
595 }
596}
597
598/// Write all three token fields, each independently chunked
599/// (`write_chunked_field`). On a later field's failure, rolls back every
600/// entry every earlier field in this call already wrote — otherwise a
601/// partial failure would leave the three fields holding a mix of old and
602/// new tokens, which is worse than either the old or the new set alone.
603///
604/// On success, returns every entry's PRE-call state (across all three
605/// fields) so `muxbus_save`'s SQL-write failure branch can roll all of it
606/// back together too.
607fn write_split_tokens(ns: &str, tokens: &MuxBusTokens) -> Result<Vec<(String, PriorKeychainState)>, StoreError> {
608 let generation = new_generation();
609 let fields: [(String, &str); 3] = [
610 (field_key(ns, FIELD_ACCESS), tokens.access_token.as_str()),
611 (field_key(ns, FIELD_REFRESH), tokens.refresh_token.as_str()),
612 (field_key(ns, FIELD_ID), tokens.id_token.as_str()),
613 ];
614 let mut all_priors: Vec<(String, PriorKeychainState)> = Vec::new();
615 for (key, value) in fields {
616 match write_chunked_field(&key, value, &generation) {
617 Ok(priors) => all_priors.extend(priors),
618 Err(e) => {
619 for (done_key, done_prior) in &all_priors {
620 if let Err(re) = done_prior.restore(done_key) {
621 tracing::warn!(
622 error = %re,
623 key = %done_key,
624 "muxbus: rollback after a partial split-token write failure also failed \
625 for this entry — keychain may now hold a stale value for it"
626 );
627 }
628 }
629 return Err(e);
630 }
631 }
632 }
633 Ok(all_priors)
634}
635
636/// The tokens stored under `ns` in either layout (chunked split entries or
637/// the single blob), without migrating or deleting anything. `None` when
638/// neither layout holds a complete, consistent set or a read fails. Used
639/// only to adopt the host-wide session into a channel namespace.
640///
641/// When both layouts are present, the fresher generation wins, the same
642/// rule `muxbus_load_tokens` applies: on macOS/Linux `muxbus_save` writes
643/// only the blob, so split entries left over beside it are older and must
644/// not be adopted over it. A blob without a generation (the legacy Windows
645/// format) ranks oldest, so Windows' current split layout wins over it.
646fn read_any_layout(ns: &str) -> Option<MuxBusTokens> {
647 let split = read_split_tokens(ns).ok().flatten();
648 let blob = secret_store::get_optional(&blob_key(ns))
649 .ok()
650 .flatten()
651 .and_then(|b| serde_json::from_str::<MuxBusBlob>(&b).ok());
652 match (split, blob) {
653 (Some((split_tokens, split_gen)), Some(blob)) => {
654 if generation_as_number(&split_gen) > generation_as_number(&blob.generation) {
655 Some(split_tokens)
656 } else {
657 Some(blob.tokens)
658 }
659 }
660 (Some((split_tokens, _)), None) => Some(split_tokens),
661 (None, Some(blob)) => Some(blob.tokens),
662 (None, None) => None,
663 }
664}
665
666/// Read the three split-entry (chunked) tokens. `Ok(None)` means none of the
667/// three fields exist yet (not migrated to this layout — caller falls
668/// through to the legacy-blob / legacy-plaintext sources). `Err` means a
669/// read itself failed (locked keychain, no Secret Service daemon,
670/// permission denied — not just "no entry"), which the caller may still
671/// recover from via a legacy fallback.
672/// Returns the tokens plus the generation stamp shared by all three fields
673/// (validated equal below) — callers that need to compare this layout's
674/// freshness against a coexisting blob use the second element; callers that
675/// don't (the common case) just ignore it.
676fn read_split_tokens(ns: &str) -> Result<Option<(MuxBusTokens, String)>, StoreError> {
677 match read_split_settled(ns)? {
678 SplitRead::Complete(tokens, generation) => Ok(Some((tokens, generation))),
679 SplitRead::Absent => Ok(None),
680 SplitRead::Torn { .. } => {
681 tracing::warn!(
682 "muxbus: split keychain entries have mismatched generation stamps or are \
683 incomplete (a torn write from an interrupted or concurrent save) — \
684 treating as not signed in; signing in again repairs it"
685 );
686 Ok(None)
687 }
688 }
689}
690
691/// Whether `tokens` were issued to the account `user_sub` names. A token that
692/// isn't a JWT, or a row with no `user_sub`, matches nothing.
693fn same_account(tokens: &MuxBusTokens, user_sub: &str) -> bool {
694 let sub = crate::muxbus::pkce::token_sub(&tokens.id_token);
695 let sub = if sub.is_empty() { crate::muxbus::pkce::token_sub(&tokens.access_token) } else { sub };
696 !user_sub.is_empty() && sub == user_sub
697}
698
699/// What the three split entries held when read.
700enum SplitRead {
701 /// None of the three fields exist yet.
702 Absent,
703 /// All three, from one save.
704 Complete(MuxBusTokens, String),
705 /// Present but not from one save: a read that landed mid-write, or a
706 /// write that was interrupted. `refresh` is the refresh token if its own
707 /// field is whole: Cognito doesn't rotate it, so refreshing with it can
708 /// make the set whole again.
709 Torn { refresh: Option<String> },
710}
711
712/// Read the split entries, retrying a few times while they look torn: a save
713/// by an older build (no cross-process lock) may be mid-write, and a moment
714/// later the same read is whole. A tear that persists is genuine.
715fn read_split_settled(ns: &str) -> Result<SplitRead, StoreError> {
716 retry_while_torn(|| read_split_state(ns), 4, std::time::Duration::from_millis(120))
717}
718
719fn retry_while_torn<F>(mut read: F, tries: u32, pause: std::time::Duration) -> Result<SplitRead, StoreError>
720where
721 F: FnMut() -> Result<SplitRead, StoreError>,
722{
723 let mut last = read()?;
724 for _ in 1..tries {
725 if !matches!(last, SplitRead::Torn { .. }) {
726 break;
727 }
728 std::thread::sleep(pause);
729 last = read()?;
730 }
731 Ok(last)
732}
733
734fn read_split_state(ns: &str) -> Result<SplitRead, StoreError> {
735 let read = |field| match read_chunked_field(&field_key(ns, field)) {
736 Err(FieldIncomplete::No(e)) => Err(e),
737 Err(FieldIncomplete::Yes) => Ok(None),
738 Ok(v) => Ok(Some(v)),
739 };
740 let (access, refresh, id) = (read(FIELD_ACCESS)?, read(FIELD_REFRESH)?, read(FIELD_ID)?);
741 let surviving = refresh.clone().flatten().map(|(token, _)| token).filter(|t| !t.is_empty());
742 let torn = || Ok(SplitRead::Torn { refresh: surviving.clone() });
743 // `None` here is a field caught mid-write (a chunk or `:gen` missing).
744 let (Some(access), Some(refresh), Some(id)) = (access, refresh, id) else {
745 return torn();
746 };
747
748 match (access, refresh, id) {
749 (None, None, None) => Ok(SplitRead::Absent),
750 (Some((access_token, gen_a)), Some((refresh_token, gen_r)), Some((id_token, gen_i))) => {
751 // reagent P2: write_split_tokens's own writes are individually
752 // consistent per-field (write_chunked_field's chunk+rollback
753 // atomicity) but not atomic ACROSS fields — a process kill
754 // between two fields' writes can leave one field's stale OLD
755 // value sitting next to the other two fields' fresh values,
756 // each independently well-formed, silently pairing tokens from
757 // two different login sessions. All three fields' generation
758 // stamps only match when they came from the SAME
759 // write_split_tokens call — a mismatch means a torn write.
760 if gen_a == gen_r && gen_r == gen_i {
761 Ok(SplitRead::Complete(
762 MuxBusTokens {
763 access_token,
764 refresh_token,
765 id_token,
766 },
767 gen_a,
768 ))
769 } else {
770 torn()
771 }
772 }
773 // Some fields present, some absent: shouldn't happen in normal
774 // operation (write_split_tokens writes and rolls back all three
775 // together) but follows an interrupted write.
776 _ => torn(),
777 }
778}
779
780impl MuxBusCredentials {
781 pub fn is_valid(&self) -> bool {
782 !self.access_token.is_empty() && self.expires_at > agentmux_common::time::now_secs()
783 }
784
785 pub fn nearly_expired(&self) -> bool {
786 !self.access_token.is_empty() && self.expires_at - agentmux_common::time::now_secs() < 300
787 }
788}
789
790impl Store {
791 pub fn muxbus_load(&self) -> Result<Option<MuxBusCredentials>, StoreError> {
792 self.muxbus_load_impl(true)
793 }
794
795 /// Side-effect-free (no keychain/SQL WRITE, see `allow_migration` below)
796 /// freshness check: valid, non-empty access token that isn't nearing
797 /// expiry. Safe for `RefreshScheduler::register`'s `is_fresh` closure,
798 /// called as it is under the per-id lock on every `ensure_fresh`/sweep
799 /// tick — the concern that closure must avoid (reagent P2 on #2260) is
800 /// an unexpected WRITE happening inside a nominally read-only check,
801 /// not lock acquisition itself; `muxbus_load_impl` below still takes
802 /// `muxbus_save_lock` (reagent P1 on this PR — see its comment) purely
803 /// to serialize this read against a concurrent writer, with no side
804 /// effect of its own performed while holding it. Any error (including a
805 /// genuinely corrupted keychain entry) is treated as "not fresh" rather
806 /// than propagated — a freshness *check* has no error channel to
807 /// propagate through, and "not fresh" just means the broker will
808 /// attempt a refresh, which surfaces the real problem through THAT path
809 /// instead.
810 pub fn muxbus_is_fresh(&self) -> bool {
811 self.muxbus_load_impl(false)
812 .ok()
813 .flatten()
814 .map(|c| !c.access_token.is_empty() && c.is_valid() && !c.nearly_expired())
815 .unwrap_or(false)
816 }
817
818 /// `allow_migration` gates only the lazy-migration self-heal WRITES in
819 /// `muxbus_load_tokens` below (`false` for `muxbus_is_fresh`'s
820 /// side-effect-free contract, `true` for the normal `muxbus_load`
821 /// path) — it does NOT gate the lock below, which every call takes.
822 fn muxbus_load_impl(&self, allow_migration: bool) -> Result<Option<MuxBusCredentials>, StoreError> {
823 // reagent P1 on #2260 (write-vs-write race) + reagent P1 on this PR
824 // (torn read): held for the ENTIRE keychain-read sequence, not just
825 // when `allow_migration` is true. Two independent reasons this must
826 // be unconditional now:
827 // 1. (#2260) The migration branches in `muxbus_load_tokens` read
828 // the keychain, then (on a cache miss) write fresh tokens to it
829 // and update SQL — without this lock, a concurrent `muxbus_save`
830 // could commit a real refresh between this thread's stale SQL
831 // read and its own keychain write, so the migration then
832 // overwrites the freshly-refreshed keychain entries with old
833 // pre-migration data.
834 // 2. (this PR) Each field's tokens now live across MULTIPLE
835 // keychain entries (`write_chunked_field`'s chunks + `:count`),
836 // written sequentially with no atomicity across them — unlike
837 // the single combined-blob write this replaced, which the OS
838 // keychain wrote atomically. An UNLOCKED concurrent read
839 // landing mid-write (previously safe when `allow_migration` was
840 // `false`, i.e. every `muxbus_is_fresh` call) can now read a mix
841 // of old and new chunks for a multi-chunk token and silently
842 // reconstruct a CORRUPTED string — every individual
843 // `get_optional` call still succeeds, so nothing errors, it's
844 // just wrong data. Locking here doesn't reintroduce a write
845 // side effect (see `muxbus_is_fresh`'s own doc comment above);
846 // it only serializes this read against `muxbus_save`/
847 // `muxbus_clear`'s writes, which already take the same lock.
848 let _migration_guard = self.muxbus_save_lock.lock().unwrap();
849 let _xproc = lock_namespace(&keychain_namespace())?;
850 let row_id = current_row_id();
851 let row = {
852 let conn = self.conn.lock().unwrap();
853 let mut stmt = conn.prepare(
854 "SELECT cognito_domain, client_id, access_token, refresh_token, id_token,
855 expires_at, user_email, user_sub
856 FROM db_muxbus_credentials WHERE id = ?1",
857 )?;
858 match stmt.query_row(params![row_id], |row| {
859 Ok((
860 row.get::<_, String>(0)?,
861 row.get::<_, String>(1)?,
862 row.get::<_, String>(2)?, // legacy plaintext access_token (pre-keychain rows)
863 row.get::<_, String>(3)?, // legacy plaintext refresh_token
864 row.get::<_, String>(4)?, // legacy plaintext id_token
865 row.get::<_, i64>(5)?,
866 row.get::<_, String>(6)?,
867 row.get::<_, String>(7)?,
868 ))
869 }) {
870 Ok(r) => r,
871 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
872 Err(e) => return Err(e.into()),
873 }
874 };
875 let (cognito_domain, client_id, legacy_access, legacy_refresh, legacy_id, expires_at, user_email, user_sub) = row;
876
877 let tokens = self.muxbus_load_tokens(allow_migration, &legacy_access, &legacy_refresh, &legacy_id, &user_sub)?;
878
879 Ok(Some(MuxBusCredentials {
880 cognito_domain,
881 client_id,
882 access_token: tokens.access_token,
883 refresh_token: tokens.refresh_token,
884 id_token: tokens.id_token,
885 expires_at,
886 user_email,
887 user_sub,
888 }))
889 }
890
891 /// Resolve the three token fields, checking sources in order:
892 /// 1. the current split-entry keychain layout (fast path),
893 /// 2. the legacy (pre-2026-08-03) single combined-blob keychain entry —
894 /// migrated in place to the split layout when found,
895 /// 3. legacy plaintext SQL columns from rows that predate keychain
896 /// storage entirely — migrated in place to the split layout too.
897 /// A keychain READ failure at any step falls back to the legacy
898 /// plaintext SQL columns when they have data, same as the pre-split-entry
899 /// code did — a transient storage failure must not present as a full
900 /// logout when we still have usable data sitting right there.
901 fn muxbus_load_tokens(
902 &self,
903 allow_migration: bool,
904 legacy_access: &str,
905 legacy_refresh: &str,
906 legacy_id: &str,
907 user_sub: &str,
908 ) -> Result<MuxBusTokens, StoreError> {
909 let legacy_plaintext = || MuxBusTokens {
910 access_token: legacy_access.to_string(),
911 refresh_token: legacy_refresh.to_string(),
912 id_token: legacy_id.to_string(),
913 };
914 let ns = keychain_namespace();
915
916 // Check THIS platform's preferred, current layout first — Windows
917 // needs the chunked per-field layout (its 1280-char keychain entry
918 // cap); macOS/Linux use the single combined blob instead (see
919 // `blob_key`'s doc comment for why they differ). No
920 // migration needed on a hit here — it's already the right format.
921 if cfg!(target_os = "windows") {
922 match read_split_settled(&ns) {
923 Ok(SplitRead::Complete(tokens, _generation)) => return Ok(tokens),
924 // Not yet on the split layout, or torn and still torn after
925 // the retries: check the other sources below.
926 Ok(SplitRead::Absent) => {}
927 // Torn, but its refresh token is whole: hand back just that,
928 // and the next refresh (which checks the account) saves a
929 // whole set again. No sign-in needed.
930 Ok(SplitRead::Torn { refresh: Some(refresh_token) }) => {
931 tracing::warn!(
932 "muxbus: split keychain entries are torn and stayed so on retry — \
933 keeping the refresh token so the next refresh repairs them"
934 );
935 return Ok(MuxBusTokens { refresh_token, ..Default::default() });
936 }
937 Ok(SplitRead::Torn { refresh: None }) => {
938 tracing::warn!(
939 "muxbus: split keychain entries are torn (mismatched generation stamps \
940 or incomplete) and stayed so on retry, with no whole refresh token — \
941 treating as not signed in; signing in again repairs it"
942 );
943 }
944 Err(e) => {
945 if !legacy_access.is_empty() {
946 tracing::warn!(
947 error = %e,
948 "muxbus: keychain read failed, falling back to legacy plaintext columns"
949 );
950 return Ok(legacy_plaintext());
951 }
952 return Err(e);
953 }
954 }
955 } else {
956 match secret_store::get_optional(&blob_key(&ns)) {
957 Ok(Some(blob)) => {
958 // reagent P2: a corrupted/unparseable keychain blob used
959 // to silently collapse to MuxBusTokens::default() via
960 // unwrap_or_default() — presenting as a full logout
961 // instead of surfacing that something is actually
962 // corrupted. A malformed blob here means something wrote
963 // bad data, not "no credential" — treat it the same way
964 // a real read error is treated: propagate it.
965 let parsed: MuxBusBlob = serde_json::from_str(&blob).map_err(|e| {
966 StoreError::Other(format!("muxbus: stored keychain blob is corrupted: {e}"))
967 })?;
968 let blob_generation = generation_as_number(&parsed.generation);
969
970 // A blob and split entries can coexist on this same
971 // machine two different ways, and they don't agree on
972 // which one is fresher — an actual timestamp comparison
973 // (not a hardcoded direction) is the only way to get
974 // both right. See `MuxBusBlob`'s doc comment.
975 // 1. (Codex P1) An EARLIER blob→split migration (the
976 // pre-2026-08-03 upgrade path, which ran on every
977 // platform before this fix existed) wrote split
978 // entries but its own best-effort blob delete then
979 // failed — split is fresher there.
980 // 2. (reagent P1) THIS fix's own self-heal
981 // (split→blob) can leave stale split leftovers if
982 // `delete_split_tokens` partially fails; a later
983 // `muxbus_save` (macOS/Linux only ever writes the
984 // blob) then makes the blob fresher than those
985 // leftovers.
986 match read_split_tokens(&ns) {
987 Ok(Some((split_tokens, split_generation))) => {
988 if generation_as_number(&split_generation) > blob_generation {
989 // Split is genuinely newer — finish the
990 // interrupted migration into this fix's
991 // preferred single-blob format.
992 if allow_migration {
993 match write_single_blob(&ns, &split_tokens) {
994 Ok(_) => delete_split_tokens(&ns),
995 Err(_) => {
996 tracing::warn!(
997 "muxbus: keychain write failed reconciling coexisting \
998 blob + split entries (split was fresher) — leaving \
999 both in place for now"
1000 );
1001 }
1002 }
1003 }
1004 return Ok(split_tokens);
1005 }
1006 // Blob is the same age or newer — it's
1007 // authoritative. The split entries are a stale
1008 // leftover from an incomplete cleanup; finish
1009 // that cleanup now instead of leaving it to
1010 // silently mislead the NEXT load too.
1011 if allow_migration {
1012 delete_split_tokens(&ns);
1013 }
1014 }
1015 Ok(None) => {} // no coexistence — the blob alone is authoritative
1016 Err(e) => {
1017 // Can't confirm whether split entries coexist —
1018 // fall back to the blob we already have in hand
1019 // rather than failing a read that would
1020 // otherwise have succeeded.
1021 tracing::warn!(
1022 error = %e,
1023 "muxbus: couldn't check for a coexisting split-entry layout — \
1024 using the blob value"
1025 );
1026 }
1027 }
1028 return Ok(parsed.tokens);
1029 }
1030 Ok(None) => {} // not yet on the single-blob layout — check other sources below
1031 Err(e) => {
1032 if !legacy_access.is_empty() {
1033 tracing::warn!(
1034 error = %e,
1035 "muxbus: keychain read failed, falling back to legacy plaintext columns"
1036 );
1037 return Ok(legacy_plaintext());
1038 }
1039 return Err(StoreError::Other(format!("muxbus: keychain read failed: {e}")));
1040 }
1041 }
1042 }
1043
1044 // Not on this platform's preferred layout — check the OTHER layout
1045 // as a migration source. On Windows this is the pre-2026-08-03
1046 // single-blob format. On macOS/Linux this is the chunked layout a
1047 // PRE-2026-08-19-FIX install on this same platform may have written
1048 // (every platform wrote it uniformly before this fix existed) — see
1049 // retro-macos-muxbus-keychain-prompt-storm-2026-08-19.md.
1050 if cfg!(target_os = "windows") {
1051 match secret_store::get_optional(&blob_key(&ns)) {
1052 Ok(Some(blob)) => {
1053 let tokens: MuxBusTokens = serde_json::from_str(&blob).map_err(|e| {
1054 StoreError::Other(format!("muxbus: stored keychain blob is corrupted: {e}"))
1055 })?;
1056 if allow_migration {
1057 match write_split_tokens(&ns, &tokens) {
1058 Ok(_) => {
1059 let _ = secret_store::delete(&blob_key(&ns));
1060 }
1061 Err(_) => {
1062 tracing::warn!(
1063 "muxbus: keychain write failed migrating the legacy combined-blob \
1064 entry to split entries — leaving the old entry in place for now"
1065 );
1066 }
1067 }
1068 }
1069 return Ok(tokens);
1070 }
1071 Ok(None) => {}
1072 Err(e) => {
1073 if !legacy_access.is_empty() {
1074 tracing::warn!(
1075 error = %e,
1076 "muxbus: keychain read failed, falling back to legacy plaintext columns"
1077 );
1078 return Ok(legacy_plaintext());
1079 }
1080 return Err(StoreError::Other(format!("muxbus: keychain read failed: {e}")));
1081 }
1082 }
1083 } else {
1084 match read_split_settled(&ns) {
1085 // Pre-blob split entries left torn: the same repair as on
1086 // Windows; the refresh's save writes a whole blob.
1087 Ok(SplitRead::Torn { refresh: Some(refresh_token) }) => {
1088 tracing::warn!(
1089 "muxbus: legacy split keychain entries are torn — keeping the refresh token \
1090 so the next refresh repairs them"
1091 );
1092 return Ok(MuxBusTokens { refresh_token, ..Default::default() });
1093 }
1094 Ok(SplitRead::Torn { refresh: None }) => {
1095 tracing::warn!(
1096 "muxbus: legacy split keychain entries are torn with no whole refresh token — \
1097 treating as not signed in; signing in again repairs it"
1098 );
1099 }
1100 Ok(SplitRead::Complete(tokens, _generation)) => {
1101 // No coexisting blob was found (the branch above this
1102 // one already checked and returned) — self-heal:
1103 // collapse the pre-fix chunked entries into one blob so
1104 // every subsequent load — and every future OS Keychain
1105 // consent prompt — touches exactly one entry instead of
1106 // up to twelve.
1107 if allow_migration {
1108 match write_single_blob(&ns, &tokens) {
1109 Ok(_) => delete_split_tokens(&ns),
1110 Err(_) => {
1111 tracing::warn!(
1112 "muxbus: keychain write failed migrating chunked entries to a \
1113 single blob — leaving the old entries in place for now"
1114 );
1115 }
1116 }
1117 }
1118 return Ok(tokens);
1119 }
1120 Ok(SplitRead::Absent) => {}
1121 Err(e) => {
1122 if !legacy_access.is_empty() {
1123 tracing::warn!(
1124 error = %e,
1125 "muxbus: keychain read failed, falling back to legacy plaintext columns"
1126 );
1127 return Ok(legacy_plaintext());
1128 }
1129 return Err(e);
1130 }
1131 }
1132 }
1133
1134 // Migration disallowed (muxbus_is_fresh's side-effect-free contract)
1135 // but legacy plaintext columns still have the data — read-only, same
1136 // values a migration would have written, just without touching the
1137 // keychain or SQL columns to get there.
1138 if !legacy_access.is_empty() {
1139 let tokens = legacy_plaintext();
1140 if allow_migration {
1141 // Lazy migration: this row predates keychain-backed storage.
1142 // Use the plaintext columns this one time, and self-heal by
1143 // writing them into this platform's preferred keychain
1144 // layout + blanking the SQL columns so every subsequent
1145 // load hits the keychain path instead.
1146 let write_result = if cfg!(target_os = "windows") {
1147 write_split_tokens(&ns, &tokens)
1148 } else {
1149 write_single_blob(&ns, &tokens)
1150 };
1151 match write_result {
1152 Ok(_) => {
1153 let conn = self.conn.lock().unwrap();
1154 let _ = conn.execute(
1155 "UPDATE db_muxbus_credentials
1156 SET access_token = '', refresh_token = '', id_token = ''
1157 WHERE id = ?1",
1158 params![current_row_id()],
1159 );
1160 }
1161 Err(_) => {
1162 tracing::warn!(
1163 "muxbus: keychain write failed during lazy migration — \
1164 leaving plaintext columns in place for now"
1165 );
1166 }
1167 }
1168 }
1169 return Ok(tokens);
1170 }
1171
1172 // This channel has its own SQL row (we only get here past the row
1173 // gate) but nothing under its own keychain namespace: it signed in
1174 // before tokens were scoped per channel, when they went to the
1175 // host-wide set. Adopt that set once, read-only — the global entries
1176 // are never written or deleted from here, so other channels and the
1177 // `stable` channel keep theirs. From the next save on, this channel's
1178 // tokens are its own. Only the same account's: `stable` may be signed
1179 // in as another, and its tokens must not pair with this row.
1180 if ns != GLOBAL_KEYCHAIN_NS {
1181 // Its own lock: `stable` saves the host-wide set under that one.
1182 // Always taken after this channel's, never the other way round.
1183 let global_tokens = {
1184 let _global = lock_namespace(GLOBAL_KEYCHAIN_NS)?;
1185 read_any_layout(GLOBAL_KEYCHAIN_NS)
1186 };
1187 if let Some(tokens) = global_tokens.filter(|t| same_account(t, user_sub)) {
1188 tracing::info!(
1189 namespace = %ns,
1190 "muxbus: adopting the host-wide session for this channel (one-time, pre-per-channel sign-in)"
1191 );
1192 if allow_migration {
1193 let write_result = if cfg!(target_os = "windows") {
1194 write_split_tokens(&ns, &tokens)
1195 } else {
1196 write_single_blob(&ns, &tokens)
1197 };
1198 if write_result.is_err() {
1199 tracing::warn!(
1200 "muxbus: couldn't copy the adopted session into this channel's \
1201 keychain namespace — it stays readable from the host-wide set for now"
1202 );
1203 }
1204 }
1205 return Ok(tokens);
1206 }
1207 }
1208
1209 Ok(MuxBusTokens::default())
1210 }
1211
1212 /// One process at a time refreshes this channel's sign-in; the others
1213 /// wait, then find it already fresh. Its own lock, apart from the store's,
1214 /// so reads aren't held up by the token request.
1215 pub fn muxbus_refresh_lock(&self) -> Result<Option<CrossProcessGuard>, StoreError> {
1216 let ns = format!("{}:refresh", keychain_namespace());
1217 lock_in(crate::registry::resolve_global_shared_root(), &ns, REFRESH_LOCK_WAIT)
1218 }
1219
1220 pub fn muxbus_save(&self, creds: &MuxBusCredentials) -> Result<(), StoreError> {
1221 // Held for the ENTIRE keychain-read/write + SQL-write + rollback
1222 // sequence below, not just the SQL portion `self.conn`'s own lock
1223 // covers — reagent P1 on #2260: `muxbus.login` and the broker's
1224 // refresh closure can each call muxbus_save independently, and
1225 // without this a race between two concurrent calls could make one
1226 // call's rollback restore a stale snapshot over the OTHER call's
1227 // already-committed credential.
1228 let _save_guard = self.muxbus_save_lock.lock().unwrap();
1229 let ns = keychain_namespace();
1230 let _xproc = lock_namespace(&ns)?;
1231
1232 // Read the outgoing account's user_sub now, before it's overwritten
1233 // below, so a genuine account switch (vs. a same-account token
1234 // refresh) can be detected after the write succeeds and the stale
1235 // per-agent credential cache cleared. reagentx P0 on PR #2342.
1236 let previous_user_sub: Option<String> = {
1237 let conn = self.conn.lock().unwrap();
1238 conn.query_row(
1239 "SELECT user_sub FROM db_muxbus_credentials WHERE id = ?1",
1240 params![current_row_id()],
1241 |row| row.get(0),
1242 )
1243 .ok()
1244 };
1245
1246 let tokens = MuxBusTokens {
1247 access_token: creds.access_token.clone(),
1248 refresh_token: creds.refresh_token.clone(),
1249 id_token: creds.id_token.clone(),
1250 };
1251
1252 // Windows needs the chunked per-field layout (its 1280-char keychain
1253 // entry cap — see `blob_key`'s doc comment); macOS/Linux
1254 // have no such cap, so they write the single combined blob instead,
1255 // to avoid the ~12-separate-consent-prompts problem chunking causes
1256 // on macOS Keychain specifically. Either function returns each
1257 // entry's pre-call state so the SQL failure branch below can roll it
1258 // all back together too if the SQL write itself then fails (reagent
1259 // P2 on #2260: without this, a keychain write that succeeds followed
1260 // by a SQL write that then fails — e.g. a transient lock — leaves
1261 // the FRESH tokens paired with the OLD SQL metadata, a mismatch that
1262 // previously only self-healed on the next successful save).
1263 let priors = if cfg!(target_os = "windows") {
1264 write_split_tokens(&ns, &tokens)?
1265 } else {
1266 write_single_blob(&ns, &tokens)?
1267 };
1268
1269 let sql_result = {
1270 let conn = self.conn.lock().unwrap();
1271 conn.execute(
1272 "INSERT OR REPLACE INTO db_muxbus_credentials
1273 (id, cognito_domain, client_id, access_token, refresh_token, id_token,
1274 expires_at, user_email, user_sub)
1275 VALUES (?6, ?1, ?2, '', '', '', ?3, ?4, ?5)",
1276 params![
1277 creds.cognito_domain,
1278 creds.client_id,
1279 creds.expires_at,
1280 creds.user_email,
1281 creds.user_sub,
1282 current_row_id(),
1283 ],
1284 )
1285 };
1286 if let Err(e) = sql_result {
1287 for (key, prior) in &priors {
1288 if let Err(re) = prior.restore(key) {
1289 tracing::warn!(
1290 error = %re,
1291 sql_error = %e,
1292 key = %key,
1293 "muxbus_save: SQL write failed AND restoring this field's prior keychain \
1294 state also failed — keychain may now hold a fresh token for it with no \
1295 matching SQL metadata until the next successful save"
1296 );
1297 }
1298 }
1299 return Err(e.into());
1300 }
1301
1302 // Account switch (not a same-account token refresh): the previous
1303 // account's per-agent M2M credentials must not silently keep
1304 // authenticating this different account's requests. A first-ever
1305 // login (previous_user_sub is None/empty) has nothing to clear.
1306 // reagentx P0 on PR #2342.
1307 if let Some(prev) = previous_user_sub {
1308 if !prev.is_empty() && prev != creds.user_sub {
1309 if let Err(e) = self.agent_credentials_clear_all() {
1310 tracing::warn!(
1311 error = %e,
1312 "muxbus_save: failed to clear stale per-agent credentials after account switch",
1313 );
1314 }
1315 }
1316 }
1317
1318 Ok(())
1319 }
1320
1321 pub fn muxbus_clear(&self) -> Result<(), StoreError> {
1322 // reagent P1 on #2260: without this lock, a concurrent muxbus_save
1323 // (broker refresh or muxbus.login) can commit its keychain + SQL
1324 // write after this function's own delete runs, silently
1325 // resurrecting a credential right after the user disconnected —
1326 // same race class muxbus_save/muxbus_load_impl already serialize
1327 // against each other for.
1328 let _clear_guard = self.muxbus_save_lock.lock().unwrap();
1329 let ns = keychain_namespace();
1330 let _xproc = lock_namespace(&ns)?;
1331 // Best-effort — a missing/inaccessible keychain entry must not block
1332 // clearing the (still-useful) SQL row. Clears every chunk + count/gen
1333 // entry the Windows-only chunked layout could have written (harmless
1334 // no-ops on macOS/Linux, which never write it, but a pre-fix install
1335 // there may still have some left over — see `delete_split_tokens`),
1336 // plus the single combined-blob key every platform's CURRENT write
1337 // path (`write_single_blob` on macOS/Linux) or legacy migration
1338 // source (Windows) uses.
1339 delete_split_tokens(&ns);
1340 let _ = secret_store::delete(&blob_key(&ns));
1341 {
1342 let conn = self.conn.lock().unwrap();
1343 conn.execute("DELETE FROM db_muxbus_credentials WHERE id = ?1", params![current_row_id()])?;
1344 }
1345 // Logging out invalidates any per-agent credentials provisioned
1346 // under this account too. reagentx P0 on PR #2342.
1347 if let Err(e) = self.agent_credentials_clear_all() {
1348 tracing::warn!(error = %e, "muxbus_clear: failed to clear per-agent credentials");
1349 }
1350 Ok(())
1351 }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357
1358 #[test]
1359 fn tokens_json_round_trips() {
1360 // Legacy combined-blob shape — still needed to deserialize an old
1361 // single-entry keychain value during one-time migration.
1362 let tokens = MuxBusTokens {
1363 access_token: "at".to_string(),
1364 refresh_token: "rt".to_string(),
1365 id_token: "it".to_string(),
1366 };
1367 let blob = serde_json::to_string(&tokens).unwrap();
1368 let back: MuxBusTokens = serde_json::from_str(&blob).unwrap();
1369 assert_eq!(back.access_token, "at");
1370 assert_eq!(back.refresh_token, "rt");
1371 assert_eq!(back.id_token, "it");
1372 }
1373
1374 #[test]
1375 fn muxbus_blob_round_trips_with_generation() {
1376 let value = MuxBusBlob {
1377 tokens: MuxBusTokens {
1378 access_token: "at".to_string(),
1379 refresh_token: "rt".to_string(),
1380 id_token: "it".to_string(),
1381 },
1382 generation: "12345".to_string(),
1383 };
1384 let json = serde_json::to_string(&value).unwrap();
1385 let back: MuxBusBlob = serde_json::from_str(&json).unwrap();
1386 assert_eq!(back.tokens.access_token, "at");
1387 assert_eq!(back.generation, "12345");
1388 }
1389
1390 #[test]
1391 fn muxbus_blob_deserializes_a_pre_generation_legacy_blob_with_empty_generation() {
1392 // A genuinely old, pre-fix blob has no `generation` key at all —
1393 // `#[serde(default)]` must fill it in as "" rather than fail to
1394 // parse, so it correctly reads as the oldest possible timestamp in
1395 // any freshness comparison (see `generation_as_number`).
1396 let legacy_json = r#"{"access_token":"at","refresh_token":"rt","id_token":"it"}"#;
1397 let back: MuxBusBlob = serde_json::from_str(legacy_json).unwrap();
1398 assert_eq!(back.tokens.access_token, "at");
1399 assert_eq!(back.generation, "");
1400 }
1401
1402 #[test]
1403 fn generation_as_number_orders_real_timestamps_correctly() {
1404 let older = new_generation();
1405 let newer = new_generation();
1406 assert!(
1407 generation_as_number(&newer) >= generation_as_number(&older),
1408 "a later new_generation() call must not compare as older"
1409 );
1410 }
1411
1412 #[test]
1413 fn generation_as_number_treats_empty_or_corrupted_values_as_the_oldest_possible() {
1414 assert_eq!(generation_as_number(""), 0);
1415 assert_eq!(generation_as_number("not-a-number"), 0);
1416 assert!(generation_as_number(&new_generation()) > generation_as_number(""));
1417 }
1418
1419 #[test]
1420 fn field_key_is_namespaced_and_distinct_per_field() {
1421 // The host-wide names must not change: installed `stable` users'
1422 // existing sign-ins live under exactly these entries.
1423 let access = field_key(GLOBAL_KEYCHAIN_NS, FIELD_ACCESS);
1424 let refresh = field_key(GLOBAL_KEYCHAIN_NS, FIELD_REFRESH);
1425 let id = field_key(GLOBAL_KEYCHAIN_NS, FIELD_ID);
1426 assert_eq!(access, "muxbus:global:access");
1427 assert_eq!(refresh, "muxbus:global:refresh");
1428 assert_eq!(id, "muxbus:global:id");
1429 assert_eq!(blob_key(GLOBAL_KEYCHAIN_NS), "muxbus:global");
1430 assert_ne!(access, blob_key(GLOBAL_KEYCHAIN_NS));
1431 }
1432
1433 #[test]
1434 fn every_channel_but_stable_signs_in_on_its_own() {
1435 // stable (the installed releases) and no channel: the host-wide set,
1436 // as ever.
1437 assert_eq!(namespace_for(Some("stable")), "muxbus:global");
1438 assert_eq!(namespace_for(None), "muxbus:global");
1439 assert_eq!(namespace_for(Some("")), "muxbus:global");
1440 // Any other channel is its own entry, whether or not auth is isolated:
1441 // the channel is the unit of sign-in, not the host.
1442 assert_eq!(namespace_for(Some("local-main-x")), "muxbus:channel:local-main-x");
1443 assert_eq!(namespace_for(Some("dev-feat-a-1234")), "muxbus:channel:dev-feat-a-1234");
1444 }
1445
1446 #[test]
1447 fn a_channels_row_is_its_own_in_the_store_channels_share() {
1448 assert_eq!(row_id_for(Some("stable"), false), "global");
1449 assert_eq!(row_id_for(None, false), "global");
1450 assert_eq!(row_id_for(Some("local-main-x"), false), "channel:local-main-x");
1451 assert_eq!(row_id_for(Some("local-main-y"), false), "channel:local-main-y");
1452 // A store that is already the channel's own keeps the row it has: an
1453 // isolated channel's existing sign-in stays valid.
1454 assert_eq!(row_id_for(Some("local-main-x"), true), "global");
1455 }
1456
1457 #[test]
1458 fn a_channels_agent_credentials_are_its_own_rows() {
1459 assert_eq!(agent_credential_prefix_for(Some("stable"), false), "");
1460 assert_eq!(agent_credential_prefix_for(None, false), "");
1461 assert_eq!(agent_credential_prefix_for(Some("local-main-x"), false), "local-main-x/");
1462 assert_eq!(agent_credential_prefix_for(Some("local-main-x"), true), "");
1463 // An agent id has no `/`, so a prefix can't be mistaken for one.
1464 assert!(!"agentx".contains('/'));
1465 }
1466
1467 #[test]
1468 fn channel_namespaces_never_share_an_entry_with_each_other_or_the_global_set() {
1469 let a = namespace_for(Some("local-main-a"));
1470 let b = namespace_for(Some("local-main-b"));
1471 let g = GLOBAL_KEYCHAIN_NS.to_string();
1472 let keys = |ns: &str| {
1473 let mut k = vec![blob_key(ns)];
1474 for f in [FIELD_ACCESS, FIELD_REFRESH, FIELD_ID] {
1475 let fk = field_key(ns, f);
1476 k.extend([chunk_key(&fk, 0), count_key(&fk), generation_key(&fk), fk]);
1477 }
1478 k
1479 };
1480 for (x, y) in [(&a, &b), (&a, &g), (&b, &g)] {
1481 let (kx, ky) = (keys(x), keys(y));
1482 assert!(kx.iter().all(|k| !ky.contains(k)), "{x} and {y} share a keychain entry");
1483 }
1484 }
1485
1486 /// Exercises the REAL OS keychain, so it's ignored in CI (no keychain
1487 /// there). Run on a dev machine:
1488 /// `cargo test -p agentmux-srv --bin agentmux-srv per_channel_keychain_live -- --ignored`.
1489 /// Uses throwaway namespaces and cleans up after itself; never touches
1490 /// `muxbus:global`.
1491 #[test]
1492 #[ignore]
1493 fn per_channel_keychain_live() {
1494 let run = new_generation();
1495 let ns_a = format!("muxbus:channel:test-{run}-a");
1496 let ns_b = format!("muxbus:channel:test-{run}-b");
1497 let tok = |s: &str| MuxBusTokens {
1498 access_token: format!("access-{s}-{}", "x".repeat(1500)),
1499 refresh_token: format!("refresh-{s}"),
1500 id_token: format!("id-{s}"),
1501 };
1502 let write = |ns: &str, t: &MuxBusTokens| {
1503 if cfg!(target_os = "windows") {
1504 write_split_tokens(ns, t).map(|_| ())
1505 } else {
1506 write_single_blob(ns, t).map(|_| ())
1507 }
1508 };
1509 let clear = |ns: &str| {
1510 delete_split_tokens(ns);
1511 let _ = secret_store::delete(&blob_key(ns));
1512 };
1513
1514 write(&ns_a, &tok("a")).expect("write a");
1515 write(&ns_b, &tok("b")).expect("write b");
1516 // Each channel reads back its own session, not the other's.
1517 assert_eq!(read_any_layout(&ns_a).unwrap().refresh_token, "refresh-a");
1518 assert_eq!(read_any_layout(&ns_b).unwrap().refresh_token, "refresh-b");
1519 // Signing one channel out leaves the other signed in.
1520 clear(&ns_a);
1521 assert!(read_any_layout(&ns_a).is_none(), "a cleared");
1522 assert_eq!(read_any_layout(&ns_b).unwrap().access_token, tok("b").access_token);
1523 clear(&ns_b);
1524 assert!(read_any_layout(&ns_b).is_none(), "b cleared");
1525
1526 // Both layouts present under one namespace: the fresher generation
1527 // wins, whichever layout it's in (adoption must not pick stale
1528 // leftovers).
1529 // Small tokens here: a single blob over Windows Credential Manager's
1530 // 2560-byte cap can't be written at all (why Windows splits).
1531 let small = |s: &str| MuxBusTokens {
1532 access_token: format!("access-{s}"),
1533 refresh_token: format!("refresh-{s}"),
1534 id_token: format!("id-{s}"),
1535 };
1536 let ns_c = format!("muxbus:channel:test-{run}-c");
1537 write_split_tokens(&ns_c, &small("split-old")).expect("split old");
1538 write_single_blob(&ns_c, &small("blob-new")).expect("blob new");
1539 assert_eq!(read_any_layout(&ns_c).unwrap().refresh_token, "refresh-blob-new");
1540 write_split_tokens(&ns_c, &small("split-newest")).expect("split newest");
1541 assert_eq!(read_any_layout(&ns_c).unwrap().refresh_token, "refresh-split-newest");
1542 clear(&ns_c);
1543 assert!(read_any_layout(&ns_c).is_none(), "c cleared");
1544 }
1545
1546 /// Regression guard for every bug this file has fixed in sequence: the
1547 /// original combined-blob-exceeds-2560-bytes bug, the follow-up found
1548 /// live-testing the first fix (a single field's own token can ALSO
1549 /// exceed the cap), and the real character limit being HALF the
1550 /// byte constant the `keyring` crate's error message reports (Windows
1551 /// stores the value as UTF-16 — see PLAN_MUXBUS_KEYCHAIN_WINDOWS_BLOB_LIMIT_2026_08_03.md
1552 /// §6-§7). Doesn't exercise the real OS keychain (not available in CI);
1553 /// asserts the pure chunking math instead: every chunk of an oversized
1554 /// value stays under the REAL char limit (not the misleading one from
1555 /// the error text), and concatenating them reconstructs the original
1556 /// exactly.
1557 #[test]
1558 fn chunk_value_keeps_every_chunk_under_windows_credential_blob_limit_and_reconstructs() {
1559 // `keyring` checks `password.encode_utf16().count() * 2 >
1560 // CRED_MAX_CREDENTIAL_BLOB_SIZE` (2560 bytes) — so the real char
1561 // budget for an all-ASCII (1 UTF-16 unit each) token is half that.
1562 const WINDOWS_CRED_BLOB_BYTE_LIMIT: usize = 2560;
1563 const WINDOWS_CRED_CHAR_LIMIT: usize = WINDOWS_CRED_BLOB_BYTE_LIMIT / 2;
1564
1565 // Larger than even the old *combined* blob would have been —
1566 // proves this doesn't just move the limit, it removes it.
1567 let oversized_token = "i".repeat(6000);
1568
1569 let chunks = chunk_value(&oversized_token);
1570 assert!(chunks.len() > 1, "a value this large must actually split");
1571 for chunk in &chunks {
1572 assert!(chunk.len() < WINDOWS_CRED_CHAR_LIMIT);
1573 }
1574 assert_eq!(chunks.concat(), oversized_token);
1575 }
1576
1577 #[test]
1578 fn chunk_value_small_input_is_a_single_chunk() {
1579 assert_eq!(chunk_value("short-token"), vec!["short-token"]);
1580 }
1581
1582 #[test]
1583 fn chunk_value_empty_string_is_one_empty_chunk() {
1584 // Not zero chunks — `read_chunked_field` needs a `:count` of at
1585 // least 1 to distinguish "field written as empty" from "field never
1586 // written at all" (`Ok(None)`).
1587 assert_eq!(chunk_value(""), vec![""]);
1588 }
1589
1590 /// The point of the lock: a second holder waits for the first, across
1591 /// separate handles (what two processes have).
1592 #[test]
1593 fn the_cross_process_lock_makes_a_second_holder_wait() {
1594 let dir = tempfile::tempdir().unwrap();
1595 let path = dir.path().join("ns.lock");
1596 let first = lock_file(&path, std::time::Duration::from_secs(1)).expect("first lock");
1597 let started = std::time::Instant::now();
1598 // Held: a short wait gives up without the lock.
1599 assert!(lock_file(&path, std::time::Duration::from_millis(150)).is_none());
1600 assert!(started.elapsed() >= std::time::Duration::from_millis(140));
1601 drop(first);
1602 // Released: the next holder gets it at once.
1603 assert!(lock_file(&path, std::time::Duration::from_millis(150)).is_some());
1604 }
1605
1606 /// A namespace whose lock another process holds fails the operation
1607 /// instead of running it unlocked; with no shared root there is no lock.
1608 #[test]
1609 fn a_lock_that_cant_be_taken_fails_the_operation() {
1610 let dir = tempfile::tempdir().unwrap();
1611 let wait = std::time::Duration::from_millis(100);
1612 let held = lock_in(Some(dir.path().to_path_buf()), "muxbus:global", wait).unwrap();
1613 assert!(held.is_some());
1614 assert!(lock_in(Some(dir.path().to_path_buf()), "muxbus:global", wait).is_err());
1615 assert!(lock_in(Some(dir.path().to_path_buf()), "muxbus:channel:dev-x", wait).unwrap().is_some(), "per namespace");
1616 drop(held);
1617 assert!(lock_in(Some(dir.path().to_path_buf()), "muxbus:global", wait).unwrap().is_some());
1618 assert!(lock_in(None, "muxbus:global", wait).unwrap().is_none());
1619 }
1620
1621 /// A torn read that heals (an older build finished its save) is retried,
1622 /// not reported as a sign-out; one that stays torn is reported as such.
1623 #[test]
1624 fn a_torn_read_is_retried_until_it_settles() {
1625 let pause = std::time::Duration::from_millis(1);
1626 let mut calls = 0;
1627 let healed = retry_while_torn(
1628 || {
1629 calls += 1;
1630 Ok(if calls < 3 {
1631 SplitRead::Torn { refresh: None }
1632 } else {
1633 SplitRead::Complete(MuxBusTokens::default(), "1".to_string())
1634 })
1635 },
1636 4,
1637 pause,
1638 )
1639 .unwrap();
1640 assert!(matches!(healed, SplitRead::Complete(..)));
1641 assert_eq!(calls, 3);
1642
1643 let mut calls = 0;
1644 let stuck = retry_while_torn(|| { calls += 1; Ok(SplitRead::Torn { refresh: None }) }, 4, pause).unwrap();
1645 assert!(matches!(stuck, SplitRead::Torn { .. }));
1646 assert_eq!(calls, 4, "gives up after the allowed tries");
1647
1648 let mut calls = 0;
1649 let absent = retry_while_torn(|| { calls += 1; Ok(SplitRead::Absent) }, 4, pause).unwrap();
1650 assert!(matches!(absent, SplitRead::Absent));
1651 assert_eq!(calls, 1, "an absent login is not retried");
1652 }
1653
1654 /// The host-wide set is adopted into a channel only for the account the
1655 /// channel's row names.
1656 #[test]
1657 fn only_the_same_accounts_tokens_are_adopted() {
1658 use base64::Engine;
1659 let jwt = |sub: &str| {
1660 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"sub":"{sub}"}}"#));
1661 format!("e30.{payload}.sig")
1662 };
1663 let tokens = |id: String, access: String| MuxBusTokens { access_token: access, refresh_token: "r".into(), id_token: id };
1664 assert!(same_account(&tokens(jwt("user-a"), jwt("user-a")), "user-a"));
1665 assert!(!same_account(&tokens(jwt("user-b"), jwt("user-b")), "user-a"), "another account's");
1666 assert!(same_account(&tokens(String::new(), jwt("user-a")), "user-a"), "no id token: the access token's");
1667 assert!(!same_account(&tokens("opaque".into(), "opaque".into()), "user-a"), "not a JWT");
1668 assert!(!same_account(&tokens(jwt(""), jwt("")), ""), "a row with no account matches nothing");
1669 }
1670
1671 /// A torn set keeps its refresh token when that field is whole (so a
1672 /// refresh can repair it), and not when the refresh field itself was
1673 /// caught mid-write. Real keychain, Windows (the split layout):
1674 /// `cargo test -p agentmux-srv --bin agentmux-srv a_torn_set_keeps -- --ignored`.
1675 #[test]
1676 #[ignore]
1677 fn a_torn_set_keeps_a_whole_refresh_token_live() {
1678 if !cfg!(target_os = "windows") {
1679 return;
1680 }
1681 let ns = format!("muxbus:channel:test-{}-torn", new_generation());
1682 let tokens = MuxBusTokens {
1683 access_token: "access-a".into(),
1684 refresh_token: "refresh-a".into(),
1685 id_token: "id-a".into(),
1686 };
1687 write_split_tokens(&ns, &tokens).expect("write");
1688 // Another save's access token with its own stamp: the set is torn.
1689 write_chunked_field(&field_key(&ns, FIELD_ACCESS), "access-b", "1").expect("tear");
1690 let refresh_after = |ns: &str| match read_split_state(ns).expect("read") {
1691 SplitRead::Torn { refresh } => refresh,
1692 _ => panic!("expected a torn set"),
1693 };
1694 assert_eq!(refresh_after(&ns).as_deref(), Some("refresh-a"));
1695 // The refresh field mid-write: its stamp goes first.
1696 secret_store::delete(&generation_key(&field_key(&ns, FIELD_REFRESH))).expect("delete stamp");
1697 assert_eq!(refresh_after(&ns), None);
1698 delete_split_tokens(&ns);
1699 let _ = secret_store::delete(&generation_key(&field_key(&ns, FIELD_REFRESH)));
1700 }
1701
1702 const HAMMER_NS: &str = "AGENTMUX_TEST_MUXBUS_HAMMER_NS";
1703 const HAMMER_LOCK: &str = "AGENTMUX_TEST_MUXBUS_HAMMER_LOCK";
1704
1705 /// One child of `concurrent_processes_never_tear_a_live_sign_in`: save and
1706 /// read the same namespace in a loop, printing how many reads were torn
1707 /// or paired tokens from two saves.
1708 fn hammer(ns: &str, lock_path: Option<&std::path::Path>) -> usize {
1709 let me = std::process::id();
1710 let wait = std::time::Duration::from_secs(120);
1711 let mut torn = 0;
1712 let mut longest_save = std::time::Duration::ZERO;
1713 for i in 0..40 {
1714 let tag = format!("{me}-{i}");
1715 let tokens = MuxBusTokens {
1716 access_token: format!("access-{tag}-{}", "x".repeat(1500)),
1717 refresh_token: format!("refresh-{tag}"),
1718 id_token: format!("id-{tag}"),
1719 };
1720 {
1721 let _guard = lock_path.map(|p| lock_file(p, wait).expect("lock"));
1722 let started = std::time::Instant::now();
1723 // Unlocked, a write can fail when another removes an entry under it.
1724 let _ = write_split_tokens(ns, &tokens);
1725 longest_save = longest_save.max(started.elapsed());
1726 }
1727 let _guard = lock_path.map(|p| lock_file(p, wait).expect("lock"));
1728 // An `Err` is a read that found a field mid-write (its `:gen` is
1729 // deleted first): bad too, though callers retry it as transient.
1730 match read_split_state(ns) {
1731 Ok(SplitRead::Complete(t, _)) => {
1732 let saved = t.refresh_token.trim_start_matches("refresh-");
1733 if !t.access_token.starts_with(&format!("access-{saved}-")) || t.id_token != format!("id-{saved}") {
1734 torn += 1;
1735 }
1736 }
1737 Ok(SplitRead::Torn { .. }) | Err(_) => torn += 1,
1738 Ok(SplitRead::Absent) => panic!("the sign-in vanished"),
1739 }
1740 }
1741 println!("HAMMER longest_save_ms={}", longest_save.as_millis());
1742 torn
1743 }
1744
1745 /// The bug behind the retro, against the REAL OS keychain: several
1746 /// processes saving and reading one sign-in at once. Without the lock it
1747 /// usually tears (printed, not asserted: it's a race); with it, never.
1748 /// `cargo test -p agentmux-srv --bin agentmux-srv concurrent_processes_never_tear -- --ignored --nocapture`
1749 #[test]
1750 #[ignore]
1751 fn concurrent_processes_never_tear_a_live_sign_in() {
1752 if let Ok(ns) = std::env::var(HAMMER_NS) {
1753 let lock = std::env::var(HAMMER_LOCK).ok().filter(|p| !p.is_empty());
1754 println!("HAMMER torn={}", hammer(&ns, lock.as_deref().map(std::path::Path::new)));
1755 return;
1756 }
1757 if !cfg!(target_os = "windows") {
1758 return; // only Windows splits a sign-in across entries
1759 }
1760 let dir = tempfile::tempdir().unwrap();
1761 let lock_path = dir.path().join("hammer.lock");
1762 let longest_save_ms = std::cell::Cell::new(0u128);
1763 let run = |locked: bool| -> usize {
1764 let ns = format!("muxbus:channel:test-hammer-{}", new_generation());
1765 let children: Vec<_> = (0..4)
1766 .map(|_| {
1767 std::process::Command::new(std::env::current_exe().unwrap())
1768 .args(["--exact", "backend::storage::muxbus::tests::concurrent_processes_never_tear_a_live_sign_in"])
1769 .args(["--ignored", "--nocapture"])
1770 .env(HAMMER_NS, &ns)
1771 .env(HAMMER_LOCK, if locked { lock_path.to_str().unwrap() } else { "" })
1772 .stdout(std::process::Stdio::piped())
1773 .spawn()
1774 .expect("spawn")
1775 })
1776 .collect();
1777 let torn = children
1778 .into_iter()
1779 .map(|c| {
1780 let out = String::from_utf8_lossy(&c.wait_with_output().unwrap().stdout).into_owned();
1781 if let Some(ms) = out.lines().find_map(|l| l.strip_prefix("HAMMER longest_save_ms=")) {
1782 longest_save_ms.set(longest_save_ms.get().max(ms.trim().parse().unwrap_or(0)));
1783 }
1784 let line = out.lines().find(|l| l.starts_with("HAMMER torn=")).unwrap_or_else(|| panic!("child output: {out}"));
1785 line["HAMMER torn=".len()..].trim().parse::<usize>().unwrap()
1786 })
1787 .sum();
1788 delete_split_tokens(&ns);
1789 torn
1790 };
1791 let unlocked = run(false);
1792 let locked = run(true);
1793 println!(
1794 "torn reads out of 160: without the lock {unlocked}, with it {locked}; longest save {} ms",
1795 longest_save_ms.get()
1796 );
1797 assert_eq!(locked, 0, "the lock must make save and read one critical section");
1798 }
1799}