@scrylog/cli 0.2.0 → 0.3.0

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 (94) hide show
  1. package/README.md +46 -7
  2. package/package.json +13 -10
  3. package/src/api/docs/sessions.ts +3 -2
  4. package/src/api/docs/system.ts +3 -0
  5. package/src/api/docs/usage.ts +2 -2
  6. package/src/api/routes/sessions.ts +6 -2
  7. package/src/api/routes/system.ts +4 -1
  8. package/src/api/routes/webhooks/claude.ts +8 -0
  9. package/src/api/routes/webhooks/opencode.ts +1 -0
  10. package/src/api/schemas/common.ts +8 -0
  11. package/src/api/schemas/sessions.ts +15 -1
  12. package/src/api/schemas/usage.ts +1 -0
  13. package/src/cli/commands.ts +85 -3
  14. package/src/cli/index.ts +18 -0
  15. package/src/cli/program.ts +175 -0
  16. package/src/daemon/collectors/claude/collector.ts +1 -1
  17. package/src/daemon/collectors/claude/discovery.ts +2 -23
  18. package/src/daemon/collectors/codex/collector.ts +3 -3
  19. package/src/daemon/collectors/git.ts +60 -2
  20. package/src/daemon/collectors/herdr/collector.ts +128 -0
  21. package/src/daemon/collectors/herdr/index.ts +2 -0
  22. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  23. package/src/daemon/collectors/opencode-db.ts +17 -5
  24. package/src/daemon/collectors/tmux/collector.ts +21 -1
  25. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  26. package/src/daemon/collectors/usage.ts +115 -49
  27. package/src/daemon/index.ts +2 -0
  28. package/src/daemon/store.ts +44 -2
  29. package/src/postinstall.cjs +11 -0
  30. package/src/shared/config.ts +33 -0
  31. package/src/shared/session-exclusions.ts +5 -0
  32. package/src/shared/session-view.ts +67 -6
  33. package/src/shared/terminal-capabilities.ts +130 -0
  34. package/src/shared/types.ts +33 -2
  35. package/src/shared/version.ts +44 -0
  36. package/src/tui/App.tsx +240 -0
  37. package/src/tui/assets/providers/SOURCES.md +21 -0
  38. package/src/tui/assets/providers/claude-terminal.png +0 -0
  39. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  40. package/src/tui/assets/providers/claude.png +0 -0
  41. package/src/tui/assets/providers/copilot.png +0 -0
  42. package/src/tui/assets/providers/copilot.svg +44 -0
  43. package/src/tui/assets/providers/openai-terminal.png +0 -0
  44. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  45. package/src/tui/assets/providers/openai.png +0 -0
  46. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  47. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  48. package/src/tui/assets/providers/opencode.png +0 -0
  49. package/src/tui/assets/providers/shell.png +0 -0
  50. package/src/tui/assets/providers/shell.svg +23 -0
  51. package/src/tui/assets/providers/unknown.png +0 -0
  52. package/src/tui/assets/providers/unknown.svg +23 -0
  53. package/src/tui/client.ts +22 -8
  54. package/src/tui/components/BindingsModal.tsx +82 -0
  55. package/src/tui/components/DashboardHeader.tsx +256 -0
  56. package/src/tui/components/KeyBar.tsx +67 -0
  57. package/src/tui/components/SessionDetailModal.tsx +169 -0
  58. package/src/tui/components/SessionsView.tsx +184 -0
  59. package/src/tui/components/TabBar.tsx +40 -0
  60. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  61. package/src/tui/components/UsageView.tsx +242 -0
  62. package/src/tui/components/tool-icons.ts +24 -0
  63. package/src/tui/index.tsx +106 -0
  64. package/src/tui/keybindings.ts +49 -0
  65. package/src/tui/model.ts +295 -0
  66. package/src/tui/state.ts +77 -0
  67. package/src/tui/theme.ts +110 -0
  68. package/src/usage/README.md +3 -3
  69. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  70. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  71. package/src/usage/clients/claude-cli-probe.ts +458 -132
  72. package/src/usage/clients/codex-app-server.ts +354 -0
  73. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  74. package/src/usage/clients/opencode-go-api.ts +100 -0
  75. package/src/usage/clients/opencode-go-usage.ts +163 -27
  76. package/src/usage/errors.ts +33 -0
  77. package/src/usage/local/opencode-go-local.ts +194 -0
  78. package/src/usage/mappers.ts +24 -15
  79. package/src/usage/planner.ts +89 -25
  80. package/src/usage/providers/claude.ts +203 -83
  81. package/src/usage/providers/codex.ts +92 -11
  82. package/src/usage/providers/opencode-go.ts +94 -16
  83. package/src/usage/resolvers/browser-cookies.ts +407 -69
  84. package/src/usage/resolvers/claude-auth.ts +140 -40
  85. package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
  86. package/src/usage/resolvers/claude-web.ts +72 -6
  87. package/src/usage/resolvers/codex-auth.ts +74 -17
  88. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  89. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  90. package/src/usage/types.ts +37 -2
  91. package/src/usage/web-session-cache.ts +37 -0
  92. package/src/neo-blessed.d.ts +0 -4
  93. package/src/tui/app.ts +0 -920
  94. package/src/tui/index.ts +0 -217
@@ -1,21 +1,28 @@
1
- import * as child_process from "node:child_process";
2
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
2
  import { homedir } from "node:os";
4
3
  import { createDecipheriv, pbkdf2Sync } from "node:crypto";
5
4
  import { join } from "node:path";
6
5
  import { Database } from "bun:sqlite";
7
6
  import { IS_MAC, IS_WINDOWS } from "../../shared/platform";
7
+ import { keychainCoordinator, type KeychainCoordinator } from "./keychain-coordinator";
8
8
 
9
9
  export interface BrowserCookie {
10
10
  name: string;
11
11
  value: string;
12
12
  }
13
13
 
14
+ export interface BrowserCookieCandidate {
15
+ browser: string;
16
+ profile: string;
17
+ database: string;
18
+ cookies: BrowserCookie[];
19
+ }
20
+
14
21
  // ---------------------------------------------------------------------------
15
22
  // Browser catalog types
16
23
  // ---------------------------------------------------------------------------
17
24
 
18
- interface ChromiumProfileEntry {
25
+ export interface ChromiumProfileEntry {
19
26
  /** Unique browser ID, e.g. "chrome", "brave", "claude-desktop" */
20
27
  id: string;
21
28
  /** Relative path under ~/Library/Application Support/ on macOS */
@@ -28,9 +35,21 @@ interface ChromiumProfileEntry {
28
35
  keychainService?: string;
29
36
  /** macOS Keychain account label, e.g. "Chrome" */
30
37
  keychainAccount?: string;
38
+ /**
39
+ * Top-level cookie database file (relative to the resolved platform root)
40
+ * attempted when the root has no profile subdirectories. Claude Desktop
41
+ * keeps its Chromium cookie database at the application support root.
42
+ */
43
+ rootCookiePath?: string;
44
+ /**
45
+ * Apply the Chrome v24+ 32-byte domain-hash strip even when the database
46
+ * lacks version metadata. Required for the Claude Desktop database, whose
47
+ * decrypted values carry the strip but report no meta version.
48
+ */
49
+ forceDomainHashStrip?: boolean;
31
50
  }
32
51
 
33
- interface GeckoProfileEntry {
52
+ export interface GeckoProfileEntry {
34
53
  /** Unique browser ID, e.g. "firefox", "zen" */
35
54
  id: string;
36
55
  /**
@@ -103,13 +122,18 @@ export const CHROMIUM_PROFILES: ChromiumProfileEntry[] = [
103
122
  keychainAccount: "Yandex",
104
123
  },
105
124
  {
106
- // Claude Desktop app (Electron/Chromium) — macOS and Windows only
125
+ // Claude Desktop app (Electron/Chromium) — macOS and Windows only.
126
+ // Keeps its Chromium cookie database at the application support root with
127
+ // no profile subdirectories; its encrypted values carry the Chrome v24+
128
+ // 32-byte domain-hash prefix despite reporting no meta version.
107
129
  id: "claude-desktop",
108
130
  macOSPath: "Claude",
109
131
  linuxPath: "", // not available on Linux
110
132
  windowsPath: "Anthropic/Claude",
111
133
  keychainService: "Claude Safe Storage",
112
134
  keychainAccount: "Claude",
135
+ rootCookiePath: "Cookies",
136
+ forceDomainHashStrip: true,
113
137
  },
114
138
  ];
115
139
 
@@ -150,7 +174,19 @@ function geckoProfilesRoot(entry: GeckoProfileEntry): string {
150
174
  return "";
151
175
  }
152
176
  // Linux
153
- return join(home, ".mozilla", entry.profilesFolder, "Profiles");
177
+ if (entry.id === "firefox") return join(home, ".mozilla", "firefox");
178
+ if (entry.id === "zen") return join(home, ".zen");
179
+ return join(home, ".mozilla", entry.profilesFolder.toLowerCase());
180
+ }
181
+
182
+ export function cookieDomainMatches(host: string, domain: string): boolean {
183
+ const normalizedHost = host.trim().toLowerCase().replace(/^\./, "");
184
+ const normalizedDomain = domain.trim().toLowerCase().replace(/^\./, "");
185
+ return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`);
186
+ }
187
+
188
+ function cookieNameAllowed(name: string, allowedNames?: ReadonlySet<string>): boolean {
189
+ return !allowedNames || allowedNames.has(name);
154
190
  }
155
191
 
156
192
  // ---------------------------------------------------------------------------
@@ -159,23 +195,24 @@ function geckoProfilesRoot(entry: GeckoProfileEntry): string {
159
195
 
160
196
  /**
161
197
  * Derives the Chromium AES-128-CBC key for a given Keychain service label.
162
- * macOS: reads from Keychain via `security` then applies PBKDF2.
198
+ * macOS: reads through the Keychain coordinator (async, single-flight, cooldown).
163
199
  * Linux/Windows: uses the fixed "peanuts" PBKDF2 key (no Keychain).
164
200
  */
165
- export function getChromiumSafeStorageKey(service: string, _account: string): Buffer | null {
201
+ export async function getChromiumSafeStorageKey(
202
+ service: string,
203
+ account: string,
204
+ coordinator: KeychainCoordinator = keychainCoordinator,
205
+ ): Promise<Buffer | null> {
166
206
  try {
167
207
  if (IS_MAC) {
168
- const password = child_process.execSync(`security find-generic-password -s "${service}" -w`, {
169
- encoding: "utf8",
170
- stdio: ["pipe", "pipe", "pipe"],
171
- }).trim();
208
+ const password = await coordinator.read(service, account);
172
209
  if (!password) return null;
173
210
  return pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
174
211
  }
175
212
 
176
213
  if (IS_WINDOWS) {
177
- // DPAPI not implemented; fall back to peanuts key (partial compatibility)
178
- return pbkdf2Sync("peanuts", "saltysalt", 1003, 16, "sha1");
214
+ // Chromium uses DPAPI on Windows; plaintext rows can still be imported.
215
+ return null;
179
216
  }
180
217
 
181
218
  // Linux
@@ -264,70 +301,180 @@ function decryptChromiumCookie(encrypted: Buffer, key: Buffer, dbVersion = 0): s
264
301
  return raw.toString("utf8");
265
302
  }
266
303
 
267
- export function readChromiumCookies(entry: ChromiumProfileEntry, domain: string): BrowserCookie[] {
268
- const cookies: BrowserCookie[] = [];
269
- const rootPath = chromiumRootPath(entry);
270
- if (!rootPath || !existsSync(rootPath)) return cookies;
304
+ export interface ChromiumCookieRows {
305
+ dbVersion: number;
306
+ plaintext: BrowserCookie[];
307
+ encrypted: Array<{ name: string; encrypted: Buffer }>;
308
+ }
271
309
 
272
- // Resolve decryption key only if we found a root (avoids Keychain prompts for absent browsers)
273
- const key = getChromiumSafeStorageKey(
274
- entry.keychainService ?? "",
275
- entry.keychainAccount ?? ""
276
- );
310
+ /**
311
+ * Reads and filters target cookie rows from a Chromium database without any
312
+ * Safe Storage key request. Rows are partitioned into plaintext values and
313
+ * encrypted-only rows so callers can decide whether a key read is warranted.
314
+ */
315
+ export function readChromiumCookieRows(
316
+ dbPath: string,
317
+ domain: string,
318
+ allowedNames?: ReadonlySet<string>,
319
+ options?: { forceDomainHashStrip?: boolean },
320
+ ): ChromiumCookieRows {
321
+ const rows: ChromiumCookieRows = { dbVersion: 0, plaintext: [], encrypted: [] };
322
+ let db: Database | undefined;
323
+ try {
324
+ db = new Database(dbPath, { readonly: true });
325
+
326
+ try {
327
+ const versionRow = db.query("SELECT value FROM meta WHERE key = 'version'").get() as {
328
+ value: number;
329
+ } | null;
330
+ if (versionRow) rows.dbVersion = Number(versionRow.value);
331
+ } catch {
332
+ // Older databases may not have a meta table.
333
+ }
334
+ if (options?.forceDomainHashStrip && rows.dbVersion < 24) {
335
+ rows.dbVersion = 24;
336
+ }
337
+
338
+ const columns = db.query("PRAGMA table_info(cookies)").all() as Array<{ name: string }>;
339
+ const hasPlaintextValue = columns.some((column) => column.name === "value");
340
+ const valueColumn = hasPlaintextValue ? "value" : "'' AS value";
341
+ const rawRows = db.query(
342
+ `SELECT name, encrypted_value, ${valueColumn}, host_key, expires_utc FROM cookies WHERE host_key LIKE ?`,
343
+ ).all(`%${domain}`) as Array<{
344
+ name: string;
345
+ encrypted_value: Uint8Array | null;
346
+ value: string | null;
347
+ host_key: string;
348
+ expires_utc: number;
349
+ }>;
350
+ // expires_utc is microseconds since the Chrome epoch (1601-01-01).
351
+ const now = (Date.now() / 1000 + 11644473600) * 1_000_000;
352
+
353
+ for (const row of rawRows) {
354
+ if (!cookieDomainMatches(row.host_key, domain) || !cookieNameAllowed(row.name, allowedNames)) {
355
+ continue;
356
+ }
357
+ const expiresUtc = Number(row.expires_utc);
358
+ if (expiresUtc > 0 && expiresUtc <= now) continue;
359
+
360
+ const plaintext = row.value || null;
361
+ if (plaintext !== null) {
362
+ rows.plaintext.push({ name: row.name, value: plaintext });
363
+ continue;
364
+ }
365
+ const encrypted = row.encrypted_value ? Buffer.from(row.encrypted_value) : null;
366
+ if (encrypted) rows.encrypted.push({ name: row.name, encrypted });
367
+ }
368
+ } catch {
369
+ // Ignore permission errors, locked DB (SQLITE_BUSY), and incompatible schemas.
370
+ } finally {
371
+ db?.close();
372
+ }
373
+ return rows;
374
+ }
375
+
376
+ function resolveChromiumCookies(rows: ChromiumCookieRows, key: Buffer | null): BrowserCookie[] {
377
+ const cookies = [...rows.plaintext];
277
378
  if (!key) return cookies;
379
+ for (const pending of rows.encrypted) {
380
+ const value = decryptChromiumCookie(pending.encrypted, key, rows.dbVersion);
381
+ if (value !== null) cookies.push({ name: pending.name, value });
382
+ }
383
+ return cookies;
384
+ }
385
+
386
+ export function readChromiumCookieDatabase(
387
+ dbPath: string,
388
+ domain: string,
389
+ key: Buffer | null,
390
+ allowedNames?: ReadonlySet<string>,
391
+ ): BrowserCookie[] {
392
+ return resolveChromiumCookies(readChromiumCookieRows(dbPath, domain, allowedNames), key);
393
+ }
394
+
395
+ /**
396
+ * Reads one Chromium profile database as an isolated candidate. A Safe Storage
397
+ * key is requested through the coordinator only when filtered rows contain an
398
+ * encrypted target cookie; plaintext rows and absent targets never trigger one.
399
+ */
400
+ export async function readChromiumCookieCandidate(
401
+ profilePath: string,
402
+ dbPath: string,
403
+ entry: ChromiumProfileEntry,
404
+ domain: string,
405
+ allowedNames?: ReadonlySet<string>,
406
+ coordinator: KeychainCoordinator = keychainCoordinator,
407
+ ): Promise<BrowserCookieCandidate | null> {
408
+ const rows = readChromiumCookieRows(dbPath, domain, allowedNames, {
409
+ forceDomainHashStrip: entry.forceDomainHashStrip,
410
+ });
411
+ if (rows.plaintext.length === 0 && rows.encrypted.length === 0) return null;
412
+
413
+ let cookies = rows.plaintext;
414
+ if (rows.encrypted.length > 0) {
415
+ const key = await getChromiumSafeStorageKey(entry.keychainService ?? "", entry.keychainAccount ?? "", coordinator);
416
+ if (!key) {
417
+ return rows.plaintext.length > 0
418
+ ? { browser: entry.id, profile: profilePath, database: dbPath, cookies: rows.plaintext }
419
+ : null;
420
+ }
421
+ cookies = resolveChromiumCookies(rows, key);
422
+ }
423
+ if (cookies.length === 0) return null;
424
+ return { browser: entry.id, profile: profilePath, database: dbPath, cookies };
425
+ }
278
426
 
427
+ export async function readChromiumCookieCandidates(
428
+ entry: ChromiumProfileEntry,
429
+ domain: string,
430
+ allowedNames?: ReadonlySet<string>,
431
+ coordinator: KeychainCoordinator = keychainCoordinator,
432
+ ): Promise<BrowserCookieCandidate[]> {
433
+ const candidates: BrowserCookieCandidate[] = [];
434
+ const rootPath = chromiumRootPath(entry);
435
+ if (!rootPath || !existsSync(rootPath)) return candidates;
436
+
437
+ // No Safe Storage key is requested here: each profile database requests the
438
+ // key lazily through readChromiumCookieCandidate only when an encrypted
439
+ // target cookie survives filtering.
279
440
  const profiles = discoverChromiumProfiles(rootPath);
280
441
  for (const profilePath of profiles) {
281
442
  for (const dbPath of chromiumCookieDbPaths(profilePath)) {
282
443
  if (!existsSync(dbPath)) continue;
283
- let db: Database | undefined;
284
- try {
285
- db = new Database(dbPath, { readonly: true });
286
-
287
- // Chrome v24+ prepends a SHA256 hash (32 bytes) to encrypted cookie values
288
- let dbVersion = 0;
289
- try {
290
- const versionStmt = db.query("SELECT value FROM meta WHERE key = 'version'");
291
- const versionRow = versionStmt.get() as { value: number } | null;
292
- if (versionRow) dbVersion = Number(versionRow.value);
293
- } catch {
294
- // meta table may not exist in older DBs
295
- }
296
-
297
- const stmt = db.query(
298
- "SELECT name, encrypted_value, host_key FROM cookies WHERE host_key LIKE ? AND expires_utc > ?"
299
- );
300
- const now = (Date.now() + 11644473600000) * 1000; // Chrome epoch offset
301
- const rows = stmt.all(`%${domain}%`, now) as Array<{
302
- name: string;
303
- encrypted_value: Uint8Array;
304
- host_key: string;
305
- }>;
306
- for (const row of rows) {
307
- const value = decryptChromiumCookie(Buffer.from(row.encrypted_value), key, dbVersion);
308
- if (value !== null) {
309
- cookies.push({ name: row.name, value });
310
- }
311
- }
312
- } catch {
313
- // Ignore permission errors, locked DB (SQLITE_BUSY), etc.
314
- } finally {
315
- db?.close();
316
- }
444
+ const candidate = await readChromiumCookieCandidate(profilePath, dbPath, entry, domain, allowedNames, coordinator);
445
+ if (candidate) candidates.push(candidate);
446
+ }
447
+ }
448
+ // Root-level cookie databases (Claude Desktop): attempt when the root holds
449
+ // no profile subdirectories.
450
+ if (profiles.length === 0 && entry.rootCookiePath) {
451
+ const rootDb = join(rootPath, entry.rootCookiePath);
452
+ if (existsSync(rootDb)) {
453
+ const candidate = await readChromiumCookieCandidate(rootPath, rootDb, entry, domain, allowedNames, coordinator);
454
+ if (candidate) candidates.push(candidate);
317
455
  }
318
456
  }
319
457
 
320
- return cookies;
458
+ return candidates;
459
+ }
460
+
461
+ export async function readChromiumCookies(entry: ChromiumProfileEntry, domain: string): Promise<BrowserCookie[]> {
462
+ const candidates = await readChromiumCookieCandidates(entry, domain);
463
+ return candidates.flatMap((candidate) => candidate.cookies);
321
464
  }
322
465
 
323
466
  // ---------------------------------------------------------------------------
324
467
  // Gecko (Firefox/Zen) support
325
468
  // ---------------------------------------------------------------------------
326
469
 
327
- export function readGeckoCookies(entry: GeckoProfileEntry, domain: string): BrowserCookie[] {
328
- const cookies: BrowserCookie[] = [];
470
+ export function readGeckoCookieCandidates(
471
+ entry: GeckoProfileEntry,
472
+ domain: string,
473
+ allowedNames?: ReadonlySet<string>,
474
+ ): BrowserCookieCandidate[] {
475
+ const candidates: BrowserCookieCandidate[] = [];
329
476
  const profilesRoot = geckoProfilesRoot(entry);
330
- if (!profilesRoot || !existsSync(profilesRoot)) return cookies;
477
+ if (!profilesRoot || !existsSync(profilesRoot)) return candidates;
331
478
 
332
479
  let profileNames: string[];
333
480
  try {
@@ -341,7 +488,7 @@ export function readGeckoCookies(entry: GeckoProfileEntry, domain: string): Brow
341
488
  return bScore - aScore;
342
489
  });
343
490
  } catch {
344
- return cookies;
491
+ return candidates;
345
492
  }
346
493
 
347
494
  for (const profileName of profileNames) {
@@ -350,12 +497,22 @@ export function readGeckoCookies(entry: GeckoProfileEntry, domain: string): Brow
350
497
  let db: Database | undefined;
351
498
  try {
352
499
  db = new Database(dbPath, { readonly: true });
353
- const stmt = db.query("SELECT name, value FROM moz_cookies WHERE host LIKE ?");
354
- const rows = stmt.all(`%${domain}%`) as Array<{ name: string; value: string }>;
500
+ const stmt = db.query("SELECT name, value, host, expiry FROM moz_cookies WHERE host LIKE ?");
501
+ const rows = stmt.all(`%${domain}`) as Array<{
502
+ name: string;
503
+ value: string;
504
+ host: string;
505
+ expiry: number;
506
+ }>;
507
+ const cookies: BrowserCookie[] = [];
355
508
  for (const row of rows) {
509
+ if (!cookieDomainMatches(row.host, domain) || !cookieNameAllowed(row.name, allowedNames)) continue;
510
+ if (Number(row.expiry) > 0 && Number(row.expiry) <= Date.now() / 1000) continue;
356
511
  cookies.push({ name: row.name, value: row.value });
357
512
  }
358
- if (cookies.length > 0) break; // first-profile-wins within a browser
513
+ if (cookies.length > 0) {
514
+ candidates.push({ browser: entry.id, profile: join(profilesRoot, profileName), database: dbPath, cookies });
515
+ }
359
516
  } catch {
360
517
  // Ignore locked or corrupt DB
361
518
  } finally {
@@ -363,7 +520,11 @@ export function readGeckoCookies(entry: GeckoProfileEntry, domain: string): Brow
363
520
  }
364
521
  }
365
522
 
366
- return cookies;
523
+ return candidates;
524
+ }
525
+
526
+ export function readGeckoCookies(entry: GeckoProfileEntry, domain: string): BrowserCookie[] {
527
+ return readGeckoCookieCandidates(entry, domain).flatMap((candidate) => candidate.cookies);
367
528
  }
368
529
 
369
530
  // ---------------------------------------------------------------------------
@@ -413,7 +574,7 @@ function readSafariCookies(domain: string): BrowserCookie[] {
413
574
  const name = data.toString("utf8", cookieOffset + nameOffset, cookieOffset + pathOffset - 1);
414
575
  const value = data.toString("utf8", cookieOffset + valueOffset, cookieOffset + size);
415
576
 
416
- if (url.includes(domain)) {
577
+ if (cookieDomainMatches(url, domain)) {
417
578
  cookies.push({ name, value });
418
579
  }
419
580
 
@@ -435,7 +596,7 @@ function readSafariCookies(domain: string): BrowserCookie[] {
435
596
 
436
597
  /**
437
598
  * Extract cookies for the given domain from all available browser sources.
438
- * Sources are tried in order that minimises Keychain prompts:
599
+ * Sources are tried in an order that minimises Keychain prompts:
439
600
  * macOS: Gecko (Firefox/Zen) → Safari → Chromium catalog
440
601
  * Linux: Gecko → Chromium catalog
441
602
  * Windows: Gecko (limited) → Chromium catalog
@@ -443,7 +604,7 @@ function readSafariCookies(domain: string): BrowserCookie[] {
443
604
  * Returns the first source that yields ≥1 matching cookie; subsequent sources
444
605
  * are skipped (first-source-wins).
445
606
  */
446
- export function extractCookies(domain: string): BrowserCookie[] {
607
+ export async function extractCookies(domain: string): Promise<BrowserCookie[]> {
447
608
  // 1. Gecko sources first — no Keychain prompt, cheap read
448
609
  for (const entry of GECKO_PROFILES) {
449
610
  const cookies = readGeckoCookies(entry, domain);
@@ -458,13 +619,190 @@ export function extractCookies(domain: string): BrowserCookie[] {
458
619
 
459
620
  // 3. Chromium catalog — may trigger Keychain prompts on macOS
460
621
  for (const entry of CHROMIUM_PROFILES) {
461
- const cookies = readChromiumCookies(entry, domain);
622
+ const cookies = await readChromiumCookies(entry, domain);
462
623
  if (cookies.length > 0) return cookies;
463
624
  }
464
625
 
465
626
  return [];
466
627
  }
467
628
 
629
+ export interface BrowserCookieCursorOptions {
630
+ /** Browser catalog ids to exclude from Chromium scans (e.g. claude-desktop). */
631
+ excludeBrowsers?: ReadonlySet<string>;
632
+ /** Test seam: override the Safari cookie reader. */
633
+ safariReader?: (domain: string) => BrowserCookie[];
634
+ /** Test seam: override the Chromium root path resolution per browser id. */
635
+ chromiumRootResolver?: (entry: ChromiumProfileEntry) => string;
636
+ /** Test seam: use an isolated Keychain coordinator. */
637
+ coordinator?: KeychainCoordinator;
638
+ /**
639
+ * Catalog entry id attempted before Gecko (Claude Desktop for the claude.ai
640
+ * cursor). Other cursors leave this unset and keep the standard order.
641
+ */
642
+ preferredBrowserId?: string;
643
+ }
644
+
645
+ /**
646
+ * Demand-driven browser cookie candidate iterator. Each `next()` call inspects
647
+ * exactly one source (Gecko profile set, Safari, or one Chromium profile
648
+ * database) and never inspects later sources speculatively. A Safe Storage key
649
+ * is requested only when the inspected database holds an encrypted target
650
+ * cookie.
651
+ */
652
+ export class BrowserCookieSourceCursor {
653
+ private readonly safariReader: (domain: string) => BrowserCookie[];
654
+ private readonly chromiumRootResolver: (entry: ChromiumProfileEntry) => string;
655
+ private readonly coordinator: KeychainCoordinator;
656
+ private readonly preferredEntry?: ChromiumProfileEntry;
657
+ private geckoIndex = 0;
658
+ private safariChecked = false;
659
+ private chromiumIndex = 0;
660
+ private preferredQueue: Array<{ entry: ChromiumProfileEntry; profilePath: string; dbPath?: string }> = [];
661
+ private preferredQueueBuilt = false;
662
+ private chromiumQueue: Array<{ entry: ChromiumProfileEntry; profilePath: string; dbPath?: string }> = [];
663
+
664
+ constructor(
665
+ private readonly domain: string,
666
+ private readonly allowedCookieNames?: readonly string[],
667
+ private readonly options: BrowserCookieCursorOptions = {},
668
+ ) {
669
+ this.safariReader = options.safariReader ?? readSafariCookies;
670
+ this.chromiumRootResolver = options.chromiumRootResolver ?? chromiumRootPath;
671
+ this.coordinator = options.coordinator ?? keychainCoordinator;
672
+ if (options.preferredBrowserId) {
673
+ this.preferredEntry = CHROMIUM_PROFILES.find((entry) => entry.id === options.preferredBrowserId);
674
+ }
675
+ }
676
+
677
+ private async pushPreferredSources(
678
+ entry: ChromiumProfileEntry,
679
+ ): Promise<void> {
680
+ const rootPath = this.chromiumRootResolver(entry);
681
+ if (!rootPath || !existsSync(rootPath)) return;
682
+ for (const profilePath of discoverChromiumProfiles(rootPath)) {
683
+ this.preferredQueue.push({ entry, profilePath });
684
+ }
685
+ if (this.preferredQueue.length === 0 && entry.rootCookiePath) {
686
+ const rootDb = join(rootPath, entry.rootCookiePath);
687
+ if (existsSync(rootDb)) {
688
+ this.preferredQueue.push({ entry, profilePath: rootPath, dbPath: rootDb });
689
+ }
690
+ }
691
+ }
692
+
693
+ private async nextPreferred(): Promise<BrowserCookieCandidate | null> {
694
+ if (!this.preferredEntry) return null;
695
+ if (!this.preferredQueueBuilt) {
696
+ this.preferredQueueBuilt = true;
697
+ await this.pushPreferredSources(this.preferredEntry);
698
+ }
699
+ while (this.preferredQueue.length > 0) {
700
+ const { entry, profilePath, dbPath } = this.preferredQueue.shift()!;
701
+ for (const path of dbPath ? [dbPath] : chromiumCookieDbPaths(profilePath)) {
702
+ if (!existsSync(path)) continue;
703
+ const candidate = await readChromiumCookieCandidate(
704
+ profilePath,
705
+ path,
706
+ entry,
707
+ this.domain,
708
+ this.allowedCookieNames ? new Set(this.allowedCookieNames) : undefined,
709
+ this.coordinator,
710
+ );
711
+ if (candidate) return candidate;
712
+ }
713
+ }
714
+ return null;
715
+ }
716
+
717
+ async next(): Promise<BrowserCookieCandidate | null> {
718
+ const allowedNames = this.allowedCookieNames ? new Set(this.allowedCookieNames) : undefined;
719
+
720
+ const preferred = await this.nextPreferred();
721
+ if (preferred) return preferred;
722
+
723
+ while (this.geckoIndex < GECKO_PROFILES.length) {
724
+ const entry = GECKO_PROFILES[this.geckoIndex++]!;
725
+ const candidates = readGeckoCookieCandidates(entry, this.domain, allowedNames);
726
+ if (candidates.length > 0) return candidates[0]!;
727
+ }
728
+
729
+ if (IS_MAC && !this.safariChecked) {
730
+ this.safariChecked = true;
731
+ const cookies = this.safariReader(this.domain).filter((cookie) => cookieNameAllowed(cookie.name, allowedNames));
732
+ if (cookies.length > 0) {
733
+ return { browser: "safari", profile: "default", database: SAFARI_COOKIE_PATH, cookies };
734
+ }
735
+ }
736
+
737
+ for (;;) {
738
+ if (this.chromiumQueue.length === 0) {
739
+ const entry = CHROMIUM_PROFILES[this.chromiumIndex++];
740
+ if (!entry) return null;
741
+ if (this.options.excludeBrowsers?.has(entry.id)) continue;
742
+ if (this.preferredEntry && entry.id === this.preferredEntry.id) continue;
743
+ const rootPath = this.chromiumRootResolver(entry);
744
+ if (!rootPath || !existsSync(rootPath)) continue;
745
+ for (const profilePath of discoverChromiumProfiles(rootPath)) {
746
+ this.chromiumQueue.push({ entry, profilePath });
747
+ }
748
+ // Root-level cookie databases (Claude Desktop): attempt when the root
749
+ // holds no profile subdirectories at all.
750
+ if (this.chromiumQueue.length === 0 && entry.rootCookiePath) {
751
+ const rootDb = join(rootPath, entry.rootCookiePath);
752
+ if (existsSync(rootDb)) {
753
+ this.chromiumQueue.push({ entry, profilePath: rootPath, dbPath: rootDb });
754
+ }
755
+ }
756
+ continue;
757
+ }
758
+ const { entry, profilePath, dbPath } = this.chromiumQueue.shift()!;
759
+ for (const path of dbPath ? [dbPath] : chromiumCookieDbPaths(profilePath)) {
760
+ if (!existsSync(path)) continue;
761
+ const candidate = await readChromiumCookieCandidate(
762
+ profilePath,
763
+ path,
764
+ entry,
765
+ this.domain,
766
+ allowedNames,
767
+ this.coordinator,
768
+ );
769
+ if (candidate) return candidate;
770
+ }
771
+ }
772
+ }
773
+ }
774
+
775
+ export async function extractCookieCandidates(
776
+ domain: string,
777
+ allowedCookieNames?: readonly string[],
778
+ options: BrowserCookieCursorOptions = {},
779
+ ): Promise<BrowserCookieCandidate[]> {
780
+ const cursor = new BrowserCookieSourceCursor(domain, allowedCookieNames, options);
781
+ const candidates: BrowserCookieCandidate[] = [];
782
+ for (let candidate = await cursor.next(); candidate !== null; candidate = await cursor.next()) {
783
+ candidates.push(candidate);
784
+ }
785
+ return candidates;
786
+ }
787
+
788
+ /**
789
+ * Cheap import-source existence check: no SQLite reads and no Keychain
790
+ * requests. Used by planning to decide whether a deferred browser import
791
+ * could yield candidates at all.
792
+ */
793
+ export function hasBrowserCookieSources(): boolean {
794
+ for (const entry of GECKO_PROFILES) {
795
+ const root = geckoProfilesRoot(entry);
796
+ if (root && existsSync(root)) return true;
797
+ }
798
+ if (IS_MAC && existsSync(SAFARI_COOKIE_PATH)) return true;
799
+ for (const entry of CHROMIUM_PROFILES) {
800
+ const root = chromiumRootPath(entry);
801
+ if (root && existsSync(root)) return true;
802
+ }
803
+ return false;
804
+ }
805
+
468
806
  export function buildCookieHeader(cookies: BrowserCookie[]): string {
469
807
  return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
470
808
  }