1use std::path::{Path, PathBuf};
10
11use serde_json::Value;
12
13use crate::cache::home_dir;
14use crate::error::{AppError, Result};
15
16pub const AUTH_FILES: &[&str] = &[OFFICIAL_AUTH_FILE, ".pi/agent/auth.json"];
18
19const OFFICIAL_AUTH_FILE: &str = ".commandcode/auth.json";
23
24const CREDENTIAL_KEYS: &[&str] = &["command-code", "commandcode"];
26
27pub const SIGNED_OUT: &str =
28 "Command Code is not signed in. Run `commandcode` and sign in, or set COMMANDCODE_API_KEY.";
29pub const EXPIRED: &str = "Command Code sign-in expired. Run `commandcode` to sign in again.";
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Credential {
34 pub token: String,
35 pub source: String,
36}
37
38struct Candidate {
40 token: String,
41 expires_ms: Option<i64>,
42}
43
44pub fn default_paths() -> Result<Vec<PathBuf>> {
46 let home = home_dir()?;
47 Ok(AUTH_FILES.iter().map(|rel| home.join(rel)).collect())
48}
49
50pub fn read_from(path: &Path) -> Option<Credential> {
55 let text = std::fs::read_to_string(path).ok()?;
56 let value: Value = serde_json::from_str(&text).ok()?;
57 let object = value.as_object()?;
58
59 let generic = path
66 .ends_with(OFFICIAL_AUTH_FILE)
67 .then(|| object.get("apiKey"))
68 .flatten();
69 let candidates = CREDENTIAL_KEYS
70 .iter()
71 .filter_map(|key| object.get(*key))
72 .chain(generic)
73 .filter_map(candidate);
74
75 for found in candidates {
76 if found.expires_ms.is_some_and(is_past) {
77 continue;
78 }
79 return Some(Credential {
80 token: found.token,
81 source: path.display().to_string(),
82 });
83 }
84 None
85}
86
87pub fn resolve(configured: Option<&[PathBuf]>) -> Result<Credential> {
90 let paths = match configured {
91 Some(paths) if !paths.is_empty() => paths.to_vec(),
92 _ => default_paths()?,
93 };
94 resolve_from(std::env::var("COMMANDCODE_API_KEY").ok().as_deref(), &paths)
95}
96
97pub fn resolve_from(env_key: Option<&str>, paths: &[PathBuf]) -> Result<Credential> {
99 if let Some(key) = env_key.map(str::trim).filter(|key| !key.is_empty()) {
100 return Ok(Credential {
101 token: key.to_string(),
102 source: "COMMANDCODE_API_KEY".to_string(),
103 });
104 }
105 for path in paths {
106 if let Some(credential) = read_from(path) {
107 return Ok(credential);
108 }
109 }
110 Err(AppError::Credentials(message_for(paths)))
111}
112
113fn message_for(paths: &[PathBuf]) -> String {
116 let any_expired = paths.iter().any(|path| {
117 std::fs::read_to_string(path)
118 .ok()
119 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
120 .and_then(|value| {
121 let object = value.as_object()?;
122 let generic = path
123 .ends_with(OFFICIAL_AUTH_FILE)
124 .then(|| object.get("apiKey"))
125 .flatten();
126 let found = CREDENTIAL_KEYS
127 .iter()
128 .filter_map(|key| object.get(*key))
129 .chain(generic)
130 .filter_map(candidate)
131 .any(|found| found.expires_ms.is_some_and(is_past));
132 Some(found)
133 })
134 .unwrap_or(false)
135 });
136 if any_expired { EXPIRED } else { SIGNED_OUT }.to_string()
137}
138
139fn candidate(value: &Value) -> Option<Candidate> {
140 if let Some(token) = value.as_str() {
141 let token = token.trim();
142 return (!token.is_empty()).then(|| Candidate {
143 token: token.to_string(),
144 expires_ms: None,
145 });
146 }
147 let object = value.as_object()?;
148 let token = ["access", "apiKey", "key"]
149 .iter()
150 .filter_map(|field| object.get(*field))
151 .filter_map(Value::as_str)
152 .map(str::trim)
153 .find(|token| !token.is_empty())?;
154 Some(Candidate {
155 token: token.to_string(),
156 expires_ms: object.get("expires").and_then(Value::as_i64),
157 })
158}
159
160fn is_past(expires_ms: i64) -> bool {
161 expires_ms > 0 && expires_ms <= chrono::Utc::now().timestamp_millis()
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn write(dir: &Path, name: &str, value: Value) -> PathBuf {
169 let path = dir.join(name);
170 std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
171 path
172 }
173
174 fn future_ms() -> i64 {
175 chrono::Utc::now().timestamp_millis() + 86_400_000
176 }
177
178 fn past_ms() -> i64 {
179 chrono::Utc::now().timestamp_millis() - 86_400_000
180 }
181
182 #[test]
183 fn reads_the_official_cli_oauth_shape() {
184 let dir = tempfile::tempdir().unwrap();
185 let path = write(
186 dir.path(),
187 "auth.json",
188 serde_json::json!({
189 "command-code": {"type": "oauth", "access": "tok", "expires": future_ms()}
190 }),
191 );
192
193 assert_eq!(read_from(&path).unwrap().token, "tok");
194 }
195
196 #[test]
203 fn the_unkeyed_apikey_is_only_read_from_command_codes_own_file() {
204 let dir = tempfile::tempdir().unwrap();
205 let body = serde_json::json!({ "apiKey": "another-providers-secret" });
206
207 let shared = dir.path().join(".pi/agent");
208 std::fs::create_dir_all(&shared).unwrap();
209 let shared = write(&shared, "auth.json", body.clone());
210 assert_eq!(
211 read_from(&shared),
212 None,
213 "an unkeyed apiKey in a shared harness keystore is not ours to read"
214 );
215
216 let own = dir.path().join(".commandcode");
217 std::fs::create_dir_all(&own).unwrap();
218 let own = write(&own, "auth.json", body);
219 assert_eq!(
220 read_from(&own).map(|c| c.token),
221 Some("another-providers-secret".to_string()),
222 "Command Code's own file still accepts a pasted key"
223 );
224 }
225
226 #[test]
227 fn reads_the_pi_oauth_shape() {
228 let dir = tempfile::tempdir().unwrap();
229 let path = write(
230 dir.path(),
231 "auth.json",
232 serde_json::json!({
233 "openai-codex": {"type": "oauth", "access": "other"},
234 "commandcode": {"type": "oauth", "access": "tok", "expires": future_ms()}
235 }),
236 );
237
238 assert_eq!(read_from(&path).unwrap().token, "tok");
239 }
240
241 #[test]
242 fn reads_a_legacy_plain_string_credential() {
243 let dir = tempfile::tempdir().unwrap();
244 let path = write(
245 dir.path(),
246 "auth.json",
247 serde_json::json!({"commandcode": "plain-token"}),
248 );
249
250 assert_eq!(read_from(&path).unwrap().token, "plain-token");
251 }
252
253 #[test]
254 fn an_expired_token_is_not_offered() {
255 let dir = tempfile::tempdir().unwrap();
256 let path = write(
257 dir.path(),
258 "auth.json",
259 serde_json::json!({
260 "command-code": {"type": "oauth", "access": "tok", "expires": past_ms()}
261 }),
262 );
263
264 assert!(read_from(&path).is_none());
265 }
266
267 #[test]
268 fn malformed_and_missing_files_are_skipped_not_fatal() {
269 let dir = tempfile::tempdir().unwrap();
270 let broken = dir.path().join("broken.json");
271 std::fs::write(&broken, "{not json").unwrap();
272
273 assert!(read_from(&broken).is_none());
274 assert!(read_from(&dir.path().join("absent.json")).is_none());
275 }
276
277 #[test]
278 fn env_key_outranks_every_file() {
279 let dir = tempfile::tempdir().unwrap();
280 let path = write(
281 dir.path(),
282 "auth.json",
283 serde_json::json!({"commandcode": {"access": "from-file"}}),
284 );
285
286 let credential = resolve_from(Some("from-env"), &[path]).unwrap();
287
288 assert_eq!(credential.token, "from-env");
289 assert_eq!(credential.source, "COMMANDCODE_API_KEY");
290 }
291
292 #[test]
293 fn a_stale_harness_does_not_mask_a_live_one() {
294 let dir = tempfile::tempdir().unwrap();
295 let stale = write(
296 dir.path(),
297 "stale.json",
298 serde_json::json!({
299 "command-code": {"access": "stale", "expires": past_ms()}
300 }),
301 );
302 let live = write(
303 dir.path(),
304 "live.json",
305 serde_json::json!({
306 "commandcode": {"access": "live", "expires": future_ms()}
307 }),
308 );
309
310 assert_eq!(resolve_from(None, &[stale, live]).unwrap().token, "live");
311 }
312
313 #[test]
314 fn all_expired_reports_expiry_and_nothing_reports_signed_out() {
315 let dir = tempfile::tempdir().unwrap();
316 let stale = write(
317 dir.path(),
318 "stale.json",
319 serde_json::json!({
320 "command-code": {"access": "stale", "expires": past_ms()}
321 }),
322 );
323
324 let expired = resolve_from(None, std::slice::from_ref(&stale)).unwrap_err();
325 assert!(expired.to_string().contains("expired"), "{expired}");
326
327 let absent = resolve_from(None, &[dir.path().join("absent.json")]).unwrap_err();
328 assert!(absent.to_string().contains("not signed in"), "{absent}");
329
330 let shared_dir = dir.path().join(".pi/agent");
331 std::fs::create_dir_all(&shared_dir).unwrap();
332 let unrelated = write(
333 &shared_dir,
334 "auth.json",
335 serde_json::json!({
336 "apiKey": {"access": "other", "expires": past_ms()}
337 }),
338 );
339 let signed_out = resolve_from(None, &[unrelated]).unwrap_err();
340 assert!(
341 signed_out.to_string().contains("not signed in"),
342 "an unrelated expired apiKey must not be treated as Command Code: {signed_out}"
343 );
344 }
345
346 #[test]
347 fn default_paths_only_include_verified_credential_files() {
348 let paths = default_paths().unwrap();
349
350 assert_eq!(
351 AUTH_FILES,
352 &[".commandcode/auth.json", ".pi/agent/auth.json"]
353 );
354 assert_eq!(paths.len(), AUTH_FILES.len());
355 assert!(paths[0].ends_with(AUTH_FILES[0]));
356 assert!(paths[1].ends_with(AUTH_FILES[1]));
357 }
358}