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}