@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.
- package/README.md +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +8 -0
- package/src/api/routes/webhooks/opencode.ts +1 -0
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +85 -3
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +1 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +44 -2
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +407 -69
- package/src/usage/resolvers/claude-auth.ts +140 -40
- package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- 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",
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
178
|
-
return
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
304
|
+
export interface ChromiumCookieRows {
|
|
305
|
+
dbVersion: number;
|
|
306
|
+
plaintext: BrowserCookie[];
|
|
307
|
+
encrypted: Array<{ name: string; encrypted: Buffer }>;
|
|
308
|
+
}
|
|
271
309
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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
|
|
328
|
-
|
|
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
|
|
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
|
|
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}
|
|
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)
|
|
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
|
|
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
|
|
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
|
}
|