Skip to main content

agentmux_srv/identity/
auth_patterns.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-provider stdout/stderr pattern matchers for the pre-launch
5//! OAuth flow. The `auth login` subprocess of each CLI provider
6//! emits an OAuth URL (or device code) to stdout/stderr in a slightly
7//! different shape. This module knows how to extract them.
8//!
9//! See `docs/specs/SPEC_PRE_LAUNCH_OAUTH_FLOW_2026_05_14.md` §4.
10
11/// What a pattern matcher extracted from a single line of provider output.
12#[derive(Debug, Clone, PartialEq)]
13pub enum AuthPatternMatch {
14    /// CLI emitted an OAuth URL the user should open in a browser.
15    OAuthUrl(String),
16    /// CLI emitted a device-code pair (GitHub Copilot style).
17    DeviceCode {
18        /// The pairing code the user types into the verification URL.
19        code: String,
20        /// Where the user goes to enter the code.
21        verification_url: String,
22    },
23    /// CLI emitted a "logged in as <email>" line. Used to know we're
24    /// done and to populate the bundle's display name.
25    LoginSuccess { email: Option<String> },
26}
27
28/// Try every pattern for the given provider against a single line of
29/// captured output. Returns the FIRST match found — patterns are
30/// listed by descending specificity in `patterns_for(provider_id)`.
31pub fn match_line(provider_id: &str, line: &str) -> Option<AuthPatternMatch> {
32    // Terminal codes (colours, OSC-8 hyperlinks) come out first.
33    let t = agentmux_common::login_pty::strip_terminal_codes(line);
34    let from_text = match_clean_line(provider_id, &t.text);
35    // A device code, a success or a failure is only in the visible text:
36    // it wins (Copilot prints "visit <link> and enter code ABCD-1234").
37    if from_text.as_ref().is_some_and(|m| !matches!(m, AuthPatternMatch::OAuthUrl(_))) {
38        return from_text;
39    }
40    // For a URL, a link's target beats the visible text, which a PTY can wrap.
41    t.link_uris
42        .iter()
43        .find_map(|uri| match match_clean_line(provider_id, uri) {
44            Some(url @ AuthPatternMatch::OAuthUrl(_)) => Some(url),
45            _ => None,
46        })
47        .or(from_text)
48}
49
50/// [`match_line`] on a line with no terminal codes in it.
51fn match_clean_line(provider_id: &str, line: &str) -> Option<AuthPatternMatch> {
52    for matcher in patterns_for(provider_id) {
53        if let Some(m) = matcher(line) {
54            return Some(m);
55        }
56    }
57    // Universal fallback — any oauth-ish https URL gets surfaced for
58    // user paste-back if the specific matcher missed it. Skipped
59    // entirely for API-key providers because their onboarding output
60    // ("get your key at https://.../auth") would otherwise be
61    // mis-classified as OAuth and drive the wrong UI branch.
62    // (reagent P1 + codex P2 on PR #840.)
63    if is_api_key_provider(provider_id) {
64        return None;
65    }
66    if let Some(url) = extract_first_https_url(line) {
67        if looks_like_oauth_url(&url) {
68            return Some(AuthPatternMatch::OAuthUrl(url));
69        }
70    }
71    None
72}
73
74fn is_api_key_provider(provider_id: &str) -> bool {
75    // OpenClaw moved out: as of SPEC_OPENCLAW_AGENT_2026_05_17.md Phase
76    // α + the "Login with OpenAI" addition, OpenClaw's launch flow runs
77    // `openclaw models auth login --provider openai-codex` which emits
78    // an OpenAI OAuth URL (auth.openai.com). The same `match_codex_url`
79    // we use for Codex matches it.
80    matches!(provider_id, "kimi" | "pi" | "qwen")
81}
82
83type LineMatcher = fn(&str) -> Option<AuthPatternMatch>;
84
85fn patterns_for(provider_id: &str) -> &'static [LineMatcher] {
86    match provider_id {
87        "claude" => &[match_claude_url, match_logged_in_as],
88        "codex" => &[match_codex_url, match_logged_in_as],
89        // OpenClaw onboards into OpenAI via the Codex harness; the OAuth
90        // URL OpenClaw emits is the same shape Codex CLI emits, so reuse
91        // the same matcher. When OpenClaw later supports Login-with-
92        // Claude / Gemini / etc., this list can grow.
93        "openclaw" => &[match_codex_url, match_logged_in_as],
94        "gemini" => &[match_gemini_url, match_logged_in_as],
95        "copilot" => &[match_copilot_device_code, match_logged_in_as],
96        // API-key providers don't OAuth — these patterns never match.
97        // Listed here so the dispatch is exhaustive and adding a new
98        // provider always lands a code edit in this table.
99        "kimi" | "pi" | "qwen" => &[],
100        _ => &[],
101    }
102}
103
104// ────────────────────────────────────────────────────────────────────
105// Per-provider matchers
106// ────────────────────────────────────────────────────────────────────
107
108fn match_claude_url(line: &str) -> Option<AuthPatternMatch> {
109    // Claude Code emits something like:
110    //   "Open this URL in your browser to authorize:"
111    //   "https://claude.com/cai/oauth/authorize?code=true&client_id=..."
112    // (earlier versions: "https://console.anthropic.com/oauth/authorize?...").
113    if let Some(url) = extract_first_https_url(line) {
114        if url.contains("claude.com/cai/oauth")
115            || url.contains("claude.ai/oauth")
116            || url.contains("anthropic.com/oauth")
117            || url.contains("console.anthropic.com")
118        {
119            return Some(AuthPatternMatch::OAuthUrl(url));
120        }
121    }
122    None
123}
124
125fn match_codex_url(line: &str) -> Option<AuthPatternMatch> {
126    if let Some(url) = extract_first_https_url(line) {
127        if url.contains("auth.openai.com")
128            || url.contains("platform.openai.com")
129            || url.contains("openai.com/oauth")
130        {
131            return Some(AuthPatternMatch::OAuthUrl(url));
132        }
133    }
134    None
135}
136
137fn match_gemini_url(line: &str) -> Option<AuthPatternMatch> {
138    if let Some(url) = extract_first_https_url(line) {
139        if url.contains("accounts.google.com") || url.contains("oauth2.googleapis.com") {
140            return Some(AuthPatternMatch::OAuthUrl(url));
141        }
142    }
143    None
144}
145
146fn match_copilot_device_code(line: &str) -> Option<AuthPatternMatch> {
147    // GitHub device flow output:
148    //   "! First copy your one-time code: XXXX-YYYY"
149    //   "Then press Enter to open github.com in your browser..."
150    //   or
151    //   "Please visit https://github.com/login/device and enter code XXXX-YYYY"
152    let line_low = line.to_lowercase();
153    let mentions_device = line_low.contains("github.com/login/device")
154        || line_low.contains("one-time code")
155        || line_low.contains("enter code");
156    if !mentions_device {
157        return None;
158    }
159    let code = extract_device_code(line);
160    if let Some(code) = code {
161        // The URL is constant for GitHub device flow.
162        return Some(AuthPatternMatch::DeviceCode {
163            code,
164            verification_url: "https://github.com/login/device".to_string(),
165        });
166    }
167    None
168}
169
170fn match_logged_in_as(line: &str) -> Option<AuthPatternMatch> {
171    let line_low = line.to_lowercase();
172    // Negative forms ("not authenticated", "not logged in", "isn't
173    // authenticated", "n't logged in", "failed to authenticate") are
174    // status messages, not success — fall through. Reagent caught the
175    // bare `contains("authenticated")` matching error lines on PR #840.
176    if line_low.contains("not authenticated")
177        || line_low.contains("not logged in")
178        || line_low.contains("n't authenticated")
179        || line_low.contains("n't logged in")
180        || line_low.contains("failed to authenticate")
181        || line_low.contains("authentication failed")
182    {
183        return None;
184    }
185    if !line_low.contains("logged in") && !line_low.contains("authenticated") {
186        return None;
187    }
188    // Heuristic email extraction. We don't gate on it — `email`
189    // can be None and the bundle gets a default name.
190    let email = extract_email(line);
191    Some(AuthPatternMatch::LoginSuccess { email })
192}
193
194// ────────────────────────────────────────────────────────────────────
195// Generic helpers
196// ────────────────────────────────────────────────────────────────────
197
198fn extract_first_https_url(line: &str) -> Option<String> {
199    let start = line.find("https://")?;
200    let tail = &line[start..];
201    // URL ends at whitespace, quote, backtick, or closing bracket.
202    // Keeps ports, paths, query strings, fragments.
203    let end = tail
204        .find(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '`' || c == ')')
205        .unwrap_or(tail.len());
206    let url = &tail[..end];
207    // Trim trailing sentence punctuation that a CLI message might
208    // append (e.g. "Authorize at https://...?state=xyz."). The
209    // browser would otherwise see an invalid URL. Be conservative —
210    // only strip end-of-sentence chars, not anything that could be
211    // part of a legitimate URL token. (reagent P1 on PR #840.)
212    let trimmed = url.trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | '!' | '?'));
213    if trimmed.is_empty() {
214        None
215    } else {
216        Some(trimmed.to_string())
217    }
218}
219
220fn looks_like_oauth_url(url: &str) -> bool {
221    let u = url.to_lowercase();
222    u.contains("oauth")
223        || u.contains("/authorize")
224        || u.contains("/login")
225        || u.contains("/auth")
226        || u.contains("device")
227}
228
229fn extract_email(line: &str) -> Option<String> {
230    // Conservative email extractor: find a token containing '@' with
231    // valid surrounding chars. Skips placeholders like "<email>".
232    for token in line.split_whitespace() {
233        let token = token.trim_matches(|c: char| !c.is_alphanumeric() && c != '@' && c != '.' && c != '-' && c != '_' && c != '+');
234        if !token.contains('@') {
235            continue;
236        }
237        // `if let` (not `?`) — `?` would exit the function and abort
238        // the search instead of just skipping this token. The `@`
239        // check above means split_once should always be Some here in
240        // practice, but the safe pattern is to never `?` inside a
241        // for-loop unless the function-exit semantics are intended.
242        // (reagent P1 / codex P2 on PR #840.)
243        let Some((local, domain)) = token.split_once('@') else {
244            continue;
245        };
246        if local.is_empty() || domain.is_empty() {
247            continue;
248        }
249        if !domain.contains('.') {
250            continue;
251        }
252        return Some(token.to_string());
253    }
254    None
255}
256
257fn extract_device_code(line: &str) -> Option<String> {
258    // GitHub device codes look like XXXX-YYYY (uppercase alphanumeric).
259    // Find a token of the form `[A-Z0-9]{4}-[A-Z0-9]{4}`.
260    for token in line.split(|c: char| c.is_whitespace() || c == ':' || c == ',' || c == '.') {
261        if token.len() == 9 {
262            let bytes = token.as_bytes();
263            let is_code = bytes[4] == b'-'
264                && bytes[..4]
265                    .iter()
266                    .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
267                && bytes[5..]
268                    .iter()
269                    .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit());
270            if is_code {
271                return Some(token.to_string());
272            }
273        }
274    }
275    None
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn claude_url_matched() {
284        let line = "Open this URL in your browser to authorize: https://console.anthropic.com/oauth/authorize?response_type=code&state=xyz";
285        let m = match_line("claude", line);
286        assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(_))));
287        if let Some(AuthPatternMatch::OAuthUrl(u)) = m {
288            assert!(u.contains("anthropic.com/oauth"));
289            assert!(u.contains("response_type=code"));
290        }
291    }
292
293    #[test]
294    fn codex_url_matched() {
295        let line = "Please visit https://auth.openai.com/u/login/identifier?state=abc to continue";
296        let m = match_line("codex", line);
297        assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(u)) if u.contains("openai.com")));
298    }
299
300    #[test]
301    fn gemini_url_matched() {
302        let line = "Visit https://accounts.google.com/o/oauth2/auth?client_id=abc&scope=...";
303        let m = match_line("gemini", line);
304        assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(u)) if u.contains("google.com")));
305    }
306
307    #[test]
308    fn copilot_device_code_matched() {
309        let line = "! First copy your one-time code: ABCD-1234";
310        let m = match_line("copilot", line);
311        if let Some(AuthPatternMatch::DeviceCode { code, verification_url }) = m {
312            assert_eq!(code, "ABCD-1234");
313            assert_eq!(verification_url, "https://github.com/login/device");
314        } else {
315            panic!("expected DeviceCode, got {m:?}");
316        }
317    }
318
319    #[test]
320    fn copilot_device_url_line_matched() {
321        let line = "Please visit https://github.com/login/device and enter code WXYZ-5678";
322        let m = match_line("copilot", line);
323        if let Some(AuthPatternMatch::DeviceCode { code, .. }) = m {
324            assert_eq!(code, "WXYZ-5678");
325        } else {
326            panic!("expected DeviceCode, got {m:?}");
327        }
328    }
329
330    #[test]
331    fn login_success_with_email() {
332        let m = match_line("claude", "Successfully logged in as asaf@example.com");
333        if let Some(AuthPatternMatch::LoginSuccess { email }) = m {
334            assert_eq!(email.as_deref(), Some("asaf@example.com"));
335        } else {
336            panic!("expected LoginSuccess, got {m:?}");
337        }
338    }
339
340    #[test]
341    fn login_success_without_email() {
342        let m = match_line("claude", "You are now authenticated.");
343        assert!(matches!(m, Some(AuthPatternMatch::LoginSuccess { email: None })));
344    }
345
346    #[test]
347    fn login_success_skips_negative_forms() {
348        // Reagent P2 on PR #840: bare `contains("authenticated")`
349        // matched "Error: not authenticated" and "Authentication
350        // failed" lines, falsely promoting them to LoginSuccess.
351        for line in [
352            "Error: not authenticated",
353            "you are not authenticated",
354            "user isn't authenticated yet",
355            "You aren't logged in",
356            "failed to authenticate",
357            "Authentication failed",
358        ] {
359            let m = match_line("claude", line);
360            assert!(m.is_none(), "expected None for {line:?}, got {m:?}");
361        }
362    }
363
364    #[test]
365    fn fallback_https_matches_for_unknown_providers() {
366        // Unknown provider, but the line has an OAuth-ish URL. The
367        // fallback should still catch it so the user gets the paste
368        // option in the UI.
369        let line = "Open https://example.com/oauth/authorize?state=x";
370        let m = match_line("future-provider", line);
371        assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(_))));
372    }
373
374    #[test]
375    fn fallback_skips_non_oauth_https() {
376        // A line with an https URL that doesn't look like OAuth (e.g.
377        // a documentation link) shouldn't false-positive.
378        let line = "See https://docs.example.com/getting-started for details.";
379        let m = match_line("future-provider", line);
380        assert!(m.is_none());
381    }
382
383    #[test]
384    fn api_key_providers_match_nothing() {
385        // kimi / pi don't have OAuth flow. Even if their output includes
386        // an https URL during onboarding, we don't want to misinterpret
387        // it as OAuth.
388        for provider in ["kimi", "pi", "qwen"] {
389            let m = match_line(provider, "Get your API key at https://example.com/keys");
390            assert!(m.is_none(), "provider {provider} unexpectedly matched");
391        }
392    }
393
394    #[test]
395    fn openclaw_matches_openai_oauth_url() {
396        // OpenClaw's `models auth login --provider openai-codex` emits
397        // the same OpenAI OAuth URL Codex CLI emits. We reuse codex's
398        // matcher for it.
399        let line = "Open this URL to sign in: https://auth.openai.com/oauth/authorize?...";
400        let m = match_line("openclaw", line);
401        assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(_))));
402    }
403
404    #[test]
405    fn url_extraction_trims_trailing_punctuation() {
406        // Period, comma, semicolon, !, ? at the END of a URL are
407        // almost certainly sentence terminators, not URL chars.
408        // The browser would reject the URL with them attached.
409        // (reagent P1 on PR #840.)
410        let cases = [
411            ("Go to https://example.com/login.", "https://example.com/login"),
412            ("Open https://example.com/auth, then press Enter", "https://example.com/auth"),
413            ("Visit https://example.com/login!", "https://example.com/login"),
414            ("Done at https://example.com/oauth?state=xyz?", "https://example.com/oauth?state=xyz"),
415        ];
416        for (line, expected) in cases {
417            let url = extract_first_https_url(line).expect("url");
418            assert_eq!(url, expected, "for line: {line}");
419        }
420    }
421
422    #[test]
423    fn url_extraction_preserves_internal_punctuation() {
424        // Make sure we don't over-trim — query strings legitimately
425        // have `&` and `=`, paths can have `.`, etc.
426        let line = "Open https://example.com/path.html?key=value&other=v2";
427        let url = extract_first_https_url(line).expect("url");
428        assert_eq!(url, "https://example.com/path.html?key=value&other=v2");
429    }
430
431    #[test]
432    fn api_key_provider_fallback_returns_none() {
433        // Reagent P1 + codex P2 on PR #840: the universal fallback
434        // used to run for API-key providers. Their onboarding output
435        // ("get your key at https://.../auth") would mis-classify
436        // as OAuth and drive the wrong UI branch. Now: no fallback
437        // for kimi/pi at all. (OpenClaw moved out — see
438        // openclaw_matches_openai_oauth_url; its `models auth login
439        // --provider openai-codex` emits a real OAuth URL.)
440        for provider in ["kimi", "pi", "qwen"] {
441            let line = "Get your API key at https://example.com/auth/keys";
442            assert!(match_line(provider, line).is_none(), "{provider} matched");
443        }
444        // Whereas an unknown provider still gets the fallback.
445        let m = match_line("unknown-provider", "Open https://example.com/oauth/authorize");
446        assert!(matches!(m, Some(AuthPatternMatch::OAuthUrl(_))));
447    }
448
449    #[test]
450    fn email_extractor_ignores_placeholders() {
451        // `<email>` is a typical CLI placeholder shown in help text.
452        assert!(extract_email("Sign in as <email>").is_none());
453        // Plain "@example" with no TLD shouldn't match.
454        assert!(extract_email("Twitter handle: @example").is_none());
455    }
456
457    #[test]
458    fn device_code_extractor_recognises_correct_shape() {
459        assert_eq!(extract_device_code("code: ABCD-1234"), Some("ABCD-1234".to_string()));
460        assert_eq!(extract_device_code("the code is XYZW-0987 right here"), Some("XYZW-0987".to_string()));
461        // Wrong length, wrong separator — should miss.
462        assert_eq!(extract_device_code("code: ABC-1234"), None);
463        assert_eq!(extract_device_code("code: ABCD_1234"), None);
464        // Lowercase — GitHub uses uppercase only.
465        assert_eq!(extract_device_code("code: abcd-1234"), None);
466    }
467
468    const CLAUDE_AUTHORIZE: &str =
469        "https://claude.com/cai/oauth/authorize?code=true&client_id=abc-123&code_challenge=xyz&state=st-789";
470
471    #[test]
472    fn claude_matches_its_current_authorize_url() {
473        let line = format!("If the browser didn't open, visit: {CLAUDE_AUTHORIZE}");
474        assert_eq!(match_line("claude", &line), Some(AuthPatternMatch::OAuthUrl(CLAUDE_AUTHORIZE.to_string())));
475    }
476
477    #[test]
478    fn a_url_inside_colour_codes_is_matched_without_them() {
479        let line = format!("\x1b[1;34m{CLAUDE_AUTHORIZE}\x1b[0m");
480        assert_eq!(match_line("claude", &line), Some(AuthPatternMatch::OAuthUrl(CLAUDE_AUTHORIZE.to_string())));
481    }
482
483    #[test]
484    fn an_osc8_link_target_wins_over_wrapped_visible_text() {
485        // The visible text was cut short by a wrap; the link target is whole.
486        let line = format!("\x1b]8;;{CLAUDE_AUTHORIZE}\x07https://claude.com/cai/oauth/auth\x1b]8;;\x07");
487        assert_eq!(match_line("claude", &line), Some(AuthPatternMatch::OAuthUrl(CLAUDE_AUTHORIZE.to_string())));
488    }
489
490    #[test]
491    fn a_device_code_in_the_visible_text_wins_over_a_link_target() {
492        let line = "Please visit ]8;;https://github.com/login/devicehttps://github.com/login/device]8;; and enter code ABCD-1234";
493        assert!(matches!(match_line("copilot", line), Some(AuthPatternMatch::DeviceCode { ref code, .. }) if code == "ABCD-1234"), "{:?}", match_line("copilot", line));
494    }
495
496    #[test]
497    fn a_non_ascii_email_on_a_coloured_success_line_survives() {
498        let line = "Successfully logged in as josé@example.com";
499        assert_eq!(match_line("claude", line), Some(AuthPatternMatch::LoginSuccess { email: Some("josé@example.com".to_string()) }));
500    }
501}