Skip to main content

ai_usagebar/copilot/
credentials.rs

1//! Obtain a GitHub OAuth token from the official GitHub CLI without handling
2//! its credential files ourselves.
3
4use std::io;
5use std::process::{Command, Stdio};
6use std::time::Duration;
7
8use crate::error::{AppError, Result};
9use crate::vendor::vendor_secret_env_vars_to_remove;
10
11/// Where the GitHub CLI records a completed login. Its existence is the
12/// cheapest honest answer to "is Copilot signed in": the token itself only
13/// comes back from `gh auth token`, and a status list that runs a subprocess
14/// per provider is not a status list.
15///
16/// This follows `gh`'s own documented precedence exactly, because a path we
17/// invent is a wrong answer on somebody's machine: `GH_CONFIG_DIR`, then
18/// `$XDG_CONFIG_HOME/gh`, then `%AppData%\GitHub CLI` on Windows, then
19/// `~/.config/gh`.
20pub fn default_hosts_path() -> Result<std::path::PathBuf> {
21    hosts_path_with(
22        |name| std::env::var_os(name).filter(|value| !value.is_empty()),
23        crate::cache::home_dir()?,
24    )
25}
26
27/// Test seam for [`default_hosts_path`]: the environment and the home
28/// directory are the production inputs, so they are injected rather than read.
29pub fn hosts_path_with(
30    environment: impl Fn(&str) -> Option<std::ffi::OsString>,
31    home: std::path::PathBuf,
32) -> Result<std::path::PathBuf> {
33    if let Some(dir) = environment("GH_CONFIG_DIR") {
34        return Ok(std::path::PathBuf::from(dir).join("hosts.yml"));
35    }
36    if let Some(dir) = environment("XDG_CONFIG_HOME") {
37        return Ok(std::path::PathBuf::from(dir).join("gh").join("hosts.yml"));
38    }
39    if cfg!(windows)
40        && let Some(dir) = environment("AppData")
41    {
42        return Ok(std::path::PathBuf::from(dir)
43            .join("GitHub CLI")
44            .join("hosts.yml"));
45    }
46    Ok(home.join(".config").join("gh").join("hosts.yml"))
47}
48
49/// The deliberately narrow process description used to obtain the current
50/// GitHub CLI OAuth token. Keeping it data makes the subprocess boundary
51/// inspectable in tests and prevents a shell from entering this path.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct GhAuthTokenCommand {
54    pub program: std::path::PathBuf,
55    /// Owned rather than `[&'static str; 2]` because a named account appends
56    /// `--user <login>`. The shape stays fixed: `auth token` always, plus that
57    /// one pair and nothing else. `user` is validated against GitHub's login
58    /// grammar first, so a config value can never arrive here as a flag.
59    pub args: Vec<String>,
60    pub env_remove: Vec<&'static str>,
61}
62
63impl GhAuthTokenCommand {
64    /// `gh` has no canonical install path across distributions, so unlike
65    /// `grok` it is looked up on `PATH` by default. That makes the binary an
66    /// ambient choice, which is why `[copilot] gh_binary` exists: point it at
67    /// the trusted executable and the lookup stops being ambient.
68    pub fn standard(gh_binary: Option<&std::path::Path>) -> Self {
69        Self {
70            program: gh_binary.map_or_else(|| std::path::PathBuf::from("gh"), Into::into),
71            args: vec!["auth".to_string(), "token".to_string()],
72            // `gh auth token` must use its saved OAuth login, rather than an
73            // arbitrary provider token inherited from this process.
74            env_remove: vendor_secret_env_vars_to_remove(&[]),
75        }
76    }
77
78    /// The same command for one named account: `gh auth token --user <login>`.
79    ///
80    /// `login` is validated before it reaches argv. That check is the security
81    /// boundary this type exists for — `gh` is spawned without a shell, so the
82    /// risk is not quoting but a config value that *looks like a flag*
83    /// (`--hostname`, `-h`) and silently re-points the command at another
84    /// account or option. Rejecting anything outside GitHub's login grammar
85    /// closes that off by construction.
86    pub fn for_user(gh_binary: Option<&std::path::Path>, login: &str) -> Result<Self> {
87        validate_gh_login(login)?;
88        let mut command = Self::standard(gh_binary);
89        command.args.push("--user".to_string());
90        command.args.push(login.to_string());
91        Ok(command)
92    }
93}
94
95/// GitHub's own login grammar: 1-39 characters of ASCII alphanumerics and
96/// single hyphens, never leading or trailing. Anything else — a flag, a path,
97/// a space, a shell metacharacter, an empty string — is refused with the
98/// offending value quoted, because the usual cause is a typo in `config.toml`.
99fn validate_gh_login(login: &str) -> Result<()> {
100    let bad = |why: &str| {
101        Err(AppError::Other(format!(
102            "GitHub Copilot: {login:?} is not a valid GitHub login ({why}). \
103             Use the account's login name, as shown by `gh auth status`."
104        )))
105    };
106    if login.is_empty() || login.len() > 39 {
107        return bad("1 to 39 characters");
108    }
109    if !login
110        .bytes()
111        .all(|b| b.is_ascii_alphanumeric() || b == b'-')
112    {
113        return bad("letters, digits and hyphens only");
114    }
115    if login.starts_with('-') || login.ends_with('-') || login.contains("--") {
116        return bad("no leading, trailing or repeated hyphens");
117    }
118    Ok(())
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct GhAuthTokenOutput {
123    pub success: bool,
124    pub stdout: Vec<u8>,
125}
126
127/// Injectable command boundary. Tests supply a fake runner, so they never
128/// execute `gh`, inspect a real GitHub config directory, or use ambient env.
129pub trait GhAuthTokenRunner {
130    fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput>;
131}
132
133pub struct SystemGhAuthTokenRunner;
134
135const GH_AUTH_TOKEN_TIMEOUT: Duration = Duration::from_secs(5);
136
137impl GhAuthTokenRunner for SystemGhAuthTokenRunner {
138    fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
139        let mut process = Command::new(&command.program);
140        process
141            .args(&command.args)
142            .stdin(Stdio::null())
143            .stdout(Stdio::piped())
144            .stderr(Stdio::null());
145        for variable in &command.env_remove {
146            process.env_remove(variable);
147        }
148        // Same reason as the SuperGrok ACP child: no console window from the
149        // tray, or the popover loses focus and closes.
150        #[cfg(windows)]
151        {
152            use std::os::windows::process::CommandExt;
153            process.creation_flags(crate::process::CREATE_NO_WINDOW);
154        }
155        let output = process.output()?;
156        Ok(GhAuthTokenOutput {
157            success: output.status.success(),
158            stdout: output.stdout,
159        })
160    }
161}
162
163impl SystemGhAuthTokenRunner {
164    /// Run the credential helper without parking a current-thread runtime.
165    /// The owned task deliberately outlives a cancelled caller: it still
166    /// enforces the deadline, kills the child, and reaps it instead of leaving
167    /// an orphaned `gh` process behind.
168    async fn run_async(
169        &self,
170        command: GhAuthTokenCommand,
171        timeout: Duration,
172    ) -> io::Result<GhAuthTokenOutput> {
173        tokio::spawn(async move { run_system_async(command, timeout).await })
174            .await
175            .map_err(|error| io::Error::other(format!("gh credential task failed: {error}")))?
176    }
177}
178
179async fn run_system_async(
180    command: GhAuthTokenCommand,
181    timeout: Duration,
182) -> io::Result<GhAuthTokenOutput> {
183    use tokio::io::AsyncReadExt;
184
185    let mut process = tokio::process::Command::new(&command.program);
186    process
187        .args(&command.args)
188        .stdin(Stdio::null())
189        .stdout(Stdio::piped())
190        .stderr(Stdio::null())
191        .kill_on_drop(true);
192    for variable in &command.env_remove {
193        process.env_remove(variable);
194    }
195    #[cfg(windows)]
196    {
197        process.creation_flags(crate::process::CREATE_NO_WINDOW);
198    }
199    let mut child = process.spawn()?;
200    let mut stdout = child
201        .stdout
202        .take()
203        .ok_or_else(|| io::Error::other("gh stdout pipe was unavailable"))?;
204    let mut bytes = Vec::new();
205    let completion = async {
206        let ((), status) = tokio::try_join!(
207            async {
208                stdout.read_to_end(&mut bytes).await?;
209                Ok::<(), io::Error>(())
210            },
211            child.wait()
212        )?;
213        Ok::<_, io::Error>((status, bytes))
214    };
215    match tokio::time::timeout(timeout, completion).await {
216        Ok(result) => {
217            let (status, stdout) = result?;
218            Ok(GhAuthTokenOutput {
219                success: status.success(),
220                stdout,
221            })
222        }
223        Err(_) => {
224            // `kill` waits on Windows; the explicit `wait` is still required
225            // on Unix to reap the child. Ignore AlreadyExited races and keep
226            // the timeout diagnosis stable.
227            let _ = child.kill().await;
228            let _ = child.wait().await;
229            Err(io::Error::new(
230                io::ErrorKind::TimedOut,
231                "gh auth token exceeded its deadline",
232            ))
233        }
234    }
235}
236
237/// `login` selects one of several `gh` accounts; `None` keeps the historical
238/// behavior of using whichever account `gh` has active.
239pub fn resolve_with(
240    runner: &impl GhAuthTokenRunner,
241    gh_binary: Option<&std::path::Path>,
242    login: Option<&str>,
243) -> Result<String> {
244    let command = match login {
245        Some(login) => GhAuthTokenCommand::for_user(gh_binary, login)?,
246        None => GhAuthTokenCommand::standard(gh_binary),
247    };
248    let output = match runner.run(&command) {
249        Ok(output) => output,
250        Err(error) if error.kind() == io::ErrorKind::NotFound => {
251            return Err(login_error(
252                "GitHub CLI (`gh`) is not installed. Install it, then run",
253            ));
254        }
255        Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
256    };
257    token_from_output(output, login)
258}
259
260/// Async production counterpart to [`resolve_with`]. The command and output
261/// interpretation stay identical; only the child wait gains reactor safety
262/// and a bounded lifetime.
263pub async fn resolve_async(
264    gh_binary: Option<&std::path::Path>,
265    login: Option<&str>,
266) -> Result<String> {
267    let command = match login {
268        Some(login) => GhAuthTokenCommand::for_user(gh_binary, login)?,
269        None => GhAuthTokenCommand::standard(gh_binary),
270    };
271    let output = match SystemGhAuthTokenRunner
272        .run_async(command, GH_AUTH_TOKEN_TIMEOUT)
273        .await
274    {
275        Ok(output) => output,
276        Err(error) if error.kind() == io::ErrorKind::NotFound => {
277            return Err(login_error(
278                "GitHub CLI (`gh`) is not installed. Install it, then run",
279            ));
280        }
281        Err(error) if error.kind() == io::ErrorKind::TimedOut => {
282            return Err(AppError::Other(
283                "GitHub Copilot: timed out while asking `gh` for the saved login token; try again."
284                    .into(),
285            ));
286        }
287        Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
288    };
289    token_from_output(output, login)
290}
291
292fn token_from_output(output: GhAuthTokenOutput, login: Option<&str>) -> Result<String> {
293    if !output.success {
294        return match login {
295            Some(login) => Err(AppError::Credentials(format!(
296                "GitHub Copilot: `gh` has no token for {login:?}. Check the login name \
297                 against `gh auth status`, then `gh auth login --web` for that account."
298            ))),
299            None => Err(login_error("GitHub CLI is not logged in. Run")),
300        };
301    }
302    String::from_utf8(output.stdout)
303        .ok()
304        .map(|value| value.trim().to_string())
305        .filter(|value| !value.is_empty())
306        .ok_or_else(|| login_error("GitHub CLI returned no OAuth token. Run"))
307}
308
309fn login_error(prefix: &str) -> AppError {
310    AppError::Credentials(format!(
311        "GitHub Copilot: {prefix} `gh auth login --web`, then select GitHub Copilot as the primary provider in Settings."
312    ))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use std::cell::RefCell;
319
320    #[cfg(unix)]
321    fn executable_script(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
322        use std::os::unix::fs::PermissionsExt;
323        let dir = tempfile::tempdir().unwrap();
324        let path = dir.path().join("fake-gh");
325        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
326        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
327        (dir, path)
328    }
329
330    #[cfg(unix)]
331    #[tokio::test(flavor = "current_thread")]
332    async fn a_slow_helper_does_not_stall_the_reactor_and_is_timed_out() {
333        // Use shell builtins only: hermetic builders such as Nix do not
334        // expose `/bin/sleep`, which would make this fixture exit immediately
335        // and test the non-zero-status path instead of the deadline.
336        let (_dir, helper) = executable_script("while :; do :; done");
337        let command = GhAuthTokenCommand::standard(Some(&helper));
338        let start = std::time::Instant::now();
339        let (timer_elapsed, result) = tokio::join!(
340            async {
341                tokio::time::sleep(Duration::from_millis(20)).await;
342                start.elapsed()
343            },
344            SystemGhAuthTokenRunner.run_async(command, Duration::from_millis(80))
345        );
346        assert!(
347            timer_elapsed < Duration::from_millis(60),
348            "timer took {timer_elapsed:?}"
349        );
350        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut);
351        assert!(start.elapsed() < Duration::from_secs(2));
352    }
353
354    #[cfg(unix)]
355    #[tokio::test]
356    async fn async_resolution_keeps_normal_and_named_account_argv() {
357        let dir = tempfile::tempdir().unwrap();
358        let helper = dir.path().join("fake-gh");
359        let args = dir.path().join("args");
360        use std::os::unix::fs::PermissionsExt;
361        std::fs::write(
362            &helper,
363            format!(
364                "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf 'async-token\\n'\n",
365                args.display()
366            ),
367        )
368        .unwrap();
369        std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o700)).unwrap();
370
371        assert_eq!(
372            resolve_async(Some(&helper), Some("octocat-work"))
373                .await
374                .unwrap(),
375            "async-token"
376        );
377        assert_eq!(
378            std::fs::read_to_string(args).unwrap(),
379            "auth\ntoken\n--user\noctocat-work\n"
380        );
381    }
382
383    struct FakeRunner {
384        result: io::Result<GhAuthTokenOutput>,
385        command: RefCell<Option<GhAuthTokenCommand>>,
386    }
387
388    impl GhAuthTokenRunner for FakeRunner {
389        fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
390            *self.command.borrow_mut() = Some(command.clone());
391            self.result
392                .as_ref()
393                .map(Clone::clone)
394                .map_err(|error| io::Error::new(error.kind(), "fake gh failure"))
395        }
396    }
397
398    #[test]
399    fn runs_only_fixed_gh_auth_token_command_and_returns_trimmed_token() {
400        let runner = FakeRunner {
401            result: Ok(GhAuthTokenOutput {
402                success: true,
403                stdout: b"test-github-oauth-token\n".to_vec(),
404            }),
405            command: RefCell::new(None),
406        };
407
408        assert_eq!(
409            resolve_with(&runner, None, None).unwrap(),
410            "test-github-oauth-token"
411        );
412        let command = runner.command.into_inner().unwrap();
413        assert_eq!(command.program, std::path::Path::new("gh"));
414        assert_eq!(command.args, ["auth", "token"]);
415        assert!(command.env_remove.contains(&"ZAI_API_KEY"));
416        assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
417        assert!(command.env_remove.contains(&"GH_TOKEN"));
418        assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
419    }
420
421    /// The whole point of keeping this command as data: a named account must
422    /// expand the argv by exactly one flag pair and nothing else.
423    #[test]
424    fn a_named_account_appends_only_user_to_the_fixed_argv() {
425        let runner = FakeRunner {
426            result: Ok(GhAuthTokenOutput {
427                success: true,
428                stdout: b"work-token\n".to_vec(),
429            }),
430            command: RefCell::new(None),
431        };
432        assert_eq!(
433            resolve_with(&runner, None, Some("octocat-work")).unwrap(),
434            "work-token"
435        );
436        let command = runner.command.into_inner().unwrap();
437        assert_eq!(command.args, ["auth", "token", "--user", "octocat-work"]);
438        // Env scrubbing is not weakened by the account path.
439        assert!(command.env_remove.contains(&"GH_TOKEN"));
440        assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
441        assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
442    }
443
444    /// `gh` is spawned without a shell, so the risk is not quoting — it is a
445    /// config value that reads as a *flag* and re-points the command at
446    /// another option or account. GitHub's login grammar excludes every such
447    /// value, so validation happens before argv is built.
448    #[test]
449    fn a_login_outside_githubs_grammar_never_reaches_argv() {
450        for bad in [
451            "--hostname",           // a flag
452            "-u",                   // a short flag
453            "octocat --hostname x", // smuggled second flag
454            "octo cat",             // whitespace
455            "octo/cat",             // path separator
456            "octo;cat",             // shell metacharacter
457            "octo@cat",             // an email, the likely typo
458            "",                     // empty
459            "-octocat",             // leading hyphen
460            "octocat-",             // trailing hyphen
461            "octo--cat",            // repeated hyphen
462            "ã",                    // non-ASCII
463        ] {
464            let error = GhAuthTokenCommand::for_user(None, bad)
465                .expect_err(&format!("{bad:?} must be refused"))
466                .to_string();
467            assert!(
468                error.contains("not a valid GitHub login"),
469                "{bad:?}: {error}"
470            );
471        }
472        // A realistic login still works, including digits and inner hyphens.
473        let command = GhAuthTokenCommand::for_user(None, "octo-cat-99").unwrap();
474        assert_eq!(command.args, ["auth", "token", "--user", "octo-cat-99"]);
475        // 39 characters is GitHub's maximum; 40 is not.
476        assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(39)).is_ok());
477        assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(40)).is_err());
478    }
479
480    /// A signed-out *named* account must not be told to run `gh auth login`
481    /// as though nothing were signed in — the usual cause is a mistyped login
482    /// while another account works fine.
483    #[test]
484    fn a_signed_out_named_account_names_the_account_in_the_error() {
485        let runner = FakeRunner {
486            result: Ok(GhAuthTokenOutput {
487                success: false,
488                stdout: b"never-echo-gh-output".to_vec(),
489            }),
490            command: RefCell::new(None),
491        };
492        let error = resolve_with(&runner, None, Some("octocat"))
493            .unwrap_err()
494            .to_string();
495        assert!(error.contains("octocat"), "{error}");
496        assert!(error.contains("gh auth status"), "{error}");
497        assert!(!error.contains("never-echo-gh-output"), "{error}");
498    }
499
500    /// `gh` has no canonical path, so `PATH` is the sensible default — but it
501    /// is still an ambient choice about which binary runs on every refresh.
502    /// `[copilot] gh_binary` is how a user pins it, so the setting has to
503    /// actually reach the spawned command.
504    /// `gh`'s own precedence, in order. A path we invent instead is a wrong
505    /// answer on somebody's machine: an XDG user's login would read as absent
506    /// and report Copilot as never signed in.
507    #[test]
508    fn hosts_path_follows_the_github_cli_precedence() {
509        use std::ffi::OsString;
510        use std::path::PathBuf;
511        let home = PathBuf::from("/home/u");
512        let only = |wanted: &'static str, value: &'static str| {
513            move |name: &str| (name == wanted).then(|| OsString::from(value))
514        };
515
516        assert_eq!(
517            hosts_path_with(only("GH_CONFIG_DIR", "/cfg/gh"), home.clone()).unwrap(),
518            PathBuf::from("/cfg/gh/hosts.yml"),
519            "GH_CONFIG_DIR is used verbatim, with no `gh` segment appended"
520        );
521        assert_eq!(
522            hosts_path_with(only("XDG_CONFIG_HOME", "/xdg"), home.clone()).unwrap(),
523            PathBuf::from("/xdg/gh/hosts.yml")
524        );
525        assert_eq!(
526            hosts_path_with(|_| None, home.clone()).unwrap(),
527            home.join(".config").join("gh").join("hosts.yml"),
528            "with nothing set, the home convention"
529        );
530
531        // GH_CONFIG_DIR outranks XDG_CONFIG_HOME.
532        let both = |name: &str| match name {
533            "GH_CONFIG_DIR" => Some(OsString::from("/cfg/gh")),
534            "XDG_CONFIG_HOME" => Some(OsString::from("/xdg")),
535            _ => None,
536        };
537        assert_eq!(
538            hosts_path_with(both, home.clone()).unwrap(),
539            PathBuf::from("/cfg/gh/hosts.yml")
540        );
541
542        // `AppData` is Windows-only and ranks below XDG, so it must not
543        // capture a Linux or macOS machine that happens to have it set.
544        let appdata =
545            hosts_path_with(only("AppData", "C:/Users/u/AppData/Roaming"), home.clone()).unwrap();
546        if cfg!(windows) {
547            assert_eq!(
548                appdata,
549                PathBuf::from("C:/Users/u/AppData/Roaming/GitHub CLI/hosts.yml")
550            );
551        } else {
552            assert_eq!(appdata, home.join(".config").join("gh").join("hosts.yml"));
553        }
554    }
555
556    #[test]
557    fn a_configured_gh_binary_replaces_the_path_lookup() {
558        let pinned = std::path::Path::new("/opt/github/bin/gh");
559        assert_eq!(
560            GhAuthTokenCommand::standard(Some(pinned)).program,
561            pinned,
562            "a pinned binary must be used verbatim"
563        );
564        assert_eq!(
565            GhAuthTokenCommand::standard(None).program,
566            std::path::Path::new("gh"),
567            "unset still means the PATH lookup"
568        );
569        // The rest of the boundary is fixed either way.
570        for command in [
571            GhAuthTokenCommand::standard(Some(pinned)),
572            GhAuthTokenCommand::standard(None),
573        ] {
574            assert_eq!(command.args, ["auth", "token"]);
575            assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
576        }
577    }
578
579    #[test]
580    fn login_failure_never_echoes_gh_output() {
581        let runner = FakeRunner {
582            result: Ok(GhAuthTokenOutput {
583                success: false,
584                stdout: b"private-token-or-error".to_vec(),
585            }),
586            command: RefCell::new(None),
587        };
588
589        let error = resolve_with(&runner, None, None).unwrap_err().to_string();
590        assert!(error.contains("gh auth login --web"));
591        assert!(!error.contains("private-token-or-error"));
592    }
593}