@scrylog/cli 0.1.4 → 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 (97) 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 +9 -21
  9. package/src/api/routes/webhooks/opencode.ts +6 -1
  10. package/src/api/routes.ts +1 -1
  11. package/src/api/schemas/common.ts +8 -0
  12. package/src/api/schemas/sessions.ts +15 -1
  13. package/src/api/schemas/usage.ts +1 -0
  14. package/src/cli/commands.ts +170 -26
  15. package/src/cli/index.ts +18 -0
  16. package/src/cli/program.ts +175 -0
  17. package/src/daemon/collectors/claude/collector.ts +11 -1
  18. package/src/daemon/collectors/claude/discovery.ts +2 -23
  19. package/src/daemon/collectors/codex/collector.ts +3 -3
  20. package/src/daemon/collectors/git.ts +60 -2
  21. package/src/daemon/collectors/herdr/collector.ts +128 -0
  22. package/src/daemon/collectors/herdr/index.ts +2 -0
  23. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  24. package/src/daemon/collectors/opencode/collector.ts +4 -2
  25. package/src/daemon/collectors/opencode-db.ts +17 -5
  26. package/src/daemon/collectors/tmux/collector.ts +21 -1
  27. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  28. package/src/daemon/collectors/usage.ts +115 -49
  29. package/src/daemon/index.ts +2 -0
  30. package/src/daemon/store.ts +109 -14
  31. package/src/postinstall.cjs +11 -0
  32. package/src/shared/config.ts +33 -0
  33. package/src/shared/session-exclusions.ts +5 -0
  34. package/src/shared/session-view.ts +67 -6
  35. package/src/shared/terminal-capabilities.ts +130 -0
  36. package/src/shared/types.ts +33 -2
  37. package/src/shared/version.ts +44 -0
  38. package/src/tui/App.tsx +240 -0
  39. package/src/tui/assets/providers/SOURCES.md +21 -0
  40. package/src/tui/assets/providers/claude-terminal.png +0 -0
  41. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  42. package/src/tui/assets/providers/claude.png +0 -0
  43. package/src/tui/assets/providers/copilot.png +0 -0
  44. package/src/tui/assets/providers/copilot.svg +44 -0
  45. package/src/tui/assets/providers/openai-terminal.png +0 -0
  46. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  47. package/src/tui/assets/providers/openai.png +0 -0
  48. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  49. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  50. package/src/tui/assets/providers/opencode.png +0 -0
  51. package/src/tui/assets/providers/shell.png +0 -0
  52. package/src/tui/assets/providers/shell.svg +23 -0
  53. package/src/tui/assets/providers/unknown.png +0 -0
  54. package/src/tui/assets/providers/unknown.svg +23 -0
  55. package/src/tui/client.ts +22 -8
  56. package/src/tui/components/BindingsModal.tsx +82 -0
  57. package/src/tui/components/DashboardHeader.tsx +256 -0
  58. package/src/tui/components/KeyBar.tsx +67 -0
  59. package/src/tui/components/SessionDetailModal.tsx +169 -0
  60. package/src/tui/components/SessionsView.tsx +184 -0
  61. package/src/tui/components/TabBar.tsx +40 -0
  62. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  63. package/src/tui/components/UsageView.tsx +242 -0
  64. package/src/tui/components/tool-icons.ts +24 -0
  65. package/src/tui/index.tsx +106 -0
  66. package/src/tui/keybindings.ts +49 -0
  67. package/src/tui/model.ts +295 -0
  68. package/src/tui/state.ts +77 -0
  69. package/src/tui/theme.ts +110 -0
  70. package/src/usage/README.md +3 -3
  71. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  72. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  73. package/src/usage/clients/claude-cli-probe.ts +458 -132
  74. package/src/usage/clients/codex-app-server.ts +354 -0
  75. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  76. package/src/usage/clients/opencode-go-api.ts +100 -0
  77. package/src/usage/clients/opencode-go-usage.ts +163 -27
  78. package/src/usage/errors.ts +33 -0
  79. package/src/usage/index.ts +2 -1
  80. package/src/usage/local/opencode-go-local.ts +194 -0
  81. package/src/usage/mappers.ts +24 -15
  82. package/src/usage/planner.ts +89 -25
  83. package/src/usage/providers/claude.ts +203 -83
  84. package/src/usage/providers/codex.ts +92 -11
  85. package/src/usage/providers/opencode-go.ts +94 -16
  86. package/src/usage/resolvers/browser-cookies.ts +679 -69
  87. package/src/usage/resolvers/claude-auth.ts +150 -41
  88. package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
  89. package/src/usage/resolvers/claude-web.ts +72 -6
  90. package/src/usage/resolvers/codex-auth.ts +74 -17
  91. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  92. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  93. package/src/usage/types.ts +37 -2
  94. package/src/usage/web-session-cache.ts +37 -0
  95. package/src/neo-blessed.d.ts +0 -4
  96. package/src/tui/app.ts +0 -920
  97. package/src/tui/index.ts +0 -193
@@ -1,131 +1,536 @@
1
- import { execSync } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
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
- const CHROME_COOKIE_PATHS = ((): string[] => {
14
+ export interface BrowserCookieCandidate {
15
+ browser: string;
16
+ profile: string;
17
+ database: string;
18
+ cookies: BrowserCookie[];
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Browser catalog types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface ChromiumProfileEntry {
26
+ /** Unique browser ID, e.g. "chrome", "brave", "claude-desktop" */
27
+ id: string;
28
+ /** Relative path under ~/Library/Application Support/ on macOS */
29
+ macOSPath: string;
30
+ /** Relative path under ~/.config/ on Linux */
31
+ linuxPath: string;
32
+ /** Relative path under %APPDATA%/ on Windows */
33
+ windowsPath: string;
34
+ /** macOS Keychain service label, e.g. "Chrome Safe Storage" */
35
+ keychainService?: string;
36
+ /** macOS Keychain account label, e.g. "Chrome" */
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;
50
+ }
51
+
52
+ export interface GeckoProfileEntry {
53
+ /** Unique browser ID, e.g. "firefox", "zen" */
54
+ id: string;
55
+ /**
56
+ * Profiles parent folder name.
57
+ * macOS: ~/Library/Application Support/<profilesFolder>/Profiles
58
+ * Linux: ~/.mozilla/<profilesFolder>/Profiles
59
+ */
60
+ profilesFolder: string;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Browser catalogs
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export const CHROMIUM_PROFILES: ChromiumProfileEntry[] = [
68
+ {
69
+ id: "chrome",
70
+ macOSPath: "Google/Chrome",
71
+ linuxPath: "google-chrome",
72
+ windowsPath: "Google/Chrome/User Data",
73
+ keychainService: "Chrome Safe Storage",
74
+ keychainAccount: "Chrome",
75
+ },
76
+ {
77
+ id: "edge",
78
+ macOSPath: "Microsoft Edge",
79
+ linuxPath: "microsoft-edge",
80
+ windowsPath: "Microsoft/Edge/User Data",
81
+ keychainService: "Microsoft Edge Safe Storage",
82
+ keychainAccount: "Microsoft Edge",
83
+ },
84
+ {
85
+ id: "brave",
86
+ macOSPath: "BraveSoftware/Brave-Browser",
87
+ linuxPath: "BraveSoftware/Brave-Browser",
88
+ windowsPath: "BraveSoftware/Brave-Browser/User Data",
89
+ keychainService: "Brave Safe Storage",
90
+ keychainAccount: "Brave",
91
+ },
92
+ {
93
+ id: "arc",
94
+ macOSPath: "Arc/User Data",
95
+ linuxPath: "Arc",
96
+ windowsPath: "Arc/User Data",
97
+ keychainService: "Arc Safe Storage",
98
+ keychainAccount: "Arc",
99
+ },
100
+ {
101
+ id: "vivaldi",
102
+ macOSPath: "Vivaldi",
103
+ linuxPath: "vivaldi",
104
+ windowsPath: "Vivaldi/User Data",
105
+ keychainService: "Vivaldi Safe Storage",
106
+ keychainAccount: "Vivaldi",
107
+ },
108
+ {
109
+ id: "chromium",
110
+ macOSPath: "Chromium",
111
+ linuxPath: "chromium",
112
+ windowsPath: "Chromium/User Data",
113
+ keychainService: "Chromium Safe Storage",
114
+ keychainAccount: "Chromium",
115
+ },
116
+ {
117
+ id: "yandex",
118
+ macOSPath: "Yandex/YandexBrowser",
119
+ linuxPath: "yandex-browser",
120
+ windowsPath: "Yandex/YandexBrowser/User Data",
121
+ keychainService: "Yandex Safe Storage",
122
+ keychainAccount: "Yandex",
123
+ },
124
+ {
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.
129
+ id: "claude-desktop",
130
+ macOSPath: "Claude",
131
+ linuxPath: "", // not available on Linux
132
+ windowsPath: "Anthropic/Claude",
133
+ keychainService: "Claude Safe Storage",
134
+ keychainAccount: "Claude",
135
+ rootCookiePath: "Cookies",
136
+ forceDomainHashStrip: true,
137
+ },
138
+ ];
139
+
140
+ export const GECKO_PROFILES: GeckoProfileEntry[] = [
141
+ { id: "firefox", profilesFolder: "Firefox" },
142
+ { id: "zen", profilesFolder: "zen" },
143
+ ];
144
+
145
+ const SAFARI_COOKIE_PATH = join(homedir(), "Library", "Cookies", "Cookies.binarycookies");
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Platform root resolution
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function chromiumRootPath(entry: ChromiumProfileEntry): string {
15
152
  const home = homedir();
16
153
  if (IS_MAC) {
17
- return [
18
- join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Cookies"),
19
- join(home, "Library", "Application Support", "Google", "Chrome", "Profile 1", "Cookies"),
20
- ];
154
+ if (!entry.macOSPath) return "";
155
+ return join(home, "Library", "Application Support", entry.macOSPath);
21
156
  }
22
157
  if (IS_WINDOWS) {
23
- return [
24
- join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Cookies"),
25
- ];
158
+ const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
159
+ if (!entry.windowsPath) return "";
160
+ return join(appData, entry.windowsPath);
26
161
  }
27
- // Linux / WSL
28
- return [
29
- join(home, ".config", "google-chrome", "Default", "Cookies"),
30
- join(home, ".config", "chromium", "Default", "Cookies"),
31
- ];
32
- })();
162
+ // Linux
163
+ if (!entry.linuxPath) return "";
164
+ return join(home, ".config", entry.linuxPath);
165
+ }
33
166
 
34
- const SAFARI_COOKIE_PATH = join(homedir(), "Library", "Cookies", "Cookies.binarycookies");
167
+ function geckoProfilesRoot(entry: GeckoProfileEntry): string {
168
+ const home = homedir();
169
+ if (IS_MAC) {
170
+ return join(home, "Library", "Application Support", entry.profilesFolder, "Profiles");
171
+ }
172
+ if (IS_WINDOWS) {
173
+ // Firefox on Windows is not in scope; skip
174
+ return "";
175
+ }
176
+ // Linux
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
+ }
35
181
 
36
- function getChromeSafeStorageKey(): Buffer | null {
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);
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Chromium helpers
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Derives the Chromium AES-128-CBC key for a given Keychain service label.
198
+ * macOS: reads through the Keychain coordinator (async, single-flight, cooldown).
199
+ * Linux/Windows: uses the fixed "peanuts" PBKDF2 key (no Keychain).
200
+ */
201
+ export async function getChromiumSafeStorageKey(
202
+ service: string,
203
+ account: string,
204
+ coordinator: KeychainCoordinator = keychainCoordinator,
205
+ ): Promise<Buffer | null> {
37
206
  try {
38
207
  if (IS_MAC) {
39
- const password = execSync('security find-generic-password -s "Chrome Safe Storage" -w', {
40
- encoding: "utf8",
41
- stdio: ["pipe", "pipe", "pipe"],
42
- }).trim();
208
+ const password = await coordinator.read(service, account);
43
209
  if (!password) return null;
44
210
  return pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
45
211
  }
46
212
 
47
213
  if (IS_WINDOWS) {
214
+ // Chromium uses DPAPI on Windows; plaintext rows can still be imported.
48
215
  return null;
49
216
  }
217
+
218
+ // Linux
50
219
  return pbkdf2Sync("peanuts", "saltysalt", 1003, 16, "sha1");
51
220
  } catch {
52
221
  return null;
53
222
  }
54
223
  }
55
224
 
56
- function decryptChromeCookie(encrypted: Buffer, key: Buffer, dbVersion = 0): string | null {
57
- if (encrypted.length < 3) return null;
58
- // v10 or v11 prefix
59
- const prefix = encrypted.slice(0, 3).toString("ascii");
225
+ /**
226
+ * Returns all profile subdirectory paths under rootPath that match the
227
+ * Chromium naming conventions: Default, Profile *, user-*.
228
+ */
229
+ export function discoverChromiumProfiles(rootPath: string): string[] {
230
+ if (!existsSync(rootPath)) return [];
231
+ let entries: string[];
232
+ try {
233
+ entries = readdirSync(rootPath, { withFileTypes: true })
234
+ .filter((d) => d.isDirectory())
235
+ .map((d) => d.name);
236
+ } catch {
237
+ return [];
238
+ }
239
+ return entries
240
+ .filter((name) => name === "Default" || /^Profile \d+$/.test(name) || /^user-/.test(name))
241
+ .map((name) => join(rootPath, name));
242
+ }
243
+
244
+ /**
245
+ * Returns the two candidate cookie DB paths for a Chromium profile directory.
246
+ * The caller is responsible for checking existence before opening.
247
+ */
248
+ export function chromiumCookieDbPaths(profilePath: string): string[] {
249
+ return [join(profilePath, "Cookies"), join(profilePath, "Network", "Cookies")];
250
+ }
251
+
252
+ /**
253
+ * Internal: decrypt AES-128-CBC safeStorage payload, returning raw bytes after
254
+ * PKCS#7 un-padding. Returns `null` on decryption failure. Callers that need
255
+ * the Chrome v24+ 32-byte domain-hash strip must apply it themselves.
256
+ */
257
+ function decryptSafeStorageRaw(buf: Buffer, key: Buffer): Buffer | null {
258
+ if (buf.length < 3) return null;
259
+ const prefix = buf.slice(0, 3).toString("ascii");
60
260
  if (prefix !== "v10" && prefix !== "v11") {
61
- // Possibly plain text (older Chrome versions)
62
- return encrypted.toString("utf8");
261
+ return buf; // plain text (older versions)
63
262
  }
64
263
  const iv = Buffer.alloc(16, 32); // space character (0x20) repeated 16 times
65
264
  try {
66
265
  const decipher = createDecipheriv("aes-128-cbc", key, iv);
67
- let decrypted = Buffer.concat([decipher.update(encrypted.slice(3)), decipher.final()]);
266
+ let decrypted = Buffer.concat([decipher.update(buf.slice(3)), decipher.final()]);
68
267
  // Remove PKCS#7 padding
69
268
  const padLen = decrypted[decrypted.length - 1];
70
269
  if (padLen > 0 && padLen <= 16) {
71
270
  decrypted = decrypted.slice(0, decrypted.length - padLen);
72
271
  }
73
- // Chrome v24+ prepends a SHA256 hash of the domain (32 bytes) to encrypted values
74
- if (dbVersion >= 24 && decrypted.length >= 32) {
75
- decrypted = decrypted.slice(32);
76
- }
77
- return decrypted.toString("utf8");
272
+ return decrypted;
78
273
  } catch {
79
274
  return null;
80
275
  }
81
276
  }
82
277
 
83
- function readChromeCookies(domain: string): BrowserCookie[] {
84
- const cookies: BrowserCookie[] = [];
85
- const key = getChromeSafeStorageKey();
278
+ /**
279
+ * Decrypt a Chromium/Electron safeStorage blob (AES-128-CBC, v10/v11 prefix)
280
+ * and return the result as a UTF-8 string.
281
+ *
282
+ * Used for Electron `safeStorage`-encrypted config fields (e.g. `oauth:tokenCache`
283
+ * in Claude Desktop's `config.json`). Does **not** strip the Chrome v24+ 32-byte
284
+ * domain-hash prefix — use `decryptChromiumCookie` for cookie DB values.
285
+ */
286
+ export function decryptSafeStorageBlob(buf: Buffer, key: Buffer): string | null {
287
+ const raw = decryptSafeStorageRaw(buf, key);
288
+ return raw !== null ? raw.toString("utf8") : null;
289
+ }
290
+
291
+ function decryptChromiumCookie(encrypted: Buffer, key: Buffer, dbVersion = 0): string | null {
292
+ const raw = decryptSafeStorageRaw(encrypted, key);
293
+ if (raw === null) return null;
294
+ // Chrome v24+ prepends a SHA256 hash of the domain (32 bytes) to encrypted cookie values
295
+ if (encrypted.length >= 3) {
296
+ const prefix = encrypted.slice(0, 3).toString("ascii");
297
+ if ((prefix === "v10" || prefix === "v11") && dbVersion >= 24 && raw.length >= 32) {
298
+ return raw.slice(32).toString("utf8");
299
+ }
300
+ }
301
+ return raw.toString("utf8");
302
+ }
303
+
304
+ export interface ChromiumCookieRows {
305
+ dbVersion: number;
306
+ plaintext: BrowserCookie[];
307
+ encrypted: Array<{ name: string; encrypted: Buffer }>;
308
+ }
309
+
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];
86
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
+ }
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;
87
436
 
88
- for (const dbPath of CHROME_COOKIE_PATHS) {
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.
440
+ const profiles = discoverChromiumProfiles(rootPath);
441
+ for (const profilePath of profiles) {
442
+ for (const dbPath of chromiumCookieDbPaths(profilePath)) {
443
+ if (!existsSync(dbPath)) continue;
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);
455
+ }
456
+ }
457
+
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);
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Gecko (Firefox/Zen) support
468
+ // ---------------------------------------------------------------------------
469
+
470
+ export function readGeckoCookieCandidates(
471
+ entry: GeckoProfileEntry,
472
+ domain: string,
473
+ allowedNames?: ReadonlySet<string>,
474
+ ): BrowserCookieCandidate[] {
475
+ const candidates: BrowserCookieCandidate[] = [];
476
+ const profilesRoot = geckoProfilesRoot(entry);
477
+ if (!profilesRoot || !existsSync(profilesRoot)) return candidates;
478
+
479
+ let profileNames: string[];
480
+ try {
481
+ profileNames = readdirSync(profilesRoot, { withFileTypes: true })
482
+ .filter((d) => d.isDirectory() && d.name.includes(".default"))
483
+ .map((d) => d.name)
484
+ // Prefer .default-release over .default
485
+ .sort((a, b) => {
486
+ const aScore = a.endsWith(".default-release") ? 1 : 0;
487
+ const bScore = b.endsWith(".default-release") ? 1 : 0;
488
+ return bScore - aScore;
489
+ });
490
+ } catch {
491
+ return candidates;
492
+ }
493
+
494
+ for (const profileName of profileNames) {
495
+ const dbPath = join(profilesRoot, profileName, "cookies.sqlite");
89
496
  if (!existsSync(dbPath)) continue;
90
497
  let db: Database | undefined;
91
498
  try {
92
499
  db = new Database(dbPath, { readonly: true });
93
-
94
- // Chrome v24+ prepends a SHA256 hash (32 bytes) to encrypted cookie values
95
- let dbVersion = 0;
96
- try {
97
- const versionStmt = db.query("SELECT value FROM meta WHERE key = 'version'");
98
- const versionRow = versionStmt.get() as { value: number } | null;
99
- if (versionRow) dbVersion = Number(versionRow.value);
100
- } catch {
101
- // meta table may not exist in older DBs
102
- }
103
-
104
- const stmt = db.query(
105
- "SELECT name, encrypted_value, host_key FROM cookies WHERE host_key LIKE ? AND expires_utc > ?"
106
- );
107
- const now = (Date.now() + 11644473600000) * 1000; // Chrome epoch offset
108
- const rows = stmt.all(`%${domain}%`, now) as Array<{
500
+ const stmt = db.query("SELECT name, value, host, expiry FROM moz_cookies WHERE host LIKE ?");
501
+ const rows = stmt.all(`%${domain}`) as Array<{
109
502
  name: string;
110
- encrypted_value: Uint8Array;
111
- host_key: string;
503
+ value: string;
504
+ host: string;
505
+ expiry: number;
112
506
  }>;
507
+ const cookies: BrowserCookie[] = [];
113
508
  for (const row of rows) {
114
- const value = decryptChromeCookie(Buffer.from(row.encrypted_value), key, dbVersion);
115
- if (value !== null) {
116
- cookies.push({ name: row.name, value });
117
- }
509
+ if (!cookieDomainMatches(row.host, domain) || !cookieNameAllowed(row.name, allowedNames)) continue;
510
+ if (Number(row.expiry) > 0 && Number(row.expiry) <= Date.now() / 1000) continue;
511
+ cookies.push({ name: row.name, value: row.value });
512
+ }
513
+ if (cookies.length > 0) {
514
+ candidates.push({ browser: entry.id, profile: join(profilesRoot, profileName), database: dbPath, cookies });
118
515
  }
119
516
  } catch {
120
- // Ignore permission errors or locked DB
517
+ // Ignore locked or corrupt DB
121
518
  } finally {
122
519
  db?.close();
123
520
  }
124
521
  }
125
522
 
126
- 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);
127
528
  }
128
529
 
530
+ // ---------------------------------------------------------------------------
531
+ // Safari (macOS-only, unchanged)
532
+ // ---------------------------------------------------------------------------
533
+
129
534
  function readSafariCookies(domain: string): BrowserCookie[] {
130
535
  const cookies: BrowserCookie[] = [];
131
536
  if (process.platform !== "darwin") return cookies;
@@ -134,7 +539,7 @@ function readSafariCookies(domain: string): BrowserCookie[] {
134
539
  try {
135
540
  const data = readFileSync(SAFARI_COOKIE_PATH);
136
541
  // Simple binarycookies parser
137
- // Header: 4 bytes magic "cook", 4 bytes page size, then pages
542
+ // Header: 4 bytes magic "cook", 4 bytes page count, then pages
138
543
  if (data.length < 8 || data.toString("ascii", 0, 4) !== "cook") return cookies;
139
544
 
140
545
  const numPages = data.readUInt32BE(4);
@@ -169,7 +574,7 @@ function readSafariCookies(domain: string): BrowserCookie[] {
169
574
  const name = data.toString("utf8", cookieOffset + nameOffset, cookieOffset + pathOffset - 1);
170
575
  const value = data.toString("utf8", cookieOffset + valueOffset, cookieOffset + size);
171
576
 
172
- if (url.includes(domain)) {
577
+ if (cookieDomainMatches(url, domain)) {
173
578
  cookies.push({ name, value });
174
579
  }
175
580
 
@@ -185,14 +590,219 @@ function readSafariCookies(domain: string): BrowserCookie[] {
185
590
  return cookies;
186
591
  }
187
592
 
188
- export function extractCookies(domain: string): BrowserCookie[] {
189
- const chrome = readChromeCookies(domain);
190
- if (chrome.length > 0) return chrome;
191
- // Safari is only available on macOS.
192
- if (IS_MAC) return readSafariCookies(domain);
593
+ // ---------------------------------------------------------------------------
594
+ // Public API
595
+ // ---------------------------------------------------------------------------
596
+
597
+ /**
598
+ * Extract cookies for the given domain from all available browser sources.
599
+ * Sources are tried in an order that minimises Keychain prompts:
600
+ * macOS: Gecko (Firefox/Zen) → Safari → Chromium catalog
601
+ * Linux: Gecko → Chromium catalog
602
+ * Windows: Gecko (limited) → Chromium catalog
603
+ *
604
+ * Returns the first source that yields ≥1 matching cookie; subsequent sources
605
+ * are skipped (first-source-wins).
606
+ */
607
+ export async function extractCookies(domain: string): Promise<BrowserCookie[]> {
608
+ // 1. Gecko sources first — no Keychain prompt, cheap read
609
+ for (const entry of GECKO_PROFILES) {
610
+ const cookies = readGeckoCookies(entry, domain);
611
+ if (cookies.length > 0) return cookies;
612
+ }
613
+
614
+ // 2. Safari (macOS only) — no Keychain prompt
615
+ if (IS_MAC) {
616
+ const safari = readSafariCookies(domain);
617
+ if (safari.length > 0) return safari;
618
+ }
619
+
620
+ // 3. Chromium catalog — may trigger Keychain prompts on macOS
621
+ for (const entry of CHROMIUM_PROFILES) {
622
+ const cookies = await readChromiumCookies(entry, domain);
623
+ if (cookies.length > 0) return cookies;
624
+ }
625
+
193
626
  return [];
194
627
  }
195
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
+
196
806
  export function buildCookieHeader(cookies: BrowserCookie[]): string {
197
807
  return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
198
808
  }