Skip to main content

ai_usagebar/cursor/
db.rs

1//! Read the Cursor IDE's own session token out of its local `state.vscdb`.
2//!
3//! Cursor has no documented usage API or API key for personal quota — every
4//! community tool that shows it (cursor-stats, cursor-usage-tracker, etc.)
5//! reads the same place: a SQLite key-value store the Cursor IDE itself
6//! maintains at `.../User/globalStorage/state.vscdb` (the same `state.vscdb`
7//! every VS Code-family app uses for `ItemTable`-shaped extension/global
8//! state), under the key `cursorAuth/accessToken`. That value has historically
9//! been a JWT whose `sub` claim (`auth0|<userId>`) is combined with the raw
10//! token into the `WorkosCursorSessionToken` cookie the dashboard's own usage
11//! call expects — see `fetch.rs`. Cursor 3.27.12 began writing an opaque
12//! `curs…` token instead (#503); the account identity then comes from
13//! `cursorAuth/cachedScopedProfile`'s `authId`, which carries the same
14//! `issuer|userId` shape, and the cookie is built unchanged.
15
16use std::path::{Path, PathBuf};
17use std::{hash::Hash, hash::Hasher};
18
19use rusqlite::{Connection, OpenFlags};
20
21use crate::display::sanitize_untrusted_path;
22use crate::error::{AppError, Result};
23
24const TOKEN_KEY: &str = "cursorAuth/accessToken";
25/// The IDE's cached account identity (`{"authId":"<issuer>|<userId>"}`);
26/// the identity source when the access token is opaque (#503).
27const SCOPED_PROFILE_KEY: &str = "cursorAuth/cachedScopedProfile";
28
29/// Default location of Cursor's local state database. This is Cursor's own
30/// per-OS convention (same one every VS Code-family app uses for its user
31/// data), not ai-usagebar's XDG cache — conveniently identical to what
32/// `directories::BaseDirs::config_dir()` already resolves on every platform:
33///   - Linux: `~/.config`
34///   - macOS: `~/Library/Application Support`
35///   - Windows: `%APPDATA%` (Roaming)
36pub fn default_db_path() -> Result<PathBuf> {
37    let base = directories::BaseDirs::new().ok_or_else(|| {
38        AppError::Other("could not resolve the platform config directory (no HOME?)".into())
39    })?;
40    Ok(base
41        .config_dir()
42        .join("Cursor")
43        .join("User")
44        .join("globalStorage")
45        .join("state.vscdb"))
46}
47
48/// Read the raw `cursorAuth/accessToken` value from `path`. A missing file or
49/// missing row means "never signed in to Cursor" — reported as a credentials
50/// error (like a missing `~/.claude/.credentials.json`) rather than a network
51/// or schema failure, so the widget's `⚠` tooltip tells the user to sign in
52/// rather than implying the API is down.
53pub fn read_access_token(path: &Path) -> Result<String> {
54    if !path.exists() {
55        return Err(AppError::Credentials(format!(
56            "Cursor database not found at {}. Open the Cursor IDE (or run `cursor-agent`) and \
57             sign in at least once, then try again.",
58            sanitize_untrusted_path(path)
59        )));
60    }
61    // Read-only: this file is Cursor's own live state, not ours to lock for
62    // writing. SQLite allows concurrent readers, so this is safe alongside a
63    // running Cursor IDE.
64    let conn =
65        Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|e| {
66            AppError::Credentials(format!(
67                "could not open Cursor database at {}: {e}",
68                sanitize_untrusted_path(path)
69            ))
70        })?;
71    let token: String = conn
72        .query_row(
73            "SELECT value FROM ItemTable WHERE key = ?1",
74            [TOKEN_KEY],
75            |row| row.get(0),
76        )
77        .map_err(|_| {
78            AppError::Credentials(format!(
79                "no Cursor session found in {}. Sign in to the Cursor IDE, then try again.",
80                sanitize_untrusted_path(path)
81            ))
82        })?;
83    if token.trim().is_empty() {
84        return Err(AppError::Credentials(
85            "Cursor session token is empty. Sign in to the Cursor IDE again.".into(),
86        ));
87    }
88    Ok(token)
89}
90
91/// Default location of the `cursor-agent` CLI's own login state — a plain
92/// JSON file, not the IDE's `state.vscdb`. Written by the headless
93/// `cursor-agent` tool, so it stays populated on machines that never run the
94/// desktop IDE at all.
95///
96/// The CLI picks the path itself, per OS, and only one of the three follows
97/// the config-directory convention the IDE database uses:
98///   - Linux: `$XDG_CONFIG_HOME/cursor/auth.json` (else `~/.config/...`)
99///   - macOS: `~/.cursor/auth.json` — not `~/Library/Application Support`
100///   - Windows: `%APPDATA%\Cursor\auth.json`
101///
102/// On macOS the CLI normally keeps its session in the Keychain instead and
103/// this file is the fallback; see [`read_agent_keychain_token`].
104pub fn default_agent_auth_path() -> Result<PathBuf> {
105    let base = directories::BaseDirs::new().ok_or_else(|| {
106        AppError::Other("could not resolve the platform config directory (no HOME?)".into())
107    })?;
108    Ok(if cfg!(target_os = "macos") {
109        base.home_dir().join(".cursor").join("auth.json")
110    } else if cfg!(windows) {
111        base.config_dir().join("Cursor").join("auth.json")
112    } else {
113        base.config_dir().join("cursor").join("auth.json")
114    })
115}
116
117/// Read `cursor-agent`'s `accessToken` out of its `auth.json`. Same error
118/// shape as [`read_access_token`] (missing file / missing field / empty
119/// value are all a [`AppError::Credentials`]) so callers can treat both
120/// sources interchangeably.
121pub fn read_agent_access_token(path: &Path) -> Result<String> {
122    if !path.exists() {
123        return Err(AppError::Credentials(format!(
124            "cursor-agent auth file not found at {}. Run `cursor-agent` and sign in at least \
125             once, then try again.",
126            sanitize_untrusted_path(path)
127        )));
128    }
129    let bytes = std::fs::read(path).map_err(|e| AppError::io_at(path, e))?;
130    let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
131        AppError::Credentials(format!(
132            "could not parse {}: {e}",
133            sanitize_untrusted_path(path)
134        ))
135    })?;
136    let token = value
137        .get("accessToken")
138        .and_then(serde_json::Value::as_str)
139        .filter(|s| !s.trim().is_empty())
140        .ok_or_else(|| {
141            AppError::Credentials(format!(
142                "no accessToken in {}. Sign in with `cursor-agent` again.",
143                sanitize_untrusted_path(path)
144            ))
145        })?;
146    Ok(token.to_string())
147}
148
149/// Where `cursor-agent` keeps its login on macOS: two generic-password items
150/// in the login Keychain (`cursor-access-token` / `cursor-refresh-token`, both
151/// under the account `cursor-user`), not the `auth.json` it writes on Linux.
152/// A Mac with the CLI and no IDE therefore has neither of the other two
153/// sources.
154#[cfg(target_os = "macos")]
155const AGENT_KEYCHAIN_SERVICE: &str = "cursor-access-token";
156#[cfg(target_os = "macos")]
157const AGENT_KEYCHAIN_ACCOUNT: &str = "cursor-user";
158
159/// Read `cursor-agent`'s access token from the macOS Keychain. `None` for
160/// every failure — item absent, Keychain locked, access denied, not macOS —
161/// because this is a last-resort source: the caller's own "sign in" error is
162/// the more useful thing to show than a `security` exit code. Goes through
163/// `security(1)`, like `anthropic::keychain`, so the item's ACL stays the
164/// one `cursor-agent` created.
165#[cfg(target_os = "macos")]
166pub fn read_agent_keychain_token() -> Option<String> {
167    let out = agent_keychain_command().output().ok()?;
168    if !out.status.success() {
169        return None;
170    }
171    let token = String::from_utf8(out.stdout).ok()?;
172    let token = token.trim();
173    (!token.is_empty()).then(|| token.to_string())
174}
175
176/// `security find-generic-password -s <service> -a <account> -w`, read-only;
177/// the secret arrives on stdout, never in argv.
178#[cfg(target_os = "macos")]
179fn agent_keychain_command() -> std::process::Command {
180    let mut command = std::process::Command::new("/usr/bin/security");
181    command
182        .args([
183            "find-generic-password",
184            "-s",
185            AGENT_KEYCHAIN_SERVICE,
186            "-a",
187            AGENT_KEYCHAIN_ACCOUNT,
188            "-w",
189        ])
190        .stdin(std::process::Stdio::null())
191        .stderr(std::process::Stdio::null());
192    // A credential lookup must not inherit this process's provider keys.
193    for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
194        command.env_remove(var);
195    }
196    command
197}
198
199#[cfg(not(target_os = "macos"))]
200pub fn read_agent_keychain_token() -> Option<String> {
201    None
202}
203
204/// Resolve a Cursor session token from the available sources. The IDE's
205/// `state.vscdb` is tried first (it is the live, continuously-refreshed source
206/// when the desktop app is actually running); a machine that has never opened
207/// the IDE falls back to whatever `cursor-agent` holds: its `auth.json`, or on
208/// macOS its Keychain items. An existing but unusable IDE database is *not*
209/// papered over by the CLI's login — that would silently swap accounts. If the
210/// agent file exists but cannot be used, the Keychain is tried before its
211/// error is surfaced, so a headless user with neither gets an actionable
212/// diagnostic. The IDE's error remains the one surfaced when every source is
213/// absent, since it names the more commonly expected path.
214///
215/// The real Keychain is only consulted when `agent_auth_path` is the default
216/// location: a config override (or a test's temp file) names a specific
217/// source and must not be widened to the machine's own login.
218pub fn resolve_access_token(db_path: &Path, agent_auth_path: &Path) -> Result<String> {
219    let default_agent = default_agent_auth_path().ok();
220    let ambient = default_agent.as_deref() == Some(agent_auth_path);
221    resolve_access_token_with(db_path, agent_auth_path, || {
222        ambient.then(read_agent_keychain_token).flatten()
223    })
224}
225
226/// [`resolve_access_token`] with the Keychain reader injected.
227pub fn resolve_access_token_with(
228    db_path: &Path,
229    agent_auth_path: &Path,
230    keychain: impl FnOnce() -> Option<String>,
231) -> Result<String> {
232    match read_access_token(db_path) {
233        Ok(token) => Ok(token),
234        Err(ide_err) if !db_path.exists() => {
235            if agent_auth_path.exists() {
236                match read_agent_access_token(agent_auth_path) {
237                    Ok(token) => return Ok(token),
238                    Err(agent_err) => return keychain().ok_or(agent_err),
239                }
240            }
241            keychain().ok_or(ide_err)
242        }
243        Err(ide_err) => Err(ide_err),
244    }
245}
246
247/// The two values the `/api/usage` call needs, both derived from the same JWT:
248/// the bare user id (a query param) and the `WorkosCursorSessionToken` cookie
249/// value (`userId%3A%3Atoken` — literal, pre-encoded `::`, matching what the
250/// Cursor dashboard's own JS sends).
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct SessionAuth {
253    pub user_id: String,
254    /// Stable, non-plaintext cache identity for the signed-in Cursor account.
255    /// The hash is only a change detector: a different toolchain may produce a
256    /// different value and force one harmless refetch.
257    pub account_key: String,
258    pub cookie_value: String,
259}
260
261/// Derive [`SessionAuth`] from the raw access token. Fails when the token
262/// isn't a decodable JWT or its `sub` claim doesn't have the `issuer|userId`
263/// shape every Cursor JWT carries — either way the token alone is unusable,
264/// so this is a credentials error, not a schema error.
265pub fn session_auth(token: &str) -> Result<SessionAuth> {
266    let claims = crate::jwt::claims(token).ok_or_else(opaque_token_error)?;
267    let sub = claims
268        .get("sub")
269        .and_then(serde_json::Value::as_str)
270        .ok_or_else(|| AppError::Credentials("Cursor session token has no `sub` claim.".into()))?;
271    auth_from_sub(token, sub)
272}
273
274/// [`session_auth`] with the IDE database as a second identity source.
275///
276/// Cursor 3.27.12 replaced the JWT in `cursorAuth/accessToken` with an opaque
277/// `curs…` token, which carries no claims to decode (#503). The account
278/// identity is still on disk: `cursorAuth/cachedScopedProfile` holds
279/// `{"authId":"<issuer>|<userId>", …}` — the same value the JWT used to carry
280/// in `sub`. When the token does not decode, that `authId` supplies the user
281/// id and the cookie is built exactly as before (`userId::<token>`); the
282/// opaque token is otherwise used verbatim.
283pub fn session_auth_for(db_path: &Path, token: &str) -> Result<SessionAuth> {
284    match session_auth(token) {
285        Ok(auth) => Ok(auth),
286        Err(jwt_err) => match read_cached_auth_id(db_path) {
287            Some(auth_id) => auth_from_sub(token, &auth_id),
288            None => Err(jwt_err),
289        },
290    }
291}
292
293/// The `authId` from `cursorAuth/cachedScopedProfile`, when Cursor stored one.
294/// Best-effort: a missing file, table, row, or field is simply no identity
295/// source here, and the caller keeps its original error.
296fn read_cached_auth_id(path: &Path) -> Option<String> {
297    let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
298    let profile: String = conn
299        .query_row(
300            "SELECT value FROM ItemTable WHERE key = ?1",
301            [SCOPED_PROFILE_KEY],
302            |row| row.get(0),
303        )
304        .ok()?;
305    let value: serde_json::Value = serde_json::from_str(&profile).ok()?;
306    value
307        .get("authId")
308        .and_then(serde_json::Value::as_str)
309        .map(str::to_owned)
310}
311
312fn opaque_token_error() -> AppError {
313    AppError::Credentials(
314        "Cursor session token could not be decoded. Sign in to the Cursor IDE again.".into(),
315    )
316}
317
318/// Build the identity from an `issuer|userId` value: the JWT `sub` or the
319/// stored `authId`, which share the shape.
320fn auth_from_sub(token: &str, sub: &str) -> Result<SessionAuth> {
321    let user_id = sub
322        .split('|')
323        .nth(1)
324        .filter(|s| !s.is_empty())
325        .ok_or_else(|| {
326            AppError::Credentials(format!(
327                "Cursor session token `sub` claim has an unexpected shape: {sub:?}"
328            ))
329        })?
330        .to_string();
331    let mut hasher = std::collections::hash_map::DefaultHasher::new();
332    user_id.hash(&mut hasher);
333    let account_key = format!("{:016x}", hasher.finish());
334    let cookie_value = format!("{user_id}%3A%3A{token}");
335    Ok(SessionAuth {
336        user_id,
337        account_key,
338        cookie_value,
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use base64::Engine as _;
346    use tempfile::TempDir;
347
348    /// Build a fake JWT with the given claims (no signature verification,
349    /// matching `openai::creds`'s test helper).
350    fn fake_jwt(claims: serde_json::Value) -> String {
351        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
352        let payload =
353            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
354        format!("{header}.{payload}.sig")
355    }
356
357    fn seed_db(path: &Path, token: Option<&str>) {
358        let conn = Connection::open(path).unwrap();
359        conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
360            .unwrap();
361        if let Some(t) = token {
362            conn.execute(
363                "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
364                rusqlite::params![TOKEN_KEY, t],
365            )
366            .unwrap();
367        }
368    }
369
370    /// Add a `cachedScopedProfile` row, the identity source for opaque tokens.
371    fn seed_scoped_profile(path: &Path, profile: &str) {
372        let conn = Connection::open(path).unwrap();
373        conn.execute(
374            "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
375            rusqlite::params![SCOPED_PROFILE_KEY, profile],
376        )
377        .unwrap();
378    }
379
380    // #503: Cursor 3.27.12 writes an opaque `curs…` token with no claims. The
381    // user id still comes from the IDE's cached profile, and the cookie keeps
382    // the `userId::token` shape the dashboard expects.
383    #[test]
384    fn an_opaque_token_uses_the_cached_profile_identity() {
385        let td = TempDir::new().unwrap();
386        let path = td.path().join("state.vscdb");
387        let opaque = format!("curs_{}", "a".repeat(400));
388        seed_db(&path, None);
389        seed_scoped_profile(
390            &path,
391            r#"{"authId":"grok|user_01M4473VZ67Q3X6HF8WZFE3SMW","displayName":"Someone"}"#,
392        );
393        let auth = session_auth_for(&path, &opaque).unwrap();
394        assert_eq!(auth.user_id, "user_01M4473VZ67Q3X6HF8WZFE3SMW");
395        assert_eq!(
396            auth.cookie_value,
397            format!("user_01M4473VZ67Q3X6HF8WZFE3SMW%3A%3A{opaque}")
398        );
399    }
400
401    #[test]
402    fn an_opaque_token_without_a_profile_keeps_the_decode_error() {
403        let td = TempDir::new().unwrap();
404        let path = td.path().join("state.vscdb");
405        seed_db(&path, None);
406        let err = session_auth_for(&path, "curs_opaque_without_profile").unwrap_err();
407        assert!(err.to_string().contains("could not be decoded"), "{err}");
408    }
409
410    #[test]
411    fn a_jwt_still_wins_over_the_cached_profile() {
412        let td = TempDir::new().unwrap();
413        let path = td.path().join("state.vscdb");
414        seed_db(&path, None);
415        seed_scoped_profile(&path, r#"{"authId":"grok|user_from_profile"}"#);
416        let jwt = fake_jwt(serde_json::json!({"sub": "auth0|user_from_jwt"}));
417        let auth = session_auth_for(&path, &jwt).unwrap();
418        assert_eq!(auth.user_id, "user_from_jwt");
419    }
420
421    #[test]
422    fn a_profile_without_a_usable_auth_id_is_not_an_identity() {
423        let td = TempDir::new().unwrap();
424        let path = td.path().join("state.vscdb");
425        seed_db(&path, None);
426        seed_scoped_profile(&path, r#"{"authId":"no-pipe-here"}"#);
427        assert!(session_auth_for(&path, "curs_opaque").is_err());
428        seed_scoped_profile(&path, r#"{"displayName":"no auth id"}"#);
429        assert!(session_auth_for(&path, "curs_opaque").is_err());
430    }
431
432    #[test]
433    fn a_missing_profile_row_falls_back_to_the_jwt_path() {
434        let td = TempDir::new().unwrap();
435        let path = td.path().join("state.vscdb");
436        seed_db(&path, None);
437        let jwt = fake_jwt(serde_json::json!({"sub": "auth0|only_jwt"}));
438        assert_eq!(session_auth_for(&path, &jwt).unwrap().user_id, "only_jwt");
439    }
440
441    #[test]
442    fn default_db_path_ends_with_the_cursor_state_file() {
443        let p = default_db_path().unwrap();
444        assert!(
445            p.ends_with(
446                std::path::Path::new("Cursor")
447                    .join("User")
448                    .join("globalStorage")
449                    .join("state.vscdb")
450            )
451        );
452    }
453
454    #[test]
455    fn missing_file_is_a_credentials_error_naming_the_path() {
456        let dir = TempDir::new().unwrap();
457        let path = dir.path().join("state.vscdb");
458        let err = read_access_token(&path).unwrap_err();
459        match err {
460            AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
461            other => panic!("expected Credentials error, got {other:?}"),
462        }
463    }
464
465    #[test]
466    fn reads_the_token_back_out_of_the_item_table() {
467        let dir = TempDir::new().unwrap();
468        let path = dir.path().join("state.vscdb");
469        seed_db(&path, Some("fake-token-value"));
470        assert_eq!(read_access_token(&path).unwrap(), "fake-token-value");
471    }
472
473    #[test]
474    fn missing_row_is_a_credentials_error() {
475        let dir = TempDir::new().unwrap();
476        let path = dir.path().join("state.vscdb");
477        seed_db(&path, None);
478        let err = read_access_token(&path).unwrap_err();
479        assert!(matches!(err, AppError::Credentials(_)));
480    }
481
482    #[test]
483    fn empty_token_is_a_credentials_error() {
484        let dir = TempDir::new().unwrap();
485        let path = dir.path().join("state.vscdb");
486        seed_db(&path, Some(""));
487        let err = read_access_token(&path).unwrap_err();
488        assert!(matches!(err, AppError::Credentials(_)));
489    }
490
491    #[test]
492    fn session_auth_extracts_user_id_and_builds_the_cookie_value() {
493        let token = fake_jwt(serde_json::json!({"sub": "auth0|user_abc123"}));
494        let auth = session_auth(&token).unwrap();
495        assert_eq!(auth.user_id, "user_abc123");
496        assert_eq!(auth.account_key.len(), 16);
497        assert!(!auth.account_key.contains("user_abc123"));
498        assert_eq!(auth.cookie_value, format!("user_abc123%3A%3A{token}"));
499    }
500
501    #[test]
502    fn session_auth_account_key_is_stable_and_account_specific() {
503        let one = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
504        let one_again = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
505        let two = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|two"}))).unwrap();
506        assert_eq!(one.account_key, one_again.account_key);
507        assert_ne!(one.account_key, two.account_key);
508    }
509
510    #[test]
511    fn session_auth_rejects_a_non_jwt_token() {
512        let err = session_auth("not-a-jwt").unwrap_err();
513        assert!(matches!(err, AppError::Credentials(_)));
514    }
515
516    #[test]
517    fn session_auth_rejects_missing_sub_claim() {
518        let token = fake_jwt(serde_json::json!({"other": "value"}));
519        let err = session_auth(&token).unwrap_err();
520        match err {
521            AppError::Credentials(m) => assert!(m.contains("sub")),
522            other => panic!("expected Credentials error, got {other:?}"),
523        }
524    }
525
526    #[test]
527    fn session_auth_rejects_sub_without_a_pipe_separated_user_id() {
528        let token = fake_jwt(serde_json::json!({"sub": "no-pipe-here"}));
529        let err = session_auth(&token).unwrap_err();
530        assert!(matches!(err, AppError::Credentials(_)));
531    }
532
533    #[test]
534    fn default_agent_auth_path_follows_the_cli_own_convention_per_os() {
535        let p = default_agent_auth_path().unwrap();
536        let expected_tail = if cfg!(target_os = "macos") {
537            std::path::Path::new(".cursor").join("auth.json")
538        } else if cfg!(windows) {
539            std::path::Path::new("Cursor").join("auth.json")
540        } else {
541            std::path::Path::new("cursor").join("auth.json")
542        };
543        assert!(p.ends_with(expected_tail), "{}", p.display());
544        // The macOS default is the CLI's `~/.cursor`, never the IDE's
545        // Application Support directory.
546        assert!(!cfg!(target_os = "macos") || !p.to_string_lossy().contains("Application Support"));
547    }
548
549    #[test]
550    fn agent_auth_missing_file_is_a_credentials_error_naming_the_path() {
551        let dir = TempDir::new().unwrap();
552        let path = dir.path().join("auth.json");
553        let err = read_agent_access_token(&path).unwrap_err();
554        match err {
555            AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
556            other => panic!("expected Credentials error, got {other:?}"),
557        }
558    }
559
560    #[test]
561    fn agent_auth_reads_access_token_out_of_the_json_file() {
562        let dir = TempDir::new().unwrap();
563        let path = dir.path().join("auth.json");
564        std::fs::write(
565            &path,
566            serde_json::json!({"accessToken": "agent-token-value", "refreshToken": "r"})
567                .to_string(),
568        )
569        .unwrap();
570        assert_eq!(read_agent_access_token(&path).unwrap(), "agent-token-value");
571    }
572
573    #[test]
574    fn agent_auth_missing_field_is_a_credentials_error() {
575        let dir = TempDir::new().unwrap();
576        let path = dir.path().join("auth.json");
577        std::fs::write(&path, serde_json::json!({"refreshToken": "r"}).to_string()).unwrap();
578        let err = read_agent_access_token(&path).unwrap_err();
579        assert!(matches!(err, AppError::Credentials(_)));
580    }
581
582    #[test]
583    fn agent_auth_empty_token_is_a_credentials_error() {
584        let dir = TempDir::new().unwrap();
585        let path = dir.path().join("auth.json");
586        std::fs::write(&path, serde_json::json!({"accessToken": ""}).to_string()).unwrap();
587        let err = read_agent_access_token(&path).unwrap_err();
588        assert!(matches!(err, AppError::Credentials(_)));
589    }
590
591    #[test]
592    fn agent_auth_malformed_json_is_a_credentials_error() {
593        let dir = TempDir::new().unwrap();
594        let path = dir.path().join("auth.json");
595        std::fs::write(&path, "not json").unwrap();
596        let err = read_agent_access_token(&path).unwrap_err();
597        assert!(matches!(err, AppError::Credentials(_)));
598    }
599
600    #[test]
601    fn resolve_prefers_the_ide_db_when_both_are_present() {
602        let dir = TempDir::new().unwrap();
603        let db_path = dir.path().join("state.vscdb");
604        seed_db(&db_path, Some("ide-token"));
605        let agent_path = dir.path().join("auth.json");
606        std::fs::write(
607            &agent_path,
608            serde_json::json!({"accessToken": "agent-token"}).to_string(),
609        )
610        .unwrap();
611        assert_eq!(
612            resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
613            "ide-token"
614        );
615    }
616
617    #[test]
618    fn resolve_falls_back_to_the_agent_file_when_the_ide_db_is_missing() {
619        let dir = TempDir::new().unwrap();
620        let db_path = dir.path().join("state.vscdb");
621        let agent_path = dir.path().join("auth.json");
622        std::fs::write(
623            &agent_path,
624            serde_json::json!({"accessToken": "agent-token"}).to_string(),
625        )
626        .unwrap();
627        assert_eq!(
628            resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
629            "agent-token"
630        );
631    }
632
633    #[test]
634    fn resolve_does_not_hide_an_existing_broken_ide_db_with_the_agent_file() {
635        let dir = TempDir::new().unwrap();
636        let db_path = dir.path().join("state.vscdb");
637        seed_db(&db_path, None);
638        let agent_path = dir.path().join("auth.json");
639        std::fs::write(
640            &agent_path,
641            serde_json::json!({"accessToken": "agent-token"}).to_string(),
642        )
643        .unwrap();
644
645        let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
646        match err {
647            AppError::Credentials(m) => {
648                assert!(m.contains(&db_path.display().to_string()));
649                assert!(!m.contains(&agent_path.display().to_string()));
650            }
651            other => panic!("expected Credentials error, got {other:?}"),
652        }
653    }
654
655    #[test]
656    fn resolve_surfaces_the_ide_error_when_both_sources_are_missing() {
657        let dir = TempDir::new().unwrap();
658        let db_path = dir.path().join("state.vscdb");
659        let agent_path = dir.path().join("auth.json");
660        let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
661        match err {
662            AppError::Credentials(m) => assert!(m.contains(&db_path.display().to_string())),
663            other => panic!("expected Credentials error, got {other:?}"),
664        }
665    }
666
667    #[test]
668    fn resolve_surfaces_the_agent_error_when_its_file_exists_but_is_malformed() {
669        let dir = TempDir::new().unwrap();
670        let db_path = dir.path().join("state.vscdb");
671        let agent_path = dir.path().join("auth.json");
672        std::fs::write(&agent_path, "not json").unwrap();
673
674        let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
675        match err {
676            AppError::Credentials(m) => {
677                assert!(m.contains(&agent_path.display().to_string()));
678                assert!(m.contains("could not parse"));
679            }
680            other => panic!("expected Credentials error, got {other:?}"),
681        }
682    }
683
684    #[test]
685    fn resolve_falls_back_to_the_keychain_when_the_ide_db_and_agent_file_are_missing() {
686        let dir = TempDir::new().unwrap();
687        let db_path = dir.path().join("state.vscdb");
688        let agent_path = dir.path().join("auth.json");
689        assert_eq!(
690            resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
691                .unwrap(),
692            "keychain-token"
693        );
694    }
695
696    #[test]
697    fn resolve_tries_the_keychain_when_the_agent_file_is_unusable() {
698        let dir = TempDir::new().unwrap();
699        let db_path = dir.path().join("state.vscdb");
700        let agent_path = dir.path().join("auth.json");
701        std::fs::write(
702            &agent_path,
703            serde_json::json!({"refreshToken": "r"}).to_string(),
704        )
705        .unwrap();
706        assert_eq!(
707            resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
708                .unwrap(),
709            "keychain-token"
710        );
711    }
712
713    #[test]
714    fn resolve_never_consults_the_keychain_over_a_present_ide_db() {
715        let dir = TempDir::new().unwrap();
716        let db_path = dir.path().join("state.vscdb");
717        seed_db(&db_path, None);
718        let agent_path = dir.path().join("auth.json");
719        let err = resolve_access_token_with(&db_path, &agent_path, || {
720            panic!("an existing IDE db must not be widened to the keychain")
721        })
722        .unwrap_err();
723        assert!(matches!(err, AppError::Credentials(_)));
724    }
725
726    #[test]
727    fn resolve_leaves_the_keychain_alone_for_a_non_default_agent_path() {
728        // A temp path is never the default location, so the real Keychain is
729        // not read: this must fail rather than pick up the machine's login.
730        let dir = TempDir::new().unwrap();
731        let err = resolve_access_token(
732            &dir.path().join("state.vscdb"),
733            &dir.path().join("auth.json"),
734        )
735        .unwrap_err();
736        assert!(matches!(err, AppError::Credentials(_)));
737    }
738
739    /// The Keychain lookup must not inherit this process's provider keys, the
740    /// rule every credential lookup in this crate follows.
741    #[cfg(target_os = "macos")]
742    #[test]
743    fn agent_keychain_lookup_scrubs_vendor_secret_env_vars() {
744        let command = agent_keychain_command();
745        assert_eq!(command.get_program(), "/usr/bin/security");
746        let removed: Vec<String> = command
747            .get_envs()
748            .filter(|(_, value)| value.is_none())
749            .map(|(key, _)| key.to_string_lossy().into_owned())
750            .collect();
751        for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
752            assert!(
753                removed.iter().any(|key| key == var),
754                "expected {var} to be scrubbed from the cursor-agent keychain lookup"
755            );
756        }
757    }
758}