@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.
Files changed (46) hide show
  1. package/dist/bin/oracle-cli.js +14 -6
  2. package/dist/docs-site/bridge.html +17 -1
  3. package/dist/docs-site/browser-mode.html +2 -2
  4. package/dist/docs-site/configuration.html +12 -2
  5. package/dist/docs-site/openai-endpoints.html +12 -0
  6. package/dist/scripts/test-browser.js +13 -2
  7. package/dist/src/browser/actions/assistantResponse.js +81 -50
  8. package/dist/src/browser/actions/attachments.js +31 -5
  9. package/dist/src/browser/actions/deepResearch.js +218 -73
  10. package/dist/src/browser/actions/modelSelection.js +30 -7
  11. package/dist/src/browser/actions/promptComposer.js +75 -19
  12. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  13. package/dist/src/browser/artifacts.js +191 -6
  14. package/dist/src/browser/chatgptFiles.js +529 -98
  15. package/dist/src/browser/chatgptImages.js +3 -4
  16. package/dist/src/browser/chromeLifecycle.js +1 -0
  17. package/dist/src/browser/constants.js +6 -0
  18. package/dist/src/browser/conversationTurns.js +16 -0
  19. package/dist/src/browser/conversationUrlMonitor.js +64 -0
  20. package/dist/src/browser/cookies.js +72 -0
  21. package/dist/src/browser/index.js +103 -94
  22. package/dist/src/browser/projectSourcesRunner.js +3 -2
  23. package/dist/src/browser/reattach.js +27 -11
  24. package/dist/src/browser/reattachHelpers.js +14 -5
  25. package/dist/src/browser/sessionRunner.js +9 -3
  26. package/dist/src/cli/bridge/client.js +4 -1
  27. package/dist/src/cli/bridge/doctor.js +19 -0
  28. package/dist/src/cli/runOptions.js +11 -2
  29. package/dist/src/cli/sessionDisplay.js +6 -1
  30. package/dist/src/cli/sessionRunner.js +28 -10
  31. package/dist/src/config.js +3 -0
  32. package/dist/src/oracle/client.js +2 -0
  33. package/dist/src/oracle/modelResolver.js +85 -0
  34. package/dist/src/oracle/multiModelRunner.js +4 -1
  35. package/dist/src/oracle/oscProgress.js +3 -2
  36. package/dist/src/oracle/run.js +4 -1
  37. package/dist/src/remote/client.js +253 -22
  38. package/dist/src/remote/health.js +27 -0
  39. package/dist/src/remote/server.js +239 -4
  40. package/dist/src/remote/types.js +1 -1
  41. package/dist/src/sessionManager.js +1 -0
  42. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  43. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  44. package/package.json +20 -20
  45. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  46. 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, CONVERSATION_TURN_SELECTOR } from "./constants.js";
4
- import { resolveSessionArtifactsDir, writeBinaryBrowserArtifact } from "./artifacts.js";
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 serializeAnchor = (anchor) => {
214
- const hrefAttr = anchor.getAttribute('href') || '';
215
- const values = [hrefAttr, anchor.href || ''];
216
- for (const attribute of Array.from(anchor.attributes || [])) {
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 = (anchor.textContent || anchor.getAttribute('aria-label') || anchor.title || '').trim();
223
- const filename =
224
- anchor.getAttribute('download') ||
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 || hrefAttr || anchor.href || '',
367
+ url: downloadUrl || sandboxUrl || values.find(hrefKind) || '',
231
368
  downloadUrl,
232
369
  sandboxUrl,
233
370
  filename,
234
371
  label,
235
- mimeType: anchor.getAttribute('type') || '',
372
+ mimeType: node.getAttribute('type') || '',
236
373
  };
237
374
  };
238
375
  const serializeFiles = (root) =>
239
- Array.from(root.querySelectorAll('a[href], a[download]'))
240
- .map(serializeAnchor)
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 = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
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 expectedFileButton = (button) => {
496
- const text = (button.textContent || '').trim().toLowerCase();
497
- return EXPECTED_LABELS.some((label) => {
498
- const downloadLabel = 'download ' + label;
499
- return text === label ||
500
- text.startsWith(label + ' ') ||
501
- text === downloadLabel ||
502
- text.startsWith(downloadLabel + ' ');
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 genericBehaviorButton = (button) => {
506
- const text = (button.textContent || '').trim().toLowerCase();
507
- return ALLOW_GENERIC_DOWNLOAD_LABELS && /^download\\b/.test(text);
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 genericFallbackButton = (button) => {
510
- if (!ALLOW_GENERIC_DOWNLOAD_LABELS) return false;
511
- const text = (button.textContent || '').trim().toLowerCase();
512
- const aria = (button.getAttribute('aria-label') || '').trim().toLowerCase();
513
- const testId = (button.getAttribute('data-testid') || '').trim().toLowerCase();
514
- return text === 'download' || aria === 'download' || testId === 'download-files-turn-action-button';
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 turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
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 buttons = Array.from(messageRoot.querySelectorAll('button'))
527
- .filter((button) => !(MARK_CLICKED && button.getAttribute(CLICKED_ATTRIBUTE) === 'true'));
528
- const behaviorButtons = buttons.filter((button) =>
529
- String(button.className || '').includes('behavior-btn')
530
- );
531
- behaviorButtons.filter(expectedFileButton).forEach((button) => expectedMatches.add(button));
532
- const genericBehavior = behaviorButtons.filter(genericBehaviorButton);
533
- genericBehavior.forEach((button) => {
534
- genericBehaviorMatches.add(button);
535
- genericAllMatches.add(button);
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
- buttons.filter(genericFallbackButton).forEach((button) => {
539
- genericFallbackMatches.add(button);
540
- genericAllMatches.add(button);
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 selectedButtons = Array.from(selected).slice(0, MAX_CLICKS > 0 ? MAX_CLICKS : undefined);
552
- selectedButtons.forEach((button) => {
553
- if (MARK_CLICKED) button.setAttribute(CLICKED_ATTRIBUTE, 'true');
554
- button.click();
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
- return selectedButtons.map((button) => ({
557
- text: (button.textContent || '').trim(),
558
- ariaLabel: button.getAttribute('aria-label') || '',
559
- testId: button.getAttribute('data-testid') || '',
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 clicked = Array.isArray(result?.value) ? result.value : [];
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 clicked;
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: mimeTypeFromFilename(filename),
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 clicked = await clickAssistantDownloadButtons({
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?.("[browser] No assistant download buttons found for button fallback.");
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 button(s).`);
676
- const downloaded = await waitForCompletedDownloadFiles(artifactsDir, before, clicked.length, downloadWaitMs);
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 clicked = await clickAssistantDownloadButtons({
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
- const displayName = describeDownloadableFile(file);
696
- if (clicked.length === 0) {
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 buttons found for button fallback.");
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 button(s).`);
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 new Error("Missing ChatGPT cookies for file download.");
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
- throw new Error(`download redirect missing location: ${response.status}`);
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 new Error(`download redirect rejected: ${redirectedUrl.protocol}`);
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
- throw new Error(`download failed: ${response.status} ${response.statusText}`);
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: response.headers.get("content-type"),
764
- finalUrl: response.url,
1162
+ contentType,
1163
+ finalUrl: response.url || currentUrl.href,
765
1164
  };
766
1165
  }
767
- throw new Error(`download exceeded ${DOWNLOAD_REDIRECT_LIMIT} redirects`);
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
- throw new Error(`download failed: ${value.status ?? "?"} ${value.statusText ?? ""}`.trim());
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: typeof value.contentType === "string" ? value.contentType : null,
809
- finalUrl: typeof value.url === "string" ? value.url : downloadUrl,
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
- errors.push(`${source}: no ChatGPT download URL found`);
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 = params.Runtime && sandboxDownloadUrl && !explicitDownloadUrl
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 ?? downloadUrl,
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
- errors.push(`${file.filename ?? file.downloadUrl ?? file.url}: ${message}`);
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}: ${message}`);
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
- if (textFiles.length > 0) {
896
- params.logger?.(`[browser] Found ${textFiles.length} downloadable file link(s) in captured answer text.`);
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).`);