Skip to main content

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}