Skip to main content

ai_usagebar/anthropic/
creds.rs

1//! Read and write `~/.claude/.credentials.json` — the OAuth state the Claude
2//! CLI maintains. Mirrors claudebar:330-333 (read) and claudebar:447-452 (write).
3//!
4//! On macOS the file often doesn't exist: recent Claude Code builds keep the
5//! same JSON in the login Keychain instead. For the *default* location we fall
6//! back to [`keychain`] when the file is missing or clearly unusable (#15);
7//! explicit paths (`--creds-path`, config, named accounts) are read strictly.
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::cache::atomic_write;
14use crate::error::{AppError, Result};
15
16#[cfg(target_os = "macos")]
17use super::keychain;
18
19/// Disk shape (matches claudebar's jq paths).
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct CredentialsFile {
22    #[serde(rename = "claudeAiOauth")]
23    pub claude_ai_oauth: OauthCreds,
24}
25
26#[derive(Debug, Clone, Deserialize, Serialize)]
27pub struct OauthCreds {
28    #[serde(rename = "accessToken")]
29    pub access_token: String,
30    #[serde(rename = "refreshToken")]
31    pub refresh_token: String,
32    /// Unix epoch in **milliseconds** (claudebar:445 multiplies seconds × 1000).
33    /// May arrive as a float in the wild — claudebar truncates with `%%.*`,
34    /// so we accept both.
35    #[serde(rename = "expiresAt", deserialize_with = "de_ms_epoch")]
36    pub expires_at_ms: i64,
37    #[serde(rename = "subscriptionType", default)]
38    pub subscription_type: String,
39    #[serde(rename = "rateLimitTier", default)]
40    pub rate_limit_tier: String,
41    /// Optional `scopes` array — preserved through round-trips so we don't
42    /// drop information when we write back after a refresh.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub scopes: Option<serde_json::Value>,
45}
46
47fn de_ms_epoch<'de, D>(d: D) -> std::result::Result<i64, D::Error>
48where
49    D: serde::Deserializer<'de>,
50{
51    // Accept int or float — float values like 5000.0 are truncated.
52    let v = serde_json::Value::deserialize(d)?;
53    match v {
54        serde_json::Value::Number(n) => {
55            if let Some(i) = n.as_i64() {
56                Ok(i)
57            } else if let Some(f) = n.as_f64() {
58                Ok(f as i64)
59            } else {
60                Err(serde::de::Error::custom("expiresAt not numeric"))
61            }
62        }
63        _ => Err(serde::de::Error::custom("expiresAt must be a number")),
64    }
65}
66
67impl OauthCreds {
68    /// Plan label rendered the way claudebar does (claudebar:547-550):
69    ///   "${sub_type^} [5x|20x]" (first letter capitalized, optional tier suffix).
70    pub fn plan_label(&self) -> String {
71        let mut name = capitalize_first(&self.subscription_type);
72        if name.is_empty() {
73            name = "Unknown".into();
74        }
75        if self.rate_limit_tier.contains("5x") {
76            name.push_str(" 5x");
77        } else if self.rate_limit_tier.contains("20x") {
78            name.push_str(" 20x");
79        }
80        name
81    }
82
83    pub fn expires_at_secs(&self) -> i64 {
84        self.expires_at_ms / 1000
85    }
86}
87
88fn capitalize_first(s: &str) -> String {
89    let mut chars = s.chars();
90    match chars.next() {
91        Some(first) => {
92            let mut out = String::with_capacity(s.len());
93            for c in first.to_uppercase() {
94                out.push(c);
95            }
96            out.push_str(chars.as_str());
97            out
98        }
99        None => String::new(),
100    }
101}
102
103/// Default location: `~/.claude/.credentials.json` (Unix/macOS) or
104/// `%USERPROFILE%\.claude\.credentials.json` (Windows).
105///
106/// Home is resolved through [`crate::cache::home_dir`] so every platform's
107/// convention is honored in one place.
108pub fn default_path() -> Result<PathBuf> {
109    Ok(crate::cache::home_dir()?
110        .join(".claude")
111        .join(".credentials.json"))
112}
113
114/// Strict file read: no Keychain fallback, ever. Explicit paths (`--creds-path`,
115/// config `credentials_path`, named accounts) go through here so a missing or
116/// broken file fails loudly instead of silently reading a *different* account's
117/// credentials from the Keychain (issues #14/#15).
118pub fn read_from(path: &Path) -> Result<CredentialsFile> {
119    match std::fs::read_to_string(path) {
120        Ok(raw) => parse(&raw, &path.display().to_string()),
121        Err(e) => Err(AppError::io_at(path, e)),
122    }
123}
124
125/// Which credentials location a fetch should use. Only the platform-default
126/// location is eligible for the macOS Keychain fallback — Claude Code owns
127/// that location, so "look where Claude Code lives" includes its Keychain
128/// item. An explicit path is a user decision and is honored strictly.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum CredsTarget {
131    /// `~/.claude/.credentials.json` (or the Windows equivalent) — falls back
132    /// to the macOS Keychain when the file is missing *or unusable* (#15).
133    Default(PathBuf),
134    /// `--creds-path`, config `credentials_path`, or a named account's file —
135    /// never consults the Keychain.
136    Explicit(PathBuf),
137}
138
139impl CredsTarget {
140    pub fn path(&self) -> &Path {
141        match self {
142            CredsTarget::Default(p) | CredsTarget::Explicit(p) => p,
143        }
144    }
145}
146
147/// Where credentials were actually read from. Write-backs must follow this —
148/// refreshing tokens read from the Keychain into a stale file (or vice versa)
149/// would fork the credential state Claude Code depends on.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum CredsSource {
152    File(PathBuf),
153    /// Only produced on macOS in production (the login Keychain item
154    /// `Claude Code-credentials`).
155    Keychain,
156}
157
158/// Credentials that can't possibly authenticate anything: no access token, no
159/// refresh token, no future expiry. This is the #15 predicate — deliberately
160/// narrow so the v0.7.2 trusted-device shape (empty `refreshToken` but a live
161/// `accessToken`) keeps its file-first behavior.
162pub fn is_unusable(oauth: &OauthCreds) -> bool {
163    oauth.access_token.trim().is_empty()
164        && oauth.refresh_token.trim().is_empty()
165        && oauth.expires_at_ms <= 0
166}
167
168/// Resolve a [`CredsTarget`] to actual credentials + the source they came
169/// from. `Explicit` is a strict [`read_from`]; `Default` adds the macOS
170/// Keychain fallback.
171pub fn resolve(target: &CredsTarget) -> Result<(CredentialsFile, CredsSource)> {
172    match target {
173        CredsTarget::Explicit(p) => Ok((read_from(p)?, CredsSource::File(p.clone()))),
174        CredsTarget::Default(p) => {
175            #[cfg(target_os = "macos")]
176            return read_default_with(p, keychain::read_raw);
177            #[cfg(not(target_os = "macos"))]
178            read_default_with(p, || Ok(None))
179        }
180    }
181}
182
183/// Default-location read with an injectable Keychain reader, so the fallback
184/// selection is unit-testable on any platform (the hermeticity invariant —
185/// tests must never touch a real Keychain). Decision table:
186///
187/// | file state          | keychain        | outcome                    |
188/// |---------------------|-----------------|----------------------------|
189/// | usable              | (not consulted*)| file                       |
190/// | missing             | usable          | keychain                   |
191/// | missing             | absent          | original I/O error         |
192/// | unusable (#15)      | usable          | keychain                   |
193/// | unusable (#15)      | absent          | file result, unchanged     |
194/// | unparsable JSON     | usable          | keychain                   |
195/// | unparsable JSON     | absent          | original parse error       |
196/// | any                 | **unreadable**  | the Keychain error         |
197///
198/// The last row matters: a *locked* login Keychain or a denied ACL is not the
199/// same as "no credentials". Reporting it as absent surfaced a "run `claude`"
200/// message and sent users to re-authenticate while their credentials were
201/// sitting there intact, so that failure now wins over the file's own error.
202///
203/// *usable file short-circuits — no `security(1)` subprocess on the happy path.
204fn read_default_with(
205    path: &Path,
206    keychain_read: impl Fn() -> Result<Option<String>>,
207) -> Result<(CredentialsFile, CredsSource)> {
208    let file_result = read_from(path);
209    match &file_result {
210        Ok(creds) if !is_unusable(&creds.claude_ai_oauth) => {
211            Ok((file_result?, CredsSource::File(path.to_path_buf())))
212        }
213        // Missing, unusable, or unparsable — see if the Keychain has better.
214        _ => match keychain_read()? {
215            Some(raw) => match parse(&raw, "macOS Keychain (Claude Code-credentials)") {
216                Ok(kc) if !is_unusable(&kc.claude_ai_oauth) => Ok((kc, CredsSource::Keychain)),
217                // Keychain no better than the file — surface the file outcome.
218                _ => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
219            },
220            None => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
221        },
222    }
223}
224
225/// Parse a credentials JSON blob from any source (`source` only labels errors).
226fn parse(raw: &str, source: &str) -> Result<CredentialsFile> {
227    serde_json::from_str(raw).map_err(|e| {
228        AppError::Credentials(format!(
229            "could not parse {source}: {e}. Run `claude` to re-authenticate."
230        ))
231    })
232}
233
234/// Merge a refreshed `claudeAiOauth` into an existing credentials document
235/// (or a fresh `{}`), preserving any unknown top-level fields the Claude CLI
236/// keeps there (e.g. `mcpOAuth`). Pure so the merge is unit-testable without
237/// touching disk or the Keychain.
238fn merge_oauth(existing: Option<&str>, new_oauth: &OauthCreds) -> Result<serde_json::Value> {
239    let mut doc: serde_json::Value = existing
240        .and_then(|s| serde_json::from_str(s).ok())
241        .unwrap_or_else(|| serde_json::json!({}));
242    if !doc.is_object() {
243        doc = serde_json::json!({});
244    }
245    doc.as_object_mut().expect("just ensured object").insert(
246        "claudeAiOauth".into(),
247        serde_json::to_value(new_oauth).map_err(AppError::Json)?,
248    );
249    Ok(doc)
250}
251
252/// Persist updated credentials to the source they were actually read from
253/// (see [`CredsSource`]), preserving any unknown top-level fields the Claude
254/// CLI might have added. Following the read source keeps a single shared
255/// source of truth with Claude Code — refreshing Keychain-read tokens into a
256/// stale shadow file would rotate the refresh token out from under it.
257pub fn write_back_to(source: &CredsSource, new_oauth: &OauthCreds) -> Result<()> {
258    match source {
259        CredsSource::File(path) => write_back(path, new_oauth),
260        #[cfg(target_os = "macos")]
261        CredsSource::Keychain => {
262            let existing = keychain::read_raw()?;
263            let doc = merge_oauth(existing.as_deref(), new_oauth)?;
264            let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
265            keychain::write_raw(&json)
266        }
267        #[cfg(not(target_os = "macos"))]
268        CredsSource::Keychain => Err(AppError::Other(
269            "Keychain credentials source is macOS-only".into(),
270        )),
271    }
272}
273
274/// Persist updated credentials to a file, preserving unknown top-level fields.
275pub fn write_back(path: &Path, new_oauth: &OauthCreds) -> Result<()> {
276    let existing = std::fs::read_to_string(path).ok();
277    let doc = merge_oauth(existing.as_deref(), new_oauth)?;
278    let bytes = serde_json::to_vec_pretty(&doc).map_err(AppError::Json)?;
279    atomic_write(path, &bytes)
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::io::Write;
286    use tempfile::{NamedTempFile, TempDir};
287
288    fn write_creds(s: &str) -> NamedTempFile {
289        let mut f = NamedTempFile::new().unwrap();
290        f.write_all(s.as_bytes()).unwrap();
291        f.flush().unwrap();
292        f
293    }
294
295    /// Like `write_creds`, but with no open handle on the file, so
296    /// `write_back`'s atomic rename-over-destination succeeds on Windows.
297    /// See [`crate::cache::closed_temp_file`].
298    fn write_creds_closed(s: &str) -> (TempDir, std::path::PathBuf) {
299        crate::cache::closed_temp_file("credentials.json", Some(s))
300    }
301
302    #[test]
303    fn parses_canonical_shape() {
304        let f = write_creds(
305            r#"{"claudeAiOauth":{
306                "accessToken":"AT",
307                "refreshToken":"RT",
308                "expiresAt": 1735000000000,
309                "subscriptionType":"max",
310                "rateLimitTier":"default_claude_max_5x"
311            }}"#,
312        );
313        let creds = read_from(f.path()).unwrap();
314        assert_eq!(creds.claude_ai_oauth.access_token, "AT");
315        assert_eq!(creds.claude_ai_oauth.expires_at_ms, 1735000000000);
316        assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 5x");
317    }
318
319    #[test]
320    fn accepts_float_expires_at() {
321        // claudebar truncates `5000.0 → 5000`; we do the same.
322        let f = write_creds(
323            r#"{"claudeAiOauth":{
324                "accessToken":"A","refreshToken":"R",
325                "expiresAt": 5000.0,
326                "subscriptionType":"pro","rateLimitTier":""
327            }}"#,
328        );
329        let creds = read_from(f.path()).unwrap();
330        assert_eq!(creds.claude_ai_oauth.expires_at_ms, 5000);
331    }
332
333    #[test]
334    fn plan_label_pro_no_tier() {
335        let f = write_creds(
336            r#"{"claudeAiOauth":{
337                "accessToken":"A","refreshToken":"R","expiresAt": 0,
338                "subscriptionType":"pro","rateLimitTier":""
339            }}"#,
340        );
341        let creds = read_from(f.path()).unwrap();
342        assert_eq!(creds.claude_ai_oauth.plan_label(), "Pro");
343    }
344
345    #[test]
346    fn plan_label_max_20x() {
347        let f = write_creds(
348            r#"{"claudeAiOauth":{
349                "accessToken":"A","refreshToken":"R","expiresAt": 0,
350                "subscriptionType":"max","rateLimitTier":"default_claude_max_20x"
351            }}"#,
352        );
353        let creds = read_from(f.path()).unwrap();
354        assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 20x");
355    }
356
357    #[test]
358    fn plan_label_empty_subscription_falls_back() {
359        let f = write_creds(
360            r#"{"claudeAiOauth":{
361                "accessToken":"A","refreshToken":"R","expiresAt": 0,
362                "subscriptionType":"","rateLimitTier":""
363            }}"#,
364        );
365        let creds = read_from(f.path()).unwrap();
366        assert_eq!(creds.claude_ai_oauth.plan_label(), "Unknown");
367    }
368
369    #[test]
370    fn malformed_file_returns_credentials_error() {
371        let f = write_creds("not json");
372        let err = read_from(f.path()).unwrap_err();
373        assert!(matches!(err, AppError::Credentials(_)));
374    }
375
376    // Linux-only: a missing file with no Keychain fallback is an I/O error,
377    // not a parse error. `read_from` is strict on every platform — explicit
378    // paths never consult the Keychain (issues #14/#15) — so this needs no
379    // macOS gate anymore.
380    #[test]
381    fn read_from_missing_file_is_io_error() {
382        let path = std::path::Path::new("/nonexistent/ai-usagebar/.credentials.json");
383        let err = read_from(path).unwrap_err();
384        assert!(matches!(err, AppError::Io { .. }));
385    }
386
387    // --- issue #15: default-location Keychain fallback selection ------------
388    // `read_default_with` takes the keychain reader as a closure, so these run
389    // hermetically on any platform — no real Keychain, no real $HOME.
390
391    const USABLE: &str = r#"{"claudeAiOauth":{
392        "accessToken":"live-token","refreshToken":"rt","expiresAt": 9999999999999,
393        "subscriptionType":"max","rateLimitTier":""}}"#;
394    const UNUSABLE: &str = r#"{"claudeAiOauth":{
395        "accessToken":"","refreshToken":"","expiresAt": 0,
396        "subscriptionType":"","rateLimitTier":""}}"#;
397    const KEYCHAIN_USABLE: &str = r#"{"claudeAiOauth":{
398        "accessToken":"kc-token","refreshToken":"kc-rt","expiresAt": 9999999999999,
399        "subscriptionType":"max","rateLimitTier":""}}"#;
400
401    #[test]
402    fn is_unusable_only_when_fully_dead() {
403        let dead: CredentialsFile = serde_json::from_str(UNUSABLE).unwrap();
404        assert!(is_unusable(&dead.claude_ai_oauth));
405        // The v0.7.2 trusted-device shape (empty refreshToken, live
406        // accessToken) must NOT count as unusable — file stays authoritative.
407        let trusted: CredentialsFile = serde_json::from_str(
408            r#"{"claudeAiOauth":{"accessToken":"live","refreshToken":"",
409                "expiresAt": 9999999999999,"subscriptionType":"max","rateLimitTier":""}}"#,
410        )
411        .unwrap();
412        assert!(!is_unusable(&trusted.claude_ai_oauth));
413    }
414
415    #[test]
416    fn default_read_usable_file_wins_without_consulting_keychain() {
417        let (_dir, path) = write_creds_closed(USABLE);
418        let (creds, source) =
419            read_default_with(&path, || panic!("keychain must not be consulted")).unwrap();
420        assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
421        assert_eq!(source, CredsSource::File(path));
422    }
423
424    #[test]
425    fn default_read_missing_file_falls_back_to_keychain() {
426        let dir = TempDir::new().unwrap();
427        let path = dir.path().join("missing.json");
428        let (creds, source) =
429            read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
430        assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
431        assert_eq!(source, CredsSource::Keychain);
432    }
433
434    #[test]
435    fn default_read_missing_file_without_keychain_is_io_error() {
436        let dir = TempDir::new().unwrap();
437        let path = dir.path().join("missing.json");
438        let err = read_default_with(&path, || Ok(None)).unwrap_err();
439        assert!(matches!(err, AppError::Io { .. }));
440    }
441
442    #[test]
443    fn a_locked_keychain_wins_over_the_file_missing_error() {
444        // The regression this guards: `read_raw` mapped *every* `security`
445        // failure to Ok(None), so a locked login Keychain looked identical to
446        // "not logged in" and the user was told to run `claude` while their
447        // credentials sat there intact.
448        let dir = TempDir::new().unwrap();
449        let path = dir.path().join("missing.json");
450        let err = read_default_with(&path, || {
451            Err(AppError::Credentials("the Keychain is locked".into()))
452        })
453        .unwrap_err();
454        assert!(
455            matches!(err, AppError::Credentials(ref m) if m.contains("locked")),
456            "expected the Keychain error to surface, got {err:?}"
457        );
458    }
459
460    #[test]
461    fn default_read_unusable_file_prefers_usable_keychain() {
462        // The #15 scenario: stale zeroed file shadowing fresh Keychain creds.
463        let (_dir, path) = write_creds_closed(UNUSABLE);
464        let (creds, source) =
465            read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
466        assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
467        assert_eq!(source, CredsSource::Keychain);
468    }
469
470    #[test]
471    fn default_read_unusable_file_kept_when_keychain_absent_or_dead() {
472        let (_dir, path) = write_creds_closed(UNUSABLE);
473        // No keychain item → the file result stands, source stays File.
474        let (creds, source) = read_default_with(&path, || Ok(None)).unwrap();
475        assert!(is_unusable(&creds.claude_ai_oauth));
476        assert_eq!(source, CredsSource::File(path.clone()));
477        // Keychain item just as dead → same.
478        let (_, source) = read_default_with(&path, || Ok(Some(UNUSABLE.into()))).unwrap();
479        assert_eq!(source, CredsSource::File(path));
480    }
481
482    #[test]
483    fn default_read_unparsable_file_falls_back_to_keychain_else_errors() {
484        let (_dir, path) = write_creds_closed("not json at all");
485        let (creds, source) =
486            read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
487        assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
488        assert_eq!(source, CredsSource::Keychain);
489        // Without a keychain rescue, the original parse error surfaces.
490        let err = read_default_with(&path, || Ok(None)).unwrap_err();
491        assert!(matches!(err, AppError::Credentials(_)));
492    }
493
494    #[test]
495    fn resolve_explicit_never_falls_back() {
496        // An explicit target with a missing file is an I/O error even on a
497        // machine whose Keychain holds valid creds — resolve() routes Explicit
498        // through the strict read_from, which has no keychain path at all.
499        let dir = TempDir::new().unwrap();
500        let target = CredsTarget::Explicit(dir.path().join("missing.json"));
501        assert!(matches!(resolve(&target).unwrap_err(), AppError::Io { .. }));
502    }
503
504    #[test]
505    fn default_path_ends_with_claude_credentials() {
506        let p = default_path().unwrap();
507        // The trailing two segments are stable across platforms; only the home
508        // prefix differs (resolved by directories::BaseDirs).
509        assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
510    }
511
512    // On Windows the home prefix is %USERPROFILE%, not $HOME — assert the
513    // resolver honors it so the credential file is found natively.
514    #[cfg(windows)]
515    #[test]
516    fn default_path_uses_userprofile_on_windows() {
517        let p = default_path().unwrap();
518        let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
519        // directories::BaseDirs resolves the home via SHGetKnownFolderPath, which
520        // can differ from %USERPROFILE% in casing or path separator. Compare on a
521        // normalized basis (lowercased, backslashes) rather than Path::starts_with,
522        // which compares components case-sensitively even on Windows.
523        let norm = |s: &str| s.to_lowercase().replace('/', "\\");
524        let p_norm = norm(&p.to_string_lossy());
525        let up_norm = norm(&userprofile);
526        assert!(
527            p_norm.starts_with(up_norm.as_str()),
528            "{} should live under {}",
529            p.display(),
530            userprofile
531        );
532    }
533
534    #[test]
535    fn merge_oauth_preserves_unknown_top_level_fields() {
536        let existing = r#"{"claudeAiOauth":{"accessToken":"OLD"},"mcpOAuth":{"x":1}}"#;
537        let new_oauth = OauthCreds {
538            access_token: "NEW".into(),
539            refresh_token: "RT".into(),
540            expires_at_ms: 99,
541            subscription_type: "max".into(),
542            rate_limit_tier: "".into(),
543            scopes: None,
544        };
545        let doc = merge_oauth(Some(existing), &new_oauth).unwrap();
546        assert_eq!(doc["mcpOAuth"]["x"], 1);
547        assert_eq!(doc["claudeAiOauth"]["accessToken"], "NEW");
548        assert_eq!(doc["claudeAiOauth"]["expiresAt"], 99);
549    }
550
551    #[test]
552    fn merge_oauth_handles_empty_and_non_object_input() {
553        let new_oauth = OauthCreds {
554            access_token: "A".into(),
555            refresh_token: "R".into(),
556            expires_at_ms: 0,
557            subscription_type: "pro".into(),
558            rate_limit_tier: "".into(),
559            scopes: None,
560        };
561        // None → fresh object.
562        let doc = merge_oauth(None, &new_oauth).unwrap();
563        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
564        // Garbage / non-object → discarded, fresh object.
565        let doc = merge_oauth(Some("not json"), &new_oauth).unwrap();
566        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
567        let doc = merge_oauth(Some("[1,2,3]"), &new_oauth).unwrap();
568        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
569    }
570
571    #[test]
572    fn write_back_round_trips_and_preserves_unknown_fields() {
573        let (_dir, path) = write_creds_closed(
574            r#"{"claudeAiOauth":{
575                "accessToken":"OLD","refreshToken":"OLD","expiresAt": 0,
576                "subscriptionType":"pro","rateLimitTier":""
577            },"someOtherField":"keep me"}"#,
578        );
579        let creds = read_from(&path).unwrap();
580        let new_oauth = OauthCreds {
581            access_token: "NEW".into(),
582            refresh_token: "NEW_RT".into(),
583            expires_at_ms: 1234,
584            subscription_type: "pro".into(),
585            rate_limit_tier: "".into(),
586            scopes: creds.claude_ai_oauth.scopes.clone(),
587        };
588        write_back(&path, &new_oauth).unwrap();
589        // Re-read & verify the unknown field survived.
590        let raw = std::fs::read_to_string(&path).unwrap();
591        let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
592        assert_eq!(v["someOtherField"], "keep me");
593        assert_eq!(v["claudeAiOauth"]["accessToken"], "NEW");
594        assert_eq!(v["claudeAiOauth"]["expiresAt"], 1234);
595    }
596}