@steipete/oracle 0.14.1 → 0.15.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.
Files changed (85) hide show
  1. package/dist/bin/oracle-cli.js +16 -6
  2. package/dist/bin/oracle.js +569 -0
  3. package/dist/docs-site/.nojekyll +0 -0
  4. package/dist/docs-site/CNAME +1 -0
  5. package/dist/docs-site/RELEASING.html +410 -0
  6. package/dist/docs-site/agents.html +374 -0
  7. package/dist/docs-site/anthropic.html +368 -0
  8. package/dist/docs-site/bridge.html +416 -0
  9. package/dist/docs-site/browser-mode.html +594 -0
  10. package/dist/docs-site/chromium-forks.html +347 -0
  11. package/dist/docs-site/cli-reference.html +346 -0
  12. package/dist/docs-site/configuration.html +462 -0
  13. package/dist/docs-site/favicon.svg +14 -0
  14. package/dist/docs-site/followup.html +375 -0
  15. package/dist/docs-site/gemini.html +383 -0
  16. package/dist/docs-site/grok.html +325 -0
  17. package/dist/docs-site/index.html +360 -0
  18. package/dist/docs-site/install.html +335 -0
  19. package/dist/docs-site/linux.html +321 -0
  20. package/dist/docs-site/llms.txt +43 -0
  21. package/dist/docs-site/manual-tests.html +596 -0
  22. package/dist/docs-site/mcp.html +391 -0
  23. package/dist/docs-site/multimodel.html +364 -0
  24. package/dist/docs-site/mythical-pro-agents.html +360 -0
  25. package/dist/docs-site/notifier.html +338 -0
  26. package/dist/docs-site/openai-endpoints.html +399 -0
  27. package/dist/docs-site/openrouter.html +344 -0
  28. package/dist/docs-site/quickstart.html +369 -0
  29. package/dist/docs-site/refactor/ux.html +532 -0
  30. package/dist/docs-site/sessions.html +388 -0
  31. package/dist/docs-site/social-card.png +0 -0
  32. package/dist/docs-site/social-card.svg +79 -0
  33. package/dist/docs-site/spec.html +363 -0
  34. package/dist/docs-site/testing.html +320 -0
  35. package/dist/docs-site/tui-debug.html +326 -0
  36. package/dist/docs-site/windows-work.html +323 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/src/browser/actions/assistantResponse.js +40 -31
  39. package/dist/src/browser/actions/attachments.js +28 -1
  40. package/dist/src/browser/actions/deepResearch.js +344 -128
  41. package/dist/src/browser/actions/modelSelection.js +75 -9
  42. package/dist/src/browser/actions/promptComposer.js +71 -14
  43. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  44. package/dist/src/browser/actions/thinkingTime.js +65 -20
  45. package/dist/src/browser/artifacts.js +193 -14
  46. package/dist/src/browser/chatgptFiles.js +674 -91
  47. package/dist/src/browser/chromeCookies.js +312 -0
  48. package/dist/src/browser/chromeLifecycle.js +35 -4
  49. package/dist/src/browser/constants.js +5 -0
  50. package/dist/src/browser/deepResearchResult.js +23 -0
  51. package/dist/src/browser/index.js +112 -41
  52. package/dist/src/browser/keytarShim.js +56 -0
  53. package/dist/src/browser/profileCopy.js +93 -0
  54. package/dist/src/browser/sessionRunner.js +9 -3
  55. package/dist/src/browser/windowsCookies.js +219 -0
  56. package/dist/src/cli/bridge/client.js +4 -1
  57. package/dist/src/cli/bridge/doctor.js +19 -0
  58. package/dist/src/cli/browserConfig.js +17 -1
  59. package/dist/src/cli/runOptions.js +11 -2
  60. package/dist/src/cli/sessionDisplay.js +6 -1
  61. package/dist/src/cli/sessionRunner.js +41 -17
  62. package/dist/src/config.js +3 -0
  63. package/dist/src/oracle/client.js +2 -0
  64. package/dist/src/oracle/modelResolver.js +85 -0
  65. package/dist/src/oracle/multiModelRunner.js +4 -1
  66. package/dist/src/oracle/run.js +4 -1
  67. package/dist/src/remote/client.js +253 -22
  68. package/dist/src/remote/health.js +27 -0
  69. package/dist/src/remote/server.js +239 -4
  70. package/dist/src/remote/types.js +1 -1
  71. package/dist/src/sessionManager.js +1 -0
  72. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  73. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  74. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  75. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  76. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  77. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  78. package/package.json +45 -43
  79. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  81. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  82. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  83. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  84. package/vendor/oracle-notifier/README.md +26 -0
  85. package/vendor/oracle-notifier/build-notifier.sh +0 -0
@@ -1,10 +1,133 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "./constants.js";
4
- import { resolveSessionArtifactsDir, writeBinaryBrowserArtifact } from "./artifacts.js";
4
+ import { computeFileSha256, resolveSessionArtifactsDir, sanitizeArtifactFilename, validateArtifactFile, writeBinaryBrowserArtifact, } from "./artifacts.js";
5
5
  const CHATGPT_DOWNLOAD_BASE_URL = "https://chatgpt.com/";
6
6
  const DOWNLOAD_BUTTON_WAIT_MS = 15_000;
7
7
  const DOWNLOAD_REDIRECT_LIMIT = 5;
8
+ const DIAGNOSTIC_BODY_SNIPPET_BYTES = 180;
9
+ class ChatGptDownloadError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "ChatGptDownloadError";
13
+ }
14
+ }
15
+ function safeDiagnosticText(value, maxLength = DIAGNOSTIC_BODY_SNIPPET_BYTES) {
16
+ const compact = value
17
+ .replace(/https?:\/\/[^\s"'<>]+/gi, (match) => {
18
+ try {
19
+ const url = new URL(match);
20
+ return `${url.origin}${url.pathname}${url.search ? "?[redacted]" : ""}`;
21
+ }
22
+ catch {
23
+ return "[redacted-url]";
24
+ }
25
+ })
26
+ .replace(/("?(?:access_token|authorization|bearer|cookie|id_token|key|secret|session|signature|sig|token)"?\s*[:=]\s*)"?[^,"'\s}]+"?/gi, "$1[redacted]")
27
+ .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]")
28
+ .replace(/[\r\n\t]+/g, " ")
29
+ .replace(/\s+/g, " ")
30
+ .trim();
31
+ return compact.length > maxLength ? `${compact.slice(0, maxLength)}…` : compact;
32
+ }
33
+ function classifyResponseBodyKind(contentType) {
34
+ const value = String(contentType ?? "").toLowerCase();
35
+ if (value.includes("json"))
36
+ return "json";
37
+ if (value.includes("html"))
38
+ return "html";
39
+ if (value.startsWith("text/") || value.includes("xml"))
40
+ return "text";
41
+ return "binary";
42
+ }
43
+ function decodeDiagnosticBodySnippet(body, contentType) {
44
+ const bodyKind = classifyResponseBodyKind(contentType);
45
+ if (body.length === 0 || bodyKind === "binary") {
46
+ return { bodyKind };
47
+ }
48
+ let text = body.subarray(0, DIAGNOSTIC_BODY_SNIPPET_BYTES * 4).toString("utf8");
49
+ if (bodyKind === "html") {
50
+ text = text.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ");
51
+ }
52
+ return { bodyKind, bodySnippet: safeDiagnosticText(text) };
53
+ }
54
+ async function readDiagnosticResponseBody(response, contentType) {
55
+ const reader = response.body?.getReader();
56
+ if (!reader) {
57
+ return { bodyKind: classifyResponseBodyKind(contentType) };
58
+ }
59
+ const limit = DIAGNOSTIC_BODY_SNIPPET_BYTES * 4;
60
+ const chunks = [];
61
+ let total = 0;
62
+ try {
63
+ while (total < limit) {
64
+ const { done, value } = await reader.read();
65
+ if (done)
66
+ break;
67
+ const remaining = limit - total;
68
+ const chunk = Buffer.from(value.subarray(0, remaining));
69
+ chunks.push(chunk);
70
+ total += chunk.length;
71
+ if (total >= limit) {
72
+ await reader.cancel().catch(() => undefined);
73
+ break;
74
+ }
75
+ }
76
+ return decodeDiagnosticBodySnippet(Buffer.concat(chunks), contentType);
77
+ }
78
+ catch {
79
+ return { bodyKind: classifyResponseBodyKind(contentType) };
80
+ }
81
+ finally {
82
+ reader.releaseLock();
83
+ }
84
+ }
85
+ function classifyUrlKind(value) {
86
+ const raw = String(value ?? "");
87
+ if (!raw)
88
+ return "unknown";
89
+ if (raw.startsWith("sandbox:"))
90
+ return "sandbox";
91
+ if (raw === "browser-download")
92
+ return "browser-download";
93
+ try {
94
+ const url = new URL(raw, CHATGPT_DOWNLOAD_BASE_URL);
95
+ if (!isAllowedChatGptHost(url.hostname))
96
+ return "external-https";
97
+ const pathName = url.pathname.toLowerCase();
98
+ if (pathName === "/backend-api/sandbox/download")
99
+ return "chatgpt-sandbox-download";
100
+ if (/^\/backend-api\/files\/[^/]+\/(?:download|content)\/?$/.test(pathName)) {
101
+ return "chatgpt-file-endpoint";
102
+ }
103
+ if (pathName === "/backend-api/estuary/content")
104
+ return "chatgpt-estuary-content";
105
+ return "chatgpt-other";
106
+ }
107
+ catch {
108
+ return "unknown";
109
+ }
110
+ }
111
+ function formatDownloadFailure(params) {
112
+ const parts = [`download failed via ${params.strategy}`];
113
+ if (params.status !== undefined)
114
+ parts.push(`status=${params.status}`);
115
+ if (params.statusText)
116
+ parts.push(`statusText=${safeDiagnosticText(params.statusText, 80)}`);
117
+ if (params.contentType)
118
+ parts.push(`contentType=${safeDiagnosticText(params.contentType, 80)}`);
119
+ parts.push(`finalUrlKind=${classifyUrlKind(params.finalUrl)}`);
120
+ if (params.bodyKind)
121
+ parts.push(`bodyKind=${params.bodyKind}`);
122
+ if (params.bodySnippet)
123
+ parts.push(`bodySnippet=${JSON.stringify(params.bodySnippet)}`);
124
+ if (params.message)
125
+ parts.push(`reason=${safeDiagnosticText(params.message, 140)}`);
126
+ return new ChatGptDownloadError(parts.join(" "));
127
+ }
128
+ function sanitizeCandidateFilename(value) {
129
+ return sanitizeArtifactFilename(String(value ?? ""), "artifact.bin");
130
+ }
8
131
  function isAllowedChatGptHost(hostname) {
9
132
  const value = hostname.toLowerCase();
10
133
  return value === "chatgpt.com" || value === "chat.openai.com";
@@ -210,34 +333,57 @@ function buildAssistantDownloadableFilesExpression(minTurnIndex) {
210
333
  return part;
211
334
  }
212
335
  };
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 || [])) {
336
+ const hrefKind = (value) => {
337
+ if (!value) return '';
338
+ if (isSandboxUrl(value)) return 'sandbox';
339
+ if (isChatGptDownloadUrl(value)) return 'chatgpt-file-endpoint';
340
+ return '';
341
+ };
342
+ const collectValues = (node) => {
343
+ const values = [];
344
+ if (String(node.tagName || '').toLowerCase() === 'a') {
345
+ values.push(node.getAttribute('href') || '', node.href || '', node.getAttribute('download') || '');
346
+ }
347
+ for (const attribute of Array.from(node.attributes || [])) {
217
348
  values.push(String(attribute.value || ''));
218
349
  }
350
+ for (const anchor of Array.from(node.querySelectorAll?.('a[href], a[download]') || [])) {
351
+ values.push(anchor.getAttribute('href') || '', anchor.href || '', anchor.getAttribute('download') || '');
352
+ for (const attribute of Array.from(anchor.attributes || [])) {
353
+ values.push(String(attribute.value || ''));
354
+ }
355
+ }
356
+ return values;
357
+ };
358
+ const serializeCandidate = (node) => {
359
+ if (!(node instanceof HTMLElement)) return null;
360
+ const values = collectValues(node);
219
361
  const downloadUrl = values.find(isChatGptDownloadUrl) || '';
220
362
  const sandboxUrl = values.find(isSandboxUrl) || '';
221
363
  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
- '';
364
+ const label = (node.textContent || node.getAttribute('aria-label') || node.getAttribute('title') || '').trim();
365
+ const downloadAttr = String(node.tagName || '').toLowerCase() === 'a' ? node.getAttribute('download') || '' : '';
366
+ const filename = downloadAttr || basename(sandboxUrl) || basename(downloadUrl) || label || '';
229
367
  return {
230
- url: downloadUrl || sandboxUrl || hrefAttr || anchor.href || '',
368
+ url: downloadUrl || sandboxUrl || values.find(hrefKind) || '',
231
369
  downloadUrl,
232
370
  sandboxUrl,
233
371
  filename,
234
372
  label,
235
- mimeType: anchor.getAttribute('type') || '',
373
+ mimeType: node.getAttribute('type') || '',
236
374
  };
237
375
  };
238
376
  const serializeFiles = (root) =>
239
- Array.from(root.querySelectorAll('a[href], a[download]'))
240
- .map(serializeAnchor)
377
+ Array.from(root.querySelectorAll([
378
+ 'a[href]',
379
+ 'a[download]',
380
+ 'button',
381
+ '[role="button"]',
382
+ '[data-testid]',
383
+ '[aria-label]',
384
+ '[title]',
385
+ ].join(',')))
386
+ .map(serializeCandidate)
241
387
  .filter(Boolean);
242
388
  const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
243
389
  const files = [];
@@ -296,6 +442,23 @@ function filenameFromContentDisposition(value) {
296
442
  }
297
443
  return /filename="?([^";]+)"?/i.exec(header)?.[1]?.trim();
298
444
  }
445
+ function classifyDownloadableFileSourceKind(file, downloadUrl) {
446
+ if (file.sandboxUrl || file.url.startsWith("sandbox:")) {
447
+ return "sandbox";
448
+ }
449
+ if ((downloadUrl ?? file.downloadUrl ?? file.url) === "browser-download") {
450
+ return "browser-download";
451
+ }
452
+ return "chatgpt-file-endpoint";
453
+ }
454
+ function describeDownloadableCandidate(file, downloadUrl) {
455
+ const filename = sanitizeCandidateFilename(file.filename ??
456
+ file.label ??
457
+ filenameFromUrl(file.sandboxUrl) ??
458
+ filenameFromUrl(file.downloadUrl) ??
459
+ filenameFromUrl(file.url));
460
+ return `filename=${filename} source=${classifyDownloadableFileSourceKind(file, downloadUrl)} urlKind=${classifyUrlKind(downloadUrl ?? file.downloadUrl ?? file.sandboxUrl ?? file.url)}`;
461
+ }
299
462
  function filenameFromUrl(value) {
300
463
  const raw = String(value ?? "")
301
464
  .split(/[?#]/)[0]
@@ -432,6 +595,7 @@ async function configureBrowserDownloadPath(params) {
432
595
  await params.Client.send("Browser.setDownloadBehavior", {
433
596
  behavior: "allow",
434
597
  downloadPath: params.downloadPath,
598
+ eventsEnabled: true,
435
599
  });
436
600
  return true;
437
601
  }
@@ -458,7 +622,7 @@ async function configureBrowserDownloadPath(params) {
458
622
  }
459
623
  return false;
460
624
  }
461
- function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabels = [], allowGenericDownloadLabels = true) {
625
+ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabels = [], allowGenericDownloadLabels = true, options = {}) {
462
626
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
463
627
  ? Math.floor(minTurnIndex)
464
628
  : -1;
@@ -466,12 +630,24 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
466
630
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
467
631
  const expectedLabelsLiteral = JSON.stringify(expectedLabels);
468
632
  const allowGenericDownloadLabelsLiteral = JSON.stringify(allowGenericDownloadLabels);
633
+ const markClickedLiteral = JSON.stringify(options.markClicked === true);
634
+ const returnDiagnosticsLiteral = JSON.stringify(options.returnDiagnostics === true);
635
+ const maxClicksLiteral = typeof options.maxClicks === "number" &&
636
+ Number.isFinite(options.maxClicks) &&
637
+ options.maxClicks > 0
638
+ ? Math.floor(options.maxClicks)
639
+ : 0;
469
640
  return `(() => {
470
641
  const MIN_TURN_INDEX = ${minTurnLiteral};
471
642
  const CONVERSATION_SELECTOR = ${conversationLiteral};
472
643
  const ASSISTANT_SELECTOR = ${assistantLiteral};
473
644
  const EXPECTED_LABELS = ${expectedLabelsLiteral};
474
645
  const ALLOW_GENERIC_DOWNLOAD_LABELS = ${allowGenericDownloadLabelsLiteral};
646
+ const MARK_CLICKED = ${markClickedLiteral};
647
+ const RETURN_DIAGNOSTICS = ${returnDiagnosticsLiteral};
648
+ const MAX_CLICKS = ${maxClicksLiteral};
649
+ const HAS_EXPECTED_LABELS = EXPECTED_LABELS.length > 0;
650
+ const CLICKED_ATTRIBUTE = 'data-oracle-download-clicked';
475
651
  const isAssistantTurn = (node) => {
476
652
  if (!(node instanceof HTMLElement)) return false;
477
653
  const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
@@ -482,40 +658,333 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
482
658
  if (testId.includes('assistant')) return true;
483
659
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
484
660
  };
661
+ const basename = (value) => {
662
+ const raw = String(value || '').split(/[?#]/)[0].replace(/\\/+$/g, '');
663
+ const part = raw.slice(raw.lastIndexOf('/') + 1);
664
+ try {
665
+ return decodeURIComponent(part);
666
+ } catch {
667
+ return part;
668
+ }
669
+ };
670
+ const isSafeSandboxUrl = (value) => {
671
+ const raw = String(value || '').trim();
672
+ if (!raw.startsWith('sandbox:/mnt/data/')) return false;
673
+ try {
674
+ const pathName = decodeURI(new URL(raw).pathname);
675
+ return pathName.startsWith('/mnt/data/') &&
676
+ !pathName.includes('\\\\') &&
677
+ !pathName.includes('\\0') &&
678
+ !pathName.split('/').includes('..');
679
+ } catch {
680
+ return false;
681
+ }
682
+ };
683
+ const isChatGptDownloadUrl = (value) => {
684
+ const raw = String(value || '').trim();
685
+ if (!raw || raw.startsWith('sandbox:') || raw.startsWith('blob:')) return false;
686
+ const isSafeSandboxPath = (pathName) => {
687
+ const normalized = String(pathName || '');
688
+ return normalized.startsWith('/mnt/data/') &&
689
+ !normalized.includes('\\\\') &&
690
+ !normalized.includes('\\0') &&
691
+ !normalized.split('/').includes('..');
692
+ };
693
+ try {
694
+ const url = new URL(raw, 'https://chatgpt.com');
695
+ const host = url.hostname.toLowerCase();
696
+ const allowedHost = host === 'chatgpt.com' || host === 'chat.openai.com';
697
+ const pathName = url.pathname.toLowerCase();
698
+ const isKnownFileDownload =
699
+ (pathName === '/backend-api/sandbox/download' && isSafeSandboxPath(url.searchParams.get('path') || '')) ||
700
+ /^\\/backend-api\\/files\\/[^/]+\\/(?:download|content)\\/?$/.test(pathName) ||
701
+ (pathName === '/backend-api/estuary/content' && String(url.searchParams.get('id') || '').startsWith('file_'));
702
+ return allowedHost && url.protocol === 'https:' && !url.port && isKnownFileDownload;
703
+ } catch {
704
+ return false;
705
+ }
706
+ };
707
+ const safeLower = (value) => String(value || '').trim().toLowerCase();
708
+ const controlInfo = (control) => {
709
+ const href = String(control.tagName || '').toLowerCase() === 'a' ? (control.getAttribute('href') || control.href || '') : '';
710
+ const text = (control.textContent || '').trim();
711
+ const ariaLabel = control.getAttribute('aria-label') || '';
712
+ const title = control.getAttribute('title') || '';
713
+ const testId = control.getAttribute('data-testid') || '';
714
+ const role = control.getAttribute('role') || '';
715
+ const download = String(control.tagName || '').toLowerCase() === 'a' ? (control.getAttribute('download') || '') : '';
716
+ const className = String(control.className || '');
717
+ const attributeValues = Array.from(control.attributes || []).map((attribute) => String(attribute.value || ''));
718
+ return {
719
+ control,
720
+ tagName: control.tagName || '',
721
+ text,
722
+ ariaLabel,
723
+ title,
724
+ testId,
725
+ role,
726
+ href,
727
+ download,
728
+ className,
729
+ attributeValues,
730
+ haystack: [text, ariaLabel, title, testId, role, href, download, basename(href), ...attributeValues]
731
+ .map(safeLower)
732
+ .filter(Boolean),
733
+ };
734
+ };
735
+ const safeClickableControl = (info) =>
736
+ safeLower(info.tagName) !== 'a' || isSafeSandboxUrl(info.href) || isChatGptDownloadUrl(info.href);
737
+ const expectedFileControl = (info) => {
738
+ return EXPECTED_LABELS.some((rawLabel) => {
739
+ const label = safeLower(rawLabel);
740
+ if (!label) return false;
741
+ const downloadLabel = 'download ' + label;
742
+ return info.haystack.some((value) =>
743
+ value === label ||
744
+ value.startsWith(label + ' ') ||
745
+ value === downloadLabel ||
746
+ value.startsWith(downloadLabel + ' ') ||
747
+ value.endsWith('/' + label) ||
748
+ value.includes('/' + label + '?') ||
749
+ value.includes('/' + label + '#') ||
750
+ value.includes('path=%2fmnt%2fdata%2f' + encodeURIComponent(label).toLowerCase()) ||
751
+ value.includes('sandbox:/mnt/data/' + label)
752
+ );
753
+ });
754
+ };
755
+ const hasDownloadIntent = (info) =>
756
+ info.haystack.some((value) =>
757
+ value === 'download' ||
758
+ /^download\\b/.test(value) ||
759
+ value.includes('download') ||
760
+ value === 'download-files-turn-action-button'
761
+ );
762
+ const genericBehaviorButton = (info) =>
763
+ ALLOW_GENERIC_DOWNLOAD_LABELS && info.className.includes('behavior-btn') && hasDownloadIntent(info);
764
+ const genericFallbackButton = (info) => ALLOW_GENERIC_DOWNLOAD_LABELS && hasDownloadIntent(info);
485
765
  const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
486
- const selected = new Set();
766
+ const expectedMatches = new Set();
767
+ const genericBehaviorMatches = new Set();
768
+ const genericFallbackMatches = new Set();
769
+ const genericAllMatches = new Set();
770
+ let inspectedCount = 0;
487
771
  for (let index = turns.length - 1; index >= 0; index -= 1) {
488
772
  const turn = turns[index];
489
773
  if (!isAssistantTurn(turn)) continue;
490
774
  if (MIN_TURN_INDEX >= 0 && index < MIN_TURN_INDEX) continue;
491
775
  const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) || turn;
492
- const buttons = Array.from(messageRoot.querySelectorAll('button'));
493
- const primary = buttons.filter((button) => {
494
- const text = (button.textContent || '').trim().toLowerCase();
495
- const expectedFile = EXPECTED_LABELS.some(
496
- (label) => text === label || text.startsWith(label + ' ')
497
- );
498
- return String(button.className || '').includes('behavior-btn') &&
499
- (expectedFile || (ALLOW_GENERIC_DOWNLOAD_LABELS && /^download\\b/.test(text)));
500
- });
501
- const fallback = primary.length > 0 ? [] : buttons.filter((button) => {
502
- if (!ALLOW_GENERIC_DOWNLOAD_LABELS) return false;
503
- const text = (button.textContent || '').trim().toLowerCase();
504
- const aria = (button.getAttribute('aria-label') || '').trim().toLowerCase();
505
- const testId = (button.getAttribute('data-testid') || '').trim().toLowerCase();
506
- return text === 'download' || aria === 'download' || testId === 'download-files-turn-action-button';
776
+ const controls = Array.from(messageRoot.querySelectorAll([
777
+ 'button',
778
+ 'a[href]',
779
+ 'a[download]',
780
+ '[role="button"]',
781
+ ].join(',')))
782
+ .filter((control) => control instanceof HTMLElement)
783
+ .filter((control) => !(MARK_CLICKED && control.getAttribute(CLICKED_ATTRIBUTE) === 'true'))
784
+ .map(controlInfo)
785
+ .filter(safeClickableControl);
786
+ inspectedCount += controls.length;
787
+ controls.filter(expectedFileControl).forEach((info) => expectedMatches.add(info));
788
+ const genericBehavior = controls.filter(genericBehaviorButton);
789
+ genericBehavior.forEach((info) => {
790
+ genericBehaviorMatches.add(info);
791
+ genericAllMatches.add(info);
507
792
  });
508
- [...primary, ...fallback].forEach((button) => selected.add(button));
509
- }
510
- const selectedButtons = Array.from(selected);
511
- selectedButtons.forEach((button) => button.click());
512
- return selectedButtons.map((button) => ({
513
- text: (button.textContent || '').trim(),
514
- ariaLabel: button.getAttribute('aria-label') || '',
515
- testId: button.getAttribute('data-testid') || '',
793
+ if (genericBehavior.length === 0) {
794
+ controls.filter(genericFallbackButton).forEach((info) => {
795
+ genericFallbackMatches.add(info);
796
+ genericAllMatches.add(info);
797
+ });
798
+ }
799
+ }
800
+ const selectedCategory = expectedMatches.size > 0
801
+ ? 'expected-label'
802
+ : HAS_EXPECTED_LABELS
803
+ ? genericBehaviorMatches.size > 0
804
+ ? 'generic-behavior'
805
+ : 'generic-fallback'
806
+ : 'generic-all';
807
+ const selected = expectedMatches.size > 0
808
+ ? expectedMatches
809
+ : HAS_EXPECTED_LABELS
810
+ ? genericBehaviorMatches.size > 0
811
+ ? genericBehaviorMatches
812
+ : genericFallbackMatches
813
+ : genericAllMatches;
814
+ const selectedControls = Array.from(selected).slice(0, MAX_CLICKS > 0 ? MAX_CLICKS : undefined);
815
+ selectedControls.forEach((info) => {
816
+ if (MARK_CLICKED) info.control.setAttribute(CLICKED_ATTRIBUTE, 'true');
817
+ info.control.click();
818
+ });
819
+ const clickedDiagnostics = selectedControls.map((info) => ({
820
+ text: info.text,
821
+ ariaLabel: info.ariaLabel,
822
+ title: info.title,
823
+ testId: info.testId,
824
+ tagName: info.tagName,
825
+ role: info.role,
826
+ hrefKind: info.href ? (info.href.startsWith('sandbox:') ? 'sandbox' : 'link') : '',
827
+ category: selectedCategory,
516
828
  }));
829
+ const clicked = clickedDiagnostics.map(({ text, ariaLabel, testId }) => ({ text, ariaLabel, testId }));
830
+ return RETURN_DIAGNOSTICS ? { inspectedCount, selectedCategory, clicked: clickedDiagnostics } : clicked;
517
831
  })()`;
518
832
  }
833
+ function describeDownloadableFile(file) {
834
+ return (file.filename ??
835
+ file.label ??
836
+ filenameFromUrl(file.sandboxUrl) ??
837
+ filenameFromUrl(file.downloadUrl) ??
838
+ filenameFromUrl(file.url) ??
839
+ file.sandboxUrl ??
840
+ file.downloadUrl ??
841
+ file.url);
842
+ }
843
+ function expectedDownloadedFilename(file) {
844
+ const filename = file.filename ??
845
+ filenameFromUrl(file.sandboxUrl) ??
846
+ filenameFromUrl(file.downloadUrl) ??
847
+ filenameFromUrl(file.url);
848
+ const basename = path.basename(String(filename ?? "").trim());
849
+ return basename && basename !== "." ? basename : undefined;
850
+ }
851
+ async function moveDownloadedFileToExpectedName(filePath, file) {
852
+ const filename = expectedDownloadedFilename(file);
853
+ if (!filename) {
854
+ return filePath;
855
+ }
856
+ const targetPath = path.join(path.dirname(filePath), filename);
857
+ if (path.resolve(targetPath) === path.resolve(filePath)) {
858
+ return filePath;
859
+ }
860
+ const expected = path.parse(filename);
861
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
862
+ const duplicatePattern = new RegExp(`^${escapeRegExp(expected.name)} ?\\(\\d+\\)${escapeRegExp(expected.ext)}$`);
863
+ if (!duplicatePattern.test(path.basename(filePath))) {
864
+ return filePath;
865
+ }
866
+ const targetExists = await fs
867
+ .stat(targetPath)
868
+ .then((stat) => stat.isFile())
869
+ .catch(() => false);
870
+ if (targetExists) {
871
+ return filePath;
872
+ }
873
+ await fs.rename(filePath, targetPath);
874
+ return targetPath;
875
+ }
876
+ async function clickAssistantDownloadButtons(params) {
877
+ const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, params.expectedLabels ?? [], params.allowGenericDownloadLabels, { markClicked: params.markClicked, maxClicks: params.maxClicks, returnDiagnostics: true });
878
+ const deadline = Date.now() + (params.timeoutMs ?? DOWNLOAD_BUTTON_WAIT_MS);
879
+ let lastInspectedCount = 0;
880
+ let lastSelectedCategory;
881
+ while (Date.now() < deadline) {
882
+ const { result } = await params.Runtime.evaluate({
883
+ expression,
884
+ returnByValue: true,
885
+ });
886
+ const value = result?.value;
887
+ const diagnosticValue = value && typeof value === "object" && !Array.isArray(value)
888
+ ? value
889
+ : undefined;
890
+ const clicked = Array.isArray(value)
891
+ ? value
892
+ : Array.isArray(diagnosticValue?.clicked)
893
+ ? diagnosticValue.clicked
894
+ : [];
895
+ if (diagnosticValue) {
896
+ lastInspectedCount = Number(diagnosticValue.inspectedCount ?? lastInspectedCount);
897
+ lastSelectedCategory = diagnosticValue.selectedCategory ?? lastSelectedCategory;
898
+ }
899
+ if (clicked.length > 0) {
900
+ return {
901
+ clicked,
902
+ inspectedCount: lastInspectedCount,
903
+ selectedCategory: lastSelectedCategory,
904
+ };
905
+ }
906
+ await new Promise((resolve) => setTimeout(resolve, 250));
907
+ }
908
+ return {
909
+ clicked: [],
910
+ inspectedCount: lastInspectedCount,
911
+ selectedCategory: lastSelectedCategory,
912
+ };
913
+ }
914
+ function summarizeClickedControls(clicked) {
915
+ return clicked
916
+ .slice(0, 5)
917
+ .map((control) => {
918
+ const tag = control.tagName ? `tag=${safeDiagnosticText(control.tagName, 24)}` : "tag=?";
919
+ const category = control.category
920
+ ? `category=${safeDiagnosticText(control.category, 40)}`
921
+ : "category=?";
922
+ const testId = control.testId ? ` testId=${safeDiagnosticText(control.testId, 60)}` : "";
923
+ const aria = control.ariaLabel ? ` aria=${safeDiagnosticText(control.ariaLabel, 60)}` : "";
924
+ const text = control.text ? ` text=${safeDiagnosticText(control.text, 60)}` : "";
925
+ const hrefKind = control.hrefKind
926
+ ? ` hrefKind=${safeDiagnosticText(control.hrefKind, 40)}`
927
+ : "";
928
+ return `${tag} ${category}${testId}${aria}${text}${hrefKind}`;
929
+ })
930
+ .join("; ");
931
+ }
932
+ async function clickGeneratedDownloadUrl(params) {
933
+ const filename = expectedDownloadedFilename(params.file) ?? "download";
934
+ const expression = `(() => {
935
+ const anchor = document.createElement('a');
936
+ anchor.href = ${JSON.stringify(params.downloadUrl)};
937
+ anchor.download = ${JSON.stringify(filename)};
938
+ anchor.rel = 'noopener';
939
+ anchor.style.display = 'none';
940
+ anchor.setAttribute('data-oracle-generated-download-anchor', 'true');
941
+ document.body.appendChild(anchor);
942
+ anchor.click();
943
+ setTimeout(() => anchor.remove(), 0);
944
+ return {
945
+ inspectedCount: 1,
946
+ selectedCategory: 'generated-download-url',
947
+ clicked: [{
948
+ text: '',
949
+ ariaLabel: '',
950
+ title: '',
951
+ testId: 'oracle-generated-download-anchor',
952
+ tagName: 'A',
953
+ role: '',
954
+ hrefKind: ${JSON.stringify(classifyUrlKind(params.downloadUrl))},
955
+ category: 'generated-download-url',
956
+ }],
957
+ };
958
+ })()`;
959
+ const { result } = await params.Runtime.evaluate({ expression, returnByValue: true });
960
+ const value = result?.value;
961
+ return {
962
+ clicked: Array.isArray(value?.clicked) ? value.clicked : [],
963
+ inspectedCount: Number(value?.inspectedCount ?? 1),
964
+ selectedCategory: value?.selectedCategory ?? "generated-download-url",
965
+ };
966
+ }
967
+ async function savedBrowserFileFromPath(filePath) {
968
+ const filename = path.basename(filePath);
969
+ const stat = await fs.stat(filePath);
970
+ const mimeType = mimeTypeFromFilename(filename);
971
+ const validation = await validateArtifactFile({ path: filePath, filename, mimeType });
972
+ return {
973
+ kind: "file",
974
+ path: filePath,
975
+ label: filename,
976
+ mimeType,
977
+ sizeBytes: stat.size,
978
+ sourceUrl: "browser-download",
979
+ sha256: await computeFileSha256(filePath),
980
+ validation,
981
+ transfer: { status: "not-needed" },
982
+ origin: { mode: "local" },
983
+ url: "browser-download",
984
+ finalUrl: "browser-download",
985
+ filename,
986
+ };
987
+ }
519
988
  export async function saveAssistantDownloadButtonArtifacts(params) {
520
989
  if ((!params.sessionId && !params.downloadPath) ||
521
990
  (!params.Client && !params.Browser && !params.Page)) {
@@ -539,41 +1008,94 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
539
1008
  params.logger?.("[browser] Browser download path could not be configured; skipping button fallback.");
540
1009
  return [];
541
1010
  }
542
- let clicked = [];
543
- const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, resolveDownloadButtonLabels(params.files ?? []), params.allowGenericDownloadLabels);
544
- const deadline = Date.now() + DOWNLOAD_BUTTON_WAIT_MS;
545
- while (Date.now() < deadline) {
546
- const { result } = await params.Runtime.evaluate({
547
- expression,
548
- returnByValue: true,
1011
+ const buttonWaitMs = params.buttonWaitMs ?? DOWNLOAD_BUTTON_WAIT_MS;
1012
+ const downloadWaitMs = params.downloadWaitMs ?? DOWNLOAD_BUTTON_WAIT_MS;
1013
+ const expectedFiles = params.files ?? [];
1014
+ if (expectedFiles.length === 0) {
1015
+ const clickedResult = await clickAssistantDownloadButtons({
1016
+ Runtime: params.Runtime,
1017
+ minTurnIndex: params.minTurnIndex,
1018
+ expectedLabels: [],
1019
+ allowGenericDownloadLabels: params.allowGenericDownloadLabels,
1020
+ timeoutMs: buttonWaitMs,
1021
+ logger: params.logger,
549
1022
  });
550
- clicked = Array.isArray(result?.value) ? result.value : [];
551
- if (clicked.length > 0) {
1023
+ if (clickedResult.clicked.length === 0) {
1024
+ params.logger?.(`[browser] No assistant download controls found for button fallback (inspected ${clickedResult.inspectedCount} control(s); category=${clickedResult.selectedCategory ?? "none"}).`);
1025
+ return [];
1026
+ }
1027
+ params.logger?.(`[browser] Clicked ${clickedResult.clicked.length} assistant download control(s) ` +
1028
+ `(inspected ${clickedResult.inspectedCount}; category=${clickedResult.selectedCategory ?? "unknown"}; ` +
1029
+ `details=${summarizeClickedControls(clickedResult.clicked)}).`);
1030
+ const downloaded = await waitForCompletedDownloadFiles(artifactsDir, before, clickedResult.clicked.length, downloadWaitMs);
1031
+ return Promise.all(downloaded.map(savedBrowserFileFromPath));
1032
+ }
1033
+ let clickedCount = 0;
1034
+ let knownEntries = before;
1035
+ const downloadedPaths = [];
1036
+ const missingFiles = [];
1037
+ const unattemptedFiles = [];
1038
+ for (const [fileIndex, file] of expectedFiles.entries()) {
1039
+ const expectedLabels = resolveDownloadButtonLabels([file]);
1040
+ const displayName = describeDownloadableFile(file);
1041
+ let clickResult = await clickAssistantDownloadButtons({
1042
+ Runtime: params.Runtime,
1043
+ minTurnIndex: params.minTurnIndex,
1044
+ expectedLabels,
1045
+ allowGenericDownloadLabels: params.allowGenericDownloadLabels === true,
1046
+ markClicked: true,
1047
+ maxClicks: 1,
1048
+ timeoutMs: buttonWaitMs,
1049
+ logger: params.logger,
1050
+ });
1051
+ params.logger?.(`[browser] Button fallback inspected ${clickResult.inspectedCount} control(s) for ${sanitizeCandidateFilename(displayName)}; category=${clickResult.selectedCategory ?? "none"}; clicked=${clickResult.clicked.length}.`);
1052
+ if (clickResult.clicked.length > 0) {
1053
+ params.logger?.(`[browser] Button fallback clicked control detail(s): ${summarizeClickedControls(clickResult.clicked)}`);
1054
+ }
1055
+ else {
1056
+ const explicitDownloadUrl = normalizeChatGptDownloadUrl(file.downloadUrl ?? file.url);
1057
+ const sandboxDownloadUrl = downloadUrlFromSandboxUrl(file.sandboxUrl ?? file.url);
1058
+ const downloadUrl = explicitDownloadUrl ?? sandboxDownloadUrl;
1059
+ if (downloadUrl) {
1060
+ params.logger?.(`[browser] No matching assistant control for ${sanitizeCandidateFilename(displayName)}; trying scoped generated browser download for urlKind=${classifyUrlKind(downloadUrl)}.`);
1061
+ clickResult = await clickGeneratedDownloadUrl({
1062
+ Runtime: params.Runtime,
1063
+ file,
1064
+ downloadUrl,
1065
+ logger: params.logger,
1066
+ });
1067
+ }
1068
+ }
1069
+ if (clickResult.clicked.length === 0) {
1070
+ missingFiles.push(displayName);
1071
+ knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
1072
+ continue;
1073
+ }
1074
+ clickedCount += clickResult.clicked.length;
1075
+ const downloaded = await waitForCompletedDownloadFiles(artifactsDir, knownEntries, 1, downloadWaitMs);
1076
+ if (downloaded.length === 0) {
1077
+ missingFiles.push(displayName);
1078
+ unattemptedFiles.push(...expectedFiles.slice(fileIndex + 1).map(describeDownloadableFile));
1079
+ missingFiles.push(...unattemptedFiles);
1080
+ params.logger?.(`[browser] Download timed out for ${displayName}${unattemptedFiles.length > 0
1081
+ ? `; skipped remaining expected file(s) to avoid misassigning a late completion: ${unattemptedFiles.join(", ")}`
1082
+ : ""}`);
552
1083
  break;
553
1084
  }
554
- await new Promise((resolve) => setTimeout(resolve, 250));
1085
+ const normalizedDownloads = await Promise.all(downloaded.map((filePath, index) => index === 0 ? moveDownloadedFileToExpectedName(filePath, file) : filePath));
1086
+ downloadedPaths.push(...normalizedDownloads);
1087
+ knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
555
1088
  }
556
- if (clicked.length === 0) {
557
- params.logger?.("[browser] No assistant download buttons found for button fallback.");
558
- return [];
1089
+ if (clickedCount === 0) {
1090
+ params.logger?.("[browser] No assistant download controls found for button fallback.");
559
1091
  }
560
- params.logger?.(`[browser] Clicked ${clicked.length} assistant download button(s).`);
561
- const downloaded = await waitForCompletedDownloadFiles(artifactsDir, before, clicked.length);
562
- return Promise.all(downloaded.map(async (filePath) => {
563
- const filename = path.basename(filePath);
564
- const stat = await fs.stat(filePath);
565
- return {
566
- kind: "file",
567
- path: filePath,
568
- label: filename,
569
- mimeType: mimeTypeFromFilename(filename),
570
- sizeBytes: stat.size,
571
- sourceUrl: "browser-download",
572
- url: "browser-download",
573
- finalUrl: "browser-download",
574
- filename,
575
- };
576
- }));
1092
+ else {
1093
+ params.logger?.(`[browser] Clicked ${clickedCount} assistant download control(s).`);
1094
+ }
1095
+ if (missingFiles.length > 0) {
1096
+ params.logger?.(`[browser] Download button fallback did not save expected file(s): ${missingFiles.join(", ")}`);
1097
+ }
1098
+ return Promise.all([...new Set(downloadedPaths)].map(savedBrowserFileFromPath));
577
1099
  }
578
1100
  async function fetchDownloadWithNode(downloadUrl, getCookieHeader) {
579
1101
  let currentUrl = new URL(downloadUrl);
@@ -585,7 +1107,11 @@ async function fetchDownloadWithNode(downloadUrl, getCookieHeader) {
585
1107
  isKnownChatGptFileDownloadUrl(currentUrl)) {
586
1108
  const cookieHeader = await getCookieHeader(currentUrl.href);
587
1109
  if (!cookieHeader) {
588
- throw new Error("Missing ChatGPT cookies for file download.");
1110
+ throw formatDownloadFailure({
1111
+ strategy: "node-fetch",
1112
+ finalUrl: currentUrl.href,
1113
+ message: "missing ChatGPT cookies for file download",
1114
+ });
589
1115
  }
590
1116
  headers.cookie = cookieHeader;
591
1117
  }
@@ -593,29 +1119,58 @@ async function fetchDownloadWithNode(downloadUrl, getCookieHeader) {
593
1119
  headers,
594
1120
  redirect: "manual",
595
1121
  });
1122
+ const contentType = response.headers.get("content-type");
596
1123
  if (response.status >= 300 && response.status < 400) {
597
1124
  const location = response.headers.get("location");
598
1125
  if (!location) {
599
- throw new Error(`download redirect missing location: ${response.status}`);
1126
+ const body = await readDiagnosticResponseBody(response, contentType);
1127
+ throw formatDownloadFailure({
1128
+ strategy: "node-fetch",
1129
+ status: response.status,
1130
+ statusText: response.statusText,
1131
+ contentType,
1132
+ finalUrl: currentUrl.href,
1133
+ ...body,
1134
+ message: "download redirect missing location",
1135
+ });
600
1136
  }
601
1137
  const redirectedUrl = new URL(location, currentUrl);
602
1138
  if (redirectedUrl.protocol !== "https:") {
603
- throw new Error(`download redirect rejected: ${redirectedUrl.protocol}`);
1139
+ throw formatDownloadFailure({
1140
+ strategy: "node-fetch",
1141
+ status: response.status,
1142
+ statusText: response.statusText,
1143
+ contentType,
1144
+ finalUrl: redirectedUrl.href,
1145
+ message: `download redirect rejected: ${redirectedUrl.protocol}`,
1146
+ });
604
1147
  }
605
1148
  currentUrl = redirectedUrl;
606
1149
  continue;
607
1150
  }
608
1151
  if (!response.ok) {
609
- throw new Error(`download failed: ${response.status} ${response.statusText}`);
1152
+ const body = await readDiagnosticResponseBody(response, contentType);
1153
+ throw formatDownloadFailure({
1154
+ strategy: "node-fetch",
1155
+ status: response.status,
1156
+ statusText: response.statusText,
1157
+ contentType,
1158
+ finalUrl: response.url || currentUrl.href,
1159
+ ...body,
1160
+ });
610
1161
  }
611
1162
  return {
612
1163
  buffer: Buffer.from(await response.arrayBuffer()),
613
1164
  contentDisposition: response.headers.get("content-disposition"),
614
- contentType: response.headers.get("content-type"),
615
- finalUrl: response.url,
1165
+ contentType,
1166
+ finalUrl: response.url || currentUrl.href,
616
1167
  };
617
1168
  }
618
- throw new Error(`download exceeded ${DOWNLOAD_REDIRECT_LIMIT} redirects`);
1169
+ throw formatDownloadFailure({
1170
+ strategy: "node-fetch",
1171
+ finalUrl: currentUrl.href,
1172
+ message: `download exceeded ${DOWNLOAD_REDIRECT_LIMIT} redirects`,
1173
+ });
619
1174
  }
620
1175
  async function fetchDownloadWithBrowser(Runtime, downloadUrl) {
621
1176
  const expression = `(() => {
@@ -650,14 +1205,24 @@ async function fetchDownloadWithBrowser(Runtime, downloadUrl) {
650
1205
  if (!value) {
651
1206
  throw new Error("browser download returned no value");
652
1207
  }
1208
+ const contentType = typeof value.contentType === "string" ? value.contentType : null;
1209
+ const finalUrl = typeof value.url === "string" ? value.url : downloadUrl;
653
1210
  if (!value.ok) {
654
- throw new Error(`download failed: ${value.status ?? "?"} ${value.statusText ?? ""}`.trim());
1211
+ const body = decodeDiagnosticBodySnippet(Buffer.from(String(value.base64 ?? ""), "base64"), contentType);
1212
+ throw formatDownloadFailure({
1213
+ strategy: "browser-fetch",
1214
+ status: value.status,
1215
+ statusText: value.statusText,
1216
+ contentType,
1217
+ finalUrl,
1218
+ ...body,
1219
+ });
655
1220
  }
656
1221
  return {
657
1222
  buffer: Buffer.from(String(value.base64 ?? ""), "base64"),
658
1223
  contentDisposition: typeof value.contentDisposition === "string" ? value.contentDisposition : null,
659
- contentType: typeof value.contentType === "string" ? value.contentType : null,
660
- finalUrl: typeof value.url === "string" ? value.url : downloadUrl,
1224
+ contentType,
1225
+ finalUrl,
661
1226
  };
662
1227
  }
663
1228
  export async function saveChatGptDownloadableFiles(params) {
@@ -685,13 +1250,17 @@ export async function saveChatGptDownloadableFiles(params) {
685
1250
  const sandboxDownloadUrl = downloadUrlFromSandboxUrl(file.sandboxUrl ?? file.url);
686
1251
  const downloadUrl = explicitDownloadUrl ?? sandboxDownloadUrl;
687
1252
  if (!downloadUrl) {
688
- const source = file.sandboxUrl ?? file.filename ?? file.url;
689
- errors.push(`${source}: no ChatGPT download URL found`);
1253
+ const source = sanitizeCandidateFilename(file.sandboxUrl ?? file.filename ?? file.url);
1254
+ const message = `${source}: no ChatGPT download URL found`;
1255
+ errors.push(message);
690
1256
  failedFiles.push(file);
1257
+ logger?.(`[browser] Skipping downloadable file ${index + 1}/${files.length}: ${message}`);
691
1258
  continue;
692
1259
  }
1260
+ const strategy = params.Runtime && sandboxDownloadUrl && !explicitDownloadUrl ? "browser-fetch" : "node-fetch";
1261
+ logger?.(`[browser] Download candidate ${index + 1}/${files.length}: ${describeDownloadableCandidate(file, downloadUrl)} strategy=${strategy}`);
693
1262
  try {
694
- const downloaded = params.Runtime && sandboxDownloadUrl && !explicitDownloadUrl
1263
+ const downloaded = strategy === "browser-fetch"
695
1264
  ? await fetchDownloadWithBrowser(params.Runtime, downloadUrl)
696
1265
  : await fetchDownloadWithNode(downloadUrl, getCookieHeader);
697
1266
  const contentType = downloaded.contentType;
@@ -708,7 +1277,7 @@ export async function saveChatGptDownloadableFiles(params) {
708
1277
  contents: downloaded.buffer,
709
1278
  label: file.label || filename,
710
1279
  mimeType: contentType ?? file.mimeType,
711
- sourceUrl: file.sandboxUrl ?? downloadUrl,
1280
+ sourceUrl: file.sandboxUrl ?? "chatgpt-file-endpoint",
712
1281
  logger,
713
1282
  });
714
1283
  if (artifact) {
@@ -727,9 +1296,14 @@ export async function saveChatGptDownloadableFiles(params) {
727
1296
  }
728
1297
  catch (error) {
729
1298
  const message = error instanceof Error ? error.message : String(error);
730
- errors.push(`${file.filename ?? file.downloadUrl ?? file.url}: ${message}`);
1299
+ const safeMessage = safeDiagnosticText(message, 500);
1300
+ const filename = sanitizeCandidateFilename(file.filename ??
1301
+ filenameFromUrl(file.sandboxUrl) ??
1302
+ filenameFromUrl(file.downloadUrl) ??
1303
+ file.url);
1304
+ errors.push(`${filename}: ${safeMessage}`);
731
1305
  failedFiles.push(file);
732
- logger?.(`[browser] Failed to save downloadable file ${index + 1}/${files.length}: ${message}`);
1306
+ logger?.(`[browser] Failed to save downloadable file ${index + 1}/${files.length} (${describeDownloadableCandidate(file, downloadUrl)} strategy=${strategy}): ${safeMessage}`);
733
1307
  }
734
1308
  }
735
1309
  return {
@@ -741,16 +1315,24 @@ export async function saveChatGptDownloadableFiles(params) {
741
1315
  };
742
1316
  }
743
1317
  export async function collectChatGptFileArtifacts(params) {
744
- const files = await readAssistantDownloadableFiles(params.Runtime, params.minTurnIndex ?? undefined).catch(() => []);
1318
+ const files = await readAssistantDownloadableFiles(params.Runtime, params.minTurnIndex ?? undefined).catch((error) => {
1319
+ const message = error instanceof Error ? error.message : String(error);
1320
+ params.logger?.(`[browser] Failed to inspect assistant DOM file candidates: ${safeDiagnosticText(message, 180)}`);
1321
+ return [];
1322
+ });
745
1323
  const textFiles = readTextDownloadableFiles(params.answerText);
746
- if (textFiles.length > 0) {
747
- params.logger?.(`[browser] Found ${textFiles.length} downloadable file link(s) in captured answer text.`);
748
- }
1324
+ params.logger?.(`[browser] Found ${files.length} DOM downloadable file candidate(s).`);
1325
+ params.logger?.(`[browser] Found ${textFiles.length} downloadable file link(s) in captured answer text.`);
749
1326
  const allFiles = dedupeFiles([...files, ...textFiles]);
750
1327
  if (allFiles.length === 0) {
751
1328
  return { files: [], savedFiles: [], fileCount: 0 };
752
1329
  }
753
1330
  params.logger?.(`[browser] Found ${allFiles.length} downloadable file candidate(s).`);
1331
+ allFiles.forEach((file, index) => {
1332
+ const explicitDownloadUrl = normalizeChatGptDownloadUrl(file.downloadUrl ?? file.url);
1333
+ const sandboxDownloadUrl = downloadUrlFromSandboxUrl(file.sandboxUrl ?? file.url);
1334
+ params.logger?.(`[browser] Candidate ${index + 1}/${allFiles.length}: ${describeDownloadableCandidate(file, explicitDownloadUrl ?? sandboxDownloadUrl)}`);
1335
+ });
754
1336
  const saved = await saveChatGptDownloadableFiles({
755
1337
  Network: params.Network,
756
1338
  Runtime: params.Runtime,
@@ -775,6 +1357,7 @@ export async function collectChatGptFileArtifacts(params) {
775
1357
  if (savedFiles.length === 0 && !saved.saved) {
776
1358
  const detail = saved.errors.length > 0 ? `\n${saved.errors.join("\n")}` : "";
777
1359
  params.logger?.(`[browser] Auto-save for downloadable files failed; returning metadata only.${detail}`);
1360
+ 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.`);
778
1361
  }
779
1362
  else {
780
1363
  params.logger?.(`[browser] Saved ${savedFiles.length} downloadable file artifact(s).`);