agent-dealer 1.2.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/server/dist/capacity/claude-events.js +27 -10
- package/bundle/server/dist/capacity/claude-local-cache.js +788 -0
- package/bundle/server/dist/capacity/claude-local-cache.test.js +520 -0
- package/bundle/server/dist/capacity/cursor-individual-credentials.js +203 -16
- package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +186 -2
- package/bundle/server/dist/capacity/cursor-individual.js +69 -2
- package/bundle/server/dist/capacity/cursor-individual.test.js +268 -2
- package/bundle/server/dist/capacity/muse-host.js +748 -0
- package/bundle/server/dist/capacity/muse-host.test.js +480 -0
- package/bundle/server/dist/capacity/muse-lifecycle.test.js +97 -0
- package/bundle/server/dist/capacity/muse.js +307 -60
- package/bundle/server/dist/capacity/muse.test.js +8 -51
- package/bundle/server/dist/coordinator/muse-spawn.js +143 -1
- package/bundle/server/dist/index.js +15 -2
- package/bundle/server/dist/routes/index.js +26 -9
- package/bundle/server/dist/routes/runtime-capacity.test.js +12 -6
- package/bundle/server/dist/runners/muse-code-jsonl.js +7 -1
- package/bundle/server/dist/runners/muse-serve-session.js +313 -0
- package/bundle/server/dist/runners/muse-serve-session.test.js +325 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/codex-YZSpL-SB.png +0 -0
- package/bundle/server/static-ui/assets/{index-DUYzboWh.css → index-CYqRh_-S.css} +1 -1
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +60 -0
- package/bundle/server/static-ui/assets/muse-CtdcC6ve.svg +33 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +57 -0
- package/dist/doctor.js +206 -1
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +246 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-0GDYfbaM.js +0 -60
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// packages/server/src/capacity/cursor-individual-credentials.ts
|
|
2
2
|
//
|
|
3
3
|
// NOT-250: isolated credential lookup for the experimental Cursor Individual
|
|
4
|
-
// dashboard adapter.
|
|
4
|
+
// dashboard adapter. NOT-267: the desktop Cursor login is the primary
|
|
5
|
+
// source, the Cursor Agent auth file the fallback.
|
|
5
6
|
//
|
|
6
7
|
// The dashboard endpoints this feature calls are undocumented: the only
|
|
7
|
-
// credential available is the existing local Cursor login, whose
|
|
8
|
-
//
|
|
8
|
+
// credential available is the existing local Cursor login, whose storage
|
|
9
|
+
// locations and shapes are themselves not a supported contract and may
|
|
9
10
|
// change without notice. All of that risk lives in this one module:
|
|
10
11
|
//
|
|
11
|
-
// -
|
|
12
|
+
// - Resolution order: Cursor desktop `state.vscdb` first (read-only,
|
|
13
|
+
// single allowlisted key), then the Cursor Agent `auth.json` allowlisted
|
|
14
|
+
// token shapes. The first usable source wins; Agent auth unchanged.
|
|
12
15
|
// - Accepted JSON shapes are an explicit allowlist below — anything else
|
|
13
16
|
// reads `unparsable` (changed format), never a guess.
|
|
14
17
|
// - `cursorIndividualCredentialStatus()` (the diagnostics surface) returns
|
|
15
|
-
// presence/path/format ONLY — never secret material.
|
|
18
|
+
// presence/source/path/format ONLY — never secret material.
|
|
16
19
|
// - `loadCursorIndividualCredential()` additionally returns an in-memory
|
|
17
20
|
// `authHeader` for the dashboard HTTP call. The header value must never be
|
|
18
21
|
// persisted, returned to the browser, or logged — the adapter, repository,
|
|
@@ -22,6 +25,9 @@
|
|
|
22
25
|
// *finds* credentials; whether they may be *used* is decided by the
|
|
23
26
|
// explicit `AGENT_DEALER_CURSOR_INDIVIDUAL_CAPACITY=experimental` opt-in in
|
|
24
27
|
// cursor-individual.ts, which short-circuits before touching this module.
|
|
28
|
+
// Nothing here writes Cursor's database, refreshes tokens, or touches the
|
|
29
|
+
// Keychain or auth files — all reads are read-only.
|
|
30
|
+
import Database from "better-sqlite3";
|
|
25
31
|
import fs from "node:fs";
|
|
26
32
|
import os from "node:os";
|
|
27
33
|
import path from "node:path";
|
|
@@ -29,6 +35,8 @@ import path from "node:path";
|
|
|
29
35
|
export const CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV = "CURSOR_INDIVIDUAL_CREDENTIAL_FILE";
|
|
30
36
|
/** Home-directory override for tests (mirrors how OS home resolution works). */
|
|
31
37
|
export const CURSOR_INDIVIDUAL_HOME_ENV = "CURSOR_INDIVIDUAL_HOME";
|
|
38
|
+
/** Explicit desktop-state database override (tests point this at tmp fixtures). */
|
|
39
|
+
export const CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV = "CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE";
|
|
32
40
|
/**
|
|
33
41
|
* Community-observed local Cursor login file. The location is undocumented
|
|
34
42
|
* and may move — when it does, reads degrade to `absent`/`unparsable`, never
|
|
@@ -83,6 +91,118 @@ export function cursorIndividualCredentialCandidates() {
|
|
|
83
91
|
return [override];
|
|
84
92
|
return [path.join(homeDir(), CURSOR_INDIVIDUAL_DEFAULT_RELATIVE_PATH)];
|
|
85
93
|
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Cursor desktop login (NOT-267, primary source).
|
|
96
|
+
//
|
|
97
|
+
// Cursor desktop keeps its login in the standard VS Code global-storage
|
|
98
|
+
// SQLite database (`state.vscdb`), under the key
|
|
99
|
+
// `cursorAuth/accessToken`. Community tools (ai-usagebar and ports) read
|
|
100
|
+
// that same key first and fall back to a Cursor Agent `auth.json` — this
|
|
101
|
+
// module follows the same order.
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
/** The ONLY desktop-state key this module ever reads — never anything else. */
|
|
104
|
+
export const CURSOR_DESKTOP_ACCESS_TOKEN_KEY = "cursorAuth/accessToken";
|
|
105
|
+
/** Format label for a credential answered by the desktop state database. */
|
|
106
|
+
export const CURSOR_DESKTOP_FORMAT = "desktop:cursorAuth/accessToken";
|
|
107
|
+
/**
|
|
108
|
+
* Ordered desktop-state database candidates: exactly one default path per
|
|
109
|
+
* OS (plus the explicit file override when set). The module never hunts
|
|
110
|
+
* beyond these — an absent database degrades to the Agent auth fallback,
|
|
111
|
+
* never to a scan of unrelated files.
|
|
112
|
+
*
|
|
113
|
+
* - macOS: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb`
|
|
114
|
+
* - Windows: `%APPDATA%/Cursor/User/globalStorage/state.vscdb`
|
|
115
|
+
* - Linux (and anything else): `~/.config/Cursor/User/globalStorage/state.vscdb`
|
|
116
|
+
*/
|
|
117
|
+
export function cursorIndividualDesktopStateCandidates(opts = {}) {
|
|
118
|
+
const override = process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV]?.trim();
|
|
119
|
+
if (override)
|
|
120
|
+
return [override];
|
|
121
|
+
const platform = opts.platform ?? process.platform;
|
|
122
|
+
const home = opts.home ?? homeDir();
|
|
123
|
+
if (platform === "win32") {
|
|
124
|
+
// An explicit `appData` opt always wins (even an explicit empty string,
|
|
125
|
+
// which reads as no candidate). A present-but-blank APPDATA is the same
|
|
126
|
+
// as unset — fall back to the roaming default instead of resolving to [].
|
|
127
|
+
const envAppData = process.env.APPDATA?.trim();
|
|
128
|
+
const appData = opts.appData ?? (envAppData ? envAppData : path.join(home, "AppData", "Roaming"));
|
|
129
|
+
if (!appData)
|
|
130
|
+
return [];
|
|
131
|
+
return [path.join(appData, "Cursor", "User", "globalStorage", "state.vscdb")];
|
|
132
|
+
}
|
|
133
|
+
if (platform === "darwin") {
|
|
134
|
+
return [
|
|
135
|
+
path.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"),
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
return [path.join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb")];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Normalize one raw desktop-state value to a token. Cursor stores the
|
|
142
|
+
* access token as a plain-text JWT; a JSON-quoted wrapping is tolerated
|
|
143
|
+
* (same bytes, quoted), anything else is unusable — never a guess.
|
|
144
|
+
*/
|
|
145
|
+
export function normalizeDesktopTokenValue(value) {
|
|
146
|
+
if (typeof value !== "string")
|
|
147
|
+
return null;
|
|
148
|
+
const trimmed = value.trim();
|
|
149
|
+
if (!trimmed)
|
|
150
|
+
return null;
|
|
151
|
+
if (trimmed.startsWith('"')) {
|
|
152
|
+
try {
|
|
153
|
+
const inner = JSON.parse(trimmed);
|
|
154
|
+
return typeof inner === "string" && inner.trim() ? inner.trim() : null;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return trimmed;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Open the desktop state database READ-ONLY and read ONLY the allowlisted
|
|
164
|
+
* access-token key. Never throws: a missing/locked database, a missing key,
|
|
165
|
+
* or an unusable value all read null. The database is never copied, never
|
|
166
|
+
* written, and no unrelated key is ever selected.
|
|
167
|
+
*/
|
|
168
|
+
export function defaultReadDesktopAccessToken(dbPath) {
|
|
169
|
+
let db = null;
|
|
170
|
+
try {
|
|
171
|
+
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
172
|
+
const row = db
|
|
173
|
+
.prepare("SELECT value FROM ItemTable WHERE key = ?")
|
|
174
|
+
.get(CURSOR_DESKTOP_ACCESS_TOKEN_KEY);
|
|
175
|
+
if (!row)
|
|
176
|
+
return null;
|
|
177
|
+
return normalizeDesktopTokenValue(row.value);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
try {
|
|
184
|
+
db?.close();
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Read-only handle cleanup is best-effort; the read verdict stands.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Build a dashboard auth header from a desktop access token. The desktop
|
|
193
|
+
* store carries no separate user-id field, so the id is derived from the
|
|
194
|
+
* token's own JWT `sub` claim through the existing normalization — a token
|
|
195
|
+
* with no derivable id is unusable (null), never a Bearer-only guess.
|
|
196
|
+
*/
|
|
197
|
+
export function desktopTokenToAuthHeader(token) {
|
|
198
|
+
const rawUserId = decodeJwtSubject(token);
|
|
199
|
+
if (!rawUserId)
|
|
200
|
+
return null;
|
|
201
|
+
const userId = normalizeWorkosUserId(rawUserId);
|
|
202
|
+
if (!userId)
|
|
203
|
+
return null;
|
|
204
|
+
return cursorIndividualAuthHeader(userId, token);
|
|
205
|
+
}
|
|
86
206
|
function pickByKeys(obj, keys) {
|
|
87
207
|
for (const key of keys) {
|
|
88
208
|
const value = obj[key];
|
|
@@ -177,12 +297,31 @@ function defaultReadFile(filePath) {
|
|
|
177
297
|
}
|
|
178
298
|
}
|
|
179
299
|
/**
|
|
180
|
-
* Find a usable local Cursor credential
|
|
181
|
-
* invalid JSON,
|
|
300
|
+
* Find a usable local Cursor credential, desktop first, Agent auth as the
|
|
301
|
+
* fallback. Never throws: unreadable files, invalid JSON, unknown shapes,
|
|
302
|
+
* and unusable desktop tokens all map to `absent`/`unparsable`.
|
|
182
303
|
* The raw token leaves this module only inside `authHeader`, held in memory
|
|
183
304
|
* for the single dashboard call the adapter makes with it.
|
|
305
|
+
*
|
|
306
|
+
* When `CURSOR_INDIVIDUAL_CREDENTIAL_FILE` pins an explicit file, ONLY that
|
|
307
|
+
* file is consulted (legacy single-source behavior for fixtures); otherwise
|
|
308
|
+
* the desktop state database is tried first and the Agent auth file second.
|
|
184
309
|
*/
|
|
185
|
-
export function loadCursorIndividualCredential(readFile = defaultReadFile) {
|
|
310
|
+
export function loadCursorIndividualCredential(readFile = defaultReadFile, readDesktopToken = defaultReadDesktopAccessToken) {
|
|
311
|
+
const explicitFile = process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV]?.trim();
|
|
312
|
+
// A present-but-unusable desktop login is remembered (not returned yet):
|
|
313
|
+
// the Agent fallback still gets its chance — any usable credential wins
|
|
314
|
+
// over a precise-but-unusable verdict. When nothing is usable, the
|
|
315
|
+
// desktop verdict is the most precise diagnostic, so it wins over the
|
|
316
|
+
// Agent one.
|
|
317
|
+
let desktopUnparsable = null;
|
|
318
|
+
if (!explicitFile) {
|
|
319
|
+
const desktop = loadDesktopCredential(readDesktopToken);
|
|
320
|
+
if (desktop?.found)
|
|
321
|
+
return desktop.found;
|
|
322
|
+
desktopUnparsable = desktop?.unparsable ?? null;
|
|
323
|
+
}
|
|
324
|
+
const agentUnparsable = (candidate, format) => desktopUnparsable ?? { status: "unparsable", source: null, path: candidate, format };
|
|
186
325
|
for (const candidate of cursorIndividualCredentialCandidates()) {
|
|
187
326
|
const raw = readFile(candidate);
|
|
188
327
|
if (raw === null)
|
|
@@ -192,14 +331,14 @@ export function loadCursorIndividualCredential(readFile = defaultReadFile) {
|
|
|
192
331
|
parsed = JSON.parse(raw);
|
|
193
332
|
}
|
|
194
333
|
catch {
|
|
195
|
-
return
|
|
334
|
+
return agentUnparsable(candidate, null);
|
|
196
335
|
}
|
|
197
336
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
198
|
-
return
|
|
337
|
+
return agentUnparsable(candidate, null);
|
|
199
338
|
}
|
|
200
339
|
const picked = pickToken(parsed);
|
|
201
340
|
if (!picked)
|
|
202
|
-
return
|
|
341
|
+
return agentUnparsable(candidate, null);
|
|
203
342
|
// The cookie needs a user id too — an explicit field wins; otherwise
|
|
204
343
|
// derive it from the token's own JWT `sub` claim. Neither present means
|
|
205
344
|
// this credential cannot build a usable session, same as any other
|
|
@@ -207,31 +346,79 @@ export function loadCursorIndividualCredential(readFile = defaultReadFile) {
|
|
|
207
346
|
// (`google-oauth2|user_abc`) — normalize both the same way.
|
|
208
347
|
const rawUserId = pickUserId(parsed) ?? decodeJwtSubject(picked.token);
|
|
209
348
|
if (!rawUserId)
|
|
210
|
-
return
|
|
349
|
+
return agentUnparsable(candidate, picked.key);
|
|
211
350
|
// Re-check AFTER normalization: an id ending in `|` (e.g. a bare
|
|
212
351
|
// `"google-oauth2|"` with nothing after it) is non-empty here but
|
|
213
352
|
// normalizes to "" — that must degrade too, not build a cookie with an
|
|
214
353
|
// empty user id.
|
|
215
354
|
const userId = normalizeWorkosUserId(rawUserId);
|
|
216
355
|
if (!userId)
|
|
217
|
-
return
|
|
356
|
+
return agentUnparsable(candidate, picked.key);
|
|
218
357
|
return {
|
|
219
358
|
status: "found",
|
|
359
|
+
source: "agent",
|
|
220
360
|
path: candidate,
|
|
221
361
|
format: picked.key,
|
|
222
362
|
authHeader: cursorIndividualAuthHeader(userId, picked.token),
|
|
223
363
|
};
|
|
224
364
|
}
|
|
225
|
-
return { status: "absent", path: null, format: null };
|
|
365
|
+
return desktopUnparsable ?? { status: "absent", source: null, path: null, format: null };
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Try the desktop state database. Returns null when no desktop database
|
|
369
|
+
* was readable at all (fall through to Agent auth); otherwise the desktop
|
|
370
|
+
* verdict — `found` when the access token yields a usable session, or an
|
|
371
|
+
* `unparsable` diagnostic when the database answered but the token cannot
|
|
372
|
+
* (missing key, empty value, no derivable user id). A present-but-unusable
|
|
373
|
+
* desktop login is format drift, not absence — but the Agent fallback still
|
|
374
|
+
* gets its chance via the caller, which prefers any usable credential.
|
|
375
|
+
*/
|
|
376
|
+
function loadDesktopCredential(readDesktopToken) {
|
|
377
|
+
let sawDatabase = false;
|
|
378
|
+
let unparsable = null;
|
|
379
|
+
for (const dbPath of cursorIndividualDesktopStateCandidates()) {
|
|
380
|
+
let token;
|
|
381
|
+
try {
|
|
382
|
+
token = readDesktopToken(dbPath);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (token === null)
|
|
388
|
+
continue;
|
|
389
|
+
sawDatabase = true;
|
|
390
|
+
const authHeader = desktopTokenToAuthHeader(token);
|
|
391
|
+
if (authHeader) {
|
|
392
|
+
return {
|
|
393
|
+
found: {
|
|
394
|
+
status: "found",
|
|
395
|
+
source: "desktop",
|
|
396
|
+
path: dbPath,
|
|
397
|
+
format: CURSOR_DESKTOP_FORMAT,
|
|
398
|
+
authHeader,
|
|
399
|
+
},
|
|
400
|
+
unparsable: null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
// Token present but no derivable user id — same verdict as an Agent
|
|
404
|
+
// token with no id: changed format, never a Bearer-only guess.
|
|
405
|
+
if (!unparsable) {
|
|
406
|
+
unparsable = { status: "unparsable", source: null, path: dbPath, format: CURSOR_DESKTOP_FORMAT };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (!sawDatabase && !unparsable)
|
|
410
|
+
return null;
|
|
411
|
+
return { found: null, unparsable };
|
|
226
412
|
}
|
|
227
413
|
/**
|
|
228
414
|
* Diagnostics-safe credential presence: delegates to the loader, then
|
|
229
415
|
* strips the in-memory secret. Safe to expose via logs and API responses.
|
|
230
416
|
*/
|
|
231
|
-
export function cursorIndividualCredentialStatus(readFile = defaultReadFile) {
|
|
232
|
-
const credential = loadCursorIndividualCredential(readFile);
|
|
417
|
+
export function cursorIndividualCredentialStatus(readFile = defaultReadFile, readDesktopToken = defaultReadDesktopAccessToken) {
|
|
418
|
+
const credential = loadCursorIndividualCredential(readFile, readDesktopToken);
|
|
233
419
|
return {
|
|
234
420
|
present: credential.status === "found",
|
|
421
|
+
source: credential.source,
|
|
235
422
|
path: credential.path,
|
|
236
423
|
format: credential.format,
|
|
237
424
|
};
|
|
@@ -9,7 +9,8 @@ import assert from "node:assert/strict";
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import
|
|
12
|
+
import Database from "better-sqlite3";
|
|
13
|
+
import { CURSOR_DESKTOP_ACCESS_TOKEN_KEY, CURSOR_DESKTOP_FORMAT, CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV, CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV, CURSOR_INDIVIDUAL_HOME_ENV, cursorIndividualAuthHeader, cursorIndividualCredentialCandidates, cursorIndividualCredentialStatus, cursorIndividualDesktopStateCandidates, decodeJwtSubject, defaultReadDesktopAccessToken, desktopTokenToAuthHeader, loadCursorIndividualCredential, normalizeDesktopTokenValue, normalizeWorkosUserId, } from "./cursor-individual-credentials.js";
|
|
13
14
|
const SECRET = "fixture-secret-token-abc123";
|
|
14
15
|
const USER_ID = "user_fixture_abc123";
|
|
15
16
|
/** header.payload.signature with { sub: USER_ID } — no signature verification, so any value works. */
|
|
@@ -20,10 +21,14 @@ function fixtureJwt(sub) {
|
|
|
20
21
|
let dir;
|
|
21
22
|
let savedFileEnv;
|
|
22
23
|
let savedHomeEnv;
|
|
24
|
+
let savedDesktopEnv;
|
|
25
|
+
let savedAppData;
|
|
23
26
|
beforeEach(() => {
|
|
24
27
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-cursor-indiv-creds-"));
|
|
25
28
|
savedFileEnv = process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV];
|
|
26
29
|
savedHomeEnv = process.env[CURSOR_INDIVIDUAL_HOME_ENV];
|
|
30
|
+
savedDesktopEnv = process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV];
|
|
31
|
+
savedAppData = process.env.APPDATA;
|
|
27
32
|
});
|
|
28
33
|
afterEach(() => {
|
|
29
34
|
if (savedFileEnv === undefined)
|
|
@@ -34,6 +39,14 @@ afterEach(() => {
|
|
|
34
39
|
delete process.env[CURSOR_INDIVIDUAL_HOME_ENV];
|
|
35
40
|
else
|
|
36
41
|
process.env[CURSOR_INDIVIDUAL_HOME_ENV] = savedHomeEnv;
|
|
42
|
+
if (savedDesktopEnv === undefined)
|
|
43
|
+
delete process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV];
|
|
44
|
+
else
|
|
45
|
+
process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV] = savedDesktopEnv;
|
|
46
|
+
if (savedAppData === undefined)
|
|
47
|
+
delete process.env.APPDATA;
|
|
48
|
+
else
|
|
49
|
+
process.env.APPDATA = savedAppData;
|
|
37
50
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
38
51
|
});
|
|
39
52
|
function fixture(name, content) {
|
|
@@ -50,7 +63,7 @@ test("absent credential file reads absent with no path", () => {
|
|
|
50
63
|
assert.equal(credential.format, null);
|
|
51
64
|
assert.equal(credential.authHeader, undefined);
|
|
52
65
|
const status = cursorIndividualCredentialStatus();
|
|
53
|
-
assert.deepEqual(status, { present: false, path: null, format: null });
|
|
66
|
+
assert.deepEqual(status, { present: false, source: null, path: null, format: null });
|
|
54
67
|
});
|
|
55
68
|
test("empty home directory reads absent (no real login touched)", () => {
|
|
56
69
|
delete process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV];
|
|
@@ -74,6 +87,7 @@ test("accepted token shapes load with an in-memory auth header", () => {
|
|
|
74
87
|
const file = fixture("auth.json", JSON.stringify(body));
|
|
75
88
|
const credential = loadCursorIndividualCredential();
|
|
76
89
|
assert.equal(credential.status, "found");
|
|
90
|
+
assert.equal(credential.source, "agent");
|
|
77
91
|
assert.equal(credential.path, file);
|
|
78
92
|
assert.equal(credential.format, format);
|
|
79
93
|
assert.equal(credential.authHeader, cursorIndividualAuthHeader(USER_ID, SECRET));
|
|
@@ -183,3 +197,173 @@ test("unreadable files degrade to absent without throwing", () => {
|
|
|
183
197
|
const dirAsFile = loadCursorIndividualCredential((_p) => null);
|
|
184
198
|
assert.equal(dirAsFile.status, "absent");
|
|
185
199
|
});
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// NOT-267: desktop state.vscdb as the primary credential source.
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
/** Build a fixture desktop-state database with exactly the given keys. */
|
|
204
|
+
function fixtureStateDb(entries, name = "state.vscdb") {
|
|
205
|
+
const file = path.join(dir, name);
|
|
206
|
+
const db = new Database(file);
|
|
207
|
+
try {
|
|
208
|
+
db.exec("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)");
|
|
209
|
+
const stmt = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)");
|
|
210
|
+
for (const [key, value] of Object.entries(entries))
|
|
211
|
+
stmt.run(key, value);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
db.close();
|
|
215
|
+
}
|
|
216
|
+
process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV] = file;
|
|
217
|
+
return file;
|
|
218
|
+
}
|
|
219
|
+
/** Desktop tests use the real resolution order: no pinned credential file. */
|
|
220
|
+
function useDefaultSources() {
|
|
221
|
+
delete process.env[CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV];
|
|
222
|
+
process.env[CURSOR_INDIVIDUAL_HOME_ENV] = dir;
|
|
223
|
+
}
|
|
224
|
+
test("desktop state paths resolve per OS without touching the real login", () => {
|
|
225
|
+
const home = path.join(dir, "fake-home");
|
|
226
|
+
assert.deepEqual(cursorIndividualDesktopStateCandidates({ platform: "darwin", home }), [
|
|
227
|
+
path.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"),
|
|
228
|
+
]);
|
|
229
|
+
assert.deepEqual(cursorIndividualDesktopStateCandidates({ platform: "linux", home }), [
|
|
230
|
+
path.join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb"),
|
|
231
|
+
]);
|
|
232
|
+
const appData = path.join(dir, "fake-appdata");
|
|
233
|
+
assert.deepEqual(cursorIndividualDesktopStateCandidates({ platform: "win32", home, appData }), [path.join(appData, "Cursor", "User", "globalStorage", "state.vscdb")]);
|
|
234
|
+
// A present-but-blank APPDATA is the same as unset: the roaming default
|
|
235
|
+
// applies instead of an empty candidate list.
|
|
236
|
+
process.env.APPDATA = " ";
|
|
237
|
+
assert.deepEqual(cursorIndividualDesktopStateCandidates({ platform: "win32", home }), [
|
|
238
|
+
path.join(home, "AppData", "Roaming", "Cursor", "User", "globalStorage", "state.vscdb"),
|
|
239
|
+
]);
|
|
240
|
+
delete process.env.APPDATA;
|
|
241
|
+
// Unknown platforms fall back to the Linux-style layout, never to nothing.
|
|
242
|
+
assert.deepEqual(cursorIndividualDesktopStateCandidates({ platform: "freebsd", home }), [
|
|
243
|
+
path.join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb"),
|
|
244
|
+
]);
|
|
245
|
+
// The explicit override wins on every platform.
|
|
246
|
+
process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV] = path.join(dir, "custom.vscdb");
|
|
247
|
+
assert.deepEqual(cursorIndividualDesktopStateCandidates({ platform: "darwin", home }), [
|
|
248
|
+
path.join(dir, "custom.vscdb"),
|
|
249
|
+
]);
|
|
250
|
+
});
|
|
251
|
+
test("a fixture state.vscdb access token resolves to a desktop credential", () => {
|
|
252
|
+
useDefaultSources();
|
|
253
|
+
const jwt = fixtureJwt(USER_ID);
|
|
254
|
+
// Unrelated state travels alongside the token — including secret-looking
|
|
255
|
+
// values the loader must never select, return, or report.
|
|
256
|
+
const dbFile = fixtureStateDb({
|
|
257
|
+
[CURSOR_DESKTOP_ACCESS_TOKEN_KEY]: jwt,
|
|
258
|
+
"cursorAuth/refreshToken": "fixture-refresh-must-never-leak",
|
|
259
|
+
"cursorAuth/cachedEmail": "someone@example.com",
|
|
260
|
+
"someOtherFeature/state": JSON.stringify({ token: "not-a-cursor-token" }),
|
|
261
|
+
});
|
|
262
|
+
const credential = loadCursorIndividualCredential();
|
|
263
|
+
assert.equal(credential.status, "found");
|
|
264
|
+
assert.equal(credential.source, "desktop");
|
|
265
|
+
assert.equal(credential.path, dbFile);
|
|
266
|
+
assert.equal(credential.format, CURSOR_DESKTOP_FORMAT);
|
|
267
|
+
assert.equal(credential.authHeader, cursorIndividualAuthHeader(USER_ID, jwt));
|
|
268
|
+
const status = cursorIndividualCredentialStatus();
|
|
269
|
+
assert.deepEqual(status, {
|
|
270
|
+
present: true,
|
|
271
|
+
source: "desktop",
|
|
272
|
+
path: dbFile,
|
|
273
|
+
format: CURSOR_DESKTOP_FORMAT,
|
|
274
|
+
});
|
|
275
|
+
const serialized = JSON.stringify({ credential: { ...credential, authHeader: undefined }, status });
|
|
276
|
+
assert.ok(!serialized.includes(jwt), "raw token must not appear outside the in-memory header");
|
|
277
|
+
assert.ok(!serialized.includes("fixture-refresh-must-never-leak"), "unrelated desktop keys must never be read");
|
|
278
|
+
});
|
|
279
|
+
test("desktop SQLite wins over Agent auth.json; Agent auth remains the fallback", () => {
|
|
280
|
+
useDefaultSources();
|
|
281
|
+
const desktopJwt = fixtureJwt("desktop-user-wins");
|
|
282
|
+
const agentJwt = fixtureJwt("agent-user-fallback");
|
|
283
|
+
fixtureStateDb({ [CURSOR_DESKTOP_ACCESS_TOKEN_KEY]: desktopJwt });
|
|
284
|
+
const agentFile = path.join(dir, ".cursor", "auth.json");
|
|
285
|
+
fs.mkdirSync(path.dirname(agentFile), { recursive: true });
|
|
286
|
+
fs.writeFileSync(agentFile, JSON.stringify({ token: agentJwt }));
|
|
287
|
+
// Both sources usable: the desktop login wins.
|
|
288
|
+
let credential = loadCursorIndividualCredential();
|
|
289
|
+
assert.equal(credential.status, "found");
|
|
290
|
+
assert.equal(credential.source, "desktop");
|
|
291
|
+
assert.equal(credential.authHeader, cursorIndividualAuthHeader("desktop-user-wins", desktopJwt));
|
|
292
|
+
// Desktop database gone: the same Agent file now answers as the fallback.
|
|
293
|
+
fs.rmSync(path.join(dir, "state.vscdb"));
|
|
294
|
+
delete process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV];
|
|
295
|
+
process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV] = path.join(dir, "missing.vscdb");
|
|
296
|
+
credential = loadCursorIndividualCredential();
|
|
297
|
+
assert.equal(credential.status, "found");
|
|
298
|
+
assert.equal(credential.source, "agent");
|
|
299
|
+
assert.equal(credential.authHeader, cursorIndividualAuthHeader("agent-user-fallback", agentJwt));
|
|
300
|
+
});
|
|
301
|
+
test("a usable Agent credential still wins over a present-but-unusable desktop token", () => {
|
|
302
|
+
useDefaultSources();
|
|
303
|
+
// Desktop token with no derivable user id (not a JWT): unusable on its
|
|
304
|
+
// own, but it must not sink a working Agent fallback.
|
|
305
|
+
fixtureStateDb({ [CURSOR_DESKTOP_ACCESS_TOKEN_KEY]: "not-a-jwt" });
|
|
306
|
+
const agentFile = path.join(dir, ".cursor", "auth.json");
|
|
307
|
+
fs.mkdirSync(path.dirname(agentFile), { recursive: true });
|
|
308
|
+
fs.writeFileSync(agentFile, JSON.stringify({ token: SECRET, userId: USER_ID }));
|
|
309
|
+
const credential = loadCursorIndividualCredential();
|
|
310
|
+
assert.equal(credential.status, "found");
|
|
311
|
+
assert.equal(credential.source, "agent");
|
|
312
|
+
});
|
|
313
|
+
test("no usable source in either login reads absent (desktop missing, Agent missing)", () => {
|
|
314
|
+
useDefaultSources();
|
|
315
|
+
process.env[CURSOR_INDIVIDUAL_DESKTOP_STATE_FILE_ENV] = path.join(dir, "missing.vscdb");
|
|
316
|
+
const credential = loadCursorIndividualCredential();
|
|
317
|
+
assert.equal(credential.status, "absent");
|
|
318
|
+
assert.equal(credential.source, null);
|
|
319
|
+
assert.equal(credential.authHeader, undefined);
|
|
320
|
+
});
|
|
321
|
+
test("a present-but-unusable desktop login reads unparsable when Agent auth cannot help", () => {
|
|
322
|
+
useDefaultSources();
|
|
323
|
+
const dbFile = fixtureStateDb({ [CURSOR_DESKTOP_ACCESS_TOKEN_KEY]: "not-a-jwt" });
|
|
324
|
+
const credential = loadCursorIndividualCredential();
|
|
325
|
+
assert.equal(credential.status, "unparsable");
|
|
326
|
+
assert.equal(credential.path, dbFile);
|
|
327
|
+
assert.equal(credential.format, CURSOR_DESKTOP_FORMAT);
|
|
328
|
+
assert.equal(credential.authHeader, undefined);
|
|
329
|
+
});
|
|
330
|
+
test("the desktop reader opens read-only, reads one key, and never throws", () => {
|
|
331
|
+
const jwt = fixtureJwt(USER_ID);
|
|
332
|
+
const dbFile = fixtureStateDb({ [CURSOR_DESKTOP_ACCESS_TOKEN_KEY]: jwt });
|
|
333
|
+
assert.equal(defaultReadDesktopAccessToken(dbFile), jwt);
|
|
334
|
+
// Missing database, directory path, and missing key all read null.
|
|
335
|
+
assert.equal(defaultReadDesktopAccessToken(path.join(dir, "missing.vscdb")), null);
|
|
336
|
+
assert.equal(defaultReadDesktopAccessToken(dir), null);
|
|
337
|
+
const noKey = fixtureStateDb({ "something/else": "x" }, "nokey.vscdb");
|
|
338
|
+
assert.equal(defaultReadDesktopAccessToken(noKey), null);
|
|
339
|
+
// The fixture database is untouched and unlocked after the read.
|
|
340
|
+
const db = new Database(noKey);
|
|
341
|
+
try {
|
|
342
|
+
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM ItemTable").get().n, 1);
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
db.close();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
test("normalizeDesktopTokenValue unwraps a JSON-quoted token; empties and non-strings read null", () => {
|
|
349
|
+
const jwt = fixtureJwt(USER_ID);
|
|
350
|
+
assert.equal(normalizeDesktopTokenValue(jwt), jwt);
|
|
351
|
+
assert.equal(normalizeDesktopTokenValue(` ${jwt} `), jwt);
|
|
352
|
+
assert.equal(normalizeDesktopTokenValue(JSON.stringify(jwt)), jwt);
|
|
353
|
+
assert.equal(normalizeDesktopTokenValue(""), null);
|
|
354
|
+
assert.equal(normalizeDesktopTokenValue(" "), null);
|
|
355
|
+
assert.equal(normalizeDesktopTokenValue(42), null);
|
|
356
|
+
assert.equal(normalizeDesktopTokenValue(null), null);
|
|
357
|
+
assert.equal(normalizeDesktopTokenValue('"not-closed'), null);
|
|
358
|
+
// A non-empty unquoted value passes through here — usability (JWT `sub`)
|
|
359
|
+
// is decided downstream by desktopTokenToAuthHeader, never by guessing.
|
|
360
|
+
assert.equal(normalizeDesktopTokenValue("null"), "null");
|
|
361
|
+
assert.equal(desktopTokenToAuthHeader("null"), null);
|
|
362
|
+
});
|
|
363
|
+
test("desktopTokenToAuthHeader derives the WorkOS id from the JWT sub", () => {
|
|
364
|
+
const jwt = fixtureJwt(USER_ID);
|
|
365
|
+
assert.equal(desktopTokenToAuthHeader(jwt), cursorIndividualAuthHeader(USER_ID, jwt));
|
|
366
|
+
assert.equal(desktopTokenToAuthHeader(fixtureJwt("google-oauth2|user_fixture")), cursorIndividualAuthHeader("user_fixture", fixtureJwt("google-oauth2|user_fixture")));
|
|
367
|
+
assert.equal(desktopTokenToAuthHeader("not-a-jwt"), null);
|
|
368
|
+
assert.equal(desktopTokenToAuthHeader(""), null);
|
|
369
|
+
});
|
|
@@ -108,9 +108,16 @@ export function cursorIndividualTimeoutMs() {
|
|
|
108
108
|
}
|
|
109
109
|
return 10_000;
|
|
110
110
|
}
|
|
111
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Operator-safe failure log: the static kind only, never tokens or payloads.
|
|
113
|
+
* A rejected login (401/403) means the local Cursor session the adapter
|
|
114
|
+
* borrowed is expired or revoked — the operator must refresh the Cursor
|
|
115
|
+
* login itself (desktop app or `cursor agent login`); Dealer never mutates
|
|
116
|
+
* Cursor auth, so the hint is static text, not a credential action.
|
|
117
|
+
*/
|
|
112
118
|
function logFailure(kind) {
|
|
113
|
-
|
|
119
|
+
const hint = kind === "forbidden" ? " (refresh the Cursor login and retry — Dealer never touches Cursor auth)" : "";
|
|
120
|
+
console.error(`[cursor-individual-capacity] dashboard read failed: ${kind}${hint}`);
|
|
114
121
|
}
|
|
115
122
|
/** Default reader: no supported surface exists yet — always null. */
|
|
116
123
|
export function defaultSupportedUsageReader() {
|
|
@@ -210,6 +217,12 @@ const USED_PERCENT_KEYS = [
|
|
|
210
217
|
"totalPercentUsed",
|
|
211
218
|
"total_percent_used",
|
|
212
219
|
];
|
|
220
|
+
// NOTE (NOT-267): `apiPercentUsed` is deliberately NOT listed anywhere here.
|
|
221
|
+
// The live payload reports per-pool scales (e.g. an API-model pool) next to
|
|
222
|
+
// the account-total `individualUsage.plan.totalPercentUsed` — substituting a
|
|
223
|
+
// pool scale for the account total would misreport personal capacity, so a
|
|
224
|
+
// payload carrying ONLY a pool scale reads as no usable scale (unparsable),
|
|
225
|
+
// never as the account total.
|
|
213
226
|
const USED_FRACTION_KEYS = ["usedFraction", "used_fraction", "usageFraction", "usage_fraction"];
|
|
214
227
|
const REMAINING_PERCENT_KEYS = [
|
|
215
228
|
"remainingPercent",
|
|
@@ -218,6 +231,34 @@ const REMAINING_PERCENT_KEYS = [
|
|
|
218
231
|
"percent_remaining",
|
|
219
232
|
];
|
|
220
233
|
const REMAINING_FRACTION_KEYS = ["remainingFraction", "remaining_fraction"];
|
|
234
|
+
/**
|
|
235
|
+
* Last-resort scale: the plan's explicit used-vs-limit pair (e.g. spend vs
|
|
236
|
+
* budget), read ONLY when no percent/fraction/remaining scale is present.
|
|
237
|
+
* Money-vs-money and count-vs-count both reduce to the same share — but
|
|
238
|
+
* this never mixes with the percent scales above, and never with a pool
|
|
239
|
+
* scale: only the `individualUsage.plan` pair counts, never merged
|
|
240
|
+
* top-level fields.
|
|
241
|
+
*/
|
|
242
|
+
const USED_AMOUNT_KEYS = [
|
|
243
|
+
"used",
|
|
244
|
+
"usage",
|
|
245
|
+
"usedCredits",
|
|
246
|
+
"used_credits",
|
|
247
|
+
"consumed",
|
|
248
|
+
"consumedCredits",
|
|
249
|
+
"consumed_credits",
|
|
250
|
+
];
|
|
251
|
+
const LIMIT_AMOUNT_KEYS = [
|
|
252
|
+
"limit",
|
|
253
|
+
"quota",
|
|
254
|
+
"allowance",
|
|
255
|
+
"totalLimit",
|
|
256
|
+
"total_limit",
|
|
257
|
+
"limitValue",
|
|
258
|
+
"limit_value",
|
|
259
|
+
"monthlyLimit",
|
|
260
|
+
"monthly_limit",
|
|
261
|
+
];
|
|
221
262
|
function clampPercent(n) {
|
|
222
263
|
return Math.min(100, Math.max(0, n));
|
|
223
264
|
}
|
|
@@ -266,6 +307,19 @@ export function cursorIndividualPayloadToReadings(payload) {
|
|
|
266
307
|
else if (remainingFraction !== null) {
|
|
267
308
|
remainingPercent = clampPercent(remainingFraction * 100);
|
|
268
309
|
}
|
|
310
|
+
else {
|
|
311
|
+
// Last resort: the plan's explicit used/limit pair, read from the plan
|
|
312
|
+
// object ONLY — never from the merged top level, where an unrelated
|
|
313
|
+
// numeric field (e.g. a top-level `usage` value next to a `limit`) could
|
|
314
|
+
// otherwise be misread as the plan's scale. A non-positive limit (or a
|
|
315
|
+
// negative used amount) is not a scale — it reads as no usable value,
|
|
316
|
+
// never a division artifact.
|
|
317
|
+
const usedAmount = plan ? pickNumber(plan, USED_AMOUNT_KEYS) : null;
|
|
318
|
+
const limitAmount = plan ? pickNumber(plan, LIMIT_AMOUNT_KEYS) : null;
|
|
319
|
+
if (usedAmount !== null && limitAmount !== null && limitAmount > 0 && usedAmount >= 0) {
|
|
320
|
+
remainingPercent = clampPercent(100 - (usedAmount / limitAmount) * 100);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
269
323
|
const hasCycle = cycleLabel !== null || cycleStart !== null || cycleEnd !== null;
|
|
270
324
|
const hasUsage = usageValue !== null || remainingPercent !== null;
|
|
271
325
|
if (!hasCycle && !hasUsage)
|
|
@@ -543,9 +597,20 @@ export async function readCursorIndividualBilling(opts = {}) {
|
|
|
543
597
|
const authHeader = credential.authHeader;
|
|
544
598
|
let fatal = null;
|
|
545
599
|
let readings = null;
|
|
600
|
+
// A 200 response this module cannot parse as JSON (`malformed`) is kept
|
|
601
|
+
// distinct from a genuine auth/rate-limit/transport/redirect failure: an
|
|
602
|
+
// undocumented path can serve an HTML SPA shell with a 200 status instead
|
|
603
|
+
// of a 404 for an account it doesn't apply to, so `malformed` is treated
|
|
604
|
+
// like `absent` and the next candidate still gets a try. The first such
|
|
605
|
+
// verdict is remembered as the diagnostic in case no candidate parses.
|
|
606
|
+
let malformedFallback = null;
|
|
546
607
|
for (const candidatePath of CURSOR_INDIVIDUAL_USAGE_PATHS) {
|
|
547
608
|
const outcome = await fetchJson(fetchImpl, `${baseUrl}${candidatePath}`, authHeader, timeoutMs);
|
|
548
609
|
if ("failure" in outcome) {
|
|
610
|
+
if (outcome.failure === "malformed") {
|
|
611
|
+
malformedFallback = malformedFallback ?? outcome.failure;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
549
614
|
// Auth, rate-limit, transport, redirect, and oversize failures dominate
|
|
550
615
|
// over remaining candidates: retrying another undocumented path cannot
|
|
551
616
|
// fix a rejected credential or an unsafe redirect.
|
|
@@ -562,6 +627,8 @@ export async function readCursorIndividualBilling(opts = {}) {
|
|
|
562
627
|
readings = parsed;
|
|
563
628
|
break;
|
|
564
629
|
}
|
|
630
|
+
if (!fatal && !readings)
|
|
631
|
+
fatal = malformedFallback;
|
|
565
632
|
if (fatal) {
|
|
566
633
|
logFailure(fatal);
|
|
567
634
|
const reason = fatal === "malformed" ? "unparsable" : "missing";
|