codex-chatgpt-control 0.5.1-alpha.1 → 0.5.1-alpha.2
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/CHANGELOG.md +12 -0
- package/contracts/v1/fixtures/backend-capabilities.json +1 -0
- package/contracts/v1/fixtures/command-descriptors.json +21 -0
- package/contracts/v1/fixtures/doctor-bridge-upload.json +2 -2
- package/contracts/v1/fixtures/doctor-scenario-preflight.json +26 -4
- package/contracts/v1/fixtures/files-attach-indeterminate.json +30 -0
- package/contracts/v1/fixtures/help-root.json +1 -1
- package/contracts/v1/fixtures/messages-stop-indeterminate.json +31 -0
- package/contracts/v1/fixtures/messages-stop-needs-confirmation.json +20 -0
- package/contracts/v1/fixtures/messages-stop-noop.json +19 -0
- package/contracts/v1/fixtures/messages-stop-success.json +21 -0
- package/contracts/v1/fixtures/primitive-bootstrap-blocker.json +3 -3
- package/contracts/v1/fixtures/reports-create-redacted.json +3 -3
- package/contracts/v1/fixtures/reports-summarize-redacted.json +1 -1
- package/contracts/v1/fixtures/run-browser-bridge-blocker.json +12 -12
- package/contracts/v1/fixtures/stream-blocked.ndjson +2 -2
- package/contracts/v1/manifest.json +25 -0
- package/contracts/v1/parity-suite.json +8 -1
- package/dist/codex-chatgpt-control-backend.mjs +2259 -498
- package/dist/codex-chatgpt-control.bundle.mjs +2263 -472
- package/dist/src/backend/client.d.ts +2 -1
- package/dist/src/backend/client.js +1 -0
- package/dist/src/backend/protocol.d.ts +1 -1
- package/dist/src/backend/protocol.js +1 -0
- package/dist/src/backend/session.js +2 -0
- package/dist/src/browser/attach.d.ts +1 -0
- package/dist/src/browser/attach.js +69 -35
- package/dist/src/browser/chatgpt-url.d.ts +8 -0
- package/dist/src/browser/chatgpt-url.js +28 -0
- package/dist/src/browser/page-state.js +211 -4
- package/dist/src/client.d.ts +2 -1
- package/dist/src/client.js +2 -1
- package/dist/src/commands/configuration.js +46 -12
- package/dist/src/commands/context.d.ts +5 -1
- package/dist/src/commands/context.js +2 -2
- package/dist/src/commands/conversation.js +13 -1
- package/dist/src/commands/doctor.js +24 -2
- package/dist/src/commands/files.d.ts +1 -0
- package/dist/src/commands/files.js +742 -191
- package/dist/src/commands/messages.d.ts +2 -1
- package/dist/src/commands/messages.js +310 -2
- package/dist/src/commands/modes.js +197 -52
- package/dist/src/commands/registry.js +3 -0
- package/dist/src/commands/sequence.js +3 -1
- package/dist/src/commands/session.d.ts +4 -1
- package/dist/src/commands/session.js +34 -3
- package/dist/src/dom/generation-state.d.ts +6 -1
- package/dist/src/dom/generation-state.js +206 -45
- package/dist/src/dom/locale/am.d.ts +10 -1
- package/dist/src/dom/locale/am.js +10 -1
- package/dist/src/dom/locale/ar.d.ts +8 -1
- package/dist/src/dom/locale/ar.js +9 -2
- package/dist/src/dom/locale/bg.d.ts +6 -1
- package/dist/src/dom/locale/bg.js +6 -1
- package/dist/src/dom/locale/bn.d.ts +10 -1
- package/dist/src/dom/locale/bn.js +10 -1
- package/dist/src/dom/locale/bs.d.ts +5 -1
- package/dist/src/dom/locale/bs.js +5 -1
- package/dist/src/dom/locale/ca.d.ts +8 -1
- package/dist/src/dom/locale/ca.js +8 -1
- package/dist/src/dom/locale/cs.d.ts +5 -1
- package/dist/src/dom/locale/cs.js +6 -2
- package/dist/src/dom/locale/da.d.ts +5 -1
- package/dist/src/dom/locale/da.js +6 -2
- package/dist/src/dom/locale/de.d.ts +4 -1
- package/dist/src/dom/locale/de.js +5 -2
- package/dist/src/dom/locale/el.d.ts +5 -1
- package/dist/src/dom/locale/el.js +6 -2
- package/dist/src/dom/locale/en.d.ts +2 -1
- package/dist/src/dom/locale/en.js +2 -1
- package/dist/src/dom/locale/es-419.d.ts +5 -1
- package/dist/src/dom/locale/es-419.js +5 -1
- package/dist/src/dom/locale/es-ES.d.ts +5 -1
- package/dist/src/dom/locale/es-ES.js +5 -1
- package/dist/src/dom/locale/et.d.ts +4 -1
- package/dist/src/dom/locale/et.js +5 -2
- package/dist/src/dom/locale/fa.d.ts +9 -1
- package/dist/src/dom/locale/fa.js +10 -2
- package/dist/src/dom/locale/fi.d.ts +5 -1
- package/dist/src/dom/locale/fi.js +6 -2
- package/dist/src/dom/locale/fr-CA.d.ts +4 -1
- package/dist/src/dom/locale/fr-CA.js +4 -1
- package/dist/src/dom/locale/fr-FR.d.ts +5 -1
- package/dist/src/dom/locale/fr-FR.js +7 -3
- package/dist/src/dom/locale/gu.d.ts +10 -1
- package/dist/src/dom/locale/gu.js +10 -1
- package/dist/src/dom/locale/hi.d.ts +10 -1
- package/dist/src/dom/locale/hi.js +11 -2
- package/dist/src/dom/locale/hr.d.ts +4 -1
- package/dist/src/dom/locale/hr.js +4 -1
- package/dist/src/dom/locale/hu.d.ts +4 -1
- package/dist/src/dom/locale/hu.js +5 -2
- package/dist/src/dom/locale/hy.d.ts +10 -1
- package/dist/src/dom/locale/hy.js +11 -2
- package/dist/src/dom/locale/id.d.ts +5 -1
- package/dist/src/dom/locale/id.js +5 -1
- package/dist/src/dom/locale/index.js +1 -0
- package/dist/src/dom/locale/is.d.ts +9 -1
- package/dist/src/dom/locale/is.js +10 -2
- package/dist/src/dom/locale/it.d.ts +4 -1
- package/dist/src/dom/locale/it.js +6 -3
- package/dist/src/dom/locale/ja.d.ts +6 -1
- package/dist/src/dom/locale/ja.js +7 -2
- package/dist/src/dom/locale/ka.d.ts +10 -1
- package/dist/src/dom/locale/ka.js +11 -2
- package/dist/src/dom/locale/kk.d.ts +10 -1
- package/dist/src/dom/locale/kk.js +11 -2
- package/dist/src/dom/locale/kn.d.ts +9 -1
- package/dist/src/dom/locale/kn.js +10 -2
- package/dist/src/dom/locale/ko.d.ts +6 -1
- package/dist/src/dom/locale/ko.js +7 -2
- package/dist/src/dom/locale/lt.d.ts +5 -1
- package/dist/src/dom/locale/lt.js +6 -2
- package/dist/src/dom/locale/lv.d.ts +5 -1
- package/dist/src/dom/locale/lv.js +5 -1
- package/dist/src/dom/locale/mk.d.ts +9 -1
- package/dist/src/dom/locale/mk.js +10 -2
- package/dist/src/dom/locale/ml.d.ts +10 -1
- package/dist/src/dom/locale/ml.js +10 -1
- package/dist/src/dom/locale/mn.d.ts +10 -1
- package/dist/src/dom/locale/mn.js +13 -4
- package/dist/src/dom/locale/mr.d.ts +10 -1
- package/dist/src/dom/locale/mr.js +10 -1
- package/dist/src/dom/locale/ms.d.ts +9 -1
- package/dist/src/dom/locale/ms.js +9 -1
- package/dist/src/dom/locale/my.d.ts +9 -1
- package/dist/src/dom/locale/my.js +9 -1
- package/dist/src/dom/locale/nb.d.ts +5 -1
- package/dist/src/dom/locale/nb.js +6 -2
- package/dist/src/dom/locale/nl.d.ts +8 -2
- package/dist/src/dom/locale/nl.js +8 -2
- package/dist/src/dom/locale/pa.d.ts +10 -1
- package/dist/src/dom/locale/pa.js +10 -1
- package/dist/src/dom/locale/pl.d.ts +5 -1
- package/dist/src/dom/locale/pl.js +6 -2
- package/dist/src/dom/locale/pt-BR.d.ts +5 -1
- package/dist/src/dom/locale/pt-BR.js +6 -2
- package/dist/src/dom/locale/pt-PT.d.ts +5 -1
- package/dist/src/dom/locale/pt-PT.js +5 -1
- package/dist/src/dom/locale/ro.d.ts +4 -1
- package/dist/src/dom/locale/ro.js +4 -1
- package/dist/src/dom/locale/ru.d.ts +8 -1
- package/dist/src/dom/locale/ru.js +9 -2
- package/dist/src/dom/locale/sk.d.ts +5 -1
- package/dist/src/dom/locale/sk.js +5 -1
- package/dist/src/dom/locale/sl.d.ts +9 -1
- package/dist/src/dom/locale/sl.js +9 -1
- package/dist/src/dom/locale/so.d.ts +9 -1
- package/dist/src/dom/locale/so.js +9 -1
- package/dist/src/dom/locale/sq.d.ts +10 -1
- package/dist/src/dom/locale/sq.js +12 -3
- package/dist/src/dom/locale/sr.d.ts +9 -1
- package/dist/src/dom/locale/sr.js +10 -2
- package/dist/src/dom/locale/sv.d.ts +4 -1
- package/dist/src/dom/locale/sv.js +4 -1
- package/dist/src/dom/locale/sw.d.ts +5 -1
- package/dist/src/dom/locale/sw.js +7 -3
- package/dist/src/dom/locale/ta.d.ts +10 -1
- package/dist/src/dom/locale/ta.js +12 -3
- package/dist/src/dom/locale/te.d.ts +10 -1
- package/dist/src/dom/locale/te.js +11 -2
- package/dist/src/dom/locale/th.d.ts +6 -1
- package/dist/src/dom/locale/th.js +6 -1
- package/dist/src/dom/locale/tl.d.ts +1 -1
- package/dist/src/dom/locale/tl.js +2 -2
- package/dist/src/dom/locale/tr.d.ts +9 -1
- package/dist/src/dom/locale/tr.js +10 -2
- package/dist/src/dom/locale/types.d.ts +1 -1
- package/dist/src/dom/locale/uk.d.ts +10 -1
- package/dist/src/dom/locale/uk.js +11 -2
- package/dist/src/dom/locale/ur.d.ts +9 -1
- package/dist/src/dom/locale/ur.js +9 -1
- package/dist/src/dom/locale/vi.d.ts +9 -1
- package/dist/src/dom/locale/vi.js +10 -2
- package/dist/src/dom/locale/zh-HK.d.ts +8 -1
- package/dist/src/dom/locale/zh-HK.js +9 -2
- package/dist/src/dom/locale/zh-Hans.d.ts +10 -1
- package/dist/src/dom/locale/zh-Hans.js +11 -2
- package/dist/src/dom/locale/zh-TW.d.ts +10 -1
- package/dist/src/dom/locale/zh-TW.js +11 -2
- package/dist/src/dom/selectors.d.ts +1 -0
- package/dist/src/dom/selectors.js +9 -1
- package/dist/src/dom/wait-snapshot.d.ts +1 -0
- package/dist/src/dom/wait-snapshot.js +45 -22
- package/dist/src/errors.d.ts +1 -1
- package/dist/src/errors.js +2 -2
- package/dist/src/safety/risk.d.ts +1 -0
- package/dist/src/safety/risk.js +1 -0
- package/dist/src/scripts/apply-intelligence-locale-captures.d.ts +2 -2
- package/dist/src/scripts/apply-intelligence-locale-captures.js +54 -14
- package/dist/src/scripts/capture-intelligence-locales.d.ts +114 -1
- package/dist/src/scripts/capture-intelligence-locales.js +437 -304
- package/dist/src/scripts/live-smoke/scenarios.d.ts +2 -1
- package/dist/src/scripts/live-smoke/scenarios.js +9 -10
- package/dist/src/scripts/locale-capture/surface-graph.d.ts +7 -2
- package/dist/src/scripts/locale-capture/surface-graph.js +7 -0
- package/dist/src/types.d.ts +27 -6
- package/package.json +2 -1
- package/references/2026-08-08-model-selection-locale-sweep.md +32 -0
- package/references/backend-protocol.md +11 -3
- package/references/language-coverage.md +14 -14
- package/references/localization.md +17 -13
- package/references/python-parity.md +8 -2
- package/references/troubleshooting.md +10 -2
|
@@ -6,9 +6,10 @@ import { downloadLatestArtifact, locatorCountWithTimeout } from "./artifacts.js"
|
|
|
6
6
|
import { waitForDownloadFromClick } from "../browser/downloads.js";
|
|
7
7
|
import { resultError, resultOk } from "../errors.js";
|
|
8
8
|
import { addFilesButton, cssSelectors, requiredLocator } from "../dom/selectors.js";
|
|
9
|
-
import { localeLabels } from "../dom/locale-labels.js";
|
|
9
|
+
import { escapeRegExp, localeLabels } from "../dom/locale-labels.js";
|
|
10
10
|
import { basenameForHostPath, currentHostPathPlatform, isHostAbsolutePath, resolveForHostPath } from "../platform/local-paths.js";
|
|
11
11
|
import { contextFromPage } from "./context.js";
|
|
12
|
+
import { createDeadline, remainingMs } from "./deadline.js";
|
|
12
13
|
import { ensurePage } from "./session.js";
|
|
13
14
|
import { localGuardTimeout, withTimeout } from "./timeouts.js";
|
|
14
15
|
const CODEX_UPLOAD_PERMISSION_FIX = "Codex Settings > Computer Use > Chrome > Permissions > Uploads: set to Always allow, or add chatgpt.com to the allowed upload domains.";
|
|
@@ -132,6 +133,7 @@ export async function preflightFiles(env, args) {
|
|
|
132
133
|
return resultOk({ files, totalBytes }, filePreflightContext(env), warnings);
|
|
133
134
|
}
|
|
134
135
|
export async function attachFiles(env, args) {
|
|
136
|
+
const deadline = createDeadline(Math.max(1, args.timeoutMs ?? 30_000));
|
|
135
137
|
const preflightArgs = { paths: args.paths };
|
|
136
138
|
if (args.includeDiagnostics === true && args.includeHashes !== undefined) {
|
|
137
139
|
preflightArgs.includeHashes = args.includeHashes;
|
|
@@ -140,52 +142,42 @@ export async function attachFiles(env, args) {
|
|
|
140
142
|
if (!preflight.ok || preflight.data === undefined) {
|
|
141
143
|
return preflight;
|
|
142
144
|
}
|
|
143
|
-
|
|
145
|
+
let boot;
|
|
146
|
+
try {
|
|
147
|
+
boot = await withinAttachmentDeadline(deadline, () => ensurePage(env, { minimalContext: true }), "ChatGPT page verification");
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error instanceof AttachmentDeadlineError || remainingMs(deadline) <= 0) {
|
|
151
|
+
return attachmentDeadlineResult(error);
|
|
152
|
+
}
|
|
153
|
+
return resultError(error instanceof Error ? error : new Error(String(error)));
|
|
154
|
+
}
|
|
144
155
|
if (!boot.ok) {
|
|
145
156
|
return boot;
|
|
146
157
|
}
|
|
147
158
|
const page = env.page;
|
|
159
|
+
const mutationState = { handoffStarted: false };
|
|
148
160
|
try {
|
|
149
161
|
const files = preflight.data.files.map(file => ({
|
|
150
162
|
path: file.path,
|
|
151
163
|
name: file.name,
|
|
152
164
|
bytes: file.bytes
|
|
153
165
|
}));
|
|
154
|
-
await
|
|
166
|
+
const rawBaseline = await withinNativeAttachmentDeadline(deadline, timeoutMs => readAttachmentEvidenceBaseline(page, timeoutMs), "Pre-upload attachment baseline", 2_000).catch(() => ({ supported: false, inputFiles: [], attachmentLabels: [] }));
|
|
167
|
+
const baseline = rawBaseline !== null
|
|
168
|
+
&& typeof rawBaseline === "object"
|
|
169
|
+
&& Array.isArray(rawBaseline.inputFiles)
|
|
170
|
+
&& Array.isArray(rawBaseline.attachmentLabels)
|
|
171
|
+
? rawBaseline
|
|
172
|
+
: { supported: false, inputFiles: [], attachmentLabels: [] };
|
|
173
|
+
await uploadFiles(page, files, deadline, mutationState);
|
|
155
174
|
const browserInput = args.includeDiagnostics === true
|
|
156
|
-
? await readBrowserInputDiagnostic(page).catch(() => undefined)
|
|
175
|
+
? await withinNativeAttachmentDeadline(deadline, timeoutMs => readBrowserInputDiagnostic(page, timeoutMs), "Browser input diagnostic", 2_000).catch(() => undefined)
|
|
157
176
|
: undefined;
|
|
158
|
-
await page
|
|
159
|
-
const readiness = await waitForAttachedFilesReady(page, files,
|
|
177
|
+
await attachmentDelay(page, deadline, Math.min(250, Math.max(1, Math.floor(remainingMs(deadline) / 4))));
|
|
178
|
+
const readiness = await waitForAttachedFilesReady(page, files, baseline, deadline);
|
|
160
179
|
if (!readiness.ready) {
|
|
161
|
-
|
|
162
|
-
kind: "upload_failed",
|
|
163
|
-
code: "attachment_processing",
|
|
164
|
-
message: "ChatGPT still appears to be processing the attached file, so the prompt was not submitted.",
|
|
165
|
-
remediation: [
|
|
166
|
-
{
|
|
167
|
-
label: "Wait for upload",
|
|
168
|
-
instruction: "Wait until the visible attachment finishes uploading or processing, then retry the askWithFiles call.",
|
|
169
|
-
userActionRequired: false
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
label: "Retry smaller file",
|
|
173
|
-
instruction: "If processing never finishes, retry with a smaller file or a different supported file type.",
|
|
174
|
-
userActionRequired: true
|
|
175
|
-
}
|
|
176
|
-
],
|
|
177
|
-
resumable: true
|
|
178
|
-
};
|
|
179
|
-
if (readiness.processingText !== undefined) {
|
|
180
|
-
blocker.visibleText = readiness.processingText;
|
|
181
|
-
}
|
|
182
|
-
return {
|
|
183
|
-
ok: false,
|
|
184
|
-
status: "blocked",
|
|
185
|
-
warnings: [],
|
|
186
|
-
blocker,
|
|
187
|
-
context: await contextFromPage(page)
|
|
188
|
-
};
|
|
180
|
+
return attachmentOutcomeIndeterminate(files, [], await attachmentContext(page, deadline), readiness.processingText);
|
|
189
181
|
}
|
|
190
182
|
const data = { files };
|
|
191
183
|
if (args.includeDiagnostics === true) {
|
|
@@ -194,10 +186,44 @@ export async function attachFiles(env, args) {
|
|
|
194
186
|
data.diagnostics.browserInput = browserInput;
|
|
195
187
|
}
|
|
196
188
|
}
|
|
197
|
-
return resultOk(data, await
|
|
189
|
+
return resultOk(data, await attachmentContext(page, deadline), preflight.warnings);
|
|
198
190
|
}
|
|
199
191
|
catch (error) {
|
|
200
|
-
if (
|
|
192
|
+
if (mutationState.handoffStarted) {
|
|
193
|
+
const permissionFailure = isUploadPermissionBlocker(error);
|
|
194
|
+
return attachmentOutcomeIndeterminate(preflight.data.files, [error instanceof Error ? error.message : String(error)], await attachmentContext(page, deadline), permissionFailure ? uploadPermissionDetails(error) : undefined, permissionFailure ? uploadPermissionRemediation() : undefined);
|
|
195
|
+
}
|
|
196
|
+
if (error instanceof AttachmentDeadlineError || remainingMs(deadline) <= 0) {
|
|
197
|
+
return attachmentDeadlineResult(error);
|
|
198
|
+
}
|
|
199
|
+
if (isUploadTransportFailure(error)) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
status: "blocked",
|
|
203
|
+
warnings: [],
|
|
204
|
+
blocker: {
|
|
205
|
+
kind: "browser_bridge_unavailable",
|
|
206
|
+
code: "upload_transport_failed",
|
|
207
|
+
message: "The Codex Chrome bridge disconnected while handing files to ChatGPT's visible composer. File attachment did not complete, so callers must not submit the prompt.",
|
|
208
|
+
visibleText: error instanceof Error ? error.message : String(error),
|
|
209
|
+
remediation: [
|
|
210
|
+
{
|
|
211
|
+
label: "Retry live attachment",
|
|
212
|
+
instruction: "Retry the attachment from the live Codex Chrome runtime; the operation is safe to resume because file attachment did not complete.",
|
|
213
|
+
userActionRequired: false
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
label: "Restart bridge if repeated",
|
|
217
|
+
instruction: "If the bridge disconnects again, restart Chrome or Codex before retrying. Do not change upload permissions unless Chrome explicitly reports a permission denial.",
|
|
218
|
+
userActionRequired: true
|
|
219
|
+
}
|
|
220
|
+
],
|
|
221
|
+
resumable: true
|
|
222
|
+
},
|
|
223
|
+
context: await attachmentContext(page, deadline)
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (isUploadPermissionBlocker(error)) {
|
|
201
227
|
return {
|
|
202
228
|
ok: false,
|
|
203
229
|
status: "blocked",
|
|
@@ -210,12 +236,70 @@ export async function attachFiles(env, args) {
|
|
|
210
236
|
remediation: uploadPermissionRemediation(),
|
|
211
237
|
resumable: true
|
|
212
238
|
},
|
|
213
|
-
context: await
|
|
239
|
+
context: await attachmentContext(page, deadline)
|
|
214
240
|
};
|
|
215
241
|
}
|
|
216
|
-
|
|
242
|
+
if (isUploadPathFailure(error)) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
status: "blocked",
|
|
246
|
+
warnings: [],
|
|
247
|
+
blocker: {
|
|
248
|
+
kind: "upload_failed",
|
|
249
|
+
code: "upload_path_unavailable",
|
|
250
|
+
message: "None of the browser's supported ChatGPT file-attachment paths completed. Callers must not submit the prompt.",
|
|
251
|
+
visibleText: error instanceof Error ? error.message : String(error),
|
|
252
|
+
remediation: [
|
|
253
|
+
{
|
|
254
|
+
label: "Retry live attachment",
|
|
255
|
+
instruction: "Retry from the live Codex Chrome runtime after confirming the ChatGPT composer is visible. Do not change upload permissions unless Chrome explicitly reports a permission denial.",
|
|
256
|
+
userActionRequired: false
|
|
257
|
+
}
|
|
258
|
+
],
|
|
259
|
+
resumable: true
|
|
260
|
+
},
|
|
261
|
+
context: await attachmentContext(page, deadline)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return resultError(error instanceof Error ? error : new Error(String(error)), await attachmentContext(page, deadline));
|
|
217
265
|
}
|
|
218
266
|
}
|
|
267
|
+
function attachmentDeadlineResult(error) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
status: "timeout",
|
|
271
|
+
warnings: error === undefined ? [] : [error instanceof Error ? error.message : String(error)],
|
|
272
|
+
blocker: {
|
|
273
|
+
kind: "upload_failed",
|
|
274
|
+
code: "attachment_deadline_exhausted",
|
|
275
|
+
message: "ChatGPT file attachment did not complete before the caller's single operation deadline. The prompt was not submitted.",
|
|
276
|
+
resumable: true
|
|
277
|
+
},
|
|
278
|
+
context: { timestamp: new Date().toISOString() }
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function attachmentOutcomeIndeterminate(files, warnings, context, visibleText, remediation) {
|
|
282
|
+
const blocker = {
|
|
283
|
+
kind: "upload_failed",
|
|
284
|
+
code: "attachment_outcome_indeterminate",
|
|
285
|
+
message: "The native file handoff started, but its outcome could not be verified. The browser request is no longer in flight, though the file may already be present. Inspect the current composer; do not submit or retry automatically.",
|
|
286
|
+
resumable: false
|
|
287
|
+
};
|
|
288
|
+
if (visibleText !== undefined)
|
|
289
|
+
blocker.visibleText = visibleText;
|
|
290
|
+
if (remediation !== undefined)
|
|
291
|
+
blocker.remediation = remediation;
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
status: "partial",
|
|
295
|
+
data: {
|
|
296
|
+
files: files.map(file => ({ path: file.path, name: file.name, bytes: file.bytes }))
|
|
297
|
+
},
|
|
298
|
+
warnings,
|
|
299
|
+
blocker,
|
|
300
|
+
context
|
|
301
|
+
};
|
|
302
|
+
}
|
|
219
303
|
function filePreflightBlocker(args) {
|
|
220
304
|
return {
|
|
221
305
|
ok: false,
|
|
@@ -328,44 +412,131 @@ function guessFileType(extension) {
|
|
|
328
412
|
function isNodeError(error) {
|
|
329
413
|
return error instanceof Error && "code" in error;
|
|
330
414
|
}
|
|
331
|
-
async function
|
|
332
|
-
|
|
415
|
+
async function readAttachmentEvidenceBaseline(page, timeoutMs) {
|
|
416
|
+
if (typeof page.evaluate !== "function") {
|
|
417
|
+
return { supported: false, inputFiles: [], attachmentLabels: [] };
|
|
418
|
+
}
|
|
419
|
+
return page.evaluate(() => {
|
|
420
|
+
const visible = (element) => {
|
|
421
|
+
if (element.hidden || element.closest("[hidden], [inert], [aria-hidden='true']") !== null)
|
|
422
|
+
return false;
|
|
423
|
+
const style = window.getComputedStyle(element);
|
|
424
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0")
|
|
425
|
+
return false;
|
|
426
|
+
const rect = element.getBoundingClientRect();
|
|
427
|
+
return rect.width > 0 || rect.height > 0;
|
|
428
|
+
};
|
|
429
|
+
const textboxes = Array.from(document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")).filter(visible);
|
|
430
|
+
const composers = [...new Set(textboxes
|
|
431
|
+
.map(textbox => textbox.closest("form")
|
|
432
|
+
?? textbox.closest("[data-testid*='composer' i]")
|
|
433
|
+
?? textbox.closest("[aria-label*='composer' i]")
|
|
434
|
+
?? textbox.closest("[class*='composer' i]"))
|
|
435
|
+
.filter((value) => value !== null))];
|
|
436
|
+
if (composers.length !== 1) {
|
|
437
|
+
return { supported: false, inputFiles: [], attachmentLabels: [] };
|
|
438
|
+
}
|
|
439
|
+
const composer = composers[0];
|
|
440
|
+
const allInputs = Array.from(composer.querySelectorAll("input[type='file']"))
|
|
441
|
+
.filter(input => !input.disabled && input.getAttribute("aria-disabled") !== "true");
|
|
442
|
+
const preferred = allInputs.filter(input => input.id === "upload-files");
|
|
443
|
+
const nonImage = allInputs.filter(input => input.getAttribute("accept") !== "image/*");
|
|
444
|
+
const inputs = preferred.length > 0 ? preferred : nonImage.length > 0 ? nonImage : allInputs;
|
|
445
|
+
if (inputs.length !== 1) {
|
|
446
|
+
return { supported: false, inputFiles: [], attachmentLabels: [] };
|
|
447
|
+
}
|
|
448
|
+
const attachmentSelector = [
|
|
449
|
+
"[data-testid*='attachment' i]",
|
|
450
|
+
"[data-testid*='file' i]",
|
|
451
|
+
"[aria-label*='attachment' i]",
|
|
452
|
+
"[aria-label*='upload' i]",
|
|
453
|
+
"[aria-label*='file' i]",
|
|
454
|
+
"[class*='attachment' i]",
|
|
455
|
+
"[class*='upload' i]",
|
|
456
|
+
"[class*='file' i]",
|
|
457
|
+
"[role='progressbar']"
|
|
458
|
+
].join(", ");
|
|
459
|
+
const labels = Array.from(composer.querySelectorAll(attachmentSelector))
|
|
460
|
+
.filter(visible)
|
|
461
|
+
.map(element => [
|
|
462
|
+
element.textContent ?? "",
|
|
463
|
+
element.getAttribute("aria-label") ?? "",
|
|
464
|
+
element.getAttribute("title") ?? ""
|
|
465
|
+
].join(" ").replace(/\s+/g, " ").trim().toLocaleLowerCase())
|
|
466
|
+
.filter(Boolean);
|
|
467
|
+
return {
|
|
468
|
+
supported: true,
|
|
469
|
+
inputFiles: Array.from(inputs[0].files ?? []).map(file => `${file.name.toLocaleLowerCase()}\u0000${file.size}`),
|
|
470
|
+
attachmentLabels: labels
|
|
471
|
+
};
|
|
472
|
+
}, undefined, { timeoutMs });
|
|
473
|
+
}
|
|
474
|
+
async function waitForAttachedFilesReady(page, files, baseline, deadline) {
|
|
333
475
|
let lastProcessingText;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
476
|
+
let sawProcessing = false;
|
|
477
|
+
while (remainingMs(deadline) > 0) {
|
|
478
|
+
const snapshot = await withinNativeAttachmentDeadline(deadline, timeoutMs => readAttachmentReadiness(page, files, baseline, timeoutMs), "Attachment readiness inspection", 2_000).catch(() => undefined);
|
|
479
|
+
if (snapshot === undefined || snapshot.supported === false) {
|
|
480
|
+
return { ready: false, reason: "unverified" };
|
|
338
481
|
}
|
|
339
|
-
const allNamesVisible = snapshot.files.length
|
|
482
|
+
const allNamesVisible = snapshot.files.length === files.length && snapshot.files.every(file => file.visible);
|
|
340
483
|
if (!snapshot.processing && allNamesVisible) {
|
|
341
484
|
return { ready: true };
|
|
342
485
|
}
|
|
343
|
-
|
|
344
|
-
return { ready: true };
|
|
345
|
-
}
|
|
486
|
+
sawProcessing ||= snapshot.processing;
|
|
346
487
|
if (snapshot.processingText !== undefined) {
|
|
347
488
|
lastProcessingText = snapshot.processingText;
|
|
348
489
|
}
|
|
349
|
-
|
|
490
|
+
const pollBudget = remainingMs(deadline);
|
|
491
|
+
if (pollBudget <= 10)
|
|
492
|
+
break;
|
|
493
|
+
await attachmentDelay(page, deadline, Math.min(250, pollBudget - 10));
|
|
350
494
|
}
|
|
351
|
-
const blocked = {
|
|
495
|
+
const blocked = {
|
|
496
|
+
ready: false,
|
|
497
|
+
reason: sawProcessing ? "processing" : "unverified"
|
|
498
|
+
};
|
|
352
499
|
if (lastProcessingText !== undefined) {
|
|
353
500
|
blocked.processingText = lastProcessingText;
|
|
354
501
|
}
|
|
355
502
|
return blocked;
|
|
356
503
|
}
|
|
357
|
-
async function readAttachmentReadiness(page, files) {
|
|
504
|
+
async function readAttachmentReadiness(page, files, baseline, timeoutMs) {
|
|
358
505
|
if (typeof page.evaluate !== "function") {
|
|
359
506
|
return undefined;
|
|
360
507
|
}
|
|
361
|
-
return page.evaluate((
|
|
362
|
-
const
|
|
508
|
+
return page.evaluate((args) => {
|
|
509
|
+
const expectedFiles = args.expectedFiles;
|
|
363
510
|
const normalize = (value) => value.toLocaleLowerCase();
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
511
|
+
const visible = (element) => {
|
|
512
|
+
if (element.hidden || element.closest("[hidden], [inert], [aria-hidden='true']") !== null)
|
|
513
|
+
return false;
|
|
514
|
+
const style = window.getComputedStyle(element);
|
|
515
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0")
|
|
516
|
+
return false;
|
|
517
|
+
const rect = element.getBoundingClientRect();
|
|
518
|
+
return rect.width > 0 || rect.height > 0;
|
|
519
|
+
};
|
|
520
|
+
const textboxes = Array.from(document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")).filter(visible);
|
|
521
|
+
const composers = [...new Set(textboxes
|
|
522
|
+
.map(textbox => textbox.closest("form")
|
|
523
|
+
?? textbox.closest("[data-testid*='composer' i]")
|
|
524
|
+
?? textbox.closest("[aria-label*='composer' i]")
|
|
525
|
+
?? textbox.closest("[class*='composer' i]"))
|
|
526
|
+
.filter((value) => value !== null))];
|
|
527
|
+
if (composers.length !== 1) {
|
|
528
|
+
return { supported: false, files: expectedFiles.map(file => ({ name: file.name, visible: false })), processing: false };
|
|
529
|
+
}
|
|
530
|
+
const composer = composers[0];
|
|
531
|
+
const allInputs = Array.from(composer.querySelectorAll("input[type='file']"))
|
|
532
|
+
.filter(input => !input.disabled && input.getAttribute("aria-disabled") !== "true");
|
|
533
|
+
const preferred = allInputs.filter(input => input.id === "upload-files");
|
|
534
|
+
const nonImage = allInputs.filter(input => input.getAttribute("accept") !== "image/*");
|
|
535
|
+
const inputs = preferred.length > 0 ? preferred : nonImage.length > 0 ? nonImage : allInputs;
|
|
536
|
+
if (inputs.length !== 1) {
|
|
537
|
+
return { supported: false, files: expectedFiles.map(file => ({ name: file.name, visible: false })), processing: false };
|
|
538
|
+
}
|
|
539
|
+
const input = inputs[0];
|
|
369
540
|
const attachmentSelectors = [
|
|
370
541
|
"[data-testid*='attachment' i]",
|
|
371
542
|
"[data-testid*='file' i]",
|
|
@@ -377,92 +548,169 @@ async function readAttachmentReadiness(page, files) {
|
|
|
377
548
|
"[class*='file' i]",
|
|
378
549
|
"[role='progressbar']"
|
|
379
550
|
].join(", ");
|
|
380
|
-
const
|
|
551
|
+
const attachmentElements = Array.from(composer.querySelectorAll(attachmentSelectors)).filter(visible);
|
|
552
|
+
const attachmentText = attachmentElements
|
|
381
553
|
.map(element => [
|
|
382
554
|
element.textContent ?? "",
|
|
383
555
|
element.getAttribute("aria-label") ?? "",
|
|
384
556
|
element.getAttribute("title") ?? ""
|
|
385
557
|
].join(" "))
|
|
386
558
|
.join(" ");
|
|
387
|
-
const
|
|
388
|
-
|
|
559
|
+
const inputFiles = Array.from(input.files ?? []).map(file => ({
|
|
560
|
+
name: normalize(file.name),
|
|
561
|
+
size: file.size,
|
|
562
|
+
signature: `${normalize(file.name)}\u0000${file.size}`
|
|
563
|
+
}));
|
|
564
|
+
const attachmentLabels = attachmentElements.map(element => normalize([
|
|
565
|
+
element.textContent ?? "",
|
|
566
|
+
element.getAttribute("aria-label") ?? "",
|
|
567
|
+
element.getAttribute("title") ?? ""
|
|
568
|
+
].join(" ")));
|
|
569
|
+
const occurrence = (values, index) => values.slice(0, index + 1).filter(value => value === values[index]).length;
|
|
570
|
+
const inputSignatures = inputFiles.map(file => file.signature);
|
|
571
|
+
const newInputIndices = new Set(inputSignatures
|
|
572
|
+
.map((signature, index) => occurrence(inputSignatures, index)
|
|
573
|
+
> args.baseline.inputFiles.filter(value => value === signature).length ? index : -1)
|
|
574
|
+
.filter(index => index >= 0));
|
|
575
|
+
const newLabelIndices = new Set(attachmentLabels
|
|
576
|
+
.map((label, index) => args.baseline.supported
|
|
577
|
+
&& occurrence(attachmentLabels, index)
|
|
578
|
+
> args.baseline.attachmentLabels.filter(value => value === label).length ? index : -1)
|
|
579
|
+
.filter(index => index >= 0));
|
|
580
|
+
const usedInputs = new Set();
|
|
581
|
+
const usedLabels = new Set();
|
|
582
|
+
const visibleFiles = expectedFiles.map(expected => {
|
|
583
|
+
const expectedName = normalize(expected.name);
|
|
584
|
+
const inputIndex = inputFiles.findIndex((candidate, index) => !usedInputs.has(index)
|
|
585
|
+
&& newInputIndices.has(index)
|
|
586
|
+
&& candidate.name === expectedName
|
|
587
|
+
&& candidate.size === expected.bytes);
|
|
588
|
+
if (inputIndex >= 0) {
|
|
589
|
+
usedInputs.add(inputIndex);
|
|
590
|
+
return { name: expected.name, visible: true };
|
|
591
|
+
}
|
|
592
|
+
const labelIndex = attachmentLabels.findIndex((label, index) => !usedLabels.has(index) && newLabelIndices.has(index) && label.includes(expectedName));
|
|
593
|
+
if (labelIndex >= 0)
|
|
594
|
+
usedLabels.add(labelIndex);
|
|
595
|
+
return { name: expected.name, visible: labelIndex >= 0 };
|
|
596
|
+
});
|
|
597
|
+
const processingMatch = /\b(uploading|processing|attaching|preparing|reading|scanning|analyzing)\b/i.exec(attachmentText);
|
|
389
598
|
const snapshot = {
|
|
390
|
-
|
|
599
|
+
supported: true,
|
|
600
|
+
files: visibleFiles,
|
|
391
601
|
processing: processingMatch !== null
|
|
392
602
|
};
|
|
393
603
|
if (processingMatch !== null) {
|
|
394
|
-
snapshot.processingText =
|
|
604
|
+
snapshot.processingText = attachmentText.slice(0, 500);
|
|
395
605
|
}
|
|
396
606
|
return snapshot;
|
|
397
|
-
},
|
|
607
|
+
}, {
|
|
608
|
+
expectedFiles: files.map(file => ({ name: file.name, bytes: file.bytes })),
|
|
609
|
+
baseline
|
|
610
|
+
}, { timeoutMs });
|
|
398
611
|
}
|
|
399
|
-
async function uploadFiles(page, files,
|
|
612
|
+
async function uploadFiles(page, files, deadline, mutationState) {
|
|
400
613
|
const paths = files.map(file => file.path);
|
|
401
614
|
const errors = [];
|
|
402
|
-
|
|
403
|
-
|
|
615
|
+
let activeComposerInput;
|
|
616
|
+
try {
|
|
617
|
+
activeComposerInput = await resolveUniqueActiveComposerFileInput(page, deadline);
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
errors.push(`active-composer-input: ${error instanceof Error ? error.message : String(error)}`);
|
|
621
|
+
}
|
|
622
|
+
const attempts = [];
|
|
623
|
+
if (activeComposerInput !== undefined) {
|
|
624
|
+
const resolvedInput = activeComposerInput;
|
|
625
|
+
attempts.push({
|
|
404
626
|
name: "visible-chatgpt-file-input",
|
|
405
627
|
run: async () => {
|
|
406
|
-
await
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
name: "add-photos-files-menu-item",
|
|
411
|
-
run: async () => {
|
|
412
|
-
await clickChatGPTAddPhotosMenuItem(page, paths, timeoutMs);
|
|
413
|
-
}
|
|
414
|
-
},
|
|
415
|
-
{
|
|
416
|
-
name: "generic-add-files-button",
|
|
417
|
-
run: async () => {
|
|
418
|
-
await clickFileChooserLocator(page, addFilesButton(page), paths, timeoutMs);
|
|
628
|
+
if (!await locatorIsRendered(resolvedInput, deadline)) {
|
|
629
|
+
throw new Error("The active-composer upload target is hidden.");
|
|
630
|
+
}
|
|
631
|
+
await clickFileChooserLocator(page, resolvedInput, paths, deadline, mutationState);
|
|
419
632
|
}
|
|
420
|
-
}
|
|
421
|
-
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
attempts.push({
|
|
636
|
+
name: "add-photos-files-menu-item",
|
|
637
|
+
run: async () => {
|
|
638
|
+
await clickChatGPTAddPhotosMenuItem(page, paths, deadline, mutationState);
|
|
639
|
+
}
|
|
640
|
+
}, {
|
|
641
|
+
name: "generic-add-files-button",
|
|
642
|
+
run: async () => {
|
|
643
|
+
const control = addFilesButton(page);
|
|
644
|
+
await assertControlInUniqueActiveComposer(control, deadline, "Generic Add files control");
|
|
645
|
+
await clickFileChooserLocator(page, control, paths, deadline, mutationState);
|
|
646
|
+
}
|
|
647
|
+
}, {
|
|
648
|
+
name: "cdp-file-input-chooser",
|
|
649
|
+
run: async () => {
|
|
650
|
+
await clickHiddenFileInputWithCdp(page, paths, deadline, mutationState);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
if (activeComposerInput !== undefined) {
|
|
654
|
+
const resolvedInput = activeComposerInput;
|
|
655
|
+
attempts.push({
|
|
422
656
|
name: "direct-file-input-set",
|
|
423
657
|
run: async () => {
|
|
424
|
-
await
|
|
658
|
+
await setResolvedFileInput(resolvedInput, files, deadline, mutationState);
|
|
425
659
|
}
|
|
426
|
-
}
|
|
427
|
-
|
|
660
|
+
});
|
|
661
|
+
}
|
|
428
662
|
for (const attempt of attempts) {
|
|
663
|
+
if (remainingMs(deadline) <= 0) {
|
|
664
|
+
errors.push(`${attempt.name}: skipped because the single attachment deadline was exhausted`);
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
429
667
|
try {
|
|
430
668
|
await attempt.run();
|
|
431
669
|
return;
|
|
432
670
|
}
|
|
433
671
|
catch (error) {
|
|
672
|
+
if (mutationState.handoffStarted) {
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
if (isUploadPermissionBlocker(error) || isUploadTransportFailure(error)) {
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
434
678
|
errors.push(`${attempt.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
435
679
|
}
|
|
436
680
|
}
|
|
437
681
|
throw new Error(`No ChatGPT upload path completed.\n${errors.join("\n")}`);
|
|
438
682
|
}
|
|
439
|
-
async function clickChatGPTAddPhotosMenuItem(page, paths,
|
|
683
|
+
async function clickChatGPTAddPhotosMenuItem(page, paths, deadline, mutationState) {
|
|
440
684
|
// The `#composer-plus-btn` id is the language-agnostic primary; the aria-label and the
|
|
441
685
|
// menu-item text are locale-sensitive (menu text sourced from the locale registry).
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
if (
|
|
686
|
+
const addPhotosFilesLabels = localeLabels.addPhotosFilesMenuItem;
|
|
687
|
+
let menuItem = await findChatGPTUploadMenuItem(page, addPhotosFilesLabels, deadline);
|
|
688
|
+
if (menuItem === undefined) {
|
|
445
689
|
const plusButton = requiredLocator(page, "#composer-plus-btn, button[aria-label='Add files and more']");
|
|
446
|
-
if (await locatorCount(plusButton) !== 1) {
|
|
690
|
+
if (await locatorCount(plusButton, deadline) !== 1) {
|
|
447
691
|
throw new Error("ChatGPT Add files button was not uniquely available.");
|
|
448
692
|
}
|
|
449
|
-
await plusButton
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
async function clickFileChooserTarget(page, selector, paths, timeoutMs, options = {}) {
|
|
456
|
-
const locator = requiredLocator(page, selector);
|
|
457
|
-
if (await locatorCount(locator) !== 1) {
|
|
458
|
-
throw new Error(`Upload target was not uniquely available: ${selector}`);
|
|
693
|
+
await assertControlInUniqueActiveComposer(plusButton, deadline, "ChatGPT Add files control");
|
|
694
|
+
if (plusButton.click === undefined)
|
|
695
|
+
throw new Error("ChatGPT Add files button does not expose click().");
|
|
696
|
+
await withinNativeAttachmentDeadline(deadline, timeoutMs => plusButton.click({ timeoutMs }), "ChatGPT Add files control click", 10_000);
|
|
697
|
+
await attachmentDelay(page, deadline, 250);
|
|
459
698
|
}
|
|
460
|
-
|
|
461
|
-
|
|
699
|
+
menuItem = await findChatGPTUploadMenuItem(page, addPhotosFilesLabels, deadline);
|
|
700
|
+
if (menuItem === undefined) {
|
|
701
|
+
throw new Error("ChatGPT's visible Add photos & files upload row was not uniquely available.");
|
|
462
702
|
}
|
|
463
|
-
|
|
703
|
+
const refreshedMenuItem = menuItem;
|
|
704
|
+
await clickFileChooserLocator(page, refreshedMenuItem, paths, deadline, mutationState);
|
|
464
705
|
}
|
|
465
|
-
async function
|
|
706
|
+
async function findChatGPTUploadMenuItem(page, addPhotosFilesLabels, deadline) {
|
|
707
|
+
// Current Chat renders the command palette upload action as a focusable row
|
|
708
|
+
// with tabindex=0 and no ARIA menuitem role.
|
|
709
|
+
const pattern = new RegExp(addPhotosFilesLabels.map(escapeRegExp).join("|"), "i");
|
|
710
|
+
const candidate = requiredLocator(page, "div[tabindex='0']").filter?.({ hasText: pattern });
|
|
711
|
+
return await locatorCount(candidate, deadline) === 1 ? candidate : undefined;
|
|
712
|
+
}
|
|
713
|
+
async function clickFileChooserLocator(page, locator, paths, deadline, mutationState) {
|
|
466
714
|
if (locator === undefined) {
|
|
467
715
|
throw new Error("Upload locator was not available.");
|
|
468
716
|
}
|
|
@@ -472,28 +720,105 @@ async function clickFileChooserLocator(page, locator, paths, timeoutMs) {
|
|
|
472
720
|
if (typeof locator.click !== "function") {
|
|
473
721
|
throw new Error("Upload locator does not expose click().");
|
|
474
722
|
}
|
|
475
|
-
|
|
723
|
+
// Attach a rejection handler immediately. Browser bridges can reject the
|
|
724
|
+
// chooser wait before the visible click promise settles; leaving that
|
|
725
|
+
// rejection temporarily unobserved can terminate the host JavaScript
|
|
726
|
+
// runtime even though attachFiles has a surrounding error boundary.
|
|
727
|
+
const chooserPromise = waitForFileChooser(page, deadline).then(chooser => ({ ok: true, chooser }), error => ({ ok: false, error }));
|
|
476
728
|
try {
|
|
477
|
-
await locator.click({ timeoutMs
|
|
729
|
+
await withinNativeAttachmentDeadline(deadline, timeoutMs => locator.click({ timeoutMs }), "ChatGPT upload-control click", 10_000);
|
|
478
730
|
}
|
|
479
731
|
catch (error) {
|
|
480
|
-
await chooserPromise
|
|
732
|
+
await chooserPromise;
|
|
481
733
|
throw error;
|
|
482
734
|
}
|
|
483
|
-
const
|
|
484
|
-
|
|
735
|
+
const chooserResult = await chooserPromise;
|
|
736
|
+
if (!chooserResult.ok) {
|
|
737
|
+
throw chooserResult.error;
|
|
738
|
+
}
|
|
739
|
+
const chooser = chooserResult.chooser;
|
|
740
|
+
await validateChooserTarget(chooser, deadline, "scoped-composer-trigger");
|
|
741
|
+
await withinAttachmentDeadline(deadline, () => validateChooserMultiplicity(chooser, paths), "File-chooser multiplicity validation");
|
|
485
742
|
try {
|
|
486
|
-
|
|
743
|
+
mutationState.handoffStarted = true;
|
|
744
|
+
await withinNativeAttachmentDeadline(deadline, timeoutMs => chooser.setFiles(paths, { timeoutMs }), "File-chooser handoff", 15_000);
|
|
487
745
|
}
|
|
488
746
|
catch (error) {
|
|
489
747
|
throw new Error(`fileChooser.setFiles failed. ${error instanceof Error ? error.message : String(error)}`);
|
|
490
748
|
}
|
|
491
749
|
}
|
|
492
|
-
async function
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
750
|
+
async function clickHiddenFileInputWithCdp(page, paths, deadline, mutationState) {
|
|
751
|
+
// Codex Chrome exposes page.evaluate as read-only and intentionally omits
|
|
752
|
+
// locator.setInputFiles. CDP supplies only the trusted user gesture here;
|
|
753
|
+
// the sanctioned file-chooser object still performs the local handoff.
|
|
754
|
+
if (typeof page.waitForEvent !== "function") {
|
|
755
|
+
throw new Error("The active browser page does not expose file chooser events.");
|
|
756
|
+
}
|
|
757
|
+
const rawCapability = await withinAttachmentDeadline(deadline, () => Promise.resolve(page.capabilities?.get?.("cdp")), "Scoped CDP upload capability resolution");
|
|
758
|
+
const cdp = rawCapability;
|
|
759
|
+
if (typeof cdp?.send !== "function") {
|
|
760
|
+
throw new Error("The active browser page does not expose the scoped CDP capability needed to click a hidden file input.");
|
|
761
|
+
}
|
|
762
|
+
const chooserPromise = waitForFileChooser(page, deadline).then(chooser => ({ ok: true, chooser }), error => ({ ok: false, error }));
|
|
763
|
+
try {
|
|
764
|
+
const evaluation = await withinNativeAttachmentDeadline(deadline, timeoutMs => Promise.resolve(cdp.send("Runtime.evaluate", {
|
|
765
|
+
expression: `(() => {
|
|
766
|
+
const visible = element => {
|
|
767
|
+
if (element.hidden || element.closest("[hidden], [inert], [aria-hidden='true']")) return false;
|
|
768
|
+
const style = getComputedStyle(element);
|
|
769
|
+
const rect = element.getBoundingClientRect();
|
|
770
|
+
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"
|
|
771
|
+
&& (rect.width > 0 || rect.height > 0);
|
|
772
|
+
};
|
|
773
|
+
const textboxes = [...document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")].filter(visible);
|
|
774
|
+
const composers = [...new Set(textboxes.map(textbox =>
|
|
775
|
+
textbox.closest("form")
|
|
776
|
+
?? textbox.closest("[data-testid*='composer' i]")
|
|
777
|
+
?? textbox.closest("[aria-label*='composer' i]")
|
|
778
|
+
?? textbox.closest("[class*='composer' i]")
|
|
779
|
+
).filter(Boolean))];
|
|
780
|
+
if (composers.length !== 1) return { ok: false, reason: "active composer was not unique" };
|
|
781
|
+
const all = [...composers[0].querySelectorAll("input[type='file']")]
|
|
782
|
+
.filter(input => !input.disabled && input.getAttribute("aria-disabled") !== "true");
|
|
783
|
+
const preferred = all.filter(input => input.id === "upload-files");
|
|
784
|
+
const nonImage = all.filter(input => input.getAttribute("accept") !== "image/*");
|
|
785
|
+
const candidates = preferred.length ? preferred : nonImage.length ? nonImage : all;
|
|
786
|
+
if (candidates.length !== 1) return { ok: false, reason: "active composer file input was not unique" };
|
|
787
|
+
candidates[0].click();
|
|
788
|
+
return { ok: true };
|
|
789
|
+
})()`,
|
|
790
|
+
userGesture: true,
|
|
791
|
+
awaitPromise: true,
|
|
792
|
+
returnByValue: true
|
|
793
|
+
}, {
|
|
794
|
+
timeoutMs
|
|
795
|
+
})), "Scoped CDP file-input click", 10_000);
|
|
796
|
+
const wrappedValue = evaluation?.result?.value;
|
|
797
|
+
const value = (wrappedValue ?? evaluation);
|
|
798
|
+
if (value?.ok !== true) {
|
|
799
|
+
throw new Error(`Scoped CDP file-input click was refused: ${value?.reason ?? "no success result"}.`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
catch (error) {
|
|
803
|
+
await chooserPromise;
|
|
804
|
+
throw error;
|
|
805
|
+
}
|
|
806
|
+
const chooserResult = await chooserPromise;
|
|
807
|
+
if (!chooserResult.ok) {
|
|
808
|
+
throw chooserResult.error;
|
|
809
|
+
}
|
|
810
|
+
await validateChooserTarget(chooserResult.chooser, deadline, "scoped-cdp-input");
|
|
811
|
+
await withinAttachmentDeadline(deadline, () => validateChooserMultiplicity(chooserResult.chooser, paths), "File-chooser multiplicity validation");
|
|
812
|
+
try {
|
|
813
|
+
mutationState.handoffStarted = true;
|
|
814
|
+
await withinNativeAttachmentDeadline(deadline, timeoutMs => chooserResult.chooser.setFiles(paths, { timeoutMs }), "File-chooser handoff", 15_000);
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
throw new Error(`fileChooser.setFiles failed. ${error instanceof Error ? error.message : String(error)}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
async function waitForFileChooser(page, deadline) {
|
|
821
|
+
const rawChooser = await withinNativeAttachmentDeadline(deadline, timeoutMs => Promise.resolve(page.waitForEvent?.("filechooser", { timeoutMs })), "File-chooser event wait");
|
|
497
822
|
if (!isFileChooserLike(rawChooser)) {
|
|
498
823
|
throw new Error("File chooser event did not return a setFiles-capable chooser.");
|
|
499
824
|
}
|
|
@@ -513,12 +838,132 @@ function isFileChooserLike(value) {
|
|
|
513
838
|
&& typeof value === "object"
|
|
514
839
|
&& typeof value.setFiles === "function";
|
|
515
840
|
}
|
|
516
|
-
async function
|
|
517
|
-
if (
|
|
841
|
+
async function validateChooserTarget(chooser, deadline, _triggerProof) {
|
|
842
|
+
if (typeof chooser.element !== "function") {
|
|
843
|
+
// The Codex Chrome bridge deliberately exposes only isMultiple/setFiles.
|
|
844
|
+
// This function is reachable only after the initiating input/control has
|
|
845
|
+
// already been proven to belong to the unique active composer (or after
|
|
846
|
+
// the scoped CDP expression proved and clicked that exact input).
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const element = await withinAttachmentDeadline(deadline, () => Promise.resolve(chooser.element()), "File-chooser backing-input resolution");
|
|
850
|
+
if (typeof element?.evaluate !== "function") {
|
|
851
|
+
throw new Error("The file chooser backing input could not be inspected safely.");
|
|
852
|
+
}
|
|
853
|
+
const scoped = await withinNativeAttachmentDeadline(deadline, timeoutMs => element.evaluate((candidate) => {
|
|
854
|
+
const visible = (node) => {
|
|
855
|
+
if (node.hidden || node.closest("[hidden], [inert], [aria-hidden='true']") !== null)
|
|
856
|
+
return false;
|
|
857
|
+
const style = window.getComputedStyle(node);
|
|
858
|
+
const rect = node.getBoundingClientRect();
|
|
859
|
+
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"
|
|
860
|
+
&& style.pointerEvents !== "none" && (rect.width > 0 || rect.height > 0);
|
|
861
|
+
};
|
|
862
|
+
const textboxes = Array.from(document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")).filter(visible);
|
|
863
|
+
const composers = [...new Set(textboxes
|
|
864
|
+
.map(textbox => textbox.closest("form")
|
|
865
|
+
?? textbox.closest("[data-testid*='composer' i]")
|
|
866
|
+
?? textbox.closest("[aria-label*='composer' i]")
|
|
867
|
+
?? textbox.closest("[class*='composer' i]"))
|
|
868
|
+
.filter((value) => value !== null))];
|
|
869
|
+
if (composers.length !== 1 || !composers[0].contains(candidate))
|
|
870
|
+
return false;
|
|
871
|
+
const all = Array.from(composers[0].querySelectorAll("input[type='file']"))
|
|
872
|
+
.filter(input => !input.disabled && input.getAttribute("aria-disabled") !== "true");
|
|
873
|
+
const preferred = all.filter(input => input.id === "upload-files");
|
|
874
|
+
const nonImage = all.filter(input => input.getAttribute("accept") !== "image/*");
|
|
875
|
+
const resolved = preferred.length > 0 ? preferred : nonImage.length > 0 ? nonImage : all;
|
|
876
|
+
return resolved.length === 1 && resolved[0] === candidate;
|
|
877
|
+
}, undefined, { timeoutMs }), "File-chooser active-composer identity check", 2_000);
|
|
878
|
+
if (!scoped) {
|
|
879
|
+
throw new Error("The file chooser backing input was not the unique active-composer upload target.");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
async function locatorCount(locator, deadline) {
|
|
883
|
+
if (locator === undefined) {
|
|
518
884
|
return 0;
|
|
519
885
|
}
|
|
886
|
+
if (deadline !== undefined && typeof locator.allTextContents === "function") {
|
|
887
|
+
const values = await withinNativeAttachmentDeadline(deadline, timeoutMs => locator.allTextContents({ timeoutMs }), "Locator enumeration", 2_000);
|
|
888
|
+
return values.length;
|
|
889
|
+
}
|
|
890
|
+
if (typeof locator.count !== "function")
|
|
891
|
+
return 0;
|
|
892
|
+
if (deadline !== undefined) {
|
|
893
|
+
return withinAttachmentDeadline(deadline, () => locator.count(), "Locator enumeration");
|
|
894
|
+
}
|
|
520
895
|
return locator.count();
|
|
521
896
|
}
|
|
897
|
+
async function locatorIsRendered(locator, deadline) {
|
|
898
|
+
if (typeof locator.evaluate !== "function")
|
|
899
|
+
return false;
|
|
900
|
+
return withinNativeAttachmentDeadline(deadline, timeoutMs => locator.evaluate((element) => {
|
|
901
|
+
const node = element;
|
|
902
|
+
if (node.hidden || node.closest("[hidden], [inert], [aria-hidden='true']") !== null)
|
|
903
|
+
return false;
|
|
904
|
+
const style = window.getComputedStyle(node);
|
|
905
|
+
const rect = node.getBoundingClientRect();
|
|
906
|
+
return style.display !== "none"
|
|
907
|
+
&& style.visibility !== "hidden"
|
|
908
|
+
&& style.opacity !== "0"
|
|
909
|
+
&& style.pointerEvents !== "none"
|
|
910
|
+
&& (rect.width > 0 || rect.height > 0);
|
|
911
|
+
}, undefined, { timeoutMs }), "Active-composer file-input visibility check", 1_000);
|
|
912
|
+
}
|
|
913
|
+
function attachmentBudget(deadline) {
|
|
914
|
+
const budget = remainingMs(deadline);
|
|
915
|
+
if (budget <= 0) {
|
|
916
|
+
throw new AttachmentDeadlineError("Attachment operation");
|
|
917
|
+
}
|
|
918
|
+
return budget;
|
|
919
|
+
}
|
|
920
|
+
class AttachmentDeadlineError extends Error {
|
|
921
|
+
constructor(label) {
|
|
922
|
+
super(`${label} exceeded the single attachment deadline.`);
|
|
923
|
+
this.name = "AttachmentDeadlineError";
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
async function withinAttachmentDeadline(deadline, operation, label) {
|
|
927
|
+
const budget = attachmentBudget(deadline);
|
|
928
|
+
try {
|
|
929
|
+
return await withTimeout(operation(), budget, `${label} exceeded the single attachment deadline.`);
|
|
930
|
+
}
|
|
931
|
+
catch (error) {
|
|
932
|
+
if (remainingMs(deadline) <= 0
|
|
933
|
+
|| (error instanceof Error && error.message.includes("exceeded the single attachment deadline"))) {
|
|
934
|
+
throw new AttachmentDeadlineError(label);
|
|
935
|
+
}
|
|
936
|
+
throw error;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async function withinNativeAttachmentDeadline(deadline, operation, label, capMs = Number.POSITIVE_INFINITY) {
|
|
940
|
+
const timeoutMs = Math.min(capMs, attachmentBudget(deadline));
|
|
941
|
+
try {
|
|
942
|
+
// Browser-native timeoutMs is the cancellation boundary. Do not wrap this
|
|
943
|
+
// promise in Promise.race: abandoning a mutation promise lets it fire later.
|
|
944
|
+
return await operation(timeoutMs);
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
if (remainingMs(deadline) <= 0) {
|
|
948
|
+
throw new AttachmentDeadlineError(label);
|
|
949
|
+
}
|
|
950
|
+
throw error;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async function attachmentDelay(_page, deadline, requestedMs) {
|
|
954
|
+
const budget = attachmentBudget(deadline);
|
|
955
|
+
const delayMs = Math.min(Math.max(0, requestedMs), Math.max(0, budget - 1));
|
|
956
|
+
if (delayMs <= 0) {
|
|
957
|
+
if (requestedMs > 0)
|
|
958
|
+
throw new AttachmentDeadlineError("Attachment settling delay");
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
// A host timer cannot strand a browser request and is sufficient for polling.
|
|
962
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
963
|
+
}
|
|
964
|
+
async function attachmentContext(page, _deadline) {
|
|
965
|
+
return contextFromPage(page, {}, { minimal: true });
|
|
966
|
+
}
|
|
522
967
|
export async function downloadLatestFile(env, args) {
|
|
523
968
|
const boot = await ensurePage(env);
|
|
524
969
|
if (!boot.ok) {
|
|
@@ -603,8 +1048,9 @@ async function tryGeneratedFilePreviewDownload(page, args) {
|
|
|
603
1048
|
}
|
|
604
1049
|
const assistant = assistantMessages.nth?.(selected.assistantIndex) ?? assistantMessages;
|
|
605
1050
|
const role = selected.tag === "button" ? "button" : "link";
|
|
606
|
-
const
|
|
607
|
-
|
|
1051
|
+
const controlLabel = selected.controlLabel ?? selected.filename;
|
|
1052
|
+
const affordance = assistant.getByRole?.(role, { name: controlLabel, exact: true })
|
|
1053
|
+
?? assistant.locator?.(`${selected.tag}[aria-label="${escapeCssAttribute(controlLabel)}"]`);
|
|
608
1054
|
const affordanceCount = await locatorCountWithTimeout(affordance, localGuardTimeout(timeoutMs, 5000), "generated_file_affordance_count_timeout");
|
|
609
1055
|
if (affordance === undefined || affordanceCount !== 1 || typeof affordance.click !== "function") {
|
|
610
1056
|
throw new Error(`Expected one clickable generated-file affordance for ${selected.filename}, found ${affordanceCount}.`);
|
|
@@ -614,8 +1060,10 @@ async function tryGeneratedFilePreviewDownload(page, args) {
|
|
|
614
1060
|
return resultOk(downloaded, await contextFromPage(page));
|
|
615
1061
|
}
|
|
616
1062
|
await affordance.click({ timeoutMs: localGuardTimeout(timeoutMs, 10000) });
|
|
617
|
-
const
|
|
618
|
-
const
|
|
1063
|
+
const labelledPreview = requiredLocator(page, `section[aria-label="${escapeCssAttribute(selected.filename)}"]`);
|
|
1064
|
+
const workbookPreviews = requiredLocator(page, "section[data-testid^='popcorn-']");
|
|
1065
|
+
const workbookPreview = workbookPreviews.filter?.({ hasText: selected.filename }) ?? workbookPreviews;
|
|
1066
|
+
const download = await waitForPreviewDownloadControl(page, [labelledPreview, workbookPreview], timeoutMs);
|
|
619
1067
|
if (download === undefined) {
|
|
620
1068
|
throw new Error(`The artifact preview for ${selected.filename} did not expose a visible Download control.`);
|
|
621
1069
|
}
|
|
@@ -628,7 +1076,7 @@ async function tryGeneratedFilePreviewDownload(page, args) {
|
|
|
628
1076
|
}
|
|
629
1077
|
async function inspectGeneratedFileAffordances(page, timeoutMs) {
|
|
630
1078
|
if (typeof page.evaluate === "function") {
|
|
631
|
-
const fromDom = await withTimeout(page.evaluate(() => {
|
|
1079
|
+
const fromDom = await withTimeout(page.evaluate((downloadLabels) => {
|
|
632
1080
|
const visible = (element) => {
|
|
633
1081
|
let current = element;
|
|
634
1082
|
while (current !== null) {
|
|
@@ -644,18 +1092,38 @@ async function inspectGeneratedFileAffordances(page, timeoutMs) {
|
|
|
644
1092
|
return true;
|
|
645
1093
|
};
|
|
646
1094
|
const fileLike = (value) => /^[^\\/\r\n]{1,255}\.[a-z0-9][a-z0-9._-]{0,15}$/i.test(value);
|
|
1095
|
+
const normalizedFilename = (value) => {
|
|
1096
|
+
const trimmed = value.trim();
|
|
1097
|
+
const lowered = trimmed.toLocaleLowerCase();
|
|
1098
|
+
const prefix = downloadLabels
|
|
1099
|
+
.map(label => label.trim())
|
|
1100
|
+
.filter(Boolean)
|
|
1101
|
+
.sort((left, right) => right.length - left.length)
|
|
1102
|
+
.find(label => lowered.startsWith(`${label.toLocaleLowerCase()} `));
|
|
1103
|
+
return prefix === undefined ? trimmed : trimmed.slice(prefix.length).trim();
|
|
1104
|
+
};
|
|
647
1105
|
const assistants = Array.from(document.querySelectorAll("[data-message-author-role='assistant']"));
|
|
648
1106
|
return assistants.flatMap((assistant, assistantIndex) => Array.from(assistant.querySelectorAll("button[aria-label], a[download], a[href*='/backend-api/files/']"))
|
|
649
1107
|
.filter(visible)
|
|
650
|
-
.map(element =>
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1108
|
+
.map(element => {
|
|
1109
|
+
const controlLabel = (element.getAttribute("aria-label") ?? element.textContent ?? "").trim();
|
|
1110
|
+
const text = (element.textContent ?? "").trim();
|
|
1111
|
+
return {
|
|
1112
|
+
assistantIndex,
|
|
1113
|
+
filename: normalizedFilename(controlLabel),
|
|
1114
|
+
controlLabel,
|
|
1115
|
+
tag: element.tagName.toLocaleLowerCase(),
|
|
1116
|
+
textFilename: normalizedFilename(text)
|
|
1117
|
+
};
|
|
1118
|
+
})
|
|
1119
|
+
.filter(item => (item.tag === "button" || item.tag === "a") && fileLike(item.filename) && item.filename === item.textFilename)
|
|
1120
|
+
.map(({ assistantIndex: index, filename, controlLabel, tag }) => ({
|
|
1121
|
+
assistantIndex: index,
|
|
1122
|
+
filename,
|
|
1123
|
+
...(controlLabel === filename ? {} : { controlLabel }),
|
|
1124
|
+
tag
|
|
1125
|
+
})));
|
|
1126
|
+
}, [...localeLabels.download]), timeoutMs, "Timed out while inspecting generated-file buttons.").catch(() => undefined);
|
|
659
1127
|
if (Array.isArray(fromDom))
|
|
660
1128
|
return fromDom;
|
|
661
1129
|
}
|
|
@@ -666,14 +1134,34 @@ async function inspectGeneratedFileAffordances(page, timeoutMs) {
|
|
|
666
1134
|
const buttonPattern = /<(button|a)\b[^>]*\baria-label=(['"])(.*?)\2[^>]*>([\s\S]*?)<\/\1>/gi;
|
|
667
1135
|
let match;
|
|
668
1136
|
while ((match = buttonPattern.exec(html)) !== null) {
|
|
669
|
-
const
|
|
1137
|
+
const controlLabel = decodeBasicHtml(match[3] ?? "").trim();
|
|
670
1138
|
const text = decodeBasicHtml((match[4] ?? "").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
671
|
-
|
|
672
|
-
|
|
1139
|
+
const filename = normalizeGeneratedFileControlLabel(controlLabel);
|
|
1140
|
+
const textFilename = normalizeGeneratedFileControlLabel(text);
|
|
1141
|
+
if (/^[^\\/\r\n]{1,255}\.[a-z0-9][a-z0-9._-]{0,15}$/i.test(filename) && filename === textFilename) {
|
|
1142
|
+
candidates.push({
|
|
1143
|
+
assistantIndex: 0,
|
|
1144
|
+
filename,
|
|
1145
|
+
...(controlLabel === filename ? {} : { controlLabel }),
|
|
1146
|
+
tag: (match[1] ?? "button").toLocaleLowerCase()
|
|
1147
|
+
});
|
|
673
1148
|
}
|
|
674
1149
|
}
|
|
675
1150
|
return candidates;
|
|
676
1151
|
}
|
|
1152
|
+
function normalizeGeneratedFileControlLabel(value) {
|
|
1153
|
+
return stripLocalizedDownloadPrefix(value, localeLabels.download);
|
|
1154
|
+
}
|
|
1155
|
+
export function stripLocalizedDownloadPrefix(value, labels) {
|
|
1156
|
+
const trimmed = value.trim();
|
|
1157
|
+
const lowered = trimmed.toLocaleLowerCase();
|
|
1158
|
+
const prefix = labels
|
|
1159
|
+
.map(label => label.trim())
|
|
1160
|
+
.filter(Boolean)
|
|
1161
|
+
.sort((left, right) => right.length - left.length)
|
|
1162
|
+
.find(label => lowered.startsWith(`${label.toLocaleLowerCase()} `));
|
|
1163
|
+
return prefix === undefined ? trimmed : trimmed.slice(prefix.length).trim();
|
|
1164
|
+
}
|
|
677
1165
|
function selectGeneratedFileAffordance(candidates, args) {
|
|
678
1166
|
let scoped = candidates;
|
|
679
1167
|
const from = args.from;
|
|
@@ -697,14 +1185,16 @@ function filenameMatches(filename, pattern) {
|
|
|
697
1185
|
return filename.toLocaleLowerCase().includes(pattern.toLocaleLowerCase());
|
|
698
1186
|
}
|
|
699
1187
|
}
|
|
700
|
-
async function waitForPreviewDownloadControl(page,
|
|
701
|
-
const deadline = Date.now() + Math.min(timeoutMs,
|
|
1188
|
+
async function waitForPreviewDownloadControl(page, previews, timeoutMs) {
|
|
1189
|
+
const deadline = Date.now() + Math.min(timeoutMs, 60000);
|
|
702
1190
|
while (Date.now() < deadline) {
|
|
703
|
-
for (const
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1191
|
+
for (const preview of previews) {
|
|
1192
|
+
for (const label of localeLabels.download) {
|
|
1193
|
+
const control = preview.getByRole?.("button", { name: label, exact: true })
|
|
1194
|
+
?? preview.locator?.(`button[aria-label="${escapeCssAttribute(label)}"]`);
|
|
1195
|
+
if (await locatorCountWithTimeout(control, localGuardTimeout(timeoutMs, 2000), "artifact_preview_download_count_timeout") === 1) {
|
|
1196
|
+
return control;
|
|
1197
|
+
}
|
|
708
1198
|
}
|
|
709
1199
|
}
|
|
710
1200
|
if (typeof page.waitForTimeout === "function") {
|
|
@@ -727,28 +1217,114 @@ function decodeBasicHtml(value) {
|
|
|
727
1217
|
.replace(/>/gi, ">")
|
|
728
1218
|
.replace(/&/gi, "&");
|
|
729
1219
|
}
|
|
730
|
-
async function
|
|
731
|
-
|
|
732
|
-
|
|
1220
|
+
async function resolveUniqueActiveComposerFileInput(page, deadline) {
|
|
1221
|
+
const candidates = requiredLocator(page, cssSelectors.hiddenFileInputs);
|
|
1222
|
+
if (typeof candidates.count !== "function") {
|
|
1223
|
+
throw new Error("ChatGPT file inputs could not be enumerated safely.");
|
|
1224
|
+
}
|
|
1225
|
+
const count = await locatorCount(candidates, deadline);
|
|
1226
|
+
const eligible = [];
|
|
1227
|
+
for (let index = 0; index < count; index += 1) {
|
|
1228
|
+
const input = count === 1 ? candidates : candidates.nth?.(index);
|
|
1229
|
+
if (input === undefined || typeof input.evaluate !== "function")
|
|
1230
|
+
continue;
|
|
1231
|
+
const scoped = await withinNativeAttachmentDeadline(deadline, timeoutMs => input.evaluate((element) => {
|
|
1232
|
+
const candidate = element;
|
|
1233
|
+
const visible = (node) => {
|
|
1234
|
+
if (node.hidden || node.closest("[hidden], [inert], [aria-hidden='true']") !== null)
|
|
1235
|
+
return false;
|
|
1236
|
+
const style = window.getComputedStyle(node);
|
|
1237
|
+
const rect = node.getBoundingClientRect();
|
|
1238
|
+
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"
|
|
1239
|
+
&& (rect.width > 0 || rect.height > 0);
|
|
1240
|
+
};
|
|
1241
|
+
const textboxes = Array.from(document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")).filter(visible);
|
|
1242
|
+
const composers = [...new Set(textboxes
|
|
1243
|
+
.map(textbox => textbox.closest("form")
|
|
1244
|
+
?? textbox.closest("[data-testid*='composer' i]")
|
|
1245
|
+
?? textbox.closest("[aria-label*='composer' i]")
|
|
1246
|
+
?? textbox.closest("[class*='composer' i]"))
|
|
1247
|
+
.filter((value) => value !== null))];
|
|
1248
|
+
if (composers.length !== 1 || !composers[0].contains(candidate))
|
|
1249
|
+
return false;
|
|
1250
|
+
const all = Array.from(composers[0].querySelectorAll("input[type='file']"))
|
|
1251
|
+
.filter(input => !input.disabled && input.getAttribute("aria-disabled") !== "true");
|
|
1252
|
+
const preferred = all.filter(input => input.id === "upload-files");
|
|
1253
|
+
const nonImage = all.filter(input => input.getAttribute("accept") !== "image/*");
|
|
1254
|
+
const resolved = preferred.length > 0 ? preferred : nonImage.length > 0 ? nonImage : all;
|
|
1255
|
+
return resolved.length === 1 && resolved[0] === candidate;
|
|
1256
|
+
}, undefined, { timeoutMs }), `File-input ${index + 1} scope check`, 2_000);
|
|
1257
|
+
if (scoped)
|
|
1258
|
+
eligible.push(input);
|
|
1259
|
+
}
|
|
1260
|
+
if (eligible.length !== 1) {
|
|
1261
|
+
throw new Error("The active browser exposes no sanctioned native file handoff because the active composer file input was not uniquely available.");
|
|
1262
|
+
}
|
|
1263
|
+
return eligible[0];
|
|
1264
|
+
}
|
|
1265
|
+
async function assertControlInUniqueActiveComposer(control, deadline, label) {
|
|
1266
|
+
if (control === undefined || typeof control.evaluate !== "function") {
|
|
1267
|
+
throw new Error(`${label} could not be scoped to the active composer.`);
|
|
733
1268
|
}
|
|
734
|
-
const
|
|
1269
|
+
const scoped = await withinNativeAttachmentDeadline(deadline, timeoutMs => control.evaluate((element) => {
|
|
1270
|
+
const visible = (node) => {
|
|
1271
|
+
if (node.hidden || node.closest("[hidden], [inert], [aria-hidden='true']") !== null)
|
|
1272
|
+
return false;
|
|
1273
|
+
const style = window.getComputedStyle(node);
|
|
1274
|
+
const rect = node.getBoundingClientRect();
|
|
1275
|
+
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"
|
|
1276
|
+
&& (rect.width > 0 || rect.height > 0);
|
|
1277
|
+
};
|
|
1278
|
+
const textboxes = Array.from(document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")).filter(visible);
|
|
1279
|
+
const composers = [...new Set(textboxes
|
|
1280
|
+
.map(textbox => textbox.closest("form")
|
|
1281
|
+
?? textbox.closest("[data-testid*='composer' i]")
|
|
1282
|
+
?? textbox.closest("[aria-label*='composer' i]")
|
|
1283
|
+
?? textbox.closest("[class*='composer' i]"))
|
|
1284
|
+
.filter((value) => value !== null))];
|
|
1285
|
+
return composers.length === 1 && composers[0].contains(element);
|
|
1286
|
+
}, undefined, { timeoutMs }), `${label} scope check`, 2_000);
|
|
1287
|
+
if (!scoped)
|
|
1288
|
+
throw new Error(`${label} was outside the unique active composer.`);
|
|
1289
|
+
}
|
|
1290
|
+
async function setResolvedFileInput(input, files, deadline, mutationState) {
|
|
735
1291
|
if (typeof input.setInputFiles !== "function") {
|
|
736
|
-
|
|
737
|
-
return;
|
|
1292
|
+
throw new Error("The active browser exposes no sanctioned native file handoff for ChatGPT's file input.");
|
|
738
1293
|
}
|
|
739
|
-
|
|
1294
|
+
mutationState.handoffStarted = true;
|
|
1295
|
+
await withinNativeAttachmentDeadline(deadline, timeoutMs => input.setInputFiles(files.map(file => file.path), { timeoutMs }), "Direct file-input handoff", 15_000);
|
|
740
1296
|
}
|
|
741
|
-
async function readBrowserInputDiagnostic(page) {
|
|
1297
|
+
async function readBrowserInputDiagnostic(page, timeoutMs) {
|
|
742
1298
|
if (typeof page.evaluate !== "function") {
|
|
743
1299
|
return undefined;
|
|
744
1300
|
}
|
|
745
1301
|
return page.evaluate(() => {
|
|
746
|
-
const
|
|
747
|
-
||
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1302
|
+
const visible = (element) => {
|
|
1303
|
+
if (element.hidden || element.closest("[hidden], [inert], [aria-hidden='true']") !== null)
|
|
1304
|
+
return false;
|
|
1305
|
+
const style = window.getComputedStyle(element);
|
|
1306
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0")
|
|
1307
|
+
return false;
|
|
1308
|
+
const rect = element.getBoundingClientRect();
|
|
1309
|
+
return rect.width > 0 || rect.height > 0;
|
|
1310
|
+
};
|
|
1311
|
+
const textboxes = Array.from(document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")).filter(visible);
|
|
1312
|
+
const composers = [...new Set(textboxes
|
|
1313
|
+
.map(textbox => textbox.closest("form")
|
|
1314
|
+
?? textbox.closest("[data-testid*='composer' i]")
|
|
1315
|
+
?? textbox.closest("[aria-label*='composer' i]")
|
|
1316
|
+
?? textbox.closest("[class*='composer' i]"))
|
|
1317
|
+
.filter((value) => value !== null))];
|
|
1318
|
+
if (composers.length !== 1)
|
|
1319
|
+
return undefined;
|
|
1320
|
+
const allInputs = Array.from(composers[0].querySelectorAll("input[type='file']"))
|
|
1321
|
+
.filter(input => !input.disabled && input.getAttribute("aria-disabled") !== "true");
|
|
1322
|
+
const preferred = allInputs.filter(input => input.id === "upload-files");
|
|
1323
|
+
const nonImage = allInputs.filter(input => input.getAttribute("accept") !== "image/*");
|
|
1324
|
+
const inputs = preferred.length > 0 ? preferred : nonImage.length > 0 ? nonImage : allInputs;
|
|
1325
|
+
if (inputs.length !== 1)
|
|
1326
|
+
return undefined;
|
|
1327
|
+
const input = inputs[0];
|
|
752
1328
|
return {
|
|
753
1329
|
files: Array.from(input.files ?? []).map(file => {
|
|
754
1330
|
const diagnostic = {
|
|
@@ -764,40 +1340,7 @@ async function readBrowserInputDiagnostic(page) {
|
|
|
764
1340
|
return diagnostic;
|
|
765
1341
|
})
|
|
766
1342
|
};
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
async function setFilesViaDomDataTransfer(page, files) {
|
|
770
|
-
const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0);
|
|
771
|
-
const maxInlineBytes = 25 * 1024 * 1024;
|
|
772
|
-
if (totalBytes > maxInlineBytes) {
|
|
773
|
-
throw new Error(`No file chooser or setInputFiles support is available for large uploads. ${CODEX_UPLOAD_PERMISSION_FIX} ${CHROME_FILE_URL_PERMISSION_FIX}`);
|
|
774
|
-
}
|
|
775
|
-
if (typeof page.evaluate !== "function") {
|
|
776
|
-
throw new Error(`No file chooser, setInputFiles, or page.evaluate support is available for file upload. ${CODEX_UPLOAD_PERMISSION_FIX} ${CHROME_FILE_URL_PERMISSION_FIX}`);
|
|
777
|
-
}
|
|
778
|
-
const payload = await Promise.all(files.map(async (file) => ({
|
|
779
|
-
name: file.name,
|
|
780
|
-
bytesBase64: (await readFile(file.path)).toString("base64"),
|
|
781
|
-
type: guessMimeType(file.name)
|
|
782
|
-
})));
|
|
783
|
-
await page.evaluate(async (payload) => {
|
|
784
|
-
const input = (document.querySelector("#upload-files") || document.querySelector("input[type='file']:not([accept='image/*'])") || document.querySelector("input[type='file']"));
|
|
785
|
-
if (!input) {
|
|
786
|
-
throw new Error("No ChatGPT file input found in the DOM.");
|
|
787
|
-
}
|
|
788
|
-
const dataTransfer = new DataTransfer();
|
|
789
|
-
for (const item of payload) {
|
|
790
|
-
const binary = atob(item.bytesBase64);
|
|
791
|
-
const bytes = new Uint8Array(binary.length);
|
|
792
|
-
for (let index = 0; index < binary.length; index += 1) {
|
|
793
|
-
bytes[index] = binary.charCodeAt(index);
|
|
794
|
-
}
|
|
795
|
-
dataTransfer.items.add(new File([bytes], item.name, { type: item.type }));
|
|
796
|
-
}
|
|
797
|
-
input.files = dataTransfer.files;
|
|
798
|
-
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
799
|
-
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
800
|
-
}, payload);
|
|
1343
|
+
}, undefined, { timeoutMs });
|
|
801
1344
|
}
|
|
802
1345
|
function guessMimeType(name) {
|
|
803
1346
|
if (/\.txt$/i.test(name))
|
|
@@ -812,9 +1355,17 @@ function guessMimeType(name) {
|
|
|
812
1355
|
return "text/markdown";
|
|
813
1356
|
return "application/octet-stream";
|
|
814
1357
|
}
|
|
815
|
-
function
|
|
1358
|
+
function isUploadPermissionBlocker(error) {
|
|
1359
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1360
|
+
return /Allow access to file URLs|Codex Settings > Computer Use|Browser Use rejected|requested that files not be uploaded|permission denied|browser blocked|fileChooser\.setFiles failed[^\n]*(?:Not allowed|permission|denied|rejected)/i.test(message);
|
|
1361
|
+
}
|
|
1362
|
+
function isUploadTransportFailure(error) {
|
|
1363
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1364
|
+
return /native pipe closed before response|browser bridge.*(?:closed|disconnect)|connection (?:was )?closed|target page, context or browser has been closed/i.test(message);
|
|
1365
|
+
}
|
|
1366
|
+
function isUploadPathFailure(error) {
|
|
816
1367
|
const message = error instanceof Error ? error.message : String(error);
|
|
817
|
-
return /
|
|
1368
|
+
return /No ChatGPT upload path completed/i.test(message);
|
|
818
1369
|
}
|
|
819
1370
|
function uploadPermissionMessage(error) {
|
|
820
1371
|
const message = error instanceof Error ? error.message : String(error);
|