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.
- package/CHANGELOG.md +25 -0
- package/README.md +49 -377
- package/docs/ORACLE_DESIGN.md +5 -2
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +9 -7
- package/docs/development.md +99 -0
- package/docs/platform-smoke.md +3 -3
- package/docs/reference.md +331 -0
- package/extensions/oracle/index.ts +1 -0
- package/extensions/oracle/lib/poller.ts +3 -8
- package/extensions/oracle/lib/queue.ts +2 -4
- package/extensions/oracle/shared/browser-profile-helpers.d.mts +1 -0
- package/extensions/oracle/shared/browser-profile-helpers.mjs +32 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +65 -12
- package/extensions/oracle/worker/auth-flow-helpers.d.mts +2 -0
- package/extensions/oracle/worker/auth-flow-helpers.mjs +24 -3
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +4 -1
- package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +1 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +7 -3
- package/extensions/oracle/worker/chromium-cookie-source.mjs +50 -6
- package/extensions/oracle/worker/linux-chromium-cookie-source.d.mts +6 -0
- package/extensions/oracle/worker/linux-chromium-cookie-source.mjs +98 -0
- package/extensions/oracle/worker/run-job.mjs +72 -7
- package/package.json +15 -11
- package/platform-smoke.config.mjs +1 -22
- package/scripts/oracle-auth.test.mjs +164 -0
- package/scripts/oracle-chatgpt-preset-proof.mjs +19 -12
- package/scripts/oracle-cookie-import.test.mjs +204 -0
- package/scripts/oracle-model.test.mjs +87 -0
- package/scripts/oracle-real-smoke.mjs +30 -13
- package/scripts/platform-smoke/Dockerfile.ubuntu +3 -2
- package/scripts/platform-smoke/crabbox-runner.mjs +3 -5
- package/scripts/platform-smoke/doctor.mjs +9 -24
- package/scripts/platform-smoke/invariants.mjs +234 -22
- package/scripts/platform-smoke/platform-build-windows.ps1 +4 -16
- package/scripts/platform-smoke/targets.mjs +35 -27
- 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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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", {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 \"${
|
|
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
|
|
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
|
-
|
|
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 "${
|
|
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
|
-
|
|
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
|
|
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
|
|
2
|
-
// Responsibilities: Snapshot
|
|
3
|
-
// Scope: macOS
|
|
4
|
-
// Usage: auth-bootstrap.mjs
|
|
5
|
-
// Invariants/Assumptions:
|
|
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,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
|
+
}
|