pi-oracle 0.6.10 → 0.6.11
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 +11 -0
- package/extensions/oracle/worker/artifact-heuristics.d.mts +1 -0
- package/extensions/oracle/worker/artifact-heuristics.mjs +6 -1
- package/extensions/oracle/worker/auth-flow-helpers.mjs +8 -6
- package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +2 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +47 -1
- package/extensions/oracle/worker/run-job.mjs +159 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.6.11 - 2026-04-30
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- updated ChatGPT auth/readiness detection and model preset selection for the current ChatGPT UI, including bare effort chips like `Light` and current `Model` controls
|
|
9
|
+
- restored generated artifact capture for ChatGPT's current `Download the file` behavior buttons by associating response-local filenames with generic download controls and following the JSON `download_url` indirection to the final file bytes
|
|
10
|
+
- reduced false-positive artifact reporting from response text labels that look like filenames but do not have a confirmed downloadable control
|
|
11
|
+
|
|
12
|
+
### Validation
|
|
13
|
+
- verified live `instant`, `thinking_light`, `instant_auto_switch`, `pro_standard`, follow-up, and isolated local-extension oracle runs against the changed ChatGPT UI
|
|
14
|
+
- verified byte-correct generated artifact downloads in both live and isolated local-extension runs
|
|
15
|
+
|
|
5
16
|
## 0.6.10 - 2026-04-23
|
|
6
17
|
|
|
7
18
|
### Changed
|
|
@@ -22,6 +22,7 @@ export interface StructuralArtifactCandidateInput {
|
|
|
22
22
|
focusableInteractiveCount?: number;
|
|
23
23
|
focusableArtifactLabelCount?: number;
|
|
24
24
|
focusableOtherTextLength?: number;
|
|
25
|
+
fromResponseTextLabel?: boolean;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export interface StructuralArtifactCandidate {
|
|
@@ -8,6 +8,7 @@ const FILE_LABEL_PATTERN = new RegExp(FILE_LABEL_PATTERN_SOURCE, "g");
|
|
|
8
8
|
export const GENERIC_ARTIFACT_LABELS = ["ATTACHED", "DONE"];
|
|
9
9
|
const GENERIC_ARTIFACT_LABEL_SET = new Set(GENERIC_ARTIFACT_LABELS);
|
|
10
10
|
const GENERIC_DOWNLOAD_CONTROL_PATTERN = /(?:^|\b)(?:download|save)(?:\b|$)/i;
|
|
11
|
+
const CODE_MEMBER_CALL_LABEL_PATTERN = /^[A-Za-z_$][\w$]*\.(?:catch|close|filter|join|json|map|match|open|read|slice|split|text|then|trim|write)$/;
|
|
11
12
|
|
|
12
13
|
export function parseSnapshotEntries(snapshot) {
|
|
13
14
|
return String(snapshot || "")
|
|
@@ -47,7 +48,7 @@ export function extractArtifactLabels(value) {
|
|
|
47
48
|
const labels = [];
|
|
48
49
|
for (const match of String(value || "").matchAll(FILE_LABEL_PATTERN)) {
|
|
49
50
|
const normalized = sanitizeArtifactLabel(match[1] || match[0] || "");
|
|
50
|
-
if (!normalized || seen.has(normalized)) continue;
|
|
51
|
+
if (!normalized || CODE_MEMBER_CALL_LABEL_PATTERN.test(normalized) || seen.has(normalized)) continue;
|
|
51
52
|
seen.add(normalized);
|
|
52
53
|
labels.push(normalized);
|
|
53
54
|
}
|
|
@@ -118,6 +119,10 @@ export function isStructuralArtifactCandidate(candidate) {
|
|
|
118
119
|
return true;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
if (candidate?.fromResponseTextLabel === true && hasGenericDownloadControl(candidate?.controlLabel)) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
121
126
|
return false;
|
|
122
127
|
}
|
|
123
128
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// Usage: Imported by auth-bootstrap.mjs and sanity tests to exercise auth classification behavior without driving a browser.
|
|
5
5
|
// Invariants/Assumptions: Inputs are already captured snapshots/probe results from the live browser session; outputs are deterministic and side-effect free.
|
|
6
6
|
|
|
7
|
+
import { snapshotHasUsableComposerControls } from "./chatgpt-ui-helpers.mjs";
|
|
8
|
+
|
|
7
9
|
/** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthLoginProbe} OracleAuthLoginProbe */
|
|
8
10
|
/** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthPageClassification} OracleAuthPageClassification */
|
|
9
11
|
|
|
@@ -67,9 +69,9 @@ export function classifyChatAuthPage(args) {
|
|
|
67
69
|
const onAllowedOrigin = args.allowedOrigins.some((origin) => args.url.startsWith(origin));
|
|
68
70
|
const hasComposer = args.snapshot.includes(`textbox "${composerLabel}"`);
|
|
69
71
|
const hasAddFiles = args.snapshot.includes(`button "${addFilesLabel}"`);
|
|
70
|
-
const
|
|
71
|
-
args.snapshot
|
|
72
|
-
|
|
72
|
+
const hasUsableComposer = composerLabel === DEFAULT_COMPOSER_LABEL && addFilesLabel === DEFAULT_ADD_FILES_LABEL
|
|
73
|
+
? snapshotHasUsableComposerControls(args.snapshot)
|
|
74
|
+
: hasComposer && hasAddFiles;
|
|
73
75
|
|
|
74
76
|
const challengePatterns = [
|
|
75
77
|
/just a moment/i,
|
|
@@ -107,7 +109,7 @@ export function classifyChatAuthPage(args) {
|
|
|
107
109
|
return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/error page. Logs: ${args.logPath}` };
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
if (args.probe?.status === 401 || args.probe?.status === 403) {
|
|
112
|
+
if (args.probe?.status === 401 || (args.probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer || args.probe?.domLoginCta))) {
|
|
111
113
|
return {
|
|
112
114
|
state: "login_required",
|
|
113
115
|
message:
|
|
@@ -133,7 +135,7 @@ export function classifyChatAuthPage(args) {
|
|
|
133
135
|
};
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
if (onAllowedOrigin && args.probe?.status === 200
|
|
138
|
+
if (onAllowedOrigin && (args.probe?.status === 200 || args.probe?.status === 403) && hasUsableComposer) {
|
|
137
139
|
if (!args.probe?.domLoginCta) {
|
|
138
140
|
return {
|
|
139
141
|
state: "authenticated_and_ready",
|
|
@@ -150,7 +152,7 @@ export function classifyChatAuthPage(args) {
|
|
|
150
152
|
};
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
if (onAllowedOrigin && args.probe?.ok &&
|
|
155
|
+
if (onAllowedOrigin && args.probe?.ok && hasUsableComposer) {
|
|
154
156
|
return {
|
|
155
157
|
state: "authenticated_and_ready",
|
|
156
158
|
message: `Imported ChatGPT auth from ${args.cookieSourceLabel} into the isolated oracle profile. Logs: ${args.logPath}`,
|
|
@@ -15,6 +15,8 @@ export declare function requestedEffortLabel(selection: OracleUiSelection): stri
|
|
|
15
15
|
export declare function effortSelectionVisible(snapshot: string, effortLabel: string | undefined): boolean;
|
|
16
16
|
export declare function thinkingChipVisible(snapshot: string): boolean;
|
|
17
17
|
export declare function snapshotHasModelConfigurationUi(snapshot: string): boolean;
|
|
18
|
+
export declare function snapshotHasUsableComposerControls(snapshot: string): boolean;
|
|
19
|
+
export declare function snapshotHasModelOpener(snapshot: string): boolean;
|
|
18
20
|
export declare function autoSwitchToThinkingSelectionVisible(snapshot: string): boolean | undefined;
|
|
19
21
|
export declare function snapshotCanSafelySkipModelConfiguration(snapshot: string, selection: OracleUiSelection): boolean;
|
|
20
22
|
export declare function snapshotStronglyMatchesRequestedModel(snapshot: string, selection: OracleUiSelection): boolean;
|
|
@@ -28,6 +28,8 @@ const MODEL_FAMILY_PREFIX = {
|
|
|
28
28
|
const AUTO_SWITCH_LABEL = "Auto-switch to Thinking";
|
|
29
29
|
const THINKING_EFFORT_COMBOBOX_LABEL = "Thinking effort";
|
|
30
30
|
const PRO_THINKING_EFFORT_COMBOBOX_LABEL = "Pro thinking effort";
|
|
31
|
+
const EFFORT_LABELS = new Set(["Light", "Standard", "Extended", "Heavy"]);
|
|
32
|
+
const BARE_EFFORT_PATTERN = /^(light|standard|extended|heavy)(?:, click to remove)?$/i;
|
|
31
33
|
const THINKING_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?thinking(?:, click to remove)?$/i;
|
|
32
34
|
const PRO_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?pro(?:, click to remove)?$/i;
|
|
33
35
|
const MODEL_FAMILY_CONTROL_KINDS = new Set(["button", "radio", "menuitemradio"]);
|
|
@@ -115,6 +117,14 @@ function parseComposerChipSelection(label) {
|
|
|
115
117
|
const normalized = normalizeChipLabel(label).toLowerCase();
|
|
116
118
|
if (!normalized) return undefined;
|
|
117
119
|
|
|
120
|
+
const bareEffortMatch = normalized.match(BARE_EFFORT_PATTERN);
|
|
121
|
+
if (bareEffortMatch) {
|
|
122
|
+
return {
|
|
123
|
+
modelFamily: /** @type {OracleUiModelFamily} */ ("thinking"),
|
|
124
|
+
effort: /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiEffort} */ (bareEffortMatch[1].toLowerCase()),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
118
128
|
const thinkingMatch = normalized.match(THINKING_CHIP_PATTERN);
|
|
119
129
|
if (thinkingMatch) {
|
|
120
130
|
return {
|
|
@@ -158,6 +168,11 @@ function detectSelectedModelFamily(entries) {
|
|
|
158
168
|
}
|
|
159
169
|
}
|
|
160
170
|
|
|
171
|
+
const hasLatestModelCombobox = entries.some(
|
|
172
|
+
(entry) => !entry.disabled && entry.kind === "combobox" && normalizeText(entry.label).toLowerCase() === "model" && /^latest\b/i.test(normalizeText(entry.value)),
|
|
173
|
+
);
|
|
174
|
+
if (hasLatestModelCombobox) return undefined;
|
|
175
|
+
|
|
161
176
|
const hasProEffortCombobox = entries.some(
|
|
162
177
|
(entry) => !entry.disabled && entry.kind === "combobox" && normalizeText(entry.label).toLowerCase() === PRO_THINKING_EFFORT_COMBOBOX_LABEL.toLowerCase(),
|
|
163
178
|
);
|
|
@@ -227,11 +242,42 @@ export function snapshotHasModelConfigurationUi(snapshot) {
|
|
|
227
242
|
);
|
|
228
243
|
const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === "Close" && !entry.disabled);
|
|
229
244
|
const hasEffortCombobox = entries.some(
|
|
230
|
-
(entry) => entry.kind === "combobox" &&
|
|
245
|
+
(entry) => entry.kind === "combobox" && EFFORT_LABELS.has(entry.value || "") && !entry.disabled,
|
|
231
246
|
);
|
|
232
247
|
return visibleFamilies.size >= 2 || hasCloseButton || hasEffortCombobox;
|
|
233
248
|
}
|
|
234
249
|
|
|
250
|
+
/**
|
|
251
|
+
* @param {string} snapshot
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
*/
|
|
254
|
+
export function snapshotHasUsableComposerControls(snapshot) {
|
|
255
|
+
/** @type {SnapshotEntry[]} */
|
|
256
|
+
const entries = parseSnapshotEntries(snapshot);
|
|
257
|
+
const hasComposer = entries.some((entry) => entry.kind === "textbox" && entry.label === "Chat with ChatGPT" && !entry.disabled);
|
|
258
|
+
const hasAddFiles = entries.some((entry) => entry.kind === "button" && entry.label === "Add files and more" && !entry.disabled);
|
|
259
|
+
return hasComposer && hasAddFiles;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {string} snapshot
|
|
264
|
+
* @returns {boolean}
|
|
265
|
+
*/
|
|
266
|
+
export function snapshotHasModelOpener(snapshot) {
|
|
267
|
+
/** @type {SnapshotEntry[]} */
|
|
268
|
+
const entries = parseSnapshotEntries(snapshot);
|
|
269
|
+
return entries.some((entry) => {
|
|
270
|
+
if (entry.disabled || entry.kind !== "button" || typeof entry.label !== "string") return false;
|
|
271
|
+
const label = normalizeChipLabel(entry.label);
|
|
272
|
+
return label === "Model"
|
|
273
|
+
|| label === "Model selector"
|
|
274
|
+
|| EFFORT_LABELS.has(label)
|
|
275
|
+
|| ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {OracleUiModelFamily} */ (family)))
|
|
276
|
+
|| THINKING_CHIP_PATTERN.test(label)
|
|
277
|
+
|| PRO_CHIP_PATTERN.test(label);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
235
281
|
/**
|
|
236
282
|
* @param {string} snapshot
|
|
237
283
|
* @returns {boolean | undefined}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
effortSelectionVisible,
|
|
29
29
|
snapshotCanSafelySkipModelConfiguration,
|
|
30
30
|
snapshotHasModelConfigurationUi,
|
|
31
|
+
snapshotHasUsableComposerControls,
|
|
31
32
|
snapshotStronglyMatchesRequestedModel,
|
|
32
33
|
snapshotWeaklyMatchesRequestedModel,
|
|
33
34
|
autoSwitchToThinkingSelectionVisible,
|
|
@@ -257,8 +258,18 @@ async function cloneSeedProfileToRuntime(job) {
|
|
|
257
258
|
await withLock(ORACLE_STATE_DIR, "auth", "global", { jobId: job.id, processPid: process.pid, action: "cloneSeedProfile" }, async () => {
|
|
258
259
|
await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
|
|
259
260
|
await ensurePrivateDir(dirname(job.runtimeProfileDir));
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
if (job.config.browser.cloneStrategy === "apfs-clone") {
|
|
262
|
+
try {
|
|
263
|
+
await spawnCommand("/bin/cp", ["-cR", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
|
|
264
|
+
} catch (error) {
|
|
265
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
266
|
+
await log(`APFS clone copy failed; falling back to recursive copy: ${message}`);
|
|
267
|
+
await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
|
|
268
|
+
await spawnCommand("/bin/cp", ["-R", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
await spawnCommand("/bin/cp", ["-R", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
|
|
272
|
+
}
|
|
262
273
|
}, 10 * 60 * 1000);
|
|
263
274
|
|
|
264
275
|
return seedGeneration;
|
|
@@ -686,7 +697,9 @@ function matchesModelFamilyControl(candidate, family) {
|
|
|
686
697
|
function matchesModelConfigurationOpener(candidate) {
|
|
687
698
|
if (candidate.kind !== "button" || typeof candidate.label !== "string" || candidate.disabled) return false;
|
|
688
699
|
const label = String(candidate.label || "");
|
|
689
|
-
return candidate.label === "Model
|
|
700
|
+
return candidate.label === "Model"
|
|
701
|
+
|| candidate.label === "Model selector"
|
|
702
|
+
|| /^(?:Light|Standard|Extended|Heavy)(?:, click to remove)?$/i.test(label)
|
|
690
703
|
|| ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiModelFamily} */ (family)))
|
|
691
704
|
|| /^(?:(?:Light|Standard|Extended|Heavy) )?Thinking(?:, click to remove)?$/i.test(label)
|
|
692
705
|
|| /^(?:(?:Light|Standard|Extended|Heavy) )?Pro(?:, click to remove)?$/i.test(label);
|
|
@@ -787,11 +800,9 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
|
|
|
787
800
|
const allowedOrigins = buildAllowedChatGptOrigins(job.config.browser.chatUrl, job.config.browser.authUrl);
|
|
788
801
|
const onAllowedOrigin = typeof url === "string" && allowedOrigins.some((origin) => url.startsWith(origin));
|
|
789
802
|
const onAuthPath = typeof url === "string" && url.includes("/auth/");
|
|
790
|
-
const
|
|
791
|
-
const hasAddFiles = snapshot.includes(`button "${CHATGPT_LABELS.addFiles}"`);
|
|
792
|
-
const hasModelControl = snapshot.includes('button "Model selector"') || /button "(?:Instant|(?:(?:Light|Standard|Extended|Heavy) )?Thinking|(?:(?:Light|Standard|Extended|Heavy) )?Pro)(?:, click to remove)?"/i.test(snapshot);
|
|
803
|
+
const hasUsableComposer = snapshotHasUsableComposerControls(snapshot);
|
|
793
804
|
|
|
794
|
-
if (probe?.status === 401 || probe?.status === 403) {
|
|
805
|
+
if (probe?.status === 401 || (probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer || probe?.domLoginCta))) {
|
|
795
806
|
return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
|
|
796
807
|
}
|
|
797
808
|
|
|
@@ -805,7 +816,7 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
|
|
|
805
816
|
return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
|
|
806
817
|
}
|
|
807
818
|
|
|
808
|
-
if (onAllowedOrigin && probe?.status === 200
|
|
819
|
+
if (onAllowedOrigin && (probe?.status === 200 || probe?.status === 403) && hasUsableComposer) {
|
|
809
820
|
if (probe?.domLoginCta && (probe?.bodyHasId || probe?.bodyHasEmail)) {
|
|
810
821
|
return {
|
|
811
822
|
state: "auth_transitioning",
|
|
@@ -1314,10 +1325,125 @@ function preferredArtifactName(label, index) {
|
|
|
1314
1325
|
return `artifact-${String(index + 1).padStart(2, "0")}`;
|
|
1315
1326
|
}
|
|
1316
1327
|
|
|
1328
|
+
async function downloadArtifactViaBrowserEval(job, selector, destinationPath) {
|
|
1329
|
+
const result = await evalPage(job, toAsyncJsonScript(`
|
|
1330
|
+
const selector = ${JSON.stringify(selector)};
|
|
1331
|
+
const maxBytes = 25 * 1024 * 1024;
|
|
1332
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1333
|
+
const element = document.querySelector(selector);
|
|
1334
|
+
if (!element) return { ok: false, error: 'artifact selector not found' };
|
|
1335
|
+
|
|
1336
|
+
const urls = [];
|
|
1337
|
+
const captures = [];
|
|
1338
|
+
const originalOpen = window.open;
|
|
1339
|
+
const originalFetch = window.fetch?.bind(window);
|
|
1340
|
+
const originalAnchorClick = HTMLAnchorElement.prototype.click;
|
|
1341
|
+
|
|
1342
|
+
const arrayBufferToBase64 = (buffer) => {
|
|
1343
|
+
const bytes = new Uint8Array(buffer);
|
|
1344
|
+
let binary = '';
|
|
1345
|
+
const chunkSize = 0x8000;
|
|
1346
|
+
for (let index = 0; index < bytes.length; index += chunkSize) {
|
|
1347
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
|
1348
|
+
}
|
|
1349
|
+
return btoa(binary);
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
const shouldCapture = (url, headers) => {
|
|
1353
|
+
const contentDisposition = headers?.get?.('content-disposition') || '';
|
|
1354
|
+
const contentType = headers?.get?.('content-type') || '';
|
|
1355
|
+
const signal = [url, contentDisposition, contentType].join(' ').toLowerCase();
|
|
1356
|
+
return /download|files|oaiusercontent|attachment/i.test(signal) || signal.includes('estuary/content');
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
const captureResponse = async (response, source) => {
|
|
1360
|
+
if (!response || captures.length > 0 || !shouldCapture(response.url || '', response.headers)) return;
|
|
1361
|
+
const contentLength = Number(response.headers.get('content-length') || 0);
|
|
1362
|
+
if (contentLength > maxBytes) {
|
|
1363
|
+
captures.push({ ok: false, error: 'artifact response too large for browser-eval fallback', url: response.url || '', source });
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
const clone = response.clone();
|
|
1367
|
+
const buffer = await clone.arrayBuffer();
|
|
1368
|
+
if (buffer.byteLength > maxBytes) {
|
|
1369
|
+
captures.push({ ok: false, error: 'artifact response too large for browser-eval fallback', url: response.url || '', source });
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const contentType = response.headers.get('content-type') || '';
|
|
1373
|
+
if (contentType.toLowerCase().includes('application/json') && originalFetch) {
|
|
1374
|
+
try {
|
|
1375
|
+
const text = new TextDecoder().decode(buffer);
|
|
1376
|
+
const payload = JSON.parse(text);
|
|
1377
|
+
const downloadUrl = typeof payload?.download_url === 'string' ? payload.download_url : undefined;
|
|
1378
|
+
if (downloadUrl) {
|
|
1379
|
+
const fileResponse = await originalFetch(downloadUrl, { credentials: 'include' });
|
|
1380
|
+
await captureResponse(fileResponse, 'download_url');
|
|
1381
|
+
if (captures.length > 0) return;
|
|
1382
|
+
}
|
|
1383
|
+
} catch (_error) {
|
|
1384
|
+
// Fall through and preserve the JSON payload as last-resort evidence.
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
captures.push({
|
|
1388
|
+
ok: true,
|
|
1389
|
+
url: response.url || '',
|
|
1390
|
+
source,
|
|
1391
|
+
contentType,
|
|
1392
|
+
contentDisposition: response.headers.get('content-disposition') || '',
|
|
1393
|
+
bytesBase64: arrayBufferToBase64(buffer),
|
|
1394
|
+
});
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
try {
|
|
1398
|
+
window.open = (url, ...args) => {
|
|
1399
|
+
if (url) urls.push(String(url));
|
|
1400
|
+
return originalOpen.call(window, url, ...args);
|
|
1401
|
+
};
|
|
1402
|
+
HTMLAnchorElement.prototype.click = function patchedAnchorClick() {
|
|
1403
|
+
if (this.href) urls.push(this.href);
|
|
1404
|
+
return originalAnchorClick.call(this);
|
|
1405
|
+
};
|
|
1406
|
+
if (originalFetch) {
|
|
1407
|
+
window.fetch = async (...args) => {
|
|
1408
|
+
const response = await originalFetch(...args);
|
|
1409
|
+
const requestUrl = String(args[0]?.url || args[0] || response?.url || '');
|
|
1410
|
+
if (shouldCapture(requestUrl, response?.headers) || shouldCapture(response?.url || '', response?.headers)) {
|
|
1411
|
+
await captureResponse(response, 'fetch');
|
|
1412
|
+
}
|
|
1413
|
+
return response;
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
element.click();
|
|
1418
|
+
await sleep(3000);
|
|
1419
|
+
for (const url of urls) {
|
|
1420
|
+
if (captures.length > 0 || !url || !originalFetch) continue;
|
|
1421
|
+
try {
|
|
1422
|
+
const response = await originalFetch(url, { credentials: 'include' });
|
|
1423
|
+
await captureResponse(response, 'url');
|
|
1424
|
+
} catch (_error) {
|
|
1425
|
+
// Keep trying any other captured URLs.
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return captures[0] || { ok: false, error: 'click did not expose a downloadable artifact response', urls };
|
|
1429
|
+
} finally {
|
|
1430
|
+
window.open = originalOpen;
|
|
1431
|
+
HTMLAnchorElement.prototype.click = originalAnchorClick;
|
|
1432
|
+
if (originalFetch) window.fetch = originalFetch;
|
|
1433
|
+
}
|
|
1434
|
+
`));
|
|
1435
|
+
|
|
1436
|
+
if (!result?.ok || typeof result.bytesBase64 !== "string") {
|
|
1437
|
+
throw new Error(result?.error || "browser-eval artifact fallback did not capture a file");
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
await writeFile(destinationPath, Buffer.from(result.bytesBase64, "base64"), { mode: 0o600 });
|
|
1441
|
+
return result;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1317
1444
|
async function collectArtifactCandidates(job, responseIndex, responseText = "") {
|
|
1318
1445
|
const snapshot = await snapshotText(job);
|
|
1319
|
-
const targetSlice = assistantSnapshotSlice(snapshot, CHATGPT_LABELS.composer, responseIndex);
|
|
1320
|
-
if (!targetSlice) return { snapshot, targetSlice, candidates: [], suspiciousLabels: [] };
|
|
1446
|
+
const targetSlice = assistantSnapshotSlice(snapshot, CHATGPT_LABELS.composer, responseIndex) || snapshot;
|
|
1321
1447
|
|
|
1322
1448
|
const structural = await evalPage(
|
|
1323
1449
|
job,
|
|
@@ -1355,7 +1481,7 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
|
|
|
1355
1481
|
const isDownloadControl = (value) => downloadControlPattern.test(normalize(value));
|
|
1356
1482
|
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'))
|
|
1357
1483
|
.filter((el) => normalize(el.textContent) === 'ChatGPT said:');
|
|
1358
|
-
const host = headings[${responseIndex}]?.nextElementSibling;
|
|
1484
|
+
const host = headings[${responseIndex}]?.nextElementSibling || document.querySelector('main') || document.body;
|
|
1359
1485
|
if (!host) return { candidates: [] };
|
|
1360
1486
|
|
|
1361
1487
|
const interactiveElements = (node) => node ? Array.from(node.querySelectorAll('button, a')) : [];
|
|
@@ -1380,6 +1506,7 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
|
|
|
1380
1506
|
return undefined;
|
|
1381
1507
|
};
|
|
1382
1508
|
|
|
1509
|
+
const responseTextArtifactLabels = ${JSON.stringify(extractArtifactLabels(responseText))};
|
|
1383
1510
|
const candidates = interactiveElements(host)
|
|
1384
1511
|
.map((button, index) => {
|
|
1385
1512
|
const controlLabel = normalize(button.textContent || button.getAttribute('aria-label') || button.getAttribute('title'));
|
|
@@ -1390,7 +1517,13 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
|
|
|
1390
1517
|
const paragraphArtifactLabels = artifactLabelsForNode(paragraph);
|
|
1391
1518
|
const listItemArtifactLabels = artifactLabelsForNode(listItem);
|
|
1392
1519
|
const focusableArtifactLabels = artifactLabelsForNode(focusable);
|
|
1393
|
-
const label = uniqueLabel(
|
|
1520
|
+
const label = uniqueLabel(
|
|
1521
|
+
ownArtifactLabels,
|
|
1522
|
+
listItemArtifactLabels,
|
|
1523
|
+
paragraphArtifactLabels,
|
|
1524
|
+
focusableArtifactLabels,
|
|
1525
|
+
isDownloadControl(controlLabel) && responseTextArtifactLabels.length > 0 ? [responseTextArtifactLabels.at(-1)] : [],
|
|
1526
|
+
);
|
|
1394
1527
|
if (!label && !isFileLabel(controlLabel) && !isDownloadControl(controlLabel)) return null;
|
|
1395
1528
|
if (!label) return null;
|
|
1396
1529
|
const marker = artifactPrefix + index;
|
|
@@ -1409,6 +1542,7 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
|
|
|
1409
1542
|
focusableInteractiveCount: interactiveElements(focusable).length,
|
|
1410
1543
|
focusableArtifactLabelCount: Array.from(new Set(focusableArtifactLabels)).length,
|
|
1411
1544
|
focusableOtherTextLength: otherTextLength(focusable?.textContent, [...focusableArtifactLabels, ...interactiveLabels(focusable)]),
|
|
1545
|
+
fromResponseTextLabel: responseTextArtifactLabels.includes(label),
|
|
1412
1546
|
};
|
|
1413
1547
|
})
|
|
1414
1548
|
.filter(Boolean);
|
|
@@ -1510,12 +1644,6 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
|
1510
1644
|
}
|
|
1511
1645
|
|
|
1512
1646
|
let { targetSlice, candidates, suspiciousLabels } = await reopenConversationForArtifacts(job, responseIndex, responseText, "initial");
|
|
1513
|
-
if (!targetSlice) {
|
|
1514
|
-
await log(`No assistant response found in snapshot for response index ${responseIndex}`);
|
|
1515
|
-
await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
|
|
1516
|
-
await mutateJob((current) => ({ ...current, artifactPaths: [] }));
|
|
1517
|
-
return [];
|
|
1518
|
-
}
|
|
1519
1647
|
|
|
1520
1648
|
await log(`Artifact candidates: ${candidates.map((candidate) => candidate.label).join(", ") || "(none)"}`);
|
|
1521
1649
|
if (suspiciousLabels.length > 0) {
|
|
@@ -1542,11 +1670,17 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
|
1542
1670
|
await rm(destinationPath, { force: true }).catch(() => undefined);
|
|
1543
1671
|
try {
|
|
1544
1672
|
await log(`Artifact "${originalCandidate.label}" download attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS} using selector ${activeCandidate.selector}`);
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1673
|
+
try {
|
|
1674
|
+
const fallback = await downloadArtifactViaBrowserEval(job, activeCandidate.selector, destinationPath);
|
|
1675
|
+
await log(`Artifact "${originalCandidate.label}" captured via browser-eval fallback (${fallback.source || "unknown"}${fallback.contentType ? `, ${fallback.contentType}` : ""})`);
|
|
1676
|
+
} catch (fallbackError) {
|
|
1677
|
+
await log(`Artifact "${originalCandidate.label}" browser-eval fallback did not capture file: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
|
|
1678
|
+
await withHeartbeatWhile(() =>
|
|
1679
|
+
agentBrowser(job, "download", activeCandidate.selector, destinationPath, {
|
|
1680
|
+
timeoutMs: ARTIFACT_DOWNLOAD_TIMEOUT_MS,
|
|
1681
|
+
}),
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1550
1684
|
await heartbeat(undefined, { force: true });
|
|
1551
1685
|
await chmod(destinationPath, 0o600).catch(() => undefined);
|
|
1552
1686
|
const [size, checksum, detectedType] = await Promise.all([
|
|
@@ -1584,7 +1718,8 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
|
1584
1718
|
}
|
|
1585
1719
|
|
|
1586
1720
|
const capturedArtifactLabels = new Set(artifacts.map((artifact) => artifact.displayName).filter(Boolean));
|
|
1587
|
-
const
|
|
1721
|
+
const capturedArtifactKeys = new Set([...capturedArtifactLabels].map((label) => String(label).replace(/\s+/g, "")));
|
|
1722
|
+
const missedArtifactLabels = suspiciousLabels.filter((label) => !capturedArtifactLabels.has(label) && !capturedArtifactKeys.has(String(label).replace(/\s+/g, "")));
|
|
1588
1723
|
if (missedArtifactLabels.length > 0) {
|
|
1589
1724
|
await log(`Marking missed artifact signals as unconfirmed: ${missedArtifactLabels.join(", ")}`);
|
|
1590
1725
|
for (const label of missedArtifactLabels) {
|