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