Skip to main content

agentmux_srv/identity/
cleanup.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! On-disk / keychain credential cleanup for a deleted identity account.
5//!
6//! Layer 1 of the account-delete auth-lifecycle remediation
7//! (`docs/analysis/ANALYSIS_ACCOUNT_DELETE_AUTH_LIFECYCLE_GAP_2026_07_14.md`
8//! §4 option 1): deleting an account row must not leave its credential
9//! material behind. Two secret backends carry on-host state:
10//!
11//! - `SecretRef::Keychain` — delete the OS-keychain entry (moved here
12//!   from the inline block in the `deleteidentityaccount` handler).
13//! - `SecretRef::OAuthConfigDir { dir }` — the CLI's live access +
14//!   refresh tokens sit in that directory; remove the tree, **except the
15//!   provider's conversation history** (`ProviderConfig::history_native_subdir`,
16//!   e.g. Claude's `projects/`), which lives in the same directory when
17//!   auth is shared. History and credentials are separate persistence
18//!   categories (SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md
19//!   P1); deleting a login must not delete the conversations
20//!   (SPEC_ARMORY_ACCOUNTS_DELETE_AND_INLINE_DETAIL_2026_10_04.md H1). This is
21//!   best-effort and **containment-guarded**: the dir is only removed
22//!   when it resolves INSIDE the agentmux identities root
23//!   (`~/.agentmux/shared/identities/`). The legacy `~/.claude`
24//!   migration case (and any other ambient/global CLI dir) is the
25//!   user's own login — log and skip, never delete.
26//!
27//! Other variants (`Env`, `SecretsManager`, `PlaintextDev`) hold no
28//! agentmux-owned on-host state → no-op.
29//!
30//! All log lines use the `identity.delete:` prefix so they land in the
31//! `muxlog auth` vocabulary (regex `identity\.(unlink|delete|self\.|account)`),
32//! and use `info!`/`warn!` — the production filter is
33//! "agentmuxsrv=info,info", so `debug!` would be invisible (reagent P1,
34//! PR #2143). Provider-side token revocation (running the CLI's own
35//! `logout` against the dir before deleting) is an explicit follow-up —
36//! it needs per-provider subprocess plumbing this module doesn't have.
37
38use std::path::{Path, PathBuf};
39use std::time::SystemTime;
40
41use crate::backend::providers::{all_providers, get_provider};
42use crate::backend::storage::store::{IdentityAccount, SecretRef, Store};
43
44/// What `cleanup_account_secrets` did, for callers/tests to assert on.
45/// Logging already happened inside the function.
46#[derive(Debug, PartialEq, Eq)]
47pub enum SecretCleanup {
48    /// Keychain entry removed (or was already absent — idempotent).
49    KeychainRemoved,
50    /// Keychain delete failed (logged as warn; account delete proceeds).
51    KeychainFailed(String),
52    /// OAuth config dir tree removed.
53    OAuthDirRemoved(PathBuf),
54    /// Everything in the OAuth config dir removed except the provider's
55    /// conversation history subdirectory, which was kept (so the dir
56    /// itself remains).
57    OAuthDirHistoryKept { dir: PathBuf, history: PathBuf },
58    /// OAuth config dir was already gone — nothing to remove.
59    OAuthDirAbsent(PathBuf),
60    /// OAuth config dir NOT removed: it does not resolve inside the
61    /// agentmux identities root (e.g. the legacy `~/.claude` migration
62    /// account), or the root itself could not be resolved/canonicalized.
63    OAuthDirSkipped { dir: PathBuf, reason: String },
64    /// OAuth config dir removal failed (fs error; logged as warn).
65    OAuthDirFailed { dir: PathBuf, error: String },
66    /// Secret backend holds no agentmux-owned on-host state.
67    NoOp,
68}
69
70impl SecretCleanup {
71    /// The `cleanup` object `deleteidentityaccount` returns, so Connectors → Accounts can
72    /// say whether the saved login is really gone
73    /// (SPEC_ARMORY_ACCOUNTS_DELETE_AND_INLINE_DETAIL_2026_10_04.md §3.4).
74    /// `outcome` is `removed`, `absent`, `skipped`, `failed` or `none` (no
75    /// on-host secret).
76    pub fn report(&self) -> serde_json::Value {
77        let path = |p: &Path| p.to_string_lossy().to_string();
78        match self {
79            SecretCleanup::KeychainRemoved => serde_json::json!({ "outcome": "removed" }),
80            SecretCleanup::KeychainFailed(e) => serde_json::json!({ "outcome": "failed", "detail": e }),
81            SecretCleanup::OAuthDirRemoved(d) => serde_json::json!({ "outcome": "removed", "path": path(d) }),
82            SecretCleanup::OAuthDirHistoryKept { dir, history } => serde_json::json!({
83                "outcome": "removed", "path": path(dir), "historyKept": true, "historyPath": path(history),
84            }),
85            SecretCleanup::OAuthDirAbsent(d) => serde_json::json!({ "outcome": "absent", "path": path(d) }),
86            SecretCleanup::OAuthDirSkipped { dir, reason } => {
87                serde_json::json!({ "outcome": "skipped", "path": path(dir), "detail": reason })
88            }
89            SecretCleanup::OAuthDirFailed { dir, error } => {
90                serde_json::json!({ "outcome": "failed", "path": path(dir), "detail": error })
91            }
92            SecretCleanup::NoOp => serde_json::json!({ "outcome": "none" }),
93        }
94    }
95}
96
97/// Best-effort removal of the on-host credential material behind
98/// `acct.secret_ref`. Never returns `Err` — account deletion must not be
99/// blocked by cleanup trouble; every outcome is logged.
100///
101/// `identities_root` is the agentmux identities root
102/// (`DataPaths::identities_dir()`), `None` when `DataPaths::from_env()`
103/// could not resolve (CI / unusual envs) — OAuth dirs are then skipped,
104/// never guessed. Blocking (keyring + fs) — call via `spawn_blocking`
105/// from async contexts.
106pub fn cleanup_account_secrets(
107    acct: &IdentityAccount,
108    identities_root: Option<&Path>,
109) -> SecretCleanup {
110    match &acct.secret_ref {
111        SecretRef::Keychain { .. } => match crate::identity::secret_store::delete(&acct.id) {
112            Ok(()) => {
113                tracing::info!(
114                    account_id = %acct.id,
115                    provider = %acct.provider,
116                    "identity.delete: keychain secret removed"
117                );
118                SecretCleanup::KeychainRemoved
119            }
120            Err(e) => {
121                tracing::warn!(
122                    account_id = %acct.id,
123                    provider = %acct.provider,
124                    error = %e,
125                    "identity.delete: keychain secret delete failed"
126                );
127                SecretCleanup::KeychainFailed(e)
128            }
129        },
130        SecretRef::OAuthConfigDir { dir } => cleanup_oauth_dir(acct, Path::new(dir), identities_root),
131        SecretRef::Env { .. } | SecretRef::SecretsManager { .. } | SecretRef::PlaintextDev { .. } => {
132            SecretCleanup::NoOp
133        }
134    }
135}
136
137fn cleanup_oauth_dir(
138    acct: &IdentityAccount,
139    dir: &Path,
140    identities_root: Option<&Path>,
141) -> SecretCleanup {
142    let skip = |reason: String| -> SecretCleanup {
143        tracing::warn!(
144            account_id = %acct.id,
145            provider = %acct.provider,
146            dir = %dir.display(),
147            reason = %reason,
148            "identity.delete: oauth config dir outside data root — skipped"
149        );
150        SecretCleanup::OAuthDirSkipped { dir: dir.to_path_buf(), reason }
151    };
152
153    let root = match identities_root {
154        Some(r) => r,
155        None => return skip("identities root unresolved (DataPaths::from_env() = None)".into()),
156    };
157    if !dir.exists() {
158        // Nothing on disk — already clean (e.g. the CLI never wrote tokens).
159        tracing::info!(
160            account_id = %acct.id,
161            provider = %acct.provider,
162            dir = %dir.display(),
163            "identity.delete: oauth config dir already absent"
164        );
165        return SecretCleanup::OAuthDirAbsent(dir.to_path_buf());
166    }
167    // Canonicalize BOTH sides so `..` segments, symlinks, and Windows
168    // `\\?\` prefixes can't defeat the containment check.
169    let canon_root = match std::fs::canonicalize(root) {
170        Ok(p) => p,
171        Err(e) => return skip(format!("identities root not canonicalizable: {e}")),
172    };
173    let canon_dir = match std::fs::canonicalize(dir) {
174        Ok(p) => p,
175        Err(e) => return skip(format!("dir not canonicalizable: {e}")),
176    };
177    // Strictly inside the root — refuse the root itself and anything
178    // outside it (the legacy `~/.claude` migration dir lands here).
179    if canon_dir == canon_root || !canon_dir.starts_with(&canon_root) {
180        return skip(format!(
181            "resolved path {} is not strictly inside identities root {}",
182            canon_dir.display(),
183            canon_root.display()
184        ));
185    }
186    let history = history_subdir_for(acct, &canon_dir);
187    match remove_credentials_keep_history(&canon_dir, history) {
188        Ok(Some(kept)) => {
189            tracing::info!(
190                account_id = %acct.id,
191                provider = %acct.provider,
192                dir = %dir.display(),
193                history = %kept.display(),
194                "identity.delete: oauth credentials removed, conversation history kept"
195            );
196            SecretCleanup::OAuthDirHistoryKept { dir: dir.to_path_buf(), history: kept }
197        }
198        Ok(None) => {
199            // Verify the removal actually took — a "removed" log that leaves
200            // the credential on disk is the exact login/logout-round bug this
201            // diagnostic exists to catch (e.g. a racing re-seed, a bind-mount,
202            // or a handle keeping the tree alive). Escalate to WARN if so.
203            if canon_dir.exists() {
204                tracing::warn!(
205                    account_id = %acct.id,
206                    provider = %acct.provider,
207                    dir = %dir.display(),
208                    "identity.delete: oauth config dir STILL PRESENT after remove_dir_all — credential not cleared"
209                );
210            } else {
211                tracing::info!(
212                    account_id = %acct.id,
213                    provider = %acct.provider,
214                    dir = %dir.display(),
215                    "identity.delete: oauth config dir removed (verified absent)"
216                );
217            }
218            SecretCleanup::OAuthDirRemoved(dir.to_path_buf())
219        }
220        // Gone between the `exists()` check above and the removal (a second
221        // delete racing this one): already clean.
222        Err(e) if e.kind() == std::io::ErrorKind::NotFound => SecretCleanup::OAuthDirAbsent(dir.to_path_buf()),
223        Err(e) => {
224            tracing::warn!(
225                account_id = %acct.id,
226                provider = %acct.provider,
227                dir = %dir.display(),
228                error = %e,
229                "identity.delete: oauth config dir removal failed"
230            );
231            SecretCleanup::OAuthDirFailed { dir: dir.to_path_buf(), error: e.to_string() }
232        }
233    }
234}
235
236/// The conversation-history subdirectory name for an OAuth config dir:
237/// from the account's provider, or else from the dir's own name, which is
238/// the provider's `auth_dir_name` (`identities/<account>/<auth_dir_name>/`).
239fn history_subdir_for(acct: &IdentityAccount, dir: &Path) -> Option<&'static str> {
240    if let Some(sub) = get_provider(&acct.provider).and_then(|p| p.history_native_subdir) {
241        return Some(sub);
242    }
243    history_subdir_for_auth_dir(dir.file_name()?.to_str()?)
244}
245
246fn history_subdir_for_auth_dir(auth_dir_name: &str) -> Option<&'static str> {
247    all_providers()
248        .find(|p| p.auth_dir_name == auth_dir_name)
249        .and_then(|p| p.history_native_subdir)
250}
251
252/// Remove `dir`, keeping its `history` child when that child is a real,
253/// non-empty directory. Returns the kept path, or `None` when the whole
254/// tree was removed.
255///
256/// A history child that is a link (a symlink, or a Windows junction — the
257/// isolated-auth redirect to the always-global history, see
258/// `identity_auth_dirs::link_history_if_isolated`) does not hold the
259/// history itself, so the whole tree goes; `remove_dir_all` removes the
260/// link without following it. An empty history child is nothing to keep.
261///
262/// Keeps removing the other entries after one fails, so one locked file
263/// doesn't leave the rest of the credential behind, then reports the first
264/// failure.
265fn remove_credentials_keep_history(dir: &Path, history: Option<&str>) -> std::io::Result<Option<PathBuf>> {
266    let keep = history.map(|h| dir.join(h)).filter(|h| {
267        std::fs::symlink_metadata(h).map(|m| m.file_type().is_dir()).unwrap_or(false)
268            && std::fs::read_dir(h).map(|mut it| it.next().is_some()).unwrap_or(false)
269    });
270    let Some(keep) = keep else {
271        std::fs::remove_dir_all(dir)?;
272        return Ok(None);
273    };
274    let mut first_err: Option<std::io::Error> = None;
275    for entry in std::fs::read_dir(dir)? {
276        let entry = entry?;
277        let path = entry.path();
278        if path == keep {
279            continue;
280        }
281        let res = match entry.file_type() {
282            Ok(ft) if ft.is_dir() => std::fs::remove_dir_all(&path),
283            // A file or a link: `remove_file` for files and Unix links,
284            // `remove_dir` for Windows directory links and junctions.
285            _ => std::fs::remove_file(&path).or_else(|_| std::fs::remove_dir(&path)),
286        };
287        if let Err(e) = res {
288            if e.kind() != std::io::ErrorKind::NotFound && first_err.is_none() {
289                first_err = Some(std::io::Error::new(e.kind(), format!("{}: {e}", path.display())));
290            }
291        }
292    }
293    match first_err {
294        Some(e) => Err(e),
295        None => Ok(Some(keep)),
296    }
297}
298
299enum PruneOutcome {
300    /// The whole account dir was removed.
301    Removed,
302    /// Credentials removed; at least one provider's conversation history kept.
303    HistoryKept,
304    /// The dir held nothing but already-kept history.
305    NothingToRemove,
306}
307
308/// Removes an orphaned account dir (`<root>/<account_id>/`), only if it
309/// resolves strictly inside `root` (the same escape guard
310/// `cleanup_oauth_dir` uses), except that each provider subdir's
311/// conversation history is kept, the same rule account delete follows
312/// (`remove_credentials_keep_history`). Without this, the sweep would
313/// delete, a few minutes later, the history that deleting the account kept.
314fn prune_orphan_keeping_history(dir: &Path, root: &Path) -> Result<PruneOutcome, String> {
315    let canon_root = std::fs::canonicalize(root).map_err(|e| format!("root not canonicalizable: {e}"))?;
316    let canon_dir = std::fs::canonicalize(dir).map_err(|e| format!("dir not canonicalizable: {e}"))?;
317    if canon_dir == canon_root || !canon_dir.starts_with(&canon_root) {
318        return Err(format!("{} is not strictly inside {}", canon_dir.display(), canon_root.display()));
319    }
320    let mut kept_any = false;
321    let mut removed_any = false;
322    for entry in std::fs::read_dir(&canon_dir).map_err(|e| e.to_string())? {
323        let entry = entry.map_err(|e| e.to_string())?;
324        let path = entry.path();
325        let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
326        let history = if is_dir {
327            path.file_name().and_then(|n| n.to_str()).and_then(history_subdir_for_auth_dir)
328        } else {
329            None
330        };
331        if let Some(h) = history {
332            // Already only history: leave it, and don't count it as a removal.
333            let only_history = std::fs::read_dir(&path)
334                .map(|it| it.flatten().all(|e| e.file_name() == h))
335                .unwrap_or(false);
336            if only_history && path.join(h).is_dir() {
337                kept_any = true;
338                continue;
339            }
340        }
341        if is_dir {
342            if remove_credentials_keep_history(&path, history).map_err(|e| e.to_string())?.is_some() {
343                kept_any = true;
344            }
345        } else {
346            std::fs::remove_file(&path).or_else(|_| std::fs::remove_dir(&path)).map_err(|e| e.to_string())?;
347        }
348        removed_any = true;
349    }
350    if kept_any {
351        return Ok(if removed_any { PruneOutcome::HistoryKept } else { PruneOutcome::NothingToRemove });
352    }
353    std::fs::remove_dir_all(&canon_dir).map_err(|e| e.to_string())?;
354    Ok(PruneOutcome::Removed)
355}
356
357/// Sweep `identities_root`'s direct children for orphaned per-account
358/// dirs: `compute_and_ensure_account_dir` mints a fresh `<account_id>/`
359/// dir and writes real credential files into it BEFORE the login that
360/// dir belongs to ever completes, so no `IdentityAccount` row exists yet
361/// to reference it. A login that's abandoned, times out, or crashes the
362/// app mid-flow leaves that directory behind forever — reagent's finding
363/// on #2260: "abandoned/failed attempts leave orphaned directories with
364/// no cleanup path."
365///
366/// Called opportunistically from `compute_and_ensure_account_dir` right
367/// before every FRESH mint (never on a reconnect — reusing an existing
368/// account's dir needs no sweep). Age-gated via `min_age_secs` so a
369/// login that's still genuinely in progress — which legitimately has no
370/// DB row yet either — is never swept out from under it; callers should
371/// pick a margin comfortably above every frontend poll timeout (5
372/// minutes as of this writing) to leave room for a slow user plus retry.
373///
374/// Best-effort and silent-safe: `identity_get` failures or an unreadable
375/// root skip that candidate (or the whole sweep) rather than risk
376/// deleting a real, in-use account's dir on a false read. Returns the
377/// dirs actually removed, mainly for tests to assert on.
378pub fn sweep_orphaned_account_dirs(
379    store: &Store,
380    identities_root: &Path,
381    min_age_secs: u64,
382) -> Vec<PathBuf> {
383    let mut removed = Vec::new();
384    let entries = match std::fs::read_dir(identities_root) {
385        Ok(e) => e,
386        Err(_) => return removed, // root doesn't exist yet — nothing to sweep
387    };
388    for entry in entries.flatten() {
389        let path = entry.path();
390        if !path.is_dir() {
391            continue;
392        }
393        let Some(account_id) = path.file_name().and_then(|n| n.to_str()) else {
394            continue;
395        };
396        match store.identity_get(account_id) {
397            // A real, persisted account — never touch it.
398            Ok(Some(_)) => continue,
399            Ok(None) => {}
400            Err(e) => {
401                tracing::warn!(
402                    account_id,
403                    error = %e,
404                    "identity.sweep: identity_get failed — skipping candidate rather than risk a false orphan"
405                );
406                continue;
407            }
408        }
409        let age_secs = match entry.metadata().and_then(|m| m.modified()) {
410            Ok(mtime) => SystemTime::now()
411                .duration_since(mtime)
412                .map(|d| d.as_secs())
413                .unwrap_or(0),
414            Err(_) => continue, // can't determine age — skip, don't guess
415        };
416        if age_secs < min_age_secs {
417            continue; // could still be a login in progress
418        }
419        match prune_orphan_keeping_history(&path, identities_root) {
420            // Only conversation history was left, already pruned: nothing to do.
421            Ok(PruneOutcome::NothingToRemove) => {}
422            Ok(PruneOutcome::HistoryKept) => {
423                tracing::info!(
424                    account_id,
425                    dir = %path.display(),
426                    age_secs,
427                    "identity.sweep: orphaned account dir pruned, conversation history kept"
428                );
429                removed.push(path);
430            }
431            Ok(PruneOutcome::Removed) => {
432                tracing::info!(
433                    account_id,
434                    dir = %path.display(),
435                    age_secs,
436                    "identity.sweep: orphaned account dir removed (no matching account row, past age threshold)"
437                );
438                removed.push(path);
439            }
440            Err(e) => {
441                tracing::debug!(account_id, dir = %path.display(), error = %e, "identity.sweep: candidate not removed");
442            }
443        }
444    }
445    removed
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn acct_with(secret_ref: SecretRef) -> IdentityAccount {
453        IdentityAccount {
454            id: "acct-test".to_string(),
455            name: "asaf-anthropic".to_string(),
456            provider: "anthropic".to_string(),
457            kind: "oauth".to_string(),
458            display_name: String::new(),
459            secret_ref,
460            context: serde_json::json!({}),
461            status: "ok".to_string(),
462            created_at: 0,
463            updated_at: 0,
464        }
465    }
466
467    /// OAuthConfigDir inside the identities root → tree removed.
468    #[test]
469    fn oauth_dir_inside_data_root_is_removed() {
470        let tmp = tempfile::tempdir().unwrap();
471        let root = tmp.path().join("identities");
472        let dir = root.join("acct-test").join("claude");
473        std::fs::create_dir_all(&dir).unwrap();
474        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
475
476        let acct = acct_with(SecretRef::OAuthConfigDir {
477            dir: dir.to_string_lossy().to_string(),
478        });
479        let out = cleanup_account_secrets(&acct, Some(&root));
480
481        assert!(matches!(out, SecretCleanup::OAuthDirRemoved(_)), "got {out:?}");
482        assert!(!dir.exists(), "token dir must be gone");
483        // The account's parent folder under the root may remain; only the
484        // configured dir tree is removed.
485        assert!(root.exists(), "identities root itself must survive");
486    }
487
488    /// OAuthConfigDir OUTSIDE the identities root (the legacy `~/.claude`
489    /// migration case) → NOT removed, skip outcome.
490    #[test]
491    fn oauth_dir_outside_data_root_is_skipped() {
492        let tmp = tempfile::tempdir().unwrap();
493        let root = tmp.path().join("identities");
494        std::fs::create_dir_all(&root).unwrap();
495        // A stand-in for the user's global ~/.claude dir.
496        let ambient = tmp.path().join("dot-claude");
497        std::fs::create_dir_all(&ambient).unwrap();
498        std::fs::write(ambient.join(".credentials.json"), "{}").unwrap();
499
500        let acct = acct_with(SecretRef::OAuthConfigDir {
501            dir: ambient.to_string_lossy().to_string(),
502        });
503        let out = cleanup_account_secrets(&acct, Some(&root));
504
505        assert!(
506            matches!(out, SecretCleanup::OAuthDirSkipped { .. }),
507            "must refuse to delete outside the identities root, got {out:?}"
508        );
509        assert!(ambient.exists(), "ambient CLI dir must be untouched");
510        assert!(ambient.join(".credentials.json").exists());
511    }
512
513    /// A `..`-laden path that textually starts under the root but resolves
514    /// outside it must also be skipped (canonicalization guard).
515    #[test]
516    fn oauth_dir_dotdot_escape_is_skipped() {
517        let tmp = tempfile::tempdir().unwrap();
518        let root = tmp.path().join("identities");
519        std::fs::create_dir_all(&root).unwrap();
520        let ambient = tmp.path().join("dot-claude");
521        std::fs::create_dir_all(&ambient).unwrap();
522
523        let sneaky = root.join("..").join("dot-claude");
524        let acct = acct_with(SecretRef::OAuthConfigDir {
525            dir: sneaky.to_string_lossy().to_string(),
526        });
527        let out = cleanup_account_secrets(&acct, Some(&root));
528
529        assert!(matches!(out, SecretCleanup::OAuthDirSkipped { .. }), "got {out:?}");
530        assert!(ambient.exists());
531    }
532
533    /// Unresolvable identities root (DataPaths::from_env() = None) → skip,
534    /// never guess.
535    #[test]
536    fn oauth_dir_with_no_root_is_skipped() {
537        let tmp = tempfile::tempdir().unwrap();
538        let dir = tmp.path().join("identities").join("acct-test").join("claude");
539        std::fs::create_dir_all(&dir).unwrap();
540
541        let acct = acct_with(SecretRef::OAuthConfigDir {
542            dir: dir.to_string_lossy().to_string(),
543        });
544        let out = cleanup_account_secrets(&acct, None);
545
546        assert!(matches!(out, SecretCleanup::OAuthDirSkipped { .. }), "got {out:?}");
547        assert!(dir.exists(), "dir must be untouched when the root is unknown");
548    }
549
550    /// Already-absent dir → absent outcome, nothing created.
551    #[test]
552    fn oauth_dir_absent_is_noop() {
553        let tmp = tempfile::tempdir().unwrap();
554        let root = tmp.path().join("identities");
555        std::fs::create_dir_all(&root).unwrap();
556        let dir = root.join("acct-test").join("claude");
557
558        let acct = acct_with(SecretRef::OAuthConfigDir {
559            dir: dir.to_string_lossy().to_string(),
560        });
561        let out = cleanup_account_secrets(&acct, Some(&root));
562
563        assert!(matches!(out, SecretCleanup::OAuthDirAbsent(_)), "got {out:?}");
564        assert!(!dir.exists());
565    }
566
567    /// Env / dev variants hold no on-host state → no-op, filesystem untouched.
568    #[test]
569    fn env_and_dev_variants_touch_nothing() {
570        let tmp = tempfile::tempdir().unwrap();
571        let root = tmp.path().join("identities");
572        let canary = root.join("acct-test").join("claude");
573        std::fs::create_dir_all(&canary).unwrap();
574
575        for secret_ref in [
576            SecretRef::Env { env_var: "ANTHROPIC_API_KEY".to_string() },
577            SecretRef::PlaintextDev { plaintext_dev: "sk-dev".to_string() },
578            SecretRef::SecretsManager { sm_path: "path/x".to_string(), sm_json_path: None },
579        ] {
580            let out = cleanup_account_secrets(&acct_with(secret_ref), Some(&root));
581            assert_eq!(out, SecretCleanup::NoOp);
582        }
583        assert!(canary.exists(), "no filesystem side effects for non-oauth variants");
584    }
585
586    /// Keychain variant must never touch the filesystem (outcome depends on
587    /// the host's keychain — absent entry deletes idempotently, headless CI
588    /// may fail — but both are non-filesystem outcomes).
589    #[test]
590    fn keychain_variant_touches_no_filesystem() {
591        let tmp = tempfile::tempdir().unwrap();
592        let root = tmp.path().join("identities");
593        let canary = root.join("acct-test").join("claude");
594        std::fs::create_dir_all(&canary).unwrap();
595
596        // Deliberately unresolvable id so the delete is a guaranteed
597        // no-entry (idempotent Ok) on a dev machine's real keychain.
598        let mut acct = acct_with(SecretRef::Keychain {
599            service: "agentmux".to_string(),
600            account: "acct:zz-unit-test-never-provisioned".to_string(),
601        });
602        acct.id = "zz-unit-test-never-provisioned".to_string();
603        let out = cleanup_account_secrets(&acct, Some(&root));
604
605        assert!(
606            matches!(out, SecretCleanup::KeychainRemoved | SecretCleanup::KeychainFailed(_)),
607            "got {out:?}"
608        );
609        assert!(canary.exists(), "keychain cleanup must not touch the filesystem");
610    }
611
612    fn make_store() -> Store {
613        Store::open_in_memory().unwrap()
614    }
615
616    /// An orphaned dir (no matching `IdentityAccount` row) past the age
617    /// threshold is removed and reported.
618    #[test]
619    fn sweep_removes_orphaned_dir_past_age_threshold() {
620        let store = make_store();
621        let tmp = tempfile::tempdir().unwrap();
622        let root = tmp.path().join("identities");
623        let orphan = root.join("orphan-uuid").join("claude");
624        std::fs::create_dir_all(&orphan).unwrap();
625        std::fs::write(orphan.join(".credentials.json"), "{}").unwrap();
626
627        let removed = sweep_orphaned_account_dirs(&store, &root, 0);
628
629        assert_eq!(removed, vec![root.join("orphan-uuid")]);
630        assert!(!root.join("orphan-uuid").exists(), "orphaned dir must be gone");
631    }
632
633    /// A dir with a real, persisted `IdentityAccount` row is never swept,
634    /// no matter the age threshold — this is the guard that keeps an
635    /// in-use account's credentials safe.
636    #[test]
637    fn sweep_never_removes_a_dir_with_a_real_account_row() {
638        let store = make_store();
639        let tmp = tempfile::tempdir().unwrap();
640        let root = tmp.path().join("identities");
641        let real = root.join("real-account-id").join("claude");
642        std::fs::create_dir_all(&real).unwrap();
643
644        store
645            .identity_upsert(&IdentityAccount {
646                id: "real-account-id".to_string(),
647                name: "asaf-claude".to_string(),
648                provider: "claude".to_string(),
649                kind: "oauth".to_string(),
650                display_name: String::new(),
651                secret_ref: SecretRef::OAuthConfigDir {
652                    dir: real.to_string_lossy().to_string(),
653                },
654                context: serde_json::json!({}),
655                status: "valid".to_string(),
656                created_at: 0,
657                updated_at: 0,
658            })
659            .unwrap();
660
661        let removed = sweep_orphaned_account_dirs(&store, &root, 0);
662
663        assert!(removed.is_empty());
664        assert!(real.exists(), "a real account's dir must survive the sweep");
665    }
666
667    /// An orphaned dir younger than `min_age_secs` is left alone — it
668    /// could be a login that's still genuinely in progress.
669    #[test]
670    fn sweep_never_removes_a_dir_younger_than_the_age_threshold() {
671        let store = make_store();
672        let tmp = tempfile::tempdir().unwrap();
673        let root = tmp.path().join("identities");
674        let fresh = root.join("fresh-uuid").join("claude");
675        std::fs::create_dir_all(&fresh).unwrap();
676
677        // Effectively "just created" vs. an implausibly high age floor.
678        let removed = sweep_orphaned_account_dirs(&store, &root, 999_999);
679
680        assert!(removed.is_empty());
681        assert!(root.join("fresh-uuid").exists(), "a fresh in-progress dir must survive");
682    }
683
684    /// The identities root not existing yet (fresh install, nothing ever
685    /// minted) is a normal state, not an error — empty result, no panic.
686    #[test]
687    fn sweep_on_missing_root_returns_empty_without_panicking() {
688        let store = make_store();
689        let tmp = tempfile::tempdir().unwrap();
690        let root = tmp.path().join("identities-never-created");
691
692        let removed = sweep_orphaned_account_dirs(&store, &root, 0);
693
694        assert!(removed.is_empty());
695    }
696
697    fn claude_dir_with_history(root: &Path, account: &str) -> PathBuf {
698        let dir = root.join(account).join("claude");
699        std::fs::create_dir_all(dir.join("projects").join("C--work")).unwrap();
700        std::fs::create_dir_all(dir.join("sessions")).unwrap();
701        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
702        std::fs::write(dir.join("settings.json"), "{}").unwrap();
703        std::fs::write(dir.join("sessions").join("s.json"), "{}").unwrap();
704        std::fs::write(dir.join("projects").join("C--work").join("t.jsonl"), "{}").unwrap();
705        dir
706    }
707
708    /// Deleting a Claude account removes its login but keeps the
709    /// conversation history that shares its config dir
710    /// (SPEC_ARMORY_ACCOUNTS_DELETE_AND_INLINE_DETAIL_2026_10_04.md H1).
711    #[test]
712    fn oauth_delete_keeps_conversation_history() {
713        let tmp = tempfile::tempdir().unwrap();
714        let root = tmp.path().join("identities");
715        let dir = claude_dir_with_history(&root, "acct-test");
716        let mut acct = acct_with(SecretRef::OAuthConfigDir {
717            dir: dir.to_string_lossy().to_string(),
718        });
719        acct.provider = "claude".to_string();
720
721        let out = cleanup_account_secrets(&acct, Some(&root));
722
723        assert!(matches!(out, SecretCleanup::OAuthDirHistoryKept { .. }), "got {out:?}");
724        assert!(dir.join("projects").join("C--work").join("t.jsonl").exists(), "history must survive");
725        assert!(!dir.join(".credentials.json").exists(), "the login must be gone");
726        assert!(!dir.join("settings.json").exists());
727        assert!(!dir.join("sessions").exists());
728    }
729
730    /// The history subdir is found from the dir's own name when the
731    /// account's provider string isn't a registered provider id.
732    #[test]
733    fn oauth_delete_finds_history_by_dir_name() {
734        let tmp = tempfile::tempdir().unwrap();
735        let root = tmp.path().join("identities");
736        let dir = claude_dir_with_history(&root, "acct-test");
737        // acct_with's provider is "anthropic", not a registered provider id.
738        let acct = acct_with(SecretRef::OAuthConfigDir {
739            dir: dir.to_string_lossy().to_string(),
740        });
741
742        let out = cleanup_account_secrets(&acct, Some(&root));
743
744        assert!(matches!(out, SecretCleanup::OAuthDirHistoryKept { .. }), "got {out:?}");
745        assert!(dir.join("projects").join("C--work").join("t.jsonl").exists());
746        assert!(!dir.join(".credentials.json").exists());
747    }
748
749    /// An empty history dir is nothing to keep: the whole tree goes.
750    #[test]
751    fn oauth_delete_with_empty_history_removes_everything() {
752        let tmp = tempfile::tempdir().unwrap();
753        let root = tmp.path().join("identities");
754        let dir = root.join("acct-test").join("claude");
755        std::fs::create_dir_all(dir.join("projects")).unwrap();
756        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
757        let mut acct = acct_with(SecretRef::OAuthConfigDir {
758            dir: dir.to_string_lossy().to_string(),
759        });
760        acct.provider = "claude".to_string();
761
762        let out = cleanup_account_secrets(&acct, Some(&root));
763
764        assert!(matches!(out, SecretCleanup::OAuthDirRemoved(_)), "got {out:?}");
765        assert!(!dir.exists());
766    }
767
768    /// The orphan sweep follows the same rule: it removes the leftover
769    /// login but keeps history, and a second sweep finds nothing to do.
770    #[test]
771    fn sweep_keeps_history_of_an_orphaned_account_dir() {
772        let store = make_store();
773        let tmp = tempfile::tempdir().unwrap();
774        let root = tmp.path().join("identities");
775        let dir = claude_dir_with_history(&root, "deleted-uuid");
776
777        let removed = sweep_orphaned_account_dirs(&store, &root, 0);
778
779        assert_eq!(removed, vec![root.join("deleted-uuid")]);
780        assert!(dir.join("projects").join("C--work").join("t.jsonl").exists(), "history must survive the sweep");
781        assert!(!dir.join(".credentials.json").exists());
782        assert!(!dir.join("sessions").exists());
783
784        let again = sweep_orphaned_account_dirs(&store, &root, 0);
785        assert!(again.is_empty(), "history-only dir must not be reported again: {again:?}");
786        assert!(dir.join("projects").join("C--work").join("t.jsonl").exists());
787    }
788}