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};
6
7use crate::error::{AppError, Result};
8use crate::vendor::vendor_secret_env_vars_to_remove;
9
10/// Where the GitHub CLI records a completed login. Its existence is the
11/// cheapest honest answer to "is Copilot signed in": the token itself only
12/// comes back from `gh auth token`, and a status list that runs a subprocess
13/// per provider is not a status list.
14///
15/// This follows `gh`'s own documented precedence exactly, because a path we
16/// invent is a wrong answer on somebody's machine: `GH_CONFIG_DIR`, then
17/// `$XDG_CONFIG_HOME/gh`, then `%AppData%\GitHub CLI` on Windows, then
18/// `~/.config/gh`.
19pub fn default_hosts_path() -> Result<std::path::PathBuf> {
20    hosts_path_with(
21        |name| std::env::var_os(name).filter(|value| !value.is_empty()),
22        crate::cache::home_dir()?,
23    )
24}
25
26/// Test seam for [`default_hosts_path`]: the environment and the home
27/// directory are the production inputs, so they are injected rather than read.
28pub fn hosts_path_with(
29    environment: impl Fn(&str) -> Option<std::ffi::OsString>,
30    home: std::path::PathBuf,
31) -> Result<std::path::PathBuf> {
32    if let Some(dir) = environment("GH_CONFIG_DIR") {
33        return Ok(std::path::PathBuf::from(dir).join("hosts.yml"));
34    }
35    if let Some(dir) = environment("XDG_CONFIG_HOME") {
36        return Ok(std::path::PathBuf::from(dir).join("gh").join("hosts.yml"));
37    }
38    if cfg!(windows)
39        && let Some(dir) = environment("AppData")
40    {
41        return Ok(std::path::PathBuf::from(dir)
42            .join("GitHub CLI")
43            .join("hosts.yml"));
44    }
45    Ok(home.join(".config").join("gh").join("hosts.yml"))
46}
47
48/// The deliberately narrow process description used to obtain the current
49/// GitHub CLI OAuth token. Keeping it data makes the subprocess boundary
50/// inspectable in tests and prevents a shell from entering this path.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct GhAuthTokenCommand {
53    pub program: std::path::PathBuf,
54    /// Owned rather than `[&'static str; 2]` because a named account appends
55    /// `--user <login>`. The shape stays fixed: `auth token` always, plus that
56    /// one pair and nothing else. `user` is validated against GitHub's login
57    /// grammar first, so a config value can never arrive here as a flag.
58    pub args: Vec<String>,
59    pub env_remove: Vec<&'static str>,
60}
61
62impl GhAuthTokenCommand {
63    /// `gh` has no canonical install path across distributions, so unlike
64    /// `grok` it is looked up on `PATH` by default. That makes the binary an
65    /// ambient choice, which is why `[copilot] gh_binary` exists: point it at
66    /// the trusted executable and the lookup stops being ambient.
67    pub fn standard(gh_binary: Option<&std::path::Path>) -> Self {
68        Self {
69            program: gh_binary.map_or_else(|| std::path::PathBuf::from("gh"), Into::into),
70            args: vec!["auth".to_string(), "token".to_string()],
71            // `gh auth token` must use its saved OAuth login, rather than an
72            // arbitrary provider token inherited from this process.
73            env_remove: vendor_secret_env_vars_to_remove(&[]),
74        }
75    }
76
77    /// The same command for one named account: `gh auth token --user <login>`.
78    ///
79    /// `login` is validated before it reaches argv. That check is the security
80    /// boundary this type exists for — `gh` is spawned without a shell, so the
81    /// risk is not quoting but a config value that *looks like a flag*
82    /// (`--hostname`, `-h`) and silently re-points the command at another
83    /// account or option. Rejecting anything outside GitHub's login grammar
84    /// closes that off by construction.
85    pub fn for_user(gh_binary: Option<&std::path::Path>, login: &str) -> Result<Self> {
86        validate_gh_login(login)?;
87        let mut command = Self::standard(gh_binary);
88        command.args.push("--user".to_string());
89        command.args.push(login.to_string());
90        Ok(command)
91    }
92}
93
94/// GitHub's own login grammar: 1-39 characters of ASCII alphanumerics and
95/// single hyphens, never leading or trailing. Anything else — a flag, a path,
96/// a space, a shell metacharacter, an empty string — is refused with the
97/// offending value quoted, because the usual cause is a typo in `config.toml`.
98fn validate_gh_login(login: &str) -> Result<()> {
99    let bad = |why: &str| {
100        Err(AppError::Other(format!(
101            "GitHub Copilot: {login:?} is not a valid GitHub login ({why}). \
102             Use the account's login name, as shown by `gh auth status`."
103        )))
104    };
105    if login.is_empty() || login.len() > 39 {
106        return bad("1 to 39 characters");
107    }
108    if !login
109        .bytes()
110        .all(|b| b.is_ascii_alphanumeric() || b == b'-')
111    {
112        return bad("letters, digits and hyphens only");
113    }
114    if login.starts_with('-') || login.ends_with('-') || login.contains("--") {
115        return bad("no leading, trailing or repeated hyphens");
116    }
117    Ok(())
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct GhAuthTokenOutput {
122    pub success: bool,
123    pub stdout: Vec<u8>,
124}
125
126/// Injectable command boundary. Tests supply a fake runner, so they never
127/// execute `gh`, inspect a real GitHub config directory, or use ambient env.
128pub trait GhAuthTokenRunner {
129    fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput>;
130}
131
132pub struct SystemGhAuthTokenRunner;
133
134impl GhAuthTokenRunner for SystemGhAuthTokenRunner {
135    fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
136        let mut process = Command::new(&command.program);
137        process
138            .args(&command.args)
139            .stdin(Stdio::null())
140            .stdout(Stdio::piped())
141            .stderr(Stdio::null());
142        for variable in &command.env_remove {
143            process.env_remove(variable);
144        }
145        // Same reason as the SuperGrok ACP child: no console window from the
146        // tray, or the popover loses focus and closes.
147        #[cfg(windows)]
148        {
149            use std::os::windows::process::CommandExt;
150            process.creation_flags(crate::process::CREATE_NO_WINDOW);
151        }
152        let output = process.output()?;
153        Ok(GhAuthTokenOutput {
154            success: output.status.success(),
155            stdout: output.stdout,
156        })
157    }
158}
159
160/// `login` selects one of several `gh` accounts; `None` keeps the historical
161/// behavior of using whichever account `gh` has active.
162pub fn resolve_with(
163    runner: &impl GhAuthTokenRunner,
164    gh_binary: Option<&std::path::Path>,
165    login: Option<&str>,
166) -> Result<String> {
167    let command = match login {
168        Some(login) => GhAuthTokenCommand::for_user(gh_binary, login)?,
169        None => GhAuthTokenCommand::standard(gh_binary),
170    };
171    let output = match runner.run(&command) {
172        Ok(output) => output,
173        Err(error) if error.kind() == io::ErrorKind::NotFound => {
174            return Err(login_error(
175                "GitHub CLI (`gh`) is not installed. Install it, then run",
176            ));
177        }
178        Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
179    };
180    if !output.success {
181        return match login {
182            Some(login) => Err(AppError::Credentials(format!(
183                "GitHub Copilot: `gh` has no token for {login:?}. Check the login name \
184                 against `gh auth status`, then `gh auth login --web` for that account."
185            ))),
186            None => Err(login_error("GitHub CLI is not logged in. Run")),
187        };
188    }
189    let token = String::from_utf8(output.stdout)
190        .ok()
191        .map(|value| value.trim().to_string())
192        .filter(|value| !value.is_empty())
193        .ok_or_else(|| login_error("GitHub CLI returned no OAuth token. Run"))?;
194    Ok(token)
195}
196
197fn login_error(prefix: &str) -> AppError {
198    AppError::Credentials(format!(
199        "GitHub Copilot: {prefix} `gh auth login --web`, then select GitHub Copilot as the primary provider in Settings."
200    ))
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use std::cell::RefCell;
207
208    struct FakeRunner {
209        result: io::Result<GhAuthTokenOutput>,
210        command: RefCell<Option<GhAuthTokenCommand>>,
211    }
212
213    impl GhAuthTokenRunner for FakeRunner {
214        fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
215            *self.command.borrow_mut() = Some(command.clone());
216            self.result
217                .as_ref()
218                .map(Clone::clone)
219                .map_err(|error| io::Error::new(error.kind(), "fake gh failure"))
220        }
221    }
222
223    #[test]
224    fn runs_only_fixed_gh_auth_token_command_and_returns_trimmed_token() {
225        let runner = FakeRunner {
226            result: Ok(GhAuthTokenOutput {
227                success: true,
228                stdout: b"test-github-oauth-token\n".to_vec(),
229            }),
230            command: RefCell::new(None),
231        };
232
233        assert_eq!(
234            resolve_with(&runner, None, None).unwrap(),
235            "test-github-oauth-token"
236        );
237        let command = runner.command.into_inner().unwrap();
238        assert_eq!(command.program, std::path::Path::new("gh"));
239        assert_eq!(command.args, ["auth", "token"]);
240        assert!(command.env_remove.contains(&"ZAI_API_KEY"));
241        assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
242        assert!(command.env_remove.contains(&"GH_TOKEN"));
243        assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
244    }
245
246    /// The whole point of keeping this command as data: a named account must
247    /// expand the argv by exactly one flag pair and nothing else.
248    #[test]
249    fn a_named_account_appends_only_user_to_the_fixed_argv() {
250        let runner = FakeRunner {
251            result: Ok(GhAuthTokenOutput {
252                success: true,
253                stdout: b"work-token\n".to_vec(),
254            }),
255            command: RefCell::new(None),
256        };
257        assert_eq!(
258            resolve_with(&runner, None, Some("octocat-work")).unwrap(),
259            "work-token"
260        );
261        let command = runner.command.into_inner().unwrap();
262        assert_eq!(command.args, ["auth", "token", "--user", "octocat-work"]);
263        // Env scrubbing is not weakened by the account path.
264        assert!(command.env_remove.contains(&"GH_TOKEN"));
265        assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
266        assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
267    }
268
269    /// `gh` is spawned without a shell, so the risk is not quoting — it is a
270    /// config value that reads as a *flag* and re-points the command at
271    /// another option or account. GitHub's login grammar excludes every such
272    /// value, so validation happens before argv is built.
273    #[test]
274    fn a_login_outside_githubs_grammar_never_reaches_argv() {
275        for bad in [
276            "--hostname",           // a flag
277            "-u",                   // a short flag
278            "octocat --hostname x", // smuggled second flag
279            "octo cat",             // whitespace
280            "octo/cat",             // path separator
281            "octo;cat",             // shell metacharacter
282            "octo@cat",             // an email, the likely typo
283            "",                     // empty
284            "-octocat",             // leading hyphen
285            "octocat-",             // trailing hyphen
286            "octo--cat",            // repeated hyphen
287            "ã",                    // non-ASCII
288        ] {
289            let error = GhAuthTokenCommand::for_user(None, bad)
290                .expect_err(&format!("{bad:?} must be refused"))
291                .to_string();
292            assert!(
293                error.contains("not a valid GitHub login"),
294                "{bad:?}: {error}"
295            );
296        }
297        // A realistic login still works, including digits and inner hyphens.
298        let command = GhAuthTokenCommand::for_user(None, "octo-cat-99").unwrap();
299        assert_eq!(command.args, ["auth", "token", "--user", "octo-cat-99"]);
300        // 39 characters is GitHub's maximum; 40 is not.
301        assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(39)).is_ok());
302        assert!(GhAuthTokenCommand::for_user(None, &"a".repeat(40)).is_err());
303    }
304
305    /// A signed-out *named* account must not be told to run `gh auth login`
306    /// as though nothing were signed in — the usual cause is a mistyped login
307    /// while another account works fine.
308    #[test]
309    fn a_signed_out_named_account_names_the_account_in_the_error() {
310        let runner = FakeRunner {
311            result: Ok(GhAuthTokenOutput {
312                success: false,
313                stdout: b"never-echo-gh-output".to_vec(),
314            }),
315            command: RefCell::new(None),
316        };
317        let error = resolve_with(&runner, None, Some("octocat"))
318            .unwrap_err()
319            .to_string();
320        assert!(error.contains("octocat"), "{error}");
321        assert!(error.contains("gh auth status"), "{error}");
322        assert!(!error.contains("never-echo-gh-output"), "{error}");
323    }
324
325    /// `gh` has no canonical path, so `PATH` is the sensible default — but it
326    /// is still an ambient choice about which binary runs on every refresh.
327    /// `[copilot] gh_binary` is how a user pins it, so the setting has to
328    /// actually reach the spawned command.
329    /// `gh`'s own precedence, in order. A path we invent instead is a wrong
330    /// answer on somebody's machine: an XDG user's login would read as absent
331    /// and report Copilot as never signed in.
332    #[test]
333    fn hosts_path_follows_the_github_cli_precedence() {
334        use std::ffi::OsString;
335        use std::path::PathBuf;
336        let home = PathBuf::from("/home/u");
337        let only = |wanted: &'static str, value: &'static str| {
338            move |name: &str| (name == wanted).then(|| OsString::from(value))
339        };
340
341        assert_eq!(
342            hosts_path_with(only("GH_CONFIG_DIR", "/cfg/gh"), home.clone()).unwrap(),
343            PathBuf::from("/cfg/gh/hosts.yml"),
344            "GH_CONFIG_DIR is used verbatim, with no `gh` segment appended"
345        );
346        assert_eq!(
347            hosts_path_with(only("XDG_CONFIG_HOME", "/xdg"), home.clone()).unwrap(),
348            PathBuf::from("/xdg/gh/hosts.yml")
349        );
350        assert_eq!(
351            hosts_path_with(|_| None, home.clone()).unwrap(),
352            home.join(".config").join("gh").join("hosts.yml"),
353            "with nothing set, the home convention"
354        );
355
356        // GH_CONFIG_DIR outranks XDG_CONFIG_HOME.
357        let both = |name: &str| match name {
358            "GH_CONFIG_DIR" => Some(OsString::from("/cfg/gh")),
359            "XDG_CONFIG_HOME" => Some(OsString::from("/xdg")),
360            _ => None,
361        };
362        assert_eq!(
363            hosts_path_with(both, home.clone()).unwrap(),
364            PathBuf::from("/cfg/gh/hosts.yml")
365        );
366
367        // `AppData` is Windows-only and ranks below XDG, so it must not
368        // capture a Linux or macOS machine that happens to have it set.
369        let appdata =
370            hosts_path_with(only("AppData", "C:/Users/u/AppData/Roaming"), home.clone()).unwrap();
371        if cfg!(windows) {
372            assert_eq!(
373                appdata,
374                PathBuf::from("C:/Users/u/AppData/Roaming/GitHub CLI/hosts.yml")
375            );
376        } else {
377            assert_eq!(appdata, home.join(".config").join("gh").join("hosts.yml"));
378        }
379    }
380
381    #[test]
382    fn a_configured_gh_binary_replaces_the_path_lookup() {
383        let pinned = std::path::Path::new("/opt/github/bin/gh");
384        assert_eq!(
385            GhAuthTokenCommand::standard(Some(pinned)).program,
386            pinned,
387            "a pinned binary must be used verbatim"
388        );
389        assert_eq!(
390            GhAuthTokenCommand::standard(None).program,
391            std::path::Path::new("gh"),
392            "unset still means the PATH lookup"
393        );
394        // The rest of the boundary is fixed either way.
395        for command in [
396            GhAuthTokenCommand::standard(Some(pinned)),
397            GhAuthTokenCommand::standard(None),
398        ] {
399            assert_eq!(command.args, ["auth", "token"]);
400            assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
401        }
402    }
403
404    #[test]
405    fn login_failure_never_echoes_gh_output() {
406        let runner = FakeRunner {
407            result: Ok(GhAuthTokenOutput {
408                success: false,
409                stdout: b"private-token-or-error".to_vec(),
410            }),
411            command: RefCell::new(None),
412        };
413
414        let error = resolve_with(&runner, None, None).unwrap_err().to_string();
415        assert!(error.contains("gh auth login --web"));
416        assert!(!error.contains("private-token-or-error"));
417    }
418}