codex-chatgpt-control 0.5.0-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 +22 -0
- package/contracts/v1/fixtures/backend-capabilities.json +1 -0
- package/contracts/v1/fixtures/command-descriptors.json +29 -2
- 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 +19 -1
- package/dist/codex-chatgpt-control-backend.mjs +3654 -603
- package/dist/codex-chatgpt-control.bundle.mjs +3627 -546
- 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/downloads.d.ts +1 -1
- package/dist/src/browser/downloads.js +13 -4
- 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 +330 -73
- 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/experience.d.ts +1 -0
- package/dist/src/commands/experience.js +156 -23
- package/dist/src/commands/files.d.ts +1 -0
- package/dist/src/commands/files.js +877 -170
- 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 +8 -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 +23 -1
- package/dist/src/dom/locale/am.js +23 -1
- package/dist/src/dom/locale/ar.d.ts +21 -1
- package/dist/src/dom/locale/ar.js +24 -4
- package/dist/src/dom/locale/bg.d.ts +19 -1
- package/dist/src/dom/locale/bg.js +22 -4
- package/dist/src/dom/locale/bn.d.ts +23 -1
- package/dist/src/dom/locale/bn.js +23 -1
- package/dist/src/dom/locale/bs.d.ts +17 -1
- package/dist/src/dom/locale/bs.js +21 -5
- package/dist/src/dom/locale/ca.d.ts +20 -1
- package/dist/src/dom/locale/ca.js +25 -6
- package/dist/src/dom/locale/cs.d.ts +17 -1
- package/dist/src/dom/locale/cs.js +17 -1
- package/dist/src/dom/locale/da.d.ts +17 -1
- package/dist/src/dom/locale/da.js +18 -2
- package/dist/src/dom/locale/de.d.ts +16 -1
- package/dist/src/dom/locale/de.js +18 -3
- package/dist/src/dom/locale/el.d.ts +18 -1
- package/dist/src/dom/locale/el.js +21 -4
- 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 +18 -1
- package/dist/src/dom/locale/es-419.js +18 -1
- package/dist/src/dom/locale/es-ES.d.ts +18 -1
- package/dist/src/dom/locale/es-ES.js +18 -1
- package/dist/src/dom/locale/et.d.ts +16 -1
- package/dist/src/dom/locale/et.js +16 -1
- package/dist/src/dom/locale/fa.d.ts +21 -1
- package/dist/src/dom/locale/fa.js +23 -3
- package/dist/src/dom/locale/fi.d.ts +18 -1
- package/dist/src/dom/locale/fi.js +18 -1
- package/dist/src/dom/locale/fr-CA.d.ts +15 -1
- package/dist/src/dom/locale/fr-CA.js +15 -1
- package/dist/src/dom/locale/fr-FR.d.ts +18 -1
- package/dist/src/dom/locale/fr-FR.js +22 -5
- package/dist/src/dom/locale/gu.d.ts +23 -1
- package/dist/src/dom/locale/gu.js +27 -5
- package/dist/src/dom/locale/hi.d.ts +23 -1
- package/dist/src/dom/locale/hi.js +25 -3
- package/dist/src/dom/locale/hr.d.ts +16 -1
- package/dist/src/dom/locale/hr.js +19 -4
- package/dist/src/dom/locale/hu.d.ts +17 -1
- package/dist/src/dom/locale/hu.js +20 -4
- package/dist/src/dom/locale/hy.d.ts +23 -1
- package/dist/src/dom/locale/hy.js +26 -4
- package/dist/src/dom/locale/id.d.ts +17 -1
- package/dist/src/dom/locale/id.js +19 -3
- package/dist/src/dom/locale/index.js +1 -0
- package/dist/src/dom/locale/is.d.ts +21 -1
- package/dist/src/dom/locale/is.js +21 -1
- package/dist/src/dom/locale/it.d.ts +15 -1
- package/dist/src/dom/locale/it.js +20 -6
- package/dist/src/dom/locale/ja.d.ts +19 -1
- package/dist/src/dom/locale/ja.js +23 -5
- package/dist/src/dom/locale/ka.d.ts +23 -1
- package/dist/src/dom/locale/ka.js +23 -1
- package/dist/src/dom/locale/kk.d.ts +23 -1
- package/dist/src/dom/locale/kk.js +25 -3
- package/dist/src/dom/locale/kn.d.ts +22 -1
- package/dist/src/dom/locale/kn.js +26 -5
- package/dist/src/dom/locale/ko.d.ts +17 -1
- package/dist/src/dom/locale/ko.js +17 -1
- package/dist/src/dom/locale/lt.d.ts +18 -1
- package/dist/src/dom/locale/lt.js +20 -3
- package/dist/src/dom/locale/lv.d.ts +18 -1
- package/dist/src/dom/locale/lv.js +18 -1
- package/dist/src/dom/locale/mk.d.ts +22 -1
- package/dist/src/dom/locale/mk.js +26 -5
- package/dist/src/dom/locale/ml.d.ts +23 -1
- package/dist/src/dom/locale/ml.js +25 -3
- package/dist/src/dom/locale/mn.d.ts +23 -1
- package/dist/src/dom/locale/mn.js +23 -1
- package/dist/src/dom/locale/mr.d.ts +23 -1
- package/dist/src/dom/locale/mr.js +25 -3
- package/dist/src/dom/locale/ms.d.ts +20 -1
- package/dist/src/dom/locale/ms.js +20 -1
- package/dist/src/dom/locale/my.d.ts +22 -1
- package/dist/src/dom/locale/my.js +24 -3
- package/dist/src/dom/locale/nb.d.ts +17 -1
- package/dist/src/dom/locale/nb.js +17 -1
- package/dist/src/dom/locale/nl.d.ts +20 -2
- package/dist/src/dom/locale/nl.js +22 -4
- package/dist/src/dom/locale/pa.d.ts +23 -1
- package/dist/src/dom/locale/pa.js +27 -5
- package/dist/src/dom/locale/pl.d.ts +17 -1
- package/dist/src/dom/locale/pl.js +19 -3
- package/dist/src/dom/locale/pt-BR.d.ts +18 -1
- package/dist/src/dom/locale/pt-BR.js +20 -3
- package/dist/src/dom/locale/pt-PT.d.ts +18 -1
- package/dist/src/dom/locale/pt-PT.js +22 -5
- package/dist/src/dom/locale/ro.d.ts +15 -1
- package/dist/src/dom/locale/ro.js +18 -4
- package/dist/src/dom/locale/ru.d.ts +23 -1
- package/dist/src/dom/locale/ru.js +24 -2
- package/dist/src/dom/locale/sk.d.ts +17 -1
- package/dist/src/dom/locale/sk.js +19 -3
- package/dist/src/dom/locale/sl.d.ts +21 -1
- package/dist/src/dom/locale/sl.js +24 -4
- package/dist/src/dom/locale/so.d.ts +22 -1
- package/dist/src/dom/locale/so.js +24 -3
- package/dist/src/dom/locale/sq.d.ts +21 -1
- package/dist/src/dom/locale/sq.js +25 -5
- package/dist/src/dom/locale/sr.d.ts +24 -1
- package/dist/src/dom/locale/sr.js +25 -2
- package/dist/src/dom/locale/sv.d.ts +16 -1
- package/dist/src/dom/locale/sv.js +16 -1
- package/dist/src/dom/locale/sw.d.ts +18 -1
- package/dist/src/dom/locale/sw.js +18 -1
- package/dist/src/dom/locale/ta.d.ts +23 -1
- package/dist/src/dom/locale/ta.js +27 -5
- package/dist/src/dom/locale/te.d.ts +23 -1
- package/dist/src/dom/locale/te.js +27 -5
- package/dist/src/dom/locale/th.d.ts +11 -1
- package/dist/src/dom/locale/th.js +11 -1
- package/dist/src/dom/locale/tl.d.ts +8 -1
- package/dist/src/dom/locale/tl.js +8 -1
- package/dist/src/dom/locale/tr.d.ts +21 -1
- package/dist/src/dom/locale/tr.js +23 -3
- package/dist/src/dom/locale/types.d.ts +1 -1
- package/dist/src/dom/locale/uk.d.ts +23 -1
- package/dist/src/dom/locale/uk.js +25 -3
- package/dist/src/dom/locale/ur.d.ts +22 -1
- package/dist/src/dom/locale/ur.js +25 -4
- package/dist/src/dom/locale/vi.d.ts +22 -1
- package/dist/src/dom/locale/vi.js +25 -4
- package/dist/src/dom/locale/zh-HK.d.ts +21 -1
- package/dist/src/dom/locale/zh-HK.js +23 -3
- package/dist/src/dom/locale/zh-Hans.d.ts +23 -1
- package/dist/src/dom/locale/zh-Hans.js +27 -5
- package/dist/src/dom/locale/zh-TW.d.ts +23 -1
- package/dist/src/dom/locale/zh-TW.js +25 -3
- package/dist/src/dom/menus.js +26 -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/platform/local-paths.d.ts +1 -0
- package/dist/src/platform/local-paths.js +7 -3
- 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 +10 -1
- package/dist/src/scripts/apply-intelligence-locale-captures.js +219 -7
- package/dist/src/scripts/capture-intelligence-locales.d.ts +119 -1
- package/dist/src/scripts/capture-intelligence-locales.js +653 -232
- package/dist/src/scripts/capture-surface-profile.d.ts +3 -1
- package/dist/src/scripts/capture-surface-profile.js +75 -25
- package/dist/src/scripts/live-smoke/scenarios.d.ts +3 -0
- package/dist/src/scripts/live-smoke/scenarios.js +321 -13
- package/dist/src/scripts/live-smoke.js +1 -0
- package/dist/src/scripts/locale-capture/surface-graph.d.ts +31 -0
- package/dist/src/scripts/locale-capture/surface-graph.js +50 -0
- package/dist/src/scripts/release-canary-module.d.ts +20 -0
- package/dist/src/scripts/release-canary-module.js +86 -0
- package/dist/src/types.d.ts +30 -6
- package/package.json +8 -2
- package/references/2026-07-16-chat-work-surfaces.md +13 -0
- package/references/2026-08-08-model-selection-locale-sweep.md +32 -0
- package/references/backend-protocol.md +22 -7
- package/references/language-coverage.md +24 -13
- package/references/localization.md +36 -14
- package/references/python-parity.md +9 -3
- package/references/troubleshooting.md +26 -4
|
@@ -6,11 +6,12 @@ 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";
|
|
10
|
-
import { basenameForHostPath, isHostAbsolutePath, resolveForHostPath } from "../platform/local-paths.js";
|
|
9
|
+
import { escapeRegExp, localeLabels } from "../dom/locale-labels.js";
|
|
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
|
-
import { localGuardTimeout } from "./timeouts.js";
|
|
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.";
|
|
15
16
|
const CHROME_FILE_URL_PERMISSION_FIX = "Chrome chrome://extensions > Codex extension > Details: enable Allow access to file URLs.";
|
|
16
17
|
const DEFAULT_MAX_BYTES_PER_FILE = 512 * 1024 * 1024;
|
|
@@ -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,
|
|
@@ -251,7 +335,7 @@ async function fileMetadata(absolute, bytes, includeHash = false) {
|
|
|
251
335
|
return metadata;
|
|
252
336
|
}
|
|
253
337
|
function extensionForHostPath(value) {
|
|
254
|
-
return
|
|
338
|
+
return currentHostPathPlatform() === "win32"
|
|
255
339
|
? path.win32.extname(value).toLowerCase()
|
|
256
340
|
: path.posix.extname(value).toLowerCase();
|
|
257
341
|
}
|
|
@@ -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
|
-
|
|
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);
|
|
451
698
|
}
|
|
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}`);
|
|
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.");
|
|
459
702
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
703
|
+
const refreshedMenuItem = menuItem;
|
|
704
|
+
await clickFileChooserLocator(page, refreshedMenuItem, paths, deadline, mutationState);
|
|
705
|
+
}
|
|
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;
|
|
464
712
|
}
|
|
465
|
-
async function clickFileChooserLocator(page, locator, paths,
|
|
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) {
|
|
@@ -526,6 +971,24 @@ export async function downloadLatestFile(env, args) {
|
|
|
526
971
|
}
|
|
527
972
|
const page = env.page;
|
|
528
973
|
try {
|
|
974
|
+
const generatedFileDownload = await tryGeneratedFilePreviewDownload(page, args);
|
|
975
|
+
if (generatedFileDownload !== undefined) {
|
|
976
|
+
return generatedFileDownload;
|
|
977
|
+
}
|
|
978
|
+
if (args.filenamePattern !== undefined) {
|
|
979
|
+
return {
|
|
980
|
+
ok: false,
|
|
981
|
+
status: "unsupported",
|
|
982
|
+
warnings: [],
|
|
983
|
+
blocker: {
|
|
984
|
+
kind: "download_unavailable",
|
|
985
|
+
code: "download_filename_not_found",
|
|
986
|
+
message: `No visible ChatGPT file affordance matched filenamePattern ${JSON.stringify(args.filenamePattern)}.`,
|
|
987
|
+
resumable: true
|
|
988
|
+
},
|
|
989
|
+
context: await contextFromPage(page)
|
|
990
|
+
};
|
|
991
|
+
}
|
|
529
992
|
const controls = requiredLocator(page, cssSelectors.downloadControls);
|
|
530
993
|
let count;
|
|
531
994
|
try {
|
|
@@ -571,28 +1034,297 @@ export async function downloadLatestFile(env, args) {
|
|
|
571
1034
|
return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
|
|
572
1035
|
}
|
|
573
1036
|
}
|
|
574
|
-
async function
|
|
575
|
-
|
|
576
|
-
|
|
1037
|
+
async function tryGeneratedFilePreviewDownload(page, args) {
|
|
1038
|
+
const timeoutMs = args.timeoutMs ?? 120000;
|
|
1039
|
+
const candidates = await inspectGeneratedFileAffordances(page, localGuardTimeout(timeoutMs, 5000));
|
|
1040
|
+
const selected = selectGeneratedFileAffordance(candidates, args);
|
|
1041
|
+
if (selected === undefined)
|
|
1042
|
+
return undefined;
|
|
1043
|
+
try {
|
|
1044
|
+
const assistantMessages = requiredLocator(page, cssSelectors.assistantMessages);
|
|
1045
|
+
const assistantCount = await locatorCountWithTimeout(assistantMessages, localGuardTimeout(timeoutMs, 5000), "generated_file_assistant_count_timeout");
|
|
1046
|
+
if (selected.assistantIndex < 0 || selected.assistantIndex >= assistantCount) {
|
|
1047
|
+
throw new Error("The selected generated-file assistant turn is no longer present.");
|
|
1048
|
+
}
|
|
1049
|
+
const assistant = assistantMessages.nth?.(selected.assistantIndex) ?? assistantMessages;
|
|
1050
|
+
const role = selected.tag === "button" ? "button" : "link";
|
|
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)}"]`);
|
|
1054
|
+
const affordanceCount = await locatorCountWithTimeout(affordance, localGuardTimeout(timeoutMs, 5000), "generated_file_affordance_count_timeout");
|
|
1055
|
+
if (affordance === undefined || affordanceCount !== 1 || typeof affordance.click !== "function") {
|
|
1056
|
+
throw new Error(`Expected one clickable generated-file affordance for ${selected.filename}, found ${affordanceCount}.`);
|
|
1057
|
+
}
|
|
1058
|
+
if (selected.tag === "a") {
|
|
1059
|
+
const downloaded = await waitForDownloadFromClick(page, () => affordance.click({ timeoutMs: localGuardTimeout(timeoutMs, 10000) }), args.destDir, timeoutMs, selected.filename);
|
|
1060
|
+
return resultOk(downloaded, await contextFromPage(page));
|
|
1061
|
+
}
|
|
1062
|
+
await affordance.click({ timeoutMs: localGuardTimeout(timeoutMs, 10000) });
|
|
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);
|
|
1067
|
+
if (download === undefined) {
|
|
1068
|
+
throw new Error(`The artifact preview for ${selected.filename} did not expose a visible Download control.`);
|
|
1069
|
+
}
|
|
1070
|
+
const downloaded = await waitForDownloadFromClick(page, async () => download.click?.({ timeoutMs: localGuardTimeout(timeoutMs, 10000) }), args.destDir, timeoutMs, selected.filename);
|
|
1071
|
+
return resultOk(downloaded, await contextFromPage(page));
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
async function inspectGeneratedFileAffordances(page, timeoutMs) {
|
|
1078
|
+
if (typeof page.evaluate === "function") {
|
|
1079
|
+
const fromDom = await withTimeout(page.evaluate((downloadLabels) => {
|
|
1080
|
+
const visible = (element) => {
|
|
1081
|
+
let current = element;
|
|
1082
|
+
while (current !== null) {
|
|
1083
|
+
const html = current;
|
|
1084
|
+
const style = window.getComputedStyle(html);
|
|
1085
|
+
const rect = html.getBoundingClientRect();
|
|
1086
|
+
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity || "1") <= 0)
|
|
1087
|
+
return false;
|
|
1088
|
+
if (current === element && (rect.width <= 0 || rect.height <= 0))
|
|
1089
|
+
return false;
|
|
1090
|
+
current = current.parentElement;
|
|
1091
|
+
}
|
|
1092
|
+
return true;
|
|
1093
|
+
};
|
|
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
|
+
};
|
|
1105
|
+
const assistants = Array.from(document.querySelectorAll("[data-message-author-role='assistant']"));
|
|
1106
|
+
return assistants.flatMap((assistant, assistantIndex) => Array.from(assistant.querySelectorAll("button[aria-label], a[download], a[href*='/backend-api/files/']"))
|
|
1107
|
+
.filter(visible)
|
|
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);
|
|
1127
|
+
if (Array.isArray(fromDom))
|
|
1128
|
+
return fromDom;
|
|
1129
|
+
}
|
|
1130
|
+
if (typeof page.content !== "function")
|
|
1131
|
+
return [];
|
|
1132
|
+
const html = await withTimeout(page.content(), timeoutMs, "Timed out while reading generated-file button markup.").catch(() => "");
|
|
1133
|
+
const candidates = [];
|
|
1134
|
+
const buttonPattern = /<(button|a)\b[^>]*\baria-label=(['"])(.*?)\2[^>]*>([\s\S]*?)<\/\1>/gi;
|
|
1135
|
+
let match;
|
|
1136
|
+
while ((match = buttonPattern.exec(html)) !== null) {
|
|
1137
|
+
const controlLabel = decodeBasicHtml(match[3] ?? "").trim();
|
|
1138
|
+
const text = decodeBasicHtml((match[4] ?? "").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
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
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return candidates;
|
|
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
|
+
}
|
|
1165
|
+
function selectGeneratedFileAffordance(candidates, args) {
|
|
1166
|
+
let scoped = candidates;
|
|
1167
|
+
const from = args.from;
|
|
1168
|
+
if (typeof from === "object" && from !== null) {
|
|
1169
|
+
scoped = scoped.filter(candidate => candidate.assistantIndex === from.assistantIndex);
|
|
1170
|
+
}
|
|
1171
|
+
else if (from !== "visible_conversation") {
|
|
1172
|
+
const latestAssistant = Math.max(-1, ...scoped.map(candidate => candidate.assistantIndex));
|
|
1173
|
+
scoped = scoped.filter(candidate => candidate.assistantIndex === latestAssistant);
|
|
1174
|
+
}
|
|
1175
|
+
if (args.filenamePattern !== undefined) {
|
|
1176
|
+
scoped = scoped.filter(candidate => filenameMatches(candidate.filename, args.filenamePattern));
|
|
1177
|
+
}
|
|
1178
|
+
return scoped.at(-1);
|
|
1179
|
+
}
|
|
1180
|
+
function filenameMatches(filename, pattern) {
|
|
1181
|
+
try {
|
|
1182
|
+
return new RegExp(pattern, "i").test(filename);
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
return filename.toLocaleLowerCase().includes(pattern.toLocaleLowerCase());
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
async function waitForPreviewDownloadControl(page, previews, timeoutMs) {
|
|
1189
|
+
const deadline = Date.now() + Math.min(timeoutMs, 60000);
|
|
1190
|
+
while (Date.now() < deadline) {
|
|
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
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (typeof page.waitForTimeout === "function") {
|
|
1201
|
+
await page.waitForTimeout(100);
|
|
1202
|
+
}
|
|
1203
|
+
else {
|
|
1204
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return undefined;
|
|
1208
|
+
}
|
|
1209
|
+
function escapeCssAttribute(value) {
|
|
1210
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n]/g, " ");
|
|
1211
|
+
}
|
|
1212
|
+
function decodeBasicHtml(value) {
|
|
1213
|
+
return value
|
|
1214
|
+
.replace(/"/gi, '"')
|
|
1215
|
+
.replace(/'|'/gi, "'")
|
|
1216
|
+
.replace(/</gi, "<")
|
|
1217
|
+
.replace(/>/gi, ">")
|
|
1218
|
+
.replace(/&/gi, "&");
|
|
1219
|
+
}
|
|
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.`);
|
|
577
1268
|
}
|
|
578
|
-
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) {
|
|
579
1291
|
if (typeof input.setInputFiles !== "function") {
|
|
580
|
-
|
|
581
|
-
return;
|
|
1292
|
+
throw new Error("The active browser exposes no sanctioned native file handoff for ChatGPT's file input.");
|
|
582
1293
|
}
|
|
583
|
-
|
|
1294
|
+
mutationState.handoffStarted = true;
|
|
1295
|
+
await withinNativeAttachmentDeadline(deadline, timeoutMs => input.setInputFiles(files.map(file => file.path), { timeoutMs }), "Direct file-input handoff", 15_000);
|
|
584
1296
|
}
|
|
585
|
-
async function readBrowserInputDiagnostic(page) {
|
|
1297
|
+
async function readBrowserInputDiagnostic(page, timeoutMs) {
|
|
586
1298
|
if (typeof page.evaluate !== "function") {
|
|
587
1299
|
return undefined;
|
|
588
1300
|
}
|
|
589
1301
|
return page.evaluate(() => {
|
|
590
|
-
const
|
|
591
|
-
||
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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];
|
|
596
1328
|
return {
|
|
597
1329
|
files: Array.from(input.files ?? []).map(file => {
|
|
598
1330
|
const diagnostic = {
|
|
@@ -608,40 +1340,7 @@ async function readBrowserInputDiagnostic(page) {
|
|
|
608
1340
|
return diagnostic;
|
|
609
1341
|
})
|
|
610
1342
|
};
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
async function setFilesViaDomDataTransfer(page, files) {
|
|
614
|
-
const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0);
|
|
615
|
-
const maxInlineBytes = 25 * 1024 * 1024;
|
|
616
|
-
if (totalBytes > maxInlineBytes) {
|
|
617
|
-
throw new Error(`No file chooser or setInputFiles support is available for large uploads. ${CODEX_UPLOAD_PERMISSION_FIX} ${CHROME_FILE_URL_PERMISSION_FIX}`);
|
|
618
|
-
}
|
|
619
|
-
if (typeof page.evaluate !== "function") {
|
|
620
|
-
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}`);
|
|
621
|
-
}
|
|
622
|
-
const payload = await Promise.all(files.map(async (file) => ({
|
|
623
|
-
name: file.name,
|
|
624
|
-
bytesBase64: (await readFile(file.path)).toString("base64"),
|
|
625
|
-
type: guessMimeType(file.name)
|
|
626
|
-
})));
|
|
627
|
-
await page.evaluate(async (payload) => {
|
|
628
|
-
const input = (document.querySelector("#upload-files") || document.querySelector("input[type='file']:not([accept='image/*'])") || document.querySelector("input[type='file']"));
|
|
629
|
-
if (!input) {
|
|
630
|
-
throw new Error("No ChatGPT file input found in the DOM.");
|
|
631
|
-
}
|
|
632
|
-
const dataTransfer = new DataTransfer();
|
|
633
|
-
for (const item of payload) {
|
|
634
|
-
const binary = atob(item.bytesBase64);
|
|
635
|
-
const bytes = new Uint8Array(binary.length);
|
|
636
|
-
for (let index = 0; index < binary.length; index += 1) {
|
|
637
|
-
bytes[index] = binary.charCodeAt(index);
|
|
638
|
-
}
|
|
639
|
-
dataTransfer.items.add(new File([bytes], item.name, { type: item.type }));
|
|
640
|
-
}
|
|
641
|
-
input.files = dataTransfer.files;
|
|
642
|
-
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
643
|
-
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
644
|
-
}, payload);
|
|
1343
|
+
}, undefined, { timeoutMs });
|
|
645
1344
|
}
|
|
646
1345
|
function guessMimeType(name) {
|
|
647
1346
|
if (/\.txt$/i.test(name))
|
|
@@ -656,9 +1355,17 @@ function guessMimeType(name) {
|
|
|
656
1355
|
return "text/markdown";
|
|
657
1356
|
return "application/octet-stream";
|
|
658
1357
|
}
|
|
659
|
-
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) {
|
|
660
1367
|
const message = error instanceof Error ? error.message : String(error);
|
|
661
|
-
return /
|
|
1368
|
+
return /No ChatGPT upload path completed/i.test(message);
|
|
662
1369
|
}
|
|
663
1370
|
function uploadPermissionMessage(error) {
|
|
664
1371
|
const message = error instanceof Error ? error.message : String(error);
|