pi-oracle 0.7.20 → 0.7.22

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 (36) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +49 -377
  3. package/docs/ORACLE_DESIGN.md +5 -2
  4. package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +9 -7
  5. package/docs/development.md +99 -0
  6. package/docs/platform-smoke.md +3 -3
  7. package/docs/reference.md +331 -0
  8. package/extensions/oracle/index.ts +1 -0
  9. package/extensions/oracle/lib/poller.ts +3 -8
  10. package/extensions/oracle/lib/queue.ts +2 -4
  11. package/extensions/oracle/shared/browser-profile-helpers.d.mts +1 -0
  12. package/extensions/oracle/shared/browser-profile-helpers.mjs +32 -0
  13. package/extensions/oracle/worker/auth-bootstrap.mjs +65 -12
  14. package/extensions/oracle/worker/auth-flow-helpers.d.mts +2 -0
  15. package/extensions/oracle/worker/auth-flow-helpers.mjs +24 -3
  16. package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +4 -1
  17. package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +1 -0
  18. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +7 -3
  19. package/extensions/oracle/worker/chromium-cookie-source.mjs +50 -6
  20. package/extensions/oracle/worker/linux-chromium-cookie-source.d.mts +6 -0
  21. package/extensions/oracle/worker/linux-chromium-cookie-source.mjs +98 -0
  22. package/extensions/oracle/worker/run-job.mjs +72 -7
  23. package/package.json +15 -11
  24. package/platform-smoke.config.mjs +1 -22
  25. package/scripts/oracle-auth.test.mjs +164 -0
  26. package/scripts/oracle-chatgpt-preset-proof.mjs +19 -12
  27. package/scripts/oracle-cookie-import.test.mjs +204 -0
  28. package/scripts/oracle-model.test.mjs +87 -0
  29. package/scripts/oracle-real-smoke.mjs +30 -13
  30. package/scripts/platform-smoke/Dockerfile.ubuntu +3 -2
  31. package/scripts/platform-smoke/crabbox-runner.mjs +3 -5
  32. package/scripts/platform-smoke/doctor.mjs +9 -24
  33. package/scripts/platform-smoke/invariants.mjs +234 -22
  34. package/scripts/platform-smoke/platform-build-windows.ps1 +4 -16
  35. package/scripts/platform-smoke/targets.mjs +35 -27
  36. package/scripts/platform-smoke.mjs +2 -2
@@ -12,14 +12,15 @@ 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";
18
19
  import { getCookiesFromConfiguredChromiumSource } from "./chromium-cookie-source.mjs";
19
20
  import { parseSnapshotEntries } from "./artifact-heuristics.mjs";
20
- import { buildAllowedChatGptOrigins } from "./chatgpt-ui-helpers.mjs";
21
+ import { buildAllowedChatGptOrigins, CHATGPT_COMPOSER_LABELS } from "./chatgpt-ui-helpers.mjs";
21
22
  import { stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
22
- import { buildAccountChooserCandidateLabels, classifyChatAuthPage, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
23
+ import { buildAccountChooserCandidateLabels, classifyChatAuthPage, isAuthNavigationError, normalizeLoginProbeResult } from "./auth-flow-helpers.mjs";
23
24
 
24
25
  const rawConfig = process.argv[2];
25
26
  if (!rawConfig) {
@@ -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
 
@@ -427,7 +437,7 @@ async function snapshotText() {
427
437
  }
428
438
 
429
439
  async function pageText() {
430
- const { stdout } = await targetCommand("get", "text", "body", { allowFailure: true, logLabel: "get text body" });
440
+ const { stdout } = await targetCommand("get", "text", "body", { logLabel: "get text body" });
431
441
  return stdout || "";
432
442
  }
433
443
 
@@ -515,7 +525,16 @@ function formatAuthFailureGuidance(error) {
515
525
  const reason = error instanceof Error ? error.message : String(error);
516
526
  const lines = ["Oracle auth failed.", "", `Reason: ${reason}`, "", "Likely causes:"];
517
527
 
518
- if (usesConfiguredChromiumCookieSource()) {
528
+ if (isAuthNavigationError(error) || /browser was closed|Timed out verifying synced/i.test(reason)) {
529
+ lines.push(
530
+ "- the isolated browser navigated, closed, or never settled during verification",
531
+ "- this browser verification failure does not by itself prove the source cookies are invalid",
532
+ "",
533
+ "Next:",
534
+ "1. Re-run /oracle-auth and leave the isolated browser open while verification completes.",
535
+ "2. If it fails again, inspect the diagnostics below.",
536
+ );
537
+ } else if (usesConfiguredChromiumCookieSource()) {
519
538
  lines.push(
520
539
  "- the configured cookie DB is stale or from the wrong browser profile",
521
540
  `- ${providerName()} is logged out in that browser profile`,
@@ -564,6 +583,15 @@ function formatAuthFailureGuidance(error) {
564
583
  return lines.join("\n");
565
584
  }
566
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
+
567
595
  async function readRawSourceCookies() {
568
596
  if (config.auth.chromeCookiePath && config.auth.chromiumKeychain) {
569
597
  return await getCookiesFromConfiguredChromiumSource({
@@ -575,14 +603,24 @@ async function readRawSourceCookies() {
575
603
  });
576
604
  }
577
605
 
578
- return await getCookies({
606
+ const options = {
579
607
  url: providerChatUrl(),
580
608
  origins: cookieOrigins(),
581
609
  browsers: ["chrome"],
582
610
  mode: "merge",
583
611
  chromeProfile: cookieSource(),
584
612
  timeoutMs: COOKIE_READ_TIMEOUT_MS,
585
- });
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);
586
624
  }
587
625
 
588
626
  async function readSourceCookies() {
@@ -708,6 +746,7 @@ function buildLoginProbeScript(timeoutMs) {
708
746
  let bodyKeys = [];
709
747
  let bodyHasId = false;
710
748
  let bodyHasEmail = false;
749
+ let bodyIsAnonymous = false;
711
750
  let resultName = '';
712
751
  let responsePreview = '';
713
752
  try {
@@ -727,6 +766,7 @@ function buildLoginProbeScript(timeoutMs) {
727
766
  if (data && typeof data === 'object' && !Array.isArray(data)) {
728
767
  bodyKeys = Object.keys(data).slice(0, 12);
729
768
  bodyHasId = typeof data.id === 'string' && data.id.length > 0;
769
+ bodyIsAnonymous = typeof data.id === 'string' && data.id.startsWith('ua-');
730
770
  bodyHasEmail = typeof data.email === 'string' && data.email.includes('@');
731
771
  const name = typeof data.name === 'string' ? data.name.trim() : '';
732
772
  if (name) resultName = name;
@@ -757,6 +797,7 @@ function buildLoginProbeScript(timeoutMs) {
757
797
  bodyKeys,
758
798
  bodyHasId,
759
799
  bodyHasEmail,
800
+ bodyIsAnonymous,
760
801
  name: resultName,
761
802
  responsePreview,
762
803
  };
@@ -868,13 +909,25 @@ async function waitForImportedAuthReady() {
868
909
  let iteration = 0;
869
910
  while (Date.now() < timeoutAt) {
870
911
  iteration += 1;
871
- const [url, snapshot, body, probe] = await Promise.all([getUrl(), snapshotText(), pageText(), loginProbe()]);
912
+ // Drain all reads before retrying or closing the browser. Promise.all can
913
+ // reject while the other CDP commands are still running against the page.
914
+ const reads = await Promise.allSettled([getUrl(), snapshotText(), pageText(), loginProbe()]);
915
+ const failures = reads.filter((read) => read.status === "rejected");
916
+ const fatal = failures.find((read) => !isAuthNavigationError(read.reason));
917
+ if (fatal) throw fatal.reason;
918
+ if (failures.length > 0) {
919
+ await log(`poll ${iteration}: auth page navigated during verification; discarding partial reads and retrying`);
920
+ await ensureBrowserConnected();
921
+ await sleep(config.auth.pollMs);
922
+ continue;
923
+ }
924
+ const [url, snapshot, body, probe] = reads.map((read) => read.value);
872
925
  await writeFile(URL_PATH, `${url}\n`, { mode: 0o600 }).catch(() => undefined);
873
926
  await writeFile(SNAPSHOT_PATH, `${snapshot}\n`, { mode: 0o600 }).catch(() => undefined);
874
927
  await writeFile(BODY_PATH, `${body}\n`, { mode: 0o600 }).catch(() => undefined);
875
928
  const classification = classifyChatPage({ url, snapshot, body, probe });
876
929
  await log(
877
- `poll ${iteration}: url=${JSON.stringify(url)} probe=${JSON.stringify(probe)} classification=${classification.state} hasComposer=${preferredProvider() === "grok" ? snapshot.includes('Ask Grok anything') || snapshot.includes('contenteditable') : snapshot.includes(`textbox \"${CHATGPT_LABELS.composer}\"`)} hasAddFiles=${preferredProvider() === "grok" ? snapshot.includes('button \"Attach\"') : snapshot.includes(`button \"${CHATGPT_LABELS.addFiles}\"`)}`,
930
+ `poll ${iteration}: url=${JSON.stringify(url)} probe=${JSON.stringify(probe)} classification=${classification.state} hasComposer=${preferredProvider() === "grok" ? snapshot.includes('Ask Grok anything') || snapshot.includes('contenteditable') : CHATGPT_COMPOSER_LABELS.some((label) => snapshot.includes(`textbox \"${label}\"`))} hasAddFiles=${preferredProvider() === "grok" ? snapshot.includes('button \"Attach\"') : snapshot.includes(`button \"${CHATGPT_LABELS.addFiles}\"`)}`,
878
931
  );
879
932
  if (classification.state === "authenticated_and_ready") return classification;
880
933
  if (classification.state === "auth_transitioning") {
@@ -8,6 +8,7 @@ export interface OracleAuthLoginProbe {
8
8
  bodyKeys?: string[];
9
9
  bodyHasId?: boolean;
10
10
  bodyHasEmail?: boolean;
11
+ bodyIsAnonymous?: boolean;
11
12
  name?: string;
12
13
  responsePreview?: string;
13
14
  }
@@ -26,6 +27,7 @@ export interface OracleAuthPageClassification {
26
27
  }
27
28
 
28
29
  export declare function normalizeLoginProbeResult(result: unknown): OracleAuthLoginProbe;
30
+ export declare function isAuthNavigationError(error: unknown): boolean;
29
31
  export declare function buildAccountChooserCandidateLabels(name?: string): string[];
30
32
  export declare function classifyChatAuthPage(args: {
31
33
  url: string;
@@ -4,7 +4,7 @@
4
4
  // Usage: Imported by auth-bootstrap.mjs and sanity tests to exercise auth classification behavior without driving a browser.
5
5
  // Invariants/Assumptions: Inputs are already captured snapshots/probe results from the live browser session; outputs are deterministic and side-effect free.
6
6
 
7
- import { snapshotHasUsableComposerControls } from "./chatgpt-ui-helpers.mjs";
7
+ import { CHATGPT_COMPOSER_LABELS, snapshotHasUsableComposerControls } from "./chatgpt-ui-helpers.mjs";
8
8
 
9
9
  /** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthLoginProbe} OracleAuthLoginProbe */
10
10
  /** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthPageClassification} OracleAuthPageClassification */
@@ -31,11 +31,23 @@ export function normalizeLoginProbeResult(result) {
31
31
  bodyKeys: Array.isArray(value.bodyKeys) ? value.bodyKeys.filter((entry) => typeof entry === "string") : [],
32
32
  bodyHasId: value.bodyHasId === true,
33
33
  bodyHasEmail: value.bodyHasEmail === true,
34
+ bodyIsAnonymous: value.bodyIsAnonymous === true,
34
35
  name: typeof value.name === "string" ? value.name : undefined,
35
36
  responsePreview: typeof value.responsePreview === "string" ? value.responsePreview : undefined,
36
37
  };
37
38
  }
38
39
 
40
+ /**
41
+ * CDP evaluations can be interrupted by normal redirects. Do not retry arbitrary
42
+ * protocol failures or explicitly closed/disconnected browser errors.
43
+ * @param {unknown} error
44
+ * @returns {boolean}
45
+ */
46
+ export function isAuthNavigationError(error) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ return /Inspected target navigated or closed|Execution context was destroyed|Cannot find context with specified id/i.test(message);
49
+ }
50
+
39
51
  /**
40
52
  * @param {string | undefined} name
41
53
  * @returns {string[]}
@@ -69,7 +81,7 @@ export function classifyChatAuthPage(args) {
69
81
  const onAllowedOrigin = args.allowedOrigins.some((origin) => args.url.startsWith(origin));
70
82
  const hasComposer = args.snapshot.includes(`textbox "${composerLabel}"`);
71
83
  const hasAddFiles = args.snapshot.includes(`button "${addFilesLabel}"`);
72
- const hasUsableComposer = composerLabel === DEFAULT_COMPOSER_LABEL && addFilesLabel === DEFAULT_ADD_FILES_LABEL
84
+ const hasUsableComposer = CHATGPT_COMPOSER_LABELS.includes(composerLabel) && addFilesLabel === DEFAULT_ADD_FILES_LABEL
73
85
  ? snapshotHasUsableComposerControls(args.snapshot)
74
86
  : hasComposer && hasAddFiles;
75
87
 
@@ -112,7 +124,16 @@ export function classifyChatAuthPage(args) {
112
124
  return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/error page. Logs: ${args.logPath}` };
113
125
  }
114
126
 
115
- const probeHasAccountIdentity = args.probe?.bodyHasId === true || args.probe?.bodyHasEmail === true;
127
+ // /backend-api/me also returns HTTP 200 and an id for anonymous visitors.
128
+ // Those ua-* identities must not drive account selection or auth reloads.
129
+ if (args.probe?.bodyIsAnonymous && (args.probe?.onAuthPage || args.probe?.domLoginCta)) {
130
+ return {
131
+ state: "login_required",
132
+ message: `Synced cookies from ${args.cookieSourceLabel}, but ChatGPT returned an anonymous visitor session, not a signed-in account. Sign in to ChatGPT in the source browser profile, quit that browser, then rerun /oracle-auth. Logs: ${args.logPath}`,
133
+ };
134
+ }
135
+
136
+ const probeHasAccountIdentity = !args.probe?.bodyIsAnonymous && (args.probe?.bodyHasId === true || args.probe?.bodyHasEmail === true);
116
137
 
117
138
  if (args.probe?.status === 401 || (args.probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer))) {
118
139
  return {
@@ -4,6 +4,8 @@
4
4
  // Usage: Imported by run-job.mjs and sanity tests to validate conversation-state heuristics without driving a browser.
5
5
  // Invariants/Assumptions: Snapshot text comes from agent-browser `snapshot -i`; URL inputs may be malformed and must fail safely.
6
6
 
7
+ import { CHATGPT_COMPOSER_LABELS } from "./chatgpt-ui-helpers.mjs";
8
+
7
9
  /** @typedef {import("./chatgpt-flow-helpers.d.mts").OracleStableValueState} OracleStableValueState */
8
10
  /** @typedef {import("./chatgpt-flow-helpers.d.mts").OracleSendAcceptanceState} OracleSendAcceptanceState */
9
11
 
@@ -23,8 +25,9 @@ export function assistantSnapshotSlice(snapshot, composerLabel, responseIndex) {
23
25
  const nextAssistantIndex = assistantHeadingIndices[responseIndex + 1];
24
26
  if (nextAssistantIndex !== undefined) endCandidates.push(nextAssistantIndex);
25
27
 
28
+ const composerLabels = CHATGPT_COMPOSER_LABELS.includes(composerLabel) ? CHATGPT_COMPOSER_LABELS : [composerLabel];
26
29
  const composerIndex = lines.findIndex(
27
- (line, index) => index > startIndex && line.includes(`textbox "${composerLabel}"`),
30
+ (line, index) => index > startIndex && composerLabels.some((label) => line.includes(`textbox "${label}"`)),
28
31
  );
29
32
  if (composerIndex !== -1) endCandidates.push(composerIndex);
30
33
 
@@ -7,6 +7,7 @@ export interface OracleUiSelection {
7
7
  autoSwitchToThinking?: boolean;
8
8
  }
9
9
 
10
+ export declare const CHATGPT_COMPOSER_LABELS: readonly string[];
10
11
  export declare const CHATGPT_CANONICAL_APP_ORIGINS: readonly string[];
11
12
 
12
13
  export declare function buildAllowedChatGptOrigins(chatUrl: string, authUrl?: string): string[];
@@ -13,6 +13,8 @@ import { parseSnapshotEntries } from "./artifact-heuristics.mjs";
13
13
  /** @typedef {{ responseText: string; artifactLabels?: string[]; suspiciousArtifactLabels?: string[] }} CompletionSignatureArgs */
14
14
  /** @typedef {{ hasStopStreaming: boolean; hasTargetCopyResponse: boolean; responseText: string; artifactLabels?: string[]; suspiciousArtifactLabels?: string[] }} DerivedCompletionSignatureArgs */
15
15
 
16
+ export const CHATGPT_COMPOSER_LABELS = Object.freeze(["Chat with ChatGPT", "Ask ChatGPT"]);
17
+
16
18
  export const CHATGPT_CANONICAL_APP_ORIGINS = Object.freeze([
17
19
  "https://chatgpt.com",
18
20
  "https://chat.openai.com",
@@ -132,7 +134,7 @@ export function requestedEffortLabel(selection) {
132
134
  * @returns {string}
133
135
  */
134
136
  function normalizeChipLabel(label) {
135
- return normalizeText(label).replace(/, click to remove$/i, "").trim();
137
+ return normalizeText(label).replace(/, click to remove$/i, "").replace(/^\d+(?:\.\d+)?\s+(?=Pro$)/i, "").trim();
136
138
  }
137
139
 
138
140
  function parseComposerChipSelection(label) {
@@ -503,7 +505,9 @@ export function snapshotHasModelConfigurationUi(snapshot) {
503
505
  const hasEffortCombobox = entries.some(
504
506
  (entry) => entry.kind === "combobox" && EFFORT_LABELS.has(entry.value || "") && !entry.disabled,
505
507
  );
506
- return visibleFamilies.size >= 2 || visibleRadioFamilies.size >= 2 || visibleCompactControls.length >= 2 || hasCompactIntelligenceMenu || hasIntelligenceHeading || hasEffortCombobox;
508
+ const hasPowerSliderMenu = entries.some((entry) => entry.kind === "menuitem" && entry.label === "Power" && !entry.disabled)
509
+ && entries.some((entry) => entry.kind === "menuitem" && entry.label === "Select model" && !entry.disabled);
510
+ return visibleFamilies.size >= 2 || visibleRadioFamilies.size >= 2 || visibleCompactControls.length >= 2 || hasCompactIntelligenceMenu || hasIntelligenceHeading || hasEffortCombobox || hasPowerSliderMenu;
507
511
  }
508
512
 
509
513
  /**
@@ -513,7 +517,7 @@ export function snapshotHasModelConfigurationUi(snapshot) {
513
517
  export function snapshotHasUsableComposerControls(snapshot) {
514
518
  /** @type {SnapshotEntry[]} */
515
519
  const entries = parseSnapshotEntries(snapshot);
516
- const hasComposer = entries.some((entry) => entry.kind === "textbox" && entry.label === "Chat with ChatGPT" && !entry.disabled);
520
+ const hasComposer = entries.some((entry) => entry.kind === "textbox" && CHATGPT_COMPOSER_LABELS.includes(entry.label || "") && !entry.disabled);
517
521
  const hasAddFiles = entries.some((entry) => entry.kind === "button" && entry.label === "Add files and more" && !entry.disabled);
518
522
  return hasComposer && hasAddFiles;
519
523
  }
@@ -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
+ }