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}