pi-agent-browser-native 0.2.47 → 0.2.48
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 +46 -19
- package/README.md +38 -15
- package/docs/ARCHITECTURE.md +10 -10
- package/docs/COMMAND_REFERENCE.md +35 -21
- package/docs/ELECTRON.md +3 -3
- package/docs/RELEASE.md +28 -19
- package/docs/REQUIREMENTS.md +1 -1
- package/docs/SUPPORT_MATRIX.md +34 -106
- package/docs/TOOL_CONTRACT.md +23 -21
- package/extensions/agent-browser/index.ts +13 -4
- package/extensions/agent-browser/lib/config.ts +2 -0
- package/extensions/agent-browser/lib/input-modes/job.ts +138 -62
- package/extensions/agent-browser/lib/input-modes/params.ts +2 -2
- package/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.ts +44 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +42 -19
- package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +6 -4
- package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +18 -9
- package/extensions/agent-browser/lib/orchestration/browser-run/prepare/direct-anchor-download.ts +158 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/prepare/network-page-filter.ts +116 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/prepare/scroll-shims.ts +147 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/prepare/snapshot-filter.ts +183 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/prepare/wait-timeouts.ts +58 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +19 -653
- package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +1 -6
- package/extensions/agent-browser/lib/orchestration/browser-run/session-artifacts.ts +8 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +1 -0
- package/extensions/agent-browser/lib/pi-tool-rendering.ts +34 -19
- package/extensions/agent-browser/lib/playbook.ts +4 -4
- package/extensions/agent-browser/lib/results/action-recommendations.ts +3 -3
- package/extensions/agent-browser/lib/web-search.ts +11 -4
- package/package.json +4 -4
- package/scripts/agent-browser-capability-baseline.mjs +6 -3
- package/scripts/doctor.mjs +11 -10
- package/scripts/platform-smoke.mjs +1 -1
|
@@ -171,7 +171,7 @@ export function createAgentBrowserParamsSchema(
|
|
|
171
171
|
action: StringEnum(AGENT_BROWSER_JOB_STEP_ACTIONS, {
|
|
172
172
|
description: "Constrained one-call job step compiled to existing upstream batch commands.",
|
|
173
173
|
}),
|
|
174
|
-
url: Type.Optional(Type.String({ description: "URL for open steps
|
|
174
|
+
url: Type.Optional(Type.String({ description: "URL for open steps; exact URL or * / ** glob-style URL pattern for assertUrl steps." })),
|
|
175
175
|
loadState: Type.Optional(StringEnum(AGENT_BROWSER_QA_LOAD_STATES, { description: "Optional readiness wait to insert immediately after an open step; use domcontentloaded/load/networkidle when the next job step needs page hydration evidence before clicking or reading." })),
|
|
176
176
|
selector: Type.Optional(Type.String({ description: "Selector or @ref for click/fill/type/select-like steps; omit when using semantic locator fields on click/fill steps." })),
|
|
177
177
|
locator: Type.Optional(StringEnum(AGENT_BROWSER_SEMANTIC_LOCATORS, { description: "Semantic locator for click/fill steps when selector is omitted." })),
|
|
@@ -191,7 +191,7 @@ export function createAgentBrowserParamsSchema(
|
|
|
191
191
|
),
|
|
192
192
|
stdin: Type.Optional(Type.String({ description: "Optional raw stdin content; only supported for batch, eval --stdin, auth save --password-stdin, and is generated internally by job, qa, sourceLookup, or networkSourceLookup mode. Do not use with electron mode." })),
|
|
193
193
|
outputPath: Type.Optional(Type.String({ description: "Optional workspace-relative or absolute file path that receives the model-facing command data/result after the browser command completes. Useful for eval/get/snapshot captures that should become durable local artifacts.", minLength: 1 })),
|
|
194
|
-
timeoutMs: Type.Optional(Type.Integer({ description: "Optional per-call wrapper subprocess watchdog in milliseconds for browser CLI args/job/qa/source lookup calls. Use for long opens or large output captures;
|
|
194
|
+
timeoutMs: Type.Optional(Type.Integer({ description: "Optional per-call wrapper subprocess watchdog in milliseconds for browser CLI args/job/qa/source lookup calls. Use for long opens or large output captures; explicit long wait steps are forwarded, so set timeoutMs above the wait duration plus a small grace window when overriding the derived watchdog. Electron actions use electron.timeoutMs instead.", minimum: 1 })),
|
|
195
195
|
sessionMode: Type.Optional(
|
|
196
196
|
StringEnum(["auto", "fresh"] as const, {
|
|
197
197
|
description:
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { extname, isAbsolute } from "node:path";
|
|
2
|
+
|
|
3
|
+
const SCREENSHOT_VALUE_FLAGS = new Set(["--screenshot-dir", "--screenshot-format", "--screenshot-quality"]);
|
|
4
|
+
const SCREENSHOT_IMAGE_EXTENSIONS = new Set([".jpeg", ".jpg", ".png", ".webp"]);
|
|
5
|
+
|
|
6
|
+
function isImagePathToken(token: string): boolean {
|
|
7
|
+
const extension = extname(token).toLowerCase();
|
|
8
|
+
return SCREENSHOT_IMAGE_EXTENSIONS.has(extension);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getScreenshotPathTokenIndex(commandTokens: string[]): number | undefined {
|
|
12
|
+
if (commandTokens[0] !== "screenshot") {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const positionalIndices: number[] = [];
|
|
17
|
+
for (let index = 1; index < commandTokens.length; index += 1) {
|
|
18
|
+
const token = commandTokens[index];
|
|
19
|
+
if (token === "--") {
|
|
20
|
+
for (let positionalIndex = index + 1; positionalIndex < commandTokens.length; positionalIndex += 1) {
|
|
21
|
+
positionalIndices.push(positionalIndex);
|
|
22
|
+
}
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
if (token.startsWith("-")) {
|
|
26
|
+
const normalizedToken = token.split("=", 1)[0] ?? token;
|
|
27
|
+
if (SCREENSHOT_VALUE_FLAGS.has(normalizedToken) && !token.includes("=")) {
|
|
28
|
+
index += 1;
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
positionalIndices.push(index);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (positionalIndices.length === 0) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const candidateIndex = positionalIndices[positionalIndices.length - 1];
|
|
39
|
+
const candidate = commandTokens[candidateIndex];
|
|
40
|
+
if (positionalIndices.length >= 2 || isImagePathToken(candidate) || isAbsolute(candidate) || candidate.startsWith("./") || candidate.startsWith("../")) {
|
|
41
|
+
return candidateIndex;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
@@ -15,6 +15,23 @@ function parseClickRefId(selector: string): string | undefined {
|
|
|
15
15
|
return /^e\d+$/.test(candidate) ? candidate : undefined;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function normalizeAccessibleName(name: string): string {
|
|
19
|
+
return name.replace(/\s+/g, " ").trim().toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getAccessibleRefDuplicateIndex(refSnapshot: SessionRefSnapshot | undefined, refId: string, role: string, name: string): number | undefined {
|
|
23
|
+
if (!refSnapshot?.refs) return undefined;
|
|
24
|
+
const normalizedRole = role.toLowerCase();
|
|
25
|
+
const normalizedName = normalizeAccessibleName(name);
|
|
26
|
+
const matchingRefIds = refSnapshot.refIds.filter((candidateRefId) => {
|
|
27
|
+
const candidate = refSnapshot.refs?.[candidateRefId];
|
|
28
|
+
return candidate?.role.toLowerCase() === normalizedRole && normalizeAccessibleName(candidate.name) === normalizedName;
|
|
29
|
+
});
|
|
30
|
+
if (matchingRefIds.length <= 1) return undefined;
|
|
31
|
+
const duplicateIndex = matchingRefIds.indexOf(refId);
|
|
32
|
+
return duplicateIndex >= 0 ? duplicateIndex : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
function getClickDispatchProbeTarget(commandTokens: string[], refSnapshot?: SessionRefSnapshot): ClickDispatchProbeTarget | undefined {
|
|
19
36
|
if (commandTokens[0] !== "click" || commandTokens.includes("--new-tab")) return undefined;
|
|
20
37
|
const selector = commandTokens[1];
|
|
@@ -23,7 +40,8 @@ function getClickDispatchProbeTarget(commandTokens: string[], refSnapshot?: Sess
|
|
|
23
40
|
if (refId) {
|
|
24
41
|
const ref = refSnapshot?.refs?.[refId];
|
|
25
42
|
if (!ref || !ACCESSIBLE_REF_CLICK_DISPATCH_ROLES.has(ref.role)) return undefined;
|
|
26
|
-
|
|
43
|
+
const duplicateIndex = getAccessibleRefDuplicateIndex(refSnapshot, refId, ref.role, ref.name);
|
|
44
|
+
return { ...(duplicateIndex === undefined ? {} : { duplicateIndex }), kind: "accessible", name: ref.name, refId, role: ref.role };
|
|
27
45
|
}
|
|
28
46
|
if (selector.startsWith("xpath=")) return { kind: "xpath", selector: selector.slice("xpath=".length) };
|
|
29
47
|
return { kind: "selector", selector };
|
|
@@ -43,6 +61,7 @@ function buildClickDispatchProbeInstallScript(probe: ClickDispatchProbe): string
|
|
|
43
61
|
const normalize = (value) => String(value ?? "").replace(/\\s+/g, " ").trim();
|
|
44
62
|
const expectedRole = ${JSON.stringify(target.role)};
|
|
45
63
|
const expectedName = normalize(${JSON.stringify(target.name)});
|
|
64
|
+
const duplicateIndex = ${JSON.stringify(target.duplicateIndex)};
|
|
46
65
|
const inferRole = (element) => {
|
|
47
66
|
const explicit = element.getAttribute("role");
|
|
48
67
|
if (explicit) return explicit;
|
|
@@ -65,6 +84,7 @@ function buildClickDispatchProbeInstallScript(probe: ClickDispatchProbe): string
|
|
|
65
84
|
return element.getClientRects().length > 0;
|
|
66
85
|
};
|
|
67
86
|
const candidates = Array.from(document.querySelectorAll("button,a[href],input,select,textarea,summary,[role],[onclick],[tabindex]")).filter((element) => inferRole(element) === expectedRole && inferName(element) === expectedName && isVisible(element));
|
|
87
|
+
if (typeof duplicateIndex === "number") return candidates[duplicateIndex] || null;
|
|
68
88
|
return candidates.length === 1 ? candidates[0] : null;
|
|
69
89
|
})()`;
|
|
70
90
|
return `(() => {
|
|
@@ -94,22 +114,24 @@ const getSelector = (node) => {
|
|
|
94
114
|
return parts.length > 0 ? parts.join(" > ") : undefined;
|
|
95
115
|
};
|
|
96
116
|
const rectInfo = (rect) => ({ bottom: rect.bottom, left: rect.left, right: rect.right, top: rect.top });
|
|
97
|
-
const targetRect = element.getBoundingClientRect();
|
|
98
|
-
const targetOutsideViewport = targetRect.bottom < 0 || targetRect.right < 0 || targetRect.top > window.innerHeight || targetRect.left > window.innerWidth;
|
|
117
|
+
const targetRect = element ? element.getBoundingClientRect() : undefined;
|
|
118
|
+
const targetOutsideViewport = targetRect ? targetRect.bottom < 0 || targetRect.right < 0 || targetRect.top > window.innerHeight || targetRect.left > window.innerWidth : undefined;
|
|
99
119
|
let nearestScrollContainer;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
120
|
+
if (element && targetRect) {
|
|
121
|
+
for (let current = element.parentElement; current && current !== document.body; current = current.parentElement) {
|
|
122
|
+
if (current.scrollHeight > current.clientHeight + 1 || current.scrollWidth > current.clientWidth + 1) {
|
|
123
|
+
const containerRect = current.getBoundingClientRect();
|
|
124
|
+
nearestScrollContainer = {
|
|
125
|
+
selector: getSelector(current),
|
|
126
|
+
tagName: current.tagName.toLowerCase(),
|
|
127
|
+
targetOutsideContainer: targetRect.bottom < containerRect.top || targetRect.top > containerRect.bottom || targetRect.right < containerRect.left || targetRect.left > containerRect.right,
|
|
128
|
+
targetOutsideViewport,
|
|
129
|
+
rect: rectInfo(containerRect),
|
|
130
|
+
scrollLeft: current.scrollLeft,
|
|
131
|
+
scrollTop: current.scrollTop,
|
|
132
|
+
};
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
113
135
|
}
|
|
114
136
|
}
|
|
115
137
|
const state = { events: [], target: { tagName: element.tagName.toLowerCase(), nearestScrollContainer, rect: rectInfo(targetRect), targetOutsideViewport } };
|
|
@@ -168,7 +190,7 @@ export function formatClickDispatchDiagnosticText(diagnostic: ClickDispatchDiagn
|
|
|
168
190
|
}
|
|
169
191
|
|
|
170
192
|
export function buildClickDispatchNextActions(options: { commandTokens: string[]; diagnostic?: ClickDispatchDiagnostic; sessionName?: string }): AgentBrowserNextAction[] {
|
|
171
|
-
const retryArgs = options.commandTokens[0] === "click" ? options.commandTokens : ["click", ...options.commandTokens];
|
|
193
|
+
const retryArgs = options.commandTokens[0] === "click" || options.commandTokens[0] === "find" ? options.commandTokens : ["click", ...options.commandTokens];
|
|
172
194
|
const actions: AgentBrowserNextAction[] = [
|
|
173
195
|
{
|
|
174
196
|
id: "inspect-click-dispatch-miss",
|
|
@@ -232,9 +254,10 @@ export async function collectClickDispatchDiagnostic(options: { cwd: string; pro
|
|
|
232
254
|
if (status !== "no-native-event-observed") return undefined;
|
|
233
255
|
const nativeEventCount = typeof result.nativeEventCount === "number" ? result.nativeEventCount : 0;
|
|
234
256
|
const scrollContainer = getClickDispatchScrollContainerDiagnostic(result);
|
|
257
|
+
const targetLabel = "no trusted DOM event reached the selected element";
|
|
235
258
|
const summary = scrollContainer
|
|
236
|
-
? `Upstream click reported success but
|
|
237
|
-
:
|
|
259
|
+
? `Upstream click reported success but ${targetLabel}. ${scrollContainer.summary}`
|
|
260
|
+
: `Upstream click reported success but ${targetLabel}. Gather evidence with snapshot or page-change checks, then retry upstream click or report the workflow issue; the wrapper does not replay clicks in-page.`;
|
|
238
261
|
return {
|
|
239
262
|
nativeEventCount,
|
|
240
263
|
reason: "native-click-produced-no-target-dom-event",
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
runSessionCommandData,
|
|
24
24
|
} from "./session-state.js";
|
|
25
25
|
import { parseValidBatchStepEntries } from "../batch-stdin.js";
|
|
26
|
-
import { getScreenshotPathTokenIndex } from "./
|
|
26
|
+
import { getScreenshotPathTokenIndex } from "./artifact-paths.js";
|
|
27
27
|
import type {
|
|
28
28
|
ArtifactCleanupGuidance,
|
|
29
29
|
ComboboxFocusDiagnostic,
|
|
@@ -511,9 +511,11 @@ export async function getArtifactCleanupGuidance(options: { command?: string; cw
|
|
|
511
511
|
|
|
512
512
|
export function formatArtifactCleanupGuidanceText(guidance: ArtifactCleanupGuidance | undefined): string | undefined {
|
|
513
513
|
if (!guidance) return undefined;
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
514
|
+
const explicitCount = guidance.explicitArtifactPaths.length;
|
|
515
|
+
const explicitSummary = explicitCount === 0
|
|
516
|
+
? "No existing explicit artifact paths were found in the recent manifest."
|
|
517
|
+
: `${explicitCount} explicit artifact${explicitCount === 1 ? "" : "s"} remain${explicitCount === 1 ? "s" : ""}; expand or inspect details.artifactCleanup.explicitArtifactPaths for paths.`;
|
|
518
|
+
return `Artifact lifecycle: ${explicitSummary} Browser close does not delete explicit screenshots, downloads, PDFs, traces, HAR files, or recordings; use host file tools for cleanup.`;
|
|
517
519
|
}
|
|
518
520
|
|
|
519
521
|
async function collectManagedSessionCommandData(options: { args: string[]; cwd: string; sessionName: string; signal?: AbortSignal; timeoutMs?: number }): Promise<{ data?: unknown; error?: string }> {
|
|
@@ -303,18 +303,27 @@ export async function prepareFinalResultRecoveryState(options: {
|
|
|
303
303
|
|
|
304
304
|
function buildTimeoutPartialProgressNextActions(options: FinalResultInput): AgentBrowserNextAction[] {
|
|
305
305
|
const retryArgs = options.timeoutPartialProgress?.retryStep?.retry?.args;
|
|
306
|
-
if (!retryArgs) return [];
|
|
307
306
|
const stepIndex = options.timeoutPartialProgress?.retryStep?.index;
|
|
308
307
|
const freshSessionAbandoned = options.sessionMode === "fresh" && options.timeoutPartialProgress?.liveUrlRecovered !== true;
|
|
308
|
+
if (retryArgs) {
|
|
309
|
+
return [{
|
|
310
|
+
id: "retry-timeout-step",
|
|
311
|
+
params: freshSessionAbandoned
|
|
312
|
+
? { args: retryArgs, sessionMode: "fresh" }
|
|
313
|
+
: { args: withOptionalSessionArgs(options.executionPlan.sessionName, retryArgs) },
|
|
314
|
+
reason: freshSessionAbandoned
|
|
315
|
+
? `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} in a fresh browser session because the timed-out fresh session was not proven live.`
|
|
316
|
+
: `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} against the current browser session.`,
|
|
317
|
+
safety: "Only read-only or idempotent timeout steps get executable retry args; inspect current page/artifact state before using the action.",
|
|
318
|
+
tool: "agent_browser" as const,
|
|
319
|
+
}];
|
|
320
|
+
}
|
|
321
|
+
if (!options.timeoutPartialProgress || freshSessionAbandoned || !options.executionPlan.sessionName) return [];
|
|
309
322
|
return [{
|
|
310
|
-
id: "
|
|
311
|
-
params:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
reason: freshSessionAbandoned
|
|
315
|
-
? `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} in a fresh browser session because the timed-out fresh session was not proven live.`
|
|
316
|
-
: `Retry the first incomplete timed-out step${stepIndex === undefined ? "" : ` ${stepIndex}`} against the current browser session.`,
|
|
317
|
-
safety: "Only read-only or idempotent timeout steps get executable retry args; inspect current page/artifact state before using the action.",
|
|
323
|
+
id: "inspect-current-page-after-timeout",
|
|
324
|
+
params: { args: withOptionalSessionArgs(options.executionPlan.sessionName, ["snapshot", "-i"]) },
|
|
325
|
+
reason: `Inspect the current page after timeout before deciding how to resume${stepIndex === undefined ? "" : ` from incomplete step ${stepIndex}`}.`,
|
|
326
|
+
safety: "Read details.timeoutPartialProgress first. Do not blindly retry mutating steps such as clicks, fills, key presses, selects, or checks; split the remaining flow into shorter batches around the next navigation or DOM mutation boundary.",
|
|
318
327
|
tool: "agent_browser" as const,
|
|
319
328
|
}];
|
|
320
329
|
}
|
package/extensions/agent-browser/lib/orchestration/browser-run/prepare/direct-anchor-download.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { isRecord } from "../../../parsing.js";
|
|
5
|
+
import { buildAgentBrowserResultCategoryDetails } from "../../../results.js";
|
|
6
|
+
import { formatSessionArtifactRetentionSummary, mergeSessionArtifactManifest } from "../../../results/artifact-manifest.js";
|
|
7
|
+
import type { SessionArtifactManifest, SessionArtifactManifestEntry } from "../../../results/contracts.js";
|
|
8
|
+
import { redactSensitiveText, type CompatibilityWorkaround } from "../../../runtime.js";
|
|
9
|
+
import { buildSessionDetailFields, runSessionCommandData } from "../session-state.js";
|
|
10
|
+
|
|
11
|
+
import type { AgentBrowserToolResult } from "../types.js";
|
|
12
|
+
|
|
13
|
+
export interface DirectAnchorDownloadResult {
|
|
14
|
+
artifactManifest?: SessionArtifactManifest;
|
|
15
|
+
result: AgentBrowserToolResult;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DIRECT_ANCHOR_DOWNLOAD_MAX_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
function getDirectDownloadRequest(commandTokens: string[]): { path: string; selector: string } | undefined {
|
|
21
|
+
if (commandTokens[0] !== "download" || commandTokens.length !== 3) return undefined;
|
|
22
|
+
const selector = commandTokens[1];
|
|
23
|
+
const path = commandTokens[2];
|
|
24
|
+
if (!selector || !path || selector.startsWith("@")) return undefined;
|
|
25
|
+
return { path, selector };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildAnchorDownloadProbe(selector: string): string {
|
|
29
|
+
return `(async () => {\n const selector = ${JSON.stringify(selector)};\n const maxBytes = ${DIRECT_ANCHOR_DOWNLOAD_MAX_BYTES};\n const isLoopbackHttpUrl = (url) => (url.protocol === "http:" || url.protocol === "https:") && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]");\n const element = document.querySelector(selector);\n const anchor = element?.closest?.("a[href]");\n const pageUrl = location.href;\n const page = new URL(pageUrl);\n if (!anchor) return { status: "no-anchor", pageUrl };\n const href = anchor.href;\n const anchorUrl = new URL(href, pageUrl);\n if (!isLoopbackHttpUrl(page)) return { download: anchor.getAttribute("download") || "", href, pageUrl, status: "not-loopback-page" };\n if (anchorUrl.origin !== page.origin) return { download: anchor.getAttribute("download") || "", href, pageUrl, status: "not-same-origin" };\n if (!isLoopbackHttpUrl(anchorUrl)) return { download: anchor.getAttribute("download") || "", href, pageUrl, status: "not-loopback-href" };\n const response = await fetch(anchorUrl.href, { credentials: "include", redirect: "manual" });\n if (!response.ok) return { download: anchor.getAttribute("download") || "", href, pageUrl, responseUrl: response.url, status: "fetch-failed", statusCode: response.status };\n const responseUrl = new URL(response.url);\n if (!isLoopbackHttpUrl(responseUrl) || responseUrl.origin !== page.origin) return { download: anchor.getAttribute("download") || "", href, pageUrl, responseUrl: response.url, status: "not-loopback-response" };\n const buffer = await response.arrayBuffer();\n if (buffer.byteLength > maxBytes) return { download: anchor.getAttribute("download") || "", href, pageUrl, responseUrl: response.url, sizeBytes: buffer.byteLength, status: "too-large" };\n const bytes = new Uint8Array(buffer);\n let binary = "";\n for (let index = 0; index < bytes.length; index += 32768) binary += String.fromCharCode(...bytes.subarray(index, index + 32768));\n return { bodyBase64: btoa(binary), contentType: response.headers.get("content-type") || "", download: anchor.getAttribute("download") || "", href, pageUrl, responseUrl: response.url, sizeBytes: buffer.byteLength, status: "fetched-anchor" };\n})()`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isLoopbackHttpUrl(url: URL): boolean {
|
|
33
|
+
return (url.protocol === "http:" || url.protocol === "https:") && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function tryDirectAnchorDownload(options: {
|
|
37
|
+
artifactManifest?: SessionArtifactManifest;
|
|
38
|
+
commandTokens: string[];
|
|
39
|
+
compatibilityWorkaround?: CompatibilityWorkaround;
|
|
40
|
+
cwd: string;
|
|
41
|
+
effectiveArgs: string[];
|
|
42
|
+
redactedArgs: string[];
|
|
43
|
+
sessionMode: "auto" | "fresh";
|
|
44
|
+
sessionName?: string;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
usedImplicitSession: boolean;
|
|
47
|
+
}): Promise<DirectAnchorDownloadResult | undefined> {
|
|
48
|
+
const request = getDirectDownloadRequest(options.commandTokens);
|
|
49
|
+
if (!request || !options.sessionName) return undefined;
|
|
50
|
+
try {
|
|
51
|
+
const probeData = await runSessionCommandData({
|
|
52
|
+
args: ["eval", "--stdin"],
|
|
53
|
+
cwd: options.cwd,
|
|
54
|
+
sessionName: options.sessionName,
|
|
55
|
+
signal: options.signal,
|
|
56
|
+
stdin: buildAnchorDownloadProbe(request.selector),
|
|
57
|
+
});
|
|
58
|
+
const probe = isRecord(probeData) && isRecord(probeData.result) ? probeData.result : probeData;
|
|
59
|
+
if (!isRecord(probe) || probe.status !== "fetched-anchor" || typeof probe.href !== "string" || typeof probe.pageUrl !== "string" || typeof probe.bodyBase64 !== "string") return undefined;
|
|
60
|
+
const href = new URL(probe.href);
|
|
61
|
+
const pageUrl = new URL(probe.pageUrl);
|
|
62
|
+
const responseUrl = typeof probe.responseUrl === "string" ? new URL(probe.responseUrl) : href;
|
|
63
|
+
if (!isLoopbackHttpUrl(pageUrl) || !isLoopbackHttpUrl(href) || !isLoopbackHttpUrl(responseUrl) || href.origin !== pageUrl.origin || responseUrl.origin !== pageUrl.origin) return undefined;
|
|
64
|
+
const body = Buffer.from(probe.bodyBase64, "base64");
|
|
65
|
+
if (body.byteLength > DIRECT_ANCHOR_DOWNLOAD_MAX_BYTES) return undefined;
|
|
66
|
+
if (typeof probe.sizeBytes === "number" && probe.sizeBytes !== body.byteLength) return undefined;
|
|
67
|
+
const absolutePath = resolve(options.cwd, request.path);
|
|
68
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
69
|
+
await writeFile(absolutePath, body);
|
|
70
|
+
const fileStat = await stat(absolutePath);
|
|
71
|
+
const mediaType = typeof probe.contentType === "string" && probe.contentType.length > 0 ? probe.contentType : undefined;
|
|
72
|
+
const manifestEntry: SessionArtifactManifestEntry = {
|
|
73
|
+
absolutePath,
|
|
74
|
+
command: "download",
|
|
75
|
+
createdAtMs: Date.now(),
|
|
76
|
+
cwd: options.cwd,
|
|
77
|
+
exists: true,
|
|
78
|
+
kind: "download",
|
|
79
|
+
mediaType,
|
|
80
|
+
path: absolutePath,
|
|
81
|
+
requestedPath: request.path,
|
|
82
|
+
retentionState: "live",
|
|
83
|
+
session: options.sessionName,
|
|
84
|
+
sizeBytes: fileStat.size,
|
|
85
|
+
storageScope: "explicit-path",
|
|
86
|
+
};
|
|
87
|
+
const artifactManifest = mergeSessionArtifactManifest({ base: options.artifactManifest, entries: [manifestEntry] });
|
|
88
|
+
const artifactRetentionSummary = artifactManifest ? formatSessionArtifactRetentionSummary(artifactManifest) : undefined;
|
|
89
|
+
const artifact = {
|
|
90
|
+
absolutePath,
|
|
91
|
+
artifactType: "download" as const,
|
|
92
|
+
command: "download",
|
|
93
|
+
cwd: options.cwd,
|
|
94
|
+
exists: true,
|
|
95
|
+
kind: "download" as const,
|
|
96
|
+
mediaType,
|
|
97
|
+
path: absolutePath,
|
|
98
|
+
requestedPath: request.path,
|
|
99
|
+
session: options.sessionName,
|
|
100
|
+
sizeBytes: fileStat.size,
|
|
101
|
+
status: "saved" as const,
|
|
102
|
+
};
|
|
103
|
+
const artifactVerification = {
|
|
104
|
+
artifacts: [{
|
|
105
|
+
absolutePath,
|
|
106
|
+
exists: true,
|
|
107
|
+
kind: "download" as const,
|
|
108
|
+
mediaType,
|
|
109
|
+
path: absolutePath,
|
|
110
|
+
requestedPath: request.path,
|
|
111
|
+
sizeBytes: fileStat.size,
|
|
112
|
+
state: "verified" as const,
|
|
113
|
+
status: "saved" as const,
|
|
114
|
+
}],
|
|
115
|
+
missingCount: 0,
|
|
116
|
+
pendingCount: 0,
|
|
117
|
+
unverifiedCount: 0,
|
|
118
|
+
verified: true,
|
|
119
|
+
verifiedCount: 1,
|
|
120
|
+
};
|
|
121
|
+
const savedFile = { command: "download" as const, kind: "download" as const, metadata: { download: probe.download, href: redactSensitiveText(href.href), method: "direct-anchor-fetch" }, path: absolutePath };
|
|
122
|
+
return {
|
|
123
|
+
artifactManifest,
|
|
124
|
+
result: {
|
|
125
|
+
content: [{
|
|
126
|
+
type: "text",
|
|
127
|
+
text: [
|
|
128
|
+
`Download completed: ${absolutePath}`,
|
|
129
|
+
`Requested path: ${request.path}`,
|
|
130
|
+
`Source: ${redactSensitiveText(href.href)}`,
|
|
131
|
+
`Size: ${fileStat.size} bytes`,
|
|
132
|
+
"Method: direct anchor fetch before upstream download fallback.",
|
|
133
|
+
].join("\n"),
|
|
134
|
+
}],
|
|
135
|
+
details: {
|
|
136
|
+
args: options.redactedArgs,
|
|
137
|
+
artifactManifest,
|
|
138
|
+
artifactRetentionSummary,
|
|
139
|
+
artifacts: [artifact],
|
|
140
|
+
artifactVerification,
|
|
141
|
+
command: "download",
|
|
142
|
+
compatibilityWorkaround: options.compatibilityWorkaround,
|
|
143
|
+
downloadRecovery: { href: redactSensitiveText(href.href), method: "direct-anchor-fetch", selector: request.selector },
|
|
144
|
+
effectiveArgs: options.effectiveArgs,
|
|
145
|
+
savedFile,
|
|
146
|
+
savedFilePath: absolutePath,
|
|
147
|
+
sessionMode: options.sessionMode,
|
|
148
|
+
...buildAgentBrowserResultCategoryDetails({ artifacts: [artifact], args: options.effectiveArgs, command: "download", savedFile, succeeded: true }),
|
|
149
|
+
...buildSessionDetailFields(options.sessionName, options.usedImplicitSession),
|
|
150
|
+
summary: `Download completed: ${absolutePath}`,
|
|
151
|
+
},
|
|
152
|
+
isError: false,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isRecord } from "../../../parsing.js";
|
|
2
|
+
import { buildAgentBrowserResultCategoryDetails } from "../../../results.js";
|
|
3
|
+
import { redactSensitiveText, type CompatibilityWorkaround } from "../../../runtime.js";
|
|
4
|
+
import { buildSessionDetailFields, runSessionCommandData } from "../session-state.js";
|
|
5
|
+
|
|
6
|
+
import type { AgentBrowserToolResult } from "../types.js";
|
|
7
|
+
|
|
8
|
+
interface NetworkRequestsPageFilterRequest {
|
|
9
|
+
cleanArgs: string[];
|
|
10
|
+
mode: "origin" | "url";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseNetworkRequestsPageFilterRequest(commandTokens: string[]): NetworkRequestsPageFilterRequest | undefined {
|
|
14
|
+
if (commandTokens[0] !== "network" || commandTokens[1] !== "requests") return undefined;
|
|
15
|
+
const cleanArgs: string[] = [];
|
|
16
|
+
let mode: NetworkRequestsPageFilterRequest["mode"] | undefined;
|
|
17
|
+
for (const token of commandTokens) {
|
|
18
|
+
if (token === "--current-page" || token === "--current-origin") {
|
|
19
|
+
mode = "origin";
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (token === "--current-url") {
|
|
23
|
+
mode = "url";
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
cleanArgs.push(token);
|
|
27
|
+
}
|
|
28
|
+
if (!mode) return undefined;
|
|
29
|
+
return { cleanArgs, mode };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractCurrentUrl(data: unknown): string | undefined {
|
|
33
|
+
if (typeof data === "string") return data;
|
|
34
|
+
if (!isRecord(data)) return undefined;
|
|
35
|
+
const candidates = [data.url, data.currentUrl, data.href, data.result];
|
|
36
|
+
for (const candidate of candidates) if (typeof candidate === "string" && candidate.length > 0) return candidate;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getRequestUrl(row: unknown): string | undefined {
|
|
41
|
+
if (!isRecord(row)) return undefined;
|
|
42
|
+
const candidate = row.url ?? row.requestUrl ?? row.href;
|
|
43
|
+
return typeof candidate === "string" ? candidate : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function requestMatchesCurrentPage(row: unknown, currentUrl: string, mode: NetworkRequestsPageFilterRequest["mode"]): boolean {
|
|
47
|
+
const requestUrl = getRequestUrl(row);
|
|
48
|
+
if (!requestUrl) return false;
|
|
49
|
+
try {
|
|
50
|
+
const current = new URL(currentUrl);
|
|
51
|
+
const request = new URL(requestUrl, current);
|
|
52
|
+
if (mode === "origin") return current.origin === request.origin;
|
|
53
|
+
const currentComparable = `${current.origin}${current.pathname}`;
|
|
54
|
+
const requestComparable = `${request.origin}${request.pathname}`;
|
|
55
|
+
return requestComparable === currentComparable;
|
|
56
|
+
} catch {
|
|
57
|
+
return mode === "url" ? requestUrl === currentUrl : requestUrl.startsWith(currentUrl);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function filterNetworkRequestsData(data: unknown, currentUrl: string, request: NetworkRequestsPageFilterRequest): { data: Record<string, unknown>; matchedRows: number; totalRows: number; rows: unknown[] } | undefined {
|
|
62
|
+
if (!isRecord(data)) return undefined;
|
|
63
|
+
const requestRows = Array.isArray(data.requests) ? data.requests : Array.isArray(data.items) ? data.items : Array.isArray(data.entries) ? data.entries : undefined;
|
|
64
|
+
if (!requestRows) return undefined;
|
|
65
|
+
const rows = requestRows.filter((row) => requestMatchesCurrentPage(row, currentUrl, request.mode));
|
|
66
|
+
const key = Array.isArray(data.requests) ? "requests" : Array.isArray(data.items) ? "items" : "entries";
|
|
67
|
+
return { data: { ...data, [key]: rows }, matchedRows: rows.length, rows, totalRows: requestRows.length };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatNetworkRequestRow(row: unknown): string {
|
|
71
|
+
if (!isRecord(row)) return redactSensitiveText(String(row));
|
|
72
|
+
const status = row.status ?? row.statusCode ?? row.responseStatus ?? "?";
|
|
73
|
+
const method = typeof row.method === "string" ? row.method : typeof row.requestMethod === "string" ? row.requestMethod : "?";
|
|
74
|
+
const id = typeof row.id === "string" ? ` id=${row.id}` : typeof row.requestId === "string" ? ` id=${row.requestId}` : "";
|
|
75
|
+
const url = getRequestUrl(row) ?? "(no url)";
|
|
76
|
+
return redactSensitiveText(`- ${status} ${method}${id} ${url}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function tryNetworkRequestsPageFilter(options: {
|
|
80
|
+
commandTokens: string[];
|
|
81
|
+
compatibilityWorkaround?: CompatibilityWorkaround;
|
|
82
|
+
cwd: string;
|
|
83
|
+
effectiveArgs: string[];
|
|
84
|
+
redactedArgs: string[];
|
|
85
|
+
sessionMode: "auto" | "fresh";
|
|
86
|
+
sessionName?: string;
|
|
87
|
+
signal?: AbortSignal;
|
|
88
|
+
usedImplicitSession: boolean;
|
|
89
|
+
}): Promise<AgentBrowserToolResult | undefined> {
|
|
90
|
+
const request = parseNetworkRequestsPageFilterRequest(options.commandTokens);
|
|
91
|
+
if (!request || !options.sessionName) return undefined;
|
|
92
|
+
const currentUrl = extractCurrentUrl(await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal }));
|
|
93
|
+
if (!currentUrl) return undefined;
|
|
94
|
+
const networkData = await runSessionCommandData({ args: request.cleanArgs, cwd: options.cwd, sessionName: options.sessionName, signal: options.signal });
|
|
95
|
+
const filtered = filterNetworkRequestsData(networkData, currentUrl, request);
|
|
96
|
+
if (!filtered) return undefined;
|
|
97
|
+
const summary = `Network requests filtered to current ${request.mode === "origin" ? "origin" : "URL"}: ${filtered.matchedRows}/${filtered.totalRows} rows matched.`;
|
|
98
|
+
const preview = filtered.rows.slice(0, 12).map(formatNetworkRequestRow);
|
|
99
|
+
const omitted = filtered.rows.length > preview.length ? [`- …${filtered.rows.length - preview.length} more matching rows omitted`] : [];
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: "text", text: [redactSensitiveText(summary), `Current page: ${redactSensitiveText(currentUrl)}`, ...preview, ...omitted].join("\n") }],
|
|
102
|
+
details: {
|
|
103
|
+
args: options.redactedArgs,
|
|
104
|
+
command: "network",
|
|
105
|
+
compatibilityWorkaround: options.compatibilityWorkaround,
|
|
106
|
+
data: filtered.data,
|
|
107
|
+
effectiveArgs: options.effectiveArgs,
|
|
108
|
+
networkRequestsPageFilter: { cleanArgs: request.cleanArgs, currentUrl: redactSensitiveText(currentUrl), matchedRows: filtered.matchedRows, mode: request.mode, totalRows: filtered.totalRows },
|
|
109
|
+
sessionMode: options.sessionMode,
|
|
110
|
+
...buildAgentBrowserResultCategoryDetails({ args: options.effectiveArgs, command: "network", succeeded: true }),
|
|
111
|
+
...buildSessionDetailFields(options.sessionName, options.usedImplicitSession),
|
|
112
|
+
summary,
|
|
113
|
+
},
|
|
114
|
+
isError: false,
|
|
115
|
+
};
|
|
116
|
+
}
|