@steipete/oracle 0.15.0 → 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 (36) 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/src/browser/actions/assistantResponse.js +40 -31
  7. package/dist/src/browser/actions/attachments.js +28 -1
  8. package/dist/src/browser/actions/deepResearch.js +212 -67
  9. package/dist/src/browser/actions/modelSelection.js +30 -7
  10. package/dist/src/browser/actions/promptComposer.js +71 -14
  11. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  12. package/dist/src/browser/artifacts.js +191 -6
  13. package/dist/src/browser/chatgptFiles.js +525 -91
  14. package/dist/src/browser/constants.js +5 -0
  15. package/dist/src/browser/index.js +30 -30
  16. package/dist/src/browser/sessionRunner.js +9 -3
  17. package/dist/src/cli/bridge/client.js +4 -1
  18. package/dist/src/cli/bridge/doctor.js +19 -0
  19. package/dist/src/cli/runOptions.js +11 -2
  20. package/dist/src/cli/sessionDisplay.js +6 -1
  21. package/dist/src/cli/sessionRunner.js +28 -10
  22. package/dist/src/config.js +3 -0
  23. package/dist/src/oracle/client.js +2 -0
  24. package/dist/src/oracle/modelResolver.js +85 -0
  25. package/dist/src/oracle/multiModelRunner.js +4 -1
  26. package/dist/src/oracle/run.js +4 -1
  27. package/dist/src/remote/client.js +253 -22
  28. package/dist/src/remote/health.js +27 -0
  29. package/dist/src/remote/server.js +239 -4
  30. package/dist/src/remote/types.js +1 -1
  31. package/dist/src/sessionManager.js +1 -0
  32. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  33. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  34. package/package.json +13 -13
  35. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  36. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +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
  }
@@ -467,6 +631,7 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
467
631
  const expectedLabelsLiteral = JSON.stringify(expectedLabels);
468
632
  const allowGenericDownloadLabelsLiteral = JSON.stringify(allowGenericDownloadLabels);
469
633
  const markClickedLiteral = JSON.stringify(options.markClicked === true);
634
+ const returnDiagnosticsLiteral = JSON.stringify(options.returnDiagnostics === true);
470
635
  const maxClicksLiteral = typeof options.maxClicks === "number" &&
471
636
  Number.isFinite(options.maxClicks) &&
472
637
  options.maxClicks > 0
@@ -479,6 +644,7 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
479
644
  const EXPECTED_LABELS = ${expectedLabelsLiteral};
480
645
  const ALLOW_GENERIC_DOWNLOAD_LABELS = ${allowGenericDownloadLabelsLiteral};
481
646
  const MARK_CLICKED = ${markClickedLiteral};
647
+ const RETURN_DIAGNOSTICS = ${returnDiagnosticsLiteral};
482
648
  const MAX_CLICKS = ${maxClicksLiteral};
483
649
  const HAS_EXPECTED_LABELS = EXPECTED_LABELS.length > 0;
484
650
  const CLICKED_ATTRIBUTE = 'data-oracle-download-clicked';
@@ -492,55 +658,152 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
492
658
  if (testId.includes('assistant')) return true;
493
659
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
494
660
  };
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
- });
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
+ }
504
669
  };
505
- const genericBehaviorButton = (button) => {
506
- const text = (button.textContent || '').trim().toLowerCase();
507
- return ALLOW_GENERIC_DOWNLOAD_LABELS && /^download\\b/.test(text);
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
+ }
508
682
  };
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';
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
+ };
515
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);
516
765
  const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
517
766
  const expectedMatches = new Set();
518
767
  const genericBehaviorMatches = new Set();
519
768
  const genericFallbackMatches = new Set();
520
769
  const genericAllMatches = new Set();
770
+ let inspectedCount = 0;
521
771
  for (let index = turns.length - 1; index >= 0; index -= 1) {
522
772
  const turn = turns[index];
523
773
  if (!isAssistantTurn(turn)) continue;
524
774
  if (MIN_TURN_INDEX >= 0 && index < MIN_TURN_INDEX) continue;
525
775
  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);
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);
536
792
  });
537
793
  if (genericBehavior.length === 0) {
538
- buttons.filter(genericFallbackButton).forEach((button) => {
539
- genericFallbackMatches.add(button);
540
- genericAllMatches.add(button);
794
+ controls.filter(genericFallbackButton).forEach((info) => {
795
+ genericFallbackMatches.add(info);
796
+ genericAllMatches.add(info);
541
797
  });
542
798
  }
543
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';
544
807
  const selected = expectedMatches.size > 0
545
808
  ? expectedMatches
546
809
  : HAS_EXPECTED_LABELS
@@ -548,16 +811,23 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
548
811
  ? genericBehaviorMatches
549
812
  : genericFallbackMatches
550
813
  : 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();
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();
555
818
  });
556
- return selectedButtons.map((button) => ({
557
- text: (button.textContent || '').trim(),
558
- ariaLabel: button.getAttribute('aria-label') || '',
559
- testId: button.getAttribute('data-testid') || '',
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,
560
828
  }));
829
+ const clicked = clickedDiagnostics.map(({ text, ariaLabel, testId }) => ({ text, ariaLabel, testId }));
830
+ return RETURN_DIAGNOSTICS ? { inspectedCount, selectedCategory, clicked: clickedDiagnostics } : clicked;
561
831
  })()`;
562
832
  }
563
833
  function describeDownloadableFile(file) {
@@ -604,31 +874,112 @@ async function moveDownloadedFileToExpectedName(filePath, file) {
604
874
  return targetPath;
605
875
  }
606
876
  async function clickAssistantDownloadButtons(params) {
607
- const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, params.expectedLabels ?? [], params.allowGenericDownloadLabels, { markClicked: params.markClicked, maxClicks: params.maxClicks });
877
+ const expression = buildClickAssistantDownloadButtonsExpression(params.minTurnIndex, params.expectedLabels ?? [], params.allowGenericDownloadLabels, { markClicked: params.markClicked, maxClicks: params.maxClicks, returnDiagnostics: true });
608
878
  const deadline = Date.now() + (params.timeoutMs ?? DOWNLOAD_BUTTON_WAIT_MS);
879
+ let lastInspectedCount = 0;
880
+ let lastSelectedCategory;
609
881
  while (Date.now() < deadline) {
610
882
  const { result } = await params.Runtime.evaluate({
611
883
  expression,
612
884
  returnByValue: true,
613
885
  });
614
- const clicked = Array.isArray(result?.value) ? result.value : [];
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
+ }
615
899
  if (clicked.length > 0) {
616
- return clicked;
900
+ return {
901
+ clicked,
902
+ inspectedCount: lastInspectedCount,
903
+ selectedCategory: lastSelectedCategory,
904
+ };
617
905
  }
618
906
  await new Promise((resolve) => setTimeout(resolve, 250));
619
907
  }
620
- return [];
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
+ };
621
966
  }
622
967
  async function savedBrowserFileFromPath(filePath) {
623
968
  const filename = path.basename(filePath);
624
969
  const stat = await fs.stat(filePath);
970
+ const mimeType = mimeTypeFromFilename(filename);
971
+ const validation = await validateArtifactFile({ path: filePath, filename, mimeType });
625
972
  return {
626
973
  kind: "file",
627
974
  path: filePath,
628
975
  label: filename,
629
- mimeType: mimeTypeFromFilename(filename),
976
+ mimeType,
630
977
  sizeBytes: stat.size,
631
978
  sourceUrl: "browser-download",
979
+ sha256: await computeFileSha256(filePath),
980
+ validation,
981
+ transfer: { status: "not-needed" },
982
+ origin: { mode: "local" },
632
983
  url: "browser-download",
633
984
  finalUrl: "browser-download",
634
985
  filename,
@@ -661,19 +1012,22 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
661
1012
  const downloadWaitMs = params.downloadWaitMs ?? DOWNLOAD_BUTTON_WAIT_MS;
662
1013
  const expectedFiles = params.files ?? [];
663
1014
  if (expectedFiles.length === 0) {
664
- const clicked = await clickAssistantDownloadButtons({
1015
+ const clickedResult = await clickAssistantDownloadButtons({
665
1016
  Runtime: params.Runtime,
666
1017
  minTurnIndex: params.minTurnIndex,
667
1018
  expectedLabels: [],
668
1019
  allowGenericDownloadLabels: params.allowGenericDownloadLabels,
669
1020
  timeoutMs: buttonWaitMs,
1021
+ logger: params.logger,
670
1022
  });
671
- if (clicked.length === 0) {
672
- params.logger?.("[browser] No assistant download buttons found for button fallback.");
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"}).`);
673
1025
  return [];
674
1026
  }
675
- params.logger?.(`[browser] Clicked ${clicked.length} assistant download button(s).`);
676
- const downloaded = await waitForCompletedDownloadFiles(artifactsDir, before, clicked.length, downloadWaitMs);
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);
677
1031
  return Promise.all(downloaded.map(savedBrowserFileFromPath));
678
1032
  }
679
1033
  let clickedCount = 0;
@@ -683,7 +1037,8 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
683
1037
  const unattemptedFiles = [];
684
1038
  for (const [fileIndex, file] of expectedFiles.entries()) {
685
1039
  const expectedLabels = resolveDownloadButtonLabels([file]);
686
- const clicked = await clickAssistantDownloadButtons({
1040
+ const displayName = describeDownloadableFile(file);
1041
+ let clickResult = await clickAssistantDownloadButtons({
687
1042
  Runtime: params.Runtime,
688
1043
  minTurnIndex: params.minTurnIndex,
689
1044
  expectedLabels,
@@ -691,14 +1046,32 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
691
1046
  markClicked: true,
692
1047
  maxClicks: 1,
693
1048
  timeoutMs: buttonWaitMs,
1049
+ logger: params.logger,
694
1050
  });
695
- const displayName = describeDownloadableFile(file);
696
- if (clicked.length === 0) {
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) {
697
1070
  missingFiles.push(displayName);
698
1071
  knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
699
1072
  continue;
700
1073
  }
701
- clickedCount += clicked.length;
1074
+ clickedCount += clickResult.clicked.length;
702
1075
  const downloaded = await waitForCompletedDownloadFiles(artifactsDir, knownEntries, 1, downloadWaitMs);
703
1076
  if (downloaded.length === 0) {
704
1077
  missingFiles.push(displayName);
@@ -714,10 +1087,10 @@ export async function saveAssistantDownloadButtonArtifacts(params) {
714
1087
  knownEntries = new Set(await fs.readdir(artifactsDir).catch(() => []));
715
1088
  }
716
1089
  if (clickedCount === 0) {
717
- params.logger?.("[browser] No assistant download buttons found for button fallback.");
1090
+ params.logger?.("[browser] No assistant download controls found for button fallback.");
718
1091
  }
719
1092
  else {
720
- params.logger?.(`[browser] Clicked ${clickedCount} assistant download button(s).`);
1093
+ params.logger?.(`[browser] Clicked ${clickedCount} assistant download control(s).`);
721
1094
  }
722
1095
  if (missingFiles.length > 0) {
723
1096
  params.logger?.(`[browser] Download button fallback did not save expected file(s): ${missingFiles.join(", ")}`);
@@ -734,7 +1107,11 @@ async function fetchDownloadWithNode(downloadUrl, getCookieHeader) {
734
1107
  isKnownChatGptFileDownloadUrl(currentUrl)) {
735
1108
  const cookieHeader = await getCookieHeader(currentUrl.href);
736
1109
  if (!cookieHeader) {
737
- 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
+ });
738
1115
  }
739
1116
  headers.cookie = cookieHeader;
740
1117
  }
@@ -742,29 +1119,58 @@ async function fetchDownloadWithNode(downloadUrl, getCookieHeader) {
742
1119
  headers,
743
1120
  redirect: "manual",
744
1121
  });
1122
+ const contentType = response.headers.get("content-type");
745
1123
  if (response.status >= 300 && response.status < 400) {
746
1124
  const location = response.headers.get("location");
747
1125
  if (!location) {
748
- 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
+ });
749
1136
  }
750
1137
  const redirectedUrl = new URL(location, currentUrl);
751
1138
  if (redirectedUrl.protocol !== "https:") {
752
- 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
+ });
753
1147
  }
754
1148
  currentUrl = redirectedUrl;
755
1149
  continue;
756
1150
  }
757
1151
  if (!response.ok) {
758
- 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
+ });
759
1161
  }
760
1162
  return {
761
1163
  buffer: Buffer.from(await response.arrayBuffer()),
762
1164
  contentDisposition: response.headers.get("content-disposition"),
763
- contentType: response.headers.get("content-type"),
764
- finalUrl: response.url,
1165
+ contentType,
1166
+ finalUrl: response.url || currentUrl.href,
765
1167
  };
766
1168
  }
767
- 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
+ });
768
1174
  }
769
1175
  async function fetchDownloadWithBrowser(Runtime, downloadUrl) {
770
1176
  const expression = `(() => {
@@ -799,14 +1205,24 @@ async function fetchDownloadWithBrowser(Runtime, downloadUrl) {
799
1205
  if (!value) {
800
1206
  throw new Error("browser download returned no value");
801
1207
  }
1208
+ const contentType = typeof value.contentType === "string" ? value.contentType : null;
1209
+ const finalUrl = typeof value.url === "string" ? value.url : downloadUrl;
802
1210
  if (!value.ok) {
803
- 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
+ });
804
1220
  }
805
1221
  return {
806
1222
  buffer: Buffer.from(String(value.base64 ?? ""), "base64"),
807
1223
  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,
1224
+ contentType,
1225
+ finalUrl,
810
1226
  };
811
1227
  }
812
1228
  export async function saveChatGptDownloadableFiles(params) {
@@ -834,13 +1250,17 @@ export async function saveChatGptDownloadableFiles(params) {
834
1250
  const sandboxDownloadUrl = downloadUrlFromSandboxUrl(file.sandboxUrl ?? file.url);
835
1251
  const downloadUrl = explicitDownloadUrl ?? sandboxDownloadUrl;
836
1252
  if (!downloadUrl) {
837
- const source = file.sandboxUrl ?? file.filename ?? file.url;
838
- 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);
839
1256
  failedFiles.push(file);
1257
+ logger?.(`[browser] Skipping downloadable file ${index + 1}/${files.length}: ${message}`);
840
1258
  continue;
841
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}`);
842
1262
  try {
843
- const downloaded = params.Runtime && sandboxDownloadUrl && !explicitDownloadUrl
1263
+ const downloaded = strategy === "browser-fetch"
844
1264
  ? await fetchDownloadWithBrowser(params.Runtime, downloadUrl)
845
1265
  : await fetchDownloadWithNode(downloadUrl, getCookieHeader);
846
1266
  const contentType = downloaded.contentType;
@@ -857,7 +1277,7 @@ export async function saveChatGptDownloadableFiles(params) {
857
1277
  contents: downloaded.buffer,
858
1278
  label: file.label || filename,
859
1279
  mimeType: contentType ?? file.mimeType,
860
- sourceUrl: file.sandboxUrl ?? downloadUrl,
1280
+ sourceUrl: file.sandboxUrl ?? "chatgpt-file-endpoint",
861
1281
  logger,
862
1282
  });
863
1283
  if (artifact) {
@@ -876,9 +1296,14 @@ export async function saveChatGptDownloadableFiles(params) {
876
1296
  }
877
1297
  catch (error) {
878
1298
  const message = error instanceof Error ? error.message : String(error);
879
- 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}`);
880
1305
  failedFiles.push(file);
881
- 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}`);
882
1307
  }
883
1308
  }
884
1309
  return {
@@ -890,16 +1315,24 @@ export async function saveChatGptDownloadableFiles(params) {
890
1315
  };
891
1316
  }
892
1317
  export async function collectChatGptFileArtifacts(params) {
893
- 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
+ });
894
1323
  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
- }
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.`);
898
1326
  const allFiles = dedupeFiles([...files, ...textFiles]);
899
1327
  if (allFiles.length === 0) {
900
1328
  return { files: [], savedFiles: [], fileCount: 0 };
901
1329
  }
902
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
+ });
903
1336
  const saved = await saveChatGptDownloadableFiles({
904
1337
  Network: params.Network,
905
1338
  Runtime: params.Runtime,
@@ -924,6 +1357,7 @@ export async function collectChatGptFileArtifacts(params) {
924
1357
  if (savedFiles.length === 0 && !saved.saved) {
925
1358
  const detail = saved.errors.length > 0 ? `\n${saved.errors.join("\n")}` : "";
926
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.`);
927
1361
  }
928
1362
  else {
929
1363
  params.logger?.(`[browser] Saved ${savedFiles.length} downloadable file artifact(s).`);