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