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 base64::Engine;
17use rusqlite::{Connection, OpenFlags};
18
19use crate::display::sanitize_untrusted_path;
20use crate::error::{AppError, Result};
21
22const TOKEN_KEY: &str = "cursorAuth/accessToken";
23
24/// Default location of Cursor's local state database. This is Cursor's own
25/// per-OS convention (same one every VS Code-family app uses for its user
26/// data), not ai-usagebar's XDG cache — conveniently identical to what
27/// `directories::BaseDirs::config_dir()` already resolves on every platform:
28///   - Linux: `~/.config`
29///   - macOS: `~/Library/Application Support`
30///   - Windows: `%APPDATA%` (Roaming)
31pub fn default_db_path() -> Result<PathBuf> {
32    let base = directories::BaseDirs::new().ok_or_else(|| {
33        AppError::Other("could not resolve the platform config directory (no HOME?)".into())
34    })?;
35    Ok(base
36        .config_dir()
37        .join("Cursor")
38        .join("User")
39        .join("globalStorage")
40        .join("state.vscdb"))
41}
42
43/// Read the raw `cursorAuth/accessToken` value from `path`. A missing file or
44/// missing row means "never signed in to Cursor" — reported as a credentials
45/// error (like a missing `~/.claude/.credentials.json`) rather than a network
46/// or schema failure, so the widget's `⚠` tooltip tells the user to sign in
47/// rather than implying the API is down.
48pub fn read_access_token(path: &Path) -> Result<String> {
49    if !path.exists() {
50        return Err(AppError::Credentials(format!(
51            "Cursor database not found at {}. Open the Cursor IDE and sign in at least once, \
52             then try again.",
53            sanitize_untrusted_path(path)
54        )));
55    }
56    // Read-only: this file is Cursor's own live state, not ours to lock for
57    // writing. SQLite allows concurrent readers, so this is safe alongside a
58    // running Cursor IDE.
59    let conn =
60        Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|e| {
61            AppError::Credentials(format!(
62                "could not open Cursor database at {}: {e}",
63                sanitize_untrusted_path(path)
64            ))
65        })?;
66    let token: String = conn
67        .query_row(
68            "SELECT value FROM ItemTable WHERE key = ?1",
69            [TOKEN_KEY],
70            |row| row.get(0),
71        )
72        .map_err(|_| {
73            AppError::Credentials(format!(
74                "no Cursor session found in {}. Sign in to the Cursor IDE, then try again.",
75                sanitize_untrusted_path(path)
76            ))
77        })?;
78    if token.trim().is_empty() {
79        return Err(AppError::Credentials(
80            "Cursor session token is empty. Sign in to the Cursor IDE again.".into(),
81        ));
82    }
83    Ok(token)
84}
85
86/// Default location of the `cursor-agent` CLI's own login state — a plain
87/// JSON file, not the IDE's `state.vscdb`. Written by the headless
88/// `cursor-agent` tool, so it stays
89/// populated on machines that never run the desktop IDE at all.
90pub fn default_agent_auth_path() -> Result<PathBuf> {
91    let base = directories::BaseDirs::new().ok_or_else(|| {
92        AppError::Other("could not resolve the platform config directory (no HOME?)".into())
93    })?;
94    Ok(base.config_dir().join("cursor").join("auth.json"))
95}
96
97/// Read `cursor-agent`'s `accessToken` out of its `auth.json`. Same error
98/// shape as [`read_access_token`] (missing file / missing field / empty
99/// value are all a [`AppError::Credentials`]) so callers can treat both
100/// sources interchangeably.
101pub fn read_agent_access_token(path: &Path) -> Result<String> {
102    if !path.exists() {
103        return Err(AppError::Credentials(format!(
104            "cursor-agent auth file not found at {}. Run `cursor-agent` and sign in at least \
105             once, then try again.",
106            sanitize_untrusted_path(path)
107        )));
108    }
109    let bytes = std::fs::read(path).map_err(|e| AppError::io_at(path, e))?;
110    let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
111        AppError::Credentials(format!(
112            "could not parse {}: {e}",
113            sanitize_untrusted_path(path)
114        ))
115    })?;
116    let token = value
117        .get("accessToken")
118        .and_then(serde_json::Value::as_str)
119        .filter(|s| !s.trim().is_empty())
120        .ok_or_else(|| {
121            AppError::Credentials(format!(
122                "no accessToken in {}. Sign in with `cursor-agent` again.",
123                sanitize_untrusted_path(path)
124            ))
125        })?;
126    Ok(token.to_string())
127}
128
129/// Resolve a Cursor session token from either source. The IDE's `state.vscdb`
130/// is tried first (it is the live, continuously-refreshed source when the
131/// desktop app is actually running); a text-only / headless machine that has
132/// never opened the IDE falls back to whatever `cursor-agent` last wrote to
133/// its own `auth.json`. If the agent file exists but cannot be used, its error
134/// is surfaced so a headless user gets an actionable diagnostic. The IDE's
135/// error remains the one surfaced when both sources are absent, since it names
136/// the more commonly expected path.
137pub fn resolve_access_token(db_path: &Path, agent_auth_path: &Path) -> Result<String> {
138    match read_access_token(db_path) {
139        Ok(token) => Ok(token),
140        Err(_) if !db_path.exists() && agent_auth_path.exists() => {
141            read_agent_access_token(agent_auth_path)
142        }
143        Err(ide_err) => Err(ide_err),
144    }
145}
146
147/// The two values the `/api/usage` call needs, both derived from the same JWT:
148/// the bare user id (a query param) and the `WorkosCursorSessionToken` cookie
149/// value (`userId%3A%3Atoken` — literal, pre-encoded `::`, matching what the
150/// Cursor dashboard's own JS sends).
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct SessionAuth {
153    pub user_id: String,
154    /// Stable, non-plaintext cache identity for the signed-in Cursor account.
155    /// The hash is only a change detector: a different toolchain may produce a
156    /// different value and force one harmless refetch.
157    pub account_key: String,
158    pub cookie_value: String,
159}
160
161/// Derive [`SessionAuth`] from the raw access token. Fails when the token
162/// isn't a decodable JWT or its `sub` claim doesn't have the `issuer|userId`
163/// shape every Cursor account token carries — either way the token is
164/// unusable, so this is a credentials error, not a schema error (the *shape*
165/// of the wire endpoint isn't in play yet at this point).
166pub fn session_auth(token: &str) -> Result<SessionAuth> {
167    let claims = parse_jwt_claims(token).ok_or_else(|| {
168        AppError::Credentials(
169            "Cursor session token could not be decoded. Sign in to the Cursor IDE again.".into(),
170        )
171    })?;
172    let sub = claims
173        .get("sub")
174        .and_then(serde_json::Value::as_str)
175        .ok_or_else(|| AppError::Credentials("Cursor session token has no `sub` claim.".into()))?;
176    let user_id = sub
177        .split('|')
178        .nth(1)
179        .filter(|s| !s.is_empty())
180        .ok_or_else(|| {
181            AppError::Credentials(format!(
182                "Cursor session token `sub` claim has an unexpected shape: {sub:?}"
183            ))
184        })?
185        .to_string();
186    let mut hasher = std::collections::hash_map::DefaultHasher::new();
187    user_id.hash(&mut hasher);
188    let account_key = format!("{:016x}", hasher.finish());
189    let cookie_value = format!("{user_id}%3A%3A{token}");
190    Ok(SessionAuth {
191        user_id,
192        account_key,
193        cookie_value,
194    })
195}
196
197/// Decode a JWT's payload segment without verifying its signature — we trust
198/// it the same way the Cursor dashboard's own browser JS does (it never
199/// verifies either; the server is the one that rejects a bad token).
200fn parse_jwt_claims(token: &str) -> Option<serde_json::Value> {
201    let mut parts = token.split('.');
202    let _header = parts.next()?;
203    let payload = parts.next()?;
204    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
205        .decode(payload)
206        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
207        .ok()?;
208    serde_json::from_slice(&decoded).ok()
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use tempfile::TempDir;
215
216    /// Build a fake JWT with the given claims (no signature verification,
217    /// matching `openai::creds`'s test helper).
218    fn fake_jwt(claims: serde_json::Value) -> String {
219        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
220        let payload =
221            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
222        format!("{header}.{payload}.sig")
223    }
224
225    fn seed_db(path: &Path, token: Option<&str>) {
226        let conn = Connection::open(path).unwrap();
227        conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
228            .unwrap();
229        if let Some(t) = token {
230            conn.execute(
231                "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
232                rusqlite::params![TOKEN_KEY, t],
233            )
234            .unwrap();
235        }
236    }
237
238    #[test]
239    fn default_db_path_ends_with_the_cursor_state_file() {
240        let p = default_db_path().unwrap();
241        assert!(
242            p.ends_with(
243                std::path::Path::new("Cursor")
244                    .join("User")
245                    .join("globalStorage")
246                    .join("state.vscdb")
247            )
248        );
249    }
250
251    #[test]
252    fn missing_file_is_a_credentials_error_naming_the_path() {
253        let dir = TempDir::new().unwrap();
254        let path = dir.path().join("state.vscdb");
255        let err = read_access_token(&path).unwrap_err();
256        match err {
257            AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
258            other => panic!("expected Credentials error, got {other:?}"),
259        }
260    }
261
262    #[test]
263    fn reads_the_token_back_out_of_the_item_table() {
264        let dir = TempDir::new().unwrap();
265        let path = dir.path().join("state.vscdb");
266        seed_db(&path, Some("fake-token-value"));
267        assert_eq!(read_access_token(&path).unwrap(), "fake-token-value");
268    }
269
270    #[test]
271    fn missing_row_is_a_credentials_error() {
272        let dir = TempDir::new().unwrap();
273        let path = dir.path().join("state.vscdb");
274        seed_db(&path, None);
275        let err = read_access_token(&path).unwrap_err();
276        assert!(matches!(err, AppError::Credentials(_)));
277    }
278
279    #[test]
280    fn empty_token_is_a_credentials_error() {
281        let dir = TempDir::new().unwrap();
282        let path = dir.path().join("state.vscdb");
283        seed_db(&path, Some(""));
284        let err = read_access_token(&path).unwrap_err();
285        assert!(matches!(err, AppError::Credentials(_)));
286    }
287
288    #[test]
289    fn session_auth_extracts_user_id_and_builds_the_cookie_value() {
290        let token = fake_jwt(serde_json::json!({"sub": "auth0|user_abc123"}));
291        let auth = session_auth(&token).unwrap();
292        assert_eq!(auth.user_id, "user_abc123");
293        assert_eq!(auth.account_key.len(), 16);
294        assert!(!auth.account_key.contains("user_abc123"));
295        assert_eq!(auth.cookie_value, format!("user_abc123%3A%3A{token}"));
296    }
297
298    #[test]
299    fn session_auth_account_key_is_stable_and_account_specific() {
300        let one = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
301        let one_again = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
302        let two = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|two"}))).unwrap();
303        assert_eq!(one.account_key, one_again.account_key);
304        assert_ne!(one.account_key, two.account_key);
305    }
306
307    #[test]
308    fn session_auth_rejects_a_non_jwt_token() {
309        let err = session_auth("not-a-jwt").unwrap_err();
310        assert!(matches!(err, AppError::Credentials(_)));
311    }
312
313    #[test]
314    fn session_auth_rejects_missing_sub_claim() {
315        let token = fake_jwt(serde_json::json!({"other": "value"}));
316        let err = session_auth(&token).unwrap_err();
317        match err {
318            AppError::Credentials(m) => assert!(m.contains("sub")),
319            other => panic!("expected Credentials error, got {other:?}"),
320        }
321    }
322
323    #[test]
324    fn session_auth_rejects_sub_without_a_pipe_separated_user_id() {
325        let token = fake_jwt(serde_json::json!({"sub": "no-pipe-here"}));
326        let err = session_auth(&token).unwrap_err();
327        assert!(matches!(err, AppError::Credentials(_)));
328    }
329
330    #[test]
331    fn default_agent_auth_path_ends_with_cursor_auth_json() {
332        let p = default_agent_auth_path().unwrap();
333        assert!(p.ends_with(std::path::Path::new("cursor").join("auth.json")));
334    }
335
336    #[test]
337    fn agent_auth_missing_file_is_a_credentials_error_naming_the_path() {
338        let dir = TempDir::new().unwrap();
339        let path = dir.path().join("auth.json");
340        let err = read_agent_access_token(&path).unwrap_err();
341        match err {
342            AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
343            other => panic!("expected Credentials error, got {other:?}"),
344        }
345    }
346
347    #[test]
348    fn agent_auth_reads_access_token_out_of_the_json_file() {
349        let dir = TempDir::new().unwrap();
350        let path = dir.path().join("auth.json");
351        std::fs::write(
352            &path,
353            serde_json::json!({"accessToken": "agent-token-value", "refreshToken": "r"})
354                .to_string(),
355        )
356        .unwrap();
357        assert_eq!(read_agent_access_token(&path).unwrap(), "agent-token-value");
358    }
359
360    #[test]
361    fn agent_auth_missing_field_is_a_credentials_error() {
362        let dir = TempDir::new().unwrap();
363        let path = dir.path().join("auth.json");
364        std::fs::write(&path, serde_json::json!({"refreshToken": "r"}).to_string()).unwrap();
365        let err = read_agent_access_token(&path).unwrap_err();
366        assert!(matches!(err, AppError::Credentials(_)));
367    }
368
369    #[test]
370    fn agent_auth_empty_token_is_a_credentials_error() {
371        let dir = TempDir::new().unwrap();
372        let path = dir.path().join("auth.json");
373        std::fs::write(&path, serde_json::json!({"accessToken": ""}).to_string()).unwrap();
374        let err = read_agent_access_token(&path).unwrap_err();
375        assert!(matches!(err, AppError::Credentials(_)));
376    }
377
378    #[test]
379    fn agent_auth_malformed_json_is_a_credentials_error() {
380        let dir = TempDir::new().unwrap();
381        let path = dir.path().join("auth.json");
382        std::fs::write(&path, "not json").unwrap();
383        let err = read_agent_access_token(&path).unwrap_err();
384        assert!(matches!(err, AppError::Credentials(_)));
385    }
386
387    #[test]
388    fn resolve_prefers_the_ide_db_when_both_are_present() {
389        let dir = TempDir::new().unwrap();
390        let db_path = dir.path().join("state.vscdb");
391        seed_db(&db_path, Some("ide-token"));
392        let agent_path = dir.path().join("auth.json");
393        std::fs::write(
394            &agent_path,
395            serde_json::json!({"accessToken": "agent-token"}).to_string(),
396        )
397        .unwrap();
398        assert_eq!(
399            resolve_access_token(&db_path, &agent_path).unwrap(),
400            "ide-token"
401        );
402    }
403
404    #[test]
405    fn resolve_falls_back_to_the_agent_file_when_the_ide_db_is_missing() {
406        let dir = TempDir::new().unwrap();
407        let db_path = dir.path().join("state.vscdb");
408        let agent_path = dir.path().join("auth.json");
409        std::fs::write(
410            &agent_path,
411            serde_json::json!({"accessToken": "agent-token"}).to_string(),
412        )
413        .unwrap();
414        assert_eq!(
415            resolve_access_token(&db_path, &agent_path).unwrap(),
416            "agent-token"
417        );
418    }
419
420    #[test]
421    fn resolve_does_not_hide_an_existing_broken_ide_db_with_the_agent_file() {
422        let dir = TempDir::new().unwrap();
423        let db_path = dir.path().join("state.vscdb");
424        seed_db(&db_path, None);
425        let agent_path = dir.path().join("auth.json");
426        std::fs::write(
427            &agent_path,
428            serde_json::json!({"accessToken": "agent-token"}).to_string(),
429        )
430        .unwrap();
431
432        let err = resolve_access_token(&db_path, &agent_path).unwrap_err();
433        match err {
434            AppError::Credentials(m) => {
435                assert!(m.contains(&db_path.display().to_string()));
436                assert!(!m.contains(&agent_path.display().to_string()));
437            }
438            other => panic!("expected Credentials error, got {other:?}"),
439        }
440    }
441
442    #[test]
443    fn resolve_surfaces_the_ide_error_when_both_sources_are_missing() {
444        let dir = TempDir::new().unwrap();
445        let db_path = dir.path().join("state.vscdb");
446        let agent_path = dir.path().join("auth.json");
447        let err = resolve_access_token(&db_path, &agent_path).unwrap_err();
448        match err {
449            AppError::Credentials(m) => assert!(m.contains(&db_path.display().to_string())),
450            other => panic!("expected Credentials error, got {other:?}"),
451        }
452    }
453
454    #[test]
455    fn resolve_surfaces_the_agent_error_when_its_file_exists_but_is_malformed() {
456        let dir = TempDir::new().unwrap();
457        let db_path = dir.path().join("state.vscdb");
458        let agent_path = dir.path().join("auth.json");
459        std::fs::write(&agent_path, "not json").unwrap();
460
461        let err = resolve_access_token(&db_path, &agent_path).unwrap_err();
462        match err {
463            AppError::Credentials(m) => {
464                assert!(m.contains(&agent_path.display().to_string()));
465                assert!(m.contains("could not parse"));
466            }
467            other => panic!("expected Credentials error, got {other:?}"),
468        }
469    }
470}