Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
110 changes: 108 additions & 2 deletions src/stateDb.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');
Expand All @@ -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 });
Expand Down Expand Up @@ -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]);
}
);
});
124 changes: 116 additions & 8 deletions src/stateDb.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,45 @@
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';

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);
Expand All @@ -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);
}
Loading