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 #[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 #[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 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 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 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}