@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.
- package/README.md +1 -1
- package/dist/bin/oracle-cli.js +75 -64
- package/dist/bin/oracle-mcp.js +0 -0
- package/dist/src/browser/actions/archiveConversation.js +31 -7
- package/dist/src/browser/actions/assistantResponse.js +43 -1
- package/dist/src/browser/actions/attachmentContext.js +239 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
- package/dist/src/browser/actions/attachmentEvidence.js +78 -0
- package/dist/src/browser/actions/attachmentProgress.js +41 -0
- package/dist/src/browser/actions/attachmentPrompt.js +130 -0
- package/dist/src/browser/actions/attachments.js +306 -355
- package/dist/src/browser/actions/deepResearch.js +2 -1
- package/dist/src/browser/actions/modelSelection.js +13 -3
- package/dist/src/browser/actions/navigation.js +1 -1
- package/dist/src/browser/actions/promptComposer.js +361 -186
- package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
- package/dist/src/browser/actions/thinkingStatus.js +2 -5
- package/dist/src/browser/actions/thinkingTime.js +267 -15
- package/dist/src/browser/attachRunning.js +46 -2
- package/dist/src/browser/attachmentValidation.js +53 -0
- package/dist/src/browser/cancellation.js +105 -0
- package/dist/src/browser/chatgptImages.js +2 -0
- package/dist/src/browser/chatgptThrottle.js +104 -0
- package/dist/src/browser/chromeLifecycle.js +267 -43
- package/dist/src/browser/config.js +15 -3
- package/dist/src/browser/detect.js +1 -1
- package/dist/src/browser/index.js +447 -263
- package/dist/src/browser/modelDisplay.js +9 -0
- package/dist/src/browser/pageActions.js +2 -2
- package/dist/src/browser/policies.js +6 -2
- package/dist/src/browser/profileState.js +114 -9
- package/dist/src/browser/projectSourcesRunner.js +88 -17
- package/dist/src/browser/prompt.js +272 -113
- package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
- package/dist/src/browser/reattach.js +14 -4
- package/dist/src/browser/sessionRunner.js +71 -11
- package/dist/src/browser/tabLeaseRegistry.js +414 -38
- package/dist/src/browser/utils.js +20 -2
- package/dist/src/cli/browserConfig.js +40 -15
- package/dist/src/cli/browserDefaults.js +13 -0
- package/dist/src/cli/browserTabs.js +12 -29
- package/dist/src/cli/detachedSession.js +69 -0
- package/dist/src/cli/dryRun.js +58 -47
- package/dist/src/cli/harvestIntegrity.js +140 -0
- package/dist/src/cli/projectSources.js +5 -3
- package/dist/src/cli/serveBrowserConfig.js +24 -0
- package/dist/src/cli/sessionDisplay.js +9 -2
- package/dist/src/cli/sessionRunner.js +91 -7
- package/dist/src/config.js +1 -0
- package/dist/src/gemini-web/client.js +67 -21
- package/dist/src/gemini-web/executor.js +4 -0
- package/dist/src/gemini-web/http.js +15 -0
- package/dist/src/gemini-web/models.js +15 -11
- package/dist/src/mcp/server.js +11 -14
- package/dist/src/mcp/tools/chatgptImage.js +4 -4
- package/dist/src/mcp/tools/consult.js +97 -26
- package/dist/src/mcp/tools/projectSources.js +4 -6
- package/dist/src/mcp/tools/sessionResources.js +1 -1
- package/dist/src/mcp/tools/sessions.js +2 -2
- package/dist/src/mcp/tools/wait.js +182 -0
- package/dist/src/mcp/types.js +7 -0
- package/dist/src/oracle/errors.js +11 -0
- package/dist/src/oracle/providerRoutePlan.js +3 -2
- package/dist/src/remote/client.js +70 -6
- package/dist/src/remote/health.js +6 -2
- package/dist/src/remote/runSlots.js +83 -0
- package/dist/src/remote/server.js +351 -72
- package/dist/src/sessionManager.js +36 -1
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +25 -23
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/bin/oracle.js +0 -683
- package/dist/docs-site/.nojekyll +0 -0
- package/dist/docs-site/CNAME +0 -1
- package/dist/docs-site/RELEASING.html +0 -410
- package/dist/docs-site/agents.html +0 -374
- package/dist/docs-site/anthropic.html +0 -368
- package/dist/docs-site/bridge.html +0 -400
- package/dist/docs-site/browser-mode.html +0 -588
- package/dist/docs-site/chromium-forks.html +0 -347
- package/dist/docs-site/cli-reference.html +0 -346
- package/dist/docs-site/configuration.html +0 -436
- package/dist/docs-site/favicon.svg +0 -14
- package/dist/docs-site/followup.html +0 -368
- package/dist/docs-site/gemini.html +0 -379
- package/dist/docs-site/grok.html +0 -325
- package/dist/docs-site/index.html +0 -360
- package/dist/docs-site/install.html +0 -335
- package/dist/docs-site/linux.html +0 -321
- package/dist/docs-site/llms.txt +0 -43
- package/dist/docs-site/manual-tests.html +0 -595
- package/dist/docs-site/mcp.html +0 -368
- package/dist/docs-site/multimodel.html +0 -364
- package/dist/docs-site/mythical-pro-agents.html +0 -360
- package/dist/docs-site/notifier.html +0 -338
- package/dist/docs-site/openai-endpoints.html +0 -387
- package/dist/docs-site/openrouter.html +0 -344
- package/dist/docs-site/quickstart.html +0 -369
- package/dist/docs-site/refactor/ux.html +0 -532
- package/dist/docs-site/sessions.html +0 -388
- package/dist/docs-site/social-card.png +0 -0
- package/dist/docs-site/social-card.svg +0 -79
- package/dist/docs-site/spec.html +0 -363
- package/dist/docs-site/testing.html +0 -320
- package/dist/docs-site/tui-debug.html +0 -326
- package/dist/docs-site/windows-work.html +0 -323
- package/dist/docs-site/windows.html +0 -320
- package/dist/markdansi/types/index.js +0 -4
- package/dist/oracle/bin/oracle-cli.js +0 -472
- package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
- package/dist/oracle/src/browser/actions/attachments.js +0 -82
- package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
- package/dist/oracle/src/browser/actions/navigation.js +0 -75
- package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
- package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
- package/dist/oracle/src/browser/config.js +0 -33
- package/dist/oracle/src/browser/constants.js +0 -40
- package/dist/oracle/src/browser/cookies.js +0 -210
- package/dist/oracle/src/browser/domDebug.js +0 -36
- package/dist/oracle/src/browser/index.js +0 -331
- package/dist/oracle/src/browser/pageActions.js +0 -5
- package/dist/oracle/src/browser/prompt.js +0 -88
- package/dist/oracle/src/browser/promptSummary.js +0 -20
- package/dist/oracle/src/browser/sessionRunner.js +0 -80
- package/dist/oracle/src/browser/types.js +0 -1
- package/dist/oracle/src/browser/utils.js +0 -62
- package/dist/oracle/src/browserMode.js +0 -1
- package/dist/oracle/src/cli/browserConfig.js +0 -44
- package/dist/oracle/src/cli/dryRun.js +0 -59
- package/dist/oracle/src/cli/engine.js +0 -17
- package/dist/oracle/src/cli/errorUtils.js +0 -9
- package/dist/oracle/src/cli/help.js +0 -70
- package/dist/oracle/src/cli/markdownRenderer.js +0 -15
- package/dist/oracle/src/cli/options.js +0 -103
- package/dist/oracle/src/cli/promptRequirement.js +0 -14
- package/dist/oracle/src/cli/rootAlias.js +0 -30
- package/dist/oracle/src/cli/sessionCommand.js +0 -77
- package/dist/oracle/src/cli/sessionDisplay.js +0 -270
- package/dist/oracle/src/cli/sessionRunner.js +0 -94
- package/dist/oracle/src/heartbeat.js +0 -43
- package/dist/oracle/src/oracle/client.js +0 -48
- package/dist/oracle/src/oracle/config.js +0 -29
- package/dist/oracle/src/oracle/errors.js +0 -101
- package/dist/oracle/src/oracle/files.js +0 -220
- package/dist/oracle/src/oracle/format.js +0 -33
- package/dist/oracle/src/oracle/fsAdapter.js +0 -7
- package/dist/oracle/src/oracle/oscProgress.js +0 -60
- package/dist/oracle/src/oracle/request.js +0 -48
- package/dist/oracle/src/oracle/run.js +0 -444
- package/dist/oracle/src/oracle/tokenStats.js +0 -39
- package/dist/oracle/src/oracle/types.js +0 -1
- package/dist/oracle/src/oracle.js +0 -9
- package/dist/oracle/src/sessionManager.js +0 -205
- package/dist/oracle/src/version.js +0 -39
- package/dist/scripts/chrome/browser-tools.js +0 -295
- package/dist/src/browser/chromeCookies.js +0 -312
- package/dist/src/browser/keytarShim.js +0 -56
- package/dist/src/browser/profileSync.js +0 -141
- 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
|
+
}
|