Skip to main content

agentmux_srv/server/
identity_auth_dirs.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Filesystem/directory provisioning for the pre-launch OAuth flow.
5//!
6//! Split out of `identity_handlers.rs` (module-organization pass, see
7//! `docs/specs/PLAN_LOGIN_SINGLE_PATH_CONSOLIDATION_2026_07_20.md`) —
8//! owns `compute_and_ensure_bundle_dir` (vestigial, bundle mode was
9//! retired in Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md,
10//! kept only so the wire shape and its one call site in
11//! `identity_handlers::register_identity_handlers` don't need touching)
12//! and `compute_and_ensure_account_dir` (the live direct-account path).
13
14use crate::backend::providers::{get_provider, ProviderConfig};
15use crate::backend::storage::store::Store;
16
17/// Best-effort: if `provider` has a linkable native history directory
18/// ([`ProviderConfig::history_native_subdir`], `None` for providers with
19/// no fixed on-disk history to redirect) and credential isolation is
20/// currently on, ensure `dir/<subdir>` is linked to the always-global
21/// history location for `entity_id` (a bundle id or account id — same
22/// namespace, see [`DataPaths::identity_dir`]). No-op when isolation is
23/// off (`dir` is already inside the global tree, so `dir/<subdir>`
24/// already IS the global location) or the provider has no linkable
25/// subdir. Never fails the caller — logs and continues, same philosophy
26/// as every other soft-fail in this module.
27///
28/// Three call sites need this identically: `compute_and_ensure_bundle_dir`
29/// and `compute_and_ensure_account_dir` below (both `auth.start`-time),
30/// and `inject::inject_identity_env_with_broker`'s OAuth branch (the
31/// ordinary spawn-time path, which reads a *previously stored*
32/// `OAuthConfigDir` and — before this shared helper existed — never
33/// re-verified the link at all; reagentx P1 on PR #2605: an account
34/// provisioned before this whole feature shipped, or since its last
35/// `auth.start`, never got linked until this fix). One function so they
36/// can't drift on the condition again.
37pub(crate) fn link_history_if_isolated(
38    paths: &agentmux_common::DataPaths,
39    dir: &std::path::Path,
40    provider: &ProviderConfig,
41    entity_id: &str,
42    log_ctx: &str,
43) {
44    let Some(subdir) = provider.history_native_subdir else {
45        return;
46    };
47    if !agentmux_common::isolated_auth_enabled() {
48        return;
49    }
50    // reagentx P2 / chatgpt-codex-connector on PR #2605: `identity_dir`'s
51    // safe-path-segment rejection (empty / `.` / `..` / a segment with
52    // `/`, `\`, a drive-letter colon, …) is validated HERE, once, for
53    // every caller — `identity_auth_dirs.rs`'s own two call sites happen
54    // to already validate `entity_id` earlier in their own function body
55    // (via this same check) before ever reaching this point, but
56    // `inject.rs`'s ordinary-spawn call site reads `entity_id` straight
57    // from a stored `binding.account_id` with no such upstream check.
58    // Rather than trust every current AND future caller to remember
59    // this, `identity_history_dir`'s own documented precondition is
60    // enforced right here instead.
61    if paths.identity_dir(entity_id).is_none() {
62        tracing::warn!(
63            target: "identity",
64            provider_id = provider.id,
65            entity_id,
66            "{log_ctx}: entity_id is not a safe path segment — skipping history link"
67        );
68        return;
69    }
70    if let Err(e) = agentmux_common::ensure_history_link(
71        &dir.join(subdir),
72        &paths.identity_history_dir(entity_id, provider.auth_dir_name, subdir),
73    ) {
74        tracing::warn!(
75            target: "identity",
76            provider_id = provider.id,
77            entity_id,
78            error = %e,
79            "{log_ctx}: failed to link conversation history to the global location — \
80             this history may not survive a channel/build change"
81        );
82    }
83}
84
85/// Compute the per-bundle OAuth config dir + ensure it exists +
86/// override the provider's `auth_config_dir_env_var` entry in `auth_env`.
87///
88/// Returns `Some(<absolute path string>)` when:
89///   - `into_bundle_id` is `Some` and non-empty AND
90///   - the provider is registered in the CLI provider registry AND
91///   - the provider declares an `auth_config_dir_env_var` (oauth-class
92///     providers per `identity::resolver::provider_class` — claude / codex /
93///     openclaw / gemini / copilot as of
94///     REPORT_AUTH_ARCHITECTURE_STATE_AND_RETHINK_2026_07_21.md §2.5/§6) AND
95///   - `DataPaths::from_env()` resolves AND
96///   - `create_dir_all` succeeds.
97///
98/// Otherwise returns `None` and leaves `auth_env` untouched. Callers
99/// must continue without per-bundle isolation (legacy ambient path,
100/// or skip the binding-persist step) — never abort the OAuth start.
101///
102/// Per `docs/specs/archive/SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md` §4.5: the dir
103/// (and the env-var key) come from the CLI provider registry
104/// (`backend::providers::get_provider(id)`) so the resolver / spawn
105/// path / OAuth-start handler never drift on which env var redirects
106/// each CLI's config home. The single source of truth lives in
107/// `crates/srv/src/backend/providers.rs`.
108pub(crate) fn compute_and_ensure_bundle_dir(
109    into_bundle_id: Option<&str>,
110    provider_id: &str,
111    auth_env: &mut std::collections::HashMap<String, String>,
112) -> Option<String> {
113    let bundle_id = into_bundle_id.filter(|s| !s.is_empty())?;
114    // Gate on provider_class so api-key-class providers (which have a
115    // registry entry with a non-empty `auth_config_dir_env_var` —
116    // e.g. kimi's `KIMI_SHARE_DIR`) never go through the per-bundle
117    // OAuth-dir path. Only providers `provider_class` classifies as
118    // OAuth-class (claude / codex / openclaw / gemini / copilot) get the
119    // per-bundle override — see that function's own doc comment for the
120    // current, authoritative set.
121    match crate::identity::resolver::provider_class(provider_id) {
122        Some(crate::identity::resolver::ProviderClass::OAuth { .. }) => {}
123        _ => return None,
124    }
125    let provider = match get_provider(provider_id) {
126        Some(p) => p,
127        None => {
128            // OAuth-class per provider_class but missing from the CLI
129            // registry — should be impossible (resolver reads the env
130            // var from the registry itself), but treat as a soft fail.
131            tracing::warn!(
132                target: "identity",
133                provider_id,
134                "auth.start: oauth-class provider not in registry — skipping per-bundle dir override"
135            );
136            return None;
137        }
138    };
139    // Empty env-var name → no isolation possible (oauth-class providers
140    // should never have this empty per spec, but belt-and-braces).
141    if provider.auth_config_dir_env_var.is_empty() {
142        return None;
143    }
144    let paths = match agentmux_common::DataPaths::from_env() {
145        Some(p) => p,
146        None => {
147            tracing::warn!(
148                target: "identity",
149                provider_id,
150                bundle_id,
151                "auth.start: DataPaths::from_env() returned None — skipping per-bundle dir override"
152            );
153            return None;
154        }
155    };
156    // identity_dir rejects unsafe path segments (empty / `.` / `..` /
157    // segment with `/`, `\`, drive-letter, …). bundle_id comes from
158    // the auth.start request body, so this guard prevents a crafted
159    // id from escaping the identities root.
160    let bundle_root = match paths.identity_dir(bundle_id) {
161        Some(p) => p,
162        None => {
163            tracing::warn!(
164                target: "identity",
165                provider_id,
166                bundle_id,
167                "auth.start: bundle_id is not a safe path segment — skipping per-bundle dir override"
168            );
169            return None;
170        }
171    };
172    let dir = bundle_root.join(provider.auth_dir_name);
173    if let Err(e) = std::fs::create_dir_all(&dir) {
174        tracing::warn!(
175            target: "identity",
176            provider_id,
177            bundle_id,
178            error = %e,
179            path = %dir.display(),
180            "auth.start: failed to create per-bundle config dir — skipping override"
181        );
182        return None;
183    }
184    link_history_if_isolated(&paths, &dir, provider, bundle_id, "auth.start");
185    let dir_str = dir.to_string_lossy().to_string();
186    // Override (or insert) the provider's config-dir env var. The
187    // frontend may have computed the legacy ambient dir via
188    // `ensureAuthDir(providerId)` and put it here under the same key;
189    // we replace it with the per-bundle dir so the OAuth CLI writes
190    // its tokens inside the bundle, not in the ambient version-config
191    // dir.
192    auth_env.insert(
193        provider.auth_config_dir_env_var.to_string(),
194        dir_str.clone(),
195    );
196    tracing::info!(
197        target: "identity",
198        provider_id,
199        bundle_id,
200        env_var = provider.auth_config_dir_env_var,
201        dir = %dir.display(),
202        "auth.start: per-bundle OAuth config dir wired"
203    );
204    Some(dir_str)
205}
206
207/// Direct-account sibling of `compute_and_ensure_bundle_dir` (issue
208/// #1624 PR-C Part B) — bypasses the bundle system entirely. Mints a
209/// fresh `account_id` when `existing_account_id` is empty (first-time
210/// OAuth connect from the launch modal); reuses it when non-empty
211/// (Reconnect — refresh tokens in place, same isolation dir, same
212/// account row updated in place).
213///
214/// Every fresh mint (`existing_account_id` empty) is swept for BEFORE it,
215/// giving prior abandoned/failed attempts a chance to be cleaned up
216/// (reagent finding on #2260 — see `sweep_orphaned_account_dirs`'s own
217/// doc comment). 30 minutes comfortably clears every frontend poll
218/// timeout in this flow (5 minutes, as of this writing) plus room for a
219/// slow user plus one retry, so an in-progress login's dir is never
220/// swept out from under it.
221const ORPHAN_SWEEP_MIN_AGE_SECS: u64 = 30 * 60;
222
223/// Returns `(account_id, dir)` — `account_id` is always populated (even
224/// on a dir-resolution failure, so the caller can still log/track it);
225/// `dir` is `None` on the same gate/registry/fs failures
226/// `compute_and_ensure_bundle_dir` treats as soft failures — never
227/// abort `auth.start` over a config-dir issue.
228pub(crate) fn compute_and_ensure_account_dir(
229    store: &Store,
230    existing_account_id: &str,
231    provider_id: &str,
232    auth_env: &mut std::collections::HashMap<String, String>,
233) -> (String, Option<String>) {
234    let account_id = if existing_account_id.is_empty() {
235        uuid::Uuid::new_v4().to_string()
236    } else {
237        existing_account_id.to_string()
238    };
239
240    // Same provider_class gate as the bundle path — only providers
241    // `provider_class` classifies as OAuth-class (claude / codex /
242    // openclaw / gemini / copilot as of
243    // REPORT_AUTH_ARCHITECTURE_STATE_AND_RETHINK_2026_07_21.md §2.5/§6)
244    // get a per-account isolation dir.
245    match crate::identity::resolver::provider_class(provider_id) {
246        Some(crate::identity::resolver::ProviderClass::OAuth { .. }) => {}
247        _ => return (account_id, None),
248    }
249    let provider = match get_provider(provider_id) {
250        Some(p) => p,
251        None => {
252            tracing::warn!(
253                target: "identity",
254                provider_id,
255                account_id,
256                "auth.start (direct-account): oauth-class provider not in registry — skipping config dir"
257            );
258            return (account_id, None);
259        }
260    };
261    if provider.auth_config_dir_env_var.is_empty() {
262        return (account_id, None);
263    }
264    let paths = match agentmux_common::DataPaths::from_env() {
265        Some(p) => p,
266        None => {
267            tracing::warn!(
268                target: "identity",
269                provider_id,
270                account_id,
271                "auth.start (direct-account): DataPaths::from_env() returned None — skipping config dir"
272            );
273            return (account_id, None);
274        }
275    };
276    if existing_account_id.is_empty() {
277        let removed = crate::identity::cleanup::sweep_orphaned_account_dirs(
278            store,
279            &paths.identities_dir(),
280            ORPHAN_SWEEP_MIN_AGE_SECS,
281        );
282        if !removed.is_empty() {
283            tracing::info!(
284                target: "identity",
285                provider_id,
286                account_id,
287                swept = removed.len(),
288                "auth.start (direct-account): swept orphaned account dirs before minting a fresh one"
289            );
290        }
291    }
292    // identity_dir is already generic (not bundle-specific) — same
293    // unsafe-path-segment rejection applies to account_id here.
294    let account_root = match paths.identity_dir(&account_id) {
295        Some(p) => p,
296        None => {
297            tracing::warn!(
298                target: "identity",
299                provider_id,
300                account_id,
301                "auth.start (direct-account): account_id is not a safe path segment — skipping config dir"
302            );
303            return (account_id, None);
304        }
305    };
306    let dir = account_root.join(provider.auth_dir_name);
307    if let Err(e) = std::fs::create_dir_all(&dir) {
308        tracing::warn!(
309            target: "identity",
310            provider_id,
311            account_id,
312            error = %e,
313            path = %dir.display(),
314            "auth.start (direct-account): failed to create config dir — skipping"
315        );
316        return (account_id, None);
317    }
318    link_history_if_isolated(&paths, &dir, provider, &account_id, "auth.start (direct-account)");
319    let dir_str = dir.to_string_lossy().to_string();
320    auth_env.insert(provider.auth_config_dir_env_var.to_string(), dir_str.clone());
321    tracing::info!(
322        target: "identity",
323        provider_id,
324        account_id,
325        env_var = provider.auth_config_dir_env_var,
326        dir = %dir.display(),
327        "auth.start (direct-account): OAuth config dir wired"
328    );
329    (account_id, Some(dir_str))
330}
331
332/// The account directory a caller already chose, as `auth_env` names it under
333/// the provider's `auth_config_dir_env_var`: the key that keeps a login the
334/// caller persists itself (not `direct_account`) to one live session per
335/// directory. `None` for a provider without one, or when it isn't set.
336pub(crate) fn caller_auth_dir(
337    provider_id: &str,
338    auth_env: &std::collections::HashMap<String, String>,
339) -> Option<String> {
340    let var = get_provider(provider_id)?.auth_config_dir_env_var;
341    if var.is_empty() {
342        return None;
343    }
344    auth_env.get(var).filter(|d| !d.is_empty()).cloned()
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn caller_auth_dir_reads_the_providers_own_variable() {
353        let mut env = std::collections::HashMap::new();
354        assert_eq!(caller_auth_dir("claude", &env), None);
355        env.insert("CLAUDE_CONFIG_DIR".to_string(), "/accounts/a".to_string());
356        env.insert("CODEX_HOME".to_string(), "/accounts/b".to_string());
357        assert_eq!(caller_auth_dir("claude", &env).as_deref(), Some("/accounts/a"));
358        assert_eq!(caller_auth_dir("codex", &env).as_deref(), Some("/accounts/b"));
359        env.insert("CLAUDE_CONFIG_DIR".to_string(), String::new());
360        assert_eq!(caller_auth_dir("claude", &env), None);
361        assert_eq!(caller_auth_dir("no-such-provider", &env), None);
362    }
363
364    // `AGENTMUX_HOME_OVERRIDE` is one of the process-global env vars
365    // `crate::test_support::ISOLATED_AUTH_ENV_LOCK` exists specifically to
366    // guard — a module-local lock here only serializes tests WITHIN this
367    // module; `cargo test` runs a crate's tests in one multi-threaded
368    // process, so a local-only lock still let this module's tests race
369    // against any other module's tests touching the same var (confirmed
370    // live: `server::app_api::bundle_self_get_registry_fallback_tests`
371    // and `server::native_memory_handlers::tests`, both added for
372    // SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md, intermittently
373    // failed this module's `compute_account_dir_mints_fresh_id_when_
374    // existing_is_empty` in full-suite runs — passed every time alone).
375    // Reusing the crate-wide lock here closes that gap.
376    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
377
378    #[test]
379    fn compute_account_dir_mints_fresh_id_when_existing_is_empty() {
380        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
381        let tmp = tempfile::TempDir::new().unwrap();
382        std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
383        let paths = agentmux_common::DataPaths::resolve(
384            "0.0.0-test",
385            &agentmux_common::RuntimeMode::Installed,
386        )
387        .unwrap();
388        paths.ensure_dirs().unwrap();
389        for (k, v) in paths.to_env_vars() {
390            std::env::set_var(k, v);
391        }
392
393        let mstore = Store::open_in_memory().unwrap();
394        let mut env = std::collections::HashMap::new();
395        let (account_id, dir) = compute_and_ensure_account_dir(&mstore, "", "claude", &mut env);
396        assert!(!account_id.is_empty(), "must mint a fresh id when none supplied");
397        let dir = dir.expect("oauth-class provider must yield a dir");
398        let expected = paths.identity_dir(&account_id).unwrap().join("claude");
399        assert_eq!(std::path::Path::new(&dir), expected);
400        assert!(expected.is_dir());
401        assert_eq!(env.get("CLAUDE_CONFIG_DIR").map(String::as_str), Some(dir.as_str()));
402
403        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
404        for (k, _) in paths.to_env_vars() {
405            std::env::remove_var(k);
406        }
407    }
408
409    #[test]
410    fn compute_account_dir_reuses_existing_id() {
411        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
412        let tmp = tempfile::TempDir::new().unwrap();
413        std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
414        let paths = agentmux_common::DataPaths::resolve(
415            "0.0.0-test",
416            &agentmux_common::RuntimeMode::Installed,
417        )
418        .unwrap();
419        paths.ensure_dirs().unwrap();
420        for (k, v) in paths.to_env_vars() {
421            std::env::set_var(k, v);
422        }
423
424        let mstore = Store::open_in_memory().unwrap();
425        let mut env = std::collections::HashMap::new();
426        let (account_id, _) = compute_and_ensure_account_dir(&mstore, "acc-reconnect", "claude", &mut env);
427        assert_eq!(account_id, "acc-reconnect", "reconnect must reuse the supplied id, not mint a new one");
428
429        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
430        for (k, _) in paths.to_env_vars() {
431            std::env::remove_var(k);
432        }
433    }
434
435    // docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md
436    // §4.1: when the credential dir is per-channel isolated, its
437    // `projects/` subpath must become a link to the always-global
438    // history location, not a real (fresh, empty, orphan-prone)
439    // directory. This is the actual end-to-end wiring test — the
440    // `ensure_history_link` unit tests in `agentmux-common` only prove
441    // the linking primitive works in isolation, not that this call site
442    // actually invokes it with the right paths.
443    #[test]
444    fn compute_account_dir_links_projects_to_the_global_history_location_when_isolated() {
445        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
446        let tmp = tempfile::TempDir::new().unwrap();
447        std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
448        std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
449        let paths = agentmux_common::DataPaths::resolve(
450            "0.0.0-test",
451            &agentmux_common::RuntimeMode::Installed,
452        )
453        .unwrap();
454        paths.ensure_dirs().unwrap();
455        for (k, v) in paths.to_env_vars() {
456            std::env::set_var(k, v);
457        }
458        assert!(agentmux_common::isolated_auth_enabled(), "precondition: this test needs isolation actually on");
459
460        let mstore = Store::open_in_memory().unwrap();
461        let mut env = std::collections::HashMap::new();
462        let (account_id, dir) = compute_and_ensure_account_dir(&mstore, "", "claude", &mut env);
463        let dir = std::path::PathBuf::from(dir.expect("oauth-class provider must yield a dir"));
464        assert!(
465            dir.starts_with(&paths.instance_dir),
466            "precondition: the credential dir must actually be the per-channel isolated one, not the global one, or this test isn't exercising the code path it claims to"
467        );
468
469        let global_history = paths.identity_history_dir(&account_id, "claude", "projects");
470        std::fs::write(global_history.join("real-session.jsonl"), b"a real session").unwrap();
471        let via_isolated_path = std::fs::read(dir.join("projects").join("real-session.jsonl"))
472            .expect("a file written at the global history location must be readable through the isolated credential dir's projects/ subpath");
473        assert_eq!(via_isolated_path, b"a real session");
474
475        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
476        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
477        for (k, _) in paths.to_env_vars() {
478            std::env::remove_var(k);
479        }
480    }
481
482    // reagentx P2 / chatgpt-codex-connector on PR #2605: link_history_if_isolated
483    // must reject an unsafe entity_id itself (defense in depth), not
484    // rely on every caller having already validated it upstream —
485    // inject.rs's spawn-path call site passes a stored account_id with
486    // no such upstream check.
487    #[test]
488    fn link_history_if_isolated_rejects_an_unsafe_entity_id() {
489        let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
490        let tmp = tempfile::TempDir::new().unwrap();
491        std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
492        std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
493        let paths = agentmux_common::DataPaths::resolve(
494            "0.0.0-test",
495            &agentmux_common::RuntimeMode::Installed,
496        )
497        .unwrap();
498        paths.ensure_dirs().unwrap();
499        for (k, v) in paths.to_env_vars() {
500            std::env::set_var(k, v);
501        }
502
503        let provider = get_provider("claude").unwrap();
504        let dir = tmp.path().join("some-credential-dir");
505        std::fs::create_dir_all(&dir).unwrap();
506        // Must not panic, must not create anything under
507        // shared/identities/../ — an unsafe segment is simply skipped.
508        link_history_if_isolated(&paths, &dir, provider, "../escape", "test");
509        assert!(
510            !paths.shared_dir.join("identities").parent().unwrap().join("escape").exists(),
511            "an unsafe entity_id must never be used to construct a real filesystem path"
512        );
513
514        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
515        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
516        for (k, _) in paths.to_env_vars() {
517            std::env::remove_var(k);
518        }
519    }
520
521    #[test]
522    fn compute_account_dir_skipped_for_api_key_provider() {
523        // Same provider_class gate as the bundle path — dir is None,
524        // but the account id is still returned (unlike bundle mode,
525        // there's no "skip entirely" case here — the account always
526        // gets minted/reused, only the isolation dir is conditional).
527        let mstore = Store::open_in_memory().unwrap();
528        let mut env = std::collections::HashMap::new();
529        let (account_id, dir) = compute_and_ensure_account_dir(&mstore, "", "kimi", &mut env);
530        assert!(!account_id.is_empty());
531        assert!(dir.is_none(), "api-key provider class must skip the OAuth dir path");
532        assert!(env.get("KIMI_SHARE_DIR").is_none());
533    }
534}