Skip to main content

agentmux_srv/identity/resolver/
errors.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Resolver error types: [`SpawnGateError`] and [`ResolverError`].
5//!
6//! Split out of the single ~2193-line `resolver.rs` (pure relocation, no
7//! behavior change).
8
9use crate::backend::storage::error::StoreError;
10
11/// Blocking spawn-gate error — layer 3 of
12/// SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md (§2.2).
13///
14/// Returned by the injection entry points when an **oauth-class** provider
15/// the agent is supposed to have credentials for (a binding exists, or the
16/// provider is the agent definition's own CLI provider) has no resolvable
17/// account. There is no per-agent opt-out. The spawn callers surface
18/// `Display` verbatim in the agent pane (same `error_during_execution`
19/// frame other spawn failures use) — the wording is the spec's.
20///
21/// Api-key-class bindings keep the historical log-and-skip behavior; this
22/// error exists only for the oauth class, where silent fallback meant the
23/// CLI would read the user's global login (`~/.claude`) after an account
24/// was deleted.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum SpawnGateError {
27    /// The gate's credentials verdict: no resolvable account.
28    MissingCredentials { provider: String },
29    /// The injection task itself could not run to completion (task-join
30    /// failure — e.g. a panic inside the blocking closure, which also
31    /// poisons the `Store` mutex for every later call). The gate FAILS
32    /// CLOSED on this: an open fallback would silently convert one panic
33    /// anywhere in the store into a permanent, systemic bypass of the
34    /// oauth gate (#2164). A
35    /// blocked spawn is retryable and visible; a silent ambient launch
36    /// is neither.
37    InjectionUnavailable { detail: String },
38    /// The bound account's `SecretRef::OAuthConfigDir` resolves to the
39    /// provider's own literal ambient home directory (e.g. `~/.claude`)
40    /// instead of an AgentMux-isolated dir. Blocked unconditionally — a
41    /// real, currently-live account
42    /// configured exactly this way was found in this repo's own data
43    /// (`docs/status/STATUS_IDENTITY_ISOLATION_GATE_NOT_ENFORCING_2026_08_20.md`
44    /// §8); this variant is the enforcement that closes that gap. See
45    /// `docs/specs/SPEC_BLOCK_AMBIENT_HOME_DIR_IDENTITY_BINDING_2026_08_25.md`.
46    AmbientHomeDirNotAllowed { provider: String, dir: String },
47    /// Failed to seed the isolated Claude Code config dir's `CLAUDE.md`
48    /// placeholder (`providers::seed_claude_md_placeholder_if_missing`).
49    /// Blocked rather than warn-and-continue: an isolated dir with no
50    /// `CLAUDE.md` of its own is exactly the condition where Claude Code
51    /// CLI falls through to the operator's real `~/.claude/CLAUDE.md` —
52    /// continuing to spawn would launch the agent with the leak this
53    /// fix exists to close. See
54    /// `docs/specs/SPEC_ISOLATE_HOST_CLAUDE_MD_2026_08_31.md`.
55    ClaudeMdSeedFailed { provider: String, dir: String, error: String },
56    /// The block names an agent that was deleted (its row is gone and the
57    /// shared definition registry holds only a retired record for it). The
58    /// pane survived the delete — deleted from another window, or mid-spawn
59    /// — and used to spawn on the shared login with no identity at all; it
60    /// is refused instead (#3577).
61    AgentDeleted { agent_id: String },
62}
63
64impl std::fmt::Display for SpawnGateError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            // Starts "this agent's" so muxspect's `classify_last_error_source`
68            // files it under "identity" with the other gate refusals, and
69            // carries "was deleted, so this pane cannot start" — the phrase
70            // `agents/failure.rs` classifies it by (no Retry: a retry is
71            // refused identically).
72            SpawnGateError::AgentDeleted { agent_id } => write!(
73                f,
74                "this agent's definition ({agent_id}) was deleted, so this pane cannot \
75                 start. Close the pane, or launch another agent in it.",
76            ),
77            // "Bind an account in Connectors" is now the ONLY path — the
78            // ambient/"use global CLI login" opt-in this used to also
79            // suggest was retired (PLAN_LOGIN_SINGLE_PATH_CONSOLIDATION_
80            // 2026_07_20.md §7): it let a credential the app couldn't
81            // attribute to any account run indefinitely, invisible to
82            // Connectors. Don't point users at a toggle that no longer works.
83            SpawnGateError::MissingCredentials { provider } => write!(
84                f,
85                "no credentials for {}: the bound account was deleted or is \
86                 unresolvable. Bind an account for this provider in Connectors → Accounts.",
87                provider,
88            ),
89            SpawnGateError::InjectionUnavailable { detail } => write!(
90                f,
91                "credential injection could not run ({detail}); the spawn was \
92                 refused rather than falling back to the global CLI login. \
93                 Retry, and check `muxlog auth` if it persists.",
94            ),
95            SpawnGateError::AmbientHomeDirNotAllowed { provider, dir } => write!(
96                f,
97                "this agent's {provider} identity points directly at your personal \
98                 {provider} config directory ({dir}) instead of an isolated AgentMux \
99                 account — AgentMux no longer allows spawning an agent against your \
100                 own global CLI login. Re-bind this identity to an isolated account \
101                 in Connectors → Accounts (delete the current {provider} account and log \
102                 in again to create a fresh, isolated one), then retry.",
103            ),
104            SpawnGateError::ClaudeMdSeedFailed { provider, dir, error } => write!(
105                f,
106                "could not isolate this agent's {provider} config directory ({dir}): \
107                 {error}. Refusing to spawn with an unprotected config dir — retry, \
108                 and check the directory's permissions if it persists.",
109            ),
110        }
111    }
112}
113
114impl std::error::Error for SpawnGateError {}
115
116/// Errors specific to the resolver. Every variant is recoverable
117/// (the spawn proceeds with whatever env vars resolved successfully)
118/// — they exist for tracing visibility, not control flow.
119#[derive(Debug, thiserror::Error)]
120pub enum ResolverError {
121    #[error("env var not set in srv environment: {0}")]
122    EnvVarMissing(String),
123
124    #[error("AWS Secrets Manager backend not yet supported (Phase 3)")]
125    SecretsManagerUnsupported,
126
127    #[error("PlaintextDev secrets are disabled in release builds")]
128    #[cfg(not(debug_assertions))]
129    PlaintextDevDisabledInRelease,
130
131    /// `OAuthConfigDir` is a filesystem pointer, not a secret string —
132    /// `resolve_secret` cannot turn it into a credential value because
133    /// the credential lives in a CLI-managed token file inside the dir.
134    /// Oauth-class providers must be routed through the config-dir
135    /// injection path that PR B adds to `inject_identity_env`. Seeing
136    /// this error from `resolve_secret` means the caller forgot to
137    /// dispatch by provider class first.
138    #[error("OAuthConfigDir is a config-dir pointer, not a resolvable secret — routed via the oauth-class injection path, not resolve_secret")]
139    OAuthConfigDirNotASecret,
140
141    /// The OS keychain read failed (no entry, locked store, or no Secret
142    /// Service agent). Account API keys (`SecretRef::Keychain`) live
143    /// in the OS keychain; this surfaces a resolution failure at spawn.
144    #[error("keychain error: {0}")]
145    KeychainError(String),
146
147    #[error("storage error: {0}")]
148    Storage(#[from] StoreError),
149}