@cad0p/pi-usage 0.0.0-stage → 0.11.2
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/LICENSE +21 -0
- package/README.md +509 -2
- package/bin/piquota.js +199 -0
- package/extensions/quota-panel.ts +963 -0
- package/package.json +59 -4
- package/src/auth/claude-code-auth.js +490 -0
- package/src/auth/jwt.js +84 -0
- package/src/auth/pi-auth.js +424 -0
- package/src/browser/cookies.js +557 -0
- package/src/browser/history.js +173 -0
- package/src/cache.js +224 -0
- package/src/cli/args.js +154 -0
- package/src/cli/commands/auth.js +216 -0
- package/src/cli/commands/gentle-pi.js +44 -0
- package/src/cli/commands/omarchy.js +75 -0
- package/src/cli/explain.js +99 -0
- package/src/cli/output.js +34 -0
- package/src/engine.js +404 -0
- package/src/exec.js +31 -0
- package/src/gentle-pi/rail-patch.js +335 -0
- package/src/http.js +182 -0
- package/src/model.js +332 -0
- package/src/omarchy/assets/antigravity.svg +3 -0
- package/src/omarchy/assets/claude.svg +1 -0
- package/src/omarchy/assets/codex.svg +1 -0
- package/src/omarchy/assets/github-actions.svg +3 -0
- package/src/omarchy/assets/opencode-go.svg +3 -0
- package/src/omarchy/publish.js +152 -0
- package/src/omarchy/record.js +462 -0
- package/src/opencode/dashboard.js +337 -0
- package/src/opencode/session.js +485 -0
- package/src/providers/antigravity-oauth.js +173 -0
- package/src/providers/antigravity.js +225 -0
- package/src/providers/backoff.js +146 -0
- package/src/providers/claude.js +194 -0
- package/src/providers/codex.js +151 -0
- package/src/providers/error-kind.js +128 -0
- package/src/providers/github-actions.js +379 -0
- package/src/providers/opencode-go.js +274 -0
- package/src/refresh.js +292 -0
- package/src/render/panel.js +411 -0
- package/src/render/theme.js +102 -0
- package/src/sticky.js +319 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only browser cookie access.
|
|
3
|
+
*
|
|
4
|
+
* Only used to obtain the opencode.ai session cookie that Pi does not store.
|
|
5
|
+
* Guarantees:
|
|
6
|
+
* - the browser database is never opened in place: it is copied to a private
|
|
7
|
+
* temporary directory (including `-wal`/`-shm`) and opened read-only there;
|
|
8
|
+
* - cookie values are returned to the caller but never logged, printed or
|
|
9
|
+
* written to the cache;
|
|
10
|
+
* - Chromium cookie databases are copied before read-only access; Linux OSCrypt
|
|
11
|
+
* values are decrypted in memory, while unsupported platform encryption is
|
|
12
|
+
* reported instead of guessed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { createDecipheriv, pbkdf2Sync } from "node:crypto";
|
|
17
|
+
import { DatabaseSync } from "node:sqlite";
|
|
18
|
+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
19
|
+
import { homedir, tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
const WINDOWS_USERS_ROOT = "/mnt/c/Users";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} CookieStore
|
|
26
|
+
* @property {"firefox" | "chromium"} browser
|
|
27
|
+
* @property {string} path Path to the cookie database.
|
|
28
|
+
* @property {string} profile Human profile label.
|
|
29
|
+
* @property {"plaintext" | "encrypted" | "unknown"} readability
|
|
30
|
+
* @property {string} [note]
|
|
31
|
+
* @property {string} [app]
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Firefox roots: ~/.mozilla/firefox, plus native Windows %APPDATA% profiles,
|
|
36
|
+
* plus every Windows profile reachable from WSL.
|
|
37
|
+
*
|
|
38
|
+
* @param {{ home?: string, appData?: string, usersRoot?: string, platform?: string }} [options]
|
|
39
|
+
* @returns {string[]}
|
|
40
|
+
*/
|
|
41
|
+
export function firefoxRoots(options = {}) {
|
|
42
|
+
const home = options.home ?? homedir();
|
|
43
|
+
const platform = options.platform ?? process.platform;
|
|
44
|
+
const env = options.env ?? process.env;
|
|
45
|
+
/** @type {string[]} */
|
|
46
|
+
const roots = [];
|
|
47
|
+
|
|
48
|
+
if (platform === "win32") {
|
|
49
|
+
// Windows keeps profiles under %APPDATA%\Mozilla\Firefox, not ~/.mozilla/firefox.
|
|
50
|
+
const appData = options.appData ?? env.APPDATA ?? join(home, "AppData", "Roaming");
|
|
51
|
+
roots.push(join(appData, "Mozilla", "Firefox"));
|
|
52
|
+
} else if (platform === "darwin") {
|
|
53
|
+
roots.push(join(home, "Library", "Application Support", "Firefox"));
|
|
54
|
+
} else {
|
|
55
|
+
roots.push(
|
|
56
|
+
join(home, ".mozilla", "firefox"),
|
|
57
|
+
join(home, ".config", "mozilla", "firefox"),
|
|
58
|
+
join(home, "Library", "Application Support", "Firefox"),
|
|
59
|
+
);
|
|
60
|
+
if (env.XDG_CONFIG_HOME) {
|
|
61
|
+
roots.push(join(env.XDG_CONFIG_HOME, "mozilla", "firefox"));
|
|
62
|
+
}
|
|
63
|
+
if (existsSync(WINDOWS_USERS_ROOT)) {
|
|
64
|
+
let entries = [];
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(options.usersRoot ?? WINDOWS_USERS_ROOT, { withFileTypes: true });
|
|
67
|
+
} catch {
|
|
68
|
+
entries = [];
|
|
69
|
+
}
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (!entry.isDirectory()) continue;
|
|
72
|
+
if (["Default", "Default User", "All Users", "Public"].includes(entry.name)) continue;
|
|
73
|
+
roots.push(join(options.usersRoot ?? WINDOWS_USERS_ROOT, entry.name, "AppData", "Roaming", "Mozilla", "Firefox"));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return roots.filter((root) => existsSync(join(root, "profiles.ini")));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse `profiles.ini` for profile directories.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} root
|
|
84
|
+
* @returns {Array<{ label: string, dir: string, isDefault: boolean }>}
|
|
85
|
+
*/
|
|
86
|
+
export function parseFirefoxProfiles(root) {
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(join(root, "profiles.ini"), "utf-8");
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @type {Array<{ label: string, dir: string, isDefault: boolean, relative: boolean }>} */
|
|
95
|
+
const profiles = [];
|
|
96
|
+
let current = null;
|
|
97
|
+
for (const rawLine of text.split("\n")) {
|
|
98
|
+
const line = rawLine.trim();
|
|
99
|
+
if (line.startsWith("[")) {
|
|
100
|
+
if (current) profiles.push(current);
|
|
101
|
+
current = { label: line.replace(/[[\]]/g, ""), dir: "", isDefault: false, relative: true };
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!current) continue;
|
|
105
|
+
const equals = line.indexOf("=");
|
|
106
|
+
if (equals <= 0) continue;
|
|
107
|
+
const key = line.slice(0, equals).trim();
|
|
108
|
+
const value = line.slice(equals + 1).trim();
|
|
109
|
+
if (key === "Path") current.dir = value;
|
|
110
|
+
if (key === "IsRelative") current.relative = value !== "0";
|
|
111
|
+
if (key === "Default") current.isDefault = value === "1";
|
|
112
|
+
}
|
|
113
|
+
if (current) profiles.push(current);
|
|
114
|
+
|
|
115
|
+
return profiles
|
|
116
|
+
.filter((profile) => profile.dir)
|
|
117
|
+
.map((profile) => ({
|
|
118
|
+
label: profile.label,
|
|
119
|
+
isDefault: profile.isDefault,
|
|
120
|
+
dir: profile.relative ? join(root, profile.dir) : profile.dir,
|
|
121
|
+
}))
|
|
122
|
+
.filter((profile) => existsSync(join(profile.dir, "cookies.sqlite")));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every readable cookie store, most readable first.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ home?: string, usersRoot?: string, platform?: string, env?: Record<string, string | undefined>, secretTool?: (args: string[]) => string | null }} [options]
|
|
129
|
+
* @returns {CookieStore[]}
|
|
130
|
+
*/
|
|
131
|
+
export function discoverCookieStores(options = {}) {
|
|
132
|
+
/** @type {CookieStore[]} */
|
|
133
|
+
const stores = [];
|
|
134
|
+
for (const root of firefoxRoots(options)) {
|
|
135
|
+
for (const profile of parseFirefoxProfiles(root)) {
|
|
136
|
+
let platformPrefix = "linux";
|
|
137
|
+
if (root.includes("Application Support")) platformPrefix = "mac";
|
|
138
|
+
else if (root.endsWith("Firefox")) platformPrefix = "windows";
|
|
139
|
+
stores.push({
|
|
140
|
+
browser: "firefox",
|
|
141
|
+
path: join(profile.dir, "cookies.sqlite"),
|
|
142
|
+
profile: `${platformPrefix}:${profile.label}${profile.isDefault ? " (default)" : ""}`,
|
|
143
|
+
readability: "plaintext",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const platform = options.platform ?? process.platform;
|
|
149
|
+
if (platform !== "win32" && platform !== "darwin") {
|
|
150
|
+
for (const store of linuxChromiumStores(options)) stores.push(store);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Chromium on Windows encrypts values with DPAPI; surface it so the user gets
|
|
154
|
+
// an explanation instead of a silent failure.
|
|
155
|
+
for (const userRoot of windowsChromiumRoots(options)) {
|
|
156
|
+
for (const browserName of ["Google/Chrome", "Microsoft/Edge", "BraveSoftware/Brave-Browser"]) {
|
|
157
|
+
const base = join(userRoot, browserName, "User Data");
|
|
158
|
+
if (!existsSync(base)) continue;
|
|
159
|
+
for (const profile of ["Default", "Profile 1", "Profile 2"]) {
|
|
160
|
+
const db = join(base, profile, "Network", "Cookies");
|
|
161
|
+
if (!existsSync(db)) continue;
|
|
162
|
+
stores.push({
|
|
163
|
+
browser: "chromium",
|
|
164
|
+
path: db,
|
|
165
|
+
profile: `windows:${browserName.split("/").pop()}/${profile}`,
|
|
166
|
+
readability: "encrypted",
|
|
167
|
+
note: "Chromium on Windows encrypts cookies with DPAPI, which is not available from WSL. Use Firefox for opencode.ai.",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return stores;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const LINUX_CHROMIUM_BROWSERS = [
|
|
177
|
+
{ directory: "google-chrome", label: "Google Chrome", app: "chrome" },
|
|
178
|
+
{ directory: "google-chrome-beta", label: "Google Chrome Beta", app: "chrome" },
|
|
179
|
+
{ directory: "google-chrome-unstable", label: "Google Chrome Unstable", app: "chrome" },
|
|
180
|
+
{ directory: "chromium", label: "Chromium", app: "chromium" },
|
|
181
|
+
{ directory: "microsoft-edge", label: "Microsoft Edge", app: "microsoft-edge" },
|
|
182
|
+
{ directory: "microsoft-edge-dev", label: "Microsoft Edge Dev", app: "microsoft-edge" },
|
|
183
|
+
{ directory: "BraveSoftware/Brave-Browser", label: "Brave", app: "brave" },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {{ home?: string, env?: Record<string, string | undefined>, secretTool?: (args: string[]) => string | null }} [options]
|
|
188
|
+
* @returns {CookieStore[]}
|
|
189
|
+
*/
|
|
190
|
+
function linuxChromiumStores(options = {}) {
|
|
191
|
+
const home = options.home ?? homedir();
|
|
192
|
+
const env = options.env ?? process.env;
|
|
193
|
+
const configRoot = env.XDG_CONFIG_HOME || join(home, ".config");
|
|
194
|
+
/** @type {CookieStore[]} */
|
|
195
|
+
const stores = [];
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
|
|
198
|
+
for (const browser of LINUX_CHROMIUM_BROWSERS) {
|
|
199
|
+
const browserDir = join(configRoot, browser.directory);
|
|
200
|
+
if (!existsSync(browserDir)) continue;
|
|
201
|
+
const profiles = ["Default"];
|
|
202
|
+
try {
|
|
203
|
+
profiles.push(...readdirSync(browserDir, { withFileTypes: true })
|
|
204
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("Profile "))
|
|
205
|
+
.map((entry) => entry.name));
|
|
206
|
+
} catch {
|
|
207
|
+
// A browser directory may disappear while discovery is running.
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const profile of profiles) {
|
|
211
|
+
for (const relative of [join(profile, "Cookies"), join(profile, "Network", "Cookies")]) {
|
|
212
|
+
const dbPath = join(browserDir, relative);
|
|
213
|
+
if (seen.has(dbPath) || !existsSync(dbPath)) continue;
|
|
214
|
+
seen.add(dbPath);
|
|
215
|
+
const status = chromiumStoreReadability(dbPath, browser.app, options);
|
|
216
|
+
stores.push({
|
|
217
|
+
browser: "chromium",
|
|
218
|
+
path: dbPath,
|
|
219
|
+
profile: `linux:${browser.label}/${profile}`,
|
|
220
|
+
app: browser.app,
|
|
221
|
+
...status,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return stores;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {{ usersRoot?: string, platform?: string }} [options]
|
|
231
|
+
* @returns {string[]}
|
|
232
|
+
*/
|
|
233
|
+
function windowsChromiumRoots(options = {}) {
|
|
234
|
+
if ((options.platform ?? process.platform) === "win32") return [];
|
|
235
|
+
const usersRoot = options.usersRoot ?? WINDOWS_USERS_ROOT;
|
|
236
|
+
if (!existsSync(usersRoot)) return [];
|
|
237
|
+
/** @type {string[]} */
|
|
238
|
+
const roots = [];
|
|
239
|
+
try {
|
|
240
|
+
for (const entry of readdirSync(usersRoot, { withFileTypes: true })) {
|
|
241
|
+
if (!entry.isDirectory()) continue;
|
|
242
|
+
if (["Default", "Default User", "All Users", "Public"].includes(entry.name)) continue;
|
|
243
|
+
roots.push(join(usersRoot, entry.name, "AppData", "Local"));
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
return roots;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const secretStoragePasswords = new Map();
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Look up the Linux Chromium Safe Storage password without exposing it outside
|
|
255
|
+
* this process. Both common Secret Service attributes are attempted.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} app
|
|
258
|
+
* @param {{ secretTool?: (args: string[]) => string | null }} [options]
|
|
259
|
+
* @returns {string | null}
|
|
260
|
+
*/
|
|
261
|
+
function linuxChromiumPassword(app, options = {}) {
|
|
262
|
+
const candidates = [app];
|
|
263
|
+
if (app !== "chromium") candidates.push("chromium");
|
|
264
|
+
if (app !== "chrome") candidates.push("chrome");
|
|
265
|
+
|
|
266
|
+
if (typeof options.secretTool === "function") {
|
|
267
|
+
for (const candidate of candidates) {
|
|
268
|
+
for (const args of [
|
|
269
|
+
["lookup", "application", candidate],
|
|
270
|
+
["lookup", "service", `${candidate} Safe Storage`],
|
|
271
|
+
]) {
|
|
272
|
+
try {
|
|
273
|
+
const value = options.secretTool(args)?.replace(/[\r\n]+$/, "");
|
|
274
|
+
if (value) return value;
|
|
275
|
+
} catch {
|
|
276
|
+
// Try the alternate Secret Service attribute.
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (secretStoragePasswords.has(app)) return secretStoragePasswords.get(app);
|
|
283
|
+
|
|
284
|
+
let password = null;
|
|
285
|
+
for (const candidate of candidates) {
|
|
286
|
+
for (const args of [
|
|
287
|
+
["lookup", "application", candidate],
|
|
288
|
+
["lookup", "service", `${candidate} Safe Storage`],
|
|
289
|
+
]) {
|
|
290
|
+
try {
|
|
291
|
+
const value = execFileSync("secret-tool", args, {
|
|
292
|
+
encoding: "utf8",
|
|
293
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
294
|
+
timeout: 2000,
|
|
295
|
+
}).replace(/[\r\n]+$/, "");
|
|
296
|
+
if (value.length > 0) {
|
|
297
|
+
password = value;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
// Missing secret-tool, locked keyrings and absent entries are expected.
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (password) break;
|
|
305
|
+
}
|
|
306
|
+
secretStoragePasswords.set(app, password);
|
|
307
|
+
return password;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Decrypt a Linux Chromium OSCrypt cookie value. The `password` option allows
|
|
312
|
+
* callers/tests to supply an already-resolved Safe Storage password.
|
|
313
|
+
*
|
|
314
|
+
* @param {Buffer | Uint8Array} encryptedValue
|
|
315
|
+
* @param {{ app?: string, password?: string | null, secretTool?: (args: string[]) => string | null }} [options]
|
|
316
|
+
* @returns {string | null}
|
|
317
|
+
*/
|
|
318
|
+
export function decryptLinuxChromiumCookie(encryptedValue, options = {}) {
|
|
319
|
+
const encrypted = Buffer.from(encryptedValue);
|
|
320
|
+
const version = encrypted.subarray(0, 3).toString("ascii");
|
|
321
|
+
if ((version !== "v10" && version !== "v11") || encrypted.length <= 3) return null;
|
|
322
|
+
|
|
323
|
+
let password;
|
|
324
|
+
if (version === "v10") password = "peanuts";
|
|
325
|
+
else if (Object.hasOwn(options, "password")) password = options.password;
|
|
326
|
+
else password = linuxChromiumPassword(options.app ?? "chromium", options);
|
|
327
|
+
if (typeof password !== "string" || password.length === 0) return null;
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const key = pbkdf2Sync(password, "saltysalt", 1, 16, "sha1");
|
|
331
|
+
const decipher = createDecipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
|
|
332
|
+
const decrypted = Buffer.concat([decipher.update(encrypted.subarray(3)), decipher.final()]);
|
|
333
|
+
const payload = version === "v11" ? decrypted.subarray(32) : decrypted;
|
|
334
|
+
if (payload.length === 0) return null;
|
|
335
|
+
if (version === "v11") {
|
|
336
|
+
const knownPrefix = payload.subarray(0, 6).toString("ascii").startsWith("Fe26.2")
|
|
337
|
+
|| payload.subarray(0, 3).toString("ascii") === "st_";
|
|
338
|
+
if (!knownPrefix && !isPrintableAscii(payload)) return null;
|
|
339
|
+
}
|
|
340
|
+
return payload.toString("utf8");
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** @param {Buffer} bytes */
|
|
347
|
+
function isPrintableAscii(bytes) {
|
|
348
|
+
return bytes.length > 0 && bytes.every((byte) => byte >= 0x20 && byte <= 0x7e);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Decide whether the encrypted values in a Linux Chromium database are
|
|
353
|
+
* supported by this process. v10 uses the documented legacy password; v11
|
|
354
|
+
* requires a password from the browser's Secret Service entry.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} dbPath
|
|
357
|
+
* @param {string} app
|
|
358
|
+
* @param {{ secretTool?: (args: string[]) => string | null }} [options]
|
|
359
|
+
* @returns {{ readability: "plaintext" | "encrypted", note?: string }}
|
|
360
|
+
*/
|
|
361
|
+
function chromiumStoreReadability(dbPath, app, options = {}) {
|
|
362
|
+
const copy = withCopy(dbPath);
|
|
363
|
+
let database;
|
|
364
|
+
try {
|
|
365
|
+
database = new DatabaseSync(copy.db, { readOnly: true });
|
|
366
|
+
const rows = database.prepare("select encrypted_value from cookies where encrypted_value is not null and length(encrypted_value) > 0").all();
|
|
367
|
+
for (const row of rows) {
|
|
368
|
+
const encrypted = Buffer.from(row.encrypted_value);
|
|
369
|
+
const version = encrypted.subarray(0, 3).toString("ascii");
|
|
370
|
+
if (version === "v10") continue;
|
|
371
|
+
if (version === "v11" && linuxChromiumPassword(app, options)) continue;
|
|
372
|
+
return {
|
|
373
|
+
readability: "encrypted",
|
|
374
|
+
note: "Chromium OSCrypt cookies need a supported v10 value or an unlocked Secret Service Safe Storage entry for this browser.",
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return { readability: "plaintext" };
|
|
378
|
+
} catch {
|
|
379
|
+
return {
|
|
380
|
+
readability: "encrypted",
|
|
381
|
+
note: "Chromium cookie database could not be inspected; its encrypted values may require the browser's Secret Service Safe Storage entry.",
|
|
382
|
+
};
|
|
383
|
+
} finally {
|
|
384
|
+
try { database?.close(); } catch {}
|
|
385
|
+
copy.cleanup();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const activeCookieCleanups = new Set();
|
|
390
|
+
if (typeof process !== "undefined" && typeof process.on === "function") {
|
|
391
|
+
const runCleanups = () => {
|
|
392
|
+
for (const fn of activeCookieCleanups) {
|
|
393
|
+
try { fn(); } catch {}
|
|
394
|
+
}
|
|
395
|
+
activeCookieCleanups.clear();
|
|
396
|
+
};
|
|
397
|
+
process.once("exit", runCleanups);
|
|
398
|
+
process.once("SIGINT", () => {
|
|
399
|
+
runCleanups();
|
|
400
|
+
process.exit(130);
|
|
401
|
+
});
|
|
402
|
+
process.once("SIGTERM", () => {
|
|
403
|
+
runCleanups();
|
|
404
|
+
process.exit(143);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Copy a SQLite database (with its journal) to a private temp dir so the
|
|
410
|
+
* browser's own file is never opened or locked by us.
|
|
411
|
+
*
|
|
412
|
+
* @param {string} dbPath
|
|
413
|
+
* @returns {{ dir: string, db: string, cleanup: () => void }}
|
|
414
|
+
*/
|
|
415
|
+
function withCopy(dbPath) {
|
|
416
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-quota-cookies-"));
|
|
417
|
+
const target = join(dir, "cookies.sqlite");
|
|
418
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
419
|
+
const source = `${dbPath}${suffix}`;
|
|
420
|
+
if (existsSync(source)) {
|
|
421
|
+
try {
|
|
422
|
+
copyFileSync(source, `${target}${suffix}`);
|
|
423
|
+
} catch {
|
|
424
|
+
// A locked sidecar is not fatal: the main database copy is usually enough.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const cleanupFn = () => {
|
|
429
|
+
activeCookieCleanups.delete(cleanupFn);
|
|
430
|
+
try {
|
|
431
|
+
rmSync(dir, { recursive: true, force: true });
|
|
432
|
+
} catch {
|
|
433
|
+
// Best effort.
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
activeCookieCleanups.add(cleanupFn);
|
|
437
|
+
return {
|
|
438
|
+
dir,
|
|
439
|
+
db: target,
|
|
440
|
+
cleanup: cleanupFn,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Query Firefox's `moz_cookies`.
|
|
446
|
+
*
|
|
447
|
+
* @param {string} dbPath
|
|
448
|
+
* @param {{ hostLike: string, name?: string }} query
|
|
449
|
+
* @returns {Array<{ host: string, name: string, value: string, path: string }>}
|
|
450
|
+
*/
|
|
451
|
+
export function readFirefoxCookies(dbPath, query) {
|
|
452
|
+
const copy = withCopy(dbPath);
|
|
453
|
+
let database;
|
|
454
|
+
try {
|
|
455
|
+
try {
|
|
456
|
+
database = new DatabaseSync(copy.db, { readOnly: true });
|
|
457
|
+
} catch {
|
|
458
|
+
database = new DatabaseSync(copy.db);
|
|
459
|
+
}
|
|
460
|
+
const sql = query.name
|
|
461
|
+
? "select host, name, value, path from moz_cookies where host like ? and name = ?"
|
|
462
|
+
: "select host, name, value, path from moz_cookies where host like ?";
|
|
463
|
+
const statement = database.prepare(sql);
|
|
464
|
+
const rows = query.name
|
|
465
|
+
? statement.all(query.hostLike, query.name)
|
|
466
|
+
: statement.all(query.hostLike);
|
|
467
|
+
return /** @type {Array<{ host: string, name: string, value: string, path: string }>} */ (rows);
|
|
468
|
+
} catch {
|
|
469
|
+
return [];
|
|
470
|
+
} finally {
|
|
471
|
+
try {
|
|
472
|
+
database?.close();
|
|
473
|
+
} catch {
|
|
474
|
+
// Ignore.
|
|
475
|
+
}
|
|
476
|
+
copy.cleanup();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Query Chromium's `cookies` table from a private database copy.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} dbPath
|
|
484
|
+
* @param {{ hostLike: string, name?: string }} query
|
|
485
|
+
* @returns {Array<{ host: string, name: string, value: string, encrypted_value: Buffer, path: string }>}
|
|
486
|
+
*/
|
|
487
|
+
function readChromiumCookies(dbPath, query) {
|
|
488
|
+
const copy = withCopy(dbPath);
|
|
489
|
+
let database;
|
|
490
|
+
try {
|
|
491
|
+
database = new DatabaseSync(copy.db, { readOnly: true });
|
|
492
|
+
const sql = query.name
|
|
493
|
+
? "select host_key as host, name, value, encrypted_value, path from cookies where host_key like ? and name = ?"
|
|
494
|
+
: "select host_key as host, name, value, encrypted_value, path from cookies where host_key like ?";
|
|
495
|
+
const rows = query.name
|
|
496
|
+
? database.prepare(sql).all(query.hostLike, query.name)
|
|
497
|
+
: database.prepare(sql).all(query.hostLike);
|
|
498
|
+
return /** @type {Array<{ host: string, name: string, value: string, encrypted_value: Buffer, path: string }>} */ (rows);
|
|
499
|
+
} catch {
|
|
500
|
+
return [];
|
|
501
|
+
} finally {
|
|
502
|
+
try { database?.close(); } catch {}
|
|
503
|
+
copy.cleanup();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Find one cookie across every readable store.
|
|
509
|
+
*
|
|
510
|
+
* @param {{
|
|
511
|
+
* host: string,
|
|
512
|
+
* name: string,
|
|
513
|
+
* stores?: CookieStore[],
|
|
514
|
+
* home?: string,
|
|
515
|
+
* usersRoot?: string,
|
|
516
|
+
* platform?: string,
|
|
517
|
+
* secretTool?: (args: string[]) => string | null,
|
|
518
|
+
* }} options
|
|
519
|
+
* @returns {{
|
|
520
|
+
* found: boolean,
|
|
521
|
+
* value?: string,
|
|
522
|
+
* store?: CookieStore,
|
|
523
|
+
* candidates: number,
|
|
524
|
+
* encryptedOnly: boolean,
|
|
525
|
+
* }}
|
|
526
|
+
*/
|
|
527
|
+
export function findCookie(options) {
|
|
528
|
+
const stores = options.stores ?? discoverCookieStores(options);
|
|
529
|
+
const readable = stores.filter((store) => store.readability === "plaintext");
|
|
530
|
+
let scanned = 0;
|
|
531
|
+
|
|
532
|
+
for (const store of readable) {
|
|
533
|
+
scanned += 1;
|
|
534
|
+
const query = { hostLike: `%${options.host}%`, name: options.name };
|
|
535
|
+
const rows = store.browser === "firefox"
|
|
536
|
+
? readFirefoxCookies(store.path, query)
|
|
537
|
+
: readChromiumCookies(store.path, query);
|
|
538
|
+
for (const row of rows) {
|
|
539
|
+
if (row.value && row.value.length > 0) {
|
|
540
|
+
return { found: true, value: row.value, store, candidates: scanned, encryptedOnly: false };
|
|
541
|
+
}
|
|
542
|
+
if (store.browser === "chromium" && row.encrypted_value?.length > 0) {
|
|
543
|
+
const value = decryptLinuxChromiumCookie(row.encrypted_value, {
|
|
544
|
+
app: store.app ?? "chromium",
|
|
545
|
+
secretTool: options.secretTool,
|
|
546
|
+
});
|
|
547
|
+
if (value) return { found: true, value, store, candidates: scanned, encryptedOnly: false };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
found: false,
|
|
554
|
+
candidates: scanned,
|
|
555
|
+
encryptedOnly: scanned === 0 && stores.length > 0,
|
|
556
|
+
};
|
|
557
|
+
}
|