pi-agent-browser-native 0.3.0 → 0.5.0
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 +127 -0
- package/README.md +63 -20
- package/dist/extensions/agent-browser/index.js +787 -105
- package/dist/extensions/agent-browser/lib/argv-descriptor.js +35 -3
- package/dist/extensions/agent-browser/lib/argv-grammar.js +44 -2
- package/dist/extensions/agent-browser/lib/batch-lifecycle.js +71 -0
- package/dist/extensions/agent-browser/lib/command-policy.js +1 -1
- package/dist/extensions/agent-browser/lib/command-taxonomy.js +35 -2
- package/dist/extensions/agent-browser/lib/input-modes/job.js +61 -4
- package/dist/extensions/agent-browser/lib/input-modes/lookups.js +2 -2
- package/dist/extensions/agent-browser/lib/input-modes/params.js +22 -23
- package/dist/extensions/agent-browser/lib/input-modes/script.js +462 -0
- package/dist/extensions/agent-browser/lib/input-modes/semantic-action.js +51 -12
- package/dist/extensions/agent-browser/lib/launch-scoped-flags.js +8 -0
- package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +3 -1
- package/dist/extensions/agent-browser/lib/managed-session-restore.js +26 -36
- package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +2 -4
- package/dist/extensions/agent-browser/lib/managed-session-state-policy.js +47 -29
- package/dist/extensions/agent-browser/lib/managed-session-storage.js +50 -24
- package/dist/extensions/agent-browser/lib/orchestration/batch-stdin.js +26 -5
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +110 -30
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.js +2 -1
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +26 -25
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +17 -3
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +2 -1
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +6 -4
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +129 -32
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +191 -55
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +57 -29
- package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +15 -11
- package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +36 -18
- package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +299 -0
- package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +32 -10
- package/dist/extensions/agent-browser/lib/playbook.js +18 -15
- package/dist/extensions/agent-browser/lib/process-environment.js +14 -0
- package/dist/extensions/agent-browser/lib/process-identity.js +4 -4
- package/dist/extensions/agent-browser/lib/process.js +131 -41
- package/dist/extensions/agent-browser/lib/recording-reservations.js +183 -0
- package/dist/extensions/agent-browser/lib/results/action-recommendations.js +62 -5
- package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +62 -4
- package/dist/extensions/agent-browser/lib/results/categories.js +6 -1
- package/dist/extensions/agent-browser/lib/results/next-actions.js +19 -5
- package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +85 -38
- package/dist/extensions/agent-browser/lib/results/presentation/batch.js +66 -11
- package/dist/extensions/agent-browser/lib/results/presentation/common.js +18 -0
- package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +7 -1
- package/dist/extensions/agent-browser/lib/results/presentation/errors.js +2 -1
- package/dist/extensions/agent-browser/lib/results/presentation/navigation.js +26 -11
- package/dist/extensions/agent-browser/lib/results/presentation/registry.js +58 -13
- package/dist/extensions/agent-browser/lib/results/presentation/semantic-action.js +1 -10
- package/dist/extensions/agent-browser/lib/results/presentation.js +6 -3
- package/dist/extensions/agent-browser/lib/results/recovery-actions.js +2 -0
- package/dist/extensions/agent-browser/lib/results/selector-recovery.js +51 -8
- package/dist/extensions/agent-browser/lib/results/snapshot-high-value-controls.js +13 -7
- package/dist/extensions/agent-browser/lib/runtime.js +116 -39
- package/dist/extensions/agent-browser/lib/session-page-state.js +62 -10
- package/dist/extensions/agent-browser/lib/upstream-version.js +14 -0
- package/dist/extensions/agent-browser/script-worker.js +169 -0
- package/dist/scripts/agent-browser-target.mjs +3 -0
- package/docs/ARCHITECTURE.md +40 -21
- package/docs/COMMAND_REFERENCE.md +90 -35
- package/docs/RELEASE.md +3 -3
- package/docs/REQUIREMENTS.md +4 -2
- package/docs/SUPPORT_MATRIX.md +26 -19
- package/docs/TOOL_CONTRACT.md +93 -52
- package/package.json +3 -1
- package/platform-smoke.config.mjs +2 -2
- package/scripts/agent-browser-capability-baseline.mjs +24 -6
- package/scripts/agent-browser-target.mjs +3 -0
- package/scripts/build.mjs +41 -0
- package/scripts/doctor.mjs +7 -6
- package/scripts/platform-smoke/browser-dogfood-windows.ps1 +9 -3
- package/scripts/platform-smoke/targets.mjs +12 -6
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { parseArgvDescriptor } from "../argv-descriptor.js";
|
|
7
|
+
import { getFlagName } from "../argv-grammar.js";
|
|
8
|
+
import { needsManagedSession } from "../command-policy.js";
|
|
9
|
+
import { isCloseCommand } from "../command-taxonomy.js";
|
|
10
|
+
import { LAUNCH_SCOPED_FLAGS, MANAGED_RESTORE_INCOMPATIBLE_FLAGS } from "../launch-scoped-flags.js";
|
|
11
|
+
import { isRecord } from "../parsing.js";
|
|
12
|
+
import { validateToolArgs } from "../runtime.js";
|
|
13
|
+
export const AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES = 64 * 1_024;
|
|
14
|
+
export const AGENT_BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
|
+
export const AGENT_BROWSER_SCRIPT_NAMESPACE = "";
|
|
16
|
+
export const AGENT_BROWSER_SCRIPT_MAX_TIMEOUT_MS = 300_000;
|
|
17
|
+
export const AGENT_BROWSER_SCRIPT_MAX_CALLS = 25;
|
|
18
|
+
export const AGENT_BROWSER_SCRIPT_FINAL_OUTPUT_MAX_BYTES = 64 * 1_024;
|
|
19
|
+
export const AGENT_BROWSER_SCRIPT_IPC_MESSAGE_MAX_BYTES = 1 * 1_024 * 1_024;
|
|
20
|
+
export const AGENT_BROWSER_SCRIPT_IPC_CUMULATIVE_MAX_BYTES = 8 * 1_024 * 1_024;
|
|
21
|
+
export const AGENT_BROWSER_SCRIPT_SPILL_MAX_BYTES = 512 * 1_024;
|
|
22
|
+
function findPackageRoot(startDir) {
|
|
23
|
+
let currentDir = startDir;
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (existsSync(join(currentDir, "package.json")))
|
|
26
|
+
return currentDir;
|
|
27
|
+
const parentDir = dirname(currentDir);
|
|
28
|
+
if (parentDir === currentDir)
|
|
29
|
+
throw new Error("Unable to resolve the pi-agent-browser-native package root.");
|
|
30
|
+
currentDir = parentDir;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function resolveScriptWorkerPath() {
|
|
34
|
+
const workerPath = join(findPackageRoot(dirname(fileURLToPath(import.meta.url))), "dist", "extensions", "agent-browser", "script-worker.js");
|
|
35
|
+
if (!existsSync(workerPath))
|
|
36
|
+
throw new Error("Compiled script worker is missing; run npm run build or reinstall pi-agent-browser-native.");
|
|
37
|
+
return workerPath;
|
|
38
|
+
}
|
|
39
|
+
const SCRIPT_SESSION_NAME_PATTERN = /^piab-script-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
40
|
+
const SCRIPT_ALLOWED_LAUNCH_FLAG = "--allowed-domains";
|
|
41
|
+
const SCRIPT_FORBIDDEN_COMMANDS = new Set(["attach", "auth", "batch", "connect", "script", "session", "state"]);
|
|
42
|
+
const SCRIPT_FORBIDDEN_FLAGS = new Set([
|
|
43
|
+
...LAUNCH_SCOPED_FLAGS.filter((flag) => flag !== SCRIPT_ALLOWED_LAUNCH_FLAG),
|
|
44
|
+
...MANAGED_RESTORE_INCOMPATIBLE_FLAGS.filter((flag) => flag !== SCRIPT_ALLOWED_LAUNCH_FLAG),
|
|
45
|
+
"--namespace",
|
|
46
|
+
"--session",
|
|
47
|
+
]);
|
|
48
|
+
export function compileAgentBrowserScript(input) {
|
|
49
|
+
if (typeof input !== "string")
|
|
50
|
+
return { error: "script must be a string." };
|
|
51
|
+
const bytes = Buffer.byteLength(input, "utf8");
|
|
52
|
+
return bytes > AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES
|
|
53
|
+
? { error: `script must be ${AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES} bytes or less.` }
|
|
54
|
+
: { compiled: { code: input } };
|
|
55
|
+
}
|
|
56
|
+
export function createAgentBrowserScriptSessionName() {
|
|
57
|
+
return `piab-script-${randomUUID()}`;
|
|
58
|
+
}
|
|
59
|
+
export function createAgentBrowserScriptCloseArgs(sessionName) {
|
|
60
|
+
return ["--namespace", AGENT_BROWSER_SCRIPT_NAMESPACE, "--session", sessionName, "close"];
|
|
61
|
+
}
|
|
62
|
+
export function isAgentBrowserScriptSessionName(value) {
|
|
63
|
+
return typeof value === "string" && SCRIPT_SESSION_NAME_PATTERN.test(value);
|
|
64
|
+
}
|
|
65
|
+
function getScriptCallPolicyError(args) {
|
|
66
|
+
const descriptor = parseArgvDescriptor(args);
|
|
67
|
+
const command = descriptor.commandInfo.command;
|
|
68
|
+
if (!command)
|
|
69
|
+
return "script browser call args must contain an agent-browser command.";
|
|
70
|
+
if (isCloseCommand(command))
|
|
71
|
+
return "script browser calls cannot close, quit, or exit their isolated session.";
|
|
72
|
+
if (SCRIPT_FORBIDDEN_COMMANDS.has(command))
|
|
73
|
+
return `script browser calls cannot use ${command}.`;
|
|
74
|
+
if (!needsManagedSession(descriptor))
|
|
75
|
+
return `script browser calls cannot use sessionless/local command ${command}.`;
|
|
76
|
+
for (const token of args) {
|
|
77
|
+
const flag = getFlagName(token);
|
|
78
|
+
if (SCRIPT_FORBIDDEN_FLAGS.has(flag)) {
|
|
79
|
+
return `script browser calls cannot use ${flag}; the parent owns the isolated session identity and launch policy.`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
export function validateAgentBrowserScriptBrowserParams(input) {
|
|
85
|
+
if (!isRecord(input))
|
|
86
|
+
return { error: "script browser(params) requires an object." };
|
|
87
|
+
const unsupportedField = Object.keys(input).find((field) => !["args", "stdin", "timeoutMs"].includes(field));
|
|
88
|
+
if (unsupportedField)
|
|
89
|
+
return { error: `script browser(params) does not support ${unsupportedField}; use only args, stdin, and timeoutMs.` };
|
|
90
|
+
if (!Array.isArray(input.args) || input.args.length === 0 || input.args.some((arg) => typeof arg !== "string")) {
|
|
91
|
+
return { error: "script browser(params).args must be a non-empty string array." };
|
|
92
|
+
}
|
|
93
|
+
if (input.stdin !== undefined && typeof input.stdin !== "string") {
|
|
94
|
+
return { error: "script browser(params).stdin must be a string when provided." };
|
|
95
|
+
}
|
|
96
|
+
if (input.timeoutMs !== undefined && (typeof input.timeoutMs !== "number" || !Number.isSafeInteger(input.timeoutMs) || input.timeoutMs <= 0)) {
|
|
97
|
+
return { error: "script browser(params).timeoutMs must be a positive integer when provided." };
|
|
98
|
+
}
|
|
99
|
+
const params = {
|
|
100
|
+
args: input.args,
|
|
101
|
+
stdin: input.stdin,
|
|
102
|
+
timeoutMs: input.timeoutMs,
|
|
103
|
+
};
|
|
104
|
+
const policyError = getScriptCallPolicyError(params.args);
|
|
105
|
+
if (policyError)
|
|
106
|
+
return { error: policyError, policyBlocked: true };
|
|
107
|
+
const validationError = validateToolArgs(params.args);
|
|
108
|
+
return validationError ? { error: validationError } : { params };
|
|
109
|
+
}
|
|
110
|
+
function buildRejectedCallEnvelope(error, policyBlocked) {
|
|
111
|
+
return {
|
|
112
|
+
data: null,
|
|
113
|
+
details: { failureCategory: policyBlocked ? "policy-blocked" : "validation-error", resultCategory: "failure" },
|
|
114
|
+
error,
|
|
115
|
+
failureCategory: policyBlocked ? "policy-blocked" : "validation-error",
|
|
116
|
+
ok: false,
|
|
117
|
+
resultCategory: "failure",
|
|
118
|
+
summary: error,
|
|
119
|
+
text: error,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function normalizeBrowserEnvelope(value) {
|
|
123
|
+
if (!isRecord(value)
|
|
124
|
+
|| typeof value.ok !== "boolean"
|
|
125
|
+
|| typeof value.text !== "string"
|
|
126
|
+
|| typeof value.summary !== "string"
|
|
127
|
+
|| (value.resultCategory !== "success" && value.resultCategory !== "failure")) {
|
|
128
|
+
return buildRejectedCallEnvelope("The ordinary agent_browser executor returned an invalid script envelope.", false);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
data: value.data ?? null,
|
|
132
|
+
details: isRecord(value.details) ? value.details : undefined,
|
|
133
|
+
error: typeof value.error === "string" ? value.error : undefined,
|
|
134
|
+
failureCategory: value.failureCategory,
|
|
135
|
+
nextActions: Array.isArray(value.nextActions) ? value.nextActions : undefined,
|
|
136
|
+
ok: value.ok,
|
|
137
|
+
resultCategory: value.resultCategory,
|
|
138
|
+
successCategory: value.successCategory,
|
|
139
|
+
summary: value.summary,
|
|
140
|
+
text: value.text,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function buildStepSummary(index, envelope) {
|
|
144
|
+
return {
|
|
145
|
+
failureCategory: envelope.failureCategory,
|
|
146
|
+
index,
|
|
147
|
+
ok: envelope.ok,
|
|
148
|
+
resultCategory: envelope.resultCategory,
|
|
149
|
+
successCategory: envelope.successCategory,
|
|
150
|
+
summary: envelope.summary,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function buildFailedRun(options) {
|
|
154
|
+
return { ...options, ok: false };
|
|
155
|
+
}
|
|
156
|
+
function describeScriptError(error) {
|
|
157
|
+
const name = typeof error?.name === "string" && error.name.length > 0 ? error.name.slice(0, 80) : "Error";
|
|
158
|
+
const message = typeof error?.message === "string" && error.message.length > 0
|
|
159
|
+
? error.message.replace(/[\r\n]+/g, " ").slice(0, 400)
|
|
160
|
+
: "Script execution failed.";
|
|
161
|
+
return `${name}: ${message}`;
|
|
162
|
+
}
|
|
163
|
+
function isScriptChildMessage(value) {
|
|
164
|
+
if (!isRecord(value) || typeof value.type !== "string")
|
|
165
|
+
return false;
|
|
166
|
+
if (value.type === "ready")
|
|
167
|
+
return true;
|
|
168
|
+
if (value.type === "call")
|
|
169
|
+
return typeof value.id === "number" && Number.isSafeInteger(value.id) && value.id > 0;
|
|
170
|
+
if (value.type === "emit")
|
|
171
|
+
return true;
|
|
172
|
+
return value.type === "complete";
|
|
173
|
+
}
|
|
174
|
+
function waitForChildExit(child) {
|
|
175
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
176
|
+
return Promise.resolve();
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
child.once("exit", () => resolve());
|
|
179
|
+
child.once("error", () => resolve());
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function terminateChild(child) {
|
|
183
|
+
child.stdin.destroy();
|
|
184
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
185
|
+
child.kill("SIGTERM");
|
|
186
|
+
return setTimeout(() => {
|
|
187
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
188
|
+
child.kill("SIGKILL");
|
|
189
|
+
}, 250);
|
|
190
|
+
}
|
|
191
|
+
async function settleWithin(promise, timeoutMs) {
|
|
192
|
+
let timer;
|
|
193
|
+
await Promise.race([
|
|
194
|
+
promise.catch(() => undefined),
|
|
195
|
+
new Promise((resolve) => {
|
|
196
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
197
|
+
}),
|
|
198
|
+
]);
|
|
199
|
+
if (timer)
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
}
|
|
202
|
+
function serializeFinalOutput(value) {
|
|
203
|
+
if (value === undefined)
|
|
204
|
+
return undefined;
|
|
205
|
+
return JSON.stringify(value);
|
|
206
|
+
}
|
|
207
|
+
export async function runAgentBrowserScript(options) {
|
|
208
|
+
const compiled = compileAgentBrowserScript(options.code);
|
|
209
|
+
if (compiled.error) {
|
|
210
|
+
return buildFailedRun({ callCount: 0, emitCount: 0, error: compiled.error, failureCategory: "validation-error", rejectedCallCount: 0, steps: [] });
|
|
211
|
+
}
|
|
212
|
+
const timeoutMs = options.timeoutMs ?? AGENT_BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS;
|
|
213
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > AGENT_BROWSER_SCRIPT_MAX_TIMEOUT_MS) {
|
|
214
|
+
return buildFailedRun({ callCount: 0, emitCount: 0, error: `script timeoutMs must be between 1 and ${AGENT_BROWSER_SCRIPT_MAX_TIMEOUT_MS}.`, failureCategory: "validation-error", rejectedCallCount: 0, steps: [] });
|
|
215
|
+
}
|
|
216
|
+
if (options.signal?.aborted) {
|
|
217
|
+
return buildFailedRun({ aborted: true, callCount: 0, emitCount: 0, error: "Script execution was aborted.", failureCategory: "aborted", rejectedCallCount: 0, steps: [] });
|
|
218
|
+
}
|
|
219
|
+
let workerPath;
|
|
220
|
+
try {
|
|
221
|
+
workerPath = resolveScriptWorkerPath();
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
const message = error instanceof Error ? error.message : "Compiled script worker is missing.";
|
|
225
|
+
return buildFailedRun({ callCount: 0, emitCount: 0, error: message, failureCategory: "missing-binary", rejectedCallCount: 0, steps: [] });
|
|
226
|
+
}
|
|
227
|
+
const child = spawn(process.execPath, [
|
|
228
|
+
"--permission",
|
|
229
|
+
"--max-old-space-size=64",
|
|
230
|
+
workerPath,
|
|
231
|
+
String(AGENT_BROWSER_SCRIPT_IPC_MESSAGE_MAX_BYTES),
|
|
232
|
+
String(AGENT_BROWSER_SCRIPT_IPC_CUMULATIVE_MAX_BYTES),
|
|
233
|
+
], {
|
|
234
|
+
env: {},
|
|
235
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
236
|
+
});
|
|
237
|
+
child.stdin.on("error", () => undefined);
|
|
238
|
+
let stdoutBuffer = Buffer.alloc(0);
|
|
239
|
+
let stderrBytes = 0;
|
|
240
|
+
let cumulativeBytes = 0;
|
|
241
|
+
let callCount = 0;
|
|
242
|
+
let rejectedCallCount = 0;
|
|
243
|
+
let leaseStarted = false;
|
|
244
|
+
let ready = false;
|
|
245
|
+
let stopping = false;
|
|
246
|
+
let activeCallController;
|
|
247
|
+
const emissions = [];
|
|
248
|
+
const steps = [];
|
|
249
|
+
const messages = [];
|
|
250
|
+
let draining = false;
|
|
251
|
+
let drainPromise = Promise.resolve();
|
|
252
|
+
let resolveResult;
|
|
253
|
+
const resultPromise = new Promise((resolve) => {
|
|
254
|
+
resolveResult = resolve;
|
|
255
|
+
});
|
|
256
|
+
const childExit = waitForChildExit(child);
|
|
257
|
+
let timeout;
|
|
258
|
+
let killTimer;
|
|
259
|
+
const sendParentMessage = async (message) => {
|
|
260
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
261
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
262
|
+
if (bytes > AGENT_BROWSER_SCRIPT_IPC_MESSAGE_MAX_BYTES || cumulativeBytes + bytes > AGENT_BROWSER_SCRIPT_IPC_CUMULATIVE_MAX_BYTES) {
|
|
263
|
+
throw new Error("Script IPC limit exceeded.");
|
|
264
|
+
}
|
|
265
|
+
cumulativeBytes += bytes;
|
|
266
|
+
await new Promise((resolve, reject) => {
|
|
267
|
+
child.stdin.write(line, (error) => error ? reject(error) : resolve());
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
const finish = async (result, waitForDrain) => {
|
|
271
|
+
if (stopping)
|
|
272
|
+
return;
|
|
273
|
+
stopping = true;
|
|
274
|
+
if (timeout)
|
|
275
|
+
clearTimeout(timeout);
|
|
276
|
+
options.signal?.removeEventListener("abort", abortListener);
|
|
277
|
+
activeCallController?.abort();
|
|
278
|
+
killTimer = terminateChild(child);
|
|
279
|
+
if (waitForDrain)
|
|
280
|
+
await settleWithin(drainPromise, 5_000);
|
|
281
|
+
await settleWithin(childExit, 1_000);
|
|
282
|
+
clearTimeout(killTimer);
|
|
283
|
+
resolveResult(result);
|
|
284
|
+
};
|
|
285
|
+
const fail = (error, failureCategory, flags = {}, waitForDrain = false) => finish(buildFailedRun({
|
|
286
|
+
...flags,
|
|
287
|
+
callCount,
|
|
288
|
+
emitCount: emissions.length,
|
|
289
|
+
error,
|
|
290
|
+
failureCategory,
|
|
291
|
+
rejectedCallCount,
|
|
292
|
+
steps,
|
|
293
|
+
}), waitForDrain);
|
|
294
|
+
const abortListener = () => {
|
|
295
|
+
void fail("Script execution was aborted.", "aborted", { aborted: true }, true);
|
|
296
|
+
};
|
|
297
|
+
options.signal?.addEventListener("abort", abortListener, { once: true });
|
|
298
|
+
timeout = setTimeout(() => {
|
|
299
|
+
void fail(`Script execution timed out after ${timeoutMs}ms.`, "timeout", { timedOut: true }, true);
|
|
300
|
+
}, timeoutMs);
|
|
301
|
+
const drainMessages = async () => {
|
|
302
|
+
if (draining)
|
|
303
|
+
return;
|
|
304
|
+
draining = true;
|
|
305
|
+
try {
|
|
306
|
+
while (!stopping && messages.length > 0) {
|
|
307
|
+
const message = messages.shift();
|
|
308
|
+
if (message.type === "ready") {
|
|
309
|
+
if (ready) {
|
|
310
|
+
await fail("Sandbox sent a duplicate ready message.", "upstream-error");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
ready = true;
|
|
314
|
+
try {
|
|
315
|
+
await sendParentMessage({ code: options.code, type: "start" });
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
await fail("Unable to start the script sandbox.", "upstream-error");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (!ready) {
|
|
324
|
+
await fail("Sandbox sent a message before it was ready.", "upstream-error");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (message.type === "emit") {
|
|
328
|
+
if (!Object.hasOwn(message, "value")) {
|
|
329
|
+
await fail("emit(value) requires a JSON-serializable value; undefined and functions are not supported.", "validation-error");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
emissions.push(message.value);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (message.type === "complete") {
|
|
336
|
+
if (message.error) {
|
|
337
|
+
await fail(describeScriptError(message.error), "script-error");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const data = emissions.length === 0
|
|
341
|
+
? message.hasValue ? message.value : undefined
|
|
342
|
+
: emissions.length === 1 ? emissions[0] : emissions;
|
|
343
|
+
let serialized;
|
|
344
|
+
try {
|
|
345
|
+
serialized = serializeFinalOutput(data);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
await fail("Final script output must be JSON-serializable.", "validation-error");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (serialized !== undefined && Buffer.byteLength(serialized, "utf8") > AGENT_BROWSER_SCRIPT_FINAL_OUTPUT_MAX_BYTES) {
|
|
352
|
+
await fail(`Final script output exceeds ${AGENT_BROWSER_SCRIPT_FINAL_OUTPUT_MAX_BYTES} bytes.`, "validation-error");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
await finish({ callCount, data, emitCount: emissions.length, ok: true, rejectedCallCount, steps }, false);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
callCount += 1;
|
|
359
|
+
if (callCount > AGENT_BROWSER_SCRIPT_MAX_CALLS) {
|
|
360
|
+
await fail(`Script browser call limit exceeded (${AGENT_BROWSER_SCRIPT_MAX_CALLS}).`, "validation-error");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const validated = validateAgentBrowserScriptBrowserParams(message.params);
|
|
364
|
+
let envelope;
|
|
365
|
+
if (!validated.params) {
|
|
366
|
+
rejectedCallCount += 1;
|
|
367
|
+
envelope = buildRejectedCallEnvelope(validated.error ?? "Invalid script browser call.", validated.policyBlocked === true);
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
if (!leaseStarted) {
|
|
371
|
+
try {
|
|
372
|
+
options.beforeFirstCall?.();
|
|
373
|
+
leaseStarted = true;
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
await fail("Unable to persist the isolated script session lease.", "upstream-error");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
activeCallController = new AbortController();
|
|
381
|
+
try {
|
|
382
|
+
envelope = normalizeBrowserEnvelope(await options.dispatch(validated.params, activeCallController.signal));
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
envelope = buildRejectedCallEnvelope("The ordinary agent_browser executor failed while dispatching this call.", false);
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
activeCallController = undefined;
|
|
389
|
+
}
|
|
390
|
+
if (stopping)
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
steps.push(buildStepSummary(callCount - 1, envelope));
|
|
394
|
+
try {
|
|
395
|
+
await sendParentMessage({ envelope, id: message.id, type: "response" });
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
await fail("Unable to return a browser result to the script sandbox.", "upstream-error");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
draining = false;
|
|
405
|
+
if (!stopping && messages.length > 0)
|
|
406
|
+
scheduleDrain();
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
function scheduleDrain() {
|
|
410
|
+
if (draining || stopping)
|
|
411
|
+
return;
|
|
412
|
+
drainPromise = drainMessages();
|
|
413
|
+
}
|
|
414
|
+
child.stdout.on("data", (chunk) => {
|
|
415
|
+
if (stopping)
|
|
416
|
+
return;
|
|
417
|
+
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]);
|
|
418
|
+
if (stdoutBuffer.length > AGENT_BROWSER_SCRIPT_IPC_MESSAGE_MAX_BYTES) {
|
|
419
|
+
void fail("Script IPC message limit exceeded.", "validation-error", {}, true);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
for (;;) {
|
|
423
|
+
const newline = stdoutBuffer.indexOf(10);
|
|
424
|
+
if (newline < 0)
|
|
425
|
+
break;
|
|
426
|
+
const lineBuffer = stdoutBuffer.subarray(0, newline);
|
|
427
|
+
stdoutBuffer = stdoutBuffer.subarray(newline + 1);
|
|
428
|
+
const bytes = lineBuffer.length + 1;
|
|
429
|
+
if (bytes > AGENT_BROWSER_SCRIPT_IPC_MESSAGE_MAX_BYTES || cumulativeBytes + bytes > AGENT_BROWSER_SCRIPT_IPC_CUMULATIVE_MAX_BYTES) {
|
|
430
|
+
void fail("Script IPC limit exceeded.", "validation-error", {}, true);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
cumulativeBytes += bytes;
|
|
434
|
+
try {
|
|
435
|
+
const parsed = JSON.parse(lineBuffer.toString("utf8"));
|
|
436
|
+
if (!isScriptChildMessage(parsed))
|
|
437
|
+
throw new Error("invalid message");
|
|
438
|
+
messages.push(parsed);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
void fail("Sandbox returned an invalid IPC message.", "upstream-error", {}, true);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
scheduleDrain();
|
|
446
|
+
});
|
|
447
|
+
child.stderr.on("data", (chunk) => {
|
|
448
|
+
stderrBytes += chunk.length;
|
|
449
|
+
if (stderrBytes > AGENT_BROWSER_SCRIPT_IPC_MESSAGE_MAX_BYTES && !stopping) {
|
|
450
|
+
void fail("Sandbox stderr limit exceeded.", "upstream-error", {}, true);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
child.once("error", () => {
|
|
454
|
+
if (!stopping)
|
|
455
|
+
void fail("Unable to start the script sandbox.", "upstream-error", {}, true);
|
|
456
|
+
});
|
|
457
|
+
child.once("exit", () => {
|
|
458
|
+
if (!stopping)
|
|
459
|
+
void fail("Script sandbox exited before completion.", "upstream-error", {}, true);
|
|
460
|
+
});
|
|
461
|
+
return await resultPromise;
|
|
462
|
+
}
|
|
@@ -9,7 +9,7 @@ export function getCompiledSemanticActionSessionPrefix(compiled) {
|
|
|
9
9
|
return commandIndex > 0 ? compiled.args.slice(0, commandIndex) : [];
|
|
10
10
|
}
|
|
11
11
|
export function isCompiledSemanticActionFindCommand(compiled) {
|
|
12
|
-
if (!compiled)
|
|
12
|
+
if (!compiled || compiled.action === "select")
|
|
13
13
|
return false;
|
|
14
14
|
return compiled.args[getCompiledSemanticActionCommandIndex(compiled)] === "find";
|
|
15
15
|
}
|
|
@@ -24,7 +24,7 @@ export function compileAgentBrowserSemanticAction(input) {
|
|
|
24
24
|
const selector = input.selector;
|
|
25
25
|
const text = input.text;
|
|
26
26
|
const role = input.role;
|
|
27
|
-
const name = input.name;
|
|
27
|
+
const name = typeof input.name === "string" && input.name.trim().length === 0 ? undefined : input.name;
|
|
28
28
|
const session = input.session;
|
|
29
29
|
if (typeof action !== "string" || !AGENT_BROWSER_SEMANTIC_ACTIONS.includes(action)) {
|
|
30
30
|
return { error: `semanticAction.action must be one of: ${AGENT_BROWSER_SEMANTIC_ACTIONS.join(", ")}.` };
|
|
@@ -33,20 +33,59 @@ export function compileAgentBrowserSemanticAction(input) {
|
|
|
33
33
|
return { error: "semanticAction.session must be a non-empty string when provided." };
|
|
34
34
|
}
|
|
35
35
|
if (action === "select") {
|
|
36
|
-
if (locator !== undefined || role !== undefined || name !== undefined) {
|
|
37
|
-
return { error: "semanticAction.locator, role, and name are not supported for select; use selector plus value or values." };
|
|
38
|
-
}
|
|
39
36
|
if (text !== undefined) {
|
|
40
37
|
return { error: "semanticAction.text is not supported for select; use value or values for option values." };
|
|
41
38
|
}
|
|
42
|
-
if (typeof selector
|
|
43
|
-
|
|
39
|
+
if (typeof selector === "string" && selector.trim().length > 0) {
|
|
40
|
+
if (locator !== undefined || role !== undefined || name !== undefined) {
|
|
41
|
+
return { error: "semanticAction.selector cannot be combined with locator, role, or name for select; use selector plus value/values, or locator fields plus values." };
|
|
42
|
+
}
|
|
43
|
+
const selectedValues = getSelectValues(input, "semanticAction");
|
|
44
|
+
if (selectedValues.error)
|
|
45
|
+
return { error: selectedValues.error };
|
|
46
|
+
const args = typeof session === "string" ? ["--session", session, "select", selector, ...selectedValues.values] : ["select", selector, ...selectedValues.values];
|
|
47
|
+
return { compiled: { action: "select", selector, values: selectedValues.values, args } };
|
|
48
|
+
}
|
|
49
|
+
if (selector !== undefined) {
|
|
50
|
+
return { error: "semanticAction.selector must be a non-empty string when provided." };
|
|
51
|
+
}
|
|
52
|
+
if (locator === undefined) {
|
|
53
|
+
return { error: "semanticAction.selector or semanticAction.locator is required for select." };
|
|
54
|
+
}
|
|
55
|
+
if (locator !== "role" && locator !== "label") {
|
|
56
|
+
return { error: "semanticAction select locator must be role or label; use selector plus value/values for other targets." };
|
|
57
|
+
}
|
|
58
|
+
if (locator === "role") {
|
|
59
|
+
if (typeof role !== "string" || !/^(?:combobox|listbox)$/i.test(role)) {
|
|
60
|
+
return { error: "semanticAction.role must be combobox or listbox for locator=role select." };
|
|
61
|
+
}
|
|
62
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
63
|
+
return { error: "semanticAction.name is required for locator=role select." };
|
|
64
|
+
}
|
|
65
|
+
const optionValues = getSelectValues({ value, values }, "semanticAction");
|
|
66
|
+
if (optionValues.error)
|
|
67
|
+
return { error: optionValues.error };
|
|
68
|
+
const args = typeof session === "string"
|
|
69
|
+
? ["--session", session, "find", "role", role, "select", ...optionValues.values, "--name", name]
|
|
70
|
+
: ["find", "role", role, "select", ...optionValues.values, "--name", name];
|
|
71
|
+
return { compiled: { action: "select", locator: "role", values: optionValues.values, args } };
|
|
72
|
+
}
|
|
73
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
74
|
+
return { error: "semanticAction.value must be the accessible label text for locator=label select." };
|
|
75
|
+
}
|
|
76
|
+
if (role !== undefined || name !== undefined) {
|
|
77
|
+
return { error: "semanticAction.role and name are only supported for locator=role select." };
|
|
78
|
+
}
|
|
79
|
+
const optionValues = getSelectValues({ values }, "semanticAction");
|
|
80
|
+
if (optionValues.error) {
|
|
81
|
+
return { error: optionValues.error.includes("required")
|
|
82
|
+
? "semanticAction.values is required for locator=label select (value is the label text)."
|
|
83
|
+
: optionValues.error };
|
|
44
84
|
}
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return { compiled: { action: "select", selector, values: selectedValues.values, args } };
|
|
85
|
+
const args = typeof session === "string"
|
|
86
|
+
? ["--session", session, "find", "label", value, "select", ...optionValues.values]
|
|
87
|
+
: ["find", "label", value, "select", ...optionValues.values];
|
|
88
|
+
return { compiled: { action: "select", locator: "label", values: optionValues.values, args } };
|
|
50
89
|
}
|
|
51
90
|
if (values !== undefined) {
|
|
52
91
|
return { error: "semanticAction.values is only supported for select actions." };
|
|
@@ -37,6 +37,14 @@ export const LAUNCH_SCOPED_FLAG_DEFINITIONS = [
|
|
|
37
37
|
flag: "--idle-timeout",
|
|
38
38
|
reason: "configures background browser lifecycle for the launched session",
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
flag: "--args",
|
|
42
|
+
reason: "selects raw Chrome arguments for the browser launch",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
flag: "--user-agent",
|
|
46
|
+
reason: "selects the browser user agent at launch time",
|
|
47
|
+
},
|
|
40
48
|
{
|
|
41
49
|
flag: "--headed",
|
|
42
50
|
reason: "selects whether the launched browser has a visible window",
|
|
@@ -12,7 +12,9 @@ const LOCK_TICKET_FILE = "ticket.json";
|
|
|
12
12
|
function getCoordinationDirectory(platform = process.platform) {
|
|
13
13
|
if (platform !== "win32") {
|
|
14
14
|
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
15
|
-
return
|
|
15
|
+
return platform === "android"
|
|
16
|
+
? join(tmpdir(), `pi-agent-browser-policy${uid === undefined ? "" : `-${uid}`}`)
|
|
17
|
+
: `/tmp/pi-agent-browser-policy${uid === undefined ? "" : `-${uid}`}`;
|
|
16
18
|
}
|
|
17
19
|
const user = process.env.USERNAME ?? process.env.USER ?? "unknown";
|
|
18
20
|
const suffix = createHash("sha256").update(user).digest("hex").slice(0, 12);
|