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    pub args: [&'static str; 2],
55    pub env_remove: Vec<&'static str>,
56}
57
58impl GhAuthTokenCommand {
59    /// `gh` has no canonical install path across distributions, so unlike
60    /// `grok` it is looked up on `PATH` by default. That makes the binary an
61    /// ambient choice, which is why `[copilot] gh_binary` exists: point it at
62    /// the trusted executable and the lookup stops being ambient.
63    pub fn standard(gh_binary: Option<&std::path::Path>) -> Self {
64        Self {
65            program: gh_binary.map_or_else(|| std::path::PathBuf::from("gh"), Into::into),
66            args: ["auth", "token"],
67            // `gh auth token` must use its saved OAuth login, rather than an
68            // arbitrary provider token inherited from this process.
69            env_remove: vendor_secret_env_vars_to_remove(&[]),
70        }
71    }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct GhAuthTokenOutput {
76    pub success: bool,
77    pub stdout: Vec<u8>,
78}
79
80/// Injectable command boundary. Tests supply a fake runner, so they never
81/// execute `gh`, inspect a real GitHub config directory, or use ambient env.
82pub trait GhAuthTokenRunner {
83    fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput>;
84}
85
86pub struct SystemGhAuthTokenRunner;
87
88impl GhAuthTokenRunner for SystemGhAuthTokenRunner {
89    fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
90        let mut process = Command::new(&command.program);
91        process
92            .args(command.args)
93            .stdin(Stdio::null())
94            .stdout(Stdio::piped())
95            .stderr(Stdio::null());
96        for variable in &command.env_remove {
97            process.env_remove(variable);
98        }
99        // Same reason as the SuperGrok ACP child: no console window from the
100        // tray, or the popover loses focus and closes.
101        #[cfg(windows)]
102        {
103            use std::os::windows::process::CommandExt;
104            process.creation_flags(crate::process::CREATE_NO_WINDOW);
105        }
106        let output = process.output()?;
107        Ok(GhAuthTokenOutput {
108            success: output.status.success(),
109            stdout: output.stdout,
110        })
111    }
112}
113
114pub fn resolve_with(
115    runner: &impl GhAuthTokenRunner,
116    gh_binary: Option<&std::path::Path>,
117) -> Result<String> {
118    let command = GhAuthTokenCommand::standard(gh_binary);
119    let output = match runner.run(&command) {
120        Ok(output) => output,
121        Err(error) if error.kind() == io::ErrorKind::NotFound => {
122            return Err(login_error(
123                "GitHub CLI (`gh`) is not installed. Install it, then run",
124            ));
125        }
126        Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
127    };
128    if !output.success {
129        return Err(login_error("GitHub CLI is not logged in. Run"));
130    }
131    let token = String::from_utf8(output.stdout)
132        .ok()
133        .map(|value| value.trim().to_string())
134        .filter(|value| !value.is_empty())
135        .ok_or_else(|| login_error("GitHub CLI returned no OAuth token. Run"))?;
136    Ok(token)
137}
138
139fn login_error(prefix: &str) -> AppError {
140    AppError::Credentials(format!(
141        "GitHub Copilot: {prefix} `gh auth login --web`, then select GitHub Copilot as the primary provider in Settings."
142    ))
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::cell::RefCell;
149
150    struct FakeRunner {
151        result: io::Result<GhAuthTokenOutput>,
152        command: RefCell<Option<GhAuthTokenCommand>>,
153    }
154
155    impl GhAuthTokenRunner for FakeRunner {
156        fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
157            *self.command.borrow_mut() = Some(command.clone());
158            self.result
159                .as_ref()
160                .map(Clone::clone)
161                .map_err(|error| io::Error::new(error.kind(), "fake gh failure"))
162        }
163    }
164
165    #[test]
166    fn runs_only_fixed_gh_auth_token_command_and_returns_trimmed_token() {
167        let runner = FakeRunner {
168            result: Ok(GhAuthTokenOutput {
169                success: true,
170                stdout: b"test-github-oauth-token\n".to_vec(),
171            }),
172            command: RefCell::new(None),
173        };
174
175        assert_eq!(
176            resolve_with(&runner, None).unwrap(),
177            "test-github-oauth-token"
178        );
179        let command = runner.command.into_inner().unwrap();
180        assert_eq!(command.program, std::path::Path::new("gh"));
181        assert_eq!(command.args, ["auth", "token"]);
182        assert!(command.env_remove.contains(&"ZAI_API_KEY"));
183        assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
184        assert!(command.env_remove.contains(&"GH_TOKEN"));
185        assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
186    }
187
188    /// `gh` has no canonical path, so `PATH` is the sensible default — but it
189    /// is still an ambient choice about which binary runs on every refresh.
190    /// `[copilot] gh_binary` is how a user pins it, so the setting has to
191    /// actually reach the spawned command.
192    /// `gh`'s own precedence, in order. A path we invent instead is a wrong
193    /// answer on somebody's machine: an XDG user's login would read as absent
194    /// and report Copilot as never signed in.
195    #[test]
196    fn hosts_path_follows_the_github_cli_precedence() {
197        use std::ffi::OsString;
198        use std::path::PathBuf;
199        let home = PathBuf::from("/home/u");
200        let only = |wanted: &'static str, value: &'static str| {
201            move |name: &str| (name == wanted).then(|| OsString::from(value))
202        };
203
204        assert_eq!(
205            hosts_path_with(only("GH_CONFIG_DIR", "/cfg/gh"), home.clone()).unwrap(),
206            PathBuf::from("/cfg/gh/hosts.yml"),
207            "GH_CONFIG_DIR is used verbatim, with no `gh` segment appended"
208        );
209        assert_eq!(
210            hosts_path_with(only("XDG_CONFIG_HOME", "/xdg"), home.clone()).unwrap(),
211            PathBuf::from("/xdg/gh/hosts.yml")
212        );
213        assert_eq!(
214            hosts_path_with(|_| None, home.clone()).unwrap(),
215            home.join(".config").join("gh").join("hosts.yml"),
216            "with nothing set, the home convention"
217        );
218
219        // GH_CONFIG_DIR outranks XDG_CONFIG_HOME.
220        let both = |name: &str| match name {
221            "GH_CONFIG_DIR" => Some(OsString::from("/cfg/gh")),
222            "XDG_CONFIG_HOME" => Some(OsString::from("/xdg")),
223            _ => None,
224        };
225        assert_eq!(
226            hosts_path_with(both, home.clone()).unwrap(),
227            PathBuf::from("/cfg/gh/hosts.yml")
228        );
229
230        // `AppData` is Windows-only and ranks below XDG, so it must not
231        // capture a Linux or macOS machine that happens to have it set.
232        let appdata =
233            hosts_path_with(only("AppData", "C:/Users/u/AppData/Roaming"), home.clone()).unwrap();
234        if cfg!(windows) {
235            assert_eq!(
236                appdata,
237                PathBuf::from("C:/Users/u/AppData/Roaming/GitHub CLI/hosts.yml")
238            );
239        } else {
240            assert_eq!(appdata, home.join(".config").join("gh").join("hosts.yml"));
241        }
242    }
243
244    #[test]
245    fn a_configured_gh_binary_replaces_the_path_lookup() {
246        let pinned = std::path::Path::new("/opt/github/bin/gh");
247        assert_eq!(
248            GhAuthTokenCommand::standard(Some(pinned)).program,
249            pinned,
250            "a pinned binary must be used verbatim"
251        );
252        assert_eq!(
253            GhAuthTokenCommand::standard(None).program,
254            std::path::Path::new("gh"),
255            "unset still means the PATH lookup"
256        );
257        // The rest of the boundary is fixed either way.
258        for command in [
259            GhAuthTokenCommand::standard(Some(pinned)),
260            GhAuthTokenCommand::standard(None),
261        ] {
262            assert_eq!(command.args, ["auth", "token"]);
263            assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
264        }
265    }
266
267    #[test]
268    fn login_failure_never_echoes_gh_output() {
269        let runner = FakeRunner {
270            result: Ok(GhAuthTokenOutput {
271                success: false,
272                stdout: b"private-token-or-error".to_vec(),
273            }),
274            command: RefCell::new(None),
275        };
276
277        let error = resolve_with(&runner, None).unwrap_err().to_string();
278        assert!(error.contains("gh auth login --web"));
279        assert!(!error.contains("private-token-or-error"));
280    }
281}