agent-dealer 1.2.2 → 1.2.4

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.
Files changed (34) hide show
  1. package/bundle/server/dist/capacity/claude-events.js +27 -10
  2. package/bundle/server/dist/capacity/claude-local-cache.js +794 -0
  3. package/bundle/server/dist/capacity/claude-local-cache.test.js +524 -0
  4. package/bundle/server/dist/capacity/cursor-individual-credentials.js +203 -16
  5. package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +186 -2
  6. package/bundle/server/dist/capacity/cursor-individual.js +69 -2
  7. package/bundle/server/dist/capacity/cursor-individual.test.js +268 -2
  8. package/bundle/server/dist/capacity/muse-host.js +778 -0
  9. package/bundle/server/dist/capacity/muse-host.test.js +480 -0
  10. package/bundle/server/dist/capacity/muse-lifecycle.test.js +96 -0
  11. package/bundle/server/dist/capacity/muse-probe.js +250 -0
  12. package/bundle/server/dist/capacity/muse-probe.test.js +183 -0
  13. package/bundle/server/dist/capacity/muse.js +320 -63
  14. package/bundle/server/dist/capacity/muse.test.js +8 -51
  15. package/bundle/server/dist/coordinator/muse-spawn.js +143 -1
  16. package/bundle/server/dist/index.js +15 -2
  17. package/bundle/server/dist/routes/index.js +25 -9
  18. package/bundle/server/dist/routes/runtime-capacity.test.js +20 -7
  19. package/bundle/server/dist/runners/muse-code-jsonl.js +7 -1
  20. package/bundle/server/dist/runners/muse-serve-session.js +314 -0
  21. package/bundle/server/dist/runners/muse-serve-session.test.js +325 -0
  22. package/bundle/server/package.json +2 -2
  23. package/bundle/server/static-ui/assets/codex-YZSpL-SB.png +0 -0
  24. package/bundle/server/static-ui/assets/{index-DUYzboWh.css → index-CYqRh_-S.css} +1 -1
  25. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +60 -0
  26. package/bundle/server/static-ui/assets/muse-CtdcC6ve.svg +33 -0
  27. package/bundle/server/static-ui/index.html +2 -2
  28. package/bundle/shared/package.json +1 -1
  29. package/dist/doctor.d.ts +72 -0
  30. package/dist/doctor.js +235 -1
  31. package/dist/doctor.test.d.ts +1 -0
  32. package/dist/doctor.test.js +276 -0
  33. package/package.json +1 -1
  34. 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 file
8
- // location and JSON shape are themselves not a supported contract and may
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
- // - Candidate files are tried in order; the first readable file wins.
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. Never throws: unreadable files,
181
- * invalid JSON, and unknown shapes all map to `absent`/`unparsable`.
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 { status: "unparsable", path: candidate, format: null };
334
+ return agentUnparsable(candidate, null);
196
335
  }
197
336
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
198
- return { status: "unparsable", path: candidate, format: null };
337
+ return agentUnparsable(candidate, null);
199
338
  }
200
339
  const picked = pickToken(parsed);
201
340
  if (!picked)
202
- return { status: "unparsable", path: candidate, format: null };
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 { status: "unparsable", path: candidate, format: picked.key };
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 { status: "unparsable", path: candidate, format: picked.key };
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 { CURSOR_INDIVIDUAL_CREDENTIAL_FILE_ENV, CURSOR_INDIVIDUAL_HOME_ENV, cursorIndividualAuthHeader, cursorIndividualCredentialCandidates, cursorIndividualCredentialStatus, decodeJwtSubject, loadCursorIndividualCredential, normalizeWorkosUserId, } from "./cursor-individual-credentials.js";
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
- /** Operator-safe failure log: the static kind only, never tokens or payloads. */
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
- console.error(`[cursor-individual-capacity] dashboard read failed: ${kind}`);
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";