@steipete/oracle 0.18.0 → 0.20.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 (169) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +76 -76
  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 +170 -45
  13. package/dist/src/browser/actions/modelSelection.js +91 -4
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +364 -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 +156 -16
  19. package/dist/src/browser/actions/webSearch.js +96 -0
  20. package/dist/src/browser/attachRunning.js +46 -2
  21. package/dist/src/browser/attachmentValidation.js +53 -0
  22. package/dist/src/browser/cancellation.js +105 -0
  23. package/dist/src/browser/chatgptImages.js +2 -0
  24. package/dist/src/browser/chatgptThrottle.js +104 -0
  25. package/dist/src/browser/chromeLifecycle.js +272 -47
  26. package/dist/src/browser/config.js +14 -1
  27. package/dist/src/browser/detect.js +1 -1
  28. package/dist/src/browser/index.js +525 -274
  29. package/dist/src/browser/liveTabs.js +3 -4
  30. package/dist/src/browser/modelDisplay.js +9 -0
  31. package/dist/src/browser/pageActions.js +2 -2
  32. package/dist/src/browser/profileState.js +114 -9
  33. package/dist/src/browser/projectSourcesRunner.js +83 -15
  34. package/dist/src/browser/prompt.js +272 -113
  35. package/dist/src/browser/providers/chatgptDomProvider.js +3 -0
  36. package/dist/src/browser/reattach.js +31 -4
  37. package/dist/src/browser/recoveryTarget.js +131 -0
  38. package/dist/src/browser/sessionRunner.js +73 -11
  39. package/dist/src/browser/tabLeaseRegistry.js +434 -38
  40. package/dist/src/browser/targetClaim.js +54 -0
  41. package/dist/src/browser/utils.js +20 -2
  42. package/dist/src/cli/browserConfig.js +36 -4
  43. package/dist/src/cli/browserDefaults.js +10 -0
  44. package/dist/src/cli/browserTabs.js +15 -29
  45. package/dist/src/cli/detachedSession.js +69 -0
  46. package/dist/src/cli/dryRun.js +58 -48
  47. package/dist/src/cli/harvestIntegrity.js +140 -0
  48. package/dist/src/cli/options.js +15 -0
  49. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  50. package/dist/src/cli/runOptions.js +19 -4
  51. package/dist/src/cli/serveBrowserConfig.js +24 -0
  52. package/dist/src/cli/sessionDisplay.js +13 -7
  53. package/dist/src/cli/sessionRunner.js +95 -12
  54. package/dist/src/config.js +1 -0
  55. package/dist/src/gemini-web/client.js +67 -21
  56. package/dist/src/gemini-web/executor.js +4 -0
  57. package/dist/src/gemini-web/http.js +15 -0
  58. package/dist/src/gemini-web/models.js +15 -11
  59. package/dist/src/mcp/server.js +11 -14
  60. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  61. package/dist/src/mcp/tools/consult.js +86 -24
  62. package/dist/src/mcp/tools/projectSources.js +4 -6
  63. package/dist/src/mcp/tools/sessionResources.js +1 -1
  64. package/dist/src/mcp/tools/sessions.js +2 -2
  65. package/dist/src/mcp/tools/wait.js +182 -0
  66. package/dist/src/mcp/types.js +8 -1
  67. package/dist/src/oracle/config.js +14 -0
  68. package/dist/src/oracle/errors.js +11 -0
  69. package/dist/src/oracle/geminiModels.js +1 -0
  70. package/dist/src/oracle/providerRoutePlan.js +3 -2
  71. package/dist/src/oracle/run.js +27 -4
  72. package/dist/src/remote/client.js +73 -6
  73. package/dist/src/remote/health.js +6 -2
  74. package/dist/src/remote/runSlots.js +83 -0
  75. package/dist/src/remote/server.js +346 -71
  76. package/dist/src/sessionManager.js +36 -1
  77. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  78. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  79. package/package.json +25 -23
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  81. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  82. package/dist/bin/oracle.js +0 -683
  83. package/dist/docs-site/.nojekyll +0 -0
  84. package/dist/docs-site/CNAME +0 -1
  85. package/dist/docs-site/RELEASING.html +0 -410
  86. package/dist/docs-site/agents.html +0 -374
  87. package/dist/docs-site/anthropic.html +0 -368
  88. package/dist/docs-site/bridge.html +0 -400
  89. package/dist/docs-site/browser-mode.html +0 -588
  90. package/dist/docs-site/chromium-forks.html +0 -347
  91. package/dist/docs-site/cli-reference.html +0 -346
  92. package/dist/docs-site/configuration.html +0 -436
  93. package/dist/docs-site/favicon.svg +0 -14
  94. package/dist/docs-site/followup.html +0 -368
  95. package/dist/docs-site/gemini.html +0 -379
  96. package/dist/docs-site/grok.html +0 -325
  97. package/dist/docs-site/index.html +0 -360
  98. package/dist/docs-site/install.html +0 -335
  99. package/dist/docs-site/linux.html +0 -321
  100. package/dist/docs-site/llms.txt +0 -43
  101. package/dist/docs-site/manual-tests.html +0 -595
  102. package/dist/docs-site/mcp.html +0 -368
  103. package/dist/docs-site/multimodel.html +0 -364
  104. package/dist/docs-site/mythical-pro-agents.html +0 -360
  105. package/dist/docs-site/notifier.html +0 -338
  106. package/dist/docs-site/openai-endpoints.html +0 -387
  107. package/dist/docs-site/openrouter.html +0 -344
  108. package/dist/docs-site/quickstart.html +0 -369
  109. package/dist/docs-site/refactor/ux.html +0 -532
  110. package/dist/docs-site/sessions.html +0 -388
  111. package/dist/docs-site/social-card.png +0 -0
  112. package/dist/docs-site/social-card.svg +0 -79
  113. package/dist/docs-site/spec.html +0 -363
  114. package/dist/docs-site/testing.html +0 -320
  115. package/dist/docs-site/tui-debug.html +0 -326
  116. package/dist/docs-site/windows-work.html +0 -323
  117. package/dist/docs-site/windows.html +0 -320
  118. package/dist/markdansi/types/index.js +0 -4
  119. package/dist/oracle/bin/oracle-cli.js +0 -472
  120. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  121. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  122. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  123. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  124. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  125. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  126. package/dist/oracle/src/browser/config.js +0 -33
  127. package/dist/oracle/src/browser/constants.js +0 -40
  128. package/dist/oracle/src/browser/cookies.js +0 -210
  129. package/dist/oracle/src/browser/domDebug.js +0 -36
  130. package/dist/oracle/src/browser/index.js +0 -331
  131. package/dist/oracle/src/browser/pageActions.js +0 -5
  132. package/dist/oracle/src/browser/prompt.js +0 -88
  133. package/dist/oracle/src/browser/promptSummary.js +0 -20
  134. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  135. package/dist/oracle/src/browser/types.js +0 -1
  136. package/dist/oracle/src/browser/utils.js +0 -62
  137. package/dist/oracle/src/browserMode.js +0 -1
  138. package/dist/oracle/src/cli/browserConfig.js +0 -44
  139. package/dist/oracle/src/cli/dryRun.js +0 -59
  140. package/dist/oracle/src/cli/engine.js +0 -17
  141. package/dist/oracle/src/cli/errorUtils.js +0 -9
  142. package/dist/oracle/src/cli/help.js +0 -70
  143. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  144. package/dist/oracle/src/cli/options.js +0 -103
  145. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  146. package/dist/oracle/src/cli/rootAlias.js +0 -30
  147. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  148. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  149. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  150. package/dist/oracle/src/heartbeat.js +0 -43
  151. package/dist/oracle/src/oracle/client.js +0 -48
  152. package/dist/oracle/src/oracle/config.js +0 -29
  153. package/dist/oracle/src/oracle/errors.js +0 -101
  154. package/dist/oracle/src/oracle/files.js +0 -220
  155. package/dist/oracle/src/oracle/format.js +0 -33
  156. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  157. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  158. package/dist/oracle/src/oracle/request.js +0 -48
  159. package/dist/oracle/src/oracle/run.js +0 -444
  160. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  161. package/dist/oracle/src/oracle/types.js +0 -1
  162. package/dist/oracle/src/oracle.js +0 -9
  163. package/dist/oracle/src/sessionManager.js +0 -205
  164. package/dist/oracle/src/version.js +0 -39
  165. package/dist/scripts/chrome/browser-tools.js +0 -295
  166. package/dist/src/browser/chromeCookies.js +0 -312
  167. package/dist/src/browser/keytarShim.js +0 -56
  168. package/dist/src/browser/profileSync.js +0 -141
  169. 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
+ }