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.
Files changed (217) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/contracts/v1/fixtures/backend-capabilities.json +1 -0
  3. package/contracts/v1/fixtures/command-descriptors.json +29 -2
  4. package/contracts/v1/fixtures/doctor-bridge-upload.json +2 -2
  5. package/contracts/v1/fixtures/doctor-scenario-preflight.json +26 -4
  6. package/contracts/v1/fixtures/files-attach-indeterminate.json +30 -0
  7. package/contracts/v1/fixtures/help-root.json +1 -1
  8. package/contracts/v1/fixtures/messages-stop-indeterminate.json +31 -0
  9. package/contracts/v1/fixtures/messages-stop-needs-confirmation.json +20 -0
  10. package/contracts/v1/fixtures/messages-stop-noop.json +19 -0
  11. package/contracts/v1/fixtures/messages-stop-success.json +21 -0
  12. package/contracts/v1/fixtures/primitive-bootstrap-blocker.json +3 -3
  13. package/contracts/v1/fixtures/reports-create-redacted.json +3 -3
  14. package/contracts/v1/fixtures/reports-summarize-redacted.json +1 -1
  15. package/contracts/v1/fixtures/run-browser-bridge-blocker.json +12 -12
  16. package/contracts/v1/fixtures/stream-blocked.ndjson +2 -2
  17. package/contracts/v1/manifest.json +25 -0
  18. package/contracts/v1/parity-suite.json +19 -1
  19. package/dist/codex-chatgpt-control-backend.mjs +3654 -603
  20. package/dist/codex-chatgpt-control.bundle.mjs +3627 -546
  21. package/dist/src/backend/client.d.ts +2 -1
  22. package/dist/src/backend/client.js +1 -0
  23. package/dist/src/backend/protocol.d.ts +1 -1
  24. package/dist/src/backend/protocol.js +1 -0
  25. package/dist/src/backend/session.js +2 -0
  26. package/dist/src/browser/attach.d.ts +1 -0
  27. package/dist/src/browser/attach.js +69 -35
  28. package/dist/src/browser/chatgpt-url.d.ts +8 -0
  29. package/dist/src/browser/chatgpt-url.js +28 -0
  30. package/dist/src/browser/downloads.d.ts +1 -1
  31. package/dist/src/browser/downloads.js +13 -4
  32. package/dist/src/browser/page-state.js +211 -4
  33. package/dist/src/client.d.ts +2 -1
  34. package/dist/src/client.js +2 -1
  35. package/dist/src/commands/configuration.js +330 -73
  36. package/dist/src/commands/context.d.ts +5 -1
  37. package/dist/src/commands/context.js +2 -2
  38. package/dist/src/commands/conversation.js +13 -1
  39. package/dist/src/commands/doctor.js +24 -2
  40. package/dist/src/commands/experience.d.ts +1 -0
  41. package/dist/src/commands/experience.js +156 -23
  42. package/dist/src/commands/files.d.ts +1 -0
  43. package/dist/src/commands/files.js +877 -170
  44. package/dist/src/commands/messages.d.ts +2 -1
  45. package/dist/src/commands/messages.js +310 -2
  46. package/dist/src/commands/modes.js +197 -52
  47. package/dist/src/commands/registry.js +8 -0
  48. package/dist/src/commands/sequence.js +3 -1
  49. package/dist/src/commands/session.d.ts +4 -1
  50. package/dist/src/commands/session.js +34 -3
  51. package/dist/src/dom/generation-state.d.ts +6 -1
  52. package/dist/src/dom/generation-state.js +206 -45
  53. package/dist/src/dom/locale/am.d.ts +23 -1
  54. package/dist/src/dom/locale/am.js +23 -1
  55. package/dist/src/dom/locale/ar.d.ts +21 -1
  56. package/dist/src/dom/locale/ar.js +24 -4
  57. package/dist/src/dom/locale/bg.d.ts +19 -1
  58. package/dist/src/dom/locale/bg.js +22 -4
  59. package/dist/src/dom/locale/bn.d.ts +23 -1
  60. package/dist/src/dom/locale/bn.js +23 -1
  61. package/dist/src/dom/locale/bs.d.ts +17 -1
  62. package/dist/src/dom/locale/bs.js +21 -5
  63. package/dist/src/dom/locale/ca.d.ts +20 -1
  64. package/dist/src/dom/locale/ca.js +25 -6
  65. package/dist/src/dom/locale/cs.d.ts +17 -1
  66. package/dist/src/dom/locale/cs.js +17 -1
  67. package/dist/src/dom/locale/da.d.ts +17 -1
  68. package/dist/src/dom/locale/da.js +18 -2
  69. package/dist/src/dom/locale/de.d.ts +16 -1
  70. package/dist/src/dom/locale/de.js +18 -3
  71. package/dist/src/dom/locale/el.d.ts +18 -1
  72. package/dist/src/dom/locale/el.js +21 -4
  73. package/dist/src/dom/locale/en.d.ts +2 -1
  74. package/dist/src/dom/locale/en.js +2 -1
  75. package/dist/src/dom/locale/es-419.d.ts +18 -1
  76. package/dist/src/dom/locale/es-419.js +18 -1
  77. package/dist/src/dom/locale/es-ES.d.ts +18 -1
  78. package/dist/src/dom/locale/es-ES.js +18 -1
  79. package/dist/src/dom/locale/et.d.ts +16 -1
  80. package/dist/src/dom/locale/et.js +16 -1
  81. package/dist/src/dom/locale/fa.d.ts +21 -1
  82. package/dist/src/dom/locale/fa.js +23 -3
  83. package/dist/src/dom/locale/fi.d.ts +18 -1
  84. package/dist/src/dom/locale/fi.js +18 -1
  85. package/dist/src/dom/locale/fr-CA.d.ts +15 -1
  86. package/dist/src/dom/locale/fr-CA.js +15 -1
  87. package/dist/src/dom/locale/fr-FR.d.ts +18 -1
  88. package/dist/src/dom/locale/fr-FR.js +22 -5
  89. package/dist/src/dom/locale/gu.d.ts +23 -1
  90. package/dist/src/dom/locale/gu.js +27 -5
  91. package/dist/src/dom/locale/hi.d.ts +23 -1
  92. package/dist/src/dom/locale/hi.js +25 -3
  93. package/dist/src/dom/locale/hr.d.ts +16 -1
  94. package/dist/src/dom/locale/hr.js +19 -4
  95. package/dist/src/dom/locale/hu.d.ts +17 -1
  96. package/dist/src/dom/locale/hu.js +20 -4
  97. package/dist/src/dom/locale/hy.d.ts +23 -1
  98. package/dist/src/dom/locale/hy.js +26 -4
  99. package/dist/src/dom/locale/id.d.ts +17 -1
  100. package/dist/src/dom/locale/id.js +19 -3
  101. package/dist/src/dom/locale/index.js +1 -0
  102. package/dist/src/dom/locale/is.d.ts +21 -1
  103. package/dist/src/dom/locale/is.js +21 -1
  104. package/dist/src/dom/locale/it.d.ts +15 -1
  105. package/dist/src/dom/locale/it.js +20 -6
  106. package/dist/src/dom/locale/ja.d.ts +19 -1
  107. package/dist/src/dom/locale/ja.js +23 -5
  108. package/dist/src/dom/locale/ka.d.ts +23 -1
  109. package/dist/src/dom/locale/ka.js +23 -1
  110. package/dist/src/dom/locale/kk.d.ts +23 -1
  111. package/dist/src/dom/locale/kk.js +25 -3
  112. package/dist/src/dom/locale/kn.d.ts +22 -1
  113. package/dist/src/dom/locale/kn.js +26 -5
  114. package/dist/src/dom/locale/ko.d.ts +17 -1
  115. package/dist/src/dom/locale/ko.js +17 -1
  116. package/dist/src/dom/locale/lt.d.ts +18 -1
  117. package/dist/src/dom/locale/lt.js +20 -3
  118. package/dist/src/dom/locale/lv.d.ts +18 -1
  119. package/dist/src/dom/locale/lv.js +18 -1
  120. package/dist/src/dom/locale/mk.d.ts +22 -1
  121. package/dist/src/dom/locale/mk.js +26 -5
  122. package/dist/src/dom/locale/ml.d.ts +23 -1
  123. package/dist/src/dom/locale/ml.js +25 -3
  124. package/dist/src/dom/locale/mn.d.ts +23 -1
  125. package/dist/src/dom/locale/mn.js +23 -1
  126. package/dist/src/dom/locale/mr.d.ts +23 -1
  127. package/dist/src/dom/locale/mr.js +25 -3
  128. package/dist/src/dom/locale/ms.d.ts +20 -1
  129. package/dist/src/dom/locale/ms.js +20 -1
  130. package/dist/src/dom/locale/my.d.ts +22 -1
  131. package/dist/src/dom/locale/my.js +24 -3
  132. package/dist/src/dom/locale/nb.d.ts +17 -1
  133. package/dist/src/dom/locale/nb.js +17 -1
  134. package/dist/src/dom/locale/nl.d.ts +20 -2
  135. package/dist/src/dom/locale/nl.js +22 -4
  136. package/dist/src/dom/locale/pa.d.ts +23 -1
  137. package/dist/src/dom/locale/pa.js +27 -5
  138. package/dist/src/dom/locale/pl.d.ts +17 -1
  139. package/dist/src/dom/locale/pl.js +19 -3
  140. package/dist/src/dom/locale/pt-BR.d.ts +18 -1
  141. package/dist/src/dom/locale/pt-BR.js +20 -3
  142. package/dist/src/dom/locale/pt-PT.d.ts +18 -1
  143. package/dist/src/dom/locale/pt-PT.js +22 -5
  144. package/dist/src/dom/locale/ro.d.ts +15 -1
  145. package/dist/src/dom/locale/ro.js +18 -4
  146. package/dist/src/dom/locale/ru.d.ts +23 -1
  147. package/dist/src/dom/locale/ru.js +24 -2
  148. package/dist/src/dom/locale/sk.d.ts +17 -1
  149. package/dist/src/dom/locale/sk.js +19 -3
  150. package/dist/src/dom/locale/sl.d.ts +21 -1
  151. package/dist/src/dom/locale/sl.js +24 -4
  152. package/dist/src/dom/locale/so.d.ts +22 -1
  153. package/dist/src/dom/locale/so.js +24 -3
  154. package/dist/src/dom/locale/sq.d.ts +21 -1
  155. package/dist/src/dom/locale/sq.js +25 -5
  156. package/dist/src/dom/locale/sr.d.ts +24 -1
  157. package/dist/src/dom/locale/sr.js +25 -2
  158. package/dist/src/dom/locale/sv.d.ts +16 -1
  159. package/dist/src/dom/locale/sv.js +16 -1
  160. package/dist/src/dom/locale/sw.d.ts +18 -1
  161. package/dist/src/dom/locale/sw.js +18 -1
  162. package/dist/src/dom/locale/ta.d.ts +23 -1
  163. package/dist/src/dom/locale/ta.js +27 -5
  164. package/dist/src/dom/locale/te.d.ts +23 -1
  165. package/dist/src/dom/locale/te.js +27 -5
  166. package/dist/src/dom/locale/th.d.ts +11 -1
  167. package/dist/src/dom/locale/th.js +11 -1
  168. package/dist/src/dom/locale/tl.d.ts +8 -1
  169. package/dist/src/dom/locale/tl.js +8 -1
  170. package/dist/src/dom/locale/tr.d.ts +21 -1
  171. package/dist/src/dom/locale/tr.js +23 -3
  172. package/dist/src/dom/locale/types.d.ts +1 -1
  173. package/dist/src/dom/locale/uk.d.ts +23 -1
  174. package/dist/src/dom/locale/uk.js +25 -3
  175. package/dist/src/dom/locale/ur.d.ts +22 -1
  176. package/dist/src/dom/locale/ur.js +25 -4
  177. package/dist/src/dom/locale/vi.d.ts +22 -1
  178. package/dist/src/dom/locale/vi.js +25 -4
  179. package/dist/src/dom/locale/zh-HK.d.ts +21 -1
  180. package/dist/src/dom/locale/zh-HK.js +23 -3
  181. package/dist/src/dom/locale/zh-Hans.d.ts +23 -1
  182. package/dist/src/dom/locale/zh-Hans.js +27 -5
  183. package/dist/src/dom/locale/zh-TW.d.ts +23 -1
  184. package/dist/src/dom/locale/zh-TW.js +25 -3
  185. package/dist/src/dom/menus.js +26 -2
  186. package/dist/src/dom/selectors.d.ts +1 -0
  187. package/dist/src/dom/selectors.js +9 -1
  188. package/dist/src/dom/wait-snapshot.d.ts +1 -0
  189. package/dist/src/dom/wait-snapshot.js +45 -22
  190. package/dist/src/errors.d.ts +1 -1
  191. package/dist/src/errors.js +2 -2
  192. package/dist/src/platform/local-paths.d.ts +1 -0
  193. package/dist/src/platform/local-paths.js +7 -3
  194. package/dist/src/safety/risk.d.ts +1 -0
  195. package/dist/src/safety/risk.js +1 -0
  196. package/dist/src/scripts/apply-intelligence-locale-captures.d.ts +10 -1
  197. package/dist/src/scripts/apply-intelligence-locale-captures.js +219 -7
  198. package/dist/src/scripts/capture-intelligence-locales.d.ts +119 -1
  199. package/dist/src/scripts/capture-intelligence-locales.js +653 -232
  200. package/dist/src/scripts/capture-surface-profile.d.ts +3 -1
  201. package/dist/src/scripts/capture-surface-profile.js +75 -25
  202. package/dist/src/scripts/live-smoke/scenarios.d.ts +3 -0
  203. package/dist/src/scripts/live-smoke/scenarios.js +321 -13
  204. package/dist/src/scripts/live-smoke.js +1 -0
  205. package/dist/src/scripts/locale-capture/surface-graph.d.ts +31 -0
  206. package/dist/src/scripts/locale-capture/surface-graph.js +50 -0
  207. package/dist/src/scripts/release-canary-module.d.ts +20 -0
  208. package/dist/src/scripts/release-canary-module.js +86 -0
  209. package/dist/src/types.d.ts +30 -6
  210. package/package.json +8 -2
  211. package/references/2026-07-16-chat-work-surfaces.md +13 -0
  212. package/references/2026-08-08-model-selection-locale-sweep.md +32 -0
  213. package/references/backend-protocol.md +22 -7
  214. package/references/language-coverage.md +24 -13
  215. package/references/localization.md +36 -14
  216. package/references/python-parity.md +9 -3
  217. 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
- const boot = await ensurePage(env);
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 uploadFiles(page, files, args.timeoutMs ?? 30000);
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.waitForTimeout?.(args.timeoutMs === undefined ? 1000 : Math.min(args.timeoutMs, 3000));
159
- const readiness = await waitForAttachedFilesReady(page, files, args.timeoutMs ?? 30000);
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
- const blocker = {
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 contextFromPage(page), preflight.warnings);
189
+ return resultOk(data, await attachmentContext(page, deadline), preflight.warnings);
198
190
  }
199
191
  catch (error) {
200
- if (isUploadBridgeBlocker(error)) {
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 contextFromPage(page)
239
+ context: await attachmentContext(page, deadline)
214
240
  };
215
241
  }
216
- return resultError(error instanceof Error ? error : new Error(String(error)), await contextFromPage(page));
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 process.platform === "win32"
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 waitForAttachedFilesReady(page, files, timeoutMs) {
332
- const started = Date.now();
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
- while (Date.now() - started < timeoutMs) {
335
- const snapshot = await readAttachmentReadiness(page, files).catch(() => undefined);
336
- if (snapshot === undefined) {
337
- return { ready: true };
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 > 0 && snapshot.files.every(file => file.visible);
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
- if (!snapshot.processing && Date.now() - started >= Math.min(timeoutMs, 1000)) {
344
- return { ready: true };
345
- }
486
+ sawProcessing ||= snapshot.processing;
346
487
  if (snapshot.processingText !== undefined) {
347
488
  lastProcessingText = snapshot.processingText;
348
489
  }
349
- await page.waitForTimeout?.(250);
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 = { ready: false };
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((fileNames) => {
362
- const visibleText = document.body?.innerText ?? "";
508
+ return page.evaluate((args) => {
509
+ const expectedFiles = args.expectedFiles;
363
510
  const normalize = (value) => value.toLocaleLowerCase();
364
- const normalizedVisibleText = normalize(visibleText);
365
- const files = fileNames.map(name => ({
366
- name,
367
- visible: normalizedVisibleText.includes(normalize(name))
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 attachmentText = Array.from(document.querySelectorAll(attachmentSelectors))
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 relevantText = attachmentText.length > 0 ? attachmentText : visibleText;
388
- const processingMatch = /\b(uploading|processing|attaching|preparing|reading|scanning|analyzing)\b/i.exec(relevantText);
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
- files,
599
+ supported: true,
600
+ files: visibleFiles,
391
601
  processing: processingMatch !== null
392
602
  };
393
603
  if (processingMatch !== null) {
394
- snapshot.processingText = relevantText.slice(0, 500);
604
+ snapshot.processingText = attachmentText.slice(0, 500);
395
605
  }
396
606
  return snapshot;
397
- }, files.map(file => file.name));
607
+ }, {
608
+ expectedFiles: files.map(file => ({ name: file.name, bytes: file.bytes })),
609
+ baseline
610
+ }, { timeoutMs });
398
611
  }
399
- async function uploadFiles(page, files, timeoutMs) {
612
+ async function uploadFiles(page, files, deadline, mutationState) {
400
613
  const paths = files.map(file => file.path);
401
614
  const errors = [];
402
- const attempts = [
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 clickFileChooserTarget(page, "#upload-files", paths, timeoutMs, { requireVisible: true });
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 setHiddenFileInput(page, files);
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, timeoutMs) {
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 addPhotosFilesText = localeLabels.addPhotosFilesMenuItem[0];
443
- const menuItem = requiredLocator(page, "div[role='menuitem']").filter?.({ hasText: addPhotosFilesText });
444
- if (await locatorCount(menuItem) !== 1) {
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.click?.({ timeoutMs: Math.min(timeoutMs, 10000) });
450
- await page.waitForTimeout?.(250);
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
- const refreshedMenuItem = requiredLocator(page, "div[role='menuitem']").filter?.({ hasText: addPhotosFilesText });
453
- await clickFileChooserLocator(page, refreshedMenuItem, paths, timeoutMs);
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
- if (options.requireVisible === true && locator.isVisible !== undefined && !await locator.isVisible({ timeoutMs: 1000 })) {
461
- throw new Error(`Upload target is hidden: ${selector}`);
462
- }
463
- await clickFileChooserLocator(page, locator, paths, timeoutMs);
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, timeoutMs) {
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
- const chooserPromise = waitForFileChooser(page, timeoutMs);
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: Math.min(timeoutMs, 10000) });
729
+ await withinNativeAttachmentDeadline(deadline, timeoutMs => locator.click({ timeoutMs }), "ChatGPT upload-control click", 10_000);
478
730
  }
479
731
  catch (error) {
480
- await chooserPromise.catch(() => undefined);
732
+ await chooserPromise;
481
733
  throw error;
482
734
  }
483
- const chooser = await chooserPromise;
484
- await validateChooserMultiplicity(chooser, paths);
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
- await chooser.setFiles(paths);
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 waitForFileChooser(page, timeoutMs) {
493
- const rawChooser = await page.waitForEvent?.("filechooser", {
494
- timeout: timeoutMs,
495
- timeoutMs
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 locatorCount(locator) {
517
- if (locator === undefined || typeof locator.count !== "function") {
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 setHiddenFileInput(page, files) {
575
- if (page === undefined) {
576
- throw new Error("No active page is available for file upload.");
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(/&quot;/gi, '"')
1215
+ .replace(/&#39;|&apos;/gi, "'")
1216
+ .replace(/&lt;/gi, "<")
1217
+ .replace(/&gt;/gi, ">")
1218
+ .replace(/&amp;/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 input = requiredLocator(page, cssSelectors.hiddenFileInputs).last?.() ?? requiredLocator(page, cssSelectors.hiddenFileInputs);
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
- await setFilesViaDomDataTransfer(page, files);
581
- return;
1292
+ throw new Error("The active browser exposes no sanctioned native file handoff for ChatGPT's file input.");
582
1293
  }
583
- await input.setInputFiles(files.map(file => file.path));
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 input = (document.querySelector("#upload-files")
591
- || document.querySelector("input[type='file']:not([accept='image/*'])")
592
- || document.querySelector("input[type='file']"));
593
- if (!input) {
594
- return { files: [] };
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 isUploadBridgeBlocker(error) {
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 /DataTransfer is not a constructor|No file chooser|setInputFiles|Allow access to file URLs|file upload|fileChooser\.setFiles failed|Not allowed|No ChatGPT upload path completed/i.test(message);
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);