1#[derive(Debug, Clone, PartialEq)]
13pub enum AuthPatternMatch {
14 OAuthUrl(String),
16 DeviceCode {
18 code: String,
20 verification_url: String,
22 },
23 LoginSuccess { email: Option<String> },
26}
27
28pub fn match_line(provider_id: &str, line: &str) -> Option<AuthPatternMatch> {
32 let t = agentmux_common::login_pty::strip_terminal_codes(line);
34 let from_text = match_clean_line(provider_id, &t.text);
35 if from_text.as_ref().is_some_and(|m| !matches!(m, AuthPatternMatch::OAuthUrl(_))) {
38 return from_text;
39 }
40 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
50fn 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 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 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" => &[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 "kimi" | "pi" | "qwen" => &[],
100 _ => &[],
101 }
102}
103
104fn match_claude_url(line: &str) -> Option<AuthPatternMatch> {
109 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 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 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 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 let email = extract_email(line);
191 Some(AuthPatternMatch::LoginSuccess { email })
192}
193
194fn extract_first_https_url(line: &str) -> Option<String> {
199 let start = line.find("https://")?;
200 let tail = &line[start..];
201 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(extract_email("Sign in as <email>").is_none());
453 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 assert_eq!(extract_device_code("code: ABC-1234"), None);
463 assert_eq!(extract_device_code("code: ABCD_1234"), None);
464 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 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 = "[32mSuccessfully logged in as josé@example.com[0m";
499 assert_eq!(match_line("claude", line), Some(AuthPatternMatch::LoginSuccess { email: Some("josé@example.com".to_string()) }));
500 }
501}