Skip to main content

agentmux_srv/backend/storage/
identities.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Identity subsystem — accounts and the direct junction that ties
5//! them to agents.
6//!
7//! Layered model:
8//! - **`IdentityAccount`** (`db_accounts`): a single credential
9//!   pointer (provider + kind + secret_ref).
10//! - **`AgentIdentityLink`** (`db_agent_identity_links`): the sole
11//!   credential-resolution path — binds an agent (definition) to an
12//!   account directly, per provider.
13//!
14//! The Identity *bundle* layer (`Identity`/`IdentityBinding`,
15//! `db_identity_bundles`/`db_identity_bindings`) was retired in Phase 4c
16//! of `SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md` — direct links had
17//! already fully superseded it as the credential-resolution path (see
18//! `identity/resolver/inject.rs::resolve_bindings_for_instance`).
19//!
20//! Extracted from `store.rs` in Phase R.2 of the storage
21//! modularization plan
22//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). The
23//! method surface is unchanged — `Store::identity_*` and
24//! `agent_identity_*` still live on `Store` via this `impl` block;
25//! existing imports of the structs from `storage::store::*` keep
26//! working thanks to re-exports.
27
28use rusqlite::params;
29use serde::{Deserialize, Serialize};
30
31use super::error::StoreError;
32use super::store::Store;
33
34/// Provider-specific credential reference. Stored as JSON in
35/// `db_accounts.secret_ref`. `backend` is the discriminator.
36/// The actual secret value is NEVER stored in the DB — only how to
37/// look it up at launch time (env var, secrets-manager path, etc.).
38/// `PlaintextDev` exists for local dev convenience and must never be
39/// the default path in production builds.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ts_rs::TS)]
41#[ts(export, export_to = "../../../frontend/types/rpc/")]
42#[serde(tag = "backend", rename_all = "snake_case")]
43pub enum SecretRef {
44    Env {
45        env_var: String,
46    },
47    SecretsManager {
48        sm_path: String,
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        #[ts(optional)]
51        sm_json_path: Option<String>,
52    },
53    PlaintextDev {
54        plaintext_dev: String,
55    },
56    /// **OAuth credentials stored as a filesystem pointer.** The CLI
57    /// (Claude Code, codex, openclaw, …) reads its OAuth tokens from
58    /// this directory at spawn time — agentmux only holds the path,
59    /// never the tokens themselves. Token refresh is the CLI's job;
60    /// the path stays stable across refreshes. Used by oauth-class
61    /// providers; the resolver (PR B) dispatches to a config-dir
62    /// env-var injection mode rather than the api-key env-var path.
63    /// See `docs/specs/archive/SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md`.
64    ///
65    /// Explicit `rename` — the enum-wide `rename_all = "snake_case"` splits
66    /// at every capital letter, including inside "OAuth" itself, producing
67    /// `o_auth_config_dir` (verified: that's what a live deserialization
68    /// failure actually expected). Every real account on disk was written
69    /// with `oauth_config_dir` (no extra underscore — how "OAuth" is
70    /// conventionally spelled), so the derived name silently stopped
71    /// matching stored data — this makes the wire format explicit instead
72    /// of relying on the derive macro to guess right for an acronym.
73    ///
74    /// The `alias` keeps accepting the derive-produced `o_auth_config_dir`
75    /// tag too: any account persisted by a backend running between this
76    /// variant's introduction and this fix would have round-tripped
77    /// self-consistently under that buggy tag, and `identity_list()` aborts
78    /// entirely on the first unparseable row — so dropping read support for
79    /// it would just move the "My Agents empty" incident to a different set
80    /// of accounts. Serialization always writes the canonical
81    /// `oauth_config_dir`; only deserialization accepts both.
82    /// ts-rs does NOT pick up the `serde(rename)` above — it emits the
83    /// derive-default `o_auth_config_dir`, i.e. exactly the legacy tag this
84    /// rename exists to stop writing. Without the `ts(rename)` below, the
85    /// generated binding would tell the frontend to discriminate on a tag the
86    /// server never serializes, silently reintroducing the "My Agents empty"
87    /// incident described above. No CI gate catches this: tsc passes (the
88    /// union simply never matches at runtime) and check-rpc-bindings passes
89    /// (the type exists and is current).
90    #[serde(rename = "oauth_config_dir", alias = "o_auth_config_dir")]
91    OAuthConfigDir {
92        /// Absolute path to the per-bundle, per-provider config
93        /// directory — e.g. `~/.agentmux/shared/identities/<id>/claude/`,
94        /// or the legacy `~/.claude/` for the Default migration bundle
95        /// (PR E).
96        dir: String,
97    },
98    /// **API key / token stored in the OS-native secret store** (macOS
99    /// Keychain / Windows Credential Manager / Linux Secret Service),
100    /// addressed by `(service, account)`. The plaintext is NEVER held in
101    /// the DB — only this pointer. Written by the account key flow
102    /// after a successful live validation; resolved to the real value at
103    /// agent spawn time via `crate::identity::secret_store`. See
104    /// docs/specs/archive/SPEC_TRUST_CENTER_2026_06_15.md §7/§12.2.
105    Keychain {
106        /// Keychain service string — always `"agentmux"`.
107        service: String,
108        /// Keychain account string — `"acct:<account_id>"`.
109        account: String,
110    },
111}
112
113/// An identity account (reusable credential, linked to agents via the
114/// `db_agent_identity_links` junction). Replaces the browser
115/// localStorage identity store.
116#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
117#[ts(export, export_to = "../../../frontend/types/rpc/")]
118pub struct IdentityAccount {
119    pub id: String,
120    pub name: String,
121    pub provider: String, // "github" | "aws" | "anthropic" | "custom"
122    pub kind: String,     // "pat" | "role" | "api_key" | "env_ref"
123    #[serde(default)]
124    pub display_name: String,
125    pub secret_ref: SecretRef,
126    /// Free-form JSON context (username, scopes, role ARN, etc.). Stored
127    /// verbatim; frontend types it by `provider`.
128    /// ts-rs has no `TS` impl for `serde_json::Value`, so this needs an
129    /// explicit type. `Record<string, unknown>` rather than bare `unknown`:
130    /// `default_context_json` is `{}` and every writer stores an object, so
131    /// the hand-written declaration's narrower type was accurate and emitting
132    /// `unknown` would lose real information (the same mistake a bare
133    /// `string` would be for a closed-set field).
134    #[serde(default = "default_context_json")]
135    #[ts(type = "Record<string, unknown>")]
136    pub context: serde_json::Value,
137    #[serde(default = "default_identity_status")]
138    pub status: String, // "unknown" | "ok" | "expired" | "invalid"
139    // `#[serde(default)]` — the `upsertidentityaccount` handler (agent_handlers/
140    // identity.rs) server-stamps both fields with `now` when they're `0`, but
141    // that logic runs AFTER `serde_json::from_value` succeeds. Every real
142    // frontend caller (persistSeededAccount, register-seeded-account.ts) omits
143    // both fields entirely, expecting the server to fill them in — without
144    // `default` here, deserialization rejects the payload before the handler's
145    // own timestamp logic ever runs, so every login's account-persist call
146    // failed 100% of the time with no visible
147    // error (report: REPORT_LOGIN_PERSIST_FAILURE_AND_STUCK_WORKING_2026_07_27.md).
148    #[serde(default)]
149    #[ts(type = "number")]
150    pub created_at: i64,
151    #[serde(default)]
152    #[ts(type = "number")]
153    pub updated_at: i64,
154}
155
156fn default_context_json() -> serde_json::Value {
157    serde_json::json!({})
158}
159
160fn default_identity_status() -> String {
161    "unknown".to_string()
162}
163
164/// Escape every backslash in `raw` unconditionally. Targets exactly the one
165/// corruption shape seen in production — a raw Windows path (`C:\Users\...`)
166/// pasted into a JSON string with NO escaping at all — so every backslash in
167/// a still-unparseable row is a literal path separator, never an intentional
168/// JSON escape (a document containing even one real `\n`/`\t`/`\\` escape
169/// alongside bare backslashes would imply two different corruption
170/// mechanisms touched the same row, which has no evidence and isn't
171/// something this targeted repair needs to handle).
172///
173/// This is deliberately NOT a no-op on already-valid JSON — doubling a
174/// genuine `\\` pair would over-escape it. That's fine because the only
175/// caller (`identity_repair_malformed_secret_refs`) already checks
176/// `serde_json::from_str` first and skips rows that parse cleanly; this
177/// function must never be called on a value that isn't already confirmed
178/// invalid.
179///
180/// A prior version tried to distinguish "bare" backslashes from "already
181/// valid escape sequences" by peeking at the next character (`\b`, `\t`,
182/// `\n`, ... look like valid escapes). That's unsound for real Windows
183/// paths: a segment like `\bob\test` starts with `\b` and `\t`, which are
184/// themselves valid JSON escape shapes, so the heuristic left them
185/// un-doubled and silently produced a backspace/tab character instead of
186/// the literal path (caught by reagent review on PR #2419).
187fn repair_bare_backslashes(raw: &str) -> String {
188    raw.replace('\\', "\\\\")
189}
190
191/// Junction row: which identity an agent uses for a given provider.
192#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
193#[ts(export, export_to = "../../../frontend/types/rpc/")]
194pub struct AgentIdentityLink {
195    pub agent_id: String,
196    pub account_id: String,
197    pub provider: String,
198}
199
200/// What [`Store::identity_delete`] removed — the account row plus the
201/// cascaded `db_agent_identity_links` junction rows. Consumed by the
202/// `deleteidentityaccount` handler for `identity.delete:` logging and
203/// the layer-2/4 affected-agent disclosure
204/// (`SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md` §3/§4).
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct IdentityDeleteOutcome {
207    /// True iff the `db_accounts` row existed and was deleted.
208    pub deleted: bool,
209    /// Number of `db_agent_identity_links` rows removed in the same
210    /// transaction.
211    pub links_cascaded: usize,
212    /// The `agent_id`s whose links were cascaded — captured by SELECT
213    /// inside the same transaction, BEFORE the delete, so the set is
214    /// exact even though the rows are gone by the time this returns.
215    /// Any of these agents that has a live process still holds working
216    /// tokens until restarted; the handler publishes a per-agent
217    /// revocation event so the UI can disclose that (spec §3).
218    pub affected_agents: Vec<String>,
219}
220
221impl Store {
222    // ---- Identity account CRUD ----
223
224    /// List identity accounts. If `provider` is `Some`, filter to that
225    /// provider; otherwise return every account, ordered by most recent
226    /// update first (so the identity panel shows live accounts on top).
227    ///
228    /// A row whose `secret_ref`/`context` JSON fails to parse is skipped
229    /// (logged with its account id) rather than aborting the whole query —
230    /// one corrupted row must never hide every other account. This is the
231    /// isolation the `oauth_config_dir` tag-alias comment above already
232    /// flagged as missing ("`identity_list()` aborts entirely on the first
233    /// unparseable row") after that incident; this closes it at the source
234    /// instead of only patching the one wire-format mismatch that triggered
235    /// it. See `ANALYSIS_ARMORY_STASH_CREDENTIAL_VISIBILITY_GAP_2026_08_04 (never committed to this repo)`.
236    pub fn identity_list(
237        &self,
238        provider: Option<&str>,
239    ) -> Result<Vec<IdentityAccount>, StoreError> {
240        let conn = self.conn.lock().unwrap();
241        let mut rows_vec = Vec::new();
242        let map_row = |row: &rusqlite::Row| -> rusqlite::Result<IdentityAccount> {
243            let id: String = row.get(0)?;
244            let secret_ref_json: String = row.get(5)?;
245            let context_json: String = row.get(6)?;
246            let secret_ref = match serde_json::from_str(&secret_ref_json) {
247                Ok(s) => s,
248                Err(e) => {
249                    tracing::warn!(
250                        target: "identity",
251                        account_id = %id,
252                        error = %e,
253                        "identity_list: skipping account with malformed secret_ref JSON",
254                    );
255                    return Err(rusqlite::Error::FromSqlConversionFailure(
256                        5,
257                        rusqlite::types::Type::Text,
258                        Box::new(e),
259                    ));
260                }
261            };
262            Ok(IdentityAccount {
263                id,
264                name: row.get(1)?,
265                provider: row.get(2)?,
266                kind: row.get(3)?,
267                display_name: row.get(4)?,
268                secret_ref,
269                context: serde_json::from_str(&context_json)
270                    .unwrap_or_else(|_| serde_json::json!({})),
271                status: row.get(7)?,
272                created_at: row.get(8)?,
273                updated_at: row.get(9)?,
274            })
275        };
276        match provider {
277            Some(p) => {
278                let mut stmt = conn.prepare(
279                    "SELECT id, name, provider, kind, display_name, secret_ref, context,
280                            status, created_at, updated_at
281                     FROM db_accounts
282                     WHERE provider = ?1
283                     ORDER BY updated_at DESC",
284                )?;
285                let iter = stmt.query_map(params![p], map_row)?;
286                for r in iter {
287                    if let Ok(account) = r {
288                        rows_vec.push(account);
289                    }
290                }
291            }
292            None => {
293                let mut stmt = conn.prepare(
294                    "SELECT id, name, provider, kind, display_name, secret_ref, context,
295                            status, created_at, updated_at
296                     FROM db_accounts
297                     ORDER BY updated_at DESC",
298                )?;
299                let iter = stmt.query_map([], map_row)?;
300                for r in iter {
301                    if let Ok(account) = r {
302                        rows_vec.push(account);
303                    }
304                }
305            }
306        }
307        Ok(rows_vec)
308    }
309
310    pub fn identity_get(&self, id: &str) -> Result<Option<IdentityAccount>, StoreError> {
311        let conn = self.conn.lock().unwrap();
312        let mut stmt = conn.prepare(
313            "SELECT id, name, provider, kind, display_name, secret_ref, context,
314                    status, created_at, updated_at
315             FROM db_accounts WHERE id = ?1",
316        )?;
317        let result = stmt.query_row(params![id], |row| {
318            let secret_ref_json: String = row.get(5)?;
319            let context_json: String = row.get(6)?;
320            Ok(IdentityAccount {
321                id: row.get(0)?,
322                name: row.get(1)?,
323                provider: row.get(2)?,
324                kind: row.get(3)?,
325                display_name: row.get(4)?,
326                secret_ref: serde_json::from_str(&secret_ref_json).map_err(|e| {
327                    rusqlite::Error::FromSqlConversionFailure(
328                        5,
329                        rusqlite::types::Type::Text,
330                        Box::new(e),
331                    )
332                })?,
333                context: serde_json::from_str(&context_json)
334                    .unwrap_or_else(|_| serde_json::json!({})),
335                status: row.get(7)?,
336                created_at: row.get(8)?,
337                updated_at: row.get(9)?,
338            })
339        });
340        match result {
341            Ok(a) => Ok(Some(a)),
342            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
343            Err(e) => Err(e.into()),
344        }
345    }
346
347    /// Upsert an identity account. If `account.id` is empty the caller
348    /// must generate one first (we don't silently mint ids here — callers
349    /// should know whether they're creating vs updating).
350    /// Set one account's `context.email`, and nothing else: no other column,
351    /// no `updated_at`, and no row created — an account deleted since it was
352    /// read stays deleted, and a status or `secret_ref` written meanwhile
353    /// stands (SPEC_ACCOUNT_EMAIL_IN_ARMORY_2026_09_23.md §4's backfill runs
354    /// inside a list). Returns whether a row was updated.
355    pub fn identity_set_context_email(&self, id: &str, email: &str) -> Result<bool, StoreError> {
356        let conn = self.conn.lock().unwrap();
357        let n = conn.execute(
358            "UPDATE db_accounts
359                SET context = json_set(
360                        CASE WHEN json_valid(context) AND json_type(context) = 'object'
361                             THEN context ELSE '{}' END,
362                        '$.email', ?2)
363              WHERE id = ?1",
364            params![id, email],
365        )?;
366        Ok(n > 0)
367    }
368
369    pub fn identity_upsert(&self, account: &IdentityAccount) -> Result<(), StoreError> {
370        let conn = self.conn.lock().unwrap();
371        let secret_ref_json = serde_json::to_string(&account.secret_ref)?;
372        let context_json = serde_json::to_string(&account.context)?;
373        conn.execute(
374            "INSERT INTO db_accounts
375                (id, name, provider, kind, display_name, secret_ref, context,
376                 status, created_at, updated_at)
377             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
378             ON CONFLICT(id) DO UPDATE SET
379                name = excluded.name,
380                provider = excluded.provider,
381                kind = excluded.kind,
382                display_name = excluded.display_name,
383                secret_ref = excluded.secret_ref,
384                context = excluded.context,
385                status = excluded.status,
386                updated_at = excluded.updated_at",
387            params![
388                account.id,
389                account.name,
390                account.provider,
391                account.kind,
392                account.display_name,
393                secret_ref_json,
394                context_json,
395                account.status,
396                account.created_at,
397                account.updated_at,
398            ],
399        )?;
400        Ok(())
401    }
402
403    /// `identity_upsert(self)` — the authoritative, primary write — followed
404    /// by a BEST-EFFORT mirror write into `identity_store`, so a link
405    /// resolved from the always-global identity store (§SPEC_IDENTITY_STORE_
406    /// SPLIT_2026_08_17.md) doesn't dead-end resolving the account it
407    /// points at on a fresh, per-channel-isolated `self` after a version/
408    /// channel switch — see `identity::resolver::inject::resolve_account`.
409    ///
410    /// Call this instead of plain `identity_upsert` at every LIVE account-
411    /// creation/update call site (reagentx P0 review on PR #2632 — the
412    /// original fix only backfilled the mirror once, via migration, so any
413    /// account created/updated AFTER the fix shipped still had no mirror
414    /// entry and reproduced the exact reported bug on its own next channel
415    /// switch).
416    ///
417    /// `self` is the primary (`id_store`) — its result is authoritative and
418    /// propagated to the caller. `mirror`'s (`identity_store`'s) result is
419    /// logged on failure and otherwise ignored: the mirror existing at all
420    /// is a durability nicety for THIS specific continuity scenario, not a
421    /// correctness requirement the primary write should ever be blocked or
422    /// failed by.
423    pub fn identity_upsert_with_mirror(&self, mirror: &Store, account: &IdentityAccount) -> Result<(), StoreError> {
424        self.identity_upsert(account)?;
425        if let Err(e) = mirror.identity_upsert(account) {
426            tracing::warn!(
427                target: "identity",
428                account_id = %account.id,
429                error = %e,
430                "identity_upsert_with_mirror: primary write succeeded but mirror write failed \
431                 (non-fatal — this account may not resolve after a future channel switch \
432                 until the mirror is repaired)"
433            );
434        }
435        Ok(())
436    }
437
438    /// Migration-only repair pass (`m0019_repair_malformed_secret_ref`):
439    /// scan every `db_accounts.secret_ref` for JSON that fails to parse and
440    /// attempt to fix the one known corruption shape — a Windows path
441    /// pasted into the JSON string with its backslashes never escaped
442    /// (found live: `{"backend":"oauth_config_dir","dir":"C:\Users\..."}`,
443    /// not reproducible from any write path in this codebase — every real
444    /// caller goes through `identity_upsert` above, which always
445    /// `serde_json::to_string`s correctly; this shape only arises from an
446    /// out-of-band edit of the database file). Rows that still don't parse
447    /// after the repair attempt are left untouched and logged — never
448    /// deleted, since a broken row may still be linked via
449    /// `db_agent_identity_links` and `identity_list`'s per-row skip (above)
450    /// already makes leaving it alone safe. Returns the number of rows
451    /// repaired.
452    pub fn identity_repair_malformed_secret_refs(&self) -> Result<usize, StoreError> {
453        let conn = self.conn.lock().unwrap();
454        let candidates: Vec<(String, String)> = {
455            let mut stmt = conn.prepare("SELECT id, secret_ref FROM db_accounts")?;
456            let iter = stmt.query_map([], |row| {
457                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
458            })?;
459            let mut out = Vec::new();
460            for r in iter {
461                out.push(r?);
462            }
463            out
464        };
465
466        let mut repaired = 0usize;
467        for (id, raw) in candidates {
468            if serde_json::from_str::<SecretRef>(&raw).is_ok() {
469                continue; // already valid — the common case
470            }
471            let candidate = repair_bare_backslashes(&raw);
472            match serde_json::from_str::<SecretRef>(&candidate) {
473                Ok(fixed) => {
474                    let canonical = serde_json::to_string(&fixed)?;
475                    conn.execute(
476                        "UPDATE db_accounts SET secret_ref = ?1 WHERE id = ?2",
477                        params![canonical, id],
478                    )?;
479                    repaired += 1;
480                    tracing::warn!(
481                        target: "identity",
482                        account_id = %id,
483                        "identity.repair: fixed malformed secret_ref JSON (unescaped backslashes)",
484                    );
485                }
486                Err(e) => {
487                    tracing::warn!(
488                        target: "identity",
489                        account_id = %id,
490                        error = %e,
491                        "identity.repair: secret_ref still unparseable after repair attempt — left as-is",
492                    );
493                }
494            }
495        }
496        Ok(repaired)
497    }
498
499    /// Delete an identity account AND every `db_agent_identity_links`
500    /// row referencing it, atomically (single transaction under the
501    /// connection lock).
502    ///
503    /// The cascade is EXPLICIT even though the current DDL carries
504    /// `FOREIGN KEY (account_id) … ON DELETE CASCADE`: databases whose
505    /// links table arrived via legacy `ALTER TABLE … RENAME` (forge era)
506    /// never got the FK clause retrofitted — `CREATE TABLE IF NOT EXISTS`
507    /// can't alter an existing table — so on real user databases the
508    /// DDL-level cascade silently doesn't exist and dangling links
509    /// survive the account row (auth-lifecycle gap §2.4,
510    /// `docs/analysis/ANALYSIS_ACCOUNT_DELETE_AUTH_LIFECYCLE_GAP_2026_07_14.md`).
511    /// `db_identity_bindings` (named by the report) was retired in Phase
512    /// 4c and no longer exists; links are the sole junction on accounts.
513    pub fn identity_delete(&self, id: &str) -> Result<IdentityDeleteOutcome, StoreError> {
514        let mut conn = self.conn.lock().unwrap();
515        let tx = conn.transaction()?;
516        // Capture the affected agent ids BEFORE the cascade delete, in
517        // the same transaction, so the set can't race a concurrent
518        // link/unlink (spec §3 — the handler publishes per-agent
519        // revocation events off this list).
520        let affected_agents: Vec<String> = {
521            let mut stmt = tx.prepare(
522                "SELECT agent_id FROM db_agent_identity_links
523                 WHERE account_id = ?1 ORDER BY agent_id",
524            )?;
525            let iter = stmt.query_map(params![id], |row| row.get::<_, String>(0))?;
526            let mut out = Vec::new();
527            for r in iter {
528                out.push(r?);
529            }
530            out
531        };
532        // Links first, so the count is exact even on fresh databases
533        // where the FK cascade would otherwise race this delete.
534        let links_cascaded = tx.execute(
535            "DELETE FROM db_agent_identity_links WHERE account_id = ?1",
536            params![id],
537        )?;
538        let rows = tx.execute("DELETE FROM db_accounts WHERE id = ?1", params![id])?;
539        tx.commit()?;
540        Ok(IdentityDeleteOutcome {
541            deleted: rows > 0,
542            links_cascaded,
543            affected_agents,
544        })
545    }
546
547    // ---- Agent ↔ Identity junction ----
548
549    /// Delete every `db_agent_identity_links` row for `account_id`, on
550    /// WHATEVER store this is called on — added for
551    /// `docs/specs/SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md`: since links now
552    /// live in a different physical store (`identity_store`) than accounts
553    /// (`id_store`), `identity_delete`'s own same-connection cascade above
554    /// no longer reaches them (it only ever deleted rows in the SAME store
555    /// as the account, which was always true before this split). Callers
556    /// deleting an account must call this on `identity_store` in addition
557    /// to `identity_delete` on `id_store` — see the `deleteidentityaccount`
558    /// handler. Returns the same `(count, affected_agents)` shape
559    /// `identity_delete` did, captured before the delete in one transaction
560    /// for the same race-safety reason.
561    pub fn agent_identity_unlink_by_account(&self, account_id: &str) -> Result<(usize, Vec<String>), StoreError> {
562        let mut conn = self.conn.lock().unwrap();
563        let tx = conn.transaction()?;
564        let affected_agents: Vec<String> = {
565            let mut stmt = tx.prepare(
566                "SELECT agent_id FROM db_agent_identity_links
567                 WHERE account_id = ?1 ORDER BY agent_id",
568            )?;
569            let iter = stmt.query_map(params![account_id], |row| row.get::<_, String>(0))?;
570            let mut out = Vec::new();
571            for r in iter {
572                out.push(r?);
573            }
574            out
575        };
576        let links_removed = tx.execute(
577            "DELETE FROM db_agent_identity_links WHERE account_id = ?1",
578            params![account_id],
579        )?;
580        tx.commit()?;
581        Ok((links_removed, affected_agents))
582    }
583
584    /// Link an agent to an identity for a given provider. Overwrites any
585    /// existing link for the same (agent_id, provider) — each agent has
586    /// at most one account per provider.
587    pub fn agent_identity_link(
588        &self,
589        agent_id: &str,
590        account_id: &str,
591        provider: &str,
592    ) -> Result<(), StoreError> {
593        let conn = self.conn.lock().unwrap();
594        conn.execute(
595            "INSERT INTO db_agent_identity_links (agent_id, account_id, provider)
596             VALUES (?1, ?2, ?3)
597             ON CONFLICT(agent_id, provider) DO UPDATE SET account_id = excluded.account_id",
598            params![agent_id, account_id, provider],
599        )?;
600        Ok(())
601    }
602
603    /// Remove the identity link for a given (agent_id, provider).
604    /// Returns true iff a link existed.
605    pub fn agent_identity_unlink(
606        &self,
607        agent_id: &str,
608        provider: &str,
609    ) -> Result<bool, StoreError> {
610        let conn = self.conn.lock().unwrap();
611        let rows = conn.execute(
612            "DELETE FROM db_agent_identity_links WHERE agent_id = ?1 AND provider = ?2",
613            params![agent_id, provider],
614        )?;
615        Ok(rows > 0)
616    }
617
618    /// List every (agent_id, account_id, provider) row in the table.
619    /// Used by the startup backfill to seed the shared store.
620    pub fn agent_identity_list_all(&self) -> Result<Vec<AgentIdentityLink>, StoreError> {
621        let conn = self.conn.lock().unwrap();
622        let mut stmt = conn.prepare(
623            "SELECT agent_id, account_id, provider FROM db_agent_identity_links ORDER BY agent_id, provider",
624        )?;
625        let iter = stmt.query_map([], |row| {
626            Ok(AgentIdentityLink {
627                agent_id: row.get(0)?,
628                account_id: row.get(1)?,
629                provider: row.get(2)?,
630            })
631        })?;
632        let mut out = Vec::new();
633        for r in iter {
634            out.push(r?);
635        }
636        Ok(out)
637    }
638
639    /// List all (agent_id, account_id, provider) triples for an agent.
640    pub fn agent_identity_list_for_agent(
641        &self,
642        agent_id: &str,
643    ) -> Result<Vec<AgentIdentityLink>, StoreError> {
644        let conn = self.conn.lock().unwrap();
645        let mut stmt = conn.prepare(
646            "SELECT agent_id, account_id, provider
647             FROM db_agent_identity_links
648             WHERE agent_id = ?1
649             ORDER BY provider",
650        )?;
651        let iter = stmt.query_map(params![agent_id], |row| {
652            Ok(AgentIdentityLink {
653                agent_id: row.get(0)?,
654                account_id: row.get(1)?,
655                provider: row.get(2)?,
656            })
657        })?;
658        let mut out = Vec::new();
659        for r in iter {
660            out.push(r?);
661        }
662        Ok(out)
663    }
664
665    /// Every distinct `(agent_id, provider)` link pair. Used by the
666    /// m0017/m0018 ambient-login grandfather migrations, which must reason
667    /// about provider CLASS: only oauth-class links mean an agent "opted
668    /// into managed CLI login" (flag stays 0) — an api-key link (e.g. a
669    /// github PAT) is never spawn-gated, so it must not forfeit
670    /// grandfathering (spec §2.4 of
671    /// SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md). Class
672    /// filtering happens at the caller via
673    /// `identity::resolver::provider_class`; the storage layer stays
674    /// class-agnostic.
675    pub fn agent_identity_link_provider_pairs(&self) -> Result<Vec<(String, String)>, StoreError> {
676        let conn = self.conn.lock().unwrap();
677        let mut stmt = conn.prepare(
678            "SELECT DISTINCT agent_id, provider FROM db_agent_identity_links",
679        )?;
680        let iter = stmt.query_map([], |row| {
681            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
682        })?;
683        let mut out = Vec::new();
684        for r in iter {
685            out.push(r?);
686        }
687        Ok(out)
688    }
689
690}
691
692#[cfg(test)]
693mod tests {
694    use rusqlite::params;
695
696    use super::super::store::{AgentDefinition, Store};
697    use super::*;
698
699    /// Pins the on-disk wire format directly, rather than round-tripping
700    /// through this crate's own serializer (which would happily agree with
701    /// itself even if `rename_all`'s derived tag drifted from what's
702    /// actually stored). `oauth_config_dir` here is copied verbatim from a
703    /// real account row in `~/.agentmux/shared/store.db` — this is the
704    /// literal regression that broke `identity_list()` (and therefore "My
705    /// Agents"/recent-sessions, which aborts entirely on the first
706    /// unparseable row) for every account written before the enum-wide
707    /// `rename_all = "snake_case"` derive silently started expecting
708    /// `o_auth_config_dir` instead (serde splits at every capital letter,
709    /// including inside "OAuth" itself).
710    #[test]
711    fn secret_ref_deserializes_real_stored_oauth_config_dir_json() {
712        let json = r#"{"backend":"oauth_config_dir","dir":"C:\\Users\\area54\\.agentmux\\shared\\identities\\bfa59d03-7a99-420e-b912-716bba1c462d\\claude"}"#;
713        let parsed: SecretRef = serde_json::from_str(json).expect("must deserialize real stored wire format");
714        match parsed {
715            SecretRef::OAuthConfigDir { dir } => {
716                assert!(dir.ends_with("claude"), "dir should round-trip: {dir}");
717            }
718            other => panic!("expected OAuthConfigDir, got {other:?}"),
719        }
720    }
721
722    /// Pins the OTHER wire format still sitting in some accounts: whatever
723    /// backend was running between this variant's introduction and the
724    /// `oauth_config_dir` rename above wrote (and read back) the
725    /// derive-produced `o_auth_config_dir` tag. Losing read support for it
726    /// reproduces the exact "My Agents empty" incident for those accounts —
727    /// `identity_list()` aborts entirely on the first unparseable row.
728    #[test]
729    fn secret_ref_deserializes_legacy_derive_produced_tag_json() {
730        let json = r#"{"backend":"o_auth_config_dir","dir":"C:\\Users\\area54\\.agentmux\\shared\\identities\\269c028b-3894-4231-b884-fa5d0ecfabdf\\claude"}"#;
731        let parsed: SecretRef = serde_json::from_str(json).expect("must deserialize legacy derive-produced wire format");
732        match parsed {
733            SecretRef::OAuthConfigDir { dir } => {
734                assert!(dir.ends_with("claude"), "dir should round-trip: {dir}");
735            }
736            other => panic!("expected OAuthConfigDir, got {other:?}"),
737        }
738    }
739
740    /// Pins the exact `upsertidentityaccount` payload shape sent by
741    /// `persistSeededAccount` (frontend/app/view/agent/flows/
742    /// register-seeded-account.ts) — no `created_at`/`updated_at` at all,
743    /// since the handler is documented to server-stamp them. Before
744    /// `#[serde(default)]` was added to those two fields, this exact shape
745    /// failed `serde_json::from_value` with "missing field `created_at`" on
746    /// every call, so every login silently never persisted an account row
747    /// (the credential landed on
748    /// disk, but no `db_accounts` row or agent link ever existed — see
749    /// REPORT_LOGIN_PERSIST_FAILURE_AND_STUCK_WORKING_2026_07_27.md).
750    #[test]
751    fn identity_account_deserializes_frontend_seed_payload_missing_timestamps() {
752        let json = r#"{
753            "id": "8d34a369-6ba6-4071-93bd-d4e051cdb457",
754            "name": "claude-oauth",
755            "provider": "claude",
756            "kind": "oauth",
757            "secret_ref": {"backend": "oauth_config_dir", "dir": "/tmp/claude"},
758            "status": "valid"
759        }"#;
760        let parsed: IdentityAccount =
761            serde_json::from_value(serde_json::from_str(json).unwrap())
762                .expect("must deserialize the frontend's timestamp-less seed payload");
763        assert_eq!(parsed.created_at, 0);
764        assert_eq!(parsed.updated_at, 0);
765        assert_eq!(parsed.status, "valid");
766    }
767
768    /// Serialization must always emit the canonical tag, never the legacy
769    /// alias — the alias is read-only backward compat, not a second valid
770    /// output format.
771    #[test]
772    fn secret_ref_serializes_oauth_config_dir_with_canonical_tag() {
773        let value = SecretRef::OAuthConfigDir {
774            dir: "/tmp/claude".to_string(),
775        };
776        let json = serde_json::to_string(&value).unwrap();
777        assert!(json.contains(r#""backend":"oauth_config_dir""#), "got: {json}");
778        assert!(!json.contains("o_auth_config_dir"), "got: {json}");
779    }
780
781    fn sample_account(id: &str, provider: &str) -> IdentityAccount {
782        IdentityAccount {
783            id: id.to_string(),
784            name: format!("asaf-{provider}"),
785            provider: provider.to_string(),
786            kind: "oauth".to_string(),
787            display_name: String::new(),
788            secret_ref: SecretRef::OAuthConfigDir {
789                dir: format!("/var/agentmux/identities/{id}/claude"),
790            },
791            context: serde_json::json!({}),
792            status: "ok".to_string(),
793            created_at: 0,
794            updated_at: 0,
795        }
796    }
797
798    fn sample_agent(id: &str, slug: &str) -> AgentDefinition {
799        AgentDefinition {
800            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
801            id: id.to_string(),
802            slug: slug.to_string(),
803            name: id.to_string(),
804            icon: "✦".to_string(),
805            provider: "claude".to_string(),
806            description: String::new(),
807            working_directory: String::new(),
808            shell: String::new(),
809            provider_flags: String::new(),
810            auto_start: 0,
811            restart_on_crash: 0,
812            idle_timeout_minutes: 0,
813            created_at: 0,
814            agent_type: "host".to_string(),
815            environment: String::new(),
816            agent_bus_id: String::new(),
817            is_seeded: 0,
818            accounts: String::new(),
819            parent_id: String::new(),
820            branch_label: String::new(),
821            updated_at: 0,
822            user_hidden: 0,
823            container_image: String::new(),
824            container_volumes: "[]".to_string(),
825            container_name: String::new(),
826            use_ambient_login: 0,
827            auto_continue_enabled: 0,
828            model_vendor_base_url: String::new(),
829            memory_id: String::new(),
830        }
831    }
832
833    fn count_links_for_account(store: &Store, account_id: &str) -> i64 {
834        let conn = store.conn.lock().unwrap();
835        conn.query_row(
836            "SELECT count(*) FROM db_agent_identity_links WHERE account_id = ?1",
837            params![account_id],
838            |r| r.get(0),
839        )
840        .unwrap()
841    }
842
843    /// Recreate `db_agent_identity_links` in the LEGACY shape: no
844    /// `FOREIGN KEY (account_id) … ON DELETE CASCADE`. This is the shape a
845    /// production `store.db`/`objects.db` still has when the table arrived
846    /// via `ALTER TABLE … RENAME` from the forge era —
847    /// `CREATE TABLE IF NOT EXISTS` never retrofits FK clauses onto an
848    /// existing table, so the DDL-level cascade only exists on fresh
849    /// databases. `identity_delete` must therefore cascade EXPLICITLY
850    /// rather than lean on the FK (the account-delete auth-lifecycle gap,
851    /// docs/analysis/ANALYSIS_ACCOUNT_DELETE_AUTH_LIFECYCLE_GAP_2026_07_14.md §2.4).
852    fn strip_links_fk(store: &Store) {
853        let conn = store.conn.lock().unwrap();
854        conn.execute_batch(
855            "DROP TABLE db_agent_identity_links;
856             CREATE TABLE db_agent_identity_links (
857                agent_id   TEXT NOT NULL,
858                account_id TEXT NOT NULL,
859                provider   TEXT NOT NULL,
860                PRIMARY KEY (agent_id, provider)
861             );",
862        )
863        .unwrap();
864    }
865
866    #[test]
867    fn identity_delete_cascades_links_on_legacy_no_fk_schema() {
868        let store = Store::open_in_memory().unwrap();
869        strip_links_fk(&store);
870        store
871            .identity_upsert(&sample_account("acct-1", "anthropic"))
872            .unwrap();
873        store
874            .agent_identity_link("ag1", "acct-1", "anthropic")
875            .unwrap();
876        assert_eq!(count_links_for_account(&store, "acct-1"), 1);
877
878        let out = store.identity_delete("acct-1").unwrap();
879
880        assert!(store.identity_get("acct-1").unwrap().is_none());
881        assert_eq!(
882            count_links_for_account(&store, "acct-1"),
883            0,
884            "junction row must not survive the account row (dangling link)"
885        );
886        assert!(out.deleted);
887        assert_eq!(out.links_cascaded, 1, "explicit cascade must report the link row");
888        assert_eq!(
889            out.affected_agents,
890            vec!["ag1".to_string()],
891            "agent ids must be captured before the cascade delete (spec §3)"
892        );
893
894        // Second delete is a no-op on both tables.
895        let again = store.identity_delete("acct-1").unwrap();
896        assert!(!again.deleted);
897        assert_eq!(again.links_cascaded, 0);
898        assert!(again.affected_agents.is_empty());
899    }
900
901    #[test]
902    fn identity_delete_cascades_links_on_current_schema() {
903        // Fresh schema — the DDL-level FK cascade also exists here; the
904        // explicit cascade must coexist with it (and still report the row).
905        let store = Store::open_in_memory().unwrap();
906        let mut agent = sample_agent("ag1", "agent-x");
907        store.agent_def_insert(&mut agent).unwrap();
908        store
909            .identity_upsert(&sample_account("acct-1", "anthropic"))
910            .unwrap();
911        store
912            .agent_identity_link("ag1", "acct-1", "anthropic")
913            .unwrap();
914
915        let out = store.identity_delete("acct-1").unwrap();
916
917        assert!(store.identity_get("acct-1").unwrap().is_none());
918        assert_eq!(count_links_for_account(&store, "acct-1"), 0);
919        assert!(out.deleted);
920        assert_eq!(
921            out.links_cascaded, 1,
922            "explicit cascade runs before the FK cascade, so the count is exact"
923        );
924        assert_eq!(
925            out.affected_agents,
926            vec!["ag1".to_string()],
927            "capture must run before both the explicit and the FK cascade"
928        );
929    }
930
931    /// Multiple linked agents → all captured, in deterministic (sorted)
932    /// order. Account with NO links → empty set (the delete-time
933    /// disclosure must not fire).
934    #[test]
935    fn identity_delete_captures_all_affected_agents() {
936        let store = Store::open_in_memory().unwrap();
937        // Fresh schema enforces the links FK on agent_id, so the agent
938        // definitions must exist before linking.
939        for (id, slug) in [("ag-b", "agent-b"), ("ag-a", "agent-a"), ("ag-other", "agent-o")] {
940            let mut agent = sample_agent(id, slug);
941            store.agent_def_insert(&mut agent).unwrap();
942        }
943        store
944            .identity_upsert(&sample_account("acct-1", "anthropic"))
945            .unwrap();
946        store
947            .identity_upsert(&sample_account("acct-2", "anthropic"))
948            .unwrap();
949        store
950            .agent_identity_link("ag-b", "acct-1", "anthropic")
951            .unwrap();
952        store
953            .agent_identity_link("ag-a", "acct-1", "anthropic")
954            .unwrap();
955        // A link on a DIFFERENT account must not leak into the set.
956        store
957            .agent_identity_link("ag-other", "acct-2", "anthropic")
958            .unwrap();
959
960        let out = store.identity_delete("acct-1").unwrap();
961        assert!(out.deleted);
962        assert_eq!(out.links_cascaded, 2);
963        assert_eq!(
964            out.affected_agents,
965            vec!["ag-a".to_string(), "ag-b".to_string()],
966            "all linked agents captured, sorted, and scoped to the deleted account"
967        );
968
969        // Account with NO links → empty affected set (disclosure must
970        // not fire for it).
971        store
972            .identity_upsert(&sample_account("acct-3", "anthropic"))
973            .unwrap();
974        let out3 = store.identity_delete("acct-3").unwrap();
975        assert!(out3.deleted);
976        assert_eq!(out3.links_cascaded, 0);
977        assert!(
978            out3.affected_agents.is_empty(),
979            "linkless account delete must report an empty affected set"
980        );
981    }
982
983    /// The analysis report (§2.4) names `db_identity_bindings` as a second
984    /// cascade target. That table was retired in Phase 4c of
985    /// SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md and is DROPPED by the
986    /// schema (`DEAD_TABLE_DROPS`) — `db_agent_identity_links` is the sole
987    /// junction referencing `db_accounts.id`. Pin that here so the cascade
988    /// in `identity_delete` is provably complete.
989    #[test]
990    fn identity_bindings_table_is_retired() {
991        let store = Store::open_in_memory().unwrap();
992        let conn = store.conn.lock().unwrap();
993        let count: i64 = conn
994            .query_row(
995                "SELECT count(*) FROM sqlite_master
996                 WHERE type='table' AND name IN ('db_identity_bindings', 'db_identity_bundles')",
997                [],
998                |r| r.get(0),
999            )
1000            .unwrap();
1001        assert_eq!(count, 0, "retired bundle-binding tables must not exist");
1002    }
1003
1004    // ---- Malformed secret_ref tolerance (ANALYSIS_ARMORY_STASH_CREDENTIAL_VISIBILITY_GAP_2026_08_04) ----
1005
1006    /// Directly pins the production corruption: a raw Windows path pasted
1007    /// into JSON with unescaped backslashes must round-trip through the
1008    /// repair to a valid, semantically-equivalent `SecretRef`.
1009    #[test]
1010    fn repair_bare_backslashes_fixes_unescaped_windows_path() {
1011        let broken = r#"{"backend":"oauth_config_dir","dir":"C:\Users\area54\.agentmux\shared\identities\34db876e-ccef-4bb4-a41f-5962d6212df5\claude"}"#;
1012        assert!(
1013            serde_json::from_str::<SecretRef>(broken).is_err(),
1014            "fixture must reproduce the real parse failure"
1015        );
1016        let repaired = repair_bare_backslashes(broken);
1017        let parsed: SecretRef =
1018            serde_json::from_str(&repaired).expect("repair must produce valid JSON");
1019        match parsed {
1020            SecretRef::OAuthConfigDir { dir } => {
1021                assert_eq!(
1022                    dir,
1023                    r"C:\Users\area54\.agentmux\shared\identities\34db876e-ccef-4bb4-a41f-5962d6212df5\claude"
1024                );
1025            }
1026            other => panic!("expected OAuthConfigDir, got {other:?}"),
1027        }
1028    }
1029
1030    /// Regression for the P0 caught on PR #2419 review: a path segment that
1031    /// starts with a letter matching a valid JSON escape (`\bob`, `\test`,
1032    /// `\node_modules`, ...) must still get its backslash doubled, not
1033    /// mistaken for an already-valid `\b`/`\t`/`\n` escape.
1034    #[test]
1035    fn repair_bare_backslashes_fixes_paths_with_escape_letter_prefixed_segments() {
1036        let broken = r#"{"backend":"oauth_config_dir","dir":"C:\Users\bob\test\node_modules\uploads\format"}"#;
1037        let repaired = repair_bare_backslashes(broken);
1038        let parsed: SecretRef =
1039            serde_json::from_str(&repaired).expect("repair must produce valid JSON");
1040        match parsed {
1041            SecretRef::OAuthConfigDir { dir } => {
1042                assert_eq!(dir, r"C:\Users\bob\test\node_modules\uploads\format");
1043            }
1044            other => panic!("expected OAuthConfigDir, got {other:?}"),
1045        }
1046    }
1047
1048    /// `repair_bare_backslashes` is only ever called on rows that already
1049    /// failed to parse as JSON (`identity_repair_malformed_secret_refs`
1050    /// checks `serde_json::from_str` first and skips valid rows) — it must
1051    /// never run against already-valid JSON, since unconditionally doubling
1052    /// every backslash would over-escape a genuine `\\` pair.
1053    #[test]
1054    fn repair_bare_backslashes_over_escapes_already_valid_json_by_design() {
1055        let valid = r#"{"backend":"oauth_config_dir","dir":"C:\\Users\\area54\\claude"}"#;
1056        assert_ne!(
1057            repair_bare_backslashes(valid),
1058            valid,
1059            "documents that callers must never invoke this on already-valid JSON"
1060        );
1061    }
1062
1063    /// `identity_list` must return the good rows and skip the malformed
1064    /// one — not abort the whole query. This is the actual bug: one
1065    /// corrupted account made 9 real accounts invisible everywhere
1066    /// `identity_list` is called (Connectors → Accounts' list, boot-time
1067    /// identity sweep, post-login cache refresh).
1068    #[test]
1069    fn identity_list_skips_a_malformed_row_instead_of_failing_the_whole_query() {
1070        let store = Store::open_in_memory().unwrap();
1071        store
1072            .identity_upsert(&sample_account("acct-good-1", "claude"))
1073            .unwrap();
1074        store
1075            .identity_upsert(&sample_account("acct-good-2", "claude"))
1076            .unwrap();
1077        {
1078            let conn = store.conn.lock().unwrap();
1079            conn.execute(
1080                "INSERT INTO db_accounts
1081                    (id, name, provider, kind, display_name, secret_ref, context,
1082                     status, created_at, updated_at)
1083                 VALUES ('acct-bad', 'broken', 'claude', 'oauth', '',
1084                         '{\"backend\":\"oauth_config_dir\",\"dir\":\"C:\\bad\\path\"}',
1085                         '{}', 'unknown', 0, 0)",
1086                [],
1087            )
1088            .unwrap();
1089        }
1090
1091        let accounts = store
1092            .identity_list(None)
1093            .expect("must not fail even with a malformed row present");
1094        let ids: Vec<&str> = accounts.iter().map(|a| a.id.as_str()).collect();
1095        assert!(ids.contains(&"acct-good-1"));
1096        assert!(ids.contains(&"acct-good-2"));
1097        assert!(!ids.contains(&"acct-bad"), "malformed row must be excluded, not error the whole call");
1098        assert_eq!(accounts.len(), 2);
1099    }
1100
1101    /// End-to-end: the migration-facing repair method fixes the known
1102    /// corruption shape in place, and a subsequent `identity_list` picks up
1103    /// the repaired row.
1104    #[test]
1105    fn identity_repair_malformed_secret_refs_fixes_row_and_identity_list_recovers_it() {
1106        let store = Store::open_in_memory().unwrap();
1107        store
1108            .identity_upsert(&sample_account("acct-good", "claude"))
1109            .unwrap();
1110        {
1111            let conn = store.conn.lock().unwrap();
1112            conn.execute(
1113                "INSERT INTO db_accounts
1114                    (id, name, provider, kind, display_name, secret_ref, context,
1115                     status, created_at, updated_at)
1116                 VALUES ('acct-broken', 'broken', 'claude', 'oauth', '',
1117                         '{\"backend\":\"oauth_config_dir\",\"dir\":\"C:\\Users\\a\\claude\"}',
1118                         '{}', 'unknown', 0, 0)",
1119                [],
1120            )
1121            .unwrap();
1122        }
1123
1124        // Before repair: identity_list tolerates it (previous test) but the
1125        // account itself is invisible.
1126        assert_eq!(store.identity_list(None).unwrap().len(), 1);
1127
1128        let repaired = store.identity_repair_malformed_secret_refs().unwrap();
1129        assert_eq!(repaired, 1);
1130
1131        let accounts = store.identity_list(None).unwrap();
1132        assert_eq!(accounts.len(), 2, "repaired row must now be visible");
1133        let fixed = accounts.iter().find(|a| a.id == "acct-broken").unwrap();
1134        match &fixed.secret_ref {
1135            SecretRef::OAuthConfigDir { dir } => assert_eq!(dir, r"C:\Users\a\claude"),
1136            other => panic!("expected OAuthConfigDir, got {other:?}"),
1137        }
1138
1139        // Idempotent: running it again on an already-repaired store is a no-op.
1140        assert_eq!(store.identity_repair_malformed_secret_refs().unwrap(), 0);
1141    }
1142
1143    /// A row whose corruption ISN'T the known "bare backslash" shape (e.g. a
1144    /// genuinely unrecognizable backend tag) must be left untouched rather
1145    /// than mangled or deleted — the repair only handles the one known bug
1146    /// class, per its own doc comment.
1147    #[test]
1148    fn identity_repair_leaves_unrecognized_corruption_untouched() {
1149        let store = Store::open_in_memory().unwrap();
1150        {
1151            let conn = store.conn.lock().unwrap();
1152            conn.execute(
1153                "INSERT INTO db_accounts
1154                    (id, name, provider, kind, display_name, secret_ref, context,
1155                     status, created_at, updated_at)
1156                 VALUES ('acct-weird', 'weird', 'claude', 'oauth', '',
1157                         '{\"backend\":\"totally_unknown_backend\"}',
1158                         '{}', 'unknown', 0, 0)",
1159                [],
1160            )
1161            .unwrap();
1162        }
1163        let repaired = store.identity_repair_malformed_secret_refs().unwrap();
1164        assert_eq!(repaired, 0, "unrecognized corruption must not be reported as repaired");
1165        assert_eq!(
1166            store.identity_list(None).unwrap().len(),
1167            0,
1168            "still-broken row stays invisible to identity_list (safe default) but is not deleted"
1169        );
1170        let conn = store.conn.lock().unwrap();
1171        let count: i64 = conn
1172            .query_row("SELECT count(*) FROM db_accounts WHERE id = 'acct-weird'", [], |r| r.get(0))
1173            .unwrap();
1174        assert_eq!(count, 1, "row must survive an unsuccessful repair attempt");
1175    }
1176
1177    /// The actual bug this whole store split fixes
1178    /// (docs/specs/REPORT_HISTORY_CONTINUITY_ACROSS_VERSION_UPGRADE_2026_08_17.md):
1179    /// a link written in one process's lifetime must be findable by a LATER,
1180    /// independent process opening the same always-global path — simulating
1181    /// "close AgentMux, a new version/channel starts, reopen the agent."
1182    ///
1183    /// Before this fix, the equivalent lookup went through `id_store`
1184    /// (`resolve_shared_store_path()`), which resolves to a fresh, empty
1185    /// per-channel file on every non-`"stable"` channel — the second `Store`
1186    /// below would have opened a DIFFERENT physical file in that world, and
1187    /// this assertion would fail. `Store::open_identity_store` never
1188    /// consults `isolated_auth_enabled()` at all, so two independent opens
1189    /// of the same path always see the same data regardless of what channel
1190    /// either "process" believes it's running on.
1191    #[test]
1192    fn identity_store_link_written_by_one_process_is_visible_to_a_later_independent_open() {
1193        let tmp = tempfile::NamedTempFile::new().unwrap();
1194
1195        // "Process 1" — e.g. the old version's srv instance.
1196        {
1197            let store = Store::open_identity_store(tmp.path()).unwrap();
1198            store
1199                .agent_identity_link("agent-continuing", "acct-real-login", "claude")
1200                .unwrap();
1201        } // dropped — simulates the process exiting (version closed).
1202
1203        // "Process 2" — e.g. the new version's srv instance, a completely
1204        // independent Store handle opening the identical always-global path.
1205        let store2 = Store::open_identity_store(tmp.path()).unwrap();
1206        let links = store2.agent_identity_list_for_agent("agent-continuing").unwrap();
1207        assert_eq!(
1208            links.len(),
1209            1,
1210            "the agent's account link must survive a full close/reopen cycle \
1211             (a version/channel switch) — this is conversation continuity"
1212        );
1213        assert_eq!(links[0].account_id, "acct-real-login");
1214        assert_eq!(links[0].provider, "claude");
1215    }
1216
1217    /// `db_agent_identity_links.account_id` must NOT carry a live FK to
1218    /// `db_accounts` in this store (unlike the per-channel `objects.db`
1219    /// schema) — even as of v2, `db_accounts` here is a read-through
1220    /// FALLBACK MIRROR only (reagentx P0 review on PR #2632), not the
1221    /// authoritative write path, and isn't guaranteed complete: a link can
1222    /// legitimately resolve to an account that hasn't been mirrored yet.
1223    /// A hard FK would make that (already-tolerated-elsewhere) case fail
1224    /// the write outright instead of just missing on read.
1225    #[test]
1226    fn identity_store_link_write_does_not_require_a_matching_accounts_row() {
1227        let store = Store::open_identity_store(":memory:".as_ref()).unwrap();
1228        // No db_accounts row for "acct-orphan-by-design" exists in this
1229        // store's (mirror-only) db_accounts table — the write must still
1230        // succeed.
1231        store
1232            .agent_identity_link("agent-x", "acct-orphan-by-design", "claude")
1233            .unwrap();
1234        let links = store.agent_identity_list_for_agent("agent-x").unwrap();
1235        assert_eq!(links.len(), 1);
1236    }
1237}
1238
1239#[cfg(test)]
1240mod secret_ref_wire_tests {
1241    use super::*;
1242    use serde_json::json;
1243
1244    // THE REASON `#[ts(rename)]` EXISTS ON OAuthConfigDir.
1245    //
1246    // ts-rs does not pick up `#[serde(rename = "oauth_config_dir")]`; left
1247    // alone it emits the derive-default `o_auth_config_dir` — the legacy tag
1248    // this rename exists to STOP writing. A frontend discriminating on that
1249    // tag would never match a real OAuth account, which is the
1250    // "My Agents empty" incident the variant's doc comment describes.
1251    //
1252    // No CI gate catches that: tsc passes (the union simply never matches at
1253    // runtime) and check-rpc-bindings passes (the type exists and is current).
1254    // So pin the serialized tag here, in Rust, where it is checkable.
1255    #[test]
1256    fn oauth_config_dir_serializes_with_the_canonical_tag() {
1257        let v = serde_json::to_value(SecretRef::OAuthConfigDir {
1258            dir: "/home/u/.claude".to_string(),
1259        })
1260        .expect("serializable");
1261        assert_eq!(v["backend"], "oauth_config_dir");
1262        assert_ne!(
1263            v["backend"], "o_auth_config_dir",
1264            "the derive-default tag must never be written; it is read-only legacy"
1265        );
1266    }
1267
1268    // The alias is still accepted on the way in — dropping it would strand
1269    // accounts persisted while the buggy tag was canonical.
1270    #[test]
1271    fn both_tags_still_deserialize() {
1272        for tag in ["oauth_config_dir", "o_auth_config_dir"] {
1273            let parsed: SecretRef =
1274                serde_json::from_value(json!({"backend": tag, "dir": "/d"})).unwrap_or_else(|e| {
1275                    panic!("tag {tag} must still deserialize: {e}")
1276                });
1277            assert!(matches!(parsed, SecretRef::OAuthConfigDir { .. }));
1278        }
1279    }
1280
1281    // display_name and status are #[serde(default)] on non-Option fields with
1282    // no skip_serializing_if, so the server ALWAYS writes them. The
1283    // hand-written declaration marked both optional, which the wire never was;
1284    // the generated binding makes them required.
1285    #[test]
1286    fn identity_account_always_writes_display_name_and_status() {
1287        let a: IdentityAccount = serde_json::from_value(json!({
1288            "id": "a1", "name": "n", "provider": "anthropic", "kind": "api_key",
1289            "secret_ref": {"backend": "env", "env_var": "K"},
1290        }))
1291        .expect("minimal account");
1292        let v = serde_json::to_value(&a).expect("serializable");
1293        let obj = v.as_object().expect("object");
1294        assert!(obj.contains_key("display_name"), "display_name is never omitted");
1295        assert!(obj.contains_key("status"), "status is never omitted");
1296        assert_eq!(v["context"], json!({}), "context defaults to an object, not null");
1297    }
1298}