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 = effective_paths(configured)?;
91 resolve_from(std::env::var("COMMANDCODE_API_KEY").ok().as_deref(), &paths)
92}
93
94pub fn effective_paths(configured: Option<&[PathBuf]>) -> Result<Vec<PathBuf>> {
97 match configured {
98 Some(paths) if !paths.is_empty() => Ok(paths.to_vec()),
99 _ => default_paths(),
100 }
101}
102
103pub fn resolve_from(env_key: Option<&str>, paths: &[PathBuf]) -> Result<Credential> {
105 if let Some(key) = env_key.map(str::trim).filter(|key| !key.is_empty()) {
106 return Ok(Credential {
107 token: key.to_string(),
108 source: "COMMANDCODE_API_KEY".to_string(),
109 });
110 }
111 for path in paths {
112 if let Some(credential) = read_from(path) {
113 return Ok(credential);
114 }
115 }
116 Err(AppError::Credentials(message_for(paths)))
117}
118
119fn message_for(paths: &[PathBuf]) -> String {
122 let any_expired = paths.iter().any(|path| {
123 std::fs::read_to_string(path)
124 .ok()
125 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
126 .and_then(|value| {
127 let object = value.as_object()?;
128 let generic = path
129 .ends_with(OFFICIAL_AUTH_FILE)
130 .then(|| object.get("apiKey"))
131 .flatten();
132 let found = CREDENTIAL_KEYS
133 .iter()
134 .filter_map(|key| object.get(*key))
135 .chain(generic)
136 .filter_map(candidate)
137 .any(|found| found.expires_ms.is_some_and(is_past));
138 Some(found)
139 })
140 .unwrap_or(false)
141 });
142 if any_expired { EXPIRED } else { SIGNED_OUT }.to_string()
143}
144
145fn candidate(value: &Value) -> Option<Candidate> {
146 if let Some(token) = value.as_str() {
147 let token = token.trim();
148 return (!token.is_empty()).then(|| Candidate {
149 token: token.to_string(),
150 expires_ms: None,
151 });
152 }
153 let object = value.as_object()?;
154 let token = ["access", "apiKey", "key"]
155 .iter()
156 .filter_map(|field| object.get(*field))
157 .filter_map(Value::as_str)
158 .map(str::trim)
159 .find(|token| !token.is_empty())?;
160 Some(Candidate {
161 token: token.to_string(),
162 expires_ms: object.get("expires").and_then(Value::as_i64),
163 })
164}
165
166fn is_past(expires_ms: i64) -> bool {
167 expires_ms > 0 && expires_ms <= chrono::Utc::now().timestamp_millis()
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn write(dir: &Path, name: &str, value: Value) -> PathBuf {
175 let path = dir.join(name);
176 std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
177 path
178 }
179
180 fn future_ms() -> i64 {
181 chrono::Utc::now().timestamp_millis() + 86_400_000
182 }
183
184 fn past_ms() -> i64 {
185 chrono::Utc::now().timestamp_millis() - 86_400_000
186 }
187
188 #[test]
189 fn reads_the_official_cli_oauth_shape() {
190 let dir = tempfile::tempdir().unwrap();
191 let path = write(
192 dir.path(),
193 "auth.json",
194 serde_json::json!({
195 "command-code": {"type": "oauth", "access": "tok", "expires": future_ms()}
196 }),
197 );
198
199 assert_eq!(read_from(&path).unwrap().token, "tok");
200 }
201
202 #[test]
209 fn the_unkeyed_apikey_is_only_read_from_command_codes_own_file() {
210 let dir = tempfile::tempdir().unwrap();
211 let body = serde_json::json!({ "apiKey": "another-providers-secret" });
212
213 let shared = dir.path().join(".pi/agent");
214 std::fs::create_dir_all(&shared).unwrap();
215 let shared = write(&shared, "auth.json", body.clone());
216 assert_eq!(
217 read_from(&shared),
218 None,
219 "an unkeyed apiKey in a shared harness keystore is not ours to read"
220 );
221
222 let own = dir.path().join(".commandcode");
223 std::fs::create_dir_all(&own).unwrap();
224 let own = write(&own, "auth.json", body);
225 assert_eq!(
226 read_from(&own).map(|c| c.token),
227 Some("another-providers-secret".to_string()),
228 "Command Code's own file still accepts a pasted key"
229 );
230 }
231
232 #[test]
233 fn reads_the_pi_oauth_shape() {
234 let dir = tempfile::tempdir().unwrap();
235 let path = write(
236 dir.path(),
237 "auth.json",
238 serde_json::json!({
239 "openai-codex": {"type": "oauth", "access": "other"},
240 "commandcode": {"type": "oauth", "access": "tok", "expires": future_ms()}
241 }),
242 );
243
244 assert_eq!(read_from(&path).unwrap().token, "tok");
245 }
246
247 #[test]
248 fn reads_a_legacy_plain_string_credential() {
249 let dir = tempfile::tempdir().unwrap();
250 let path = write(
251 dir.path(),
252 "auth.json",
253 serde_json::json!({"commandcode": "plain-token"}),
254 );
255
256 assert_eq!(read_from(&path).unwrap().token, "plain-token");
257 }
258
259 #[test]
260 fn an_expired_token_is_not_offered() {
261 let dir = tempfile::tempdir().unwrap();
262 let path = write(
263 dir.path(),
264 "auth.json",
265 serde_json::json!({
266 "command-code": {"type": "oauth", "access": "tok", "expires": past_ms()}
267 }),
268 );
269
270 assert!(read_from(&path).is_none());
271 }
272
273 #[test]
274 fn malformed_and_missing_files_are_skipped_not_fatal() {
275 let dir = tempfile::tempdir().unwrap();
276 let broken = dir.path().join("broken.json");
277 std::fs::write(&broken, "{not json").unwrap();
278
279 assert!(read_from(&broken).is_none());
280 assert!(read_from(&dir.path().join("absent.json")).is_none());
281 }
282
283 #[test]
284 fn env_key_outranks_every_file() {
285 let dir = tempfile::tempdir().unwrap();
286 let path = write(
287 dir.path(),
288 "auth.json",
289 serde_json::json!({"commandcode": {"access": "from-file"}}),
290 );
291
292 let credential = resolve_from(Some("from-env"), &[path]).unwrap();
293
294 assert_eq!(credential.token, "from-env");
295 assert_eq!(credential.source, "COMMANDCODE_API_KEY");
296 }
297
298 #[test]
299 fn a_stale_harness_does_not_mask_a_live_one() {
300 let dir = tempfile::tempdir().unwrap();
301 let stale = write(
302 dir.path(),
303 "stale.json",
304 serde_json::json!({
305 "command-code": {"access": "stale", "expires": past_ms()}
306 }),
307 );
308 let live = write(
309 dir.path(),
310 "live.json",
311 serde_json::json!({
312 "commandcode": {"access": "live", "expires": future_ms()}
313 }),
314 );
315
316 assert_eq!(resolve_from(None, &[stale, live]).unwrap().token, "live");
317 }
318
319 #[test]
320 fn all_expired_reports_expiry_and_nothing_reports_signed_out() {
321 let dir = tempfile::tempdir().unwrap();
322 let stale = write(
323 dir.path(),
324 "stale.json",
325 serde_json::json!({
326 "command-code": {"access": "stale", "expires": past_ms()}
327 }),
328 );
329
330 let expired = resolve_from(None, std::slice::from_ref(&stale)).unwrap_err();
331 assert!(expired.to_string().contains("expired"), "{expired}");
332
333 let absent = resolve_from(None, &[dir.path().join("absent.json")]).unwrap_err();
334 assert!(absent.to_string().contains("not signed in"), "{absent}");
335
336 let shared_dir = dir.path().join(".pi/agent");
337 std::fs::create_dir_all(&shared_dir).unwrap();
338 let unrelated = write(
339 &shared_dir,
340 "auth.json",
341 serde_json::json!({
342 "apiKey": {"access": "other", "expires": past_ms()}
343 }),
344 );
345 let signed_out = resolve_from(None, &[unrelated]).unwrap_err();
346 assert!(
347 signed_out.to_string().contains("not signed in"),
348 "an unrelated expired apiKey must not be treated as Command Code: {signed_out}"
349 );
350 }
351
352 #[test]
353 fn default_paths_only_include_verified_credential_files() {
354 let paths = default_paths().unwrap();
355
356 assert_eq!(
357 AUTH_FILES,
358 &[".commandcode/auth.json", ".pi/agent/auth.json"]
359 );
360 assert_eq!(paths.len(), AUTH_FILES.len());
361 assert!(paths[0].ends_with(AUTH_FILES[0]));
362 assert!(paths[1].ends_with(AUTH_FILES[1]));
363 }
364}