pi-agent-browser-native 0.2.31 → 0.2.32
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 +18 -0
- package/README.md +41 -6
- package/docs/ARCHITECTURE.md +10 -8
- package/docs/COMMAND_REFERENCE.md +56 -9
- package/docs/ELECTRON.md +368 -0
- package/docs/RELEASE.md +30 -2
- package/docs/REQUIREMENTS.md +4 -2
- package/docs/SUPPORT_MATRIX.md +8 -5
- package/docs/TOOL_CONTRACT.md +172 -19
- package/extensions/agent-browser/index.ts +2225 -159
- package/extensions/agent-browser/lib/electron/cleanup.ts +287 -0
- package/extensions/agent-browser/lib/electron/discovery.ts +717 -0
- package/extensions/agent-browser/lib/electron/launch.ts +553 -0
- package/extensions/agent-browser/lib/playbook.ts +7 -6
- package/extensions/agent-browser/lib/results/presentation.ts +37 -7
- package/extensions/agent-browser/lib/results/shared.ts +88 -0
- package/extensions/agent-browser/lib/temp.ts +26 -0
- package/package.json +5 -4
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Launch wrapper-owned Electron applications and discover their CDP endpoint.
|
|
3
|
+
* Responsibilities: Resolve Electron targets, enforce caller-owned allow/deny policy, create isolated userDataDir profiles, launch with remote debugging on an OS-chosen port, poll DevToolsActivePort, and read bounded CDP version/target metadata.
|
|
4
|
+
* Scope: Host-side Electron lifecycle setup only; upstream agent-browser attach/presentation stays in the extension entrypoint.
|
|
5
|
+
* Usage: Called by the agent_browser electron.launch shorthand before routing through upstream `connect`.
|
|
6
|
+
* Invariants/Assumptions: The wrapper only launches targets with Electron framework evidence, always uses an isolated temp profile, and never accepts a caller-supplied remote debugging port.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { readFile, rm } from "node:fs/promises";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
discoverElectronApps,
|
|
16
|
+
inspectElectronAppPath,
|
|
17
|
+
inspectElectronExecutablePath,
|
|
18
|
+
type ElectronAppDiscovery,
|
|
19
|
+
} from "./discovery.js";
|
|
20
|
+
import { createSecureTempDirectory } from "../temp.js";
|
|
21
|
+
|
|
22
|
+
export const ELECTRON_LAUNCH_RECORD_VERSION = 1;
|
|
23
|
+
export const ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS = 15_000;
|
|
24
|
+
export const ELECTRON_LAUNCH_MAX_TIMEOUT_MS = 120_000;
|
|
25
|
+
|
|
26
|
+
const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
|
|
27
|
+
export const ELECTRON_PROFILE_DIR_PREFIX = "electron-profile-";
|
|
28
|
+
const ELECTRON_DEFAULT_APP_ARGS = ["--disable-extensions", "--no-first-run", "--no-default-browser-check"] as const;
|
|
29
|
+
const ELECTRON_DEVTOOLS_POLL_INTERVAL_MS = 100;
|
|
30
|
+
const ELECTRON_CDP_FETCH_TIMEOUT_MS = 1_000;
|
|
31
|
+
|
|
32
|
+
export interface ElectronDevToolsActivePortRead {
|
|
33
|
+
error?: string;
|
|
34
|
+
found: boolean;
|
|
35
|
+
path: string;
|
|
36
|
+
port?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ElectronLaunchFailureDiagnostics {
|
|
40
|
+
cdpVersionReached?: boolean;
|
|
41
|
+
devToolsActivePort?: ElectronDevToolsActivePortRead;
|
|
42
|
+
elapsedMs?: number;
|
|
43
|
+
exitCode?: number | null;
|
|
44
|
+
exitSignal?: NodeJS.Signals | null;
|
|
45
|
+
outputCaptured: false;
|
|
46
|
+
pid?: number;
|
|
47
|
+
pidAlive?: boolean;
|
|
48
|
+
port?: number;
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
userDataDir?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ElectronLaunchCleanupState = "active" | "cleaned" | "dead" | "failed" | "partial";
|
|
54
|
+
export type ElectronLaunchFailureReason =
|
|
55
|
+
| "non-electron-target"
|
|
56
|
+
| "policy-blocked"
|
|
57
|
+
| "port-not-found"
|
|
58
|
+
| "single-instance-conflict"
|
|
59
|
+
| "spawn-error"
|
|
60
|
+
| "timeout";
|
|
61
|
+
|
|
62
|
+
export interface ElectronCdpVersion {
|
|
63
|
+
browser?: string;
|
|
64
|
+
protocolVersion?: string;
|
|
65
|
+
userAgent?: string;
|
|
66
|
+
v8Version?: string;
|
|
67
|
+
webKitVersion?: string;
|
|
68
|
+
webSocketDebuggerUrl?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ElectronCdpTarget {
|
|
72
|
+
id?: string;
|
|
73
|
+
title?: string;
|
|
74
|
+
type?: string;
|
|
75
|
+
url?: string;
|
|
76
|
+
webSocketDebuggerUrl?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ElectronLaunchRecord {
|
|
80
|
+
appName: string;
|
|
81
|
+
appPath?: string;
|
|
82
|
+
bundleId?: string;
|
|
83
|
+
cleanupState: ElectronLaunchCleanupState;
|
|
84
|
+
createdAtMs: number;
|
|
85
|
+
desktopId?: string;
|
|
86
|
+
executablePath: string;
|
|
87
|
+
launchId: string;
|
|
88
|
+
launchedByWrapper: true;
|
|
89
|
+
packageSource?: string;
|
|
90
|
+
pid?: number;
|
|
91
|
+
platform?: string;
|
|
92
|
+
port: number;
|
|
93
|
+
processGroupId?: number;
|
|
94
|
+
sessionName?: string;
|
|
95
|
+
targetType?: "any" | "page" | "webview";
|
|
96
|
+
userDataDir: string;
|
|
97
|
+
version: typeof ELECTRON_LAUNCH_RECORD_VERSION;
|
|
98
|
+
webSocketDebuggerUrl?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ElectronPolicyBlock {
|
|
102
|
+
entry?: string;
|
|
103
|
+
list: "allow" | "deny";
|
|
104
|
+
message: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ElectronLaunchSuccess {
|
|
108
|
+
appArgs: string[];
|
|
109
|
+
child: ChildProcess;
|
|
110
|
+
connectArg: string;
|
|
111
|
+
record: ElectronLaunchRecord;
|
|
112
|
+
target: ElectronAppDiscovery;
|
|
113
|
+
targets: ElectronCdpTarget[];
|
|
114
|
+
version: ElectronCdpVersion;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ElectronLaunchFailure {
|
|
118
|
+
appArgs: string[];
|
|
119
|
+
cleanupError?: string;
|
|
120
|
+
diagnostics?: ElectronLaunchFailureDiagnostics;
|
|
121
|
+
error: string;
|
|
122
|
+
policy?: ElectronPolicyBlock;
|
|
123
|
+
reason: ElectronLaunchFailureReason;
|
|
124
|
+
target?: ElectronAppDiscovery;
|
|
125
|
+
userDataDir?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ElectronLaunchResult = { ok: true; value: ElectronLaunchSuccess } | { ok: false; failure: ElectronLaunchFailure };
|
|
129
|
+
|
|
130
|
+
export interface ResolveElectronTargetOptions {
|
|
131
|
+
appName?: string;
|
|
132
|
+
appPath?: string;
|
|
133
|
+
bundleId?: string;
|
|
134
|
+
executablePath?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeTimeoutMs(timeoutMs: number | undefined): number {
|
|
138
|
+
if (!Number.isSafeInteger(timeoutMs) || (timeoutMs ?? 0) <= 0) return ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS;
|
|
139
|
+
return Math.min(timeoutMs as number, ELECTRON_LAUNCH_MAX_TIMEOUT_MS);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sleep(ms: number): Promise<void> {
|
|
143
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeIdentifier(value: string | undefined): string | undefined {
|
|
147
|
+
const trimmed = value?.trim().toLowerCase();
|
|
148
|
+
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function appIdentifiers(app: ElectronAppDiscovery): string[] {
|
|
152
|
+
return [app.name, app.bundleId, app.desktopId, app.appPath, app.executablePath]
|
|
153
|
+
.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function policyEntryMatchesApp(entry: string, app: ElectronAppDiscovery): boolean {
|
|
157
|
+
const normalizedEntry = normalizeIdentifier(entry);
|
|
158
|
+
if (!normalizedEntry) return false;
|
|
159
|
+
return appIdentifiers(app).some((identifier) => identifier.toLowerCase().includes(normalizedEntry));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function evaluateElectronLaunchPolicy(options: {
|
|
163
|
+
allow?: string[];
|
|
164
|
+
deny?: string[];
|
|
165
|
+
target: ElectronAppDiscovery;
|
|
166
|
+
}): ElectronPolicyBlock | undefined {
|
|
167
|
+
const denyEntry = options.deny?.find((entry) => policyEntryMatchesApp(entry, options.target));
|
|
168
|
+
if (denyEntry) {
|
|
169
|
+
return {
|
|
170
|
+
entry: denyEntry,
|
|
171
|
+
list: "deny",
|
|
172
|
+
message: `Electron launch blocked by caller deny policy: ${denyEntry}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (options.allow && options.allow.length > 0) {
|
|
176
|
+
const allowEntry = options.allow.find((entry) => policyEntryMatchesApp(entry, options.target));
|
|
177
|
+
if (!allowEntry) {
|
|
178
|
+
return {
|
|
179
|
+
list: "allow",
|
|
180
|
+
message: "Electron launch blocked because the resolved app did not match caller allow policy.",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function resolveElectronLaunchTarget(options: ResolveElectronTargetOptions): Promise<ElectronAppDiscovery | undefined> {
|
|
188
|
+
if (options.appPath) return inspectElectronAppPath(options.appPath);
|
|
189
|
+
if (options.executablePath) return inspectElectronExecutablePath(options.executablePath);
|
|
190
|
+
const query = options.bundleId ?? options.appName;
|
|
191
|
+
const discovery = await discoverElectronApps({ maxResults: 200, query });
|
|
192
|
+
if (options.bundleId) {
|
|
193
|
+
const normalizedBundleId = normalizeIdentifier(options.bundleId);
|
|
194
|
+
return discovery.apps.find((app) => normalizeIdentifier(app.bundleId) === normalizedBundleId);
|
|
195
|
+
}
|
|
196
|
+
if (options.appName) {
|
|
197
|
+
const normalizedName = normalizeIdentifier(options.appName);
|
|
198
|
+
return discovery.apps.find((app) => normalizeIdentifier(app.name) === normalizedName) ?? discovery.apps[0];
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
204
|
+
return typeof value === "object" && value !== null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function asString(value: unknown): string | undefined {
|
|
208
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseCdpVersion(value: unknown): ElectronCdpVersion | undefined {
|
|
212
|
+
if (!isRecord(value)) return undefined;
|
|
213
|
+
return {
|
|
214
|
+
browser: asString(value.Browser) ?? asString(value.browser),
|
|
215
|
+
protocolVersion: asString(value["Protocol-Version"]) ?? asString(value.protocolVersion),
|
|
216
|
+
userAgent: asString(value["User-Agent"]) ?? asString(value.userAgent),
|
|
217
|
+
v8Version: asString(value["V8-Version"]) ?? asString(value.v8Version),
|
|
218
|
+
webKitVersion: asString(value["WebKit-Version"]) ?? asString(value.webKitVersion),
|
|
219
|
+
webSocketDebuggerUrl: asString(value.webSocketDebuggerUrl),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseCdpTargets(value: unknown): ElectronCdpTarget[] {
|
|
224
|
+
if (!Array.isArray(value)) return [];
|
|
225
|
+
return value.filter(isRecord).map((target) => ({
|
|
226
|
+
id: asString(target.id),
|
|
227
|
+
title: asString(target.title),
|
|
228
|
+
type: asString(target.type),
|
|
229
|
+
url: asString(target.url),
|
|
230
|
+
webSocketDebuggerUrl: asString(target.webSocketDebuggerUrl),
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function targetMatchesType(target: ElectronCdpTarget, targetType: "any" | "page" | "webview" | undefined): boolean {
|
|
235
|
+
return targetType === undefined || targetType === "any" || target.type === targetType;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function selectElectronConnectArg(options: {
|
|
239
|
+
port: number;
|
|
240
|
+
targets: ElectronCdpTarget[];
|
|
241
|
+
targetType?: "any" | "page" | "webview";
|
|
242
|
+
version: ElectronCdpVersion;
|
|
243
|
+
}): string {
|
|
244
|
+
const targetWebSocket = options.targets.find((target) => targetMatchesType(target, options.targetType) && target.webSocketDebuggerUrl)?.webSocketDebuggerUrl;
|
|
245
|
+
return targetWebSocket ?? options.version.webSocketDebuggerUrl ?? String(options.port);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function fetchJson(url: string): Promise<unknown | undefined> {
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const timeout = setTimeout(() => controller.abort(), ELECTRON_CDP_FETCH_TIMEOUT_MS);
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
253
|
+
if (!response.ok) return undefined;
|
|
254
|
+
return await response.json() as unknown;
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
} finally {
|
|
258
|
+
clearTimeout(timeout);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function readDevToolsActivePort(userDataDir: string): Promise<ElectronDevToolsActivePortRead> {
|
|
263
|
+
const path = `${userDataDir}/${DEVTOOLS_ACTIVE_PORT_FILE}`;
|
|
264
|
+
try {
|
|
265
|
+
const text = await readFile(path, "utf8");
|
|
266
|
+
const [portLine] = text.split(/\r?\n/);
|
|
267
|
+
const port = Number(portLine?.trim());
|
|
268
|
+
return {
|
|
269
|
+
found: true,
|
|
270
|
+
path,
|
|
271
|
+
port: Number.isSafeInteger(port) && port > 0 && port <= 65_535 ? port : undefined,
|
|
272
|
+
...(Number.isSafeInteger(port) && port > 0 && port <= 65_535 ? {} : { error: "DevToolsActivePort did not contain a valid TCP port." }),
|
|
273
|
+
};
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
276
|
+
return {
|
|
277
|
+
error: code && code !== "ENOENT" ? `${code}: ${error instanceof Error ? error.message : String(error)}` : undefined,
|
|
278
|
+
found: false,
|
|
279
|
+
path,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function pollDevToolsActivePort(options: {
|
|
285
|
+
deadlineMs: number;
|
|
286
|
+
getChildExit: () => { code: number | null; signal: NodeJS.Signals | null };
|
|
287
|
+
getSpawnError: () => Error | undefined;
|
|
288
|
+
userDataDir: string;
|
|
289
|
+
}): Promise<{ devToolsActivePort?: ElectronDevToolsActivePortRead; failure?: ElectronLaunchFailureReason; port?: number; spawnError?: Error }> {
|
|
290
|
+
let devToolsActivePort: ElectronDevToolsActivePortRead | undefined;
|
|
291
|
+
while (Date.now() <= options.deadlineMs) {
|
|
292
|
+
const spawnError = options.getSpawnError();
|
|
293
|
+
if (spawnError) return { devToolsActivePort, failure: "spawn-error", spawnError };
|
|
294
|
+
devToolsActivePort = await readDevToolsActivePort(options.userDataDir);
|
|
295
|
+
if (devToolsActivePort.port) return { devToolsActivePort, port: devToolsActivePort.port };
|
|
296
|
+
const exit = options.getChildExit();
|
|
297
|
+
if (exit.code !== null || exit.signal !== null) {
|
|
298
|
+
return { devToolsActivePort, failure: exit.code === 0 ? "single-instance-conflict" : "spawn-error" };
|
|
299
|
+
}
|
|
300
|
+
await sleep(ELECTRON_DEVTOOLS_POLL_INTERVAL_MS);
|
|
301
|
+
}
|
|
302
|
+
return { devToolsActivePort, failure: "timeout" };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function pollCdpMetadata(port: number, deadlineMs: number): Promise<{ targets: ElectronCdpTarget[]; version: ElectronCdpVersion } | undefined> {
|
|
306
|
+
while (Date.now() <= deadlineMs) {
|
|
307
|
+
const version = parseCdpVersion(await fetchJson(`http://127.0.0.1:${port}/json/version`));
|
|
308
|
+
if (version) {
|
|
309
|
+
const targets = parseCdpTargets(await fetchJson(`http://127.0.0.1:${port}/json/list`));
|
|
310
|
+
return { targets, version };
|
|
311
|
+
}
|
|
312
|
+
await sleep(ELECTRON_DEVTOOLS_POLL_INTERVAL_MS);
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function buildLaunchArgs(userDataDir: string, appArgs: string[]): string[] {
|
|
318
|
+
return [
|
|
319
|
+
...appArgs,
|
|
320
|
+
`--user-data-dir=${userDataDir}`,
|
|
321
|
+
"--remote-debugging-port=0",
|
|
322
|
+
...ELECTRON_DEFAULT_APP_ARGS,
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function waitForLaunchChildExit(child: ChildProcess, deadlineMs: number): Promise<boolean> {
|
|
327
|
+
while (Date.now() <= deadlineMs) {
|
|
328
|
+
if (child.exitCode !== null || child.signalCode !== null) return true;
|
|
329
|
+
await sleep(50);
|
|
330
|
+
}
|
|
331
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isLaunchChildPidAlive(child: ChildProcess): boolean | undefined {
|
|
335
|
+
if (!child.pid) return undefined;
|
|
336
|
+
if (child.exitCode !== null || child.signalCode !== null) return false;
|
|
337
|
+
try {
|
|
338
|
+
process.kill(child.pid, 0);
|
|
339
|
+
return true;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function terminateLaunchChild(child: ChildProcess): Promise<string | undefined> {
|
|
346
|
+
if (!child.pid || child.exitCode !== null || child.signalCode !== null) return undefined;
|
|
347
|
+
try {
|
|
348
|
+
child.kill("SIGTERM");
|
|
349
|
+
} catch (error) {
|
|
350
|
+
return error instanceof Error ? error.message : String(error);
|
|
351
|
+
}
|
|
352
|
+
if (await waitForLaunchChildExit(child, Date.now() + 1_000)) return undefined;
|
|
353
|
+
try {
|
|
354
|
+
child.kill("SIGKILL");
|
|
355
|
+
} catch (error) {
|
|
356
|
+
return error instanceof Error ? error.message : String(error);
|
|
357
|
+
}
|
|
358
|
+
if (await waitForLaunchChildExit(child, Date.now() + 1_000)) return undefined;
|
|
359
|
+
return `PID ${child.pid} remained alive after failed Electron launch cleanup.`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function buildLaunchRecord(options: {
|
|
363
|
+
createdAtMs: number;
|
|
364
|
+
pid?: number;
|
|
365
|
+
port: number;
|
|
366
|
+
target: ElectronAppDiscovery;
|
|
367
|
+
targetType?: "any" | "page" | "webview";
|
|
368
|
+
userDataDir: string;
|
|
369
|
+
version: ElectronCdpVersion;
|
|
370
|
+
}): ElectronLaunchRecord {
|
|
371
|
+
return {
|
|
372
|
+
appName: options.target.name,
|
|
373
|
+
appPath: options.target.appPath,
|
|
374
|
+
bundleId: options.target.bundleId,
|
|
375
|
+
cleanupState: "active",
|
|
376
|
+
createdAtMs: options.createdAtMs,
|
|
377
|
+
desktopId: options.target.desktopId,
|
|
378
|
+
executablePath: options.target.executablePath,
|
|
379
|
+
launchId: `electron-${randomUUID()}`,
|
|
380
|
+
launchedByWrapper: true,
|
|
381
|
+
packageSource: options.target.packageSource,
|
|
382
|
+
pid: options.pid,
|
|
383
|
+
platform: options.target.platform,
|
|
384
|
+
port: options.port,
|
|
385
|
+
processGroupId: process.platform === "win32" ? undefined : options.pid,
|
|
386
|
+
targetType: options.targetType,
|
|
387
|
+
userDataDir: options.userDataDir,
|
|
388
|
+
version: ELECTRON_LAUNCH_RECORD_VERSION,
|
|
389
|
+
webSocketDebuggerUrl: options.version.webSocketDebuggerUrl,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function launchFailureMessage(reason: ElectronLaunchFailureReason, target: ElectronAppDiscovery | undefined, detail?: string): string {
|
|
394
|
+
const label = target ? `${target.name} (${target.appPath ?? target.executablePath})` : "target";
|
|
395
|
+
switch (reason) {
|
|
396
|
+
case "non-electron-target":
|
|
397
|
+
return `Electron launch rejected: ${label} does not have Electron framework evidence.`;
|
|
398
|
+
case "policy-blocked":
|
|
399
|
+
return detail ?? `Electron launch blocked by caller policy for ${label}.`;
|
|
400
|
+
case "single-instance-conflict":
|
|
401
|
+
return `Electron launch did not expose a debug port for ${label}; the app may already be running as a single-instance Electron app. Quit the running app and retry.`;
|
|
402
|
+
case "port-not-found":
|
|
403
|
+
return `Electron launch found a DevToolsActivePort for ${label}, but /json/version never returned a valid CDP payload.`;
|
|
404
|
+
case "spawn-error":
|
|
405
|
+
return `Electron launch failed while starting ${label}${detail ? `: ${detail}` : "."}`;
|
|
406
|
+
case "timeout":
|
|
407
|
+
return `Electron launch timed out waiting for DevToolsActivePort for ${label}.`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export async function launchElectronApp(options: {
|
|
412
|
+
allow?: string[];
|
|
413
|
+
appArgs?: string[];
|
|
414
|
+
deny?: string[];
|
|
415
|
+
appName?: string;
|
|
416
|
+
appPath?: string;
|
|
417
|
+
bundleId?: string;
|
|
418
|
+
executablePath?: string;
|
|
419
|
+
targetType?: "any" | "page" | "webview";
|
|
420
|
+
timeoutMs?: number;
|
|
421
|
+
}): Promise<ElectronLaunchResult> {
|
|
422
|
+
const appArgs = options.appArgs ?? [];
|
|
423
|
+
const target = await resolveElectronLaunchTarget(options);
|
|
424
|
+
if (!target) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
failure: {
|
|
428
|
+
appArgs,
|
|
429
|
+
error: launchFailureMessage("non-electron-target", undefined),
|
|
430
|
+
reason: "non-electron-target",
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const policy = evaluateElectronLaunchPolicy({ allow: options.allow, deny: options.deny, target });
|
|
436
|
+
if (policy) {
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
failure: {
|
|
440
|
+
appArgs,
|
|
441
|
+
error: launchFailureMessage("policy-blocked", target, policy.message),
|
|
442
|
+
policy,
|
|
443
|
+
reason: "policy-blocked",
|
|
444
|
+
target,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
450
|
+
const startedAtMs = Date.now();
|
|
451
|
+
const deadlineMs = startedAtMs + timeoutMs;
|
|
452
|
+
const userDataDir = await createSecureTempDirectory(ELECTRON_PROFILE_DIR_PREFIX);
|
|
453
|
+
let cleanupError: string | undefined;
|
|
454
|
+
let spawnError: Error | undefined;
|
|
455
|
+
let exitCode: number | null = null;
|
|
456
|
+
let exitSignal: NodeJS.Signals | null = null;
|
|
457
|
+
const args = buildLaunchArgs(userDataDir, appArgs);
|
|
458
|
+
const child = spawn(target.executablePath, args, {
|
|
459
|
+
cwd: dirname(target.executablePath),
|
|
460
|
+
detached: process.platform !== "win32",
|
|
461
|
+
stdio: "ignore",
|
|
462
|
+
});
|
|
463
|
+
child.once("error", (error) => {
|
|
464
|
+
spawnError = error;
|
|
465
|
+
});
|
|
466
|
+
child.once("exit", (code, signal) => {
|
|
467
|
+
exitCode = code;
|
|
468
|
+
exitSignal = signal;
|
|
469
|
+
});
|
|
470
|
+
child.unref();
|
|
471
|
+
|
|
472
|
+
const buildFailureDiagnostics = (options: {
|
|
473
|
+
cdpVersionReached?: boolean;
|
|
474
|
+
devToolsActivePort?: ElectronDevToolsActivePortRead;
|
|
475
|
+
port?: number;
|
|
476
|
+
} = {}): ElectronLaunchFailureDiagnostics => ({
|
|
477
|
+
cdpVersionReached: options.cdpVersionReached,
|
|
478
|
+
devToolsActivePort: options.devToolsActivePort,
|
|
479
|
+
elapsedMs: Math.max(0, Date.now() - startedAtMs),
|
|
480
|
+
exitCode,
|
|
481
|
+
exitSignal,
|
|
482
|
+
outputCaptured: false,
|
|
483
|
+
pid: child.pid,
|
|
484
|
+
pidAlive: isLaunchChildPidAlive(child),
|
|
485
|
+
port: options.port ?? options.devToolsActivePort?.port,
|
|
486
|
+
timeoutMs,
|
|
487
|
+
userDataDir,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const fail = async (reason: ElectronLaunchFailureReason, detail?: string, diagnosticOptions?: Parameters<typeof buildFailureDiagnostics>[0]): Promise<ElectronLaunchResult> => {
|
|
491
|
+
const diagnostics = buildFailureDiagnostics(diagnosticOptions);
|
|
492
|
+
const processCleanupError = await terminateLaunchChild(child);
|
|
493
|
+
try {
|
|
494
|
+
await rm(userDataDir, { force: true, recursive: true });
|
|
495
|
+
} catch (error) {
|
|
496
|
+
cleanupError = error instanceof Error ? error.message : String(error);
|
|
497
|
+
}
|
|
498
|
+
cleanupError = [processCleanupError, cleanupError].filter((value): value is string => value !== undefined).join("; ") || undefined;
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
failure: {
|
|
502
|
+
appArgs,
|
|
503
|
+
cleanupError,
|
|
504
|
+
diagnostics,
|
|
505
|
+
error: launchFailureMessage(reason, target, detail),
|
|
506
|
+
reason,
|
|
507
|
+
target,
|
|
508
|
+
userDataDir,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const portResult = await pollDevToolsActivePort({
|
|
514
|
+
deadlineMs,
|
|
515
|
+
getChildExit: () => ({ code: exitCode, signal: exitSignal }),
|
|
516
|
+
getSpawnError: () => spawnError,
|
|
517
|
+
userDataDir,
|
|
518
|
+
});
|
|
519
|
+
if (!portResult.port) {
|
|
520
|
+
return fail(portResult.failure ?? "timeout", portResult.spawnError?.message, { devToolsActivePort: portResult.devToolsActivePort });
|
|
521
|
+
}
|
|
522
|
+
const metadata = await pollCdpMetadata(portResult.port, deadlineMs);
|
|
523
|
+
if (!metadata) {
|
|
524
|
+
return fail("port-not-found", undefined, { cdpVersionReached: false, devToolsActivePort: portResult.devToolsActivePort, port: portResult.port });
|
|
525
|
+
}
|
|
526
|
+
const record = buildLaunchRecord({
|
|
527
|
+
createdAtMs: Date.now(),
|
|
528
|
+
pid: child.pid,
|
|
529
|
+
port: portResult.port,
|
|
530
|
+
target,
|
|
531
|
+
targetType: options.targetType,
|
|
532
|
+
userDataDir,
|
|
533
|
+
version: metadata.version,
|
|
534
|
+
});
|
|
535
|
+
const connectArg = selectElectronConnectArg({
|
|
536
|
+
port: portResult.port,
|
|
537
|
+
targets: metadata.targets,
|
|
538
|
+
targetType: options.targetType,
|
|
539
|
+
version: metadata.version,
|
|
540
|
+
});
|
|
541
|
+
return {
|
|
542
|
+
ok: true,
|
|
543
|
+
value: {
|
|
544
|
+
appArgs,
|
|
545
|
+
child,
|
|
546
|
+
connectArg,
|
|
547
|
+
record,
|
|
548
|
+
target,
|
|
549
|
+
targets: metadata.targets,
|
|
550
|
+
version: metadata.version,
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
}
|
|
@@ -18,11 +18,11 @@ export function buildInstalledDocsGuideline(paths: { readmePath: string; command
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export const QUICK_START_GUIDELINES = [
|
|
21
|
-
"Quick start mental model: use exactly one of args (exact agent-browser CLI args after the binary), semanticAction (a thin shorthand compiled to find argv for locator actions or select argv for native dropdowns), job (a constrained short-workflow schema compiled to batch), qa (a lightweight QA preset built on job/batch), or the experimental sourceLookup / networkSourceLookup helpers (each compiled to batch); stdin is only for batch, eval --stdin, auth save --password-stdin, and wrapper-generated batch stdin from job, qa, sourceLookup, or networkSourceLookup, and
|
|
21
|
+
"Quick start mental model: use exactly one of args (exact agent-browser CLI args after the binary), semanticAction (a thin shorthand compiled to find argv for locator actions or select argv for native dropdowns), job (a constrained short-workflow schema compiled to batch), qa (a lightweight QA preset built on job/batch, including qa.attached for current sessions), electron (desktop Electron list/launch/status/cleanup/probe), or the experimental sourceLookup / networkSourceLookup helpers (each compiled to batch); stdin is only for batch, eval --stdin, auth save --password-stdin, and wrapper-generated batch stdin from job, qa, sourceLookup, or networkSourceLookup, and is rejected with electron; sessionMode=fresh switches the extension-managed pi-scoped session to a fresh upstream launch when you need new --profile, --session-name, --cdp, --state, --auto-connect, --init-script, --enable, -p/--provider, or iOS --device state.",
|
|
22
22
|
"There is no first-class reusable named browser recipe runtime above top-level job, the qa preset, and raw batch stdin; keep recurring flows in documentation examples or those inputs (closed RQ-0068; see docs/ARCHITECTURE.md#no-reusable-recipe-layer-yet).",
|
|
23
23
|
"Common first calls: { args: [\"open\", \"https://example.com\"] } then { args: [\"snapshot\", \"-i\"] }; after navigation, use { args: [\"click\", \"@e2\"] } then { args: [\"snapshot\", \"-i\"] }.",
|
|
24
24
|
"Locator-first clicks/fills and native select changes without hand-building argv: { semanticAction: { action: \"click\", locator: \"text\", value: \"Close\" } }, { semanticAction: { action: \"fill\", locator: \"label\", value: \"Email\", text: \"user@example.com\" } }, or { semanticAction: { action: \"select\", selector: \"#flavor\", value: \"chocolate\" } }; add semanticAction.session when targeting a named upstream browser session; details.compiledSemanticAction shows the semantic target, while details.effectiveArgs may show a resolved current @ref for active-session role/name click/check/uncheck actions to avoid hidden duplicate matches; selector-not-found failures may append bounded try-*-candidate next actions (and an Agent-browser candidate fallbacks prose block) for specific placeholder/text/label shapes, and stale-ref failures can return retry-semantic-action-after-stale-ref for compiled find actions when retry safety is provable.",
|
|
25
|
-
"Common advanced calls: { args: [\"batch\"], stdin: \"[[\\\"open\\\",\\\"https://example.com\\\"],[\\\"snapshot\\\",\\\"-i\\\"]]\" }, { job: { steps: [{ action: \"open\", url: \"https://example.com\" }, { action: \"assertText\", text: \"Example Domain\" }, { action: \"screenshot\", path: \".dogfood/example.png\" }] } }, { qa: { url: \"https://example.com\", expectedText: \"Example Domain\", screenshotPath: \".dogfood/qa-example.png\" } }, { args: [\"eval\", \"--stdin\"], stdin: \"document.title\" }, { args: [\"auth\", \"save\", \"name\", \"--password-stdin\"], stdin: \"<password from user-approved secret source>\" }, { args: [\"--profile\", \"Default\", \"open\", \"https://example.com/account\"], sessionMode: \"fresh\" }, and { args: [\"open\", \"--enable\", \"react-devtools\", \"https://example.com\"], sessionMode: \"fresh\" }. For app pages with a native dropdown, job steps can include { action: \"select\", selector: \"#flavor\", value: \"chocolate\" } before the dependent assertion.",
|
|
25
|
+
"Common advanced calls: { args: [\"batch\"], stdin: \"[[\\\"open\\\",\\\"https://example.com\\\"],[\\\"snapshot\\\",\\\"-i\\\"]]\" }, { job: { steps: [{ action: \"open\", url: \"https://example.com\" }, { action: \"assertText\", text: \"Example Domain\" }, { action: \"screenshot\", path: \".dogfood/example.png\" }] } }, { qa: { url: \"https://example.com\", expectedText: \"Example Domain\", screenshotPath: \".dogfood/qa-example.png\" } }, { electron: { action: \"list\", query: \"code\" } }, { electron: { action: \"launch\", appName: \"Visual Studio Code\", handoff: \"snapshot\" } }, { electron: { action: \"probe\" } }, { qa: { attached: true, expectedText: \"Explorer\" } }, { args: [\"eval\", \"--stdin\"], stdin: \"document.title\" }, { args: [\"auth\", \"save\", \"name\", \"--password-stdin\"], stdin: \"<password from user-approved secret source>\" }, { args: [\"--profile\", \"Default\", \"open\", \"https://example.com/account\"], sessionMode: \"fresh\" }, and { args: [\"open\", \"--enable\", \"react-devtools\", \"https://example.com\"], sessionMode: \"fresh\" }. For app pages with a native dropdown, job steps can include { action: \"select\", selector: \"#flavor\", value: \"chocolate\" } before the dependent assertion.",
|
|
26
26
|
"High-value command reference: select <selector> <value...> changes native dropdown values; download <selector> <path> saves a file triggered by a click; get title/url/text/html/value/attr/count reads page state; screenshot [path] captures an image; pdf <path> saves a PDF; tab list and tab <tab-id-or-label> inspect or recover the active tab; react tree/inspect/renders/suspense introspect React after --enable react-devtools; vitals [url] measures Core Web Vitals; pushstate <url> performs SPA navigation.",
|
|
27
27
|
"For artifact-producing commands, read the visible artifact block and details.artifactVerification before using files: check requested path, absolute path, existence, size bytes, artifact kind, optional mediaType, status, optional limitation, and verified/missing/pending/unverified counts. details.artifacts contains per-file metadata. Browser close does not delete explicit saved files; if close reports details.artifactCleanup, use host file tools to remove paths listed in explicitArtifactPaths (when non-empty) after inspection. For annotated screenshots inside batch, put --annotate in top-level args (for example { args: [\"--annotate\", \"batch\"], stdin: \"[[\\\"screenshot\\\",\\\"/tmp/page.png\\\"]]\" }) rather than inside the screenshot step.",
|
|
28
28
|
"When details.nextActions is present, prefer those exact native agent_browser follow-up payloads over prose guidance; they may include args, stdin, sessionMode, networkSourceLookup, safety notes, or artifactPath for saved files.",
|
|
@@ -46,6 +46,7 @@ export const SHARED_BROWSER_PLAYBOOK_GUIDELINES = [
|
|
|
46
46
|
"For stateful browser context work, prefer purpose-specific page actions before dumping browser data: use auth save --password-stdin with the tool stdin field for credentials, state save/load for portable test state, cookies get/set/clear and storage local|session only when the task needs those values, and expect cookie/storage/auth/state summaries to redact credential-like fields.",
|
|
47
47
|
"For batch chains that touch cookies, storage, auth, or other secret-bearing commands, use details.batchSteps for per-step artifacts, categories, spill paths, and full structured errors; top-level details.data on batch is only a compact redacted step matrix (success, argv-redacted command, redacted result or scrubbed error text) built from the same presentation rules as standalone calls.",
|
|
48
48
|
"For non-core families, pass current upstream commands through the native tool directly: network route/requests/har, diff snapshot/screenshot/url, trace/profiler/record, console/errors/highlight/inspect/clipboard, stream enable/disable/status, dashboard start/stop, and chat. For compact network requests output, prefer details.nextActions for request detail, actionable failed-request networkSourceLookup, filtering, or HAR capture follow-ups instead of guessing request-id syntax. Artifact-producing commands report details.artifacts and verification state; long-running starts such as stream, dashboard, trace/profiler, and record should be paired with the matching stop/disable command when the task is done.",
|
|
49
|
+
"For Electron desktop apps, prefer top-level electron for wrapper-owned discovery, isolated launch, status, compact probe, and cleanup: list first, treat likely-sensitive annotations as hints rather than enforcement, launch with the default snapshot handoff unless handoff: \"tabs\" is the safer diagnostic starting point, use electron.probe or snapshot -i/qa.attached for current-session state, and always cleanup the returned launchId when done. electron.launch uses an isolated temporary profile; it does not reuse the app's normal signed-in profile or attach to an already-running authenticated app. For signed-in local app state, host-launch the normal app with --remote-debugging-port when appropriate, then use raw args connect <port|url>; leave shutdown/profile cleanup to the host owner.",
|
|
49
50
|
"For provider or specialized app workflows, load version-matched upstream guidance with skills get agentcore|electron|slack|dogfood|vercel-sandbox through the native tool. Provider launches such as -p ios, --provider browserbase/kernel/browseruse/browserless/agentcore, and iOS --device are upstream-owned setup paths; use sessionMode fresh when switching providers and expect external credentials or local Appium/Xcode setup to be required.",
|
|
50
51
|
"For dialogs and frames, use dialog status/accept/dismiss and frame <selector|main> through native args; when --confirm-actions produces a pending confirmation, use details.nextActions or exact confirm <id> / deny <id> calls instead of inventing ids.",
|
|
51
52
|
"If a session lands on the wrong page or tab, an interaction changes origin unexpectedly, or an open call returns blocked, blank, or otherwise unexpected results, use tab list / tab <tab-id-or-label> / snapshot -i to recover state before retrying different URLs or fallback strategies. Only use wait with an explicit argument like milliseconds, --load <state>, --url <matcher>, --fn <js>, or --text <matcher>.",
|
|
@@ -90,12 +91,12 @@ export function buildSharedBrowserPlaybookGuidelines(options: { includeBraveSear
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
const RUNTIME_PROMPT_GUIDELINES = [
|
|
93
|
-
"Use exactly one input mode: args, semanticAction, job, qa, sourceLookup, or
|
|
94
|
+
"Use exactly one input mode: args, semanticAction, job, qa, sourceLookup, networkSourceLookup, or electron. Use stdin only for batch, eval --stdin, auth save --password-stdin, or wrapper-generated batch modes; electron rejects caller stdin.",
|
|
94
95
|
"Common flow: open, snapshot -i, interact with current @refs or semanticAction, then re-snapshot after navigation, scrolling, rerenders, or DOM changes. For ordinary forms, batch same-snapshot fill @refs before the submit/click step; split if a fill may autosubmit, navigate, or rerender later fields. Respect explicit stop boundaries: if the user says to stop before order/post/purchase/submit, do not click that final action.",
|
|
95
96
|
"Prefer stable locators for visible text/names: semanticAction or upstream find with role/text/label/placeholder/alt/title/testid. For native selects, prefer select <selector> <value...> or semanticAction/job select over clicking option refs. Use current @refs only from the latest same-page snapshot.",
|
|
96
|
-
"For
|
|
97
|
-
"For requested screenshots, recordings, downloads, PDFs, or HARs, save the exact user path and read details.artifactVerification before claiming success; report unavailable/missing artifacts instead of silently substituting paths. record stop needs ffmpeg on PATH. close does not delete saved files; cleanup is host-owned.",
|
|
98
|
-
"When details.nextActions is present, prefer those exact follow-up payloads over prose or guessed selectors; network request diagnostics may include request-detail, actionable failed-request networkSourceLookup, filter, or HAR-capture follow-ups.",
|
|
97
|
+
"For signed-in/account-specific content on the web, start with --profile Default plus sessionMode=fresh unless asked otherwise; visible content is model-visible. For desktop/local Electron apps, use electron.list first, not web open; electron.launch is isolated. For signed-in desktop content, if not running, host-launch with --remote-debugging-port then agent_browser connect; if running without a port, ask before relaunch. Use sessionMode=fresh for other launch-scoped state; otherwise keep implicit continuity.",
|
|
98
|
+
"For requested screenshots, recordings, downloads, PDFs, or HARs, save the exact user path and read details.artifactVerification before claiming success; report unavailable/missing artifacts instead of silently substituting paths. record stop needs ffmpeg on PATH. close does not delete saved files; cleanup is host-owned. Electron cleanup only covers wrapper-launched Electron processes/profiles for the returned launchId, not explicit artifacts or externally launched apps.",
|
|
99
|
+
"When details.nextActions is present, prefer those exact follow-up payloads over prose or guessed selectors; network request diagnostics may include request-detail, actionable failed-request networkSourceLookup, filter, or HAR-capture follow-ups, and Electron lifecycle results may include status/cleanup/tab/snapshot actions keyed to the wrapper launchId/session.",
|
|
99
100
|
"For dense snapshots, check Omitted high-value controls and details.data.highValueControlRefIds before opening large spill files.",
|
|
100
101
|
"For dashboards, verify scroll with screenshot/snapshot; if nothing moved, use scrollintoview <@ref> or target the real scroll region. For native selects use select/semanticAction/job select instead of option refs; custom combobox clicks may only focus, so re-snapshot and fall back to type, Enter/arrows, or visible option refs.",
|
|
101
102
|
"For extraction, prefer get title/url/text/html/value/attr/count or eval --stdin with a plain expression in the tool stdin field; do not rely on console.log. When reading several known refs/selectors, use batch with JSON-array stdin (for example [[\"get\",\"text\",\"@e1\"]]) or eval --stdin instead of many serial get calls. If selector visibility warnings appear, prefer visible @refs or nextActions.",
|
|
@@ -1704,6 +1704,33 @@ function buildUnknownCommandSuggestionActions(suggestions: CommandSuggestion[],
|
|
|
1704
1704
|
return actions.length > 0 ? actions : undefined;
|
|
1705
1705
|
}
|
|
1706
1706
|
|
|
1707
|
+
function isWaitTextAssertionCommand(command: string[] | undefined): boolean {
|
|
1708
|
+
return command?.[0] === "wait" && command.includes("--text");
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function buildWaitTextAssertionFailureNextAction(sessionName: string | undefined): AgentBrowserNextAction {
|
|
1712
|
+
return {
|
|
1713
|
+
id: "inspect-after-text-assertion-failure",
|
|
1714
|
+
params: { args: withSessionPrefix(sessionName, ["snapshot", "-i"]) },
|
|
1715
|
+
reason: "Inspect the current page after the text assertion failed before concluding the expected text is absent.",
|
|
1716
|
+
safety: "Read-only snapshot; use current refs or visible text from this page before retrying the assertion.",
|
|
1717
|
+
tool: "agent_browser",
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function mergePresentationNextActions(...groups: Array<AgentBrowserNextAction[] | undefined>): AgentBrowserNextAction[] | undefined {
|
|
1722
|
+
const actions: AgentBrowserNextAction[] = [];
|
|
1723
|
+
const seen = new Set<string>();
|
|
1724
|
+
for (const group of groups) {
|
|
1725
|
+
for (const action of group ?? []) {
|
|
1726
|
+
if (seen.has(action.id)) continue;
|
|
1727
|
+
actions.push(action);
|
|
1728
|
+
seen.add(action.id);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
return actions.length > 0 ? actions : undefined;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1707
1734
|
function appendSelectorRecoveryHint(errorText: string): string {
|
|
1708
1735
|
const hint = getSelectorRecoveryHint(errorText);
|
|
1709
1736
|
if (!hint || errorText.includes("Agent-browser hint:")) {
|
|
@@ -1785,13 +1812,16 @@ async function buildBatchStepPresentation(options: {
|
|
|
1785
1812
|
errorText,
|
|
1786
1813
|
});
|
|
1787
1814
|
const confirmationRequired = detectConfirmationRequired(item.error);
|
|
1788
|
-
const nextActions =
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1815
|
+
const nextActions = mergePresentationNextActions(
|
|
1816
|
+
buildAgentBrowserNextActions({
|
|
1817
|
+
args: command,
|
|
1818
|
+
command: command?.[0],
|
|
1819
|
+
confirmationId: confirmationRequired?.id,
|
|
1820
|
+
failureCategory,
|
|
1821
|
+
resultCategory: "failure",
|
|
1822
|
+
}),
|
|
1823
|
+
isWaitTextAssertionCommand(command) ? [buildWaitTextAssertionFailureNextAction(sessionName)] : undefined,
|
|
1824
|
+
);
|
|
1795
1825
|
const presentation: ToolPresentation = {
|
|
1796
1826
|
content: [{ type: "text", text: errorText }],
|
|
1797
1827
|
failureCategory,
|