@steipete/oracle 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -11
- package/dist/bin/oracle-cli.js +71 -71
- package/dist/src/browser/actions/assistantResponse.js +93 -10
- package/dist/src/browser/actions/deepResearch.js +180 -52
- package/dist/src/browser/actions/modelSelection.js +357 -40
- package/dist/src/browser/actions/navigation.js +294 -65
- package/dist/src/browser/actions/promptComposer.js +50 -4
- package/dist/src/browser/actions/thinkingTime.js +517 -105
- package/dist/src/browser/artifacts.js +19 -0
- package/dist/src/browser/chatgptFiles.js +797 -0
- package/dist/src/browser/chatgptImages.js +256 -27
- package/dist/src/browser/chromeLifecycle.js +4 -0
- package/dist/src/browser/config.js +2 -0
- package/dist/src/browser/index.js +404 -41
- package/dist/src/browser/pageActions.js +1 -1
- package/dist/src/browser/policies.js +2 -6
- package/dist/src/browser/projectSourcesRunner.js +16 -3
- package/dist/src/browser/prompt.js +161 -36
- package/dist/src/browser/reattach.js +6 -2
- package/dist/src/browser/reattachability.js +22 -10
- package/dist/src/browser/recoverConversation.js +73 -0
- package/dist/src/browser/sessionRunner.js +4 -1
- package/dist/src/cli/browserConfig.js +6 -2
- package/dist/src/cli/browserDefaults.js +2 -1
- package/dist/src/cli/browserTabs.js +129 -54
- package/dist/src/cli/followup.js +72 -0
- package/dist/src/cli/help.js +1 -1
- package/dist/src/cli/options.js +24 -0
- package/dist/src/cli/reattachGuidance.js +8 -0
- package/dist/src/cli/runOptions.js +2 -12
- package/dist/src/cli/sessionCommand.js +4 -0
- package/dist/src/cli/sessionDisplay.js +17 -3
- package/dist/src/cli/sessionLineage.js +7 -4
- package/dist/src/cli/sessionRunner.js +16 -1
- package/dist/src/gemini-web/client.js +5 -10
- package/dist/src/gemini-web/executor.js +1 -24
- package/dist/src/gemini-web/models.js +83 -0
- package/dist/src/mcp/server.js +2 -0
- package/dist/src/mcp/tools/chatgptImage.js +132 -0
- package/dist/src/mcp/tools/consult.js +270 -175
- package/dist/src/mcp/types.js +13 -2
- package/dist/src/mcp/utils.js +87 -1
- package/dist/src/oracle/config.js +27 -1
- package/dist/src/oracle/files.js +4 -6
- package/dist/src/oracle/geminiModels.js +2 -0
- package/dist/src/oracle/markdown.js +36 -3
- package/dist/src/oracle/modelResolver.js +21 -13
- package/dist/src/oracle/promptAssembly.js +2 -4
- package/dist/src/oracle/request.js +3 -5
- package/dist/src/oracle/thinkingTime.js +40 -0
- package/dist/src/oracle/tokenStats.js +5 -1
- package/dist/src/oracle.js +1 -1
- package/dist/src/sessionManager.js +1 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
- package/package.json +47 -58
- package/vendor/oracle-notifier/build-notifier.sh +0 -0
- package/dist/bin/oracle.js +0 -569
- package/dist/src/browser/chromeCookies.js +0 -312
- package/dist/src/browser/keytarShim.js +0 -56
- package/dist/src/browser/windowsCookies.js +0 -219
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
- package/vendor/oracle-notifier/README.md +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, INPUT_SELECTORS } from "../constants.js";
|
|
1
|
+
import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, } from "../constants.js";
|
|
2
2
|
import { delay } from "../utils.js";
|
|
3
3
|
import { logDomFailure } from "../domDebug.js";
|
|
4
4
|
import { BrowserAutomationError } from "../../oracle/errors.js";
|
|
@@ -139,9 +139,14 @@ export async function ensureNotBlocked(Runtime, headless, logger) {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
const LOGIN_CHECK_TIMEOUT_MS = 5_000;
|
|
142
|
+
const CHATGPT_ACCOUNT_EMAIL_ENV = "ORACLE_CHATGPT_ACCOUNT_EMAIL";
|
|
143
|
+
function preferredChatGptAccountEmail() {
|
|
144
|
+
const email = process.env[CHATGPT_ACCOUNT_EMAIL_ENV]?.trim().toLowerCase();
|
|
145
|
+
return email ? email : null;
|
|
146
|
+
}
|
|
142
147
|
export async function ensureLoggedIn(Runtime, logger, options = {}) {
|
|
143
148
|
// Learned: ChatGPT can render the UI (project view) while auth silently failed.
|
|
144
|
-
//
|
|
149
|
+
// Session state plus DOM login signals catch both valid and stale app shells.
|
|
145
150
|
const outcome = await Runtime.evaluate({
|
|
146
151
|
expression: buildLoginProbeExpression(LOGIN_CHECK_TIMEOUT_MS),
|
|
147
152
|
awaitPromise: true,
|
|
@@ -149,11 +154,11 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
|
|
|
149
154
|
});
|
|
150
155
|
const probe = normalizeLoginProbe(outcome.result?.value);
|
|
151
156
|
if (probe.ok) {
|
|
152
|
-
logger(`Login check passed (
|
|
157
|
+
logger(`Login check passed (sessionStatus=${probe.status}, sessionAuthenticated=${Boolean(probe.sessionAuthenticated)}, backendStatus=${probe.backendStatus ?? "n/a"}, domLoginCta=${Boolean(probe.domLoginCta)}, appAuthenticated=${Boolean(probe.appAuthenticated)})`);
|
|
153
158
|
return;
|
|
154
159
|
}
|
|
155
|
-
const
|
|
156
|
-
if (accepted) {
|
|
160
|
+
const welcomeBack = await attemptWelcomeBackLogin(Runtime, logger, preferredChatGptAccountEmail());
|
|
161
|
+
if (welcomeBack.accepted) {
|
|
157
162
|
// Learned: "Welcome back" account picker needs a click even when cookies are valid,
|
|
158
163
|
// and the redirect can lag, so re-probe before failing hard.
|
|
159
164
|
await delay(1500);
|
|
@@ -167,46 +172,25 @@ export async function ensureLoggedIn(Runtime, logger, options = {}) {
|
|
|
167
172
|
logger("Login restored via Welcome back account picker");
|
|
168
173
|
return;
|
|
169
174
|
}
|
|
170
|
-
logger(`Login retry after Welcome back failed (
|
|
175
|
+
logger(`Login retry after Welcome back failed (sessionStatus=${retryProbe.status}, sessionAuthenticated=${Boolean(retryProbe.sessionAuthenticated)}, backendStatus=${retryProbe.backendStatus ?? "n/a"}, domLoginCta=${Boolean(retryProbe.domLoginCta)}, appAuthenticated=${Boolean(retryProbe.appAuthenticated)})`);
|
|
171
176
|
}
|
|
172
|
-
logger(`Login probe failed (
|
|
177
|
+
logger(`Login probe failed (sessionStatus=${probe.status}, sessionAuthenticated=${Boolean(probe.sessionAuthenticated)}, sessionResolved=${Boolean(probe.sessionResolved)}, backendStatus=${probe.backendStatus ?? "n/a"}, domLoginCta=${Boolean(probe.domLoginCta)}, onAuthPage=${Boolean(probe.onAuthPage)}, appAuthenticated=${Boolean(probe.appAuthenticated)}, cfBlocked=${Boolean(probe.cfBlocked)}, url=${probe.pageUrl ?? "n/a"}, error=${probe.error ?? "none"})`);
|
|
173
178
|
const domLabel = probe.domLoginCta ? " Login button detected on page." : "";
|
|
174
179
|
const cookieHint = options.remoteSession
|
|
175
180
|
? "The remote Chrome session is not signed into ChatGPT. Sign in there, then rerun."
|
|
176
181
|
: (options.appliedCookies ?? 0) === 0
|
|
177
182
|
? "No ChatGPT cookies were applied; sign in to chatgpt.com in Chrome or pass inline cookies (--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON)."
|
|
178
183
|
: "ChatGPT login appears missing; open chatgpt.com in Chrome to refresh the session or provide inline cookies (--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON).";
|
|
179
|
-
|
|
184
|
+
const accountHint = welcomeBack.hint ? ` ${welcomeBack.hint}` : "";
|
|
185
|
+
throw new Error(`ChatGPT session not detected.${domLabel}${accountHint} ${cookieHint}`);
|
|
180
186
|
}
|
|
181
|
-
async function attemptWelcomeBackLogin(Runtime, logger) {
|
|
187
|
+
async function attemptWelcomeBackLogin(Runtime, logger, preferredEmail = null) {
|
|
182
188
|
const deadline = Date.now() + 30_000;
|
|
183
189
|
while (Date.now() < deadline) {
|
|
184
190
|
let outcome;
|
|
185
191
|
try {
|
|
186
192
|
outcome = await Runtime.evaluate({
|
|
187
|
-
expression:
|
|
188
|
-
// Learned: "Welcome back" shows as a modal with account chips; click the email chip.
|
|
189
|
-
const getLabel = (node) =>
|
|
190
|
-
(node?.textContent || node?.getAttribute?.('aria-label') || '').trim();
|
|
191
|
-
const isAccount = (label) =>
|
|
192
|
-
Boolean(label) &&
|
|
193
|
-
label.includes('@') &&
|
|
194
|
-
!/log in|sign up|create account|another account/i.test(label);
|
|
195
|
-
const candidates = Array.from(document.querySelectorAll('[role="button"],button,a'));
|
|
196
|
-
const account = candidates.find((node) => isAccount(getLabel(node))) || null;
|
|
197
|
-
if (!account) {
|
|
198
|
-
return { clicked: false, reason: 'not-found' };
|
|
199
|
-
}
|
|
200
|
-
const label = getLabel(account);
|
|
201
|
-
setTimeout(() => {
|
|
202
|
-
try {
|
|
203
|
-
account.click();
|
|
204
|
-
} catch {
|
|
205
|
-
// ignore; caller will re-probe login state
|
|
206
|
-
}
|
|
207
|
-
}, 0);
|
|
208
|
-
return { clicked: true, label };
|
|
209
|
-
})()`,
|
|
193
|
+
expression: buildWelcomeBackAccountPickerExpression(preferredEmail),
|
|
210
194
|
awaitPromise: false,
|
|
211
195
|
returnByValue: true,
|
|
212
196
|
});
|
|
@@ -215,10 +199,10 @@ async function attemptWelcomeBackLogin(Runtime, logger) {
|
|
|
215
199
|
const message = error instanceof Error ? error.message : String(error);
|
|
216
200
|
if (/navigated or closed|context was destroyed|target closed/i.test(message)) {
|
|
217
201
|
logger("Welcome back account click triggered navigation.");
|
|
218
|
-
return true;
|
|
202
|
+
return { accepted: true };
|
|
219
203
|
}
|
|
220
204
|
logger(`Welcome back auto-select probe failed: ${message}`);
|
|
221
|
-
return false;
|
|
205
|
+
return { accepted: false };
|
|
222
206
|
}
|
|
223
207
|
if (outcome.exceptionDetails) {
|
|
224
208
|
const details = outcome.exceptionDetails;
|
|
@@ -228,29 +212,93 @@ async function attemptWelcomeBackLogin(Runtime, logger) {
|
|
|
228
212
|
details.text ||
|
|
229
213
|
"unknown error";
|
|
230
214
|
logger(`Welcome back auto-select probe failed: ${description}`);
|
|
231
|
-
return false;
|
|
215
|
+
return { accepted: false };
|
|
232
216
|
}
|
|
233
217
|
const result = outcome.result?.value;
|
|
234
218
|
if (!result) {
|
|
235
219
|
logger("Welcome back auto-select probe returned no result.");
|
|
236
|
-
return false;
|
|
220
|
+
return { accepted: false };
|
|
237
221
|
}
|
|
238
222
|
if (!("clicked" in result) && !("reason" in result)) {
|
|
239
223
|
logger("Welcome back auto-select probe returned an unexpected result.");
|
|
240
|
-
return false;
|
|
224
|
+
return { accepted: false };
|
|
241
225
|
}
|
|
242
226
|
if (result.clicked) {
|
|
243
|
-
logger(
|
|
244
|
-
|
|
227
|
+
logger(result.selection === "preferred"
|
|
228
|
+
? "Welcome back modal detected; selected configured account."
|
|
229
|
+
: "Welcome back modal detected; selected only saved account.");
|
|
230
|
+
return { accepted: true };
|
|
231
|
+
}
|
|
232
|
+
if (result.reason === "preferred-not-found") {
|
|
233
|
+
logger(`Welcome back modal present but ${CHATGPT_ACCOUNT_EMAIL_ENV} did not match any saved account (${result.accountCount ?? 0} account chips found).`);
|
|
234
|
+
return {
|
|
235
|
+
accepted: false,
|
|
236
|
+
hint: `${CHATGPT_ACCOUNT_EMAIL_ENV} did not match a saved account. Set it to the exact account email on the browser host or sign in manually.`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (result.reason === "multiple-accounts") {
|
|
240
|
+
logger(`Welcome back modal present with multiple saved accounts; refusing to select one without ${CHATGPT_ACCOUNT_EMAIL_ENV}.`);
|
|
241
|
+
return {
|
|
242
|
+
accepted: false,
|
|
243
|
+
hint: `Multiple saved ChatGPT accounts were found. Set ${CHATGPT_ACCOUNT_EMAIL_ENV} to the exact account email on the browser host.`,
|
|
244
|
+
};
|
|
245
245
|
}
|
|
246
246
|
if (result.reason && result.reason !== "not-found") {
|
|
247
247
|
logger(`Welcome back modal present but auto-select failed (${result.reason}).`);
|
|
248
|
-
return false;
|
|
248
|
+
return { accepted: false };
|
|
249
249
|
}
|
|
250
250
|
await delay(500);
|
|
251
251
|
}
|
|
252
252
|
logger("Welcome back modal not detected after login probe failure.");
|
|
253
|
-
return false;
|
|
253
|
+
return { accepted: false };
|
|
254
|
+
}
|
|
255
|
+
function buildWelcomeBackAccountPickerExpression(preferredEmail = null) {
|
|
256
|
+
const normalizedPreferredEmail = preferredEmail?.trim().toLowerCase() || null;
|
|
257
|
+
return `(() => {
|
|
258
|
+
// Learned: "Welcome back" can list several saved accounts; substring matching can select the wrong identity.
|
|
259
|
+
const preferredEmail = ${JSON.stringify(normalizedPreferredEmail)};
|
|
260
|
+
const getLabel = (node) =>
|
|
261
|
+
[node?.textContent, node?.getAttribute?.('aria-label')]
|
|
262
|
+
.filter((value) => typeof value === 'string' && value.trim())
|
|
263
|
+
.join(' ')
|
|
264
|
+
.trim();
|
|
265
|
+
const extractEmails = (label) =>
|
|
266
|
+
String(label || '')
|
|
267
|
+
.toLowerCase()
|
|
268
|
+
.match(/[a-z0-9.!#$%&'*+/=?^_{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*/g) || [];
|
|
269
|
+
const isAccount = (label) => !/log in|sign up|create account|another account/i.test(label);
|
|
270
|
+
const candidates = Array.from(document.querySelectorAll('[role="button"],button,a'));
|
|
271
|
+
const accounts = candidates
|
|
272
|
+
.map((node) => {
|
|
273
|
+
const label = getLabel(node);
|
|
274
|
+
return { node, emails: isAccount(label) ? extractEmails(label) : [] };
|
|
275
|
+
})
|
|
276
|
+
.filter((entry) => entry.emails.length > 0);
|
|
277
|
+
if (!accounts.length) {
|
|
278
|
+
return { clicked: false, reason: 'not-found' };
|
|
279
|
+
}
|
|
280
|
+
const savedEmails = Array.from(new Set(accounts.flatMap((entry) => entry.emails)));
|
|
281
|
+
if (!preferredEmail && savedEmails.length !== 1) {
|
|
282
|
+
return { clicked: false, reason: 'multiple-accounts', accountCount: savedEmails.length };
|
|
283
|
+
}
|
|
284
|
+
const selectedEmail = preferredEmail || savedEmails[0];
|
|
285
|
+
const account = accounts.find((entry) => entry.emails.includes(selectedEmail));
|
|
286
|
+
if (!account) {
|
|
287
|
+
return { clicked: false, reason: 'preferred-not-found', accountCount: savedEmails.length };
|
|
288
|
+
}
|
|
289
|
+
setTimeout(() => {
|
|
290
|
+
try {
|
|
291
|
+
account.node.click();
|
|
292
|
+
} catch {
|
|
293
|
+
// ignore; caller will re-probe login state
|
|
294
|
+
}
|
|
295
|
+
}, 0);
|
|
296
|
+
return {
|
|
297
|
+
clicked: true,
|
|
298
|
+
selection: preferredEmail ? 'preferred' : 'only-account',
|
|
299
|
+
accountCount: savedEmails.length,
|
|
300
|
+
};
|
|
301
|
+
})()`;
|
|
254
302
|
}
|
|
255
303
|
export async function ensurePromptReady(Runtime, timeoutMs, logger) {
|
|
256
304
|
const ready = await waitForPrompt(Runtime, timeoutMs);
|
|
@@ -269,6 +317,89 @@ export async function ensurePromptReady(Runtime, timeoutMs, logger) {
|
|
|
269
317
|
throw new Error("Prompt textarea did not appear before timeout");
|
|
270
318
|
}
|
|
271
319
|
}
|
|
320
|
+
function conversationIdFromUrl(value) {
|
|
321
|
+
if (!value)
|
|
322
|
+
return null;
|
|
323
|
+
try {
|
|
324
|
+
return new URL(value).pathname.match(/(?:^|\/)c\/([^/]+)/)?.[1] ?? null;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* After navigating to a *resumed* ChatGPT conversation, its prior turns hydrate
|
|
332
|
+
* asynchronously and ChatGPT can reset the composer mid-hydration — wiping a
|
|
333
|
+
* freshly-typed prompt. A fresh chat has no history, so it never hits this race;
|
|
334
|
+
* a large resumed thread reliably does.
|
|
335
|
+
*
|
|
336
|
+
* Wait for the prior turns to render AND stop growing (a big thread keeps
|
|
337
|
+
* appending turns as it hydrates), let React settle, then re-confirm the
|
|
338
|
+
* composer is ready — before the caller types/submits. Shared by the local and
|
|
339
|
+
* remote browser execution paths so neither loses the submitted prompt.
|
|
340
|
+
*
|
|
341
|
+
* Returns the number of prior turns observed once hydration settled.
|
|
342
|
+
*/
|
|
343
|
+
export async function waitForResumedConversationHydration(Runtime, timeoutMs, logger, deps = {}) {
|
|
344
|
+
const ensureReady = deps.ensurePromptReady ?? ensurePromptReady;
|
|
345
|
+
const hydrationDeadline = Date.now() + Math.min(timeoutMs || 30_000, 30_000);
|
|
346
|
+
let priorTurns = 0;
|
|
347
|
+
let stableChecks = 0;
|
|
348
|
+
let settled = false;
|
|
349
|
+
while (Date.now() < hydrationDeadline) {
|
|
350
|
+
let turns = 0;
|
|
351
|
+
try {
|
|
352
|
+
const { result } = await Runtime.evaluate({
|
|
353
|
+
expression: `document.querySelectorAll(${JSON.stringify(CONVERSATION_TURN_SELECTOR)}).length`,
|
|
354
|
+
returnByValue: true,
|
|
355
|
+
});
|
|
356
|
+
turns = typeof result?.value === "number" ? result.value : 0;
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// keep polling until the conversation hydrates
|
|
360
|
+
}
|
|
361
|
+
if (turns > 0 && turns === priorTurns) {
|
|
362
|
+
stableChecks += 1;
|
|
363
|
+
if (stableChecks >= 3) {
|
|
364
|
+
settled = true;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
stableChecks = 0;
|
|
370
|
+
}
|
|
371
|
+
priorTurns = turns;
|
|
372
|
+
await delay(250);
|
|
373
|
+
}
|
|
374
|
+
await delay(1_000); // final settle so React won't wipe the composer after we type
|
|
375
|
+
await ensureReady(Runtime, timeoutMs, logger);
|
|
376
|
+
if ((deps.requirePriorTurns ?? false) && (!settled || priorTurns <= 0)) {
|
|
377
|
+
throw new BrowserAutomationError("Saved ChatGPT conversation did not load stable prior turns; refusing to submit follow-up as a fresh chat.", {
|
|
378
|
+
stage: "resume-conversation",
|
|
379
|
+
priorTurns,
|
|
380
|
+
settled,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (deps.expectedConversationUrl) {
|
|
384
|
+
const { result } = await Runtime.evaluate({
|
|
385
|
+
expression: "location.href",
|
|
386
|
+
returnByValue: true,
|
|
387
|
+
});
|
|
388
|
+
const actualUrl = typeof result?.value === "string" ? result.value : undefined;
|
|
389
|
+
const expectedConversationId = conversationIdFromUrl(deps.expectedConversationUrl);
|
|
390
|
+
const actualConversationId = conversationIdFromUrl(actualUrl);
|
|
391
|
+
if (!expectedConversationId || actualConversationId !== expectedConversationId) {
|
|
392
|
+
throw new BrowserAutomationError("Saved ChatGPT conversation redirected to a different thread; refusing to submit follow-up.", {
|
|
393
|
+
stage: "resume-conversation",
|
|
394
|
+
expectedConversationId,
|
|
395
|
+
actualConversationId,
|
|
396
|
+
actualUrl,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
logger(`[browser] Resumed conversation hydrated (${priorTurns} prior turns); composer settled.`);
|
|
401
|
+
return priorTurns;
|
|
402
|
+
}
|
|
272
403
|
async function waitForDocumentReady(Runtime, timeoutMs) {
|
|
273
404
|
const start = Date.now();
|
|
274
405
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -360,8 +491,8 @@ async function isChatGptAccountSecurityBlock(Runtime) {
|
|
|
360
491
|
}
|
|
361
492
|
function buildLoginProbeExpression(timeoutMs) {
|
|
362
493
|
return `(async () => {
|
|
363
|
-
//
|
|
364
|
-
//
|
|
494
|
+
// /api/auth/session remains cookie-authenticated and exposes user presence without
|
|
495
|
+
// requiring the bearer token used by /backend-api/*. Never return or log its token.
|
|
365
496
|
const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
|
|
366
497
|
const onAuthPage =
|
|
367
498
|
typeof location === 'object' &&
|
|
@@ -432,6 +563,68 @@ function buildLoginProbeExpression(timeoutMs) {
|
|
|
432
563
|
(head.includes('<style global>') && head.includes('scale-appear'))
|
|
433
564
|
);
|
|
434
565
|
};
|
|
566
|
+
const readSessionDetail = async () => {
|
|
567
|
+
try {
|
|
568
|
+
if (typeof fetch !== 'function') {
|
|
569
|
+
return { status: 0, resolved: false, authenticated: false, cfBlocked: false, error: null };
|
|
570
|
+
}
|
|
571
|
+
const controller = new AbortController();
|
|
572
|
+
const timeout = setTimeout(() => controller.abort(), ${timeoutMs});
|
|
573
|
+
try {
|
|
574
|
+
const response = await fetch('/api/auth/session', {
|
|
575
|
+
cache: 'no-store',
|
|
576
|
+
credentials: 'include',
|
|
577
|
+
signal: controller.signal,
|
|
578
|
+
});
|
|
579
|
+
let cfBlocked = false;
|
|
580
|
+
if (response.status === 403 || response.status === 503 || response.status === 429) {
|
|
581
|
+
try {
|
|
582
|
+
const text = await response.clone().text();
|
|
583
|
+
cfBlocked = isCloudflareBody(text);
|
|
584
|
+
} catch {}
|
|
585
|
+
}
|
|
586
|
+
if (response.status !== 200) {
|
|
587
|
+
return {
|
|
588
|
+
status: response.status || 0,
|
|
589
|
+
resolved: false,
|
|
590
|
+
authenticated: false,
|
|
591
|
+
cfBlocked,
|
|
592
|
+
error: null,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
const body = await response.json();
|
|
597
|
+
const resolved =
|
|
598
|
+
Boolean(body) && typeof body === 'object' && !Array.isArray(body);
|
|
599
|
+
return {
|
|
600
|
+
status: response.status || 0,
|
|
601
|
+
resolved,
|
|
602
|
+
authenticated: resolved && Boolean(body.user),
|
|
603
|
+
cfBlocked,
|
|
604
|
+
error: null,
|
|
605
|
+
};
|
|
606
|
+
} catch {
|
|
607
|
+
return {
|
|
608
|
+
status: response.status || 0,
|
|
609
|
+
resolved: false,
|
|
610
|
+
authenticated: false,
|
|
611
|
+
cfBlocked,
|
|
612
|
+
error: null,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
} finally {
|
|
616
|
+
clearTimeout(timeout);
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
return {
|
|
620
|
+
status: 0,
|
|
621
|
+
resolved: false,
|
|
622
|
+
authenticated: false,
|
|
623
|
+
cfBlocked: false,
|
|
624
|
+
error: err ? String(err) : 'unknown',
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
};
|
|
435
628
|
const readBackendDetail = async () => {
|
|
436
629
|
try {
|
|
437
630
|
if (typeof fetch !== 'function') return { status: 0, cfBlocked: false, error: null };
|
|
@@ -458,6 +651,15 @@ function buildLoginProbeExpression(timeoutMs) {
|
|
|
458
651
|
return { status: 0, cfBlocked: false, error: err ? String(err) : 'unknown' };
|
|
459
652
|
}
|
|
460
653
|
};
|
|
654
|
+
const readAuthDetail = async () => {
|
|
655
|
+
const session = await readSessionDetail();
|
|
656
|
+
const sessionDenied =
|
|
657
|
+
!session.cfBlocked && (session.status === 401 || session.status === 403);
|
|
658
|
+
if (session.resolved || sessionDenied) {
|
|
659
|
+
return { session, backend: null };
|
|
660
|
+
}
|
|
661
|
+
return { session, backend: await readBackendDetail() };
|
|
662
|
+
};
|
|
461
663
|
|
|
462
664
|
const hasAppAuthSignal = () => {
|
|
463
665
|
// Composer must be present and visible — the auth/login page never renders one.
|
|
@@ -483,39 +685,58 @@ function buildLoginProbeExpression(timeoutMs) {
|
|
|
483
685
|
return Boolean(profileButton || historyItem);
|
|
484
686
|
};
|
|
485
687
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
688
|
+
const classifyAuth = (auth, appSignal) => {
|
|
689
|
+
const sessionResolved = auth.session.status === 200 && auth.session.resolved;
|
|
690
|
+
const sessionDenied =
|
|
691
|
+
!auth.session.cfBlocked &&
|
|
692
|
+
(auth.session.status === 401 || auth.session.status === 403);
|
|
693
|
+
const sessionUnavailable = !sessionResolved && !sessionDenied;
|
|
694
|
+
const backendStatus = auth.backend ? auth.backend.status : null;
|
|
695
|
+
const backendUnavailable =
|
|
696
|
+
Boolean(auth.backend) &&
|
|
697
|
+
(auth.backend.cfBlocked ||
|
|
698
|
+
backendStatus === 0 ||
|
|
699
|
+
backendStatus === 401 ||
|
|
700
|
+
backendStatus === 403 ||
|
|
701
|
+
backendStatus === 429 ||
|
|
702
|
+
backendStatus === 503);
|
|
703
|
+
return {
|
|
704
|
+
authenticated:
|
|
705
|
+
auth.session.authenticated ||
|
|
706
|
+
(sessionUnavailable &&
|
|
707
|
+
(backendStatus === 200 || (backendUnavailable && appSignal))),
|
|
708
|
+
sessionResolved,
|
|
709
|
+
sessionUnavailable,
|
|
710
|
+
};
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
let auth = await readAuthDetail();
|
|
490
714
|
let domLoginCta = hasLoginCta();
|
|
491
715
|
let appAuthenticated = hasAppAuthSignal();
|
|
492
|
-
|
|
493
|
-
status === 0 || status === 401 || status === 403 || status === 503 || status === 429;
|
|
716
|
+
let classification = classifyAuth(auth, appAuthenticated);
|
|
494
717
|
const settleDeadline = Date.now() + Math.min(${timeoutMs}, 2500);
|
|
495
|
-
while (
|
|
718
|
+
while (
|
|
719
|
+
!domLoginCta &&
|
|
720
|
+
!classification.authenticated &&
|
|
721
|
+
classification.sessionUnavailable &&
|
|
722
|
+
Date.now() < settleDeadline
|
|
723
|
+
) {
|
|
496
724
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
497
725
|
domLoginCta = hasLoginCta();
|
|
498
726
|
appAuthenticated = hasAppAuthSignal();
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
cfBlocked = backend.cfBlocked;
|
|
502
|
-
error = backend.error;
|
|
727
|
+
auth = await readAuthDetail();
|
|
728
|
+
classification = classifyAuth(auth, appAuthenticated);
|
|
503
729
|
}
|
|
504
730
|
|
|
505
731
|
const loginSignals = domLoginCta || onAuthPage;
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const apiBlocked =
|
|
511
|
-
cfBlocked ||
|
|
512
|
-
status === 429 ||
|
|
513
|
-
status === 503 ||
|
|
514
|
-
status === 0;
|
|
515
|
-
const ok = !loginSignals && (status === 200 || (apiBlocked && appAuthenticated));
|
|
732
|
+
const backendStatus = auth.backend ? auth.backend.status : null;
|
|
733
|
+
const cfBlocked = auth.session.cfBlocked || Boolean(auth.backend?.cfBlocked);
|
|
734
|
+
const error = auth.session.error || auth.backend?.error || null;
|
|
735
|
+
const ok = !loginSignals && classification.authenticated;
|
|
516
736
|
return {
|
|
517
737
|
ok,
|
|
518
|
-
status,
|
|
738
|
+
status: auth.session.status,
|
|
739
|
+
backendStatus,
|
|
519
740
|
redirected: false,
|
|
520
741
|
url: pageUrl,
|
|
521
742
|
pageUrl,
|
|
@@ -523,6 +744,8 @@ function buildLoginProbeExpression(timeoutMs) {
|
|
|
523
744
|
onAuthPage,
|
|
524
745
|
appAuthenticated,
|
|
525
746
|
cfBlocked,
|
|
747
|
+
sessionAuthenticated: auth.session.authenticated,
|
|
748
|
+
sessionResolved: classification.sessionResolved,
|
|
526
749
|
error,
|
|
527
750
|
};
|
|
528
751
|
})()`;
|
|
@@ -548,9 +771,15 @@ function normalizeLoginProbe(raw) {
|
|
|
548
771
|
domLoginCta: Boolean(value.domLoginCta),
|
|
549
772
|
onAuthPage: Boolean(value.onAuthPage),
|
|
550
773
|
appAuthenticated: Boolean(value.appAuthenticated),
|
|
774
|
+
backendStatus: typeof value.backendStatus === "number" ? value.backendStatus : null,
|
|
551
775
|
cfBlocked: Boolean(value.cfBlocked),
|
|
776
|
+
sessionAuthenticated: Boolean(value.sessionAuthenticated),
|
|
777
|
+
sessionResolved: Boolean(value.sessionResolved),
|
|
552
778
|
};
|
|
553
779
|
}
|
|
554
780
|
export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
|
|
555
781
|
return buildLoginProbeExpression(timeoutMs);
|
|
556
782
|
}
|
|
783
|
+
export function buildWelcomeBackAccountPickerExpressionForTest(preferredEmail = null) {
|
|
784
|
+
return buildWelcomeBackAccountPickerExpression(preferredEmail);
|
|
785
|
+
}
|
|
@@ -283,12 +283,14 @@ async function waitForDomReady(Runtime, logger, timeoutMs = 10_000) {
|
|
|
283
283
|
logger?.(`Page did not reach ready/composer state within ${timeoutMs}ms; continuing cautiously.`);
|
|
284
284
|
}
|
|
285
285
|
function buildAttachmentReadyExpression(attachmentNames) {
|
|
286
|
-
const attachmentExpectations = attachmentNames.map((
|
|
286
|
+
const attachmentExpectations = attachmentNames.map((attachment) => {
|
|
287
|
+
const name = typeof attachment === "string" ? attachment : attachment.name;
|
|
287
288
|
const normalized = name.toLowerCase().replace(/\s+/g, " ").trim();
|
|
288
289
|
return {
|
|
289
290
|
name: normalized,
|
|
290
291
|
stem: normalized.replace(/\.[a-z0-9]{1,10}$/i, ""),
|
|
291
292
|
extension: normalized.match(/(\.[a-z0-9]{1,10})$/i)?.[1] ?? "",
|
|
293
|
+
generatedBundle: typeof attachment === "object" && attachment.generatedBundle === true,
|
|
292
294
|
};
|
|
293
295
|
});
|
|
294
296
|
const namesLiteral = JSON.stringify(attachmentExpectations);
|
|
@@ -304,13 +306,43 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
304
306
|
if (index === -1) return false;
|
|
305
307
|
const previous = text[index - 1] || '';
|
|
306
308
|
const next = text[index + name.length] || '';
|
|
307
|
-
const previousOk = !previous || !/[a-z0-9]/.test(previous);
|
|
308
|
-
const nextOk = !next || !/[a-z0-9]/.test(next);
|
|
309
|
+
const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
|
|
310
|
+
const nextOk = !next || !/[a-z0-9._-]/.test(next);
|
|
309
311
|
if (previousOk && nextOk) return true;
|
|
310
312
|
from = index + name.length;
|
|
311
313
|
}
|
|
312
314
|
return false;
|
|
313
315
|
};
|
|
316
|
+
const hasStemFileBoundary = (text, stem) => {
|
|
317
|
+
if (!stem) return false;
|
|
318
|
+
let from = 0;
|
|
319
|
+
while (from < text.length) {
|
|
320
|
+
const index = text.indexOf(stem, from);
|
|
321
|
+
if (index === -1) return false;
|
|
322
|
+
const previous = text[index - 1] || '';
|
|
323
|
+
const next = text[index + stem.length] || '';
|
|
324
|
+
const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
|
|
325
|
+
const nextOk = !next || !/[a-z0-9._-]/.test(next);
|
|
326
|
+
if (previousOk && nextOk) return true;
|
|
327
|
+
from = index + stem.length;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
};
|
|
331
|
+
const hasBareStemBoundary = (text, stem) => {
|
|
332
|
+
if (!stem) return false;
|
|
333
|
+
let from = 0;
|
|
334
|
+
while (from < text.length) {
|
|
335
|
+
const index = text.indexOf(stem, from);
|
|
336
|
+
if (index === -1) return false;
|
|
337
|
+
const previous = text[index - 1] || '';
|
|
338
|
+
const next = text[index + stem.length] || '';
|
|
339
|
+
const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
|
|
340
|
+
const nextOk = !next || !/[a-z0-9._(-]/.test(next);
|
|
341
|
+
if (previousOk && nextOk) return true;
|
|
342
|
+
from = index + stem.length;
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
};
|
|
314
346
|
const hasExtensionBoundary = (text, extension) => {
|
|
315
347
|
if (!extension) return false;
|
|
316
348
|
let from = 0;
|
|
@@ -327,6 +359,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
327
359
|
const text = normalize(value);
|
|
328
360
|
if (!text) return false;
|
|
329
361
|
if (hasNameBoundary(text, item.name)) return true;
|
|
362
|
+
if (item.generatedBundle && hasBareStemBoundary(text, item.stem)) return true;
|
|
330
363
|
if (
|
|
331
364
|
item.stem &&
|
|
332
365
|
item.stem.length >= 4 &&
|
|
@@ -364,6 +397,8 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
364
397
|
};
|
|
365
398
|
// Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
|
|
366
399
|
const attachmentSelectors = [
|
|
400
|
+
// Current ChatGPT file tiles expose the filename through a role-group aria label.
|
|
401
|
+
'[role="group"][aria-label]',
|
|
367
402
|
'[data-testid*="chip"]',
|
|
368
403
|
'[data-testid*="attachment"]',
|
|
369
404
|
'[data-testid*="upload"]',
|
|
@@ -476,6 +511,15 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
476
511
|
const visibleExtensionLabelsMatchExpected = chipOwnLabelsWithVisibleNames.every((label) =>
|
|
477
512
|
expected.some((item) => matchesExpected(label, item)),
|
|
478
513
|
);
|
|
514
|
+
const visibleStemOnlyMismatch = chipOwnLabels.some((label) =>
|
|
515
|
+
expected.some(
|
|
516
|
+
(item) =>
|
|
517
|
+
!item.generatedBundle &&
|
|
518
|
+
item.stem &&
|
|
519
|
+
hasStemFileBoundary(label, item.stem) &&
|
|
520
|
+
!matchesExpected(label, item),
|
|
521
|
+
),
|
|
522
|
+
);
|
|
479
523
|
|
|
480
524
|
const chipsReady = (() => {
|
|
481
525
|
const used = new Set();
|
|
@@ -521,7 +565,9 @@ function buildAttachmentReadyExpression(attachmentNames) {
|
|
|
521
565
|
}
|
|
522
566
|
}
|
|
523
567
|
const countReady =
|
|
524
|
-
|
|
568
|
+
!visibleStemOnlyMismatch &&
|
|
569
|
+
visibleExtensionLabelsMatchExpected &&
|
|
570
|
+
removeAffordances.length >= expected.length;
|
|
525
571
|
|
|
526
572
|
return chipsReady || inputsReady || countReady;
|
|
527
573
|
})()`;
|