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