Skip to main content

vtcode_core/copilot/
auth.rs

1#![allow(clippy::let_underscore_must_use)]
2
3use std::collections::HashMap;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::process::Stdio;
7use std::thread;
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result, anyhow};
11use once_cell::sync::Lazy;
12use portable_pty::{CommandBuilder, PtySize, native_pty_system};
13use regex::Regex;
14use serde::Deserialize;
15use tokio::io::{AsyncRead, AsyncReadExt, BufReader};
16use tokio::sync::mpsc;
17use tokio::time::timeout;
18use url::Url;
19use vtcode_config::auth::CopilotAuthConfig;
20
21use crate::utils::ansi_parser::strip_ansi;
22
23use super::command::{ResolvedCopilotCommand, copilot_command_available, resolve_copilot_command};
24use super::types::{COPILOT_AUTH_DOC_PATH, CopilotAuthEvent, CopilotAuthStatus};
25
26const DEFAULT_HOST_URL: &str = "https://github.com";
27const ENV_AUTH_VARS: &[&str] = &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
28static DEVICE_FLOW_LINE_RE: Lazy<Regex> = Lazy::new(|| {
29    Regex::new(r"(?i)visit\s+(https?://\S+)\s+and\s+enter code\s+([A-Z0-9-]+)")
30        .expect("device flow regex must compile")
31});
32
33pub async fn login(config: &CopilotAuthConfig, workspace_root: &Path) -> Result<()> {
34    login_with_events(config, workspace_root, |_| Ok(())).await
35}
36
37pub async fn login_with_events<F>(
38    config: &CopilotAuthConfig,
39    workspace_root: &Path,
40    mut on_event: F,
41) -> Result<()>
42where
43    F: FnMut(CopilotAuthEvent) -> Result<()>,
44{
45    let resolved = resolve_copilot_command(config).context("invalid copilot command")?;
46    if let Err(err) = ensure_command_available(&resolved) {
47        emit_missing_command_guidance(config, &mut on_event)?;
48        on_event(CopilotAuthEvent::Failure {
49            message: err.to_string(),
50        })?;
51        return Err(err);
52    }
53
54    let host = resolve_copilot_host(config)?;
55    let args = login_command_args(&host);
56
57    run_captured_command(
58        &resolved,
59        workspace_root,
60        &args,
61        "copilot login",
62        CommandKind::Login,
63        &mut on_event,
64    )
65    .await?;
66
67    let account = probe_auth_status(config, Some(workspace_root))
68        .await
69        .message
70        .as_deref()
71        .and_then(extract_account_from_status_message)
72        .map(ToString::to_string);
73    on_event(CopilotAuthEvent::Success { account })?;
74    Ok(())
75}
76
77pub async fn logout(config: &CopilotAuthConfig, workspace_root: &Path) -> Result<()> {
78    logout_with_events(config, workspace_root, |_| Ok(())).await
79}
80
81pub async fn logout_with_events<F>(
82    config: &CopilotAuthConfig,
83    workspace_root: &Path,
84    mut on_event: F,
85) -> Result<()>
86where
87    F: FnMut(CopilotAuthEvent) -> Result<()>,
88{
89    let resolved = resolve_copilot_command(config).context("invalid copilot command")?;
90    if let Err(err) = ensure_command_available(&resolved) {
91        emit_missing_command_guidance(config, &mut on_event)?;
92        on_event(CopilotAuthEvent::Failure {
93            message: err.to_string(),
94        })?;
95        return Err(err);
96    }
97
98    let host = resolve_copilot_host(config)?;
99    let interactive_result = run_interactive_logout_command(&resolved, workspace_root, &host)
100        .await
101        .with_context(|| "copilot logout started an interactive Copilot CLI session");
102
103    if let Err(interactive_err) = interactive_result {
104        let args = logout_command_args();
105        let direct_logout = run_captured_command(
106            &resolved,
107            workspace_root,
108            &args,
109            "copilot logout",
110            CommandKind::Logout,
111            &mut on_event,
112        )
113        .await;
114
115        match direct_logout {
116            Ok(()) => {}
117            Err(err) if should_retry_logout_interactively(&err.to_string()) => {
118                return Err(interactive_err);
119            }
120            Err(err) => {
121                return Err(err).with_context(|| {
122                    format!("interactive copilot logout failed: {interactive_err}")
123                });
124            }
125        }
126    }
127
128    on_event(CopilotAuthEvent::Success { account: None })?;
129    Ok(())
130}
131
132pub async fn probe_auth_status(
133    config: &CopilotAuthConfig,
134    workspace_root: Option<&Path>,
135) -> CopilotAuthStatus {
136    let host = match resolve_copilot_host(config) {
137        Ok(host) => host,
138        Err(err) => return CopilotAuthStatus::auth_flow_failed(err.to_string()),
139    };
140
141    let auth_source = match detect_auth_source(&host, workspace_root).await {
142        Ok(source) => source,
143        Err(err) => return CopilotAuthStatus::auth_flow_failed(err.to_string()),
144    };
145
146    let resolved = match resolve_copilot_command(config) {
147        Ok(resolved) => resolved,
148        Err(err) => return CopilotAuthStatus::auth_flow_failed(err.to_string()),
149    };
150
151    if !copilot_command_available(&resolved) {
152        return CopilotAuthStatus::server_unavailable(format!(
153            "GitHub Copilot CLI command `{}` was not found. Install `copilot`, set `VTCODE_COPILOT_COMMAND`, or configure `[auth.copilot].command`.{source_suffix}",
154            resolved.display(),
155            source_suffix = auth_source
156                .as_ref()
157                .map(auth_source_suffix)
158                .unwrap_or_default(),
159        ));
160    }
161
162    match auth_source {
163        Some(source) => CopilotAuthStatus::authenticated(Some(source.message(&host))),
164        None => CopilotAuthStatus::unauthenticated(Some(format!(
165            "No GitHub Copilot authentication source found for {}. Run `vtcode login copilot`, or set one of {}. `gh auth login` is only used as an optional fallback.",
166            host.gh_hostname,
167            ENV_AUTH_VARS.join(", ")
168        ))),
169    }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173enum CommandKind {
174    Login,
175    Logout,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179enum CapturedStream {
180    Stdout,
181    Stderr,
182}
183
184#[derive(Debug)]
185struct CapturedLine {
186    stream: CapturedStream,
187    text: String,
188}
189
190async fn run_captured_command<F>(
191    resolved: &ResolvedCopilotCommand,
192    workspace_root: &Path,
193    extra_args: &[String],
194    action_name: &str,
195    kind: CommandKind,
196    on_event: &mut F,
197) -> Result<()>
198where
199    F: FnMut(CopilotAuthEvent) -> Result<()>,
200{
201    let mut command = resolved.command(Some(workspace_root), extra_args);
202    command
203        .stdin(Stdio::null())
204        .stdout(Stdio::piped())
205        .stderr(Stdio::piped())
206        .kill_on_drop(true);
207
208    let mut child = command
209        .spawn()
210        .with_context(|| format!("failed to spawn `{}`", resolved.display()))?;
211    let stdout = child
212        .stdout
213        .take()
214        .ok_or_else(|| anyhow!("{action_name} stdout unavailable"))?;
215    let stderr = child
216        .stderr
217        .take()
218        .ok_or_else(|| anyhow!("{action_name} stderr unavailable"))?;
219
220    let (line_tx, mut line_rx) = mpsc::unbounded_channel();
221    spawn_line_reader(stdout, CapturedStream::Stdout, line_tx.clone());
222    spawn_line_reader(stderr, CapturedStream::Stderr, line_tx);
223    let mut state = CapturedCommandState::default();
224
225    let status = match timeout(resolved.auth_timeout, async {
226        let wait_result: Result<std::process::ExitStatus> = loop {
227            tokio::select! {
228                status = child.wait() => {
229                    break status.with_context(|| format!("{action_name} process failed"));
230                }
231                maybe_line = line_rx.recv() => {
232                    let Some(line) = maybe_line else {
233                        continue;
234                    };
235                    state.handle_line(kind, line, on_event)?;
236                }
237            }
238        };
239        wait_result
240    })
241    .await
242    {
243        Ok(status) => status?,
244        Err(_) => {
245            let _ = child.start_kill();
246            let message = format!(
247                "{action_name} timed out after {} seconds",
248                resolved.auth_timeout.as_secs()
249            );
250            on_event(CopilotAuthEvent::Failure {
251                message: message.clone(),
252            })?;
253            return Err(anyhow!(message));
254        }
255    };
256
257    while let Ok(line) = line_rx.try_recv() {
258        state.handle_line(kind, line, on_event)?;
259    }
260
261    if status.success() {
262        Ok(())
263    } else {
264        let message = state.failure_message(action_name, status);
265        on_event(CopilotAuthEvent::Failure {
266            message: message.clone(),
267        })?;
268        Err(anyhow!(message))
269    }
270}
271
272#[derive(Default)]
273struct CapturedCommandState {
274    emitted_verification_code: bool,
275    emitted_waiting_message: bool,
276    last_safe_message: Option<String>,
277}
278
279impl CapturedCommandState {
280    fn handle_line<F>(
281        &mut self,
282        kind: CommandKind,
283        line: CapturedLine,
284        on_event: &mut F,
285    ) -> Result<()>
286    where
287        F: FnMut(CopilotAuthEvent) -> Result<()>,
288    {
289        let normalized = normalize_captured_line(&line.text);
290        let trimmed = normalized.trim();
291        if trimmed.is_empty() {
292            return Ok(());
293        }
294
295        if matches!(kind, CommandKind::Logout)
296            && matches!(line.stream, CapturedStream::Stdout)
297            && trimmed
298                .to_ascii_lowercase()
299                .contains("non-interactive mode")
300        {
301            self.record_safe_message(trimmed.to_string());
302            return Ok(());
303        }
304
305        if matches!(kind, CommandKind::Login)
306            && let Some(event) = parse_login_event(trimmed)
307        {
308            match &event {
309                CopilotAuthEvent::VerificationCode { .. } if self.emitted_verification_code => {
310                    return Ok(());
311                }
312                CopilotAuthEvent::VerificationCode { .. } => {
313                    self.emitted_verification_code = true;
314                }
315                CopilotAuthEvent::Progress { message }
316                    if message.eq_ignore_ascii_case("Waiting for authorization")
317                        && self.emitted_waiting_message =>
318                {
319                    return Ok(());
320                }
321                CopilotAuthEvent::Progress { message }
322                    if message.eq_ignore_ascii_case("Waiting for authorization") =>
323                {
324                    self.emitted_waiting_message = true;
325                }
326                _ => {}
327            }
328            return on_event(event);
329        }
330
331        if let Some(message) = sanitize_cli_line(trimmed, line.stream) {
332            self.record_safe_message(message);
333        }
334        Ok(())
335    }
336
337    fn record_safe_message(&mut self, message: String) {
338        let is_low_signal = is_low_signal_cli_hint(&message);
339        match self.last_safe_message.as_ref() {
340            Some(existing) if !is_low_signal_cli_hint(existing) && is_low_signal => {}
341            _ => {
342                self.last_safe_message = Some(message);
343            }
344        }
345    }
346
347    fn failure_message(&self, action_name: &str, status: std::process::ExitStatus) -> String {
348        if let Some(message) = self.last_safe_message.as_deref() {
349            format!("{action_name} exited with status {status}: {message}")
350        } else {
351            format!("{action_name} exited with status {status}")
352        }
353    }
354}
355
356fn spawn_line_reader<R>(
357    reader: R,
358    stream: CapturedStream,
359    line_tx: mpsc::UnboundedSender<CapturedLine>,
360) where
361    R: AsyncRead + Unpin + Send + 'static,
362{
363    tokio::spawn(async move {
364        let mut reader = BufReader::new(reader);
365        let mut buffer = Vec::new();
366        let mut chunk = [0_u8; 1024];
367
368        loop {
369            match reader.read(&mut chunk).await {
370                Ok(0) => break,
371                Ok(read) => {
372                    buffer.extend_from_slice(&chunk[..read]);
373                    for text in drain_complete_lines(&mut buffer) {
374                        let _ = line_tx.send(CapturedLine { stream, text });
375                    }
376                }
377                Err(_) => return,
378            }
379        }
380
381        if !buffer.is_empty() {
382            let text = String::from_utf8_lossy(&buffer).into_owned();
383            let _ = line_tx.send(CapturedLine { stream, text });
384        }
385    });
386}
387
388fn drain_complete_lines(buffer: &mut Vec<u8>) -> Vec<String> {
389    let mut lines = Vec::new();
390    let mut start = 0usize;
391    let mut index = 0usize;
392
393    while index < buffer.len() {
394        let byte = buffer[index];
395        if byte == b'\n' || byte == b'\r' {
396            let line = String::from_utf8_lossy(&buffer[start..index]).into_owned();
397            lines.push(line);
398
399            if byte == b'\r' && buffer.get(index + 1) == Some(&b'\n') {
400                index += 1;
401            }
402            index += 1;
403            start = index;
404            continue;
405        }
406        index += 1;
407    }
408
409    if start > 0 {
410        buffer.drain(..start);
411    }
412
413    lines
414}
415
416fn parse_login_event(line: &str) -> Option<CopilotAuthEvent> {
417    if let Some((url, user_code)) = parse_device_flow_code(line) {
418        return Some(CopilotAuthEvent::VerificationCode { url, user_code });
419    }
420
421    let lower = line.to_ascii_lowercase();
422    if lower.contains("waiting for authorization") {
423        return Some(CopilotAuthEvent::Progress {
424            message: "Waiting for authorization".to_string(),
425        });
426    }
427    if lower.contains("opening browser") || lower.contains("opened browser") {
428        return Some(CopilotAuthEvent::Progress {
429            message: "Opened the browser for GitHub device authorization".to_string(),
430        });
431    }
432    None
433}
434
435fn parse_device_flow_code(line: &str) -> Option<(String, String)> {
436    let captures = DEVICE_FLOW_LINE_RE.captures(line)?;
437    let url = captures
438        .get(1)?
439        .as_str()
440        .trim_end_matches(['.', ',', ')', ']'])
441        .to_string();
442    let code = captures
443        .get(2)?
444        .as_str()
445        .trim_matches(|ch: char| matches!(ch, '.' | ',' | ':' | ';'))
446        .to_string();
447    (!url.is_empty() && !code.is_empty()).then_some((url, code))
448}
449
450fn normalize_captured_line(line: &str) -> String {
451    strip_ansi(line)
452        .chars()
453        .filter(|ch| {
454            !matches!(
455                ch,
456                '\u{0000}'..='\u{0008}'
457                    | '\u{000B}'
458                    | '\u{000C}'
459                    | '\u{000E}'..='\u{001F}'
460                    | '\u{007F}'
461            )
462        })
463        .collect()
464}
465
466fn login_command_args(host: &CopilotHost) -> Vec<String> {
467    let mut args = vec!["login".to_string()];
468    if !host.is_default() {
469        args.push("--host".to_string());
470        args.push(host.url.clone());
471    }
472    args
473}
474
475fn logout_command_args() -> Vec<String> {
476    vec!["logout".to_string()]
477}
478
479fn sanitize_cli_line(line: &str, stream: CapturedStream) -> Option<String> {
480    let lower = line.to_ascii_lowercase();
481    if lower.contains("copilot_github_token")
482        || lower.contains("gh_token")
483        || lower.contains("github_token")
484        || lower.contains("auth-token-env")
485    {
486        return Some(
487            "GitHub Copilot CLI reported an authentication configuration issue.".to_string(),
488        );
489    }
490    if lower.contains("secitemcopymatching failed") {
491        return Some(
492            "GitHub Copilot CLI failed to access the macOS Keychain while clearing credentials."
493                .to_string(),
494        );
495    }
496
497    match stream {
498        CapturedStream::Stdout => None,
499        CapturedStream::Stderr => Some(line.to_string()),
500    }
501}
502
503fn is_low_signal_cli_hint(line: &str) -> bool {
504    let trimmed = line.trim();
505    trimmed.eq_ignore_ascii_case("Try 'copilot --help' for more information.")
506}
507
508fn extract_account_from_status_message(message: &str) -> Option<&str> {
509    let login = message.split(" for ").nth(1)?.split(" on ").next()?.trim();
510    (!login.is_empty()).then_some(login)
511}
512
513fn should_retry_logout_interactively(message: &str) -> bool {
514    message
515        .to_ascii_lowercase()
516        .contains("for non-interactive mode, use the -p or --prompt option")
517}
518
519async fn run_interactive_logout_command(
520    resolved: &ResolvedCopilotCommand,
521    workspace_root: &Path,
522    host: &CopilotHost,
523) -> Result<()> {
524    let resolved = resolved.clone();
525    let workspace_root = workspace_root.to_path_buf();
526    let host = host.clone();
527    tokio::task::spawn_blocking(move || {
528        blocking_interactive_logout_command(&resolved, &workspace_root, &host)
529    })
530    .await
531    .context("failed to join interactive copilot logout task")?
532}
533
534fn blocking_interactive_logout_command(
535    resolved: &ResolvedCopilotCommand,
536    workspace_root: &Path,
537    host: &CopilotHost,
538) -> Result<()> {
539    let pty_system = native_pty_system();
540    let pair = pty_system
541        .openpty(PtySize {
542            rows: 24,
543            cols: 80,
544            pixel_width: 0,
545            pixel_height: 0,
546        })
547        .context("failed to allocate PTY for interactive copilot logout")?;
548
549    let mut builder = CommandBuilder::new(&resolved.program);
550    for arg in &resolved.args {
551        builder.arg(arg);
552    }
553    builder.cwd(workspace_root);
554    builder.env("TERM", "xterm-256color");
555    builder.env("COLUMNS", "80");
556    builder.env("LINES", "24");
557
558    let mut child = pair
559        .slave
560        .spawn_command(builder)
561        .with_context(|| format!("failed to spawn `{}`", resolved.display()))?;
562    let mut killer = child.clone_killer();
563    drop(pair.slave);
564
565    let mut reader = pair
566        .master
567        .try_clone_reader()
568        .context("failed to clone PTY reader for copilot logout")?;
569    let mut writer = pair
570        .master
571        .take_writer()
572        .context("failed to take PTY writer for copilot logout")?;
573
574    let writer_thread = thread::spawn(move || -> Result<()> {
575        writer
576            .write_all(b"/logout\n")
577            .context("failed to send /logout to Copilot CLI")?;
578        writer
579            .flush()
580            .context("failed to flush /logout to Copilot CLI")?;
581        thread::sleep(Duration::from_millis(250));
582        writer
583            .write_all(b"/exit\n")
584            .context("failed to send /exit to Copilot CLI")?;
585        writer
586            .flush()
587            .context("failed to flush /exit to Copilot CLI")?;
588        Ok(())
589    });
590
591    let (line_tx, line_rx) = std::sync::mpsc::channel();
592    let reader_thread = thread::spawn(move || -> Result<()> {
593        let mut chunk = [0_u8; 1024];
594        let mut buffer = Vec::new();
595        loop {
596            match reader.read(&mut chunk) {
597                Ok(0) => break,
598                Ok(read) => {
599                    buffer.extend_from_slice(&chunk[..read]);
600                    for text in drain_complete_lines(&mut buffer) {
601                        let _ = line_tx.send(text);
602                    }
603                }
604                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
605                Err(error) => {
606                    return Err(error).context("failed to read interactive copilot logout output");
607                }
608            }
609        }
610
611        if !buffer.is_empty() {
612            let text = String::from_utf8_lossy(&buffer).into_owned();
613            let _ = line_tx.send(text);
614        }
615
616        Ok(())
617    });
618
619    let (wait_tx, wait_rx) = std::sync::mpsc::channel();
620    let wait_thread = thread::spawn(move || {
621        let status = child.wait();
622        let _ = wait_tx.send(());
623        status
624    });
625
626    let start = Instant::now();
627    let mut last_auth_check = Instant::now();
628    let mut auth_cleared = false;
629    let mut state = CapturedCommandState::default();
630    let wait_granularity = Duration::from_millis(100);
631
632    loop {
633        while let Ok(text) = line_rx.try_recv() {
634            state.handle_line(
635                CommandKind::Logout,
636                CapturedLine {
637                    stream: CapturedStream::Stderr,
638                    text,
639                },
640                &mut |_| Ok(()),
641            )?;
642        }
643
644        if wait_rx.try_recv().is_ok() {
645            break;
646        }
647
648        if last_auth_check.elapsed() >= Duration::from_millis(250) {
649            last_auth_check = Instant::now();
650            if stored_auth_source(host)?.is_none() {
651                auth_cleared = true;
652                let _ = killer.kill();
653                break;
654            }
655        }
656
657        if start.elapsed() >= resolved.auth_timeout {
658            let _ = killer.kill();
659            let _ = writer_thread.join();
660            let _ = reader_thread.join();
661            let _ = wait_thread.join();
662            return Err(anyhow!(
663                "copilot logout timed out after {} seconds",
664                resolved.auth_timeout.as_secs()
665            ));
666        }
667
668        thread::sleep(wait_granularity);
669    }
670
671    let status = wait_thread.join().map_err(|panic| {
672        anyhow!(
673            "interactive copilot logout wait thread panicked: {:?}",
674            panic
675        )
676    })?;
677
678    let writer_result = writer_thread.join().map_err(|panic| {
679        anyhow!(
680            "interactive copilot logout writer thread panicked: {:?}",
681            panic
682        )
683    })?;
684
685    let reader_result = reader_thread.join().map_err(|panic| {
686        anyhow!(
687            "interactive copilot logout reader thread panicked: {:?}",
688            panic
689        )
690    })?;
691
692    if auth_cleared {
693        return Ok(());
694    }
695
696    let status = status.context("failed to wait for interactive copilot logout process")?;
697    writer_result.context("failed to write interactive copilot logout commands")?;
698    reader_result.context("failed to read interactive copilot logout output")?;
699
700    while let Ok(text) = line_rx.try_recv() {
701        state.handle_line(
702            CommandKind::Logout,
703            CapturedLine {
704                stream: CapturedStream::Stderr,
705                text,
706            },
707            &mut |_| Ok(()),
708        )?;
709    }
710
711    if stored_auth_source(host)?.is_none() {
712        return Ok(());
713    }
714
715    let exit_status = format_portable_exit_status(status);
716    let failure = if let Some(message) = state.last_safe_message.as_deref() {
717        format!("copilot logout exited with status {exit_status}: {message}")
718    } else {
719        format!("copilot logout exited with status {exit_status}")
720    };
721    Err(anyhow!(failure))
722}
723
724fn format_portable_exit_status(status: portable_pty::ExitStatus) -> String {
725    status
726        .signal()
727        .map(|signal| format!("signal {signal}"))
728        .unwrap_or_else(|| status.exit_code().to_string())
729}
730
731fn ensure_command_available(resolved: &ResolvedCopilotCommand) -> Result<()> {
732    if copilot_command_available(resolved) {
733        return Ok(());
734    }
735
736    Err(anyhow!(
737        "GitHub Copilot CLI command `{}` was not found. Install `copilot`, set `VTCODE_COPILOT_COMMAND`, or configure `[auth.copilot].command`. See `{COPILOT_AUTH_DOC_PATH}`.",
738        resolved.display(),
739    ))
740}
741
742fn emit_missing_command_guidance<F>(config: &CopilotAuthConfig, on_event: &mut F) -> Result<()>
743where
744    F: FnMut(CopilotAuthEvent) -> Result<()>,
745{
746    let Some(lines) = missing_copilot_command_help_lines(config)? else {
747        return Ok(());
748    };
749
750    for line in lines {
751        on_event(CopilotAuthEvent::Progress { message: line })?;
752    }
753
754    Ok(())
755}
756
757fn missing_copilot_command_help_lines(config: &CopilotAuthConfig) -> Result<Option<Vec<String>>> {
758    let resolved = resolve_copilot_command(config).context("invalid copilot command")?;
759    Ok(missing_copilot_command_help_lines_with(
760        &resolved.display(),
761        copilot_command_available(&resolved),
762        which::which("gh").is_ok(),
763    ))
764}
765
766fn missing_copilot_command_help_lines_with(
767    command_display: &str,
768    copilot_available: bool,
769    gh_available: bool,
770) -> Option<Vec<String>> {
771    if copilot_available {
772        return None;
773    }
774
775    let mut lines = vec![
776        format!(
777            "GitHub Copilot login/logout requires the configured Copilot CLI command `{command_display}` to be runnable."
778        ),
779        "Install `copilot`, then rerun `/login copilot`, `/logout copilot`, or `vtcode login copilot`.".to_string(),
780        format!(
781            "If the CLI is installed outside PATH, set `VTCODE_COPILOT_COMMAND` or `[auth.copilot].command`. See `{COPILOT_AUTH_DOC_PATH}`."
782        ),
783    ];
784
785    if gh_available {
786        lines.push(
787            "`gh` is optional fallback only. VT Code still requires the official `copilot` CLI for login/logout."
788                .to_string(),
789        );
790    } else {
791        lines.push(
792            "`gh` is also not installed. That is okay for login/logout: VT Code only uses `gh` as an optional fallback when probing existing GitHub auth."
793                .to_string(),
794        );
795    }
796
797    Some(lines)
798}
799
800async fn detect_auth_source(
801    host: &CopilotHost,
802    workspace_root: Option<&Path>,
803) -> Result<Option<CopilotAuthSource>> {
804    if let Some(source) = env_auth_source_with(|name| std::env::var(name).ok()) {
805        return Ok(Some(source));
806    }
807
808    if let Some(source) = stored_auth_source(host)? {
809        return Ok(Some(source));
810    }
811
812    if github_cli_auth_available(host, workspace_root).await? {
813        return Ok(Some(CopilotAuthSource::GitHubCli));
814    }
815
816    Ok(None)
817}
818
819fn env_auth_source_with<F>(mut read_var: F) -> Option<CopilotAuthSource>
820where
821    F: FnMut(&str) -> Option<String>,
822{
823    ENV_AUTH_VARS.iter().find_map(|name| {
824        read_var(name)
825            .as_deref()
826            .map(str::trim)
827            .filter(|value| !value.is_empty())
828            .map(|_| CopilotAuthSource::Environment(name))
829    })
830}
831
832fn stored_auth_source(host: &CopilotHost) -> Result<Option<CopilotAuthSource>> {
833    let Some(config_path) = copilot_config_path() else {
834        return Ok(None);
835    };
836    if !config_path.exists() {
837        return Ok(None);
838    }
839
840    let config_text = std::fs::read_to_string(&config_path)
841        .with_context(|| format!("failed to read {}", config_path.display()))?;
842    let config: CopilotCliConfig = serde_json::from_str(&config_text)
843        .with_context(|| format!("failed to parse {}", config_path.display()))?;
844
845    if let Some(user) = config
846        .logged_in_users
847        .iter()
848        .find(|user| user.host_matches(host))
849        .or_else(|| {
850            config
851                .last_logged_in_user
852                .as_ref()
853                .filter(|user| user.host_matches(host))
854        })
855    {
856        return Ok(Some(CopilotAuthSource::StoredCredentials {
857            login: user.login.clone(),
858        }));
859    }
860
861    let token_login = config
862        .copilot_tokens
863        .keys()
864        .find_map(|key| copilot_token_login_for_host(host, key));
865    let token_host_match = token_login.is_some()
866        || config
867            .copilot_tokens
868            .keys()
869            .any(|key| copilot_token_key_matches_host(host, key));
870
871    if token_host_match {
872        return Ok(Some(CopilotAuthSource::StoredCredentials {
873            login: token_login.or_else(|| config.last_logged_in_user.and_then(|user| user.login)),
874        }));
875    }
876
877    Ok(None)
878}
879
880async fn github_cli_auth_available(
881    host: &CopilotHost,
882    workspace_root: Option<&Path>,
883) -> Result<bool> {
884    if which::which("gh").is_err() {
885        return Ok(false);
886    }
887
888    let mut command = tokio::process::Command::new("gh");
889    command
890        .arg("auth")
891        .arg("status")
892        .arg("--hostname")
893        .arg(&host.gh_hostname)
894        .stdout(Stdio::null())
895        .stderr(Stdio::null())
896        .kill_on_drop(true);
897
898    if let Some(cwd) = workspace_root {
899        command.current_dir(cwd);
900    }
901
902    let mut child = command.spawn().with_context(|| {
903        format!(
904            "failed to spawn `gh auth status --hostname {}`",
905            host.gh_hostname
906        )
907    })?;
908
909    let status = match timeout(Duration::from_secs(5), child.wait()).await {
910        Ok(status) => status.context("`gh auth status` failed")?,
911        Err(_) => {
912            let _ = child.start_kill();
913            return Ok(false);
914        }
915    };
916
917    Ok(status.success())
918}
919
920fn copilot_config_path() -> Option<PathBuf> {
921    let base_dir = std::env::var_os("COPILOT_HOME")
922        .filter(|value| !value.is_empty())
923        .map(PathBuf::from)
924        .or_else(|| dirs::home_dir().map(|home| home.join(".copilot")))?;
925    Some(base_dir.join("config.json"))
926}
927
928fn auth_source_suffix(source: &CopilotAuthSource) -> String {
929    format!(" {}.", source.short_label())
930}
931
932fn resolve_copilot_host(config: &CopilotAuthConfig) -> Result<CopilotHost> {
933    let raw = config
934        .host
935        .as_deref()
936        .map(str::trim)
937        .filter(|value| !value.is_empty())
938        .map(ToString::to_string)
939        .or_else(|| {
940            std::env::var("GH_HOST")
941                .ok()
942                .map(|value| value.trim().to_string())
943                .filter(|value| !value.is_empty())
944        })
945        .unwrap_or_else(|| DEFAULT_HOST_URL.to_string());
946
947    CopilotHost::parse(&raw)
948}
949
950#[derive(Debug, Clone, PartialEq, Eq)]
951struct CopilotHost {
952    url: String,
953    gh_hostname: String,
954}
955
956impl CopilotHost {
957    fn parse(value: &str) -> Result<Self> {
958        let trimmed = value.trim();
959        if trimmed.is_empty() {
960            return Self::parse(DEFAULT_HOST_URL);
961        }
962
963        let normalized = if trimmed.contains("://") {
964            trimmed.to_string()
965        } else {
966            format!("https://{trimmed}")
967        };
968
969        let parsed = Url::parse(&normalized)
970            .with_context(|| format!("invalid GitHub Copilot host `{trimmed}`"))?;
971        let hostname = parsed
972            .host_str()
973            .ok_or_else(|| anyhow!("GitHub Copilot host `{trimmed}` is missing a hostname"))?;
974
975        let mut url = format!("{}://{}", parsed.scheme(), hostname);
976        if let Some(port) = parsed.port() {
977            url.push(':');
978            url.push_str(&port.to_string());
979        }
980        let path = parsed.path().trim_end_matches('/');
981        if !path.is_empty() && path != "/" {
982            url.push_str(path);
983        }
984
985        Ok(Self {
986            url,
987            gh_hostname: hostname.to_string(),
988        })
989    }
990
991    fn is_default(&self) -> bool {
992        self.url == DEFAULT_HOST_URL
993    }
994
995    fn matches_config_host(&self, value: &str) -> bool {
996        Self::parse(value)
997            .map(|candidate| candidate.url == self.url || candidate.gh_hostname == self.gh_hostname)
998            .unwrap_or_else(|_| value.trim().eq_ignore_ascii_case(&self.gh_hostname))
999    }
1000}
1001
1002fn copilot_token_key_matches_host(host: &CopilotHost, key: &str) -> bool {
1003    let trimmed = key.trim();
1004    if trimmed.is_empty() {
1005        return false;
1006    }
1007    if host.matches_config_host(trimmed) {
1008        return true;
1009    }
1010
1011    trimmed
1012        .rsplit_once(':')
1013        .map(|(candidate_host, _)| host.matches_config_host(candidate_host))
1014        .unwrap_or(false)
1015}
1016
1017fn copilot_token_login_for_host(host: &CopilotHost, key: &str) -> Option<String> {
1018    let trimmed = key.trim();
1019    if trimmed.is_empty() || !copilot_token_key_matches_host(host, trimmed) {
1020        return None;
1021    }
1022
1023    let (candidate_host, login) = trimmed.rsplit_once(':')?;
1024    host.matches_config_host(candidate_host)
1025        .then(|| login.trim().to_string())
1026        .filter(|login| !login.is_empty())
1027}
1028
1029#[derive(Debug)]
1030enum CopilotAuthSource {
1031    Environment(&'static str),
1032    StoredCredentials { login: Option<String> },
1033    GitHubCli,
1034}
1035
1036impl CopilotAuthSource {
1037    fn short_label(&self) -> String {
1038        match self {
1039            Self::Environment(name) => format!("Authentication source detected via {name}"),
1040            Self::StoredCredentials { .. } => {
1041                "Stored Copilot CLI credentials were detected".to_string()
1042            }
1043            Self::GitHubCli => "GitHub CLI authentication was detected".to_string(),
1044        }
1045    }
1046
1047    fn message(&self, host: &CopilotHost) -> String {
1048        match self {
1049            Self::Environment(name) => format!("Using {name} for GitHub Copilot authentication."),
1050            Self::StoredCredentials { login: Some(login) } => format!(
1051                "Using Copilot CLI stored credentials for {login} on {}.",
1052                host.gh_hostname
1053            ),
1054            Self::StoredCredentials { login: None } => format!(
1055                "Using Copilot CLI stored credentials on {}.",
1056                host.gh_hostname
1057            ),
1058            Self::GitHubCli => format!(
1059                "Using GitHub CLI authentication fallback on {}.",
1060                host.gh_hostname
1061            ),
1062        }
1063    }
1064}
1065
1066#[derive(Debug, Default, Deserialize)]
1067struct CopilotCliConfig {
1068    #[serde(default)]
1069    logged_in_users: Vec<CopilotCliUser>,
1070    #[serde(default)]
1071    last_logged_in_user: Option<CopilotCliUser>,
1072    #[serde(default)]
1073    copilot_tokens: HashMap<String, String>,
1074}
1075
1076#[derive(Debug, Clone, Default, Deserialize)]
1077struct CopilotCliUser {
1078    #[serde(default)]
1079    host: Option<String>,
1080    #[serde(default)]
1081    login: Option<String>,
1082}
1083
1084impl CopilotCliUser {
1085    fn host_matches(&self, host: &CopilotHost) -> bool {
1086        self.host
1087            .as_deref()
1088            .map(|candidate| host.matches_config_host(candidate))
1089            .unwrap_or(false)
1090    }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::{
1096        CapturedCommandState, CapturedLine, CapturedStream, CommandKind, CopilotAuthSource,
1097        CopilotCliConfig, CopilotCliUser, CopilotHost, copilot_token_login_for_host,
1098        drain_complete_lines, env_auth_source_with, extract_account_from_status_message,
1099        login_command_args, logout_command_args, missing_copilot_command_help_lines_with,
1100        normalize_captured_line, parse_device_flow_code,
1101    };
1102
1103    #[test]
1104    fn env_auth_source_respects_documented_precedence() {
1105        let source = env_auth_source_with(|name| match name {
1106            "COPILOT_GITHUB_TOKEN" => None,
1107            "GH_TOKEN" => Some("ghp_example".to_string()),
1108            "GITHUB_TOKEN" => Some("github_example".to_string()),
1109            _ => None,
1110        });
1111
1112        assert!(matches!(
1113            source,
1114            Some(CopilotAuthSource::Environment("GH_TOKEN"))
1115        ));
1116    }
1117
1118    #[test]
1119    fn host_parser_accepts_bare_hostname() {
1120        let host = CopilotHost::parse("github.com").unwrap();
1121
1122        assert_eq!(host.url, "https://github.com");
1123        assert_eq!(host.gh_hostname, "github.com");
1124    }
1125
1126    #[test]
1127    fn stored_credentials_match_host() {
1128        let host = CopilotHost::parse("https://github.com").unwrap();
1129        let config = CopilotCliConfig {
1130            logged_in_users: vec![CopilotCliUser {
1131                host: Some("https://github.com".to_string()),
1132                login: Some("vinhnx".to_string()),
1133            }],
1134            ..CopilotCliConfig::default()
1135        };
1136
1137        let matched = config
1138            .logged_in_users
1139            .iter()
1140            .find(|user| user.host_matches(&host))
1141            .and_then(|user| user.login.as_deref());
1142
1143        assert_eq!(matched, Some("vinhnx"));
1144    }
1145
1146    #[test]
1147    fn stored_plaintext_token_keys_match_host_and_extract_login() {
1148        let host = CopilotHost::parse("https://example.ghe.com:8443").unwrap();
1149
1150        let login = copilot_token_login_for_host(&host, "https://example.ghe.com:8443:vinhnx");
1151
1152        assert_eq!(login.as_deref(), Some("vinhnx"));
1153    }
1154
1155    #[test]
1156    fn auth_source_message_does_not_include_token_value() {
1157        let host = CopilotHost::parse("https://github.com").unwrap();
1158        let message = CopilotAuthSource::Environment("GH_TOKEN").message(&host);
1159
1160        assert!(!message.contains("ghp_"));
1161        assert_eq!(message, "Using GH_TOKEN for GitHub Copilot authentication.");
1162    }
1163
1164    #[test]
1165    fn device_flow_code_parser_extracts_url_and_code() {
1166        let parsed = parse_device_flow_code(
1167            "To authenticate, visit https://github.com/login/device and enter code D8E1-101D.",
1168        );
1169
1170        assert_eq!(
1171            parsed,
1172            Some((
1173                "https://github.com/login/device".to_string(),
1174                "D8E1-101D".to_string()
1175            ))
1176        );
1177    }
1178
1179    #[test]
1180    fn device_flow_code_parser_handles_ansi_styled_output() {
1181        let normalized = normalize_captured_line(
1182            "\u{1b}[1mTo authenticate, visit https://github.com/login/device and enter code D8E1-101D.\u{1b}[0m",
1183        );
1184
1185        let parsed = parse_device_flow_code(&normalized);
1186
1187        assert_eq!(
1188            parsed,
1189            Some((
1190                "https://github.com/login/device".to_string(),
1191                "D8E1-101D".to_string()
1192            ))
1193        );
1194    }
1195
1196    #[test]
1197    fn drain_complete_lines_splits_on_carriage_return_and_newline() {
1198        let mut buffer = b"To authenticate, visit https://github.com/login/device and enter code D8E1-101D.\rWaiting for authorization...\npartial".to_vec();
1199
1200        let lines = drain_complete_lines(&mut buffer);
1201
1202        assert_eq!(
1203            lines,
1204            vec![
1205                "To authenticate, visit https://github.com/login/device and enter code D8E1-101D."
1206                    .to_string(),
1207                "Waiting for authorization...".to_string(),
1208            ]
1209        );
1210        assert_eq!(buffer, b"partial");
1211    }
1212
1213    #[test]
1214    fn login_args_include_host_for_non_default_host() {
1215        let host = CopilotHost::parse("https://example.ghe.com").unwrap();
1216
1217        let args = login_command_args(&host);
1218
1219        assert_eq!(args, vec!["login", "--host", "https://example.ghe.com"]);
1220    }
1221
1222    #[test]
1223    fn logout_args_do_not_include_host() {
1224        let args = logout_command_args();
1225
1226        assert_eq!(args, vec!["logout"]);
1227    }
1228
1229    #[test]
1230    fn captured_failure_prefers_specific_error_over_help_hint() {
1231        let mut state = CapturedCommandState::default();
1232
1233        state
1234            .handle_line(
1235                CommandKind::Logout,
1236                CapturedLine {
1237                    stream: CapturedStream::Stderr,
1238                    text: "ERROR: SecItemCopyMatching failed -50".to_string(),
1239                },
1240                &mut |_| Ok(()),
1241            )
1242            .unwrap();
1243        state
1244            .handle_line(
1245                CommandKind::Logout,
1246                CapturedLine {
1247                    stream: CapturedStream::Stderr,
1248                    text: "Try 'copilot --help' for more information.".to_string(),
1249                },
1250                &mut |_| Ok(()),
1251            )
1252            .unwrap();
1253
1254        assert_eq!(
1255            state.last_safe_message.as_deref(),
1256            Some(
1257                "GitHub Copilot CLI failed to access the macOS Keychain while clearing credentials."
1258            )
1259        );
1260    }
1261
1262    #[test]
1263    fn account_extraction_reads_stored_credential_message() {
1264        let login = extract_account_from_status_message(
1265            "Using Copilot CLI stored credentials for vinhnx on github.com.",
1266        );
1267
1268        assert_eq!(login, Some("vinhnx"));
1269    }
1270
1271    #[test]
1272    fn missing_copilot_help_explains_required_cli_and_optional_gh() {
1273        let lines = missing_copilot_command_help_lines_with("copilot", false, false).expect("help");
1274
1275        assert!(lines.iter().any(|line| line.contains("Install `copilot`")));
1276        assert!(
1277            lines
1278                .iter()
1279                .any(|line| line.contains("`gh` is also not installed"))
1280        );
1281        assert!(
1282            lines
1283                .iter()
1284                .any(|line| line.contains("docs/providers/copilot.md"))
1285        );
1286    }
1287
1288    #[test]
1289    fn missing_copilot_help_is_suppressed_when_command_exists() {
1290        let lines = missing_copilot_command_help_lines_with("copilot", true, true);
1291
1292        assert!(lines.is_none());
1293    }
1294}