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