@steipete/oracle 0.18.0 → 0.19.0

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 (159) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +65 -63
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/archiveConversation.js +31 -7
  5. package/dist/src/browser/actions/assistantResponse.js +43 -1
  6. package/dist/src/browser/actions/attachmentContext.js +239 -0
  7. package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
  8. package/dist/src/browser/actions/attachmentEvidence.js +78 -0
  9. package/dist/src/browser/actions/attachmentProgress.js +41 -0
  10. package/dist/src/browser/actions/attachmentPrompt.js +130 -0
  11. package/dist/src/browser/actions/attachments.js +306 -355
  12. package/dist/src/browser/actions/deepResearch.js +2 -1
  13. package/dist/src/browser/actions/modelSelection.js +13 -3
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +361 -186
  16. package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
  17. package/dist/src/browser/actions/thinkingStatus.js +2 -5
  18. package/dist/src/browser/actions/thinkingTime.js +119 -16
  19. package/dist/src/browser/attachRunning.js +46 -2
  20. package/dist/src/browser/attachmentValidation.js +53 -0
  21. package/dist/src/browser/cancellation.js +105 -0
  22. package/dist/src/browser/chatgptImages.js +2 -0
  23. package/dist/src/browser/chatgptThrottle.js +104 -0
  24. package/dist/src/browser/chromeLifecycle.js +267 -43
  25. package/dist/src/browser/config.js +13 -0
  26. package/dist/src/browser/detect.js +1 -1
  27. package/dist/src/browser/index.js +444 -261
  28. package/dist/src/browser/modelDisplay.js +9 -0
  29. package/dist/src/browser/pageActions.js +2 -2
  30. package/dist/src/browser/profileState.js +114 -9
  31. package/dist/src/browser/projectSourcesRunner.js +83 -15
  32. package/dist/src/browser/prompt.js +272 -113
  33. package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
  34. package/dist/src/browser/reattach.js +10 -3
  35. package/dist/src/browser/sessionRunner.js +71 -11
  36. package/dist/src/browser/tabLeaseRegistry.js +414 -38
  37. package/dist/src/browser/utils.js +20 -2
  38. package/dist/src/cli/browserConfig.js +3 -1
  39. package/dist/src/cli/browserDefaults.js +10 -0
  40. package/dist/src/cli/browserTabs.js +12 -29
  41. package/dist/src/cli/detachedSession.js +69 -0
  42. package/dist/src/cli/dryRun.js +58 -48
  43. package/dist/src/cli/harvestIntegrity.js +140 -0
  44. package/dist/src/cli/serveBrowserConfig.js +24 -0
  45. package/dist/src/cli/sessionDisplay.js +9 -2
  46. package/dist/src/cli/sessionRunner.js +91 -7
  47. package/dist/src/config.js +1 -0
  48. package/dist/src/gemini-web/client.js +67 -21
  49. package/dist/src/gemini-web/executor.js +4 -0
  50. package/dist/src/gemini-web/http.js +15 -0
  51. package/dist/src/gemini-web/models.js +15 -11
  52. package/dist/src/mcp/server.js +11 -14
  53. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  54. package/dist/src/mcp/tools/consult.js +84 -22
  55. package/dist/src/mcp/tools/projectSources.js +4 -6
  56. package/dist/src/mcp/tools/sessionResources.js +1 -1
  57. package/dist/src/mcp/tools/sessions.js +2 -2
  58. package/dist/src/mcp/tools/wait.js +182 -0
  59. package/dist/src/mcp/types.js +7 -0
  60. package/dist/src/oracle/errors.js +11 -0
  61. package/dist/src/oracle/providerRoutePlan.js +3 -2
  62. package/dist/src/remote/client.js +70 -6
  63. package/dist/src/remote/health.js +6 -2
  64. package/dist/src/remote/runSlots.js +83 -0
  65. package/dist/src/remote/server.js +345 -71
  66. package/dist/src/sessionManager.js +36 -1
  67. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  68. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  69. package/package.json +25 -23
  70. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  71. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  72. package/dist/bin/oracle.js +0 -683
  73. package/dist/docs-site/.nojekyll +0 -0
  74. package/dist/docs-site/CNAME +0 -1
  75. package/dist/docs-site/RELEASING.html +0 -410
  76. package/dist/docs-site/agents.html +0 -374
  77. package/dist/docs-site/anthropic.html +0 -368
  78. package/dist/docs-site/bridge.html +0 -400
  79. package/dist/docs-site/browser-mode.html +0 -588
  80. package/dist/docs-site/chromium-forks.html +0 -347
  81. package/dist/docs-site/cli-reference.html +0 -346
  82. package/dist/docs-site/configuration.html +0 -436
  83. package/dist/docs-site/favicon.svg +0 -14
  84. package/dist/docs-site/followup.html +0 -368
  85. package/dist/docs-site/gemini.html +0 -379
  86. package/dist/docs-site/grok.html +0 -325
  87. package/dist/docs-site/index.html +0 -360
  88. package/dist/docs-site/install.html +0 -335
  89. package/dist/docs-site/linux.html +0 -321
  90. package/dist/docs-site/llms.txt +0 -43
  91. package/dist/docs-site/manual-tests.html +0 -595
  92. package/dist/docs-site/mcp.html +0 -368
  93. package/dist/docs-site/multimodel.html +0 -364
  94. package/dist/docs-site/mythical-pro-agents.html +0 -360
  95. package/dist/docs-site/notifier.html +0 -338
  96. package/dist/docs-site/openai-endpoints.html +0 -387
  97. package/dist/docs-site/openrouter.html +0 -344
  98. package/dist/docs-site/quickstart.html +0 -369
  99. package/dist/docs-site/refactor/ux.html +0 -532
  100. package/dist/docs-site/sessions.html +0 -388
  101. package/dist/docs-site/social-card.png +0 -0
  102. package/dist/docs-site/social-card.svg +0 -79
  103. package/dist/docs-site/spec.html +0 -363
  104. package/dist/docs-site/testing.html +0 -320
  105. package/dist/docs-site/tui-debug.html +0 -326
  106. package/dist/docs-site/windows-work.html +0 -323
  107. package/dist/docs-site/windows.html +0 -320
  108. package/dist/markdansi/types/index.js +0 -4
  109. package/dist/oracle/bin/oracle-cli.js +0 -472
  110. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  111. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  112. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  113. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  114. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  115. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  116. package/dist/oracle/src/browser/config.js +0 -33
  117. package/dist/oracle/src/browser/constants.js +0 -40
  118. package/dist/oracle/src/browser/cookies.js +0 -210
  119. package/dist/oracle/src/browser/domDebug.js +0 -36
  120. package/dist/oracle/src/browser/index.js +0 -331
  121. package/dist/oracle/src/browser/pageActions.js +0 -5
  122. package/dist/oracle/src/browser/prompt.js +0 -88
  123. package/dist/oracle/src/browser/promptSummary.js +0 -20
  124. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  125. package/dist/oracle/src/browser/types.js +0 -1
  126. package/dist/oracle/src/browser/utils.js +0 -62
  127. package/dist/oracle/src/browserMode.js +0 -1
  128. package/dist/oracle/src/cli/browserConfig.js +0 -44
  129. package/dist/oracle/src/cli/dryRun.js +0 -59
  130. package/dist/oracle/src/cli/engine.js +0 -17
  131. package/dist/oracle/src/cli/errorUtils.js +0 -9
  132. package/dist/oracle/src/cli/help.js +0 -70
  133. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  134. package/dist/oracle/src/cli/options.js +0 -103
  135. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  136. package/dist/oracle/src/cli/rootAlias.js +0 -30
  137. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  138. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  139. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  140. package/dist/oracle/src/heartbeat.js +0 -43
  141. package/dist/oracle/src/oracle/client.js +0 -48
  142. package/dist/oracle/src/oracle/config.js +0 -29
  143. package/dist/oracle/src/oracle/errors.js +0 -101
  144. package/dist/oracle/src/oracle/files.js +0 -220
  145. package/dist/oracle/src/oracle/format.js +0 -33
  146. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  147. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  148. package/dist/oracle/src/oracle/request.js +0 -48
  149. package/dist/oracle/src/oracle/run.js +0 -444
  150. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  151. package/dist/oracle/src/oracle/types.js +0 -1
  152. package/dist/oracle/src/oracle.js +0 -9
  153. package/dist/oracle/src/sessionManager.js +0 -205
  154. package/dist/oracle/src/version.js +0 -39
  155. package/dist/scripts/chrome/browser-tools.js +0 -295
  156. package/dist/src/browser/chromeCookies.js +0 -312
  157. package/dist/src/browser/keytarShim.js +0 -56
  158. package/dist/src/browser/profileSync.js +0 -141
  159. package/dist/src/browser/windowsCookies.js +0 -219
@@ -0,0 +1,239 @@
1
+ import { withoutBrowserCancellation } from "../cancellation.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import { BrowserAutomationError } from "../../oracle/errors.js";
4
+ import { conversationIdFromUrl } from "./navigation.js";
5
+ export async function captureComposerNavigationUrl(runtime) {
6
+ const result = await runtime.evaluate({
7
+ expression: "location.href",
8
+ returnByValue: true,
9
+ });
10
+ const url = result?.result?.value;
11
+ if (typeof url !== "string" || !url) {
12
+ throw new BrowserAutomationError("Oracle could not capture ChatGPT's page identity before attachment upload.", {
13
+ stage: "upload-attachment",
14
+ code: "attachment-navigation-identity-unavailable",
15
+ });
16
+ }
17
+ return url;
18
+ }
19
+ export async function assertComposerPlusStayedInPlace(runtime, startUrl) {
20
+ const result = await runtime.evaluate({
21
+ expression: buildComposerNavigationProbeExpression(),
22
+ returnByValue: true,
23
+ });
24
+ assertComposerNavigationSnapshot(startUrl, result?.result?.value);
25
+ }
26
+ export function buildComposerNavigationProbeExpression() {
27
+ return `(() => {
28
+ const normalize = value => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
29
+ const isWorkLabel = value => ['work', '工作'].includes(normalize(value));
30
+ const controlLabel = node => normalize(node.textContent || node.getAttribute('aria-label'));
31
+ const modeValue = node => normalize(node.getAttribute('data-mode') || node.getAttribute('data-value') || node.getAttribute('value'));
32
+ const visible = node => {
33
+ if (node.closest?.('[data-message-author-role], [data-testid^="conversation-turn-"], article[data-turn]')) return false;
34
+ const rect = node.getBoundingClientRect();
35
+ if (rect.width <= 0 || rect.height <= 0) return false;
36
+ for (let current = node; current && typeof current.getBoundingClientRect === 'function'; current = current.parentElement) {
37
+ const style = window.getComputedStyle(current);
38
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0' || current.getAttribute?.('aria-hidden') === 'true') return false;
39
+ }
40
+ return true;
41
+ };
42
+ const selected = node =>
43
+ node?.getAttribute?.('aria-checked') === 'true' ||
44
+ node?.getAttribute?.('aria-selected') === 'true' ||
45
+ node?.getAttribute?.('aria-pressed') === 'true' ||
46
+ node?.getAttribute?.('data-state') === 'on' ||
47
+ node?.getAttribute?.('data-state') === 'active';
48
+ const prompt = document.querySelector('#prompt-textarea');
49
+ const fallbackPrompt = document.querySelector('textarea[name="prompt-textarea"]');
50
+ const composer = (prompt ?? fallbackPrompt)?.closest('form, [data-testid="composer"]');
51
+ const selectedControls = Array.from(
52
+ document.querySelectorAll('[aria-checked="true"],[aria-selected="true"],[aria-pressed="true"],[data-state="on"],[data-state="active"]'),
53
+ ).filter(visible).filter(selected);
54
+ const workToggleSelected = selectedControls.some(node => modeValue(node) === 'work' ||
55
+ composer?.contains(node) && node.matches('button[role="radio"],button[role="tab"],button[aria-pressed]') && isWorkLabel(controlLabel(node)));
56
+ // Labels outside the composer may name unrelated sidebar items or tabs.
57
+ const placeholder = normalize(
58
+ prompt?.getAttribute?.('data-placeholder') ||
59
+ prompt?.getAttribute?.('placeholder') ||
60
+ fallbackPrompt?.getAttribute?.('placeholder'),
61
+ );
62
+ return {
63
+ currentUrl: location.href,
64
+ workSelected: /(?:^|\\/)c\\/WEB(?::|%3a)/i.test(location.pathname) || workToggleSelected || placeholder === 'work on anything',
65
+ modeUnverified: false,
66
+ };
67
+ })()`;
68
+ }
69
+ export function assertComposerNavigationSnapshot(startUrl, snapshot) {
70
+ const value = snapshot;
71
+ const currentUrl = typeof value?.currentUrl === "string" ? value.currentUrl : "";
72
+ const startIdentity = composerNavigationIdentityFromUrl(startUrl);
73
+ const currentIdentity = composerNavigationIdentityFromUrl(currentUrl);
74
+ const unexpectedNavigation = !startIdentity ||
75
+ !currentIdentity ||
76
+ startIdentity.origin !== currentIdentity.origin ||
77
+ (startIdentity.conversationId !== null
78
+ ? currentIdentity.conversationId !== startIdentity.conversationId
79
+ : currentIdentity.conversationId !== null ||
80
+ currentIdentity.landingPath !== startIdentity.landingPath);
81
+ const workConversation = currentIdentity?.conversationId && /^WEB(?::|%3a)/i.test(currentIdentity.conversationId);
82
+ if (value?.workSelected || workConversation || unexpectedNavigation) {
83
+ throw new BrowserAutomationError("ChatGPT navigated to Work or another ChatGPT context during attachment preparation; upload/send was stopped before prompt submission.", {
84
+ stage: "upload-attachment",
85
+ code: "attachment-control-unexpected-navigation",
86
+ startUrl,
87
+ currentUrl,
88
+ workSelected: Boolean(value?.workSelected),
89
+ startNavigationIdentity: startIdentity,
90
+ currentNavigationIdentity: currentIdentity,
91
+ });
92
+ }
93
+ if (value?.modeUnverified) {
94
+ throw new BrowserAutomationError("ChatGPT mode could not be verified; attachment upload/send was stopped.", {
95
+ stage: "upload-attachment",
96
+ code: "attachment-control-mode-unverified",
97
+ startUrl,
98
+ currentUrl,
99
+ });
100
+ }
101
+ }
102
+ export function composerNavigationIdentityFromUrl(value) {
103
+ try {
104
+ const url = new URL(value);
105
+ const conversationId = conversationIdFromUrl(url.href);
106
+ return {
107
+ origin: url.origin.toLowerCase(),
108
+ conversationId,
109
+ landingPath: conversationId === null ? url.pathname.replace(/\/+$/, "") || "/" : null,
110
+ };
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ export function buildComposerNavigationValidationExpression(startUrl) {
117
+ const expected = composerNavigationIdentityFromUrl(startUrl);
118
+ return `(() => {
119
+ const snapshot = ${buildComposerNavigationProbeExpression()};
120
+ const expected = ${JSON.stringify(expected)};
121
+ let contextMatches = false;
122
+ try {
123
+ const current = new URL(snapshot.currentUrl);
124
+ const id = current.pathname.match(/(?:^|\\/)c\\/([^/]+)/)?.[1] ?? null;
125
+ contextMatches = Boolean(expected) && !snapshot.workSelected && !snapshot.modeUnverified &&
126
+ current.origin.toLowerCase() === expected.origin &&
127
+ (expected.conversationId !== null ? id === expected.conversationId :
128
+ id === null && (current.pathname.replace(/\\/+$/, '') || '/') === expected.landingPath);
129
+ } catch {}
130
+ return { ...snapshot, contextMatches };
131
+ })()`;
132
+ }
133
+ /** Guards native file-selection events before application handlers can upload the bytes. */
134
+ export function buildFileInputGuardExpression(inputExpression, startUrl) {
135
+ return `(() => {
136
+ const guardedInput = ${inputExpression};
137
+ let blocked = null;
138
+ let listening = false;
139
+ const cleanup = () => {
140
+ if (!listening) return;
141
+ window.removeEventListener('input', onEvent, true);
142
+ window.removeEventListener('change', onEvent, true);
143
+ listening = false;
144
+ };
145
+ const validate = (rollback = false) => {
146
+ try {
147
+ const snapshot = ${buildComposerNavigationValidationExpression(startUrl)};
148
+ if (!snapshot.contextMatches) blocked = snapshot;
149
+ } catch { blocked = { currentUrl: location.href, modeUnverified: true }; }
150
+ if (blocked && rollback && guardedInput) { try { guardedInput.value = ''; } catch {} }
151
+ return !blocked;
152
+ };
153
+ const onEvent = event => {
154
+ if (event.target !== guardedInput) return;
155
+ if (!validate(true)) { event.preventDefault(); event.stopImmediatePropagation(); }
156
+ if (event.type === 'change') cleanup();
157
+ };
158
+ if (validate()) {
159
+ window.addEventListener('input', onEvent, true);
160
+ window.addEventListener('change', onEvent, true);
161
+ listening = true;
162
+ }
163
+ return { get blocked() { return blocked; }, validate, cleanup };
164
+ })()`;
165
+ }
166
+ export async function withGuardedFileInput(runtime, selector, startUrl, assign) {
167
+ const id = randomUUID();
168
+ let result;
169
+ try {
170
+ const prepared = await runtime.evaluate({
171
+ expression: `(() => {
172
+ const input = document.querySelector(${JSON.stringify(selector)});
173
+ if (!(input instanceof HTMLInputElement) || input.type !== 'file') return { installed: false };
174
+ const guard = ${buildFileInputGuardExpression("input", startUrl)};
175
+ if (guard.blocked) { guard.cleanup(); return { blocked: guard.blocked }; }
176
+ const guards = window.__oracleAttachmentInputGuards ??= Object.create(null);
177
+ guards[${JSON.stringify(id)}] = guard;
178
+ return { installed: true };
179
+ })()`,
180
+ returnByValue: true,
181
+ });
182
+ const preparation = prepared.result?.value;
183
+ if (preparation?.blocked)
184
+ assertComposerNavigationSnapshot(startUrl, preparation.blocked);
185
+ if (!preparation?.installed)
186
+ throw new BrowserAutomationError("Attachment input changed before assignment.", {
187
+ stage: "upload-attachment",
188
+ code: "attachment-input-unavailable",
189
+ });
190
+ await assign();
191
+ }
192
+ finally {
193
+ const observed = await withoutBrowserCancellation(() => runtime
194
+ .evaluate({
195
+ expression: `(() => {
196
+ const guards = window.__oracleAttachmentInputGuards;
197
+ const guard = guards?.[${JSON.stringify(id)}];
198
+ if (!guard) return null;
199
+ guard.validate(true);
200
+ const summary = { blocked: guard.blocked };
201
+ guard.cleanup(); delete guards[${JSON.stringify(id)}]; return summary;
202
+ })()`,
203
+ returnByValue: true,
204
+ })
205
+ .catch(() => undefined));
206
+ result = observed?.result?.value;
207
+ }
208
+ if (!result)
209
+ throw new BrowserAutomationError("Attachment assignment could not be verified; do not retry automatically.", { stage: "upload-attachment", code: "attachment-assignment-ambiguous" });
210
+ if (result.blocked)
211
+ assertComposerNavigationSnapshot(startUrl, result.blocked);
212
+ }
213
+ async function mutateGuardedFileInput(runtime, selector, startUrl, events) {
214
+ const observed = await runtime.evaluate({
215
+ expression: `(() => {
216
+ const input = document.querySelector(${JSON.stringify(selector)});
217
+ if (!(input instanceof HTMLInputElement)) return { missing: true };
218
+ const guard = ${buildFileInputGuardExpression("input", startUrl)};
219
+ try {
220
+ if (!guard.blocked) {
221
+ ${events ? "input.dispatchEvent(new Event('input', { bubbles: true })); if (guard.validate()) input.dispatchEvent(new Event('change', { bubbles: true }));" : "input.value = '';"}
222
+ guard.validate(true);
223
+ }
224
+ return { blocked: guard.blocked };
225
+ } finally { guard.cleanup(); }
226
+ })()`,
227
+ returnByValue: true,
228
+ });
229
+ const value = observed.result?.value;
230
+ if (value?.blocked)
231
+ assertComposerNavigationSnapshot(startUrl, value.blocked);
232
+ if (!value || value.missing || observed.exceptionDetails)
233
+ throw new BrowserAutomationError("Attachment input changed during preparation.", {
234
+ stage: "upload-attachment",
235
+ code: "attachment-input-unavailable",
236
+ });
237
+ }
238
+ export const dispatchGuardedFileInputEvents = (runtime, selector, startUrl) => mutateGuardedFileInput(runtime, selector, startUrl, true);
239
+ export const clearGuardedFileInput = (runtime, selector, startUrl) => mutateGuardedFileInput(runtime, selector, startUrl, false);
@@ -1,7 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { assertComposerNavigationSnapshot, buildFileInputGuardExpression, } from "./attachmentContext.js";
3
4
  const MAX_DATA_TRANSFER_BYTES = 20 * 1024 * 1024;
4
- export async function transferAttachmentViaDataTransfer(runtime, attachment, selector) {
5
+ export async function transferAttachmentViaDataTransfer(runtime, attachment, selector, navigationUrl) {
5
6
  const fileContent = await readFile(attachment.path);
6
7
  if (fileContent.length > MAX_DATA_TRANSFER_BYTES) {
7
8
  throw new Error(`Attachment ${path.basename(attachment.path)} is too large for data transfer (${fileContent.length} bytes). Maximum size is ${MAX_DATA_TRANSFER_BYTES} bytes.`);
@@ -22,6 +23,16 @@ export async function transferAttachmentViaDataTransfer(runtime, attachment, sel
22
23
  return { success: false, error: 'Found element is not a file input' };
23
24
  }
24
25
 
26
+ const guard = ${navigationUrl ? buildFileInputGuardExpression("fileInput", navigationUrl) : "null"};
27
+ if (guard?.blocked) { guard.cleanup(); return { success: false, navigationBlocked: guard.blocked }; }
28
+ let syntheticFiles = false;
29
+ const navigationFailure = () => {
30
+ if (syntheticFiles) delete fileInput.files;
31
+ fileInput.value = '';
32
+ return { success: false, navigationBlocked: guard.blocked };
33
+ };
34
+ try {
35
+
25
36
  const base64Data = ${JSON.stringify(base64Content)};
26
37
  const binaryString = atob(base64Data);
27
38
  const bytes = new Uint8Array(binaryString.length);
@@ -55,6 +66,7 @@ export async function transferAttachmentViaDataTransfer(runtime, attachment, sel
55
66
  configurable: true,
56
67
  get: () => dataTransfer.files,
57
68
  });
69
+ syntheticFiles = true;
58
70
  assigned = true;
59
71
  } catch {
60
72
  assigned = false;
@@ -72,8 +84,11 @@ export async function transferAttachmentViaDataTransfer(runtime, attachment, sel
72
84
  return { success: false, error: 'Unable to assign FileList to input' };
73
85
  }
74
86
 
87
+ if (guard && !guard.validate(true)) return navigationFailure();
75
88
  fileInput.dispatchEvent(new Event('change', { bubbles: true }));
89
+ if (guard && !guard.validate(true)) return navigationFailure();
76
90
  return { success: true, fileName: file.name, size: file.size };
91
+ } finally { guard?.cleanup(); }
77
92
  })()`;
78
93
  const evalResult = await runtime.evaluate({ expression, returnByValue: true });
79
94
  if (evalResult.exceptionDetails) {
@@ -86,6 +101,9 @@ export async function transferAttachmentViaDataTransfer(runtime, attachment, sel
86
101
  throw new Error("Failed to transfer file to browser: unexpected evaluation result");
87
102
  }
88
103
  const uploadResult = evalResult.result.value;
104
+ if (uploadResult.navigationBlocked && navigationUrl) {
105
+ assertComposerNavigationSnapshot(navigationUrl, uploadResult.navigationBlocked);
106
+ }
89
107
  if (!uploadResult.success) {
90
108
  throw new Error(`Failed to transfer file to browser: ${uploadResult.error || "Unknown error"}`);
91
109
  }
@@ -0,0 +1,78 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { INPUT_SELECTORS, SEND_BUTTON_SELECTORS } from "../constants.js";
3
+ // Receipts live in the renderer: retaining the actual removal control, rather than
4
+ // its label/count, lets every stage reject removed, replaced, or sent attachments.
5
+ export function buildAttachmentEvidenceExpression(names, action = "read", id) {
6
+ return `(() => {
7
+ const names = ${JSON.stringify(names)}.map(name => String(name).toLowerCase().replace(/\\s+/g, ' ').trim());
8
+ const action = ${JSON.stringify(action)};
9
+ const id = ${JSON.stringify(id ?? "")};
10
+ const key = '__oracleAttachmentEvidence';
11
+ if (action === 'clear') { delete globalThis[key]; return true; }
12
+ const empty = names.map(() => false);
13
+ if (action === 'read' && !globalThis[key]) return empty;
14
+ const receipts = globalThis[key] ??= new Map();
15
+ const visible = node => node instanceof HTMLElement && node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0;
16
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
17
+ const prompt = ${JSON.stringify(INPUT_SELECTORS)}.flatMap(selector => Array.from(document.querySelectorAll(selector))).find(visible);
18
+ const send = sendSelectors.map(selector => document.querySelector(selector)).find(visible);
19
+ let composer = null;
20
+ let fallback = null;
21
+ for (let node = (prompt ?? send)?.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {
22
+ if (!sendSelectors.some(selector => node.querySelector(selector))) continue;
23
+ fallback ??= node;
24
+ if (node.querySelector('input[type="file"],[data-testid*="attachment"],[aria-label*="Remove" i]')) {
25
+ composer = node;
26
+ break;
27
+ }
28
+ }
29
+ composer ??= fallback;
30
+ if (!composer) return action === 'read' ? empty : false;
31
+ const controls = () => Array.from(composer.querySelectorAll('[aria-label*="Remove" i]')).filter(node => {
32
+ if (!visible(node) || node.closest('textarea,[contenteditable="true"]')) return false;
33
+ const label = (node.getAttribute('aria-label') ?? '').toLowerCase();
34
+ return /^remove (?:file|attachment|image|photo)(?: |$)/.test(label) || Boolean(node.closest('[data-testid*="chip"],[data-testid*="attachment"],[data-testid*="upload"]'));
35
+ });
36
+ const currentControls = controls();
37
+ const valid = receipt => receipt.composer === composer && receipt.control?.isConnected && currentControls.includes(receipt.control) && receipt.control.getAttribute('aria-label') === receipt.label;
38
+ for (const [receiptId, receipt] of receipts) {
39
+ if (receipt.composer !== composer || (receipt.control && !valid(receipt))) receipts.delete(receiptId);
40
+ }
41
+ if (action === 'begin') {
42
+ for (const [receiptId, receipt] of receipts) {
43
+ if (receipt.name === names[0]) receipts.delete(receiptId);
44
+ }
45
+ receipts.set(id, { name: names[0], composer, before: new Set(currentControls) });
46
+ return true;
47
+ }
48
+ if (action === 'confirm') {
49
+ const receipt = receipts.get(id);
50
+ if (!receipt || receipt.composer !== composer) return false;
51
+ if (valid(receipt)) return true;
52
+ const claimed = new Set(Array.from(receipts.values()).map(entry => entry.control).filter(Boolean));
53
+ const added = currentControls.filter(control => !receipt.before.has(control) && !claimed.has(control));
54
+ // Ambiguous additions cannot identify which file was accepted.
55
+ if (added.length !== 1) return false;
56
+ receipt.control = added[0];
57
+ receipt.label = added[0].getAttribute('aria-label');
58
+ receipt.before.clear();
59
+ return true;
60
+ }
61
+ return names.map(name => Array.from(receipts.values()).some(receipt => receipt.name === name && valid(receipt)));
62
+ })()`;
63
+ }
64
+ export async function beginAttachmentEvidence(runtime, name) {
65
+ const id = randomUUID();
66
+ await runtime.evaluate({
67
+ expression: buildAttachmentEvidenceExpression([name], "begin", id),
68
+ returnByValue: true,
69
+ });
70
+ return id;
71
+ }
72
+ export async function confirmAttachmentEvidence(runtime, id) {
73
+ const result = await runtime.evaluate({
74
+ expression: buildAttachmentEvidenceExpression([], "confirm", id),
75
+ returnByValue: true,
76
+ });
77
+ return result.result?.value === true;
78
+ }
@@ -0,0 +1,41 @@
1
+ // Use the same composer-scoped signal at completion and immediately before send.
2
+ export function buildAttachmentProgressExpression(composerExpression) {
3
+ return String.raw `(() => {
4
+ const root = ${composerExpression};
5
+ if (!root || root === document || root === document.body) return false;
6
+ // Text can name a file or describe a completed operation; only explicit state blocks sending.
7
+ const selectors = ['[data-state="loading"]', '[data-state="uploading"]', '[data-state="pending"]', '[aria-busy="true"]', '[role~="progressbar"]', 'progress'];
8
+ const candidates = new Set(selectors.flatMap(selector => Array.from(root.querySelectorAll(selector))));
9
+ if (selectors.some(selector => root.matches?.(selector))) candidates.add(root);
10
+ return Array.from(candidates).some(node => {
11
+ if (typeof node.getBoundingClientRect !== 'function') return false;
12
+ // Non-editable widgets inside rich-text editors own their upload controls.
13
+ const boundary = node.closest('textarea,[contenteditable=""],[contenteditable="true" i],[contenteditable="plaintext-only" i],[contenteditable="false" i]');
14
+ const editor = boundary?.matches('[contenteditable="false" i]') ? null : boundary;
15
+ if (editor && editor !== node) return false;
16
+ const state = node.getAttribute('data-state');
17
+ const pending = node.getAttribute('aria-busy') === 'true' || ['loading', 'uploading', 'pending'].includes(state);
18
+ // File inputs and backing editors may be hidden behind the visible composer.
19
+ if (pending && (node.matches('input[type="file"]') || editor === node)) return true;
20
+ const rect = node.getBoundingClientRect();
21
+ if (rect.width <= 0 || rect.height <= 0) return false;
22
+ for (let ancestor = node; ancestor && typeof ancestor.getBoundingClientRect === 'function'; ancestor = ancestor.parentElement) {
23
+ const style = typeof window === 'undefined' ? {} : window.getComputedStyle(ancestor);
24
+ if (style.display === 'none' || style.visibility === 'hidden' || Number.parseFloat(style.opacity) === 0) return false;
25
+ const clip = style.clip?.match(/^rect\((.*)\)$/)?.[1].split(/[,\s]+/).filter(Boolean).map(Number.parseFloat);
26
+ if (clip?.length === 4 && clip.every(Number.isFinite) && (clip[2] <= clip[0] || clip[1] <= clip[3])) return false;
27
+ if (style.clipPath === 'inset(50%)') return false;
28
+ }
29
+ if (pending) return true;
30
+ const nativeProgress = node.matches('progress');
31
+ if (nativeProgress || node.matches('[role~="progressbar"]')) {
32
+ const current = nativeProgress
33
+ ? (node.hasAttribute('value') ? node.value : NaN)
34
+ : Number.parseFloat(node.getAttribute('aria-valuenow'));
35
+ const maximum = nativeProgress ? node.max : Number.parseFloat(node.getAttribute('aria-valuemax') ?? '100');
36
+ return !Number.isFinite(current) || !Number.isFinite(maximum) || current < maximum;
37
+ }
38
+ return false;
39
+ });
40
+ })()`;
41
+ }
@@ -0,0 +1,130 @@
1
+ import { withoutBrowserCancellation } from "../cancellation.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import { INPUT_SELECTORS } from "../constants.js";
4
+ import { delay } from "../utils.js";
5
+ import { BrowserAutomationError } from "../../oracle/errors.js";
6
+ import { assertComposerNavigationSnapshot, buildComposerNavigationValidationExpression, } from "./attachmentContext.js";
7
+ import { buildClickDispatcher } from "./domEvents.js";
8
+ /** Bind editing commands and fallback writes to the original renderer and editor. */
9
+ export async function stageAttachmentPrompt(runtime, prompt, navigationUrl) {
10
+ const id = randomUUID();
11
+ const requireSafe = (value) => {
12
+ if (value?.blocked)
13
+ assertComposerNavigationSnapshot(navigationUrl, value.blocked);
14
+ if (!value?.ready || value.blocked) {
15
+ throw new BrowserAutomationError("Attachment prompt focus or context changed; staging was stopped.", {
16
+ stage: "submit-prompt",
17
+ code: "attachment-prompt-not-ready",
18
+ });
19
+ }
20
+ };
21
+ try {
22
+ const prepared = await runtime.evaluate({
23
+ expression: `(() => {
24
+ ${buildClickDispatcher()}
25
+ const selectors = ${JSON.stringify(INPUT_SELECTORS)};
26
+ const visible = node => { const r = node?.getBoundingClientRect(); return r && r.width > 0 && r.height > 0; };
27
+ const resolveEditor = () => selectors.map(s => document.querySelector(s)).find(visible);
28
+ const editor = resolveEditor();
29
+ const read = () => editor instanceof HTMLTextAreaElement ? editor.value : editor?.innerText ?? '';
30
+ const write = value => { if (editor instanceof HTMLTextAreaElement) editor.value = value; else if (editor) editor.textContent = value; };
31
+ const guard = { blocked: null, beforeValue: '', sawInput: false };
32
+ const validate = (requireFocus = true) => {
33
+ try {
34
+ const state = ${buildComposerNavigationValidationExpression(navigationUrl)};
35
+ const focused = editor instanceof HTMLElement && editor.isConnected && resolveEditor() === editor &&
36
+ (!requireFocus || document.activeElement === editor || editor.contains(document.activeElement));
37
+ if (!state.contextMatches || !focused) guard.blocked = { ...state, focused };
38
+ } catch { guard.blocked = { currentUrl: location.href, modeUnverified: true }; }
39
+ return !guard.blocked;
40
+ };
41
+ const cancel = event => { event.preventDefault(); event.stopImmediatePropagation(); };
42
+ const onBeforeInput = event => {
43
+ if (!validate() || event.target !== editor) { guard.blocked ??= { currentUrl: location.href, focused: false }; cancel(event); return; }
44
+ };
45
+ const onInput = event => {
46
+ if (!validate() || event.target !== editor) {
47
+ if (event.target === editor) write(guard.beforeValue);
48
+ guard.blocked ??= { currentUrl: location.href, focused: false };
49
+ cancel(event); return;
50
+ }
51
+ guard.sawInput = true;
52
+ };
53
+ guard.cleanup = () => { window.removeEventListener('beforeinput', onBeforeInput, true); window.removeEventListener('input', onInput, true); };
54
+ guard.summary = () => {
55
+ const ready = validate();
56
+ if (!ready && guard.sawInput) write(guard.beforeValue);
57
+ return { ready, blocked: guard.blocked, length: read().length, sawInput: guard.sawInput };
58
+ };
59
+ guard.insert = text => {
60
+ if (!validate()) return;
61
+ guard.beforeValue = read();
62
+ const before = new InputEvent('beforeinput', { bubbles: true, cancelable: true, data: text, inputType: 'insertFromPaste' });
63
+ if (!editor.dispatchEvent(before) || !validate()) return;
64
+ try { document.execCommand('insertText', false, text); } catch {}
65
+ };
66
+ guard.fallback = text => {
67
+ if (!validate() || read().trim()) return;
68
+ const before = new InputEvent('beforeinput', { bubbles: true, cancelable: true, data: text, inputType: 'insertFromPaste' });
69
+ if (!editor.dispatchEvent(before) || !validate()) return;
70
+ write(text);
71
+ editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: text, inputType: 'insertFromPaste' }));
72
+ };
73
+ (window.__oracleAttachmentPromptGuards ??= Object.create(null))[${JSON.stringify(id)}] = guard;
74
+ if (!validate(false)) return { ready: false, blocked: guard.blocked };
75
+ guard.beforeValue = read();
76
+ window.addEventListener('beforeinput', onBeforeInput, true);
77
+ window.addEventListener('input', onInput, true);
78
+ dispatchClickSequence(editor);
79
+ if (!validate(false)) return { ready: false, blocked: guard.blocked };
80
+ editor.focus();
81
+ if (!validate()) return { ready: false, blocked: guard.blocked };
82
+ const selection = document.getSelection();
83
+ if (selection && !(editor instanceof HTMLTextAreaElement)) {
84
+ const range = document.createRange(); range.selectNodeContents(editor); range.collapse(false);
85
+ selection.removeAllRanges(); selection.addRange(range);
86
+ }
87
+ return guard.summary();
88
+ })()`,
89
+ returnByValue: true,
90
+ });
91
+ requireSafe(prepared.result?.value);
92
+ // A renderer command cannot land in a replacement document after navigation.
93
+ const inserted = await runtime.evaluate({
94
+ expression: `(() => {
95
+ const guard = window.__oracleAttachmentPromptGuards?.[${JSON.stringify(id)}];
96
+ if (!guard) return { ready: false };
97
+ guard.insert(${JSON.stringify(prompt)});
98
+ return guard.summary();
99
+ })()`,
100
+ returnByValue: true,
101
+ });
102
+ requireSafe(inserted.result?.value);
103
+ await delay(500);
104
+ const staged = await runtime.evaluate({
105
+ expression: `(() => {
106
+ const guard = window.__oracleAttachmentPromptGuards?.[${JSON.stringify(id)}];
107
+ if (!guard) return { ready: false };
108
+ const before = guard.summary();
109
+ if (before.ready && !before.length) guard.fallback(${JSON.stringify(prompt)});
110
+ return guard.summary();
111
+ })()`,
112
+ returnByValue: true,
113
+ });
114
+ const value = staged.result?.value;
115
+ requireSafe(value);
116
+ if (!value?.sawInput)
117
+ throw new BrowserAutomationError("Attachment prompt input was not observed; do not retry automatically.", {
118
+ stage: "submit-prompt",
119
+ code: "attachment-prompt-ambiguous",
120
+ });
121
+ return value.length ?? 0;
122
+ }
123
+ finally {
124
+ await withoutBrowserCancellation(() => runtime
125
+ .evaluate({
126
+ expression: `(() => { const guards = window.__oracleAttachmentPromptGuards; const guard = guards?.[${JSON.stringify(id)}]; guard?.cleanup(); if (guards) delete guards[${JSON.stringify(id)}]; })()`,
127
+ })
128
+ .catch(() => undefined));
129
+ }
130
+ }