pi-agent-browser-native 0.2.46 → 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 +64 -20
- package/README.md +45 -20
- package/docs/ARCHITECTURE.md +14 -14
- package/docs/COMMAND_REFERENCE.md +37 -23
- package/docs/ELECTRON.md +3 -3
- package/docs/RELEASE.md +33 -24
- package/docs/REQUIREMENTS.md +4 -4
- package/docs/SUPPORT_MATRIX.md +34 -106
- package/docs/TOOL_CONTRACT.md +24 -22
- package/docs/platform-smoke.md +2 -2
- package/extensions/agent-browser/index.ts +20 -2
- package/extensions/agent-browser/lib/config-policy.js +16 -5
- package/extensions/agent-browser/lib/config.ts +17 -4
- 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 +12 -11
- package/scripts/platform-smoke/platform-build-windows.ps1 +2 -2
- package/scripts/platform-smoke/targets.mjs +7 -3
- package/scripts/platform-smoke.mjs +2 -2
|
@@ -60,20 +60,70 @@ function getUnsupportedJobStepField(step: Record<string, unknown>, allowedFields
|
|
|
60
60
|
return Object.keys(step).find((field) => !allowedFields.has(field));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
function getUnsupportedJobStepFieldError(step: Record<string, unknown>, action: AgentBrowserJobStepAction, allowedFields: ReadonlySet<string>): string | undefined {
|
|
64
|
+
const unsupportedField = getUnsupportedJobStepField(step, allowedFields);
|
|
65
|
+
if (!unsupportedField) return undefined;
|
|
66
|
+
const supportedFields = [...allowedFields].filter((field) => field !== "action");
|
|
67
|
+
const supportedText = supportedFields.length > 0 ? `supported fields are ${supportedFields.join(", ")}.` : "no additional fields are supported.";
|
|
68
|
+
return `job step ${action} does not support ${unsupportedField}; ${supportedText}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const JOB_STEP_ALLOWED_FIELDS = {
|
|
72
|
+
assertText: new Set(["action", "text"]),
|
|
73
|
+
assertUrl: new Set(["action", "url"]),
|
|
74
|
+
click: new Set(["action", "locator", "name", "role", "selector", "value"]),
|
|
75
|
+
fill: new Set(["action", "locator", "name", "role", "selector", "text", "value"]),
|
|
76
|
+
open: new Set(["action", "loadState", "url"]),
|
|
77
|
+
screenshot: new Set(["action", "path"]),
|
|
78
|
+
select: new Set(["action", "selector", "value", "values"]),
|
|
79
|
+
snapshot: new Set(["action"]),
|
|
80
|
+
type: new Set(["action", "delayMs", "press", "selector", "text"]),
|
|
81
|
+
wait: new Set(["action", "milliseconds"]),
|
|
82
|
+
waitForDownload: new Set(["action", "path"]),
|
|
83
|
+
} satisfies Record<AgentBrowserJobStepAction, ReadonlySet<string>>;
|
|
84
|
+
|
|
85
|
+
type CompileJobStepResult = {
|
|
86
|
+
args?: string[];
|
|
87
|
+
error?: string;
|
|
88
|
+
extraSteps?: CompiledAgentBrowserJobStep[];
|
|
89
|
+
generatedFrom?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type JobStepCompiler = (step: Record<string, unknown>, index: number) => CompileJobStepResult;
|
|
93
|
+
|
|
94
|
+
type JobStepDescriptor = {
|
|
95
|
+
allowedFields: ReadonlySet<string>;
|
|
96
|
+
compile: JobStepCompiler;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function globUrlPatternToRegexSource(pattern: string): string {
|
|
100
|
+
let source = "^";
|
|
101
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
102
|
+
const char = pattern.charAt(index);
|
|
103
|
+
if (char === "*") {
|
|
104
|
+
let runLength = 1;
|
|
105
|
+
while (pattern[index + runLength] === "*") runLength += 1;
|
|
106
|
+
source += runLength === 1 ? "[^/]*" : ".*";
|
|
107
|
+
index += runLength - 1;
|
|
108
|
+
} else {
|
|
109
|
+
source += char.replace(/[\\^$+?.()|[\]{}]/g, "\\$&");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return `${source}$`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function compileJobAssertUrlArgs(url: string): string[] {
|
|
116
|
+
if (!url.includes("*")) return ["wait", "--url", url];
|
|
117
|
+
return ["wait", "--fn", `new RegExp(${JSON.stringify(globUrlPatternToRegexSource(url))}).test(location.href)`];
|
|
118
|
+
}
|
|
64
119
|
|
|
65
120
|
function compileJobTypeSteps(step: Record<string, unknown>): { error?: string; steps?: CompiledAgentBrowserJobStep[] } {
|
|
66
|
-
const unsupportedField = getUnsupportedJobStepField(step, JOB_TYPE_ALLOWED_FIELDS);
|
|
67
|
-
if (unsupportedField) return { error: `job step type does not support ${unsupportedField}; supported fields are selector, text, delayMs, and press.` };
|
|
68
121
|
const text = getRequiredJobString(step, "text", "type");
|
|
69
122
|
if (text.error) return { error: text.error };
|
|
70
123
|
const selector = step.selector;
|
|
71
124
|
if (selector !== undefined && (typeof selector !== "string" || selector.trim().length === 0)) {
|
|
72
125
|
return { error: "job step type selector must be a non-empty string when provided." };
|
|
73
126
|
}
|
|
74
|
-
if (step.locator !== undefined || step.role !== undefined || step.name !== undefined || step.value !== undefined || step.values !== undefined) {
|
|
75
|
-
return { error: "job step type supports selector, text, delayMs, and press only; focus the target first or use click/fill semantic locator fields in a separate step." };
|
|
76
|
-
}
|
|
77
127
|
const delayMs = step.delayMs;
|
|
78
128
|
if (delayMs !== undefined && (typeof delayMs !== "number" || !Number.isInteger(delayMs) || delayMs <= 0)) {
|
|
79
129
|
return { error: "job step type delayMs must be a positive integer when provided." };
|
|
@@ -102,6 +152,82 @@ function compileJobTypeSteps(step: Record<string, unknown>): { error?: string; s
|
|
|
102
152
|
return { steps: compiledSteps };
|
|
103
153
|
}
|
|
104
154
|
|
|
155
|
+
function compileOpenJobStep(step: Record<string, unknown>, index: number): CompileJobStepResult {
|
|
156
|
+
const result = getRequiredJobString(step, "url", "open");
|
|
157
|
+
if (result.error) return { error: result.error };
|
|
158
|
+
const extraSteps: CompiledAgentBrowserJobStep[] = [];
|
|
159
|
+
if (step.loadState !== undefined) {
|
|
160
|
+
if (typeof step.loadState !== "string" || !AGENT_BROWSER_QA_LOAD_STATES.includes(step.loadState as AgentBrowserQaLoadState)) {
|
|
161
|
+
return { error: `job.steps[${index}].loadState must be one of: ${AGENT_BROWSER_QA_LOAD_STATES.join(", ")}.` };
|
|
162
|
+
}
|
|
163
|
+
extraSteps.push({ action: "wait", args: ["wait", "--load", step.loadState], generatedFrom: "open.loadState" });
|
|
164
|
+
}
|
|
165
|
+
return { args: ["open", result.value as string], extraSteps };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function compileClickJobStep(step: Record<string, unknown>): CompileJobStepResult {
|
|
169
|
+
return compileJobClickOrFillStep(step, "click");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function compileFillJobStep(step: Record<string, unknown>): CompileJobStepResult {
|
|
173
|
+
return compileJobClickOrFillStep(step, "fill");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function compileTypeJobStep(step: Record<string, unknown>): CompileJobStepResult {
|
|
177
|
+
const result = compileJobTypeSteps(step);
|
|
178
|
+
if (result.error) return { error: result.error };
|
|
179
|
+
const [firstStep, ...extraSteps] = result.steps as CompiledAgentBrowserJobStep[];
|
|
180
|
+
return { args: firstStep.args, extraSteps, generatedFrom: firstStep.generatedFrom };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function compileSelectJobStep(step: Record<string, unknown>, index: number): CompileJobStepResult {
|
|
184
|
+
const selector = getRequiredJobString(step, "selector", "select");
|
|
185
|
+
if (selector.error) return { error: selector.error };
|
|
186
|
+
const values = getSelectValues(step, `job.steps[${index}]`);
|
|
187
|
+
if (values.error) return { error: values.error };
|
|
188
|
+
return { args: ["select", selector.value as string, ...(values.values as string[])] };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function compileWaitJobStep(step: Record<string, unknown>): CompileJobStepResult {
|
|
192
|
+
const milliseconds = step.milliseconds;
|
|
193
|
+
if (typeof milliseconds !== "number" || !Number.isInteger(milliseconds) || milliseconds <= 0) {
|
|
194
|
+
return { error: "job step wait requires a positive integer milliseconds value." };
|
|
195
|
+
}
|
|
196
|
+
return { args: ["wait", String(milliseconds)] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function compileAssertTextJobStep(step: Record<string, unknown>): CompileJobStepResult {
|
|
200
|
+
const result = getRequiredJobString(step, "text", "assertText");
|
|
201
|
+
if (result.error) return { error: result.error };
|
|
202
|
+
return { args: ["wait", "--text", result.value as string] };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function compileAssertUrlJobStep(step: Record<string, unknown>): CompileJobStepResult {
|
|
206
|
+
const result = getRequiredJobString(step, "url", "assertUrl");
|
|
207
|
+
if (result.error) return { error: result.error };
|
|
208
|
+
return { args: compileJobAssertUrlArgs(result.value as string) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function compilePathArtifactJobStep(step: Record<string, unknown>, action: "screenshot" | "waitForDownload"): CompileJobStepResult {
|
|
212
|
+
const result = getRequiredJobString(step, "path", action);
|
|
213
|
+
if (result.error) return { error: result.error };
|
|
214
|
+
return { args: action === "waitForDownload" ? ["wait", "--download", result.value as string] : ["screenshot", result.value as string] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const JOB_STEP_DESCRIPTORS: Record<AgentBrowserJobStepAction, JobStepDescriptor> = {
|
|
218
|
+
assertText: { allowedFields: JOB_STEP_ALLOWED_FIELDS.assertText, compile: compileAssertTextJobStep },
|
|
219
|
+
assertUrl: { allowedFields: JOB_STEP_ALLOWED_FIELDS.assertUrl, compile: compileAssertUrlJobStep },
|
|
220
|
+
click: { allowedFields: JOB_STEP_ALLOWED_FIELDS.click, compile: compileClickJobStep },
|
|
221
|
+
fill: { allowedFields: JOB_STEP_ALLOWED_FIELDS.fill, compile: compileFillJobStep },
|
|
222
|
+
open: { allowedFields: JOB_STEP_ALLOWED_FIELDS.open, compile: compileOpenJobStep },
|
|
223
|
+
screenshot: { allowedFields: JOB_STEP_ALLOWED_FIELDS.screenshot, compile: (step) => compilePathArtifactJobStep(step, "screenshot") },
|
|
224
|
+
select: { allowedFields: JOB_STEP_ALLOWED_FIELDS.select, compile: compileSelectJobStep },
|
|
225
|
+
snapshot: { allowedFields: JOB_STEP_ALLOWED_FIELDS.snapshot, compile: () => ({ args: ["snapshot", "-i"] }) },
|
|
226
|
+
type: { allowedFields: JOB_STEP_ALLOWED_FIELDS.type, compile: compileTypeJobStep },
|
|
227
|
+
wait: { allowedFields: JOB_STEP_ALLOWED_FIELDS.wait, compile: compileWaitJobStep },
|
|
228
|
+
waitForDownload: { allowedFields: JOB_STEP_ALLOWED_FIELDS.waitForDownload, compile: (step) => compilePathArtifactJobStep(step, "waitForDownload") },
|
|
229
|
+
};
|
|
230
|
+
|
|
105
231
|
export function compileAgentBrowserJob(input: unknown): { compiled?: CompiledAgentBrowserJob; error?: string } {
|
|
106
232
|
if (!isRecord(input)) {
|
|
107
233
|
return { error: "job must be an object." };
|
|
@@ -125,62 +251,12 @@ export function compileAgentBrowserJob(input: unknown): { compiled?: CompiledAge
|
|
|
125
251
|
return { error: `job.steps[${index}].action must be one of: ${AGENT_BROWSER_JOB_STEP_ACTIONS.join(", ")}.` };
|
|
126
252
|
}
|
|
127
253
|
const jobAction = action as AgentBrowserJobStepAction;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
args = ["open", result.value as string];
|
|
135
|
-
if (rawStep.loadState !== undefined) {
|
|
136
|
-
if (typeof rawStep.loadState !== "string" || !AGENT_BROWSER_QA_LOAD_STATES.includes(rawStep.loadState as AgentBrowserQaLoadState)) {
|
|
137
|
-
return { error: `job.steps[${index}].loadState must be one of: ${AGENT_BROWSER_QA_LOAD_STATES.join(", ")}.` };
|
|
138
|
-
}
|
|
139
|
-
extraSteps = [{ action: "wait", args: ["wait", "--load", rawStep.loadState], generatedFrom: "open.loadState" }];
|
|
140
|
-
}
|
|
141
|
-
} else if (jobAction === "click" || jobAction === "fill") {
|
|
142
|
-
const result = compileJobClickOrFillStep(rawStep, jobAction);
|
|
143
|
-
if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
|
|
144
|
-
args = result.args as string[];
|
|
145
|
-
} else if (jobAction === "type") {
|
|
146
|
-
const result = compileJobTypeSteps(rawStep);
|
|
147
|
-
if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
|
|
148
|
-
const [firstStep, ...restSteps] = result.steps as CompiledAgentBrowserJobStep[];
|
|
149
|
-
args = firstStep.args;
|
|
150
|
-
generatedFrom = firstStep.generatedFrom;
|
|
151
|
-
extraSteps = restSteps;
|
|
152
|
-
} else if (jobAction === "select") {
|
|
153
|
-
const selector = getRequiredJobString(rawStep, "selector", jobAction);
|
|
154
|
-
if (selector.error) return { error: `job.steps[${index}]: ${selector.error}` };
|
|
155
|
-
const values = getSelectValues(rawStep, `job.steps[${index}]`);
|
|
156
|
-
if (values.error) return { error: values.error };
|
|
157
|
-
args = ["select", selector.value as string, ...(values.values as string[])];
|
|
158
|
-
} else if (jobAction === "wait") {
|
|
159
|
-
const milliseconds = rawStep.milliseconds;
|
|
160
|
-
if (typeof milliseconds !== "number" || !Number.isInteger(milliseconds) || milliseconds <= 0) {
|
|
161
|
-
return { error: `job.steps[${index}]: job step wait requires a positive integer milliseconds value.` };
|
|
162
|
-
}
|
|
163
|
-
args = ["wait", String(milliseconds)];
|
|
164
|
-
} else if (jobAction === "assertText") {
|
|
165
|
-
const result = getRequiredJobString(rawStep, "text", jobAction);
|
|
166
|
-
if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
|
|
167
|
-
args = ["wait", "--text", result.value as string];
|
|
168
|
-
} else if (jobAction === "assertUrl") {
|
|
169
|
-
const result = getRequiredJobString(rawStep, "url", jobAction);
|
|
170
|
-
if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
|
|
171
|
-
args = ["wait", "--url", result.value as string];
|
|
172
|
-
} else if (jobAction === "waitForDownload") {
|
|
173
|
-
const result = getRequiredJobString(rawStep, "path", jobAction);
|
|
174
|
-
if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
|
|
175
|
-
args = ["wait", "--download", result.value as string];
|
|
176
|
-
} else if (jobAction === "snapshot") {
|
|
177
|
-
args = ["snapshot", "-i"];
|
|
178
|
-
} else {
|
|
179
|
-
const result = getRequiredJobString(rawStep, "path", jobAction);
|
|
180
|
-
if (result.error) return { error: `job.steps[${index}]: ${result.error}` };
|
|
181
|
-
args = ["screenshot", result.value as string];
|
|
182
|
-
}
|
|
183
|
-
steps.push({ action: jobAction, args, generatedFrom }, ...extraSteps);
|
|
254
|
+
const descriptor = JOB_STEP_DESCRIPTORS[jobAction];
|
|
255
|
+
const unsupportedFieldError = getUnsupportedJobStepFieldError(rawStep, jobAction, descriptor.allowedFields);
|
|
256
|
+
if (unsupportedFieldError) return { error: `job.steps[${index}]: ${unsupportedFieldError}` };
|
|
257
|
+
const compiledStep = descriptor.compile(rawStep, index);
|
|
258
|
+
if (compiledStep.error) return { error: compiledStep.error.startsWith(`job.steps[${index}]`) ? compiledStep.error : `job.steps[${index}]: ${compiledStep.error}` };
|
|
259
|
+
steps.push({ action: jobAction, args: compiledStep.args as string[], generatedFrom: compiledStep.generatedFrom }, ...(compiledStep.extraSteps ?? []));
|
|
184
260
|
}
|
|
185
261
|
return { compiled: { args: failFast ? ["batch", "--bail"] : ["batch"], failFast, stdin: JSON.stringify(steps.map((step) => step.args)), steps } };
|
|
186
262
|
}
|
|
@@ -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
|
+
}
|