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/// The deliberately narrow process description used to obtain the current
11/// GitHub CLI OAuth token. Keeping it data makes the subprocess boundary
12/// inspectable in tests and prevents a shell from entering this path.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct GhAuthTokenCommand {
15    pub program: std::path::PathBuf,
16    pub args: [&'static str; 2],
17    pub env_remove: Vec<&'static str>,
18}
19
20impl GhAuthTokenCommand {
21    /// `gh` has no canonical install path across distributions, so unlike
22    /// `grok` it is looked up on `PATH` by default. That makes the binary an
23    /// ambient choice, which is why `[copilot] gh_binary` exists: point it at
24    /// the trusted executable and the lookup stops being ambient.
25    pub fn standard(gh_binary: Option<&std::path::Path>) -> Self {
26        Self {
27            program: gh_binary.map_or_else(|| std::path::PathBuf::from("gh"), Into::into),
28            args: ["auth", "token"],
29            // `gh auth token` must use its saved OAuth login, rather than an
30            // arbitrary provider token inherited from this process.
31            env_remove: vendor_secret_env_vars_to_remove(&[]),
32        }
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct GhAuthTokenOutput {
38    pub success: bool,
39    pub stdout: Vec<u8>,
40}
41
42/// Injectable command boundary. Tests supply a fake runner, so they never
43/// execute `gh`, inspect a real GitHub config directory, or use ambient env.
44pub trait GhAuthTokenRunner {
45    fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput>;
46}
47
48pub struct SystemGhAuthTokenRunner;
49
50impl GhAuthTokenRunner for SystemGhAuthTokenRunner {
51    fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
52        let mut process = Command::new(&command.program);
53        process
54            .args(command.args)
55            .stdin(Stdio::null())
56            .stdout(Stdio::piped())
57            .stderr(Stdio::null());
58        for variable in &command.env_remove {
59            process.env_remove(variable);
60        }
61        let output = process.output()?;
62        Ok(GhAuthTokenOutput {
63            success: output.status.success(),
64            stdout: output.stdout,
65        })
66    }
67}
68
69pub fn resolve_with(
70    runner: &impl GhAuthTokenRunner,
71    gh_binary: Option<&std::path::Path>,
72) -> Result<String> {
73    let command = GhAuthTokenCommand::standard(gh_binary);
74    let output = match runner.run(&command) {
75        Ok(output) => output,
76        Err(error) if error.kind() == io::ErrorKind::NotFound => {
77            return Err(login_error(
78                "GitHub CLI (`gh`) is not installed. Install it, then run",
79            ));
80        }
81        Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
82    };
83    if !output.success {
84        return Err(login_error("GitHub CLI is not logged in. Run"));
85    }
86    let token = String::from_utf8(output.stdout)
87        .ok()
88        .map(|value| value.trim().to_string())
89        .filter(|value| !value.is_empty())
90        .ok_or_else(|| login_error("GitHub CLI returned no OAuth token. Run"))?;
91    Ok(token)
92}
93
94fn login_error(prefix: &str) -> AppError {
95    AppError::Credentials(format!(
96        "GitHub Copilot: {prefix} `gh auth login --web`, then select GitHub Copilot as the primary provider in Settings."
97    ))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use std::cell::RefCell;
104
105    struct FakeRunner {
106        result: io::Result<GhAuthTokenOutput>,
107        command: RefCell<Option<GhAuthTokenCommand>>,
108    }
109
110    impl GhAuthTokenRunner for FakeRunner {
111        fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
112            *self.command.borrow_mut() = Some(command.clone());
113            self.result
114                .as_ref()
115                .map(Clone::clone)
116                .map_err(|error| io::Error::new(error.kind(), "fake gh failure"))
117        }
118    }
119
120    #[test]
121    fn runs_only_fixed_gh_auth_token_command_and_returns_trimmed_token() {
122        let runner = FakeRunner {
123            result: Ok(GhAuthTokenOutput {
124                success: true,
125                stdout: b"test-github-oauth-token\n".to_vec(),
126            }),
127            command: RefCell::new(None),
128        };
129
130        assert_eq!(
131            resolve_with(&runner, None).unwrap(),
132            "test-github-oauth-token"
133        );
134        let command = runner.command.into_inner().unwrap();
135        assert_eq!(command.program, std::path::Path::new("gh"));
136        assert_eq!(command.args, ["auth", "token"]);
137        assert!(command.env_remove.contains(&"ZAI_API_KEY"));
138        assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
139        assert!(command.env_remove.contains(&"GH_TOKEN"));
140        assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
141    }
142
143    /// `gh` has no canonical path, so `PATH` is the sensible default — but it
144    /// is still an ambient choice about which binary runs on every refresh.
145    /// `[copilot] gh_binary` is how a user pins it, so the setting has to
146    /// actually reach the spawned command.
147    #[test]
148    fn a_configured_gh_binary_replaces_the_path_lookup() {
149        let pinned = std::path::Path::new("/opt/github/bin/gh");
150        assert_eq!(
151            GhAuthTokenCommand::standard(Some(pinned)).program,
152            pinned,
153            "a pinned binary must be used verbatim"
154        );
155        assert_eq!(
156            GhAuthTokenCommand::standard(None).program,
157            std::path::Path::new("gh"),
158            "unset still means the PATH lookup"
159        );
160        // The rest of the boundary is fixed either way.
161        for command in [
162            GhAuthTokenCommand::standard(Some(pinned)),
163            GhAuthTokenCommand::standard(None),
164        ] {
165            assert_eq!(command.args, ["auth", "token"]);
166            assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
167        }
168    }
169
170    #[test]
171    fn login_failure_never_echoes_gh_output() {
172        let runner = FakeRunner {
173            result: Ok(GhAuthTokenOutput {
174                success: false,
175                stdout: b"private-token-or-error".to_vec(),
176            }),
177            command: RefCell::new(None),
178        };
179
180        let error = resolve_with(&runner, None).unwrap_err().to_string();
181        assert!(error.contains("gh auth login --web"));
182        assert!(!error.contains("private-token-or-error"));
183    }
184}