Skip to main content

agentmux_srv/migrations/
m0019_repair_malformed_secret_ref.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Repair `db_accounts.secret_ref` rows whose JSON was corrupted by an
5//! out-of-band edit of the shared store — found live: a raw Windows path
6//! pasted into the JSON string with its backslashes never escaped
7//! (`{"backend":"oauth_config_dir","dir":"C:\Users\..."}`, valid JSON needs
8//! `\\` per separator). Not reproducible from any write path in this
9//! codebase (`Store::identity_upsert` always `serde_json::to_string`s
10//! correctly) — see
11//! `ANALYSIS_ARMORY_STASH_CREDENTIAL_VISIBILITY_GAP_2026_08_04 (never committed to this repo)`
12//! §7 for the full incident.
13//!
14//! Global-scoped (touches the shared store directly, like
15//! `m0012_dedup_identity_accounts` and `m0018_ambient_login_registry`). Safe
16//! to re-run: `identity_repair_malformed_secret_refs` is a no-op once every
17//! row parses.
18//!
19//! This migration is a data repair, not a substitute for
20//! `identity_list`/`identity_get`'s own per-row tolerance (see the same
21//! analysis doc's §6 option 1, implemented alongside this migration in
22//! `backend/storage/identities.rs`) — the two are independent defenses:
23//! this fixes the one known-bad row; that keeps any future corruption from
24//! ever again hiding every other account.
25
26use crate::backend::storage::store::Store;
27use super::{Migration, MigrationContext, MigrationError, MigrationScope};
28
29pub struct M0019RepairMalformedSecretRef;
30
31impl Migration for M0019RepairMalformedSecretRef {
32    fn id(&self) -> &'static str { "0019_repair_malformed_secret_ref" }
33    fn scope(&self) -> MigrationScope { MigrationScope::Global }
34    fn description(&self) -> &'static str {
35        "Repair db_accounts.secret_ref rows with unescaped-backslash JSON corruption"
36    }
37
38    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
39        if !ctx.shared_store_path.exists() {
40            return Ok(());
41        }
42        let shared = Store::open_shared(&ctx.shared_store_path)
43            .map_err(|e| MigrationError(format!("repair_malformed_secret_ref: open shared store: {}", e)))?;
44        let repaired = shared
45            .identity_repair_malformed_secret_refs()
46            .map_err(|e| MigrationError(format!("repair_malformed_secret_ref: {}", e)))?;
47        tracing::info!(
48            target: "identity",
49            repaired,
50            "identity.repair: secret_ref repair pass — {} row(s) fixed",
51            repaired,
52        );
53        Ok(())
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::backend::storage::store::{IdentityAccount, SecretRef};
61
62    fn ctx_for(shared: &std::path::Path) -> MigrationContext {
63        MigrationContext {
64            home: std::env::temp_dir(),
65            data_dir: std::env::temp_dir(),
66            shared_store_path: shared.to_path_buf(),
67            channel_store_path: std::env::temp_dir().join("agentmux-test-unused-channel.db"),
68        }
69    }
70
71    #[test]
72    fn repairs_a_malformed_row_in_the_shared_store() {
73        let shared = tempfile::NamedTempFile::new().unwrap();
74        let store = Store::open_shared(shared.path()).unwrap();
75        store
76            .identity_upsert(&IdentityAccount {
77                id: "acct-good".to_string(),
78                name: "claude-good".to_string(),
79                provider: "claude".to_string(),
80                kind: "oauth".to_string(),
81                display_name: String::new(),
82                secret_ref: SecretRef::OAuthConfigDir { dir: "/tmp/good".to_string() },
83                context: serde_json::json!({}),
84                status: "ok".to_string(),
85                created_at: 0,
86                updated_at: 0,
87            })
88            .unwrap();
89        {
90            let conn = store.conn().lock().unwrap();
91            conn.execute(
92                "INSERT INTO db_accounts
93                    (id, name, provider, kind, display_name, secret_ref, context,
94                     status, created_at, updated_at)
95                 VALUES ('acct-broken', 'broken', 'claude', 'oauth', '',
96                         '{\"backend\":\"oauth_config_dir\",\"dir\":\"C:\\Users\\a\\claude\"}',
97                         '{}', 'unknown', 0, 0)",
98                [],
99            )
100            .unwrap();
101        }
102        drop(store);
103
104        M0019RepairMalformedSecretRef.up(&ctx_for(shared.path())).unwrap();
105
106        let store = Store::open_shared(shared.path()).unwrap();
107        let accounts = store.identity_list(None).unwrap();
108        assert_eq!(accounts.len(), 2, "both the already-good and the repaired row must be visible");
109        assert!(accounts.iter().any(|a| a.id == "acct-broken"));
110    }
111
112    #[test]
113    fn missing_shared_store_is_a_noop() {
114        let missing = std::env::temp_dir().join("agentmux-test-shared-definitely-missing-r7z.db");
115        let _ = std::fs::remove_file(&missing);
116        M0019RepairMalformedSecretRef.up(&ctx_for(&missing)).unwrap();
117        assert!(!missing.exists(), "up() must not create the store");
118    }
119}