Skip to main content

vtcode_llm/copilot/
auth.rs

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