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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ Use these commands to manage Cursor authentication and the local cache that Toke
tokenleak cursor login --name work
tokenleak cursor status
tokenleak cursor accounts --json
tokenleak cursor doctor
tokenleak cursor switch work
tokenleak cursor logout --name work
tokenleak cursor logout --all --purge-cache
Expand Down Expand Up @@ -238,6 +239,28 @@ bun packages/cli/dist/cli.js --provider cursor --format json
- If `cursor status` is valid but `--list-providers` still shows Cursor as unavailable, run `tokenleak --provider cursor` once to sync the cache, then rerun `--list-providers`.
- Cursor session tokens are stored in plaintext at `~/.config/tokenleak/cursor-credentials.json` (or under `TOKENLEAK_CURSOR_DIR`) with local-only file permissions.

#### Corporate VPN / protected network

If `tokenleak cursor login` works off VPN but fails on a company protected VPN with a connection, proxy, or certificate error, run the token-free doctor first:

```bash
tokenleak cursor doctor
```

For managed proxy networks, pass a Cursor-specific proxy or use your standard shell proxy variables:

```bash
TOKENLEAK_CURSOR_PROXY=http://proxy.company:8080 tokenleak cursor doctor
```

For TLS inspection networks, export the company root CA as a PEM file and point Tokenleak at it:

```bash
TOKENLEAK_CURSOR_CA_FILE=/path/company-root-ca.pem tokenleak cursor doctor --with-token
```

Tokenleak also honors `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, and `TOKENLEAK_CURSOR_TIMEOUT_MS` for Cursor API requests. `tokenleak cursor doctor --insecure-skip-tls-verify` exists only to prove that TLS inspection is the failure mode; do not use it for normal login or sync.

### Date filtering

By default, Tokenleak shows the last **90 days** of usage.
Expand Down
132 changes: 127 additions & 5 deletions packages/cli/src/cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
loadCursorCredentialsStore,
removeAllCursorAccounts,
resetCursorProviderState,
runCursorCommand,
saveCursorCredentials,
setActiveCursorAccount,
shouldSyncCursorForRun,
Expand All @@ -24,21 +25,47 @@ const SAMPLE_CSV = [
].join('\n');

describe('cursor auth and sync helpers', () => {
const originalCursorDir = process.env['TOKENLEAK_CURSOR_DIR'];
const originalFetch = globalThis.fetch;
const originalEnv = { ...process.env };
let tempRoot = '';

beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), 'tokenleak-cursor-'));
process.env['TOKENLEAK_CURSOR_DIR'] = tempRoot;
for (const key of [
'TOKENLEAK_CURSOR_PROXY',
'TOKENLEAK_CURSOR_CA_FILE',
'TOKENLEAK_CURSOR_TIMEOUT_MS',
'HTTPS_PROXY',
'https_proxy',
'HTTP_PROXY',
'http_proxy',
'NO_PROXY',
'no_proxy',
]) {
delete process.env[key];
}
globalThis.fetch = originalFetch;
});

afterEach(() => {
if (originalCursorDir === undefined) {
delete process.env['TOKENLEAK_CURSOR_DIR'];
} else {
process.env['TOKENLEAK_CURSOR_DIR'] = originalCursorDir;
for (const key of [
'TOKENLEAK_CURSOR_DIR',
'TOKENLEAK_CURSOR_PROXY',
'TOKENLEAK_CURSOR_CA_FILE',
'TOKENLEAK_CURSOR_TIMEOUT_MS',
'HTTPS_PROXY',
'https_proxy',
'HTTP_PROXY',
'http_proxy',
'NO_PROXY',
'no_proxy',
]) {
if (originalEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originalEnv[key];
}
}
globalThis.fetch = originalFetch;
rmSync(tempRoot, { recursive: true, force: true });
Expand Down Expand Up @@ -168,4 +195,99 @@ describe('cursor auth and sync helpers', () => {
expect(existsSync(join(getCursorCacheDir(), 'usage.csv'))).toBe(false);
expect(listCursorAccounts()).toEqual([]);
});

test('cursor help includes doctor diagnostics', async () => {
let output = '';
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
output += String(chunk);
return true;
}) as typeof process.stdout.write;

try {
await runCursorCommand(['--help']);
} finally {
process.stdout.write = originalWrite;
}

expect(output).toContain('tokenleak cursor doctor [--name <label>] [--with-token] [--insecure-skip-tls-verify]');
});

test('cursor doctor redacts proxy credentials and saved token details', async () => {
process.env['TOKENLEAK_CURSOR_PROXY'] = 'http://user:secret@proxy.company:8080';
saveCursorCredentials('user-work::super-secret-token', 'work');
globalThis.fetch = (async (url, init) => {
const cookie = String((init?.headers as Record<string, string> | undefined)?.['Cookie'] ?? '');
const hasToken = cookie.includes('super-secret-token');
if (String(url).includes('/api/usage-summary')) {
if (!hasToken) {
return new Response(JSON.stringify({ error: 'not_authenticated' }), { status: 401 });
}
return new Response(JSON.stringify({
billingCycleStart: '2026-03-01',
billingCycleEnd: '2026-03-31',
membershipType: 'pro',
}), { status: 200 });
}
if (!hasToken) {
return new Response('', { status: 307, headers: { location: 'https://api.workos.com' } });
}
return new Response(SAMPLE_CSV, { status: 200 });
}) as typeof fetch;

let output = '';
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
output += String(chunk);
return true;
}) as typeof process.stdout.write;

try {
await runCursorCommand(['doctor', '--name', 'work', '--with-token']);
} finally {
process.stdout.write = originalWrite;
}

expect(output).toContain('Cursor network doctor');
expect(output).toContain('Proxy: http://***:***@proxy.company:8080');
expect(output).toContain('Token check: enabled');
expect(output).not.toContain('super-secret-token');
expect(output).not.toContain('user:secret');
expect(output).not.toContain('Set-Cookie');
});

test('cursor login network errors point to doctor and Cursor network env vars', async () => {
globalThis.fetch = (async () => {
throw new Error('self signed certificate in certificate chain');
}) as typeof fetch;

await expect(validateCursorSession('token-123')).resolves.toMatchObject({
valid: false,
reason: 'network',
error: expect.stringContaining('TOKENLEAK_CURSOR_CA_FILE'),
});
});

test('cursor doctor reports a missing CA file without crashing', async () => {
const missingCaPath = join(tempRoot, 'missing-company-root-ca.pem');
process.env['TOKENLEAK_CURSOR_CA_FILE'] = missingCaPath;
let output = '';
const originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
output += String(chunk);
return true;
}) as typeof process.stdout.write;

try {
await runCursorCommand(['doctor']);
} finally {
process.stdout.write = originalWrite;
}

expect(output).toContain('Cursor network doctor');
expect(output).toContain('CA file:');
expect(output).toContain(missingCaPath);
expect(output).toContain('[fail] ca-file');
expect(output).toContain('TOKENLEAK_CURSOR_CA_FILE');
});
});
87 changes: 87 additions & 0 deletions packages/cli/src/cursor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
CursorAuthError,
diagnoseCursorConnection,
getActiveCursorCredentials,
getCursorCacheDir,
getCursorCredentialsFor,
Expand All @@ -19,6 +20,8 @@ import {
type CursorAccountInfo,
type CursorCredentials,
type CursorCredentialsStore,
type CursorDiagnosticCheck,
type CursorDiagnosticResult,
type SyncCursorResult,
type ValidateCursorSessionResult,
} from '@tokenleak/registry';
Expand All @@ -43,11 +46,14 @@ export {
shouldSyncCursorForRun,
syncCursorCache,
validateCursorSession,
diagnoseCursorConnection,
};
export type {
CursorAccountInfo,
CursorCredentials,
CursorCredentialsStore,
CursorDiagnosticCheck,
CursorDiagnosticResult,
SyncCursorResult,
ValidateCursorSessionResult,
};
Expand Down Expand Up @@ -118,6 +124,7 @@ export function buildCursorHelpText(): string {
'Usage:',
' tokenleak cursor login [--name <label>]',
' tokenleak cursor status [--name <label>]',
' tokenleak cursor doctor [--name <label>] [--with-token] [--insecure-skip-tls-verify]',
' tokenleak cursor accounts [--json]',
' tokenleak cursor switch <name-or-id>',
' tokenleak cursor logout [--name <label> | --all] [--purge-cache]',
Expand All @@ -126,12 +133,40 @@ export function buildCursorHelpText(): string {
'Notes:',
' Session tokens come from https://www.cursor.com/settings',
' Session tokens are stored in plaintext with local-only file permissions.',
' For protected VPN/proxy networks, run: tokenleak cursor doctor',
` Credentials: ${getCursorCredentialsPath()}`,
` Cache: ${getCursorCacheDir()}`,
'',
].join('\n');
}

function formatDiagnosticCheck(check: CursorDiagnosticCheck): string {
const prefix = check.ok ? '[ok]' : '[fail]';
const status = check.status === undefined ? '' : ` HTTP ${check.status}`;
const kind = check.kind ? ` ${check.kind}` : '';
const hint = check.hint ? `\n Hint: ${check.hint}` : '';
return ` ${prefix} ${check.name}${status}${kind}: ${check.message}${hint}`;
}

function printCursorDoctorResult(result: CursorDiagnosticResult, tokenEnabled: boolean): void {
process.stdout.write('Cursor network doctor\n');
process.stdout.write(`Timeout: ${result.network.timeoutMs}ms\n`);
process.stdout.write(`Proxy: ${result.network.proxy ?? 'not configured'}\n`);
if (result.network.proxySource) {
process.stdout.write(`Proxy source: ${result.network.proxySource}\n`);
}
if (result.network.noProxyMatched) {
process.stdout.write('Proxy bypass: matched NO_PROXY/no_proxy\n');
}
process.stdout.write(`CA file: ${result.network.caFile ?? 'not configured'}\n`);
process.stdout.write(`TLS verification: ${result.network.tlsVerification}\n`);
process.stdout.write(`Token check: ${tokenEnabled ? 'enabled' : 'disabled'}\n`);
process.stdout.write('Checks:\n');
for (const check of result.checks) {
process.stdout.write(`${formatDiagnosticCheck(check)}\n`);
}
}

function printCursorAccounts(json: boolean): void {
const accounts = listCursorAccounts();
if (json) {
Expand Down Expand Up @@ -193,6 +228,27 @@ async function runCursorStatus(name?: string): Promise<void> {
}
}

async function runCursorDoctor(options: {
name?: string;
withToken: boolean;
insecureSkipTlsVerify: boolean;
}): Promise<void> {
let credentials: CursorCredentials | null = null;
if (options.withToken) {
credentials = options.name ? getCursorCredentialsFor(options.name) : getActiveCursorCredentials();
if (!credentials) {
throw new TokenleakError(options.name ? `Account not found: ${options.name}` : 'No saved Cursor accounts');
}
}

const result = await diagnoseCursorConnection({
credentials,
includeToken: options.withToken,
insecureSkipTlsVerify: options.insecureSkipTlsVerify,
});
printCursorDoctorResult(result, options.withToken);
}

function runCursorLogout(name: string | undefined, all: boolean, purgeCache: boolean): void {
if (all) {
removeAllCursorAccounts(purgeCache);
Expand Down Expand Up @@ -278,6 +334,37 @@ export async function runCursorCommand(argv: string[]): Promise<void> {
return;
}

if (command === 'doctor') {
let name: string | undefined;
let withToken = false;
let insecureSkipTlsVerify = false;
for (let index = 1; index < argv.length; ) {
const arg = argv[index]!;
if (arg === '--name') {
[name, index] = parseNameFlag(argv, index);
continue;
}
if (arg === '--with-token') {
withToken = true;
index += 1;
continue;
}
if (arg === '--insecure-skip-tls-verify') {
insecureSkipTlsVerify = true;
index += 1;
continue;
}
throw new TokenleakError(`Unknown cursor doctor flag "${arg}"`);
}

try {
await runCursorDoctor({ name, withToken, insecureSkipTlsVerify });
} catch (error: unknown) {
wrapCursorError(error);
}
return;
}

if (command === 'accounts') {
if (argv.length > 2 || (argv[1] && argv[1] !== '--json')) {
throw new TokenleakError(`Unknown cursor flag "${argv[1]}"`);
Expand Down
Loading
Loading