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
10#[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 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 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
42pub 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 #[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 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}