pi-oracle 0.7.21 → 0.7.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +49 -385
  3. package/docs/ORACLE_DESIGN.md +1 -1
  4. package/docs/development.md +99 -0
  5. package/docs/platform-smoke.md +18 -7
  6. package/docs/reference.md +338 -0
  7. package/extensions/oracle/index.ts +1 -0
  8. package/extensions/oracle/lib/poller.ts +3 -8
  9. package/extensions/oracle/lib/queue.ts +2 -4
  10. package/extensions/oracle/shared/browser-profile-helpers.d.mts +1 -0
  11. package/extensions/oracle/shared/browser-profile-helpers.mjs +32 -0
  12. package/extensions/oracle/worker/auth-bootstrap.mjs +35 -6
  13. package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +1 -0
  14. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +18 -13
  15. package/extensions/oracle/worker/chromium-cookie-source.mjs +50 -6
  16. package/extensions/oracle/worker/linux-chromium-cookie-source.d.mts +6 -0
  17. package/extensions/oracle/worker/linux-chromium-cookie-source.mjs +98 -0
  18. package/extensions/oracle/worker/run-job.mjs +34 -34
  19. package/package.json +12 -11
  20. package/platform-smoke.config.mjs +1 -22
  21. package/scripts/oracle-auth.test.mjs +35 -0
  22. package/scripts/oracle-chatgpt-preset-proof.mjs +50 -21
  23. package/scripts/oracle-cookie-import.test.mjs +204 -0
  24. package/scripts/oracle-model.test.mjs +24 -17
  25. package/scripts/oracle-real-smoke.mjs +30 -9
  26. package/scripts/platform-smoke/Dockerfile.ubuntu +3 -2
  27. package/scripts/platform-smoke/crabbox-runner.mjs +18 -6
  28. package/scripts/platform-smoke/doctor.mjs +58 -41
  29. package/scripts/platform-smoke/invariants.mjs +254 -26
  30. package/scripts/platform-smoke/platform-build-windows.ps1 +21 -17
  31. package/scripts/platform-smoke/targets.mjs +59 -30
  32. package/scripts/platform-smoke.mjs +47 -14
@@ -97,6 +97,38 @@ export function linuxChromiumCookieImportUserDataDirs(options = {}) {
97
97
  return LINUX_COOKIE_IMPORT_USER_DATA_RELATIVE_DIRS.map((segments) => join(configHome, ...segments));
98
98
  }
99
99
 
100
+ /**
101
+ * Match only native Chromium sources under the same roots as autodetection.
102
+ * Profile names alone remain Chrome selectors, as in Sweet Cookie.
103
+ * @param {string} source
104
+ * @param {BrowserPathOptions} [options]
105
+ * @returns {boolean}
106
+ */
107
+ export function isNativeLinuxChromiumCookieSource(source, options = {}) {
108
+ if (!source.includes("/") && !source.includes("\\")) return false;
109
+ if (isAbsolute(source) && !source.includes("chromium")) return false;
110
+ return matchesNativeLinuxChromiumCookieSource(source, options);
111
+ }
112
+
113
+ /**
114
+ * @param {string} source
115
+ * @param {BrowserPathOptions} options
116
+ * @returns {boolean}
117
+ */
118
+ function matchesNativeLinuxChromiumCookieSource(source, options) {
119
+ const path = normalizedAbsolutePath(source, options);
120
+ let profile = path;
121
+ if (basename(profile) === "Cookies") {
122
+ profile = dirname(profile);
123
+ if (basename(profile) === "Network") profile = dirname(profile);
124
+ }
125
+ const root = dirname(profile);
126
+ if (basename(root) !== "chromium" && basename(root) !== "chromium-browser") return false;
127
+ if (!linuxChromiumCookieImportUserDataDirs(options).includes(root)) return false;
128
+ return basename(path) === "Cookies" ? existsSync(path)
129
+ : existsSync(join(profile, "Cookies")) || existsSync(join(profile, "Network", "Cookies"));
130
+ }
131
+
100
132
  /**
101
133
  * @param {BrowserPathOptions} [options]
102
134
  * @returns {string[]}
@@ -12,6 +12,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
12
12
  import { getCookies } from "@steipete/sweet-cookie";
13
13
  import {
14
14
  assertNotKnownBrowserUserDataPath,
15
+ isNativeLinuxChromiumCookieSource,
15
16
  sweetCookieSafeStoragePasswordScrubbedEnv,
16
17
  } from "../shared/browser-profile-helpers.mjs";
17
18
  import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
@@ -340,10 +341,19 @@ async function launchTargetBrowser() {
340
341
  await closeTargetBrowser();
341
342
  const args = [...targetBrowserBaseArgs({ withLaunchOptions: true, mode: "headed" }), "open", "about:blank"];
342
343
  await log(`Launching isolated browser: agent-browser ${JSON.stringify(args)}`);
343
- const result = await spawnCommand(AGENT_BROWSER_BIN, args, { allowFailure: true, timeoutMs: AGENT_BROWSER_COMMAND_TIMEOUT_MS });
344
- await log(`launch result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
345
- if (result.code !== 0) {
346
- throw new Error(result.stderr || result.stdout || "Failed to launch isolated oracle browser");
344
+ const deadline = Date.now() + AGENT_BROWSER_COMMAND_TIMEOUT_MS;
345
+ while (true) {
346
+ const result = await spawnCommand(AGENT_BROWSER_BIN, args, { allowFailure: true, timeoutMs: Math.max(1, deadline - Date.now()) });
347
+ await log(`launch result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
348
+ if (result.code === 0) return;
349
+ const message = result.stderr || result.stdout || "Failed to launch isolated oracle browser";
350
+ // agent-browser 0.39 can return from close before its daemon socket disappears.
351
+ // This configure failure happens before open is dispatched; other launch errors are not safe to retry.
352
+ if (!/Could not configure browser: Failed to connect: No such file or directory/i.test(message) || Date.now() >= deadline) {
353
+ throw new Error(message);
354
+ }
355
+ await log("Waiting for the closing agent-browser daemon before retrying launch");
356
+ await sleep(Math.min(250, deadline - Date.now()));
347
357
  }
348
358
  }
349
359
 
@@ -573,6 +583,15 @@ function formatAuthFailureGuidance(error) {
573
583
  return lines.join("\n");
574
584
  }
575
585
 
586
+ function canUseCalibratedChromeKey(source) {
587
+ if (!source.includes("/") && !source.includes("\\")) return false;
588
+ // Match Sweet Cookie's candidate expansion, not normalized source qualification:
589
+ // a literal absolute Cookies path can retain a Brave selector before "..".
590
+ const candidate = source.startsWith("~/") ? join(homedir(), source.slice(2))
591
+ : isAbsolute(source) ? source : resolve(process.cwd(), source);
592
+ return !/bravesoftware|brave-browser|brave browser/i.test(candidate);
593
+ }
594
+
576
595
  async function readRawSourceCookies() {
577
596
  if (config.auth.chromeCookiePath && config.auth.chromiumKeychain) {
578
597
  return await getCookiesFromConfiguredChromiumSource({
@@ -584,14 +603,24 @@ async function readRawSourceCookies() {
584
603
  });
585
604
  }
586
605
 
587
- return await getCookies({
606
+ const options = {
588
607
  url: providerChatUrl(),
589
608
  origins: cookieOrigins(),
590
609
  browsers: ["chrome"],
591
610
  mode: "merge",
592
611
  chromeProfile: cookieSource(),
593
612
  timeoutMs: COOKIE_READ_TIMEOUT_MS,
594
- });
613
+ };
614
+ if (process.platform === "linux") {
615
+ if (process.env.SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD?.trim() && canUseCalibratedChromeKey(options.chromeProfile)) {
616
+ return await getCookies(options);
617
+ }
618
+ if (isNativeLinuxChromiumCookieSource(options.chromeProfile)) {
619
+ const { getCookiesFromNativeLinuxChromium } = await import("./linux-chromium-cookie-source.mjs");
620
+ return await getCookiesFromNativeLinuxChromium(options, getCookies);
621
+ }
622
+ }
623
+ return await getCookies(options);
595
624
  }
596
625
 
597
626
  async function readSourceCookies() {
@@ -16,6 +16,7 @@ export declare function matchesModelFamilyLabel(label: string | undefined, famil
16
16
  export declare function matchesRequestedModelControlLabel(label: string | undefined, selection: OracleUiSelection): boolean;
17
17
  export declare function matchesCompactIntelligenceControlLabel(label: string | undefined): boolean;
18
18
  export declare function matchesCompactIntelligenceOpenerLabel(label: string | undefined): boolean;
19
+ export declare function matchesModelConfigurationOpener(entry: import("./artifact-heuristics.mjs").SnapshotEntry): boolean;
19
20
  export declare function requestedEffortLabel(selection: OracleUiSelection): string | undefined;
20
21
  export declare function effortSelectionVisible(snapshot: string, effortLabel: string | undefined): boolean;
21
22
  export declare function thinkingChipVisible(snapshot: string): boolean;
@@ -522,24 +522,29 @@ export function snapshotHasUsableComposerControls(snapshot) {
522
522
  return hasComposer && hasAddFiles;
523
523
  }
524
524
 
525
+ /**
526
+ * @param {SnapshotEntry} entry
527
+ * @returns {boolean}
528
+ */
529
+ export function matchesModelConfigurationOpener(entry) {
530
+ if (entry.disabled || entry.kind !== "button" || typeof entry.label !== "string") return false;
531
+ const label = normalizeChipLabel(entry.label);
532
+ return label === "Model"
533
+ || label === "Model selector"
534
+ || label === "Select ChatGPT model"
535
+ || matchesCompactIntelligenceOpenerLabel(label)
536
+ || BARE_EFFORT_PATTERN.test(label)
537
+ || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {OracleUiModelFamily} */ (family)))
538
+ || THINKING_CHIP_PATTERN.test(label)
539
+ || PRO_CHIP_PATTERN.test(label);
540
+ }
541
+
525
542
  /**
526
543
  * @param {string} snapshot
527
544
  * @returns {boolean}
528
545
  */
529
546
  export function snapshotHasModelOpener(snapshot) {
530
- /** @type {SnapshotEntry[]} */
531
- const entries = parseSnapshotEntries(snapshot);
532
- return entries.some((entry) => {
533
- if (entry.disabled || entry.kind !== "button" || typeof entry.label !== "string") return false;
534
- const label = normalizeChipLabel(entry.label);
535
- return label === "Model"
536
- || label === "Model selector"
537
- || COMPACT_INTELLIGENCE_OPENER_PATTERN.test(label)
538
- || EFFORT_LABELS.has(label)
539
- || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {OracleUiModelFamily} */ (family)))
540
- || THINKING_CHIP_PATTERN.test(label)
541
- || PRO_CHIP_PATTERN.test(label);
542
- });
547
+ return parseSnapshotEntries(snapshot).some(matchesModelConfigurationOpener);
543
548
  }
544
549
 
545
550
  /**
@@ -1,8 +1,8 @@
1
- // Purpose: Read provider cookies from arbitrary macOS Chromium-family cookie stores when sweet-cookie's built-in browser list is too narrow.
2
- // Responsibilities: Snapshot a Chromium Cookies SQLite DB, decrypt AES-CBC cookie values with a configured Keychain item, and return sweet-cookie-shaped cookie objects.
3
- // Scope: macOS Chromium cookie extraction only; auth policy filtering and browser seeding stay in auth-bootstrap.mjs.
4
- // Usage: auth-bootstrap.mjs uses this when auth.chromiumKeychain is configured alongside auth.chromeCookiePath.
5
- // Invariants/Assumptions: The configured cookie path points at a Chromium Cookies DB and the configured Keychain item is the browser's safe-storage secret.
1
+ // Purpose: Read provider cookies from configured macOS Chromium stores.
2
+ // Responsibilities: Snapshot and decrypt configured macOS cookie stores.
3
+ // Scope: macOS cookie extraction only; auth policy filtering and browser seeding stay in auth-bootstrap.mjs.
4
+ // Usage: auth-bootstrap.mjs calls the configured macOS API after selecting the source.
5
+ // Invariants/Assumptions: Configured Keychain items identify the browser secret; helper environments are scrubbed.
6
6
  import { spawn } from "node:child_process";
7
7
  import { sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
8
8
  import { createDecipheriv, pbkdf2Sync } from "node:crypto";
@@ -11,10 +11,20 @@ import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { DatabaseSync } from "node:sqlite";
13
13
 
14
+ /** @typedef {import("./chromium-cookie-source.d.mts").ChromiumKeychainConfig} ChromiumKeychainConfig */
15
+ /** @typedef {import("./chromium-cookie-source.d.mts").ConfiguredChromiumSourceOptions} ConfiguredChromiumSourceOptions */
16
+ /** @typedef {import("./auth-cookie-policy.mjs").ImportedAuthCookie} ImportedAuthCookie */
17
+
14
18
  const CHROMIUM_EPOCH_OFFSET_SECONDS = 11_644_473_600n;
15
19
  const COOKIE_VALUE_DECODER = new TextDecoder("utf-8", { fatal: true });
16
20
  const MACOS_CHROMIUM_KEY_ITERATIONS = 1003;
17
21
 
22
+ /**
23
+ * @param {string} command
24
+ * @param {string[]} args
25
+ * @param {{timeoutMs?: number}} [options]
26
+ * @returns {Promise<{ok: boolean, stdout: string, stderr: string, error: string}>}
27
+ */
18
28
  function spawnCapture(command, args, options = {}) {
19
29
  return new Promise((resolve) => {
20
30
  const child = spawn(command, args, { env: sweetCookieSafeStoragePasswordScrubbedEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
@@ -46,9 +56,14 @@ function spawnCapture(command, args, options = {}) {
46
56
  });
47
57
  }
48
58
 
59
+ /**
60
+ * @param {ChromiumKeychainConfig} keychain
61
+ * @param {number} timeoutMs
62
+ * @returns {Promise<{ok: true, password: string, service: string} | {ok: false, error: string}>}
63
+ */
49
64
  async function readKeychainPassword(keychain, timeoutMs) {
50
65
  const services = Array.isArray(keychain.services) && keychain.services.length > 0 ? keychain.services : [keychain.service];
51
- for (const service of services.filter(Boolean)) {
66
+ for (const service of services.filter(/** @returns {service is string} */ (service) => Boolean(service))) {
52
67
  const result = await spawnCapture("security", ["find-generic-password", "-w", "-a", keychain.account, "-s", service], { timeoutMs });
53
68
  if (result.ok) {
54
69
  const password = result.stdout.trim();
@@ -59,6 +74,7 @@ async function readKeychainPassword(keychain, timeoutMs) {
59
74
  return { ok: false, error: `Failed to read macOS Keychain (${keychain.label || keychain.account}): no configured service returned a password.` };
60
75
  }
61
76
 
77
+ /** @param {string} dbPath */
62
78
  function snapshotCookieDb(dbPath) {
63
79
  const tempDir = mkdtempSync(join(tmpdir(), "pi-oracle-chromium-cookies-"));
64
80
  const tempDbPath = join(tempDir, "Cookies");
@@ -73,6 +89,7 @@ function snapshotCookieDb(dbPath) {
73
89
  }
74
90
  }
75
91
 
92
+ /** @param {string} sourceDbPath @param {string} targetPath @param {string} suffix */
76
93
  function copySidecar(sourceDbPath, targetPath, suffix) {
77
94
  const sidecarPath = `${sourceDbPath}${suffix}`;
78
95
  try {
@@ -83,10 +100,12 @@ function copySidecar(sourceDbPath, targetPath, suffix) {
83
100
  }
84
101
  }
85
102
 
103
+ /** @param {unknown} error */
86
104
  function isMissingFileError(error) {
87
105
  return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
88
106
  }
89
107
 
108
+ /** @param {DatabaseSync} db */
90
109
  function readMetaVersion(db) {
91
110
  try {
92
111
  const row = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
@@ -97,6 +116,7 @@ function readMetaVersion(db) {
97
116
  }
98
117
  }
99
118
 
119
+ /** @param {string} host */
100
120
  function parentCookieDomains(host) {
101
121
  const labels = host.split(".").filter(Boolean);
102
122
  const domains = new Set([host, `.${host}`]);
@@ -108,11 +128,14 @@ function parentCookieDomains(host) {
108
128
  return [...domains];
109
129
  }
110
130
 
131
+ /** @param {string} value */
111
132
  function sqlStringLiteral(value) {
112
133
  return `'${value.replaceAll("'", "''")}'`;
113
134
  }
114
135
 
136
+ /** @param {string[]} origins */
115
137
  function buildHostWhereClause(origins) {
138
+ /** @type {Set<string>} */
116
139
  const domains = new Set();
117
140
  for (const origin of origins) {
118
141
  try {
@@ -125,11 +148,13 @@ function buildHostWhereClause(origins) {
125
148
  return `host_key IN (${[...domains].map(sqlStringLiteral).join(", ")})`;
126
149
  }
127
150
 
151
+ /** @param {string[]} originHosts @param {string} hostKey */
128
152
  function hostMatchesAny(originHosts, hostKey) {
129
153
  const cookieDomain = hostKey.startsWith(".") ? hostKey.slice(1) : hostKey;
130
154
  return originHosts.some((host) => host === cookieDomain || host.endsWith(`.${cookieDomain}`));
131
155
  }
132
156
 
157
+ /** @param {unknown} value */
133
158
  function chromiumExpirationToUnixSeconds(value) {
134
159
  if (value === undefined || value === null || String(value) === "0") return undefined;
135
160
  try {
@@ -142,6 +167,7 @@ function chromiumExpirationToUnixSeconds(value) {
142
167
  }
143
168
  }
144
169
 
170
+ /** @param {unknown} value @returns {ImportedAuthCookie["sameSite"]} */
145
171
  function normalizeSameSite(value) {
146
172
  const normalized = String(value ?? "").toLowerCase();
147
173
  if (normalized === "2" || normalized === "strict") return "Strict";
@@ -150,10 +176,12 @@ function normalizeSameSite(value) {
150
176
  return undefined;
151
177
  }
152
178
 
179
+ /** @param {string} password */
153
180
  function deriveMacosChromiumKey(password) {
154
181
  return pbkdf2Sync(password, "saltysalt", MACOS_CHROMIUM_KEY_ITERATIONS, 16, "sha1");
155
182
  }
156
183
 
184
+ /** @param {Uint8Array} encryptedValue @param {Buffer} key @param {{stripHashPrefix: boolean}} options */
157
185
  function decryptCookieValue(encryptedValue, key, options) {
158
186
  const buffer = Buffer.from(encryptedValue);
159
187
  if (buffer.length < 3) return null;
@@ -172,6 +200,7 @@ function decryptCookieValue(encryptedValue, key, options) {
172
200
  }
173
201
  }
174
202
 
203
+ /** @param {Buffer} value */
175
204
  function removePkcs7Padding(value) {
176
205
  if (!value.length) return value;
177
206
  const padding = value[value.length - 1];
@@ -179,6 +208,7 @@ function removePkcs7Padding(value) {
179
208
  return value.subarray(0, value.length - padding);
180
209
  }
181
210
 
211
+ /** @param {Uint8Array} value @param {boolean} stripHashPrefix */
182
212
  function decodeCookieBytes(value, stripHashPrefix) {
183
213
  const bytes = stripHashPrefix && value.length >= 32 ? value.subarray(32) : value;
184
214
  try {
@@ -188,12 +218,19 @@ function decodeCookieBytes(value, stripHashPrefix) {
188
218
  }
189
219
  }
190
220
 
221
+ /** @param {string} value */
191
222
  function stripLeadingControlChars(value) {
192
223
  let index = 0;
193
224
  while (index < value.length && value.charCodeAt(index) < 0x20) index += 1;
194
225
  return value.slice(index);
195
226
  }
196
227
 
228
+ /**
229
+ * @param {Record<string, unknown>[]} rows
230
+ * @param {Pick<ConfiguredChromiumSourceOptions, "origins" | "profile" | "includeExpired"> & {stripHashPrefix: boolean}} options
231
+ * @param {Buffer} key
232
+ * @param {string[]} warnings
233
+ */
197
234
  function collectCookies(rows, options, key, warnings) {
198
235
  const cookies = [];
199
236
  const now = Math.floor(Date.now() / 1000);
@@ -221,6 +258,7 @@ function collectCookies(rows, options, key, warnings) {
221
258
  const expires = chromiumExpirationToUnixSeconds(row.expires_utc);
222
259
  if (!options.includeExpired && expires !== undefined && expires < now) continue;
223
260
 
261
+ /** @type {ImportedAuthCookie & {source: {browser: string, profile: string}}} */
224
262
  const cookie = {
225
263
  name,
226
264
  value,
@@ -239,7 +277,9 @@ function collectCookies(rows, options, key, warnings) {
239
277
  return dedupeCookies(cookies);
240
278
  }
241
279
 
280
+ /** @param {ImportedAuthCookie[]} cookies */
242
281
  function dedupeCookies(cookies) {
282
+ /** @type {Map<string, ImportedAuthCookie>} */
243
283
  const seen = new Map();
244
284
  for (const cookie of cookies) {
245
285
  const key = `${cookie.domain}\t${cookie.path}\t${cookie.name}`;
@@ -248,6 +288,10 @@ function dedupeCookies(cookies) {
248
288
  return [...seen.values()];
249
289
  }
250
290
 
291
+ /**
292
+ * @param {ConfiguredChromiumSourceOptions} options
293
+ * @returns {Promise<{cookies: ImportedAuthCookie[], warnings: string[]}>}
294
+ */
251
295
  export async function getCookiesFromConfiguredChromiumSource(options) {
252
296
  const warnings = [];
253
297
  if (!options.dbPath || !existsSync(options.dbPath)) {
@@ -0,0 +1,6 @@
1
+ import type { GetCookiesOptions, GetCookiesResult } from "@steipete/sweet-cookie";
2
+
3
+ export function getCookiesFromNativeLinuxChromium(
4
+ options: GetCookiesOptions,
5
+ readCookies: typeof import("@steipete/sweet-cookie").getCookies,
6
+ ): Promise<GetCookiesResult>;
@@ -0,0 +1,98 @@
1
+ // Purpose: Select native Linux Chromium's safe-storage key for the public Sweet Cookie parser.
2
+ // Scope: Called only after source qualification in the short-lived auth worker.
3
+ // Invariants: Restore password/backend overrides and scrub keyring helper environments.
4
+ import { execFile } from "node:child_process";
5
+ import { sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
6
+
7
+ // ponytail: Native Linux Chromium key selection only. Keep Sweet Cookie 0.3's
8
+ // public parser/decryptor; remove when an upstream fix passes unchanged-path
9
+ // performance checks. This API runs only in the short-lived auth worker.
10
+ const PASSWORD_ENV = "SWEET_COOKIE_CHROME_SAFE_STORAGE_PASSWORD";
11
+ const BRAVE_PASSWORD_ENV = "SWEET_COOKIE_BRAVE_SAFE_STORAGE_PASSWORD";
12
+ const BACKEND_ENV = "SWEET_COOKIE_LINUX_KEYRING";
13
+
14
+ /** @param {string} command @param {string[]} args @returns {Promise<string | undefined>} */
15
+ function readHelper(command, args) {
16
+ return new Promise((resolve) => {
17
+ execFile(command, args, {
18
+ env: sweetCookieSafeStoragePasswordScrubbedEnv(),
19
+ encoding: "utf8", timeout: 3_000, killSignal: "SIGKILL",
20
+ }, (error, stdout) => resolve(error ? undefined : stdout.trim()));
21
+ });
22
+ }
23
+
24
+ function keyringBackend() {
25
+ const configured = process.env[BACKEND_ENV]?.trim().toLowerCase();
26
+ if (configured === "gnome" || configured === "kwallet" || configured === "basic") return configured;
27
+ const kde = process.env.XDG_CURRENT_DESKTOP?.split(":").some((part) => part.trim().toLowerCase() === "kde")
28
+ || Boolean(process.env.KDE_FULL_SESSION?.trim());
29
+ return kde ? "kwallet" : "gnome";
30
+ }
31
+
32
+ async function readChromiumPassword() {
33
+ const backend = keyringBackend();
34
+ if (backend === "basic") return { password: "", warnings: [] };
35
+ if (backend === "gnome") {
36
+ const password = await readHelper("secret-tool", ["lookup", "service", "Chromium Safe Storage", "account", "Chromium"])
37
+ || await readHelper("secret-tool", ["lookup", "application", "chromium"]);
38
+ return { password: password || "", warnings: password ? [] : [
39
+ "Failed to read Linux keyring via secret-tool; v11 cookies may be unavailable.",
40
+ ] };
41
+ }
42
+ const version = process.env.KDE_SESSION_VERSION?.trim();
43
+ const suffix = version === "5" || version === "6" ? version : "";
44
+ const wallet = await readHelper("dbus-send", [
45
+ "--session", "--print-reply=literal", `--dest=org.kde.kwalletd${suffix}`,
46
+ `/modules/kwalletd${suffix}`, "org.kde.KWallet.networkWallet",
47
+ ]);
48
+ const password = await readHelper("kwallet-query", [
49
+ "--read-password", "Chromium Safe Storage", "--folder", "Chromium Keys",
50
+ wallet?.replaceAll('"', "").trim() || "kdewallet",
51
+ ]);
52
+ return {
53
+ password: password?.toLowerCase().startsWith("failed to read") ? "" : password || "",
54
+ warnings: password === undefined ? [
55
+ "Failed to read Linux keyring via kwallet-query; v11 cookies may be unavailable.",
56
+ ] : [],
57
+ };
58
+ }
59
+
60
+ /**
61
+ * @param {import("@steipete/sweet-cookie").GetCookiesOptions} options
62
+ * @param {typeof import("@steipete/sweet-cookie").getCookies} readCookies
63
+ */
64
+ export async function getCookiesFromNativeLinuxChromium(options, readCookies) {
65
+ const override = process.env[PASSWORD_ENV]?.trim();
66
+ const { password, warnings } = override
67
+ ? { password: override, warnings: [] }
68
+ : await readChromiumPassword();
69
+ const previous = {
70
+ [PASSWORD_ENV]: process.env[PASSWORD_ENV],
71
+ [BRAVE_PASSWORD_ENV]: process.env[BRAVE_PASSWORD_ENV],
72
+ [BACKEND_ENV]: process.env[BACKEND_ENV],
73
+ };
74
+ try {
75
+ // Sweet Cookie 0.3 classifies Brave by substrings anywhere in the DB path.
76
+ // Even a Chromium XDG/HOME ancestor can trigger it. Supply the selected
77
+ // Chromium key to either branch so neither can spawn a vendor keyring helper.
78
+ if (password) {
79
+ process.env[PASSWORD_ENV] = password;
80
+ process.env[BRAVE_PASSWORD_ENV] = password;
81
+ }
82
+ // Empty env values are ignored by Sweet Cookie. Its basic backend supplies
83
+ // the empty key without accidentally probing Chrome's keyring instead.
84
+ else {
85
+ delete process.env[PASSWORD_ENV];
86
+ delete process.env[BRAVE_PASSWORD_ENV];
87
+ process.env[BACKEND_ENV] = "basic";
88
+ }
89
+ const result = await readCookies(options);
90
+ result.warnings.unshift(...warnings);
91
+ return result;
92
+ } finally {
93
+ for (const [name, value] of Object.entries(previous)) {
94
+ if (value === undefined) delete process.env[name];
95
+ else process.env[name] = value;
96
+ }
97
+ }
98
+ }