Skip to main content

agentmux_srv/server/
identity_auth_persist.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `IdentityAccount` persistence on a successful OAuth handshake.
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//! called from `identity_auth_spawn`'s drain/post-exit success paths
9//! (`spawn_auth_cli` + `spawn_auth_cli_pty`, two call sites each).
10
11use std::sync::Arc;
12
13use crate::backend::storage::store::{IdentityAccount, SecretRef, Store};
14use crate::backend::mps::Broker;
15
16/// Upserts the `IdentityAccount` (`SecretRef::OAuthConfigDir`, status
17/// "valid") on a successful OAuth handshake (CLI exited 0 +
18/// authCheckCommand confirmed). The actual `agent_identity_link` write
19/// happens later, once the agent exists (the launch-flow write-through
20/// reconcile) — this function only makes sure the account itself exists
21/// and is ready to be linked.
22///
23/// Publishes `identityaccounts:changed` (the same broad event
24/// `account.key.verify`/`upsertidentityaccount` already use) rather
25/// than a bundle-scoped event, since there's no bundle id to scope to.
26///
27/// Returns `None` on any persistence failure (dir never resolved, or
28/// the account upsert itself failed) — same "log + skip, session still
29/// succeeds" contract as the bundle path, just without a synthetic
30/// placeholder to fall back to (direct-account mode has no "ambient"
31/// concept to fall back to; the caller surfaces `account_id: None` on
32/// the wire and the frontend treats that as "nothing to select").
33fn persist_oauth_direct_account(
34    mstore: &Arc<Store>,
35    identity_store: &Arc<Store>,
36    broker: &Arc<Broker>,
37    account_id: &str,
38    provider_id: &str,
39    dir: Option<&str>,
40    _session_id: &str,
41    email: Option<&str>,
42) -> Option<String> {
43    let dir = match dir.filter(|s| !s.is_empty()) {
44        Some(d) => d,
45        None => {
46            tracing::warn!(
47                target: "identity",
48                account_id,
49                provider_id,
50                "auth success (direct-account): dir unresolved — skipping account persist"
51            );
52            return None;
53        }
54    };
55    let now = agentmux_common::time::now_ms();
56    let account = IdentityAccount {
57        id: account_id.to_string(),
58        name: format!("{provider_id}-oauth"),
59        provider: provider_id.to_string(),
60        kind: "oauth".to_string(),
61        // `display_name` is deliberately left for the USER to set — see
62        // SPEC_ACCOUNT_EMAIL_IN_ARMORY_2026_09_23.md §2. The login email is
63        // provider metadata and belongs in `context`, so naming an account
64        // "work" later cannot clobber it and vice versa.
65        display_name: String::new(),
66        secret_ref: SecretRef::OAuthConfigDir { dir: dir.to_string() },
67        // The key is OMITTED, not set empty, when the provider reported no
68        // email — so "this provider does not surface one" stays
69        // distinguishable from "logged in as a blank address" downstream.
70        // Falls back to the email the CLI recorded in the account's own
71        // config dir (Claude's login transcript prints none) — §4.
72        context: match email
73            .map(str::trim)
74            .filter(|e| !e.is_empty())
75            .map(str::to_string)
76            .or_else(|| crate::identity::account_email::email_from_oauth_dir(provider_id, dir))
77        {
78            Some(e) => serde_json::json!({ "email": e }),
79            None => serde_json::json!({}),
80        },
81        // Same rationale as the bundle path: a binding the user JUST
82        // OAuth'd into is `valid` by definition.
83        status: crate::identity::resolver::oauth_status::VALID.to_string(),
84        created_at: now,
85        updated_at: now,
86    };
87    // identity_upsert_with_mirror — reagentx P0 review on PR #2632: this
88    // is THE primary OAuth account-creation path (auth.start), so without
89    // the mirror write every newly-OAuth'd account had no fallback entry
90    // and reproduced the reported bug on its own next channel switch.
91    if let Err(e) = mstore.identity_upsert_with_mirror(identity_store, &account) {
92        tracing::warn!(
93            target: "identity",
94            account_id,
95            provider_id,
96            error = %e,
97            "auth success (direct-account): identity_upsert failed"
98        );
99        return None;
100    }
101    broker.publish_event::<crate::backend::mps_events::IdentityAccountsChanged>(&(), vec![], 0);
102    tracing::info!(
103        target: "identity",
104        account_id,
105        provider_id,
106        dir,
107        "auth success (direct-account): OAuth account persisted"
108    );
109    Some(account_id.to_string())
110}
111
112/// Shared by all 4 OAuth-success call sites (pipes drain/post-exit, PTY
113/// drain/post-exit) — persists the account and builds the
114/// `(bundle_id, account_id)` pair `AuthSessionManager::finish_success`
115/// expects. `bundle_id` is always empty now: bundle mode (`db_identity_bundles`
116/// binding) was retired in Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md
117/// — confirmed unreachable from the frontend (`AuthFlowController` hardcodes
118/// `directAccount: true`). `_direct_account`/`_into_bundle_id` stay as
119/// parameters so the wire request shape and the 4 call sites don't need
120/// touching. `dir` is the account's own isolation dir, resolved once at
121/// spawn time by `compute_and_ensure_*_dir` in the `auth.start` handler.
122///
123/// Guards on `account_id` being non-empty before persisting: `auth.start`
124/// only populates a real account_id when `direct_account` is true (via
125/// `compute_and_ensure_account_dir`, which always mints/reuses a real id);
126/// when `direct_account` is false (the wire default, still reachable by
127/// any caller other than the one production frontend path), `account_id`
128/// is `""`. Without this guard an empty id would flow into
129/// `persist_oauth_direct_account`'s `identity_upsert`, whose
130/// `ON CONFLICT(id) DO UPDATE` would silently overwrite/corrupt any prior
131/// row that happened to have `id=""`. Reagent P1.
132#[allow(clippy::too_many_arguments)]
133pub(crate) fn persist_oauth_success(
134    mstore: &Arc<Store>,
135    identity_store: &Arc<Store>,
136    broker: &Arc<Broker>,
137    _direct_account: bool,
138    account_id: &str,
139    _into_bundle_id: Option<&str>,
140    provider_id: &str,
141    dir: Option<&str>,
142    session_id: &str,
143    email: Option<&str>,
144) -> (String, Option<String>) {
145    if account_id.is_empty() {
146        return (String::new(), None);
147    }
148    let persisted = persist_oauth_direct_account(mstore, identity_store, broker, account_id, provider_id, dir, session_id, email);
149    (String::new(), persisted)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn persist_oauth_direct_account_round_trip() {
158        let mstore = Arc::new(Store::open_in_memory().unwrap());
159        let identity_store = Arc::new(Store::open_in_memory().unwrap());
160        let broker = Arc::new(crate::backend::mps::Broker::new());
161        let r = persist_oauth_direct_account(
162            &mstore,
163            &identity_store,
164            &broker,
165            "acc-1",
166            "claude",
167            Some("/some/account/dir"),
168            "sess-z",
169            None,
170        );
171        assert_eq!(r, Some("acc-1".to_string()));
172
173        let acct = mstore.identity_get("acc-1").unwrap().expect("account row exists");
174        assert_eq!(acct.provider, "claude");
175        assert_eq!(acct.kind, "oauth");
176        assert_eq!(acct.status, "valid");
177        match acct.secret_ref {
178            SecretRef::OAuthConfigDir { dir } => assert_eq!(dir, "/some/account/dir"),
179            other => panic!("expected OAuthConfigDir, got {other:?}"),
180        }
181    }
182
183    #[test]
184    fn persist_oauth_direct_account_returns_none_when_dir_unresolved() {
185        let mstore = Arc::new(Store::open_in_memory().unwrap());
186        let identity_store = Arc::new(Store::open_in_memory().unwrap());
187        let broker = Arc::new(crate::backend::mps::Broker::new());
188        let r = persist_oauth_direct_account(&mstore, &identity_store, &broker, "acc-1", "claude", None, "sess-z", None);
189        assert!(r.is_none());
190        assert!(mstore.identity_get("acc-1").unwrap().is_none(), "nothing persisted when dir is unresolved");
191    }
192
193    // SPEC_ACCOUNT_EMAIL_IN_ARMORY_2026_09_23.md. The login email is provider
194    // metadata and goes in `context`, NOT `display_name` — that field belongs
195    // to the user, so writing an email there would clobber any label they set
196    // (and vice versa).
197    #[test]
198    fn persist_records_the_login_email_in_context_leaving_display_name_free() {
199        let mstore = Arc::new(Store::open_in_memory().unwrap());
200        let identity_store = Arc::new(Store::open_in_memory().unwrap());
201        let broker = Arc::new(crate::backend::mps::Broker::new());
202        persist_oauth_direct_account(
203            &mstore,
204            &identity_store,
205            &broker,
206            "acc-1",
207            "claude",
208            Some("/dir"),
209            "sess-z",
210            Some("user@example.com"),
211        );
212        let acct = mstore.identity_get("acc-1").unwrap().expect("account row");
213        assert_eq!(acct.context["email"], "user@example.com");
214        assert_eq!(acct.display_name, "", "display_name stays the user's to set");
215    }
216
217    // Absent, not empty. A provider whose CLI reports no email must stay
218    // distinguishable from one that reported a blank address — otherwise the
219    // Connectors cannot tell "nothing to show" from "logged in as ''".
220    #[test]
221    fn persist_omits_the_email_key_entirely_when_none_was_captured() {
222        let mstore = Arc::new(Store::open_in_memory().unwrap());
223        let identity_store = Arc::new(Store::open_in_memory().unwrap());
224        let broker = Arc::new(crate::backend::mps::Broker::new());
225        for email in [None, Some(""), Some("   ")] {
226            let id = format!("acc-{}", email.unwrap_or("none").len());
227            persist_oauth_direct_account(
228                &mstore, &identity_store, &broker, &id, "claude", Some("/dir"), "sess-z", email,
229            );
230            let acct = mstore.identity_get(&id).unwrap().expect("account row");
231            assert!(
232                acct.context.get("email").is_none(),
233                "blank email {email:?} must not create the key"
234            );
235        }
236    }
237
238    #[test]
239    fn persist_oauth_success_always_routes_direct_account_mode() {
240        // Bundle mode was retired in Phase 4c of
241        // SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md — persist_oauth_success
242        // always persists a direct account now, regardless of the
243        // (now-vestigial) direct_account/into_bundle_id parameters.
244        let mstore = Arc::new(Store::open_in_memory().unwrap());
245        let identity_store = Arc::new(Store::open_in_memory().unwrap());
246        let broker = Arc::new(crate::backend::mps::Broker::new());
247        let (bundle_id, account_id) = persist_oauth_success(
248            &mstore,
249            &identity_store,
250            &broker,
251            true,
252            "acc-1",
253            None,
254            "claude",
255            Some("/some/dir"),
256            "sess-route",
257            None,
258        );
259        assert_eq!(bundle_id, "", "bundle id is always empty now");
260        assert_eq!(account_id, Some("acc-1".to_string()));
261        assert!(mstore.identity_get("acc-1").unwrap().is_some());
262    }
263
264    #[test]
265    fn persist_oauth_success_skips_persistence_when_account_id_is_empty() {
266        // Reagent P1: `auth.start` sets account_id = "" whenever
267        // `direct_account` is false (the wire default) — a caller other
268        // than the one production frontend path (which always sends
269        // `directAccount: true`) can still reach this. Without the
270        // empty-id guard, persist_oauth_direct_account's identity_upsert
271        // would silently write/overwrite a db_accounts row with id="".
272        let mstore = Arc::new(Store::open_in_memory().unwrap());
273        let identity_store = Arc::new(Store::open_in_memory().unwrap());
274        let broker = Arc::new(crate::backend::mps::Broker::new());
275        let (bundle_id, account_id) = persist_oauth_success(
276            &mstore,
277            &identity_store,
278            &broker,
279            false,
280            "",
281            None,
282            "claude",
283            Some("/some/dir"),
284            "sess-empty",
285            None,
286        );
287        assert_eq!(bundle_id, "");
288        assert_eq!(account_id, None, "empty account_id must not be persisted");
289        assert!(
290            mstore.identity_get("").unwrap().is_none(),
291            "no row with id=\"\" should ever be written"
292        );
293    }
294}