ai_usagebar/copilot/
credentials.rs1use std::io;
5use std::process::{Command, Stdio};
6
7use crate::error::{AppError, Result};
8use crate::vendor::vendor_secret_env_vars_to_remove;
9
10pub 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
26pub 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#[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 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 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
80pub 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 let output = process.output()?;
100 Ok(GhAuthTokenOutput {
101 success: output.status.success(),
102 stdout: output.stdout,
103 })
104 }
105}
106
107pub fn resolve_with(
108 runner: &impl GhAuthTokenRunner,
109 gh_binary: Option<&std::path::Path>,
110) -> Result<String> {
111 let command = GhAuthTokenCommand::standard(gh_binary);
112 let output = match runner.run(&command) {
113 Ok(output) => output,
114 Err(error) if error.kind() == io::ErrorKind::NotFound => {
115 return Err(login_error(
116 "GitHub CLI (`gh`) is not installed. Install it, then run",
117 ));
118 }
119 Err(_) => return Err(login_error("GitHub CLI could not be started. Run")),
120 };
121 if !output.success {
122 return Err(login_error("GitHub CLI is not logged in. Run"));
123 }
124 let token = String::from_utf8(output.stdout)
125 .ok()
126 .map(|value| value.trim().to_string())
127 .filter(|value| !value.is_empty())
128 .ok_or_else(|| login_error("GitHub CLI returned no OAuth token. Run"))?;
129 Ok(token)
130}
131
132fn login_error(prefix: &str) -> AppError {
133 AppError::Credentials(format!(
134 "GitHub Copilot: {prefix} `gh auth login --web`, then select GitHub Copilot as the primary provider in Settings."
135 ))
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use std::cell::RefCell;
142
143 struct FakeRunner {
144 result: io::Result<GhAuthTokenOutput>,
145 command: RefCell<Option<GhAuthTokenCommand>>,
146 }
147
148 impl GhAuthTokenRunner for FakeRunner {
149 fn run(&self, command: &GhAuthTokenCommand) -> io::Result<GhAuthTokenOutput> {
150 *self.command.borrow_mut() = Some(command.clone());
151 self.result
152 .as_ref()
153 .map(Clone::clone)
154 .map_err(|error| io::Error::new(error.kind(), "fake gh failure"))
155 }
156 }
157
158 #[test]
159 fn runs_only_fixed_gh_auth_token_command_and_returns_trimmed_token() {
160 let runner = FakeRunner {
161 result: Ok(GhAuthTokenOutput {
162 success: true,
163 stdout: b"test-github-oauth-token\n".to_vec(),
164 }),
165 command: RefCell::new(None),
166 };
167
168 assert_eq!(
169 resolve_with(&runner, None).unwrap(),
170 "test-github-oauth-token"
171 );
172 let command = runner.command.into_inner().unwrap();
173 assert_eq!(command.program, std::path::Path::new("gh"));
174 assert_eq!(command.args, ["auth", "token"]);
175 assert!(command.env_remove.contains(&"ZAI_API_KEY"));
176 assert!(command.env_remove.contains(&"GITHUB_COPILOT_TOKEN"));
177 assert!(command.env_remove.contains(&"GH_TOKEN"));
178 assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
179 }
180
181 #[test]
189 fn hosts_path_follows_the_github_cli_precedence() {
190 use std::ffi::OsString;
191 use std::path::PathBuf;
192 let home = PathBuf::from("/home/u");
193 let only = |wanted: &'static str, value: &'static str| {
194 move |name: &str| (name == wanted).then(|| OsString::from(value))
195 };
196
197 assert_eq!(
198 hosts_path_with(only("GH_CONFIG_DIR", "/cfg/gh"), home.clone()).unwrap(),
199 PathBuf::from("/cfg/gh/hosts.yml"),
200 "GH_CONFIG_DIR is used verbatim, with no `gh` segment appended"
201 );
202 assert_eq!(
203 hosts_path_with(only("XDG_CONFIG_HOME", "/xdg"), home.clone()).unwrap(),
204 PathBuf::from("/xdg/gh/hosts.yml")
205 );
206 assert_eq!(
207 hosts_path_with(|_| None, home.clone()).unwrap(),
208 home.join(".config").join("gh").join("hosts.yml"),
209 "with nothing set, the home convention"
210 );
211
212 let both = |name: &str| match name {
214 "GH_CONFIG_DIR" => Some(OsString::from("/cfg/gh")),
215 "XDG_CONFIG_HOME" => Some(OsString::from("/xdg")),
216 _ => None,
217 };
218 assert_eq!(
219 hosts_path_with(both, home.clone()).unwrap(),
220 PathBuf::from("/cfg/gh/hosts.yml")
221 );
222
223 let appdata =
226 hosts_path_with(only("AppData", "C:/Users/u/AppData/Roaming"), home.clone()).unwrap();
227 if cfg!(windows) {
228 assert_eq!(
229 appdata,
230 PathBuf::from("C:/Users/u/AppData/Roaming/GitHub CLI/hosts.yml")
231 );
232 } else {
233 assert_eq!(appdata, home.join(".config").join("gh").join("hosts.yml"));
234 }
235 }
236
237 #[test]
238 fn a_configured_gh_binary_replaces_the_path_lookup() {
239 let pinned = std::path::Path::new("/opt/github/bin/gh");
240 assert_eq!(
241 GhAuthTokenCommand::standard(Some(pinned)).program,
242 pinned,
243 "a pinned binary must be used verbatim"
244 );
245 assert_eq!(
246 GhAuthTokenCommand::standard(None).program,
247 std::path::Path::new("gh"),
248 "unset still means the PATH lookup"
249 );
250 for command in [
252 GhAuthTokenCommand::standard(Some(pinned)),
253 GhAuthTokenCommand::standard(None),
254 ] {
255 assert_eq!(command.args, ["auth", "token"]);
256 assert!(command.env_remove.contains(&"GITHUB_TOKEN"));
257 }
258 }
259
260 #[test]
261 fn login_failure_never_echoes_gh_output() {
262 let runner = FakeRunner {
263 result: Ok(GhAuthTokenOutput {
264 success: false,
265 stdout: b"private-token-or-error".to_vec(),
266 }),
267 command: RefCell::new(None),
268 };
269
270 let error = resolve_with(&runner, None).unwrap_err().to_string();
271 assert!(error.contains("gh auth login --web"));
272 assert!(!error.contains("private-token-or-error"));
273 }
274}