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.
Files changed (34) hide show
  1. package/CHANGELOG.md +46 -19
  2. package/README.md +38 -15
  3. package/docs/ARCHITECTURE.md +10 -10
  4. package/docs/COMMAND_REFERENCE.md +35 -21
  5. package/docs/ELECTRON.md +3 -3
  6. package/docs/RELEASE.md +28 -19
  7. package/docs/REQUIREMENTS.md +1 -1
  8. package/docs/SUPPORT_MATRIX.md +34 -106
  9. package/docs/TOOL_CONTRACT.md +23 -21
  10. package/extensions/agent-browser/index.ts +13 -4
  11. package/extensions/agent-browser/lib/config.ts +2 -0
  12. package/extensions/agent-browser/lib/input-modes/job.ts +138 -62
  13. package/extensions/agent-browser/lib/input-modes/params.ts +2 -2
  14. package/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.ts +44 -0
  15. package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +42 -19
  16. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +6 -4
  17. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +18 -9
  18. package/extensions/agent-browser/lib/orchestration/browser-run/prepare/direct-anchor-download.ts +158 -0
  19. package/extensions/agent-browser/lib/orchestration/browser-run/prepare/network-page-filter.ts +116 -0
  20. package/extensions/agent-browser/lib/orchestration/browser-run/prepare/scroll-shims.ts +147 -0
  21. package/extensions/agent-browser/lib/orchestration/browser-run/prepare/snapshot-filter.ts +183 -0
  22. package/extensions/agent-browser/lib/orchestration/browser-run/prepare/wait-timeouts.ts +58 -0
  23. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +19 -653
  24. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +1 -6
  25. package/extensions/agent-browser/lib/orchestration/browser-run/session-artifacts.ts +8 -0
  26. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +1 -0
  27. package/extensions/agent-browser/lib/pi-tool-rendering.ts +34 -19
  28. package/extensions/agent-browser/lib/playbook.ts +4 -4
  29. package/extensions/agent-browser/lib/results/action-recommendations.ts +3 -3
  30. package/extensions/agent-browser/lib/web-search.ts +11 -4
  31. package/package.json +4 -4
  32. package/scripts/agent-browser-capability-baseline.mjs +6 -3
  33. package/scripts/doctor.mjs +11 -10
  34. 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, or URL pattern for assertUrl 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; fixed wait steps still must stay below the upstream IPC wait budget. Electron actions use electron.timeoutMs instead.", minimum: 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; 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
- return { kind: "accessible", name: ref.name, refId, role: ref.role };
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
- for (let current = element.parentElement; current && current !== document.body; current = current.parentElement) {
101
- if (current.scrollHeight > current.clientHeight + 1 || current.scrollWidth > current.clientWidth + 1) {
102
- const containerRect = current.getBoundingClientRect();
103
- nearestScrollContainer = {
104
- selector: getSelector(current),
105
- tagName: current.tagName.toLowerCase(),
106
- targetOutsideContainer: targetRect.bottom < containerRect.top || targetRect.top > containerRect.bottom || targetRect.right < containerRect.left || targetRect.left > containerRect.right,
107
- targetOutsideViewport,
108
- rect: rectInfo(containerRect),
109
- scrollLeft: current.scrollLeft,
110
- scrollTop: current.scrollTop,
111
- };
112
- break;
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 no trusted DOM event reached the selected element. ${scrollContainer.summary}`
237
- : "Upstream click reported success but no trusted DOM event reached the selected element. 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.";
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 "./prepare.js";
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 lines = ["Artifact lifecycle:", `- ${guidance.summary}`, `- ${guidance.note}`];
515
- if (guidance.explicitArtifactPaths.length > 0) lines.push(`- Explicit artifact paths to review: ${guidance.explicitArtifactPaths.join(", ")}`);
516
- return lines.join("\n");
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: "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.",
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
  }
@@ -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
+ }