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}