diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c1041..3745d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.2 + +### Fixes + +- Cursor keeps `state.vscdb` in WAL mode. The WASM SQLite driver cannot open WAL files, so 0.3.1 failed with `Could not read Cursor session from local DB: unable to open database file`. Copy the DB to a temp file, strip the WAL flag on that copy, then query. The live Cursor database is never written. Non-WAL files still open in place, so multi-GiB state DBs are not copied. +- Decode `ItemTable` BLOB values as UTF-8 so blob-stored access tokens still authenticate. + ## 0.3.1 ### Fixes diff --git a/README.md b/README.md index f50bdbd..4810330 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ There is no `sessionToken` setting — use the Set / Clear Session Token command | Symptom | Fix | | --- | --- | +| **Could not read Cursor session from local DB: unable to open database file** | Update to 0.3.2+. 0.3.1 could not open Cursor's WAL-mode `state.vscdb`. | | **No token found** | Sign in to Cursor, or run **Plan Usage: Set Session Token** with a valid session token / JWT. | | **401 / unauthorized** | Re-sign into Cursor, or set a fresh token via **Plan Usage: Set Session Token**. | | **Remote-SSH / WSL** | The extension runs in the **local** Cursor UI and uses the local session DB. If usage looks wrong in a remote window, use a local window or set a session token override. **Since last commit** / **This branch** need the built-in git API and stay hidden when it is unavailable in remote UI hosts. | diff --git a/package.json b/package.json index 96ad3b1..f328562 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "cursor-plan-usage", "displayName": "Cursor Plan Usage", "description": "Dockable sidebar showing Cursor Models and Other Models plan usage percentages.", - "version": "0.3.1", + "version": "0.3.2", "publisher": "CodyKoInABox", "license": "MIT", "icon": "resources/icon.png", diff --git a/src/stateDb.test.ts b/src/stateDb.test.ts index 473ba18..87a3f37 100644 --- a/src/stateDb.test.ts +++ b/src/stateDb.test.ts @@ -1,4 +1,14 @@ -import { mkdtempSync, rmSync, statSync, truncateSync } from 'fs'; +import { spawnSync } from 'child_process'; +import { + closeSync, + mkdtempSync, + openSync, + readSync, + rmSync, + statSync, + truncateSync, + writeSync, +} from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -7,8 +17,12 @@ import { readAuthFromStateDb } from './stateDb'; const TWO_GIB = 2 * 1024 ** 3; const tempDirs: string[] = []; +const hasNativeSqlite3 = + spawnSync('sqlite3', ['-version'], { encoding: 'utf8' }).status === 0; -function createStateDb(entries: Array<[string, string]>): string { +function createStateDb( + entries: Array<[string, string | Uint8Array]> +): string { const dir = mkdtempSync(join(tmpdir(), 'cursor-plan-usage-')); tempDirs.push(dir); const dbPath = join(dir, 'state.vscdb'); @@ -24,6 +38,27 @@ function createStateDb(entries: Array<[string, string]>): string { return dbPath; } +/** Stamp WAL format-version bytes. WASM SQLite cannot open this file in place. */ +function stampWalHeader(dbPath: string): void { + const fd = openSync(dbPath, 'r+'); + try { + writeSync(fd, Buffer.from([2, 2]), 0, 2, 18); + } finally { + closeSync(fd); + } +} + +function readHeaderVersions(dbPath: string): [number, number] { + const fd = openSync(dbPath, 'r'); + try { + const header = Buffer.alloc(20); + readSync(fd, header, 0, 20, 0); + return [header[18], header[19]]; + } finally { + closeSync(fd); + } +} + afterEach(() => { for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); @@ -63,4 +98,75 @@ describe('readAuthFromStateDb', () => { expect(statSync(dbPath).size).toBeGreaterThan(TWO_GIB); expect(readAuthFromStateDb(dbPath)?.accessToken).toBe('large-db-token'); }); + + it('reads a WAL-mode database that WASM SQLite cannot open in place', () => { + const dbPath = createStateDb([ + ['cursorAuth/accessToken', 'wal-token'], + ['cursorAuth/stripeMembershipType', 'pro'], + ['cursorAuth/cachedEmail', 'user@example.com'], + ]); + stampWalHeader(dbPath); + + expect(() => { + const db = new Database(dbPath, { readOnly: true }); + try { + db.get('SELECT value FROM ItemTable WHERE key = ?', 'cursorAuth/accessToken'); + } finally { + db.close(); + } + }).toThrow(/unable to open database file/); + + expect(readAuthFromStateDb(dbPath)).toEqual({ + accessToken: 'wal-token', + membershipType: 'pro', + email: 'user@example.com', + source: 'db', + }); + expect(readHeaderVersions(dbPath)).toEqual([2, 2]); + }); + + it('decodes ItemTable BLOB values as UTF-8', () => { + const dbPath = createStateDb([ + ['cursorAuth/accessToken', Buffer.from('blob-token', 'utf8')], + ['cursorAuth/stripeMembershipType', Buffer.from('pro', 'utf8')], + ['cursorAuth/cachedEmail', Buffer.from('user@example.com', 'utf8')], + ]); + + expect(readAuthFromStateDb(dbPath)).toEqual({ + accessToken: 'blob-token', + membershipType: 'pro', + email: 'user@example.com', + source: 'db', + }); + }); + + it.skipIf(!hasNativeSqlite3)( + 'reads a native sqlite3 WAL database including sidecar files', + () => { + const dir = mkdtempSync(join(tmpdir(), 'cursor-plan-usage-')); + tempDirs.push(dir); + const dbPath = join(dir, 'state.vscdb'); + const sql = [ + 'PRAGMA journal_mode=WAL;', + 'CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB);', + "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', 'native-wal-token');", + "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/stripeMembershipType', 'pro');", + "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/cachedEmail', 'user@example.com');", + ].join('\n'); + const created = spawnSync('sqlite3', [dbPath], { + input: sql, + encoding: 'utf8', + }); + expect(created.status).toBe(0); + expect(readHeaderVersions(dbPath)).toEqual([2, 2]); + + expect(readAuthFromStateDb(dbPath)).toEqual({ + accessToken: 'native-wal-token', + membershipType: 'pro', + email: 'user@example.com', + source: 'db', + }); + expect(readHeaderVersions(dbPath)).toEqual([2, 2]); + } + ); }); diff --git a/src/stateDb.ts b/src/stateDb.ts index 2a46af5..d75fa01 100644 --- a/src/stateDb.ts +++ b/src/stateDb.ts @@ -1,3 +1,15 @@ +import { + closeSync, + constants, + copyFileSync, + mkdtempSync, + openSync, + readSync, + rmSync, + writeSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { Database } from 'node-sqlite3-wasm'; import type { AuthResult } from './types'; @@ -5,20 +17,29 @@ const ACCESS_TOKEN_KEY = 'cursorAuth/accessToken'; const MEMBERSHIP_KEY = 'cursorAuth/stripeMembershipType'; const EMAIL_KEY = 'cursorAuth/cachedEmail'; +/** SQLite header: write/read format version. 2 = WAL. */ +const WAL_FORMAT_VERSION = 2; +const ROLLBACK_FORMAT_VERSION = 1; + +function sqliteText(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + if (value instanceof Uint8Array) { + return new TextDecoder('utf-8').decode(value); + } + return undefined; +} + function readTextItem(db: Database, key: string): string | undefined { const row = db.get('SELECT value FROM ItemTable WHERE key = ?', key); - if (!row || !('value' in row) || typeof row.value !== 'string') { + if (!row || !('value' in row)) { return undefined; } - return row.value; + return sqliteText(row.value); } -/** - * Open Cursor's state database in place and query only the pages needed for - * authentication. The filesystem-backed WASM VFS avoids loading the entire - * database into memory, which also supports state files larger than 2 GiB. - */ -export function readAuthFromStateDb(dbPath: string): AuthResult | undefined { +function queryAuth(dbPath: string): AuthResult | undefined { const db = new Database(dbPath, { readOnly: true }); try { const accessToken = readTextItem(db, ACCESS_TOKEN_KEY); @@ -35,3 +56,90 @@ export function readAuthFromStateDb(dbPath: string): AuthResult | undefined { db.close(); } } + +function isOpenFailure(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /unable to open database file|Could not open the database|SQLITE_CANTOPEN|database is locked|SQLITE_BUSY/i.test( + msg + ); +} + +/** True when the SQLite header says WAL. Never writes the source file. */ +function fileUsesWal(dbPath: string): boolean { + let fd: number | undefined; + try { + fd = openSync(dbPath, 'r'); + const header = Buffer.alloc(20); + const n = readSync(fd, header, 0, 20, 0); + if (n < 20) { + return false; + } + return header[18] === WAL_FORMAT_VERSION || header[19] === WAL_FORMAT_VERSION; + } catch { + return false; + } finally { + if (fd !== undefined) { + closeSync(fd); + } + } +} + +/** + * WASM SQLite is built without WAL/shared-memory, so a WAL-mode file cannot be + * opened at all. Rewrite the format-version bytes on a copy only. + */ +function disableWalHeader(dbPath: string): void { + const fd = openSync(dbPath, 'r+'); + try { + const header = Buffer.alloc(20); + const n = readSync(fd, header, 0, 20, 0); + if (n < 20) { + return; + } + if ( + header[18] !== WAL_FORMAT_VERSION && + header[19] !== WAL_FORMAT_VERSION + ) { + return; + } + header[18] = ROLLBACK_FORMAT_VERSION; + header[19] = ROLLBACK_FORMAT_VERSION; + writeSync(fd, header, 18, 2, 18); + } finally { + closeSync(fd); + } +} + +function queryAuthFromCopy(dbPath: string): AuthResult | undefined { + const dir = mkdtempSync(join(tmpdir(), 'cursor-plan-usage-')); + const tmpPath = join(dir, 'state.vscdb'); + try { + copyFileSync(dbPath, tmpPath, constants.COPYFILE_FICLONE); + disableWalHeader(tmpPath); + return queryAuth(tmpPath); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Read Cursor auth keys from `state.vscdb`. + * + * Opens the file in place when possible (avoids copying multi-GiB state DBs). + * Cursor keeps this file in WAL mode, and node-sqlite3-wasm cannot open WAL + * databases (`unable to open database file`). In that case, and on lock + * errors, copy to a temp file, strip the WAL flag on the copy, then query. + * The live Cursor database is never modified. + */ +export function readAuthFromStateDb(dbPath: string): AuthResult | undefined { + if (!fileUsesWal(dbPath)) { + try { + return queryAuth(dbPath); + } catch (err) { + if (!isOpenFailure(err)) { + throw err; + } + } + } + return queryAuthFromCopy(dbPath); +}