@steipete/oracle 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/oracle-cli.js +14 -6
- package/dist/docs-site/bridge.html +17 -1
- package/dist/docs-site/browser-mode.html +2 -2
- package/dist/docs-site/configuration.html +12 -2
- package/dist/docs-site/openai-endpoints.html +12 -0
- package/dist/scripts/test-browser.js +13 -2
- package/dist/src/browser/actions/assistantResponse.js +81 -50
- package/dist/src/browser/actions/attachments.js +31 -5
- package/dist/src/browser/actions/deepResearch.js +218 -73
- package/dist/src/browser/actions/modelSelection.js +30 -7
- package/dist/src/browser/actions/promptComposer.js +75 -19
- package/dist/src/browser/actions/thinkingStatus.js +19 -1
- package/dist/src/browser/artifacts.js +191 -6
- package/dist/src/browser/chatgptFiles.js +529 -98
- package/dist/src/browser/chatgptImages.js +3 -4
- package/dist/src/browser/chromeLifecycle.js +1 -0
- package/dist/src/browser/constants.js +6 -0
- package/dist/src/browser/conversationTurns.js +16 -0
- package/dist/src/browser/conversationUrlMonitor.js +64 -0
- package/dist/src/browser/cookies.js +72 -0
- package/dist/src/browser/index.js +103 -94
- package/dist/src/browser/projectSourcesRunner.js +3 -2
- package/dist/src/browser/reattach.js +27 -11
- package/dist/src/browser/reattachHelpers.js +14 -5
- package/dist/src/browser/sessionRunner.js +9 -3
- package/dist/src/cli/bridge/client.js +4 -1
- package/dist/src/cli/bridge/doctor.js +19 -0
- package/dist/src/cli/runOptions.js +11 -2
- package/dist/src/cli/sessionDisplay.js +6 -1
- package/dist/src/cli/sessionRunner.js +28 -10
- package/dist/src/config.js +3 -0
- package/dist/src/oracle/client.js +2 -0
- package/dist/src/oracle/modelResolver.js +85 -0
- package/dist/src/oracle/multiModelRunner.js +4 -1
- package/dist/src/oracle/oscProgress.js +3 -2
- package/dist/src/oracle/run.js +4 -1
- package/dist/src/remote/client.js +253 -22
- package/dist/src/remote/health.js +27 -0
- package/dist/src/remote/server.js +239 -4
- package/dist/src/remote/types.js +1 -1
- package/dist/src/sessionManager.js +1 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +20 -20
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
|
@@ -1,10 +1,134 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { ASSISTANT_ROLE_SELECTOR
|
|
4
|
-
import {
|
|
3
|
+
import { ASSISTANT_ROLE_SELECTOR } from "./constants.js";
|
|
4
|
+
import { buildConversationTurnListExpression } from "./conversationTurns.js";
|
|
5
|
+
import { computeFileSha256, resolveSessionArtifactsDir, sanitizeArtifactFilename, validateArtifactFile, writeBinaryBrowserArtifact, } from "./artifacts.js";
|
|
5
6
|
const CHATGPT_DOWNLOAD_BASE_URL = "https://chatgpt.com/";
|
|
6
7
|
const DOWNLOAD_BUTTON_WAIT_MS = 15_000;
|
|
7
8
|
const DOWNLOAD_REDIRECT_LIMIT = 5;
|
|
9
|
+
const DIAGNOSTIC_BODY_SNIPPET_BYTES = 180;
|
|
10
|
+
class ChatGptDownloadError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ChatGptDownloadError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function safeDiagnosticText(value, maxLength = DIAGNOSTIC_BODY_SNIPPET_BYTES) {
|
|
17
|
+
const compact = value
|
|
18
|
+
.replace(/https?:\/\/[^\s"'<>]+/gi, (match) => {
|
|
19
|
+
try {
|
|
20
|
+
const url = new URL(match);
|
|
21
|
+
return `${url.origin}${url.pathname}${url.search ? "?[redacted]" : ""}`;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "[redacted-url]";
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
.replace(/("?(?:access_token|authorization|bearer|cookie|id_token|key|secret|session|signature|sig|token)"?\s*[:=]\s*)"?[^,"'\s}]+"?/gi, "$1[redacted]")
|
|
28
|
+
.replace(/\b(access[_ -]?token|authorization|bearer|cookie|id[_ -]?token|api[_ -]?key|secret|session|signature|sig|token)\b\s+["']?[a-z0-9._~+/=-]{4,}/gi, "$1 [redacted]")
|
|
29
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
return compact.length > maxLength ? `${compact.slice(0, maxLength)}…` : compact;
|
|
33
|
+
}
|
|
34
|
+
function classifyResponseBodyKind(contentType) {
|
|
35
|
+
const value = String(contentType ?? "").toLowerCase();
|
|
36
|
+
if (value.includes("json"))
|
|
37
|
+
return "json";
|
|
38
|
+
if (value.includes("html"))
|
|
39
|
+
return "html";
|
|
40
|
+
if (value.startsWith("text/") || value.includes("xml"))
|
|
41
|
+
return "text";
|
|
42
|
+
return "binary";
|
|
43
|
+
}
|
|
44
|
+
function decodeDiagnosticBodySnippet(body, contentType) {
|
|
45
|
+
const bodyKind = classifyResponseBodyKind(contentType);
|
|
46
|
+
if (body.length === 0 || bodyKind === "binary") {
|
|
47
|
+
return { bodyKind };
|
|
48
|
+
}
|
|
49
|
+
let text = body.subarray(0, DIAGNOSTIC_BODY_SNIPPET_BYTES * 4).toString("utf8");
|
|
50
|
+
if (bodyKind === "html") {
|
|
51
|
+
text = text.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ");
|
|
52
|
+
}
|
|
53
|
+
return { bodyKind, bodySnippet: safeDiagnosticText(text) };
|
|
54
|
+
}
|
|
55
|
+
async function readDiagnosticResponseBody(response, contentType) {
|
|
56
|
+
const reader = response.body?.getReader();
|
|
57
|
+
if (!reader) {
|
|
58
|
+
return { bodyKind: classifyResponseBodyKind(contentType) };
|
|
59
|
+
}
|
|
60
|
+
const limit = DIAGNOSTIC_BODY_SNIPPET_BYTES * 4;
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let total = 0;
|
|
63
|
+
try {
|
|
64
|
+
while (total < limit) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done)
|
|
67
|
+
break;
|
|
68
|
+
const remaining = limit - total;
|
|
69
|
+
const chunk = Buffer.from(value.subarray(0, remaining));
|
|
70
|
+
chunks.push(chunk);
|
|
71
|
+
total += chunk.length;
|
|
72
|
+
if (total >= limit) {
|
|
73
|
+
await reader.cancel().catch(() => undefined);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return decodeDiagnosticBodySnippet(Buffer.concat(chunks), contentType);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { bodyKind: classifyResponseBodyKind(contentType) };
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
reader.releaseLock();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function classifyUrlKind(value) {
|
|
87
|
+
const raw = String(value ?? "");
|
|
88
|
+
if (!raw)
|
|
89
|
+
return "unknown";
|
|
90
|
+
if (raw.startsWith("sandbox:"))
|
|
91
|
+
return "sandbox";
|
|
92
|
+
if (raw === "browser-download")
|
|
93
|
+
return "browser-download";
|
|
94
|
+
try {
|
|
95
|
+
const url = new URL(raw, CHATGPT_DOWNLOAD_BASE_URL);
|
|
96
|
+
if (!isAllowedChatGptHost(url.hostname))
|
|
97
|
+
return "external-https";
|
|
98
|
+
const pathName = url.pathname.toLowerCase();
|
|
99
|
+
if (pathName === "/backend-api/sandbox/download")
|
|
100
|
+
return "chatgpt-sandbox-download";
|
|
101
|
+
if (/^\/backend-api\/files\/[^/]+\/(?:download|content)\/?$/.test(pathName)) {
|
|
102
|
+
return "chatgpt-file-endpoint";
|
|
103
|
+
}
|
|
104
|
+
if (pathName === "/backend-api/estuary/content")
|
|
105
|
+
return "chatgpt-estuary-content";
|
|
106
|
+
return "chatgpt-other";
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return "unknown";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function formatDownloadFailure(params) {
|
|
113
|
+
const parts = [`download failed via ${params.strategy}`];
|
|
114
|
+
if (params.status !== undefined)
|
|
115
|
+
parts.push(`status=${params.status}`);
|
|
116
|
+
if (params.statusText)
|
|
117
|
+
parts.push(`statusText=${safeDiagnosticText(params.statusText, 80)}`);
|
|
118
|
+
if (params.contentType)
|
|
119
|
+
parts.push(`contentType=${safeDiagnosticText(params.contentType, 80)}`);
|
|
120
|
+
parts.push(`finalUrlKind=${classifyUrlKind(params.finalUrl)}`);
|
|
121
|
+
if (params.bodyKind)
|
|
122
|
+
parts.push(`bodyKind=${params.bodyKind}`);
|
|
123
|
+
if (params.bodySnippet)
|
|
124
|
+
parts.push(`bodySnippet=${JSON.stringify(params.bodySnippet)}`);
|
|
125
|
+
if (params.message)
|
|
126
|
+
parts.push(`reason=${safeDiagnosticText(params.message, 140)}`);
|
|
127
|
+
return new ChatGptDownloadError(parts.join(" "));
|
|
128
|
+
}
|
|
129
|
+
function sanitizeCandidateFilename(value) {
|
|
130
|
+
return sanitizeArtifactFilename(String(value ?? ""), "artifact.bin");
|
|
131
|
+
}
|
|
8
132
|
function isAllowedChatGptHost(hostname) {
|
|
9
133
|
const value = hostname.toLowerCase();
|
|
10
134
|
return value === "chatgpt.com" || value === "chat.openai.com";
|
|
@@ -149,11 +273,9 @@ function buildAssistantDownloadableFilesExpression(minTurnIndex) {
|
|
|
149
273
|
const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
|
|
150
274
|
? Math.floor(minTurnIndex)
|
|
151
275
|
: -1;
|
|
152
|
-
const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
153
276
|
const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
154
277
|
return `(() => {
|
|
155
278
|
const MIN_TURN_INDEX = ${minTurnLiteral};
|
|
156
|
-
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
157
279
|
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
158
280
|
const isAssistantTurn = (node) => {
|
|
159
281
|
if (!(node instanceof HTMLElement)) return false;
|
|
@@ -210,36 +332,59 @@ function buildAssistantDownloadableFilesExpression(minTurnIndex) {
|
|
|
210
332
|
return part;
|
|
211
333
|
}
|
|
212
334
|
};
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
335
|
+
const hrefKind = (value) => {
|
|
336
|
+
if (!value) return '';
|
|
337
|
+
if (isSandboxUrl(value)) return 'sandbox';
|
|
338
|
+
if (isChatGptDownloadUrl(value)) return 'chatgpt-file-endpoint';
|
|
339
|
+
return '';
|
|
340
|
+
};
|
|
341
|
+
const collectValues = (node) => {
|
|
342
|
+
const values = [];
|
|
343
|
+
if (String(node.tagName || '').toLowerCase() === 'a') {
|
|
344
|
+
values.push(node.getAttribute('href') || '', node.href || '', node.getAttribute('download') || '');
|
|
345
|
+
}
|
|
346
|
+
for (const attribute of Array.from(node.attributes || [])) {
|
|
217
347
|
values.push(String(attribute.value || ''));
|
|
218
348
|
}
|
|
349
|
+
for (const anchor of Array.from(node.querySelectorAll?.('a[href], a[download]') || [])) {
|
|
350
|
+
values.push(anchor.getAttribute('href') || '', anchor.href || '', anchor.getAttribute('download') || '');
|
|
351
|
+
for (const attribute of Array.from(anchor.attributes || [])) {
|
|
352
|
+
values.push(String(attribute.value || ''));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return values;
|
|
356
|
+
};
|
|
357
|
+
const serializeCandidate = (node) => {
|
|
358
|
+
if (!(node instanceof HTMLElement)) return null;
|
|
359
|
+
const values = collectValues(node);
|
|
219
360
|
const downloadUrl = values.find(isChatGptDownloadUrl) || '';
|
|
220
361
|
const sandboxUrl = values.find(isSandboxUrl) || '';
|
|
221
362
|
if (!downloadUrl && !sandboxUrl) return null;
|
|
222
|
-
const label = (
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
basename(sandboxUrl) ||
|
|
226
|
-
basename(downloadUrl) ||
|
|
227
|
-
label ||
|
|
228
|
-
'';
|
|
363
|
+
const label = (node.textContent || node.getAttribute('aria-label') || node.getAttribute('title') || '').trim();
|
|
364
|
+
const downloadAttr = String(node.tagName || '').toLowerCase() === 'a' ? node.getAttribute('download') || '' : '';
|
|
365
|
+
const filename = downloadAttr || basename(sandboxUrl) || basename(downloadUrl) || label || '';
|
|
229
366
|
return {
|
|
230
|
-
url: downloadUrl || sandboxUrl ||
|
|
367
|
+
url: downloadUrl || sandboxUrl || values.find(hrefKind) || '',
|
|
231
368
|
downloadUrl,
|
|
232
369
|
sandboxUrl,
|
|
233
370
|
filename,
|
|
234
371
|
label,
|
|
235
|
-
mimeType:
|
|
372
|
+
mimeType: node.getAttribute('type') || '',
|
|
236
373
|
};
|
|
237
374
|
};
|
|
238
375
|
const serializeFiles = (root) =>
|
|
239
|
-
Array.from(root.querySelectorAll(
|
|
240
|
-
|
|
376
|
+
Array.from(root.querySelectorAll([
|
|
377
|
+
'a[href]',
|
|
378
|
+
'a[download]',
|
|
379
|
+
'button',
|
|
380
|
+
'[role="button"]',
|
|
381
|
+
'[data-testid]',
|
|
382
|
+
'[aria-label]',
|
|
383
|
+
'[title]',
|
|
384
|
+
].join(',')))
|
|
385
|
+
.map(serializeCandidate)
|
|
241
386
|
.filter(Boolean);
|
|
242
|
-
const turns =
|
|
387
|
+
const turns = ${buildConversationTurnListExpression()};
|
|
243
388
|
const files = [];
|
|
244
389
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
245
390
|
const turn = turns[index];
|
|
@@ -296,6 +441,23 @@ function filenameFromContentDisposition(value) {
|
|
|
296
441
|
}
|
|
297
442
|
return /filename="?([^";]+)"?/i.exec(header)?.[1]?.trim();
|
|
298
443
|
}
|
|
444
|
+
function classifyDownloadableFileSourceKind(file, downloadUrl) {
|
|
445
|
+
if (file.sandboxUrl || file.url.startsWith("sandbox:")) {
|
|
446
|
+
return "sandbox";
|
|
447
|
+
}
|
|
448
|
+
if ((downloadUrl ?? file.downloadUrl ?? file.url) === "browser-download") {
|
|
449
|
+
return "browser-download";
|
|
450
|
+
}
|
|
451
|
+
return "chatgpt-file-endpoint";
|
|
452
|
+
}
|
|
453
|
+
function describeDownloadableCandidate(file, downloadUrl) {
|
|
454
|
+
const filename = sanitizeCandidateFilename(file.filename ??
|
|
455
|
+
file.label ??
|
|
456
|
+
filenameFromUrl(file.sandboxUrl) ??
|
|
457
|
+
filenameFromUrl(file.downloadUrl) ??
|
|
458
|
+
filenameFromUrl(file.url));
|
|
459
|
+
return `filename=${filename} source=${classifyDownloadableFileSourceKind(file, downloadUrl)} urlKind=${classifyUrlKind(downloadUrl ?? file.downloadUrl ?? file.sandboxUrl ?? file.url)}`;
|
|
460
|
+
}
|
|
299
461
|
function filenameFromUrl(value) {
|
|
300
462
|
const raw = String(value ?? "")
|
|
301
463
|
.split(/[?#]/)[0]
|
|
@@ -432,6 +594,7 @@ async function configureBrowserDownloadPath(params) {
|
|
|
432
594
|
await params.Client.send("Browser.setDownloadBehavior", {
|
|
433
595
|
behavior: "allow",
|
|
434
596
|
downloadPath: params.downloadPath,
|
|
597
|
+
eventsEnabled: true,
|
|
435
598
|
});
|
|
436
599
|
return true;
|
|
437
600
|
}
|
|
@@ -462,11 +625,11 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
|
|
|
462
625
|
const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
|
|
463
626
|
? Math.floor(minTurnIndex)
|
|
464
627
|
: -1;
|
|
465
|
-
const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
466
628
|
const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
467
629
|
const expectedLabelsLiteral = JSON.stringify(expectedLabels);
|
|
468
630
|
const allowGenericDownloadLabelsLiteral = JSON.stringify(allowGenericDownloadLabels);
|
|
469
631
|
const markClickedLiteral = JSON.stringify(options.markClicked === true);
|
|
632
|
+
const returnDiagnosticsLiteral = JSON.stringify(options.returnDiagnostics === true);
|
|
470
633
|
const maxClicksLiteral = typeof options.maxClicks === "number" &&
|
|
471
634
|
Number.isFinite(options.maxClicks) &&
|
|
472
635
|
options.maxClicks > 0
|
|
@@ -474,11 +637,11 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
|
|
|
474
637
|
: 0;
|
|
475
638
|
return `(() => {
|
|
476
639
|
const MIN_TURN_INDEX = ${minTurnLiteral};
|
|
477
|
-
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
478
640
|
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
479
641
|
const EXPECTED_LABELS = ${expectedLabelsLiteral};
|
|
480
642
|
const ALLOW_GENERIC_DOWNLOAD_LABELS = ${allowGenericDownloadLabelsLiteral};
|
|
481
643
|
const MARK_CLICKED = ${markClickedLiteral};
|
|
644
|
+
const RETURN_DIAGNOSTICS = ${returnDiagnosticsLiteral};
|
|
482
645
|
const MAX_CLICKS = ${maxClicksLiteral};
|
|
483
646
|
const HAS_EXPECTED_LABELS = EXPECTED_LABELS.length > 0;
|
|
484
647
|
const CLICKED_ATTRIBUTE = 'data-oracle-download-clicked';
|
|
@@ -492,55 +655,152 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
|
|
|
492
655
|
if (testId.includes('assistant')) return true;
|
|
493
656
|
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
494
657
|
};
|
|
495
|
-
const
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
return
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
});
|
|
658
|
+
const basename = (value) => {
|
|
659
|
+
const raw = String(value || '').split(/[?#]/)[0].replace(/\\/+$/g, '');
|
|
660
|
+
const part = raw.slice(raw.lastIndexOf('/') + 1);
|
|
661
|
+
try {
|
|
662
|
+
return decodeURIComponent(part);
|
|
663
|
+
} catch {
|
|
664
|
+
return part;
|
|
665
|
+
}
|
|
504
666
|
};
|
|
505
|
-
const
|
|
506
|
-
const
|
|
507
|
-
|
|
667
|
+
const isSafeSandboxUrl = (value) => {
|
|
668
|
+
const raw = String(value || '').trim();
|
|
669
|
+
if (!raw.startsWith('sandbox:/mnt/data/')) return false;
|
|
670
|
+
try {
|
|
671
|
+
const pathName = decodeURI(new URL(raw).pathname);
|
|
672
|
+
return pathName.startsWith('/mnt/data/') &&
|
|
673
|
+
!pathName.includes('\\\\') &&
|
|
674
|
+
!pathName.includes('\\0') &&
|
|
675
|
+
!pathName.split('/').includes('..');
|
|
676
|
+
} catch {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
508
679
|
};
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
680
|
+
const isChatGptDownloadUrl = (value) => {
|
|
681
|
+
const raw = String(value || '').trim();
|
|
682
|
+
if (!raw || raw.startsWith('sandbox:') || raw.startsWith('blob:')) return false;
|
|
683
|
+
const isSafeSandboxPath = (pathName) => {
|
|
684
|
+
const normalized = String(pathName || '');
|
|
685
|
+
return normalized.startsWith('/mnt/data/') &&
|
|
686
|
+
!normalized.includes('\\\\') &&
|
|
687
|
+
!normalized.includes('\\0') &&
|
|
688
|
+
!normalized.split('/').includes('..');
|
|
689
|
+
};
|
|
690
|
+
try {
|
|
691
|
+
const url = new URL(raw, 'https://chatgpt.com');
|
|
692
|
+
const host = url.hostname.toLowerCase();
|
|
693
|
+
const allowedHost = host === 'chatgpt.com' || host === 'chat.openai.com';
|
|
694
|
+
const pathName = url.pathname.toLowerCase();
|
|
695
|
+
const isKnownFileDownload =
|
|
696
|
+
(pathName === '/backend-api/sandbox/download' && isSafeSandboxPath(url.searchParams.get('path') || '')) ||
|
|
697
|
+
/^\\/backend-api\\/files\\/[^/]+\\/(?:download|content)\\/?$/.test(pathName) ||
|
|
698
|
+
(pathName === '/backend-api/estuary/content' && String(url.searchParams.get('id') || '').startsWith('file_'));
|
|
699
|
+
return allowedHost && url.protocol === 'https:' && !url.port && isKnownFileDownload;
|
|
700
|
+
} catch {
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
const safeLower = (value) => String(value || '').trim().toLowerCase();
|
|
705
|
+
const controlInfo = (control) => {
|
|
706
|
+
const href = String(control.tagName || '').toLowerCase() === 'a' ? (control.getAttribute('href') || control.href || '') : '';
|
|
707
|
+
const text = (control.textContent || '').trim();
|
|
708
|
+
const ariaLabel = control.getAttribute('aria-label') || '';
|
|
709
|
+
const title = control.getAttribute('title') || '';
|
|
710
|
+
const testId = control.getAttribute('data-testid') || '';
|
|
711
|
+
const role = control.getAttribute('role') || '';
|
|
712
|
+
const download = String(control.tagName || '').toLowerCase() === 'a' ? (control.getAttribute('download') || '') : '';
|
|
713
|
+
const className = String(control.className || '');
|
|
714
|
+
const attributeValues = Array.from(control.attributes || []).map((attribute) => String(attribute.value || ''));
|
|
715
|
+
return {
|
|
716
|
+
control,
|
|
717
|
+
tagName: control.tagName || '',
|
|
718
|
+
text,
|
|
719
|
+
ariaLabel,
|
|
720
|
+
title,
|
|
721
|
+
testId,
|
|
722
|
+
role,
|
|
723
|
+
href,
|
|
724
|
+
download,
|
|
725
|
+
className,
|
|
726
|
+
attributeValues,
|
|
727
|
+
haystack: [text, ariaLabel, title, testId, role, href, download, basename(href), ...attributeValues]
|
|
728
|
+
.map(safeLower)
|
|
729
|
+
.filter(Boolean),
|
|
730
|
+
};
|
|
515
731
|
};
|
|
516
|
-
const
|
|
732
|
+
const safeClickableControl = (info) =>
|
|
733
|
+
safeLower(info.tagName) !== 'a' || isSafeSandboxUrl(info.href) || isChatGptDownloadUrl(info.href);
|
|
734
|
+
const expectedFileControl = (info) => {
|
|
735
|
+
return EXPECTED_LABELS.some((rawLabel) => {
|
|
736
|
+
const label = safeLower(rawLabel);
|
|
737
|
+
if (!label) return false;
|
|
738
|
+
const downloadLabel = 'download ' + label;
|
|
739
|
+
return info.haystack.some((value) =>
|
|
740
|
+
value === label ||
|
|
741
|
+
value.startsWith(label + ' ') ||
|
|
742
|
+
value === downloadLabel ||
|
|
743
|
+
value.startsWith(downloadLabel + ' ') ||
|
|
744
|
+
value.endsWith('/' + label) ||
|
|
745
|
+
value.includes('/' + label + '?') ||
|
|
746
|
+
value.includes('/' + label + '#') ||
|
|
747
|
+
value.includes('path=%2fmnt%2fdata%2f' + encodeURIComponent(label).toLowerCase()) ||
|
|
748
|
+
value.includes('sandbox:/mnt/data/' + label)
|
|
749
|
+
);
|
|
750
|
+
});
|
|
751
|
+
};
|
|
752
|
+
const hasDownloadIntent = (info) =>
|
|
753
|
+
info.haystack.some((value) =>
|
|
754
|
+
value === 'download' ||
|
|
755
|
+
/^download\\b/.test(value) ||
|
|
756
|
+
value.includes('download') ||
|
|
757
|
+
value === 'download-files-turn-action-button'
|
|
758
|
+
);
|
|
759
|
+
const genericBehaviorButton = (info) =>
|
|
760
|
+
ALLOW_GENERIC_DOWNLOAD_LABELS && info.className.includes('behavior-btn') && hasDownloadIntent(info);
|
|
761
|
+
const genericFallbackButton = (info) => ALLOW_GENERIC_DOWNLOAD_LABELS && hasDownloadIntent(info);
|
|
762
|
+
const turns = ${buildConversationTurnListExpression()};
|
|
517
763
|
const expectedMatches = new Set();
|
|
518
764
|
const genericBehaviorMatches = new Set();
|
|
519
765
|
const genericFallbackMatches = new Set();
|
|
520
766
|
const genericAllMatches = new Set();
|
|
767
|
+
let inspectedCount = 0;
|
|
521
768
|
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
522
769
|
const turn = turns[index];
|
|
523
770
|
if (!isAssistantTurn(turn)) continue;
|
|
524
771
|
if (MIN_TURN_INDEX >= 0 && index < MIN_TURN_INDEX) continue;
|
|
525
772
|
const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) || turn;
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
773
|
+
const controls = Array.from(messageRoot.querySelectorAll([
|
|
774
|
+
'button',
|
|
775
|
+
'a[href]',
|
|
776
|
+
'a[download]',
|
|
777
|
+
'[role="button"]',
|
|
778
|
+
].join(',')))
|
|
779
|
+
.filter((control) => control instanceof HTMLElement)
|
|
780
|
+
.filter((control) => !(MARK_CLICKED && control.getAttribute(CLICKED_ATTRIBUTE) === 'true'))
|
|
781
|
+
.map(controlInfo)
|
|
782
|
+
.filter(safeClickableControl);
|
|
783
|
+
inspectedCount += controls.length;
|
|
784
|
+
controls.filter(expectedFileControl).forEach((info) => expectedMatches.add(info));
|
|
785
|
+
const genericBehavior = controls.filter(genericBehaviorButton);
|
|
786
|
+
genericBehavior.forEach((info) => {
|
|
787
|
+
genericBehaviorMatches.add(info);
|
|
788
|
+
genericAllMatches.add(info);
|
|
536
789
|
});
|
|
537
790
|
if (genericBehavior.length === 0) {
|
|
538
|
-
|
|
539
|
-
genericFallbackMatches.add(
|
|
540
|
-
genericAllMatches.add(
|
|
791
|
+
controls.filter(genericFallbackButton).forEach((info) => {
|
|
792
|
+
genericFallbackMatches.add(info);
|
|
793
|
+
genericAllMatches.add(info);
|
|
541
794
|
});
|
|
542
795
|
}
|
|
543
796
|
}
|
|
797
|
+
const selectedCategory = expectedMatches.size > 0
|
|
798
|
+
? 'expected-label'
|
|
799
|
+
: HAS_EXPECTED_LABELS
|
|
800
|
+
? genericBehaviorMatches.size > 0
|
|
801
|
+
? 'generic-behavior'
|
|
802
|
+
: 'generic-fallback'
|
|
803
|
+
: 'generic-all';
|
|
544
804
|
const selected = expectedMatches.size > 0
|
|
545
805
|
? expectedMatches
|
|
546
806
|
: HAS_EXPECTED_LABELS
|
|
@@ -548,16 +808,23 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
|
|
|
548
808
|
? genericBehaviorMatches
|
|
549
809
|
: genericFallbackMatches
|
|
550
810
|
: genericAllMatches;
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
if (MARK_CLICKED)
|
|
554
|
-
|
|
811
|
+
const selectedControls = Array.from(selected).slice(0, MAX_CLICKS > 0 ? MAX_CLICKS : undefined);
|
|
812
|
+
selectedControls.forEach((info) => {
|
|
813
|
+
if (MARK_CLICKED) info.control.setAttribute(CLICKED_ATTRIBUTE, 'true');
|
|
814
|
+
info.control.click();
|
|
555
815
|
});
|
|
556
|
-
|
|
557
|
-
text:
|
|
558
|
-
ariaLabel:
|
|
559
|
-
|
|
816
|
+
const clickedDiagnostics = selectedControls.map((info) => ({
|
|
817
|
+
text: info.text,
|
|
818
|
+
ariaLabel: info.ariaLabel,
|
|
819
|
+
title: info.title,
|
|
820
|
+
testId: info.testId,
|
|
821
|
+
tagName: info.tagName,
|
|
822
|
+
role: info.role,
|
|
823
|
+
hrefKind: info.href ? (info.href.startsWith('sandbox:') ? 'sandbox' : 'link') : '',
|
|
824
|
+
category: selectedCategory,
|
|
560
825
|
}));
|
|
826
|
+
const clicked = clickedDiagnostics.map(({ text, ariaLabel, testId }) => ({ text, ariaLabel, testId }));
|
|
827
|
+
return RETURN_DIAGNOSTICS ? { inspectedCount, selectedCategory, clicked: clickedDiagnostics } : clicked;
|
|
561
828
|
})()`;
|
|
562
829
|
}
|
|
563
830
|
function describeDownloadableFile(file) {
|
|
@@ -604,31 +871,112 @@ async function moveDownloadedFileToExpectedName(filePath, file) {
|
|
|
604
871
|
return targetPath;
|
|
605
872
|
}
|
|
606
873
|
async function clickAssistantDownloadButtons(params) {
|
|
607
|
-
const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, params.expectedLabels ?? [], params.allowGenericDownloadLabels, { markClicked: params.markClicked, maxClicks: params.maxClicks });
|
|
874
|
+
const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, params.expectedLabels ?? [], params.allowGenericDownloadLabels, { markClicked: params.markClicked, maxClicks: params.maxClicks, returnDiagnostics: true });
|
|
608
875
|
const deadline = Date.now() + (params.timeoutMs ?? DOWNLOAD_BUTTON_WAIT_MS);
|
|
876
|
+
let lastInspectedCount = 0;
|
|
877
|
+
let lastSelectedCategory;
|
|
609
878
|
while (Date.now() < deadline) {
|
|
610
879
|
const { result } = await params.Runtime.evaluate({
|
|
611
880
|
expression,
|
|
612
881
|
returnByValue: true,
|
|
613
882
|
});
|
|
614
|
-
const
|
|
883
|
+
const value = result?.value;
|
|
884
|
+
const diagnosticValue = value && typeof value === "object" && !Array.isArray(value)
|
|
885
|
+
? value
|
|
886
|
+
: undefined;
|
|
887
|
+
const clicked = Array.isArray(value)
|
|
888
|
+
? value
|
|
889
|
+
: Array.isArray(diagnosticValue?.clicked)
|
|
890
|
+
? diagnosticValue.clicked
|
|
891
|
+
: [];
|
|
892
|
+
if (diagnosticValue) {
|
|
893
|
+
lastInspectedCount = Number(diagnosticValue.inspectedCount ?? lastInspectedCount);
|
|
894
|
+
lastSelectedCategory = diagnosticValue.selectedCategory ?? lastSelectedCategory;
|
|
895
|
+
}
|
|
615
896
|
if (clicked.length > 0) {
|
|
616
|
-
return
|
|
897
|
+
return {
|
|
898
|
+
clicked,
|
|
899
|
+
inspectedCount: lastInspectedCount,
|
|
900
|
+
selectedCategory: lastSelectedCategory,
|
|
901
|
+
};
|
|
617
902
|
}
|
|
618
903
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
619
904
|
}
|
|
620
|
-
return
|
|
905
|
+
return {
|
|
906
|
+
clicked: [],
|
|
907
|
+
inspectedCount: lastInspectedCount,
|
|
908
|
+
selectedCategory: lastSelectedCategory,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
function summarizeClickedControls(clicked) {
|
|
912
|
+
return clicked
|
|
913
|
+
.slice(0, 5)
|
|
914
|
+
.map((control) => {
|
|
915
|
+
const tag = control.tagName ? `tag=${safeDiagnosticText(control.tagName, 24)}` : "tag=?";
|
|
916
|
+
const category = control.category
|
|
917
|
+
? `category=${safeDiagnosticText(control.category, 40)}`
|
|
918
|
+
: "category=?";
|
|
919
|
+
const testId = control.testId ? ` testId=${safeDiagnosticText(control.testId, 60)}` : "";
|
|
920
|
+
const aria = control.ariaLabel ? ` aria=${safeDiagnosticText(control.ariaLabel, 60)}` : "";
|
|
921
|
+
const text = control.text ? ` text=${safeDiagnosticText(control.text, 60)}` : "";
|
|
922
|
+
const hrefKind = control.hrefKind
|
|
923
|
+
? ` hrefKind=${safeDiagnosticText(control.hrefKind, 40)}`
|
|
924
|
+
: "";
|
|
925
|
+
return `${tag} ${category}${testId}${aria}${text}${hrefKind}`;
|
|
926
|
+
})
|
|
927
|
+
.join("; ");
|
|
928
|
+
}
|
|
929
|
+
async function clickGeneratedDownloadUrl(params) {
|
|
930
|
+
const filename = expectedDownloadedFilename(params.file) ?? "download";
|
|
931
|
+
const expression = `(() => {
|
|
932
|
+
const anchor = document.createElement('a');
|
|
933
|
+
anchor.href = ${JSON.stringify(params.downloadUrl)};
|
|
934
|
+
anchor.download = ${JSON.stringify(filename)};
|
|
935
|
+
anchor.rel = 'noopener';
|
|
936
|
+
anchor.style.display = 'none';
|
|
937
|
+
anchor.setAttribute('data-oracle-generated-download-anchor', 'true');
|
|
938
|
+
document.body.appendChild(anchor);
|
|
939
|
+
anchor.click();
|
|
940
|
+
setTimeout(() => anchor.remove(), 0);
|
|
941
|
+
return {
|
|
942
|
+
inspectedCount: 1,
|
|
943
|
+
selectedCategory: 'generated-download-url',
|
|
944
|
+
clicked: [{
|
|
945
|
+
text: '',
|
|
946
|
+
ariaLabel: '',
|
|
947
|
+
title: '',
|
|
948
|
+
testId: 'oracle-generated-download-anchor',
|
|
949
|
+
tagName: 'A',
|
|
950
|
+
role: '',
|
|
951
|
+
hrefKind: ${JSON.stringify(classifyUrlKind(params.downloadUrl))},
|
|
952
|
+
category: 'generated-download-url',
|
|
953
|
+
}],
|
|
954
|
+
};
|
|
955
|
+
})()`;
|
|
956
|
+
const { result } = await params.Runtime.evaluate({ expression, returnByValue: true });
|
|
957
|
+
const value = result?.value;
|
|
958
|
+
return {
|
|
959
|
+
clicked: Array.isArray(value?.clicked) ? value.clicked : [],
|
|
960
|
+
inspectedCount: Number(value?.inspectedCount ?? 1),
|
|
961
|
+
selectedCategory: value?.selectedCategory ?? "generated-download-url",
|
|
962
|
+
};
|
|
621
963
|
}
|
|
622
964
|
async function savedBrowserFileFromPath(filePath) {
|
|
623
965
|
const filename = path.basename(filePath);
|
|
624
966
|
const stat = await fs.stat(filePath);
|
|
967
|
+
const mimeType = mimeTypeFromFilename(filename);
|
|
968
|
+
const validation = await validateArtifactFile({ path: filePath, filename, mimeType });
|
|
625
969
|
return {
|
|
626
970
|
kind: "file",
|
|
627
971
|
path: filePath,
|
|
628
972
|
label: filename,
|
|
629
|
-
mimeType
|
|
973
|
+
mimeType,
|
|
630
974
|
sizeBytes: stat.size,
|
|
631
975
|
sourceUrl: "browser-download",
|
|
976
|
+
sha256: await computeFileSha256(filePath),
|
|
977
|
+
validation,
|
|
978
|
+
transfer: { status: "not-needed" },
|
|
979
|
+
origin: { mode: "local" },
|
|
632
980
|
url: "browser-download",
|
|
633
981
|
finalUrl: "browser-download",
|
|
634
982
|
filename,
|
|
@@ -661,19 +1009,22 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
|
|
|
661
1009
|
const downloadWaitMs = params.downloadWaitMs ?? DOWNLOAD_BUTTON_WAIT_MS;
|
|
662
1010
|
const expectedFiles = params.files ?? [];
|
|
663
1011
|
if (expectedFiles.length === 0) {
|
|
664
|
-
const
|
|
1012
|
+
const clickedResult = await clickAssistantDownloadButtons({
|
|
665
1013
|
Runtime: params.Runtime,
|
|
666
1014
|
minTurnIndex: params.minTurnIndex,
|
|
667
1015
|
expectedLabels: [],
|
|
668
1016
|
allowGenericDownloadLabels: params.allowGenericDownloadLabels,
|
|
669
1017
|
timeoutMs: buttonWaitMs,
|
|
1018
|
+
logger: params.logger,
|
|
670
1019
|
});
|
|
671
|
-
if (clicked.length === 0) {
|
|
672
|
-
params.logger?.(
|
|
1020
|
+
if (clickedResult.clicked.length === 0) {
|
|
1021
|
+
params.logger?.(`[browser] No assistant download controls found for button fallback (inspected ${clickedResult.inspectedCount} control(s); category=${clickedResult.selectedCategory ?? "none"}).`);
|
|
673
1022
|
return [];
|
|
674
1023
|
}
|
|
675
|
-
params.logger?.(`[browser] Clicked ${clicked.length} assistant download
|
|
676
|
-
|
|
1024
|
+
params.logger?.(`[browser] Clicked ${clickedResult.clicked.length} assistant download control(s) ` +
|
|
1025
|
+
`(inspected ${clickedResult.inspectedCount}; category=${clickedResult.selectedCategory ?? "unknown"}; ` +
|
|
1026
|
+
`details=${summarizeClickedControls(clickedResult.clicked)}).`);
|
|
1027
|
+
const downloaded = await waitForCompletedDownloadFiles(artifactsDir, before, clickedResult.clicked.length, downloadWaitMs);
|
|
677
1028
|
return Promise.all(downloaded.map(savedBrowserFileFromPath));
|
|
678
1029
|
}
|
|
679
1030
|
let clickedCount = 0;
|
|
@@ -683,7 +1034,8 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
|
|
|
683
1034
|
const unattemptedFiles = [];
|
|
684
1035
|
for (const [fileIndex, file] of expectedFiles.entries()) {
|
|
685
1036
|
const expectedLabels = resolveDownloadButtonLabels([file]);
|
|
686
|
-
const
|
|
1037
|
+
const displayName = describeDownloadableFile(file);
|
|
1038
|
+
let clickResult = await clickAssistantDownloadButtons({
|
|
687
1039
|
Runtime: params.Runtime,
|
|
688
1040
|
minTurnIndex: params.minTurnIndex,
|
|
689
1041
|
expectedLabels,
|
|
@@ -691,14 +1043,32 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
|
|
|
691
1043
|
markClicked: true,
|
|
692
1044
|
maxClicks: 1,
|
|
693
1045
|
timeoutMs: buttonWaitMs,
|
|
1046
|
+
logger: params.logger,
|
|
694
1047
|
});
|
|
695
|
-
|
|
696
|
-
if (clicked.length
|
|
1048
|
+
params.logger?.(`[browser] Button fallback inspected ${clickResult.inspectedCount} control(s) for ${sanitizeCandidateFilename(displayName)}; category=${clickResult.selectedCategory ?? "none"}; clicked=${clickResult.clicked.length}.`);
|
|
1049
|
+
if (clickResult.clicked.length > 0) {
|
|
1050
|
+
params.logger?.(`[browser] Button fallback clicked control detail(s): ${summarizeClickedControls(clickResult.clicked)}`);
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
const explicitDownloadUrl = normalizeChatGptDownloadUrl(file.downloadUrl ?? file.url);
|
|
1054
|
+
const sandboxDownloadUrl = downloadUrlFromSandboxUrl(file.sandboxUrl ?? file.url);
|
|
1055
|
+
const downloadUrl = explicitDownloadUrl ?? sandboxDownloadUrl;
|
|
1056
|
+
if (downloadUrl) {
|
|
1057
|
+
params.logger?.(`[browser] No matching assistant control for ${sanitizeCandidateFilename(displayName)}; trying scoped generated browser download for urlKind=${classifyUrlKind(downloadUrl)}.`);
|
|
1058
|
+
clickResult = await clickGeneratedDownloadUrl({
|
|
1059
|
+
Runtime: params.Runtime,
|
|
1060
|
+
file,
|
|
1061
|
+
downloadUrl,
|
|
1062
|
+
logger: params.logger,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (clickResult.clicked.length === 0) {
|
|
697
1067
|
missingFiles.push(displayName);
|
|
698
1068
|
knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
|
|
699
1069
|
continue;
|
|
700
1070
|
}
|
|
701
|
-
clickedCount += clicked.length;
|
|
1071
|
+
clickedCount += clickResult.clicked.length;
|
|
702
1072
|
const downloaded = await waitForCompletedDownloadFiles(artifactsDir, knownEntries, 1, downloadWaitMs);
|
|
703
1073
|
if (downloaded.length === 0) {
|
|
704
1074
|
missingFiles.push(displayName);
|
|
@@ -714,10 +1084,10 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
|
|
|
714
1084
|
knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
|
|
715
1085
|
}
|
|
716
1086
|
if (clickedCount === 0) {
|
|
717
|
-
params.logger?.("[browser] No assistant download
|
|
1087
|
+
params.logger?.("[browser] No assistant download controls found for button fallback.");
|
|
718
1088
|
}
|
|
719
1089
|
else {
|
|
720
|
-
params.logger?.(`[browser] Clicked ${clickedCount} assistant download
|
|
1090
|
+
params.logger?.(`[browser] Clicked ${clickedCount} assistant download control(s).`);
|
|
721
1091
|
}
|
|
722
1092
|
if (missingFiles.length > 0) {
|
|
723
1093
|
params.logger?.(`[browser] Download button fallback did not save expected file(s): ${missingFiles.join(", ")}`);
|
|
@@ -734,7 +1104,11 @@ async function fetchDownloadWithNode(downloadUrl, getCookieHeader) {
|
|
|
734
1104
|
isKnownChatGptFileDownloadUrl(currentUrl)) {
|
|
735
1105
|
const cookieHeader = await getCookieHeader(currentUrl.href);
|
|
736
1106
|
if (!cookieHeader) {
|
|
737
|
-
throw
|
|
1107
|
+
throw formatDownloadFailure({
|
|
1108
|
+
strategy: "node-fetch",
|
|
1109
|
+
finalUrl: currentUrl.href,
|
|
1110
|
+
message: "missing ChatGPT cookies for file download",
|
|
1111
|
+
});
|
|
738
1112
|
}
|
|
739
1113
|
headers.cookie = cookieHeader;
|
|
740
1114
|
}
|
|
@@ -742,29 +1116,58 @@ async function fetchDownloadWithNode(downloadUrl, getCookieHeader) {
|
|
|
742
1116
|
headers,
|
|
743
1117
|
redirect: "manual",
|
|
744
1118
|
});
|
|
1119
|
+
const contentType = response.headers.get("content-type");
|
|
745
1120
|
if (response.status >= 300 && response.status < 400) {
|
|
746
1121
|
const location = response.headers.get("location");
|
|
747
1122
|
if (!location) {
|
|
748
|
-
|
|
1123
|
+
const body = await readDiagnosticResponseBody(response, contentType);
|
|
1124
|
+
throw formatDownloadFailure({
|
|
1125
|
+
strategy: "node-fetch",
|
|
1126
|
+
status: response.status,
|
|
1127
|
+
statusText: response.statusText,
|
|
1128
|
+
contentType,
|
|
1129
|
+
finalUrl: currentUrl.href,
|
|
1130
|
+
...body,
|
|
1131
|
+
message: "download redirect missing location",
|
|
1132
|
+
});
|
|
749
1133
|
}
|
|
750
1134
|
const redirectedUrl = new URL(location, currentUrl);
|
|
751
1135
|
if (redirectedUrl.protocol !== "https:") {
|
|
752
|
-
throw
|
|
1136
|
+
throw formatDownloadFailure({
|
|
1137
|
+
strategy: "node-fetch",
|
|
1138
|
+
status: response.status,
|
|
1139
|
+
statusText: response.statusText,
|
|
1140
|
+
contentType,
|
|
1141
|
+
finalUrl: redirectedUrl.href,
|
|
1142
|
+
message: `download redirect rejected: ${redirectedUrl.protocol}`,
|
|
1143
|
+
});
|
|
753
1144
|
}
|
|
754
1145
|
currentUrl = redirectedUrl;
|
|
755
1146
|
continue;
|
|
756
1147
|
}
|
|
757
1148
|
if (!response.ok) {
|
|
758
|
-
|
|
1149
|
+
const body = await readDiagnosticResponseBody(response, contentType);
|
|
1150
|
+
throw formatDownloadFailure({
|
|
1151
|
+
strategy: "node-fetch",
|
|
1152
|
+
status: response.status,
|
|
1153
|
+
statusText: response.statusText,
|
|
1154
|
+
contentType,
|
|
1155
|
+
finalUrl: response.url || currentUrl.href,
|
|
1156
|
+
...body,
|
|
1157
|
+
});
|
|
759
1158
|
}
|
|
760
1159
|
return {
|
|
761
1160
|
buffer: Buffer.from(await response.arrayBuffer()),
|
|
762
1161
|
contentDisposition: response.headers.get("content-disposition"),
|
|
763
|
-
contentType
|
|
764
|
-
finalUrl: response.url,
|
|
1162
|
+
contentType,
|
|
1163
|
+
finalUrl: response.url || currentUrl.href,
|
|
765
1164
|
};
|
|
766
1165
|
}
|
|
767
|
-
throw
|
|
1166
|
+
throw formatDownloadFailure({
|
|
1167
|
+
strategy: "node-fetch",
|
|
1168
|
+
finalUrl: currentUrl.href,
|
|
1169
|
+
message: `download exceeded ${DOWNLOAD_REDIRECT_LIMIT} redirects`,
|
|
1170
|
+
});
|
|
768
1171
|
}
|
|
769
1172
|
async function fetchDownloadWithBrowser(Runtime, downloadUrl) {
|
|
770
1173
|
const expression = `(() => {
|
|
@@ -799,14 +1202,24 @@ async function fetchDownloadWithBrowser(Runtime, downloadUrl) {
|
|
|
799
1202
|
if (!value) {
|
|
800
1203
|
throw new Error("browser download returned no value");
|
|
801
1204
|
}
|
|
1205
|
+
const contentType = typeof value.contentType === "string" ? value.contentType : null;
|
|
1206
|
+
const finalUrl = typeof value.url === "string" ? value.url : downloadUrl;
|
|
802
1207
|
if (!value.ok) {
|
|
803
|
-
|
|
1208
|
+
const body = decodeDiagnosticBodySnippet(Buffer.from(String(value.base64 ?? ""), "base64"), contentType);
|
|
1209
|
+
throw formatDownloadFailure({
|
|
1210
|
+
strategy: "browser-fetch",
|
|
1211
|
+
status: value.status,
|
|
1212
|
+
statusText: value.statusText,
|
|
1213
|
+
contentType,
|
|
1214
|
+
finalUrl,
|
|
1215
|
+
...body,
|
|
1216
|
+
});
|
|
804
1217
|
}
|
|
805
1218
|
return {
|
|
806
1219
|
buffer: Buffer.from(String(value.base64 ?? ""), "base64"),
|
|
807
1220
|
contentDisposition: typeof value.contentDisposition === "string" ? value.contentDisposition : null,
|
|
808
|
-
contentType
|
|
809
|
-
finalUrl
|
|
1221
|
+
contentType,
|
|
1222
|
+
finalUrl,
|
|
810
1223
|
};
|
|
811
1224
|
}
|
|
812
1225
|
export async function saveChatGptDownloadableFiles(params) {
|
|
@@ -834,13 +1247,17 @@ export async function saveChatGptDownloadableFiles(params) {
|
|
|
834
1247
|
const sandboxDownloadUrl = downloadUrlFromSandboxUrl(file.sandboxUrl ?? file.url);
|
|
835
1248
|
const downloadUrl = explicitDownloadUrl ?? sandboxDownloadUrl;
|
|
836
1249
|
if (!downloadUrl) {
|
|
837
|
-
const source = file.sandboxUrl ?? file.filename ?? file.url;
|
|
838
|
-
|
|
1250
|
+
const source = sanitizeCandidateFilename(file.sandboxUrl ?? file.filename ?? file.url);
|
|
1251
|
+
const message = `${source}: no ChatGPT download URL found`;
|
|
1252
|
+
errors.push(message);
|
|
839
1253
|
failedFiles.push(file);
|
|
1254
|
+
logger?.(`[browser] Skipping downloadable file ${index + 1}/${files.length}: ${message}`);
|
|
840
1255
|
continue;
|
|
841
1256
|
}
|
|
1257
|
+
const strategy = params.Runtime && sandboxDownloadUrl && !explicitDownloadUrl ? "browser-fetch" : "node-fetch";
|
|
1258
|
+
logger?.(`[browser] Download candidate ${index + 1}/${files.length}: ${describeDownloadableCandidate(file, downloadUrl)} strategy=${strategy}`);
|
|
842
1259
|
try {
|
|
843
|
-
const downloaded =
|
|
1260
|
+
const downloaded = strategy === "browser-fetch"
|
|
844
1261
|
? await fetchDownloadWithBrowser(params.Runtime, downloadUrl)
|
|
845
1262
|
: await fetchDownloadWithNode(downloadUrl, getCookieHeader);
|
|
846
1263
|
const contentType = downloaded.contentType;
|
|
@@ -857,7 +1274,7 @@ export async function saveChatGptDownloadableFiles(params) {
|
|
|
857
1274
|
contents: downloaded.buffer,
|
|
858
1275
|
label: file.label || filename,
|
|
859
1276
|
mimeType: contentType ?? file.mimeType,
|
|
860
|
-
sourceUrl: file.sandboxUrl ??
|
|
1277
|
+
sourceUrl: file.sandboxUrl ?? "chatgpt-file-endpoint",
|
|
861
1278
|
logger,
|
|
862
1279
|
});
|
|
863
1280
|
if (artifact) {
|
|
@@ -876,9 +1293,14 @@ export async function saveChatGptDownloadableFiles(params) {
|
|
|
876
1293
|
}
|
|
877
1294
|
catch (error) {
|
|
878
1295
|
const message = error instanceof Error ? error.message : String(error);
|
|
879
|
-
|
|
1296
|
+
const safeMessage = safeDiagnosticText(message, 500);
|
|
1297
|
+
const filename = sanitizeCandidateFilename(file.filename ??
|
|
1298
|
+
filenameFromUrl(file.sandboxUrl) ??
|
|
1299
|
+
filenameFromUrl(file.downloadUrl) ??
|
|
1300
|
+
file.url);
|
|
1301
|
+
errors.push(`${filename}: ${safeMessage}`);
|
|
880
1302
|
failedFiles.push(file);
|
|
881
|
-
logger?.(`[browser] Failed to save downloadable file ${index + 1}/${files.length}: ${
|
|
1303
|
+
logger?.(`[browser] Failed to save downloadable file ${index + 1}/${files.length} (${describeDownloadableCandidate(file, downloadUrl)} strategy=${strategy}): ${safeMessage}`);
|
|
882
1304
|
}
|
|
883
1305
|
}
|
|
884
1306
|
return {
|
|
@@ -890,16 +1312,24 @@ export async function saveChatGptDownloadableFiles(params) {
|
|
|
890
1312
|
};
|
|
891
1313
|
}
|
|
892
1314
|
export async function collectChatGptFileArtifacts(params) {
|
|
893
|
-
const files = await readAssistantDownloadableFiles(params.Runtime, params.minTurnIndex ?? undefined).catch(() =>
|
|
1315
|
+
const files = await readAssistantDownloadableFiles(params.Runtime, params.minTurnIndex ?? undefined).catch((error) => {
|
|
1316
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1317
|
+
params.logger?.(`[browser] Failed to inspect assistant DOM file candidates: ${safeDiagnosticText(message, 180)}`);
|
|
1318
|
+
return [];
|
|
1319
|
+
});
|
|
894
1320
|
const textFiles = readTextDownloadableFiles(params.answerText);
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
}
|
|
1321
|
+
params.logger?.(`[browser] Found ${files.length} DOM downloadable file candidate(s).`);
|
|
1322
|
+
params.logger?.(`[browser] Found ${textFiles.length} downloadable file link(s) in captured answer text.`);
|
|
898
1323
|
const allFiles = dedupeFiles([...files, ...textFiles]);
|
|
899
1324
|
if (allFiles.length === 0) {
|
|
900
1325
|
return { files: [], savedFiles: [], fileCount: 0 };
|
|
901
1326
|
}
|
|
902
1327
|
params.logger?.(`[browser] Found ${allFiles.length} downloadable file candidate(s).`);
|
|
1328
|
+
allFiles.forEach((file, index) => {
|
|
1329
|
+
const explicitDownloadUrl = normalizeChatGptDownloadUrl(file.downloadUrl ?? file.url);
|
|
1330
|
+
const sandboxDownloadUrl = downloadUrlFromSandboxUrl(file.sandboxUrl ?? file.url);
|
|
1331
|
+
params.logger?.(`[browser] Candidate ${index + 1}/${allFiles.length}: ${describeDownloadableCandidate(file, explicitDownloadUrl ?? sandboxDownloadUrl)}`);
|
|
1332
|
+
});
|
|
903
1333
|
const saved = await saveChatGptDownloadableFiles({
|
|
904
1334
|
Network: params.Network,
|
|
905
1335
|
Runtime: params.Runtime,
|
|
@@ -924,6 +1354,7 @@ export async function collectChatGptFileArtifacts(params) {
|
|
|
924
1354
|
if (savedFiles.length === 0 && !saved.saved) {
|
|
925
1355
|
const detail = saved.errors.length > 0 ? `\n${saved.errors.join("\n")}` : "";
|
|
926
1356
|
params.logger?.(`[browser] Auto-save for downloadable files failed; returning metadata only.${detail}`);
|
|
1357
|
+
params.logger?.(`[browser] WARNING: ${allFiles.length} downloadable candidate(s) existed, but no local browser-host artifact was saved; bridge artifact-ready will not be emitted until ChatGPT file capture succeeds.`);
|
|
927
1358
|
}
|
|
928
1359
|
else {
|
|
929
1360
|
params.logger?.(`[browser] Saved ${savedFiles.length} downloadable file artifact(s).`);
|