Skip to main content

agentmux_srv/server/
identity_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pre-launch OAuth flow RPC handlers.
5//!
6//! Four commands per `docs/specs/SPEC_PRE_LAUNCH_OAUTH_FLOW_2026_05_14.md` §7
7//! (request types named after that section's RPCs), plus
8//! `identity.ensureaccountdir`:
9//!
10//!   * `auth.start`           — `StartProviderAuthReq`
11//!   * `auth.poll`            — `PollProviderAuthReq`
12//!   * `auth.submitcallback`  — `SubmitAuthCallbackReq`
13//!   * `auth.cancel`          — `CancelProviderAuthReq`
14//!
15//! The spec's fifth, `auth.submitapikey`, was removed: it was never built, and
16//! API-key accounts are added through `account.key.verify` and
17//! `upsertidentityaccount` instead.
18//!
19//! This module owns the RPC surface and dispatches into
20//! `crate::identity::auth_session::AuthSessionManager`. `auth.start` also
21//! spawns the provider CLI (`spawn_auth_cli`), which feeds stdout lines
22//! into the session via `record_line`.
23//!
24//! Split across four files (module-organization pass, see
25//! `docs/specs/PLAN_LOGIN_SINGLE_PATH_CONSOLIDATION_2026_07_20.md`):
26//! this file owns the RPC surface (wire types + `register_identity_handlers`);
27//! `identity_auth_spawn` owns the subprocess spawn/drain engine
28//! (`spawn_auth_cli`, `spawn_auth_cli_pty`, `confirm_authenticated`);
29//! `identity_auth_dirs` owns filesystem/directory provisioning
30//! (`compute_and_ensure_bundle_dir`, `compute_and_ensure_account_dir`);
31//! `identity_auth_persist` owns `IdentityAccount` persistence on OAuth
32//! success (`persist_oauth_direct_account`, `persist_oauth_success`).
33
34use std::sync::Arc;
35
36use serde::{Deserialize, Serialize};
37
38use crate::backend::rpc::engine::WshRpcEngine;
39
40use super::identity_auth_dirs::{caller_auth_dir, compute_and_ensure_account_dir, compute_and_ensure_bundle_dir};
41use super::identity_auth_spawn::spawn_auth_cli;
42use super::AppState;
43
44pub const COMMAND_AUTH_START: &str = "auth.start";
45pub const COMMAND_AUTH_POLL: &str = "auth.poll";
46pub const COMMAND_AUTH_SUBMIT_CALLBACK: &str = "auth.submitcallback";
47pub const COMMAND_AUTH_CANCEL: &str = "auth.cancel";
48/// Mints (or reuses) a per-account isolated config dir without spawning any
49/// CLI — a standalone entry point onto `compute_and_ensure_account_dir` for
50/// callers that need the dir BEFORE a credential exists in it — today the
51/// terminal-login tier, which must know where to point the login before it
52/// opens a terminal. (It previously also served `seed_provider_auth_from_global`,
53/// the copy-from-personal-`~/.claude` path removed 2026-08-31.) See
54/// `docs/specs/PLAN_LOGIN_SINGLE_PATH_CONSOLIDATION_2026_07_20.md` §7 —
55/// "single point, not global": every credential-bearing dir a running agent
56/// reads from must belong to a real `IdentityAccount` row, never the shared
57/// default dir.
58pub const COMMAND_ENSURE_ACCOUNT_DIR: &str = "identity.ensureaccountdir";
59
60#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
61#[ts(export, export_to = "../../../frontend/types/rpc/")]
62#[serde(rename_all = "camelCase")]
63pub struct StartProviderAuthReq {
64    pub provider_id: String,
65    /// Vestigial — bundle mode (a successful auth adding the account to
66    /// a named group of accounts) was retired in Phase 4c of
67    /// SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md. Kept on the wire
68    /// shape only; `direct_account` is always true in practice.
69    #[serde(default)]
70    #[ts(optional)]
71    pub into_bundle_id: Option<String>,
72    /// Always `true` in practice — `AuthFlowController` (the sole
73    /// frontend caller) hardcodes `directAccount: true`. A successful
74    /// auth persists a standalone `IdentityAccount`; the actual
75    /// agent<->account link is written later, once the agent exists, by
76    /// the launch-flow reconcile.
77    #[serde(default)]
78    pub direct_account: bool,
79    /// Direct-account reconnect: non-empty to refresh an already-
80    /// linked account's tokens in place (same isolation dir, same
81    /// account row updated). Empty mints a fresh account id. Ignored
82    /// unless `direct_account` is set.
83    #[serde(default)]
84    pub existing_account_id: String,
85    /// Resolved CLI path. The frontend calls `resolvecli` first to
86    /// install / locate the provider's CLI; the resulting path is
87    /// passed here. Keeps the provider table single-sourced in the
88    /// frontend.
89    pub cli_path: String,
90    /// e.g. `["auth", "login"]` from the provider definition's
91    /// `authLoginCommand` field.
92    pub auth_login_args: Vec<String>,
93    /// e.g. `["auth", "status", "--json"]` — used to confirm
94    /// authentication after the CLI emits its success line.
95    pub auth_check_args: Vec<String>,
96    /// Env vars to inject at spawn time. Per-provider auth
97    /// isolation env vars come here (CLAUDE_CONFIG_DIR, CODEX_HOME,
98    /// GEMINI_CLI_HOME, etc.).
99    #[serde(default)]
100    pub auth_env: std::collections::HashMap<String, String>,
101    /// Spawn the auth login subprocess under a PTY (instead of plain
102    /// piped stdio). Required by providers whose auth subcommand
103    /// checks `isatty()` and refuses to run otherwise (currently
104    /// OpenClaw's `models auth login --provider <id>`). The flag is
105    /// forwarded down to the CEF host's `run_cli_login` which picks
106    /// the PTY branch when set.
107    #[serde(default)]
108    pub requires_tty: bool,
109}
110
111#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
112#[ts(export, export_to = "../../../frontend/types/rpc/")]
113#[serde(rename_all = "camelCase")]
114pub struct PollProviderAuthReq {
115    pub session_id: String,
116}
117
118#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
119#[ts(export, export_to = "../../../frontend/types/rpc/")]
120#[serde(rename_all = "camelCase")]
121pub struct SubmitAuthCallbackReq {
122    pub session_id: String,
123    /// The redirect URL the user pasted back (browser-didn't-open
124    /// fallback). The handler writes it to the spawned CLI's stdin.
125    pub callback_url: String,
126}
127
128#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
129#[ts(export, export_to = "../../../frontend/types/rpc/")]
130#[serde(rename_all = "camelCase")]
131pub struct CancelProviderAuthReq {
132    pub session_id: String,
133}
134
135#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
136#[ts(export, export_to = "../../../frontend/types/rpc/")]
137#[serde(rename_all = "camelCase")]
138pub struct AckResp {
139    pub success: bool,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    #[ts(optional)]
142    pub error: Option<String>,
143}
144
145#[derive(Debug, Deserialize, Serialize, ts_rs::TS)]
146#[ts(export, export_to = "../../../frontend/types/rpc/")]
147#[serde(rename_all = "camelCase")]
148pub struct EnsureAccountDirReq {
149    pub provider_id: String,
150    /// Non-empty to resolve an already-minted account's own dir
151    /// (reconnect-in-place); empty mints a fresh account id.
152    #[serde(default)]
153    pub existing_account_id: String,
154}
155
156#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
157#[ts(export, export_to = "../../../frontend/types/rpc/")]
158#[serde(rename_all = "camelCase")]
159pub struct EnsureAccountDirResp {
160    pub account_id: String,
161    /// `None` when the provider isn't oauth-class or the dir couldn't be
162    /// resolved/created — same soft-failure contract as `auth.start`'s
163    /// internal use of `compute_and_ensure_account_dir`.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    #[ts(optional)]
166    pub dir: Option<String>,
167}
168
169pub fn register_identity_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
170    let mgr = state.auth_session_manager.clone();
171    let mstore = state.id_store.clone();
172    let identity_store = state.identity_store.clone();
173    let broker = state.broker.clone();
174    let mstore_for_ensure_dir = mstore.clone();
175    engine.register_typed(
176        COMMAND_AUTH_START,
177        move |req: StartProviderAuthReq, _ctx| {
178            let mgr = mgr.clone();
179            let mstore = mstore.clone();
180            let identity_store = identity_store.clone();
181            let broker = broker.clone();
182            async move {
183                tracing::info!(
184                    provider_id = %req.provider_id,
185                    cli_path = %req.cli_path,
186                    into_bundle_id = ?req.into_bundle_id,
187                    direct_account = req.direct_account,
188                    "auth.start"
189                );
190                // `direct_account` is the only path actually exercised
191                // (`AuthFlowController` hardcodes `directAccount: true`) —
192                // mints/reuses an account id and resolves its own
193                // isolation dir, mirroring the dir into `auth_env` under
194                // the provider's `auth_config_dir_env_var` (e.g.
195                // `CLAUDE_CONFIG_DIR`), overriding whatever the frontend
196                // computed via the legacy ambient `ensureAuthDir` path.
197                //
198                // The `into_bundle_id`/`compute_and_ensure_bundle_dir`
199                // branch below is vestigial — bundle mode was retired in
200                // Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md
201                // — kept only so this wire shape and its call sites don't
202                // need touching.
203                //
204                // Errors (path resolve, mkdir) log + fall back to the
205                // legacy env — never abort `auth.start` over a dir issue.
206                // Mirrors the `inject_identity_env` pattern.
207                let mut auth_env = req.auth_env;
208                let (account_id, bundle_dir) = if req.direct_account {
209                    let (account_id, dir) = compute_and_ensure_account_dir(
210                        &mstore,
211                        &req.existing_account_id,
212                        &req.provider_id,
213                        &mut auth_env,
214                    );
215                    (account_id, dir)
216                } else {
217                    let dir = compute_and_ensure_bundle_dir(
218                        req.into_bundle_id.as_deref(),
219                        &req.provider_id,
220                        &mut auth_env,
221                    );
222                    (String::new(), dir)
223                };
224                // One live login per account directory (`start_session`): srv's
225                // own, or the one the caller chose when it persists the
226                // account itself.
227                let exclusive_key = bundle_dir
228                    .clone()
229                    .or_else(|| caller_auth_dir(&req.provider_id, &auth_env));
230                let r = mgr.start_session(req.provider_id.clone(), exclusive_key);
231                spawn_auth_cli(
232                    mgr,
233                    mstore,
234                    identity_store,
235                    broker,
236                    r.session_id.clone(),
237                    req.provider_id,
238                    req.into_bundle_id,
239                    bundle_dir,
240                    req.direct_account,
241                    account_id,
242                    req.cli_path,
243                    req.auth_login_args,
244                    req.auth_check_args,
245                    auth_env,
246                    req.requires_tty,
247                );
248                Ok(r)
249            }
250        },
251    );
252
253    let mgr = state.auth_session_manager.clone();
254    engine.register_typed(
255        COMMAND_AUTH_POLL,
256        move |req: PollProviderAuthReq, _ctx| {
257            let mgr = mgr.clone();
258            async move {
259                let snap = mgr
260                    .poll_session(&req.session_id)
261                    .ok_or_else(|| format!("auth.poll: unknown session {}", req.session_id))?;
262                Ok(snap)
263            }
264        },
265    );
266
267    let mgr = state.auth_session_manager.clone();
268    engine.register_typed(
269        COMMAND_AUTH_CANCEL,
270        move |req: CancelProviderAuthReq, _ctx| {
271            let mgr = mgr.clone();
272            async move {
273                let ok = mgr.cancel_session(&req.session_id);
274                Ok(AckResp {
275                    success: ok,
276                    error: if ok {
277                        None
278                    } else {
279                        Some(format!("unknown or already-terminal session: {}", req.session_id))
280                    },
281                })
282            }
283        },
284    );
285
286    let mgr = state.auth_session_manager.clone();
287    engine.register_typed(
288        COMMAND_AUTH_SUBMIT_CALLBACK,
289        move |req: SubmitAuthCallbackReq, _ctx| {
290            let mgr = mgr.clone();
291            async move {
292                let delivered = mgr
293                    .send_to_stdin(&req.session_id, req.callback_url)
294                    .await;
295                Ok(AckResp {
296                    success: delivered,
297                    error: if delivered {
298                        None
299                    } else {
300                        Some(format!(
301                            "no stdin sender for session {} (process exited or session unknown)",
302                            req.session_id
303                        ))
304                    },
305                })
306            }
307        },
308    );
309
310    engine.register_typed(
311        COMMAND_ENSURE_ACCOUNT_DIR,
312        move |req: EnsureAccountDirReq, _ctx| {
313            let mstore = mstore_for_ensure_dir.clone();
314            async move {
315                let mut auth_env = std::collections::HashMap::new();
316                let (account_id, dir) = compute_and_ensure_account_dir(
317                    &mstore,
318                    &req.existing_account_id,
319                    &req.provider_id,
320                    &mut auth_env,
321                );
322                let resp = EnsureAccountDirResp { account_id, dir };
323                Ok(resp)
324            }
325        },
326    );
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    // Request shape parsing tests — verify the wire contract matches
334    // what the frontend (PR B) will send. The end-to-end RPC-engine
335    // invocation is covered at the integration level once the CLI
336    // spawn lands; the underlying AuthSessionManager behaviour is
337    // covered in `identity::auth_session::tests`.
338
339    #[test]
340    fn start_req_parses_minimal() {
341        let v = serde_json::json!({
342            "providerId": "claude",
343            "cliPath": "/usr/bin/claude",
344            "authLoginArgs": ["login"],
345            "authCheckArgs": ["whoami"]
346        });
347        let r: StartProviderAuthReq = serde_json::from_value(v).unwrap();
348        assert_eq!(r.provider_id, "claude");
349        assert_eq!(r.cli_path, "/usr/bin/claude");
350        assert_eq!(r.auth_login_args, vec!["login"]);
351        assert_eq!(r.auth_check_args, vec!["whoami"]);
352        assert!(r.into_bundle_id.is_none());
353        assert!(r.auth_env.is_empty());
354    }
355
356    #[test]
357    fn start_req_parses_with_bundle_id() {
358        let v = serde_json::json!({
359            "providerId": "codex",
360            "cliPath": "/usr/bin/codex",
361            "authLoginArgs": ["auth", "login"],
362            "authCheckArgs": ["auth", "status"],
363            "authEnv": { "FOO": "bar" },
364            "intoBundleId": "bundle-1"
365        });
366        let r: StartProviderAuthReq = serde_json::from_value(v).unwrap();
367        assert_eq!(r.provider_id, "codex");
368        assert_eq!(r.into_bundle_id.as_deref(), Some("bundle-1"));
369        assert_eq!(r.auth_env.get("FOO").map(String::as_str), Some("bar"));
370    }
371
372    #[test]
373    fn poll_req_round_trips() {
374        let v = serde_json::json!({ "sessionId": "auth-xyz" });
375        let r: PollProviderAuthReq = serde_json::from_value(v).unwrap();
376        assert_eq!(r.session_id, "auth-xyz");
377    }
378
379    #[test]
380    fn submit_callback_req_round_trips() {
381        let v = serde_json::json!({
382            "sessionId": "auth-xyz",
383            "callbackUrl": "https://example.com/cb?code=abc"
384        });
385        let r: SubmitAuthCallbackReq = serde_json::from_value(v).unwrap();
386        assert_eq!(r.session_id, "auth-xyz");
387        assert!(r.callback_url.contains("code=abc"));
388    }
389
390    #[test]
391    fn ack_resp_omits_error_when_success() {
392        let r = AckResp { success: true, error: None };
393        let v = serde_json::to_value(&r).unwrap();
394        assert_eq!(v, serde_json::json!({ "success": true }));
395    }
396
397    #[test]
398    fn ack_resp_includes_error_when_failure() {
399        let r = AckResp { success: false, error: Some("boom".into()) };
400        let v = serde_json::to_value(&r).unwrap();
401        assert_eq!(v, serde_json::json!({ "success": false, "error": "boom" }));
402    }
403
404    // ── Issue #1624 PR-C Part B: direct-account OAuth ─────────────
405
406    #[test]
407    fn start_req_parses_direct_account_fields() {
408        let v = serde_json::json!({
409            "providerId": "claude",
410            "cliPath": "/usr/bin/claude",
411            "authLoginArgs": ["login"],
412            "authCheckArgs": ["whoami"],
413            "directAccount": true,
414            "existingAccountId": "acc-1"
415        });
416        let r: StartProviderAuthReq = serde_json::from_value(v).unwrap();
417        assert!(r.direct_account);
418        assert_eq!(r.existing_account_id, "acc-1");
419    }
420
421    #[test]
422    fn start_req_direct_account_fields_default_when_absent() {
423        // Same minimal payload as start_req_parses_minimal — must still
424        // parse now that these two fields exist, defaulting to the
425        // legacy bundle-mode shape.
426        let v = serde_json::json!({
427            "providerId": "claude",
428            "cliPath": "/usr/bin/claude",
429            "authLoginArgs": ["login"],
430            "authCheckArgs": ["whoami"]
431        });
432        let r: StartProviderAuthReq = serde_json::from_value(v).unwrap();
433        assert!(!r.direct_account);
434        assert_eq!(r.existing_account_id, "");
435    }
436}
437
438// Request/result shape tests for the six auth/identity commands.
439#[cfg(test)]
440mod req_shape_tests {
441    use super::*;
442    use serde_json::json;
443
444    // auth.start is the one with real optionality. Four of its fields are
445    // #[serde(default)] on non-Option types, so they are omittable on the wire
446    // even though the generated binding marks them required -- the frontend
447    // derives the accurate shape (AuthStartInput). This pins the wire half,
448    // which TypeScript cannot check.
449    #[test]
450    fn auth_start_requires_only_four_fields() {
451        let minimal: StartProviderAuthReq = serde_json::from_value(json!({
452            "providerId": "claude",
453            "cliPath": "/usr/bin/claude",
454            "authLoginArgs": ["login"],
455            "authCheckArgs": ["whoami"],
456        }))
457        .expect("auth.start must accept just the four required fields");
458        assert!(minimal.into_bundle_id.is_none());
459        assert!(!minimal.direct_account);
460        assert_eq!(minimal.existing_account_id, "");
461        assert!(minimal.auth_env.is_empty());
462        assert!(!minimal.requires_tty);
463
464        assert!(
465            serde_json::from_value::<StartProviderAuthReq>(json!({"providerId": "c"})).is_err(),
466            "cliPath and the two arg lists have no defaults"
467        );
468    }
469
470    #[test]
471    fn the_session_id_commands_accept_their_payloads() {
472        serde_json::from_value::<PollProviderAuthReq>(json!({"sessionId": "s1"}))
473            .expect("auth.poll");
474        serde_json::from_value::<CancelProviderAuthReq>(json!({"sessionId": "s1"}))
475            .expect("auth.cancel");
476        serde_json::from_value::<SubmitAuthCallbackReq>(
477            json!({"sessionId": "s1", "callbackUrl": "http://localhost/cb"}),
478        )
479        .expect("auth.submitcallback");
480        serde_json::from_value::<EnsureAccountDirReq>(json!({"providerId": "claude"}))
481            .expect("identity.ensureaccountdir, existingAccountId omitted");
482
483        assert!(
484            serde_json::from_value::<PollProviderAuthReq>(json!({"session_id": "s1"})).is_err(),
485            "snake_case is not the wire format for these"
486        );
487    }
488
489    // AckResp.error and EnsureAccountDirResp.dir are both
490    // skip_serializing_if = "Option::is_none", so the key is OMITTED rather
491    // than nulled -- which is why the bindings say `error?` / `dir?`.
492    //
493    // Worth contrasting with InstallCancelResult in install_handlers.rs, which
494    // looks identical but writes an explicit null and is therefore
495    // `error: string | null`. Same-shaped Rust, different wire contract.
496    #[test]
497    fn the_ack_shapes_omit_their_optional_keys_rather_than_nulling_them() {
498        let ok = serde_json::to_value(AckResp { success: true, error: None }).expect("serializable");
499        assert_eq!(ok, json!({"success": true}));
500
501        let dir = serde_json::to_value(EnsureAccountDirResp {
502            account_id: "a1".to_string(),
503            dir: None,
504        })
505        .expect("serializable");
506        assert_eq!(dir, json!({"accountId": "a1"}));
507    }
508}