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    Ok(resolve_access_tokens(db_path, agent_auth_path)?.primary)
220}
221
222/// Primary token plus an optional `cursor-agent` fallback. The IDE remains
223/// authoritative for normal operation; the fallback is exposed so the fetch
224/// layer can retry only after Cursor rejects an opaque IDE token (#503).
225pub struct AccessTokens {
226    pub primary: String,
227    pub agent_fallback: Option<String>,
228}
229
230pub fn resolve_access_tokens(db_path: &Path, agent_auth_path: &Path) -> Result<AccessTokens> {
231    let default_agent = default_agent_auth_path().ok();
232    let ambient = default_agent.as_deref() == Some(agent_auth_path);
233    match read_access_token(db_path) {
234        Ok(primary) => {
235            let agent_fallback = read_agent_access_token(agent_auth_path)
236                .ok()
237                .or_else(|| ambient.then(read_agent_keychain_token).flatten())
238                .filter(|token| token != &primary);
239            Ok(AccessTokens {
240                primary,
241                agent_fallback,
242            })
243        }
244        Err(ide_err) if !db_path.exists() => {
245            let primary = if agent_auth_path.exists() {
246                read_agent_access_token(agent_auth_path).or_else(|agent_err| {
247                    ambient
248                        .then(read_agent_keychain_token)
249                        .flatten()
250                        .ok_or(agent_err)
251                })?
252            } else {
253                ambient
254                    .then(read_agent_keychain_token)
255                    .flatten()
256                    .ok_or(ide_err)?
257            };
258            Ok(AccessTokens {
259                primary,
260                agent_fallback: None,
261            })
262        }
263        Err(ide_err) => Err(ide_err),
264    }
265}
266
267/// [`resolve_access_token`] with the Keychain reader injected.
268pub fn resolve_access_token_with(
269    db_path: &Path,
270    agent_auth_path: &Path,
271    keychain: impl FnOnce() -> Option<String>,
272) -> Result<String> {
273    match read_access_token(db_path) {
274        Ok(token) => Ok(token),
275        Err(ide_err) if !db_path.exists() => {
276            if agent_auth_path.exists() {
277                match read_agent_access_token(agent_auth_path) {
278                    Ok(token) => return Ok(token),
279                    Err(agent_err) => return keychain().ok_or(agent_err),
280                }
281            }
282            keychain().ok_or(ide_err)
283        }
284        Err(ide_err) => Err(ide_err),
285    }
286}
287
288/// The two values the `/api/usage` call needs, both derived from the same JWT:
289/// the bare user id (a query param) and the `WorkosCursorSessionToken` cookie
290/// value (`userId%3A%3Atoken` — literal, pre-encoded `::`, matching what the
291/// Cursor dashboard's own JS sends).
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct SessionAuth {
294    pub user_id: String,
295    /// Stable, non-plaintext cache identity for the signed-in Cursor account.
296    /// The hash is only a change detector: a different toolchain may produce a
297    /// different value and force one harmless refetch.
298    pub account_key: String,
299    pub cookie_value: String,
300}
301
302/// Derive [`SessionAuth`] from the raw access token. Fails when the token
303/// isn't a decodable JWT or its `sub` claim doesn't have the `issuer|userId`
304/// shape every Cursor JWT carries — either way the token alone is unusable,
305/// so this is a credentials error, not a schema error.
306pub fn session_auth(token: &str) -> Result<SessionAuth> {
307    let claims = crate::jwt::claims(token).ok_or_else(opaque_token_error)?;
308    let sub = claims
309        .get("sub")
310        .and_then(serde_json::Value::as_str)
311        .ok_or_else(|| AppError::Credentials("Cursor session token has no `sub` claim.".into()))?;
312    auth_from_sub(token, sub)
313}
314
315/// [`session_auth`] with the IDE database as a second identity source.
316///
317/// Cursor 3.27.12 replaced the JWT in `cursorAuth/accessToken` with an opaque
318/// `curs…` token, which carries no claims to decode (#503). The account
319/// identity is still on disk: `cursorAuth/cachedScopedProfile` holds
320/// `{"authId":"<issuer>|<userId>", …}` — the same value the JWT used to carry
321/// in `sub`. When the token does not decode, that `authId` supplies the user
322/// id and the cookie is built exactly as before (`userId::<token>`); the
323/// opaque token is otherwise used verbatim.
324pub fn session_auth_for(db_path: &Path, token: &str) -> Result<SessionAuth> {
325    match session_auth(token) {
326        Ok(auth) => Ok(auth),
327        Err(jwt_err) => match read_cached_auth_id(db_path) {
328            Some(auth_id) => auth_from_sub(token, &auth_id),
329            None => Err(jwt_err),
330        },
331    }
332}
333
334/// The `authId` from `cursorAuth/cachedScopedProfile`, when Cursor stored one.
335/// Best-effort: a missing file, table, row, or field is simply no identity
336/// source here, and the caller keeps its original error.
337fn read_cached_auth_id(path: &Path) -> Option<String> {
338    let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
339    let profile: String = conn
340        .query_row(
341            "SELECT value FROM ItemTable WHERE key = ?1",
342            [SCOPED_PROFILE_KEY],
343            |row| row.get(0),
344        )
345        .ok()?;
346    let value: serde_json::Value = serde_json::from_str(&profile).ok()?;
347    value
348        .get("authId")
349        .and_then(serde_json::Value::as_str)
350        .map(str::to_owned)
351}
352
353fn opaque_token_error() -> AppError {
354    AppError::Credentials(
355        "Cursor session token could not be decoded. Sign in to the Cursor IDE again.".into(),
356    )
357}
358
359/// Build the identity from an `issuer|userId` value: the JWT `sub` or the
360/// stored `authId`, which share the shape.
361fn auth_from_sub(token: &str, sub: &str) -> Result<SessionAuth> {
362    let user_id = sub
363        .split('|')
364        .nth(1)
365        .filter(|s| !s.is_empty())
366        .ok_or_else(|| {
367            AppError::Credentials(format!(
368                "Cursor session token `sub` claim has an unexpected shape: {sub:?}"
369            ))
370        })?
371        .to_string();
372    let mut hasher = std::collections::hash_map::DefaultHasher::new();
373    user_id.hash(&mut hasher);
374    let account_key = format!("{:016x}", hasher.finish());
375    let cookie_value = format!("{user_id}%3A%3A{token}");
376    Ok(SessionAuth {
377        user_id,
378        account_key,
379        cookie_value,
380    })
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use base64::Engine as _;
387    use tempfile::TempDir;
388
389    /// Build a fake JWT with the given claims (no signature verification,
390    /// matching `openai::creds`'s test helper).
391    fn fake_jwt(claims: serde_json::Value) -> String {
392        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
393        let payload =
394            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
395        format!("{header}.{payload}.sig")
396    }
397
398    fn seed_db(path: &Path, token: Option<&str>) {
399        let conn = Connection::open(path).unwrap();
400        conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
401            .unwrap();
402        if let Some(t) = token {
403            conn.execute(
404                "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
405                rusqlite::params![TOKEN_KEY, t],
406            )
407            .unwrap();
408        }
409    }
410
411    /// Add a `cachedScopedProfile` row, the identity source for opaque tokens.
412    fn seed_scoped_profile(path: &Path, profile: &str) {
413        let conn = Connection::open(path).unwrap();
414        conn.execute(
415            "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
416            rusqlite::params![SCOPED_PROFILE_KEY, profile],
417        )
418        .unwrap();
419    }
420
421    // #503: Cursor 3.27.12 writes an opaque `curs…` token with no claims. The
422    // user id still comes from the IDE's cached profile, and the cookie keeps
423    // the `userId::token` shape the dashboard expects.
424    #[test]
425    fn an_opaque_token_uses_the_cached_profile_identity() {
426        let td = TempDir::new().unwrap();
427        let path = td.path().join("state.vscdb");
428        let opaque = format!("curs_{}", "a".repeat(400));
429        seed_db(&path, None);
430        seed_scoped_profile(
431            &path,
432            r#"{"authId":"grok|user_01M4473VZ67Q3X6HF8WZFE3SMW","displayName":"Someone"}"#,
433        );
434        let auth = session_auth_for(&path, &opaque).unwrap();
435        assert_eq!(auth.user_id, "user_01M4473VZ67Q3X6HF8WZFE3SMW");
436        assert_eq!(
437            auth.cookie_value,
438            format!("user_01M4473VZ67Q3X6HF8WZFE3SMW%3A%3A{opaque}")
439        );
440    }
441
442    #[test]
443    fn an_opaque_token_without_a_profile_keeps_the_decode_error() {
444        let td = TempDir::new().unwrap();
445        let path = td.path().join("state.vscdb");
446        seed_db(&path, None);
447        let err = session_auth_for(&path, "curs_opaque_without_profile").unwrap_err();
448        assert!(err.to_string().contains("could not be decoded"), "{err}");
449    }
450
451    #[test]
452    fn a_jwt_still_wins_over_the_cached_profile() {
453        let td = TempDir::new().unwrap();
454        let path = td.path().join("state.vscdb");
455        seed_db(&path, None);
456        seed_scoped_profile(&path, r#"{"authId":"grok|user_from_profile"}"#);
457        let jwt = fake_jwt(serde_json::json!({"sub": "auth0|user_from_jwt"}));
458        let auth = session_auth_for(&path, &jwt).unwrap();
459        assert_eq!(auth.user_id, "user_from_jwt");
460    }
461
462    #[test]
463    fn a_profile_without_a_usable_auth_id_is_not_an_identity() {
464        let td = TempDir::new().unwrap();
465        let path = td.path().join("state.vscdb");
466        seed_db(&path, None);
467        seed_scoped_profile(&path, r#"{"authId":"no-pipe-here"}"#);
468        assert!(session_auth_for(&path, "curs_opaque").is_err());
469        seed_scoped_profile(&path, r#"{"displayName":"no auth id"}"#);
470        assert!(session_auth_for(&path, "curs_opaque").is_err());
471    }
472
473    #[test]
474    fn a_missing_profile_row_falls_back_to_the_jwt_path() {
475        let td = TempDir::new().unwrap();
476        let path = td.path().join("state.vscdb");
477        seed_db(&path, None);
478        let jwt = fake_jwt(serde_json::json!({"sub": "auth0|only_jwt"}));
479        assert_eq!(session_auth_for(&path, &jwt).unwrap().user_id, "only_jwt");
480    }
481
482    #[test]
483    fn default_db_path_ends_with_the_cursor_state_file() {
484        let p = default_db_path().unwrap();
485        assert!(
486            p.ends_with(
487                std::path::Path::new("Cursor")
488                    .join("User")
489                    .join("globalStorage")
490                    .join("state.vscdb")
491            )
492        );
493    }
494
495    #[test]
496    fn missing_file_is_a_credentials_error_naming_the_path() {
497        let dir = TempDir::new().unwrap();
498        let path = dir.path().join("state.vscdb");
499        let err = read_access_token(&path).unwrap_err();
500        match err {
501            AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
502            other => panic!("expected Credentials error, got {other:?}"),
503        }
504    }
505
506    #[test]
507    fn reads_the_token_back_out_of_the_item_table() {
508        let dir = TempDir::new().unwrap();
509        let path = dir.path().join("state.vscdb");
510        seed_db(&path, Some("fake-token-value"));
511        assert_eq!(read_access_token(&path).unwrap(), "fake-token-value");
512    }
513
514    #[test]
515    fn missing_row_is_a_credentials_error() {
516        let dir = TempDir::new().unwrap();
517        let path = dir.path().join("state.vscdb");
518        seed_db(&path, None);
519        let err = read_access_token(&path).unwrap_err();
520        assert!(matches!(err, AppError::Credentials(_)));
521    }
522
523    #[test]
524    fn empty_token_is_a_credentials_error() {
525        let dir = TempDir::new().unwrap();
526        let path = dir.path().join("state.vscdb");
527        seed_db(&path, Some(""));
528        let err = read_access_token(&path).unwrap_err();
529        assert!(matches!(err, AppError::Credentials(_)));
530    }
531
532    #[test]
533    fn session_auth_extracts_user_id_and_builds_the_cookie_value() {
534        let token = fake_jwt(serde_json::json!({"sub": "auth0|user_abc123"}));
535        let auth = session_auth(&token).unwrap();
536        assert_eq!(auth.user_id, "user_abc123");
537        assert_eq!(auth.account_key.len(), 16);
538        assert!(!auth.account_key.contains("user_abc123"));
539        assert_eq!(auth.cookie_value, format!("user_abc123%3A%3A{token}"));
540    }
541
542    #[test]
543    fn session_auth_account_key_is_stable_and_account_specific() {
544        let one = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
545        let one_again = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
546        let two = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|two"}))).unwrap();
547        assert_eq!(one.account_key, one_again.account_key);
548        assert_ne!(one.account_key, two.account_key);
549    }
550
551    #[test]
552    fn session_auth_rejects_a_non_jwt_token() {
553        let err = session_auth("not-a-jwt").unwrap_err();
554        assert!(matches!(err, AppError::Credentials(_)));
555    }
556
557    #[test]
558    fn session_auth_rejects_missing_sub_claim() {
559        let token = fake_jwt(serde_json::json!({"other": "value"}));
560        let err = session_auth(&token).unwrap_err();
561        match err {
562            AppError::Credentials(m) => assert!(m.contains("sub")),
563            other => panic!("expected Credentials error, got {other:?}"),
564        }
565    }
566
567    #[test]
568    fn session_auth_rejects_sub_without_a_pipe_separated_user_id() {
569        let token = fake_jwt(serde_json::json!({"sub": "no-pipe-here"}));
570        let err = session_auth(&token).unwrap_err();
571        assert!(matches!(err, AppError::Credentials(_)));
572    }
573
574    #[test]
575    fn default_agent_auth_path_follows_the_cli_own_convention_per_os() {
576        let p = default_agent_auth_path().unwrap();
577        let expected_tail = if cfg!(target_os = "macos") {
578            std::path::Path::new(".cursor").join("auth.json")
579        } else if cfg!(windows) {
580            std::path::Path::new("Cursor").join("auth.json")
581        } else {
582            std::path::Path::new("cursor").join("auth.json")
583        };
584        assert!(p.ends_with(expected_tail), "{}", p.display());
585        // The macOS default is the CLI's `~/.cursor`, never the IDE's
586        // Application Support directory.
587        assert!(!cfg!(target_os = "macos") || !p.to_string_lossy().contains("Application Support"));
588    }
589
590    #[test]
591    fn agent_auth_missing_file_is_a_credentials_error_naming_the_path() {
592        let dir = TempDir::new().unwrap();
593        let path = dir.path().join("auth.json");
594        let err = read_agent_access_token(&path).unwrap_err();
595        match err {
596            AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
597            other => panic!("expected Credentials error, got {other:?}"),
598        }
599    }
600
601    #[test]
602    fn agent_auth_reads_access_token_out_of_the_json_file() {
603        let dir = TempDir::new().unwrap();
604        let path = dir.path().join("auth.json");
605        std::fs::write(
606            &path,
607            serde_json::json!({"accessToken": "agent-token-value", "refreshToken": "r"})
608                .to_string(),
609        )
610        .unwrap();
611        assert_eq!(read_agent_access_token(&path).unwrap(), "agent-token-value");
612    }
613
614    #[test]
615    fn agent_auth_missing_field_is_a_credentials_error() {
616        let dir = TempDir::new().unwrap();
617        let path = dir.path().join("auth.json");
618        std::fs::write(&path, serde_json::json!({"refreshToken": "r"}).to_string()).unwrap();
619        let err = read_agent_access_token(&path).unwrap_err();
620        assert!(matches!(err, AppError::Credentials(_)));
621    }
622
623    #[test]
624    fn agent_auth_empty_token_is_a_credentials_error() {
625        let dir = TempDir::new().unwrap();
626        let path = dir.path().join("auth.json");
627        std::fs::write(&path, serde_json::json!({"accessToken": ""}).to_string()).unwrap();
628        let err = read_agent_access_token(&path).unwrap_err();
629        assert!(matches!(err, AppError::Credentials(_)));
630    }
631
632    #[test]
633    fn agent_auth_malformed_json_is_a_credentials_error() {
634        let dir = TempDir::new().unwrap();
635        let path = dir.path().join("auth.json");
636        std::fs::write(&path, "not json").unwrap();
637        let err = read_agent_access_token(&path).unwrap_err();
638        assert!(matches!(err, AppError::Credentials(_)));
639    }
640
641    #[test]
642    fn resolve_prefers_the_ide_db_when_both_are_present() {
643        let dir = TempDir::new().unwrap();
644        let db_path = dir.path().join("state.vscdb");
645        seed_db(&db_path, Some("ide-token"));
646        let agent_path = dir.path().join("auth.json");
647        std::fs::write(
648            &agent_path,
649            serde_json::json!({"accessToken": "agent-token"}).to_string(),
650        )
651        .unwrap();
652        assert_eq!(
653            resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
654            "ide-token"
655        );
656    }
657
658    #[test]
659    fn resolve_falls_back_to_the_agent_file_when_the_ide_db_is_missing() {
660        let dir = TempDir::new().unwrap();
661        let db_path = dir.path().join("state.vscdb");
662        let agent_path = dir.path().join("auth.json");
663        std::fs::write(
664            &agent_path,
665            serde_json::json!({"accessToken": "agent-token"}).to_string(),
666        )
667        .unwrap();
668        assert_eq!(
669            resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
670            "agent-token"
671        );
672    }
673
674    #[test]
675    fn resolve_does_not_hide_an_existing_broken_ide_db_with_the_agent_file() {
676        let dir = TempDir::new().unwrap();
677        let db_path = dir.path().join("state.vscdb");
678        seed_db(&db_path, None);
679        let agent_path = dir.path().join("auth.json");
680        std::fs::write(
681            &agent_path,
682            serde_json::json!({"accessToken": "agent-token"}).to_string(),
683        )
684        .unwrap();
685
686        let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
687        match err {
688            AppError::Credentials(m) => {
689                assert!(m.contains(&db_path.display().to_string()));
690                assert!(!m.contains(&agent_path.display().to_string()));
691            }
692            other => panic!("expected Credentials error, got {other:?}"),
693        }
694    }
695
696    #[test]
697    fn resolve_surfaces_the_ide_error_when_both_sources_are_missing() {
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        let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
702        match err {
703            AppError::Credentials(m) => assert!(m.contains(&db_path.display().to_string())),
704            other => panic!("expected Credentials error, got {other:?}"),
705        }
706    }
707
708    #[test]
709    fn resolve_surfaces_the_agent_error_when_its_file_exists_but_is_malformed() {
710        let dir = TempDir::new().unwrap();
711        let db_path = dir.path().join("state.vscdb");
712        let agent_path = dir.path().join("auth.json");
713        std::fs::write(&agent_path, "not json").unwrap();
714
715        let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
716        match err {
717            AppError::Credentials(m) => {
718                assert!(m.contains(&agent_path.display().to_string()));
719                assert!(m.contains("could not parse"));
720            }
721            other => panic!("expected Credentials error, got {other:?}"),
722        }
723    }
724
725    #[test]
726    fn resolve_falls_back_to_the_keychain_when_the_ide_db_and_agent_file_are_missing() {
727        let dir = TempDir::new().unwrap();
728        let db_path = dir.path().join("state.vscdb");
729        let agent_path = dir.path().join("auth.json");
730        assert_eq!(
731            resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
732                .unwrap(),
733            "keychain-token"
734        );
735    }
736
737    #[test]
738    fn resolve_tries_the_keychain_when_the_agent_file_is_unusable() {
739        let dir = TempDir::new().unwrap();
740        let db_path = dir.path().join("state.vscdb");
741        let agent_path = dir.path().join("auth.json");
742        std::fs::write(
743            &agent_path,
744            serde_json::json!({"refreshToken": "r"}).to_string(),
745        )
746        .unwrap();
747        assert_eq!(
748            resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
749                .unwrap(),
750            "keychain-token"
751        );
752    }
753
754    #[test]
755    fn resolve_never_consults_the_keychain_over_a_present_ide_db() {
756        let dir = TempDir::new().unwrap();
757        let db_path = dir.path().join("state.vscdb");
758        seed_db(&db_path, None);
759        let agent_path = dir.path().join("auth.json");
760        let err = resolve_access_token_with(&db_path, &agent_path, || {
761            panic!("an existing IDE db must not be widened to the keychain")
762        })
763        .unwrap_err();
764        assert!(matches!(err, AppError::Credentials(_)));
765    }
766
767    #[test]
768    fn resolve_leaves_the_keychain_alone_for_a_non_default_agent_path() {
769        // A temp path is never the default location, so the real Keychain is
770        // not read: this must fail rather than pick up the machine's login.
771        let dir = TempDir::new().unwrap();
772        let err = resolve_access_token(
773            &dir.path().join("state.vscdb"),
774            &dir.path().join("auth.json"),
775        )
776        .unwrap_err();
777        assert!(matches!(err, AppError::Credentials(_)));
778    }
779
780    /// The Keychain lookup must not inherit this process's provider keys, the
781    /// rule every credential lookup in this crate follows.
782    #[cfg(target_os = "macos")]
783    #[test]
784    fn agent_keychain_lookup_scrubs_vendor_secret_env_vars() {
785        let command = agent_keychain_command();
786        assert_eq!(command.get_program(), "/usr/bin/security");
787        let removed: Vec<String> = command
788            .get_envs()
789            .filter(|(_, value)| value.is_none())
790            .map(|(key, _)| key.to_string_lossy().into_owned())
791            .collect();
792        for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
793            assert!(
794                removed.iter().any(|key| key == var),
795                "expected {var} to be scrubbed from the cursor-agent keychain lookup"
796            );
797        }
798    }
799}