@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.
- 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 +9 -21
- package/src/api/routes/webhooks/opencode.ts +6 -1
- package/src/api/routes.ts +1 -1
- 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 +170 -26
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +11 -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/collector.ts +4 -2
- 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 +109 -14
- 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/index.ts +2 -1
- 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 +679 -69
- package/src/usage/resolvers/claude-auth.ts +150 -41
- package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
- 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 -193
|
@@ -1,131 +1,536 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
];
|
|
32
|
-
})();
|
|
162
|
+
// Linux
|
|
163
|
+
if (!entry.linuxPath) return "";
|
|
164
|
+
return join(home, ".config", entry.linuxPath);
|
|
165
|
+
}
|
|
33
166
|
|
|
34
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
503
|
+
value: string;
|
|
504
|
+
host: string;
|
|
505
|
+
expiry: number;
|
|
112
506
|
}>;
|
|
507
|
+
const cookies: BrowserCookie[] = [];
|
|
113
508
|
for (const row of rows) {
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
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
|
|
517
|
+
// Ignore locked or corrupt DB
|
|
121
518
|
} finally {
|
|
122
519
|
db?.close();
|
|
123
520
|
}
|
|
124
521
|
}
|
|
125
522
|
|
|
126
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
}
|