Skip to content

Commit 7acd6f8

Browse files
authored
fix cursor vpn auth diagnostics
Add Cursor protected-network diagnostics and handle missing CA file configuration.
1 parent 9dadccf commit 7acd6f8

6 files changed

Lines changed: 929 additions & 27 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ Use these commands to manage Cursor authentication and the local cache that Toke
213213
tokenleak cursor login --name work
214214
tokenleak cursor status
215215
tokenleak cursor accounts --json
216+
tokenleak cursor doctor
216217
tokenleak cursor switch work
217218
tokenleak cursor logout --name work
218219
tokenleak cursor logout --all --purge-cache
@@ -255,6 +256,28 @@ bun packages/cli/dist/cli.js --provider cursor --format json
255256
- 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`.
256257
- Cursor session tokens are stored in plaintext at `~/.config/tokenleak/cursor-credentials.json` (or under `TOKENLEAK_CURSOR_DIR`) with local-only file permissions.
257258

259+
#### Corporate VPN / protected network
260+
261+
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:
262+
263+
```bash
264+
tokenleak cursor doctor
265+
```
266+
267+
For managed proxy networks, pass a Cursor-specific proxy or use your standard shell proxy variables:
268+
269+
```bash
270+
TOKENLEAK_CURSOR_PROXY=http://proxy.company:8080 tokenleak cursor doctor
271+
```
272+
273+
For TLS inspection networks, export the company root CA as a PEM file and point Tokenleak at it:
274+
275+
```bash
276+
TOKENLEAK_CURSOR_CA_FILE=/path/company-root-ca.pem tokenleak cursor doctor --with-token
277+
```
278+
279+
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.
280+
258281
### Date filtering
259282

260283
By default, Tokenleak shows the last **90 days** of usage.

packages/cli/src/cursor.test.ts

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
loadCursorCredentialsStore,
1010
removeAllCursorAccounts,
1111
resetCursorProviderState,
12+
runCursorCommand,
1213
saveCursorCredentials,
1314
setActiveCursorAccount,
1415
shouldSyncCursorForRun,
@@ -24,21 +25,47 @@ const SAMPLE_CSV = [
2425
].join('\n');
2526

2627
describe('cursor auth and sync helpers', () => {
27-
const originalCursorDir = process.env['TOKENLEAK_CURSOR_DIR'];
2828
const originalFetch = globalThis.fetch;
29+
const originalEnv = { ...process.env };
2930
let tempRoot = '';
3031

3132
beforeEach(() => {
3233
tempRoot = mkdtempSync(join(tmpdir(), 'tokenleak-cursor-'));
3334
process.env['TOKENLEAK_CURSOR_DIR'] = tempRoot;
35+
for (const key of [
36+
'TOKENLEAK_CURSOR_PROXY',
37+
'TOKENLEAK_CURSOR_CA_FILE',
38+
'TOKENLEAK_CURSOR_TIMEOUT_MS',
39+
'HTTPS_PROXY',
40+
'https_proxy',
41+
'HTTP_PROXY',
42+
'http_proxy',
43+
'NO_PROXY',
44+
'no_proxy',
45+
]) {
46+
delete process.env[key];
47+
}
3448
globalThis.fetch = originalFetch;
3549
});
3650

3751
afterEach(() => {
38-
if (originalCursorDir === undefined) {
39-
delete process.env['TOKENLEAK_CURSOR_DIR'];
40-
} else {
41-
process.env['TOKENLEAK_CURSOR_DIR'] = originalCursorDir;
52+
for (const key of [
53+
'TOKENLEAK_CURSOR_DIR',
54+
'TOKENLEAK_CURSOR_PROXY',
55+
'TOKENLEAK_CURSOR_CA_FILE',
56+
'TOKENLEAK_CURSOR_TIMEOUT_MS',
57+
'HTTPS_PROXY',
58+
'https_proxy',
59+
'HTTP_PROXY',
60+
'http_proxy',
61+
'NO_PROXY',
62+
'no_proxy',
63+
]) {
64+
if (originalEnv[key] === undefined) {
65+
delete process.env[key];
66+
} else {
67+
process.env[key] = originalEnv[key];
68+
}
4269
}
4370
globalThis.fetch = originalFetch;
4471
rmSync(tempRoot, { recursive: true, force: true });
@@ -168,4 +195,99 @@ describe('cursor auth and sync helpers', () => {
168195
expect(existsSync(join(getCursorCacheDir(), 'usage.csv'))).toBe(false);
169196
expect(listCursorAccounts()).toEqual([]);
170197
});
198+
199+
test('cursor help includes doctor diagnostics', async () => {
200+
let output = '';
201+
const originalWrite = process.stdout.write;
202+
process.stdout.write = ((chunk: string | Uint8Array) => {
203+
output += String(chunk);
204+
return true;
205+
}) as typeof process.stdout.write;
206+
207+
try {
208+
await runCursorCommand(['--help']);
209+
} finally {
210+
process.stdout.write = originalWrite;
211+
}
212+
213+
expect(output).toContain('tokenleak cursor doctor [--name <label>] [--with-token] [--insecure-skip-tls-verify]');
214+
});
215+
216+
test('cursor doctor redacts proxy credentials and saved token details', async () => {
217+
process.env['TOKENLEAK_CURSOR_PROXY'] = 'http://user:secret@proxy.company:8080';
218+
saveCursorCredentials('user-work::super-secret-token', 'work');
219+
globalThis.fetch = (async (url, init) => {
220+
const cookie = String((init?.headers as Record<string, string> | undefined)?.['Cookie'] ?? '');
221+
const hasToken = cookie.includes('super-secret-token');
222+
if (String(url).includes('/api/usage-summary')) {
223+
if (!hasToken) {
224+
return new Response(JSON.stringify({ error: 'not_authenticated' }), { status: 401 });
225+
}
226+
return new Response(JSON.stringify({
227+
billingCycleStart: '2026-03-01',
228+
billingCycleEnd: '2026-03-31',
229+
membershipType: 'pro',
230+
}), { status: 200 });
231+
}
232+
if (!hasToken) {
233+
return new Response('', { status: 307, headers: { location: 'https://api.workos.com' } });
234+
}
235+
return new Response(SAMPLE_CSV, { status: 200 });
236+
}) as typeof fetch;
237+
238+
let output = '';
239+
const originalWrite = process.stdout.write;
240+
process.stdout.write = ((chunk: string | Uint8Array) => {
241+
output += String(chunk);
242+
return true;
243+
}) as typeof process.stdout.write;
244+
245+
try {
246+
await runCursorCommand(['doctor', '--name', 'work', '--with-token']);
247+
} finally {
248+
process.stdout.write = originalWrite;
249+
}
250+
251+
expect(output).toContain('Cursor network doctor');
252+
expect(output).toContain('Proxy: http://***:***@proxy.company:8080');
253+
expect(output).toContain('Token check: enabled');
254+
expect(output).not.toContain('super-secret-token');
255+
expect(output).not.toContain('user:secret');
256+
expect(output).not.toContain('Set-Cookie');
257+
});
258+
259+
test('cursor login network errors point to doctor and Cursor network env vars', async () => {
260+
globalThis.fetch = (async () => {
261+
throw new Error('self signed certificate in certificate chain');
262+
}) as typeof fetch;
263+
264+
await expect(validateCursorSession('token-123')).resolves.toMatchObject({
265+
valid: false,
266+
reason: 'network',
267+
error: expect.stringContaining('TOKENLEAK_CURSOR_CA_FILE'),
268+
});
269+
});
270+
271+
test('cursor doctor reports a missing CA file without crashing', async () => {
272+
const missingCaPath = join(tempRoot, 'missing-company-root-ca.pem');
273+
process.env['TOKENLEAK_CURSOR_CA_FILE'] = missingCaPath;
274+
let output = '';
275+
const originalWrite = process.stdout.write;
276+
process.stdout.write = ((chunk: string | Uint8Array) => {
277+
output += String(chunk);
278+
return true;
279+
}) as typeof process.stdout.write;
280+
281+
try {
282+
await runCursorCommand(['doctor']);
283+
} finally {
284+
process.stdout.write = originalWrite;
285+
}
286+
287+
expect(output).toContain('Cursor network doctor');
288+
expect(output).toContain('CA file:');
289+
expect(output).toContain(missingCaPath);
290+
expect(output).toContain('[fail] ca-file');
291+
expect(output).toContain('TOKENLEAK_CURSOR_CA_FILE');
292+
});
171293
});

packages/cli/src/cursor.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
CursorAuthError,
3+
diagnoseCursorConnection,
34
getActiveCursorCredentials,
45
getCursorCacheDir,
56
getCursorCredentialsFor,
@@ -19,6 +20,8 @@ import {
1920
type CursorAccountInfo,
2021
type CursorCredentials,
2122
type CursorCredentialsStore,
23+
type CursorDiagnosticCheck,
24+
type CursorDiagnosticResult,
2225
type SyncCursorResult,
2326
type ValidateCursorSessionResult,
2427
} from '@tokenleak/registry';
@@ -43,11 +46,14 @@ export {
4346
shouldSyncCursorForRun,
4447
syncCursorCache,
4548
validateCursorSession,
49+
diagnoseCursorConnection,
4650
};
4751
export type {
4852
CursorAccountInfo,
4953
CursorCredentials,
5054
CursorCredentialsStore,
55+
CursorDiagnosticCheck,
56+
CursorDiagnosticResult,
5157
SyncCursorResult,
5258
ValidateCursorSessionResult,
5359
};
@@ -118,6 +124,7 @@ export function buildCursorHelpText(): string {
118124
'Usage:',
119125
' tokenleak cursor login [--name <label>]',
120126
' tokenleak cursor status [--name <label>]',
127+
' tokenleak cursor doctor [--name <label>] [--with-token] [--insecure-skip-tls-verify]',
121128
' tokenleak cursor accounts [--json]',
122129
' tokenleak cursor switch <name-or-id>',
123130
' tokenleak cursor logout [--name <label> | --all] [--purge-cache]',
@@ -126,12 +133,40 @@ export function buildCursorHelpText(): string {
126133
'Notes:',
127134
' Session tokens come from https://www.cursor.com/settings',
128135
' Session tokens are stored in plaintext with local-only file permissions.',
136+
' For protected VPN/proxy networks, run: tokenleak cursor doctor',
129137
` Credentials: ${getCursorCredentialsPath()}`,
130138
` Cache: ${getCursorCacheDir()}`,
131139
'',
132140
].join('\n');
133141
}
134142

143+
function formatDiagnosticCheck(check: CursorDiagnosticCheck): string {
144+
const prefix = check.ok ? '[ok]' : '[fail]';
145+
const status = check.status === undefined ? '' : ` HTTP ${check.status}`;
146+
const kind = check.kind ? ` ${check.kind}` : '';
147+
const hint = check.hint ? `\n Hint: ${check.hint}` : '';
148+
return ` ${prefix} ${check.name}${status}${kind}: ${check.message}${hint}`;
149+
}
150+
151+
function printCursorDoctorResult(result: CursorDiagnosticResult, tokenEnabled: boolean): void {
152+
process.stdout.write('Cursor network doctor\n');
153+
process.stdout.write(`Timeout: ${result.network.timeoutMs}ms\n`);
154+
process.stdout.write(`Proxy: ${result.network.proxy ?? 'not configured'}\n`);
155+
if (result.network.proxySource) {
156+
process.stdout.write(`Proxy source: ${result.network.proxySource}\n`);
157+
}
158+
if (result.network.noProxyMatched) {
159+
process.stdout.write('Proxy bypass: matched NO_PROXY/no_proxy\n');
160+
}
161+
process.stdout.write(`CA file: ${result.network.caFile ?? 'not configured'}\n`);
162+
process.stdout.write(`TLS verification: ${result.network.tlsVerification}\n`);
163+
process.stdout.write(`Token check: ${tokenEnabled ? 'enabled' : 'disabled'}\n`);
164+
process.stdout.write('Checks:\n');
165+
for (const check of result.checks) {
166+
process.stdout.write(`${formatDiagnosticCheck(check)}\n`);
167+
}
168+
}
169+
135170
function printCursorAccounts(json: boolean): void {
136171
const accounts = listCursorAccounts();
137172
if (json) {
@@ -193,6 +228,27 @@ async function runCursorStatus(name?: string): Promise<void> {
193228
}
194229
}
195230

231+
async function runCursorDoctor(options: {
232+
name?: string;
233+
withToken: boolean;
234+
insecureSkipTlsVerify: boolean;
235+
}): Promise<void> {
236+
let credentials: CursorCredentials | null = null;
237+
if (options.withToken) {
238+
credentials = options.name ? getCursorCredentialsFor(options.name) : getActiveCursorCredentials();
239+
if (!credentials) {
240+
throw new TokenleakError(options.name ? `Account not found: ${options.name}` : 'No saved Cursor accounts');
241+
}
242+
}
243+
244+
const result = await diagnoseCursorConnection({
245+
credentials,
246+
includeToken: options.withToken,
247+
insecureSkipTlsVerify: options.insecureSkipTlsVerify,
248+
});
249+
printCursorDoctorResult(result, options.withToken);
250+
}
251+
196252
function runCursorLogout(name: string | undefined, all: boolean, purgeCache: boolean): void {
197253
if (all) {
198254
removeAllCursorAccounts(purgeCache);
@@ -278,6 +334,37 @@ export async function runCursorCommand(argv: string[]): Promise<void> {
278334
return;
279335
}
280336

337+
if (command === 'doctor') {
338+
let name: string | undefined;
339+
let withToken = false;
340+
let insecureSkipTlsVerify = false;
341+
for (let index = 1; index < argv.length; ) {
342+
const arg = argv[index]!;
343+
if (arg === '--name') {
344+
[name, index] = parseNameFlag(argv, index);
345+
continue;
346+
}
347+
if (arg === '--with-token') {
348+
withToken = true;
349+
index += 1;
350+
continue;
351+
}
352+
if (arg === '--insecure-skip-tls-verify') {
353+
insecureSkipTlsVerify = true;
354+
index += 1;
355+
continue;
356+
}
357+
throw new TokenleakError(`Unknown cursor doctor flag "${arg}"`);
358+
}
359+
360+
try {
361+
await runCursorDoctor({ name, withToken, insecureSkipTlsVerify });
362+
} catch (error: unknown) {
363+
wrapCursorError(error);
364+
}
365+
return;
366+
}
367+
281368
if (command === 'accounts') {
282369
if (argv.length > 2 || (argv[1] && argv[1] !== '--json')) {
283370
throw new TokenleakError(`Unknown cursor flag "${argv[1]}"`);

0 commit comments

Comments
 (0)