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 = agent_keychain_command().output().ok()?;
162    if !out.status.success() {
163        return None;
164    }
165    let token = String::from_utf8(out.stdout).ok()?;
166    let token = token.trim();
167    (!token.is_empty()).then(|| token.to_string())
168}
169
170/// `security find-generic-password -s <service> -a <account> -w`, read-only;
171/// the secret arrives on stdout, never in argv.
172#[cfg(target_os = "macos")]
173fn agent_keychain_command() -> std::process::Command {
174    let mut command = std::process::Command::new("/usr/bin/security");
175    command
176        .args([
177            "find-generic-password",
178            "-s",
179            AGENT_KEYCHAIN_SERVICE,
180            "-a",
181            AGENT_KEYCHAIN_ACCOUNT,
182            "-w",
183        ])
184        .stdin(std::process::Stdio::null())
185        .stderr(std::process::Stdio::null());
186    // A credential lookup must not inherit this process's provider keys.
187    for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
188        command.env_remove(var);
189    }
190    command
191}
192
193#[cfg(not(target_os = "macos"))]
194pub fn read_agent_keychain_token() -> Option<String> {
195    None
196}
197
198/// Resolve a Cursor session token from the available sources. The IDE's
199/// `state.vscdb` is tried first (it is the live, continuously-refreshed source
200/// when the desktop app is actually running); a machine that has never opened
201/// the IDE falls back to whatever `cursor-agent` holds: its `auth.json`, or on
202/// macOS its Keychain items. An existing but unusable IDE database is *not*
203/// papered over by the CLI's login — that would silently swap accounts. If the
204/// agent file exists but cannot be used, the Keychain is tried before its
205/// error is surfaced, so a headless user with neither gets an actionable
206/// diagnostic. The IDE's error remains the one surfaced when every source is
207/// absent, since it names the more commonly expected path.
208///
209/// The real Keychain is only consulted when `agent_auth_path` is the default
210/// location: a config override (or a test's temp file) names a specific
211/// source and must not be widened to the machine's own login.
212pub fn resolve_access_token(db_path: &Path, agent_auth_path: &Path) -> Result<String> {
213    let default_agent = default_agent_auth_path().ok();
214    let ambient = default_agent.as_deref() == Some(agent_auth_path);
215    resolve_access_token_with(db_path, agent_auth_path, || {
216        ambient.then(read_agent_keychain_token).flatten()
217    })
218}
219
220/// [`resolve_access_token`] with the Keychain reader injected.
221pub fn resolve_access_token_with(
222    db_path: &Path,
223    agent_auth_path: &Path,
224    keychain: impl FnOnce() -> Option<String>,
225) -> Result<String> {
226    match read_access_token(db_path) {
227        Ok(token) => Ok(token),
228        Err(ide_err) if !db_path.exists() => {
229            if agent_auth_path.exists() {
230                match read_agent_access_token(agent_auth_path) {
231                    Ok(token) => return Ok(token),
232                    Err(agent_err) => return keychain().ok_or(agent_err),
233                }
234            }
235            keychain().ok_or(ide_err)
236        }
237        Err(ide_err) => Err(ide_err),
238    }
239}
240
241/// The two values the `/api/usage` call needs, both derived from the same JWT:
242/// the bare user id (a query param) and the `WorkosCursorSessionToken` cookie
243/// value (`userId%3A%3Atoken` — literal, pre-encoded `::`, matching what the
244/// Cursor dashboard's own JS sends).
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct SessionAuth {
247    pub user_id: String,
248    /// Stable, non-plaintext cache identity for the signed-in Cursor account.
249    /// The hash is only a change detector: a different toolchain may produce a
250    /// different value and force one harmless refetch.
251    pub account_key: String,
252    pub cookie_value: String,
253}
254
255/// Derive [`SessionAuth`] from the raw access token. Fails when the token
256/// isn't a decodable JWT or its `sub` claim doesn't have the `issuer|userId`
257/// shape every Cursor account token carries — either way the token is
258/// unusable, so this is a credentials error, not a schema error (the *shape*
259/// of the wire endpoint isn't in play yet at this point).
260pub fn session_auth(token: &str) -> Result<SessionAuth> {
261    let claims = crate::jwt::claims(token).ok_or_else(|| {
262        AppError::Credentials(
263            "Cursor session token could not be decoded. Sign in to the Cursor IDE again.".into(),
264        )
265    })?;
266    let sub = claims
267        .get("sub")
268        .and_then(serde_json::Value::as_str)
269        .ok_or_else(|| AppError::Credentials("Cursor session token has no `sub` claim.".into()))?;
270    let user_id = sub
271        .split('|')
272        .nth(1)
273        .filter(|s| !s.is_empty())
274        .ok_or_else(|| {
275            AppError::Credentials(format!(
276                "Cursor session token `sub` claim has an unexpected shape: {sub:?}"
277            ))
278        })?
279        .to_string();
280    let mut hasher = std::collections::hash_map::DefaultHasher::new();
281    user_id.hash(&mut hasher);
282    let account_key = format!("{:016x}", hasher.finish());
283    let cookie_value = format!("{user_id}%3A%3A{token}");
284    Ok(SessionAuth {
285        user_id,
286        account_key,
287        cookie_value,
288    })
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use base64::Engine as _;
295    use tempfile::TempDir;
296
297    /// Build a fake JWT with the given claims (no signature verification,
298    /// matching `openai::creds`'s test helper).
299    fn fake_jwt(claims: serde_json::Value) -> String {
300        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
301        let payload =
302            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string().as_bytes());
303        format!("{header}.{payload}.sig")
304    }
305
306    fn seed_db(path: &Path, token: Option<&str>) {
307        let conn = Connection::open(path).unwrap();
308        conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
309            .unwrap();
310        if let Some(t) = token {
311            conn.execute(
312                "INSERT INTO ItemTable (key, value) VALUES (?1, ?2)",
313                rusqlite::params![TOKEN_KEY, t],
314            )
315            .unwrap();
316        }
317    }
318
319    #[test]
320    fn default_db_path_ends_with_the_cursor_state_file() {
321        let p = default_db_path().unwrap();
322        assert!(
323            p.ends_with(
324                std::path::Path::new("Cursor")
325                    .join("User")
326                    .join("globalStorage")
327                    .join("state.vscdb")
328            )
329        );
330    }
331
332    #[test]
333    fn missing_file_is_a_credentials_error_naming_the_path() {
334        let dir = TempDir::new().unwrap();
335        let path = dir.path().join("state.vscdb");
336        let err = read_access_token(&path).unwrap_err();
337        match err {
338            AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
339            other => panic!("expected Credentials error, got {other:?}"),
340        }
341    }
342
343    #[test]
344    fn reads_the_token_back_out_of_the_item_table() {
345        let dir = TempDir::new().unwrap();
346        let path = dir.path().join("state.vscdb");
347        seed_db(&path, Some("fake-token-value"));
348        assert_eq!(read_access_token(&path).unwrap(), "fake-token-value");
349    }
350
351    #[test]
352    fn missing_row_is_a_credentials_error() {
353        let dir = TempDir::new().unwrap();
354        let path = dir.path().join("state.vscdb");
355        seed_db(&path, None);
356        let err = read_access_token(&path).unwrap_err();
357        assert!(matches!(err, AppError::Credentials(_)));
358    }
359
360    #[test]
361    fn empty_token_is_a_credentials_error() {
362        let dir = TempDir::new().unwrap();
363        let path = dir.path().join("state.vscdb");
364        seed_db(&path, Some(""));
365        let err = read_access_token(&path).unwrap_err();
366        assert!(matches!(err, AppError::Credentials(_)));
367    }
368
369    #[test]
370    fn session_auth_extracts_user_id_and_builds_the_cookie_value() {
371        let token = fake_jwt(serde_json::json!({"sub": "auth0|user_abc123"}));
372        let auth = session_auth(&token).unwrap();
373        assert_eq!(auth.user_id, "user_abc123");
374        assert_eq!(auth.account_key.len(), 16);
375        assert!(!auth.account_key.contains("user_abc123"));
376        assert_eq!(auth.cookie_value, format!("user_abc123%3A%3A{token}"));
377    }
378
379    #[test]
380    fn session_auth_account_key_is_stable_and_account_specific() {
381        let one = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
382        let one_again = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|one"}))).unwrap();
383        let two = session_auth(&fake_jwt(serde_json::json!({"sub": "auth0|two"}))).unwrap();
384        assert_eq!(one.account_key, one_again.account_key);
385        assert_ne!(one.account_key, two.account_key);
386    }
387
388    #[test]
389    fn session_auth_rejects_a_non_jwt_token() {
390        let err = session_auth("not-a-jwt").unwrap_err();
391        assert!(matches!(err, AppError::Credentials(_)));
392    }
393
394    #[test]
395    fn session_auth_rejects_missing_sub_claim() {
396        let token = fake_jwt(serde_json::json!({"other": "value"}));
397        let err = session_auth(&token).unwrap_err();
398        match err {
399            AppError::Credentials(m) => assert!(m.contains("sub")),
400            other => panic!("expected Credentials error, got {other:?}"),
401        }
402    }
403
404    #[test]
405    fn session_auth_rejects_sub_without_a_pipe_separated_user_id() {
406        let token = fake_jwt(serde_json::json!({"sub": "no-pipe-here"}));
407        let err = session_auth(&token).unwrap_err();
408        assert!(matches!(err, AppError::Credentials(_)));
409    }
410
411    #[test]
412    fn default_agent_auth_path_follows_the_cli_own_convention_per_os() {
413        let p = default_agent_auth_path().unwrap();
414        let expected_tail = if cfg!(target_os = "macos") {
415            std::path::Path::new(".cursor").join("auth.json")
416        } else if cfg!(windows) {
417            std::path::Path::new("Cursor").join("auth.json")
418        } else {
419            std::path::Path::new("cursor").join("auth.json")
420        };
421        assert!(p.ends_with(expected_tail), "{}", p.display());
422        // The macOS default is the CLI's `~/.cursor`, never the IDE's
423        // Application Support directory.
424        assert!(!cfg!(target_os = "macos") || !p.to_string_lossy().contains("Application Support"));
425    }
426
427    #[test]
428    fn agent_auth_missing_file_is_a_credentials_error_naming_the_path() {
429        let dir = TempDir::new().unwrap();
430        let path = dir.path().join("auth.json");
431        let err = read_agent_access_token(&path).unwrap_err();
432        match err {
433            AppError::Credentials(m) => assert!(m.contains(&path.display().to_string())),
434            other => panic!("expected Credentials error, got {other:?}"),
435        }
436    }
437
438    #[test]
439    fn agent_auth_reads_access_token_out_of_the_json_file() {
440        let dir = TempDir::new().unwrap();
441        let path = dir.path().join("auth.json");
442        std::fs::write(
443            &path,
444            serde_json::json!({"accessToken": "agent-token-value", "refreshToken": "r"})
445                .to_string(),
446        )
447        .unwrap();
448        assert_eq!(read_agent_access_token(&path).unwrap(), "agent-token-value");
449    }
450
451    #[test]
452    fn agent_auth_missing_field_is_a_credentials_error() {
453        let dir = TempDir::new().unwrap();
454        let path = dir.path().join("auth.json");
455        std::fs::write(&path, serde_json::json!({"refreshToken": "r"}).to_string()).unwrap();
456        let err = read_agent_access_token(&path).unwrap_err();
457        assert!(matches!(err, AppError::Credentials(_)));
458    }
459
460    #[test]
461    fn agent_auth_empty_token_is_a_credentials_error() {
462        let dir = TempDir::new().unwrap();
463        let path = dir.path().join("auth.json");
464        std::fs::write(&path, serde_json::json!({"accessToken": ""}).to_string()).unwrap();
465        let err = read_agent_access_token(&path).unwrap_err();
466        assert!(matches!(err, AppError::Credentials(_)));
467    }
468
469    #[test]
470    fn agent_auth_malformed_json_is_a_credentials_error() {
471        let dir = TempDir::new().unwrap();
472        let path = dir.path().join("auth.json");
473        std::fs::write(&path, "not json").unwrap();
474        let err = read_agent_access_token(&path).unwrap_err();
475        assert!(matches!(err, AppError::Credentials(_)));
476    }
477
478    #[test]
479    fn resolve_prefers_the_ide_db_when_both_are_present() {
480        let dir = TempDir::new().unwrap();
481        let db_path = dir.path().join("state.vscdb");
482        seed_db(&db_path, Some("ide-token"));
483        let agent_path = dir.path().join("auth.json");
484        std::fs::write(
485            &agent_path,
486            serde_json::json!({"accessToken": "agent-token"}).to_string(),
487        )
488        .unwrap();
489        assert_eq!(
490            resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
491            "ide-token"
492        );
493    }
494
495    #[test]
496    fn resolve_falls_back_to_the_agent_file_when_the_ide_db_is_missing() {
497        let dir = TempDir::new().unwrap();
498        let db_path = dir.path().join("state.vscdb");
499        let agent_path = dir.path().join("auth.json");
500        std::fs::write(
501            &agent_path,
502            serde_json::json!({"accessToken": "agent-token"}).to_string(),
503        )
504        .unwrap();
505        assert_eq!(
506            resolve_access_token_with(&db_path, &agent_path, || None).unwrap(),
507            "agent-token"
508        );
509    }
510
511    #[test]
512    fn resolve_does_not_hide_an_existing_broken_ide_db_with_the_agent_file() {
513        let dir = TempDir::new().unwrap();
514        let db_path = dir.path().join("state.vscdb");
515        seed_db(&db_path, None);
516        let agent_path = dir.path().join("auth.json");
517        std::fs::write(
518            &agent_path,
519            serde_json::json!({"accessToken": "agent-token"}).to_string(),
520        )
521        .unwrap();
522
523        let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
524        match err {
525            AppError::Credentials(m) => {
526                assert!(m.contains(&db_path.display().to_string()));
527                assert!(!m.contains(&agent_path.display().to_string()));
528            }
529            other => panic!("expected Credentials error, got {other:?}"),
530        }
531    }
532
533    #[test]
534    fn resolve_surfaces_the_ide_error_when_both_sources_are_missing() {
535        let dir = TempDir::new().unwrap();
536        let db_path = dir.path().join("state.vscdb");
537        let agent_path = dir.path().join("auth.json");
538        let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
539        match err {
540            AppError::Credentials(m) => assert!(m.contains(&db_path.display().to_string())),
541            other => panic!("expected Credentials error, got {other:?}"),
542        }
543    }
544
545    #[test]
546    fn resolve_surfaces_the_agent_error_when_its_file_exists_but_is_malformed() {
547        let dir = TempDir::new().unwrap();
548        let db_path = dir.path().join("state.vscdb");
549        let agent_path = dir.path().join("auth.json");
550        std::fs::write(&agent_path, "not json").unwrap();
551
552        let err = resolve_access_token_with(&db_path, &agent_path, || None).unwrap_err();
553        match err {
554            AppError::Credentials(m) => {
555                assert!(m.contains(&agent_path.display().to_string()));
556                assert!(m.contains("could not parse"));
557            }
558            other => panic!("expected Credentials error, got {other:?}"),
559        }
560    }
561
562    #[test]
563    fn resolve_falls_back_to_the_keychain_when_the_ide_db_and_agent_file_are_missing() {
564        let dir = TempDir::new().unwrap();
565        let db_path = dir.path().join("state.vscdb");
566        let agent_path = dir.path().join("auth.json");
567        assert_eq!(
568            resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
569                .unwrap(),
570            "keychain-token"
571        );
572    }
573
574    #[test]
575    fn resolve_tries_the_keychain_when_the_agent_file_is_unusable() {
576        let dir = TempDir::new().unwrap();
577        let db_path = dir.path().join("state.vscdb");
578        let agent_path = dir.path().join("auth.json");
579        std::fs::write(
580            &agent_path,
581            serde_json::json!({"refreshToken": "r"}).to_string(),
582        )
583        .unwrap();
584        assert_eq!(
585            resolve_access_token_with(&db_path, &agent_path, || Some("keychain-token".into()))
586                .unwrap(),
587            "keychain-token"
588        );
589    }
590
591    #[test]
592    fn resolve_never_consults_the_keychain_over_a_present_ide_db() {
593        let dir = TempDir::new().unwrap();
594        let db_path = dir.path().join("state.vscdb");
595        seed_db(&db_path, None);
596        let agent_path = dir.path().join("auth.json");
597        let err = resolve_access_token_with(&db_path, &agent_path, || {
598            panic!("an existing IDE db must not be widened to the keychain")
599        })
600        .unwrap_err();
601        assert!(matches!(err, AppError::Credentials(_)));
602    }
603
604    #[test]
605    fn resolve_leaves_the_keychain_alone_for_a_non_default_agent_path() {
606        // A temp path is never the default location, so the real Keychain is
607        // not read: this must fail rather than pick up the machine's login.
608        let dir = TempDir::new().unwrap();
609        let err = resolve_access_token(
610            &dir.path().join("state.vscdb"),
611            &dir.path().join("auth.json"),
612        )
613        .unwrap_err();
614        assert!(matches!(err, AppError::Credentials(_)));
615    }
616
617    /// The Keychain lookup must not inherit this process's provider keys, the
618    /// rule every credential lookup in this crate follows.
619    #[cfg(target_os = "macos")]
620    #[test]
621    fn agent_keychain_lookup_scrubs_vendor_secret_env_vars() {
622        let command = agent_keychain_command();
623        assert_eq!(command.get_program(), "/usr/bin/security");
624        let removed: Vec<String> = command
625            .get_envs()
626            .filter(|(_, value)| value.is_none())
627            .map(|(key, _)| key.to_string_lossy().into_owned())
628            .collect();
629        for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
630            assert!(
631                removed.iter().any(|key| key == var),
632                "expected {var} to be scrubbed from the cursor-agent keychain lookup"
633            );
634        }
635    }
636}