pi-agent-browser-native 0.6.17 → 0.7.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 +13 -0
- package/README.md +55 -167
- package/dist/extensions/agent-browser/index.js +317 -223
- package/dist/extensions/agent-browser/lib/browser-transcript.js +33 -0
- package/dist/extensions/agent-browser/lib/execution-cwd.js +4 -2
- package/dist/extensions/agent-browser/lib/input-modes/job.js +2 -213
- package/dist/extensions/agent-browser/lib/input-modes/params.js +146 -142
- package/dist/extensions/agent-browser/lib/input-modes/script.js +64 -92
- package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +170 -24
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +13 -1
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +26 -13
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +21 -1
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/snapshot-filter.js +4 -2
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +1 -3
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +35 -20
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/recording-recovery.js +3 -1
- package/dist/extensions/agent-browser/lib/orchestration/browser-run/screenshot-observation.js +79 -0
- package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +3 -4
- package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +19 -52
- package/dist/extensions/agent-browser/lib/orchestration/native-session-defaults.js +3 -3
- package/dist/extensions/agent-browser/lib/orchestration/output-file.js +3 -3
- package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +91 -278
- package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +9 -9
- package/dist/extensions/agent-browser/lib/playbook.js +46 -25
- package/dist/extensions/agent-browser/lib/process-identity.js +22 -6
- package/dist/extensions/agent-browser/lib/process.js +16 -13
- package/dist/extensions/agent-browser/lib/results/action-recommendations.js +2 -2
- package/dist/extensions/agent-browser/lib/results/next-actions.js +1 -2
- package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +25 -4
- package/dist/extensions/agent-browser/lib/results/presentation/batch.js +6 -2
- package/dist/extensions/agent-browser/lib/results/presentation/common.js +2 -0
- package/dist/extensions/agent-browser/lib/results/presentation/content.js +39 -0
- package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +3 -3
- package/dist/extensions/agent-browser/lib/results/presentation/large-output.js +41 -1
- package/dist/extensions/agent-browser/lib/results/presentation.js +16 -11
- package/dist/extensions/agent-browser/lib/runtime.js +18 -6
- package/dist/extensions/agent-browser/lib/session-page-state.js +3 -4
- package/dist/extensions/agent-browser/lib/tool-surface.js +131 -0
- package/dist/extensions/agent-browser/script-worker.js +4 -1
- package/docs/ARCHITECTURE.md +46 -62
- package/docs/COMMAND_REFERENCE.md +77 -136
- package/docs/ELECTRON.md +51 -49
- package/docs/RELEASE.md +31 -15
- package/docs/REQUIREMENTS.md +18 -14
- package/docs/SUPPORT_MATRIX.md +33 -15
- package/docs/TOOL_CONTRACT.md +216 -405
- package/package.json +1 -1
- package/scripts/doctor.mjs +2 -2
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
2
|
import { existsSync } from "node:fs";
|
|
4
3
|
import { dirname, join } from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { parseArgvDescriptor } from "../argv-descriptor.js";
|
|
7
|
-
import { getFlagName } from "../argv-grammar.js";
|
|
8
|
-
import { isBrowserIndependentRead, 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
5
|
import { isRecord } from "../parsing.js";
|
|
12
|
-
import {
|
|
6
|
+
import { extractExplicitNamespace, extractExplicitSessionName, scanUpstreamGlobalFlagOccurrences } from "../argv-grammar.js";
|
|
7
|
+
import { getUpstreamEffectiveBatchSteps } from "../orchestration/batch-stdin.js";
|
|
8
|
+
import { extractUpstreamCommandTokens } from "../argv-descriptor.js";
|
|
9
|
+
import { isCloseAllCommand } from "../command-taxonomy.js";
|
|
10
|
+
import { redactSensitiveText, validateToolArgs } from "../runtime.js";
|
|
13
11
|
export const AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES = 64 * 1_024;
|
|
14
12
|
export const AGENT_BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
13
|
export const AGENT_BROWSER_SCRIPT_NAMESPACE = "";
|
|
@@ -18,7 +16,6 @@ export const AGENT_BROWSER_SCRIPT_MAX_CALLS = 25;
|
|
|
18
16
|
export const AGENT_BROWSER_SCRIPT_FINAL_OUTPUT_MAX_BYTES = 64 * 1_024;
|
|
19
17
|
export const AGENT_BROWSER_SCRIPT_IPC_MESSAGE_MAX_BYTES = 1 * 1_024 * 1_024;
|
|
20
18
|
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
19
|
function findPackageRoot(startDir) {
|
|
23
20
|
let currentDir = startDir;
|
|
24
21
|
for (;;) {
|
|
@@ -37,25 +34,13 @@ function resolveScriptWorkerPath() {
|
|
|
37
34
|
return workerPath;
|
|
38
35
|
}
|
|
39
36
|
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
|
-
|
|
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
|
-
"--config",
|
|
48
|
-
]);
|
|
49
|
-
export function compileAgentBrowserScript(input) {
|
|
37
|
+
export function validateAgentBrowserScriptSource(input) {
|
|
50
38
|
if (typeof input !== "string")
|
|
51
39
|
return { error: "script must be a string." };
|
|
52
40
|
const bytes = Buffer.byteLength(input, "utf8");
|
|
53
41
|
return bytes > AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES
|
|
54
42
|
? { error: `script must be ${AGENT_BROWSER_SCRIPT_CODE_MAX_BYTES} bytes or less.` }
|
|
55
|
-
: {
|
|
56
|
-
}
|
|
57
|
-
export function createAgentBrowserScriptSessionName() {
|
|
58
|
-
return `piab-script-${randomUUID()}`;
|
|
43
|
+
: {};
|
|
59
44
|
}
|
|
60
45
|
export function createAgentBrowserScriptCloseArgs(sessionName) {
|
|
61
46
|
return ["--namespace", AGENT_BROWSER_SCRIPT_NAMESPACE, "--session", sessionName, "close"];
|
|
@@ -63,25 +48,6 @@ export function createAgentBrowserScriptCloseArgs(sessionName) {
|
|
|
63
48
|
export function isAgentBrowserScriptSessionName(value) {
|
|
64
49
|
return typeof value === "string" && SCRIPT_SESSION_NAME_PATTERN.test(value);
|
|
65
50
|
}
|
|
66
|
-
function getScriptCallPolicyError(args) {
|
|
67
|
-
const descriptor = parseArgvDescriptor(args);
|
|
68
|
-
const command = descriptor.commandInfo.command;
|
|
69
|
-
if (!command)
|
|
70
|
-
return "script browser call args must contain an agent-browser command.";
|
|
71
|
-
if (isCloseCommand(command))
|
|
72
|
-
return "script browser calls cannot close, quit, or exit their isolated session.";
|
|
73
|
-
if (SCRIPT_FORBIDDEN_COMMANDS.has(command))
|
|
74
|
-
return `script browser calls cannot use ${command}.`;
|
|
75
|
-
if (!needsManagedSession(descriptor) && !isBrowserIndependentRead(descriptor.upstreamCommandTokens))
|
|
76
|
-
return `script browser calls cannot use sessionless/local command ${command}.`;
|
|
77
|
-
for (const token of args) {
|
|
78
|
-
const flag = getFlagName(token);
|
|
79
|
-
if (SCRIPT_FORBIDDEN_FLAGS.has(flag)) {
|
|
80
|
-
return `script browser calls cannot use ${flag}; the parent owns the isolated session identity and launch policy.`;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
51
|
export function validateAgentBrowserScriptBrowserParams(input) {
|
|
86
52
|
if (!isRecord(input))
|
|
87
53
|
return { error: "script browser(params) requires an object." };
|
|
@@ -102,53 +68,51 @@ export function validateAgentBrowserScriptBrowserParams(input) {
|
|
|
102
68
|
stdin: input.stdin,
|
|
103
69
|
timeoutMs: input.timeoutMs,
|
|
104
70
|
};
|
|
105
|
-
const policyError = getScriptCallPolicyError(params.args);
|
|
106
|
-
if (policyError)
|
|
107
|
-
return { error: policyError, policyBlocked: true };
|
|
108
71
|
const validationError = validateToolArgs(params.args);
|
|
109
72
|
return validationError ? { error: validationError } : { params };
|
|
110
73
|
}
|
|
111
|
-
function
|
|
74
|
+
export function bindBrowserCodeCall(params, identity) {
|
|
75
|
+
const explicitSession = extractExplicitSessionName(params.args);
|
|
76
|
+
const namespaceFlags = scanUpstreamGlobalFlagOccurrences(params.args, "--namespace");
|
|
77
|
+
const explicitNamespace = extractExplicitNamespace(params.args);
|
|
78
|
+
if ((explicitSession !== undefined && explicitSession !== identity.sessionName)
|
|
79
|
+
|| (namespaceFlags.length > 0 && (explicitNamespace || undefined) !== (identity.namespace || undefined))) {
|
|
80
|
+
throw new Error("A code call uses one browser identity. Set session/namespace on agent_browser_code to choose another browser.");
|
|
81
|
+
}
|
|
82
|
+
const tokens = extractUpstreamCommandTokens(params.args);
|
|
83
|
+
if (isCloseAllCommand(tokens) || getUpstreamEffectiveBatchSteps(tokens, params.stdin).some(isCloseAllCommand)) {
|
|
84
|
+
throw new Error("Run namespace-wide close --all directly with agent_browser, outside a session-scoped code call.");
|
|
85
|
+
}
|
|
86
|
+
return { ...params, args: [
|
|
87
|
+
...(namespaceFlags.length === 0 ? ["--namespace", identity.namespace ?? ""] : []),
|
|
88
|
+
...(explicitSession === undefined ? ["--session", identity.sessionName] : []),
|
|
89
|
+
...params.args,
|
|
90
|
+
] };
|
|
91
|
+
}
|
|
92
|
+
function buildRejectedCallEnvelope(error) {
|
|
112
93
|
return {
|
|
113
94
|
data: null,
|
|
114
|
-
details: { failureCategory: policyBlocked ? "policy-blocked" : "validation-error", resultCategory: "failure" },
|
|
115
95
|
error,
|
|
116
|
-
failureCategory:
|
|
117
|
-
|
|
96
|
+
failureCategory: "validation-error",
|
|
97
|
+
success: false,
|
|
118
98
|
resultCategory: "failure",
|
|
119
99
|
summary: error,
|
|
120
|
-
text: error,
|
|
121
100
|
};
|
|
122
101
|
}
|
|
123
102
|
function normalizeBrowserEnvelope(value) {
|
|
124
|
-
if (!isRecord(value)
|
|
125
|
-
|
|
126
|
-
|| typeof value.text !== "string"
|
|
127
|
-
|| typeof value.summary !== "string"
|
|
128
|
-
|| (value.resultCategory !== "success" && value.resultCategory !== "failure")) {
|
|
129
|
-
return buildRejectedCallEnvelope("The ordinary agent_browser executor returned an invalid script envelope.", false);
|
|
103
|
+
if (!isRecord(value) || typeof value.success !== "boolean" || (value.resultCategory !== "success" && value.resultCategory !== "failure")) {
|
|
104
|
+
return buildRejectedCallEnvelope("The browser executor returned an invalid code observation.");
|
|
130
105
|
}
|
|
131
|
-
return
|
|
132
|
-
data: value.data ?? null,
|
|
133
|
-
details: isRecord(value.details) ? value.details : undefined,
|
|
134
|
-
error: typeof value.error === "string" ? value.error : undefined,
|
|
135
|
-
failureCategory: value.failureCategory,
|
|
136
|
-
nextActions: Array.isArray(value.nextActions) ? value.nextActions : undefined,
|
|
137
|
-
ok: value.ok,
|
|
138
|
-
resultCategory: value.resultCategory,
|
|
139
|
-
successCategory: value.successCategory,
|
|
140
|
-
summary: value.summary,
|
|
141
|
-
text: value.text,
|
|
142
|
-
};
|
|
106
|
+
return value;
|
|
143
107
|
}
|
|
144
108
|
function buildStepSummary(index, envelope) {
|
|
145
109
|
return {
|
|
146
110
|
failureCategory: envelope.failureCategory,
|
|
147
111
|
index,
|
|
148
|
-
ok: envelope.
|
|
112
|
+
ok: envelope.success,
|
|
149
113
|
resultCategory: envelope.resultCategory,
|
|
150
114
|
successCategory: envelope.successCategory,
|
|
151
|
-
summary: envelope.summary,
|
|
115
|
+
summary: envelope.summary ?? (typeof envelope.error === "string" ? envelope.error : "Browser call completed."),
|
|
152
116
|
};
|
|
153
117
|
}
|
|
154
118
|
function buildFailedRun(options) {
|
|
@@ -168,7 +132,7 @@ function isScriptChildMessage(value) {
|
|
|
168
132
|
return true;
|
|
169
133
|
if (value.type === "call")
|
|
170
134
|
return typeof value.id === "number" && Number.isSafeInteger(value.id) && value.id > 0;
|
|
171
|
-
if (value.type === "emit")
|
|
135
|
+
if (value.type === "emit" || value.type === "image")
|
|
172
136
|
return true;
|
|
173
137
|
return value.type === "complete";
|
|
174
138
|
}
|
|
@@ -206,7 +170,7 @@ function serializeFinalOutput(value) {
|
|
|
206
170
|
return JSON.stringify(value);
|
|
207
171
|
}
|
|
208
172
|
export async function runAgentBrowserScript(options) {
|
|
209
|
-
const compiled =
|
|
173
|
+
const compiled = validateAgentBrowserScriptSource(options.code);
|
|
210
174
|
if (compiled.error) {
|
|
211
175
|
return buildFailedRun({ callCount: 0, emitCount: 0, error: compiled.error, failureCategory: "validation-error", rejectedCallCount: 0, steps: [] });
|
|
212
176
|
}
|
|
@@ -241,12 +205,12 @@ export async function runAgentBrowserScript(options) {
|
|
|
241
205
|
let cumulativeBytes = 0;
|
|
242
206
|
let callCount = 0;
|
|
243
207
|
let rejectedCallCount = 0;
|
|
244
|
-
let leaseStarted = false;
|
|
245
208
|
let ready = false;
|
|
246
209
|
let stopping = false;
|
|
247
210
|
let activeCallController;
|
|
248
211
|
const emissions = [];
|
|
249
212
|
const steps = [];
|
|
213
|
+
const failures = [];
|
|
250
214
|
const messages = [];
|
|
251
215
|
let draining = false;
|
|
252
216
|
let drainPromise = Promise.resolve();
|
|
@@ -275,10 +239,11 @@ export async function runAgentBrowserScript(options) {
|
|
|
275
239
|
if (timeout)
|
|
276
240
|
clearTimeout(timeout);
|
|
277
241
|
options.signal?.removeEventListener("abort", abortListener);
|
|
278
|
-
activeCallController?.abort();
|
|
242
|
+
activeCallController?.abort(result.timedOut ? new DOMException("Browser code deadline exceeded.", "TimeoutError") : options.signal?.reason);
|
|
279
243
|
killTimer = terminateChild(child);
|
|
244
|
+
// The caller holds the browser lease until dispatch actually settles.
|
|
280
245
|
if (waitForDrain)
|
|
281
|
-
await
|
|
246
|
+
await drainPromise.catch(() => undefined);
|
|
282
247
|
await settleWithin(childExit, 1_000);
|
|
283
248
|
clearTimeout(killTimer);
|
|
284
249
|
resolveResult(result);
|
|
@@ -287,13 +252,16 @@ export async function runAgentBrowserScript(options) {
|
|
|
287
252
|
...flags,
|
|
288
253
|
callCount,
|
|
289
254
|
emitCount: emissions.length,
|
|
255
|
+
...(emissions.length ? { data: emissions.length === 1 ? emissions[0] : emissions } : {}),
|
|
290
256
|
error,
|
|
291
257
|
failureCategory,
|
|
258
|
+
failures,
|
|
292
259
|
rejectedCallCount,
|
|
293
260
|
steps,
|
|
294
261
|
}), waitForDrain);
|
|
295
262
|
const abortListener = () => {
|
|
296
|
-
|
|
263
|
+
const timedOut = options.signal?.reason instanceof Error && options.signal.reason.name === "TimeoutError";
|
|
264
|
+
void fail(timedOut ? "Browser code deadline exceeded." : "Browser code was aborted.", timedOut ? "timeout" : "aborted", timedOut ? { timedOut: true } : { aborted: true }, true);
|
|
297
265
|
};
|
|
298
266
|
options.signal?.addEventListener("abort", abortListener, { once: true });
|
|
299
267
|
timeout = setTimeout(() => {
|
|
@@ -333,6 +301,18 @@ export async function runAgentBrowserScript(options) {
|
|
|
333
301
|
emissions.push(message.value);
|
|
334
302
|
continue;
|
|
335
303
|
}
|
|
304
|
+
if (message.type === "image") {
|
|
305
|
+
try {
|
|
306
|
+
if (!options.emitImage)
|
|
307
|
+
throw new Error("Image emission is unavailable.");
|
|
308
|
+
await options.emitImage(message.value);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
await fail(error instanceof Error ? error.message : "Invalid image handle.", "validation-error");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
336
316
|
if (message.type === "complete") {
|
|
337
317
|
if (message.error) {
|
|
338
318
|
await fail(describeScriptError(message.error), "script-error");
|
|
@@ -353,7 +333,7 @@ export async function runAgentBrowserScript(options) {
|
|
|
353
333
|
await fail(`Final script output exceeds ${AGENT_BROWSER_SCRIPT_FINAL_OUTPUT_MAX_BYTES} bytes.`, "validation-error");
|
|
354
334
|
return;
|
|
355
335
|
}
|
|
356
|
-
await finish({ callCount, data, emitCount: emissions.length, ok: true, rejectedCallCount, steps }, false);
|
|
336
|
+
await finish({ callCount, data, emitCount: emissions.length, failures, ok: true, rejectedCallCount, steps }, false);
|
|
357
337
|
return;
|
|
358
338
|
}
|
|
359
339
|
callCount += 1;
|
|
@@ -365,38 +345,30 @@ export async function runAgentBrowserScript(options) {
|
|
|
365
345
|
let envelope;
|
|
366
346
|
if (!validated.params) {
|
|
367
347
|
rejectedCallCount += 1;
|
|
368
|
-
envelope = buildRejectedCallEnvelope(validated.error ?? "Invalid script browser call."
|
|
348
|
+
envelope = buildRejectedCallEnvelope(validated.error ?? "Invalid script browser call.");
|
|
369
349
|
}
|
|
370
350
|
else {
|
|
371
|
-
if (!leaseStarted) {
|
|
372
|
-
try {
|
|
373
|
-
options.beforeFirstCall?.();
|
|
374
|
-
leaseStarted = true;
|
|
375
|
-
}
|
|
376
|
-
catch {
|
|
377
|
-
await fail("Unable to persist the isolated script session lease.", "upstream-error");
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
351
|
activeCallController = new AbortController();
|
|
382
352
|
try {
|
|
383
353
|
envelope = normalizeBrowserEnvelope(await options.dispatch(validated.params, activeCallController.signal));
|
|
384
354
|
}
|
|
385
|
-
catch {
|
|
386
|
-
envelope = buildRejectedCallEnvelope("The
|
|
355
|
+
catch (error) {
|
|
356
|
+
envelope = buildRejectedCallEnvelope(redactSensitiveText(error instanceof Error ? error.message : "The browser executor failed while dispatching this call."));
|
|
387
357
|
}
|
|
388
358
|
finally {
|
|
389
359
|
activeCallController = undefined;
|
|
390
360
|
}
|
|
391
|
-
if (stopping)
|
|
392
|
-
return;
|
|
393
361
|
}
|
|
394
362
|
steps.push(buildStepSummary(callCount - 1, envelope));
|
|
363
|
+
if (!envelope.success)
|
|
364
|
+
failures.push({ ...envelope, index: callCount - 1 });
|
|
365
|
+
if (stopping)
|
|
366
|
+
return;
|
|
395
367
|
try {
|
|
396
368
|
await sendParentMessage({ envelope, id: message.id, type: "response" });
|
|
397
369
|
}
|
|
398
370
|
catch {
|
|
399
|
-
await fail("Unable to return a browser result
|
|
371
|
+
await fail("Unable to return a browser result within the code IPC limit. Narrow the native extraction or use agent_browser with outputPath; already-dispatched effects are not rolled back.", "upstream-error");
|
|
400
372
|
return;
|
|
401
373
|
}
|
|
402
374
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { basename, dirname, join } from "node:path";
|
|
5
|
-
import { canonicalizeAgentBrowserNamespace,
|
|
6
|
+
import { canonicalizeAgentBrowserNamespace, foldAgentBrowserFilesystemIdentity } from "./argv-grammar.js";
|
|
7
|
+
import { getAgentBrowserSocketDirValidationError, resolveAgentBrowserSocketDir } from "./process.js";
|
|
6
8
|
import { processStartIdentitiesMatch, readProcessStartIdentity } from "./process-identity.js";
|
|
7
9
|
const POLICY_LOCK_WAIT_MS = 1_000;
|
|
8
10
|
const POLICY_LOCK_RETRY_MS = 10;
|
|
9
|
-
const POLICY_LOCK_MAX_BYTES =
|
|
11
|
+
const POLICY_LOCK_MAX_BYTES = 1_048_576; // One claim may cover an entire owned-browser cleanup set.
|
|
10
12
|
const LOCK_OWNER_FILE = "owner.json";
|
|
11
13
|
const LOCK_TICKET_FILE = "ticket.json";
|
|
12
14
|
function getCoordinationDirectory(platform = process.platform) {
|
|
@@ -43,19 +45,133 @@ async function ensureCoordinationDirectory(path, platform) {
|
|
|
43
45
|
return false;
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
|
-
function
|
|
47
|
-
const
|
|
48
|
-
|
|
48
|
+
export async function resolveBrowserExecutionIdentity(options) {
|
|
49
|
+
const socketDir = resolveAgentBrowserSocketDir(options);
|
|
50
|
+
const namespace = canonicalizeAgentBrowserNamespace(options.namespace);
|
|
51
|
+
let socketContext;
|
|
52
|
+
if (process.platform === "win32") {
|
|
53
|
+
// Native Windows can reach the same namespace/session TCP daemon from
|
|
54
|
+
// different storage roots. Do not split those callers into separate locks.
|
|
55
|
+
socketContext = `win32-native:${namespace ?? ""}`;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
if (socketDir === undefined)
|
|
59
|
+
throw new Error("Browser execution coordination requires the native socket directory.");
|
|
60
|
+
const error = await getAgentBrowserSocketDirValidationError(socketDir);
|
|
61
|
+
if (error)
|
|
62
|
+
throw new Error(`Browser execution coordination cannot use socket storage: ${error}.`);
|
|
63
|
+
const root = await realpath(socketDir);
|
|
64
|
+
socketContext = namespace ? join(root, "namespaces", namespace, "run") : root;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
socketContext: foldAgentBrowserFilesystemIdentity(socketContext, process.platform),
|
|
68
|
+
...(options.sessionName !== undefined ? { sessionName: foldAgentBrowserFilesystemIdentity(options.sessionName, process.platform) } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function getBrowserExecutionLockPath(identity) {
|
|
72
|
+
const digest = createHash("sha256").update(identity.socketContext).digest("hex");
|
|
73
|
+
return join(getCoordinationDirectory(), `.pi-agent-browser-policy-${digest}.lock-v4`);
|
|
74
|
+
}
|
|
75
|
+
function groupExecutionIdentities(identities) {
|
|
76
|
+
if (identities.length === 0)
|
|
77
|
+
throw new Error("Browser execution coordination requires at least one identity.");
|
|
78
|
+
const groups = new Map();
|
|
79
|
+
for (const { socketContext, sessionName } of identities) {
|
|
80
|
+
if (sessionName === undefined)
|
|
81
|
+
groups.set(socketContext, null);
|
|
82
|
+
else if (groups.get(socketContext) !== null) {
|
|
83
|
+
const sessions = groups.get(socketContext) ?? new Set();
|
|
84
|
+
sessions.add(sessionName);
|
|
85
|
+
groups.set(socketContext, sessions);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...groups.keys()].sort().map(socketContext => {
|
|
89
|
+
const sessions = groups.get(socketContext);
|
|
90
|
+
return { socketContext, sessionNames: sessions ? [...sessions].sort() : null };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const executionScope = new AsyncLocalStorage();
|
|
94
|
+
function scopeCovers(outer, inner) {
|
|
95
|
+
return inner.every(identity => outer.some(held => held.socketContext === identity.socketContext
|
|
96
|
+
&& (held.sessionNames === null || identity.sessionNames !== null && identity.sessionNames.every(session => held.sessionNames.includes(session)))));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Hold across every helper and action (or a complete code cell), not each CLI call.
|
|
100
|
+
* Nested siblings are serialized; recursive inner calls and managed policy checks borrow
|
|
101
|
+
* the claims. Declare every old/new identity up front for replacement or multi-browser cleanup;
|
|
102
|
+
* omit sessionName for close --all. Upgrading mid-operation would allow a lock-order cycle.
|
|
103
|
+
* Sessions sharing a socket context use ONE set-valued claim: acquiring them separately
|
|
104
|
+
* could deadlock against a namespace-wide claimant between the first and second session.
|
|
105
|
+
* Cancellation never releases a still-running callback. The callback must pass its signal
|
|
106
|
+
* to subprocesses and await their termination; finally drains started children before release.
|
|
107
|
+
*/
|
|
108
|
+
export async function withBrowserExecutionLocks(options, run) {
|
|
109
|
+
if (!Number.isFinite(options.deadline))
|
|
110
|
+
throw new Error("Browser execution coordination requires a finite deadline.");
|
|
111
|
+
const identities = groupExecutionIdentities(options.identities);
|
|
112
|
+
const parent = executionScope.getStore();
|
|
113
|
+
if (parent && !parent.active)
|
|
114
|
+
throw new Error("Browser execution scope has already finished.");
|
|
115
|
+
if (parent && !scopeCovers(parent.identities, identities))
|
|
116
|
+
throw new Error("Cannot change or upgrade browser identity inside an execution lock; acquire every required identity before starting.");
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
const signals = [controller.signal, options.signal, parent?.signal].filter((signal) => signal !== undefined);
|
|
119
|
+
const signal = AbortSignal.any(signals);
|
|
120
|
+
const expire = () => controller.abort(new DOMException("Browser execution deadline exceeded.", "TimeoutError"));
|
|
121
|
+
const remaining = options.deadline - Date.now();
|
|
122
|
+
const timer = setTimeout(expire, Math.max(0, Math.min(remaining, 2_147_483_647)));
|
|
123
|
+
const checkBudget = () => {
|
|
124
|
+
if (Date.now() >= options.deadline)
|
|
125
|
+
expire();
|
|
126
|
+
signal.throwIfAborted();
|
|
127
|
+
};
|
|
128
|
+
const locks = [];
|
|
129
|
+
try {
|
|
130
|
+
checkBudget();
|
|
131
|
+
if (!parent) {
|
|
132
|
+
for (const identity of identities) {
|
|
133
|
+
const lock = await acquireExecutionClaim({ identity, deadline: options.deadline, signal });
|
|
134
|
+
if (lock)
|
|
135
|
+
locks.push(lock);
|
|
136
|
+
checkBudget();
|
|
137
|
+
if (!lock)
|
|
138
|
+
throw new Error("Browser execution coordination is unavailable or busy; no browser command was run.");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const execute = async () => {
|
|
142
|
+
checkBudget();
|
|
143
|
+
if (options.waitOnly)
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
const scope = { identities: parent?.identities ?? identities, active: true, signal, children: Promise.resolve() };
|
|
146
|
+
try {
|
|
147
|
+
return await executionScope.run(scope, () => run(signal));
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
scope.active = false;
|
|
151
|
+
await scope.children;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
if (!parent)
|
|
155
|
+
return await execute();
|
|
156
|
+
const result = parent.children.then(execute);
|
|
157
|
+
parent.children = result.then(() => undefined, () => undefined);
|
|
158
|
+
return await result;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
await Promise.all(locks.reverse().map(lock => lock.release()));
|
|
163
|
+
}
|
|
49
164
|
}
|
|
50
|
-
export function
|
|
51
|
-
return
|
|
165
|
+
export function withBrowserExecutionLock(options, run) {
|
|
166
|
+
return withBrowserExecutionLocks({ identities: [options.identity], signal: options.signal, deadline: options.deadline, waitOnly: options.waitOnly }, run);
|
|
52
167
|
}
|
|
53
168
|
function parseOwner(content) {
|
|
54
169
|
if (Buffer.byteLength(content) > POLICY_LOCK_MAX_BYTES)
|
|
55
170
|
return undefined;
|
|
56
171
|
try {
|
|
57
172
|
const parsed = JSON.parse(content);
|
|
58
|
-
return parsed.version ===
|
|
173
|
+
return parsed.version === 4
|
|
174
|
+
&& (parsed.sessionNames === null || Array.isArray(parsed.sessionNames) && parsed.sessionNames.length > 0 && parsed.sessionNames.every(session => typeof session === "string"))
|
|
59
175
|
&& Number.isSafeInteger(parsed.pid) && (parsed.pid ?? 0) > 0
|
|
60
176
|
&& typeof parsed.startIdentity === "string" && parsed.startIdentity.length > 0
|
|
61
177
|
&& typeof parsed.token === "string" && parsed.token.length > 0
|
|
@@ -71,7 +187,7 @@ function parseTicket(content, token) {
|
|
|
71
187
|
return undefined;
|
|
72
188
|
try {
|
|
73
189
|
const parsed = JSON.parse(content);
|
|
74
|
-
return parsed.version ===
|
|
190
|
+
return parsed.version === 4
|
|
75
191
|
&& parsed.token === token
|
|
76
192
|
&& Number.isSafeInteger(parsed.ticket)
|
|
77
193
|
&& (parsed.ticket ?? 0) > 0
|
|
@@ -150,7 +266,7 @@ async function readClaims(basePath) {
|
|
|
150
266
|
}
|
|
151
267
|
return claims;
|
|
152
268
|
}
|
|
153
|
-
async function ownerAlive(owner) {
|
|
269
|
+
async function ownerAlive(owner, budget) {
|
|
154
270
|
try {
|
|
155
271
|
process.kill(owner.pid, 0);
|
|
156
272
|
}
|
|
@@ -161,7 +277,7 @@ async function ownerAlive(owner) {
|
|
|
161
277
|
if (code !== "EPERM")
|
|
162
278
|
return undefined;
|
|
163
279
|
}
|
|
164
|
-
const current = await readProcessStartIdentity(owner.pid);
|
|
280
|
+
const current = await readProcessStartIdentity(owner.pid, process.platform, budget);
|
|
165
281
|
return current === undefined ? undefined : processStartIdentitiesMatch(owner.startIdentity, current);
|
|
166
282
|
}
|
|
167
283
|
async function removeClaimOwnedBy(path, token, deadline = 0) {
|
|
@@ -199,7 +315,7 @@ async function removeClaimOwnedBy(path, token, deadline = 0) {
|
|
|
199
315
|
await rm(movedPath, { force: true, recursive: true });
|
|
200
316
|
return true;
|
|
201
317
|
}
|
|
202
|
-
async function cleanDeadPolicyArtifacts(directory) {
|
|
318
|
+
async function cleanDeadPolicyArtifacts(directory, budget) {
|
|
203
319
|
let names;
|
|
204
320
|
try {
|
|
205
321
|
names = await readdir(directory);
|
|
@@ -208,10 +324,12 @@ async function cleanDeadPolicyArtifacts(directory) {
|
|
|
208
324
|
return;
|
|
209
325
|
}
|
|
210
326
|
for (const name of names.filter((candidate) => candidate.startsWith(".pi-agent-browser-policy-remove-")
|
|
211
|
-
|| candidate.includes(".lock-
|
|
327
|
+
|| candidate.includes(".lock-v4.candidate-"))) {
|
|
328
|
+
if (budget.signal?.aborted || Date.now() >= (budget.deadline ?? Infinity))
|
|
329
|
+
return;
|
|
212
330
|
const path = join(directory, name);
|
|
213
331
|
const claim = await readClaim(path);
|
|
214
|
-
if (claim && await ownerAlive(claim.owner) === false)
|
|
332
|
+
if (claim && await ownerAlive(claim.owner, budget) === false)
|
|
215
333
|
await rm(path, { force: true, recursive: true }).catch(() => undefined);
|
|
216
334
|
}
|
|
217
335
|
}
|
|
@@ -244,23 +362,48 @@ function waitForRetry(signal) {
|
|
|
244
362
|
export async function acquireManagedSessionPolicyLock(options) {
|
|
245
363
|
if (options.signal?.aborted)
|
|
246
364
|
return undefined;
|
|
365
|
+
let identity;
|
|
366
|
+
try {
|
|
367
|
+
identity = await resolveBrowserExecutionIdentity({ ...options, ownedManagedSession: true });
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
const scope = executionScope.getStore();
|
|
373
|
+
if (scope) {
|
|
374
|
+
if (!scope.active || !scopeCovers(scope.identities, groupExecutionIdentities([identity])))
|
|
375
|
+
return undefined;
|
|
376
|
+
return { release: async () => undefined };
|
|
377
|
+
}
|
|
378
|
+
return acquireExecutionClaim({ identity: groupExecutionIdentities([identity])[0], signal: options.signal, timeoutMs: options.timeoutMs });
|
|
379
|
+
}
|
|
380
|
+
async function acquireExecutionClaim(options) {
|
|
381
|
+
if (options.signal?.aborted)
|
|
382
|
+
return undefined;
|
|
383
|
+
const deadline = options.deadline ?? Date.now() + (options.timeoutMs ?? POLICY_LOCK_WAIT_MS);
|
|
384
|
+
// timeoutMs: 0 retains the existing single-attempt policy probe contract.
|
|
385
|
+
const budget = { signal: options.signal, deadline: options.timeoutMs === 0 ? undefined : deadline };
|
|
247
386
|
const platform = process.platform;
|
|
248
387
|
const directory = getCoordinationDirectory(platform);
|
|
249
388
|
if (!await ensureCoordinationDirectory(directory, platform))
|
|
250
389
|
return undefined;
|
|
251
|
-
const basePath =
|
|
390
|
+
const basePath = getBrowserExecutionLockPath(options.identity);
|
|
252
391
|
const token = randomUUID();
|
|
253
|
-
const startIdentity = await readProcessStartIdentity(process.pid);
|
|
392
|
+
const startIdentity = await readProcessStartIdentity(process.pid, process.platform, budget);
|
|
254
393
|
if (!startIdentity)
|
|
255
394
|
return undefined;
|
|
256
|
-
const owner = { pid: process.pid, startIdentity, token, version:
|
|
395
|
+
const owner = { pid: process.pid, startIdentity, token, sessionNames: options.identity.sessionNames, version: 4 };
|
|
396
|
+
const ownerContent = JSON.stringify(owner);
|
|
397
|
+
// Never publish metadata our reader cannot validate and ownership-safely remove.
|
|
398
|
+
if (Buffer.byteLength(ownerContent) > POLICY_LOCK_MAX_BYTES)
|
|
399
|
+
return undefined;
|
|
257
400
|
const candidatePath = `${basePath}.candidate-${token}`;
|
|
258
401
|
const claimPath = `${basePath}.claim-${token}`;
|
|
259
402
|
let claimPublished = false;
|
|
260
403
|
let lockAcquired = false;
|
|
261
404
|
try {
|
|
262
405
|
await mkdir(candidatePath, { mode: 0o700 });
|
|
263
|
-
await writeFile(join(candidatePath, LOCK_OWNER_FILE),
|
|
406
|
+
await writeFile(join(candidatePath, LOCK_OWNER_FILE), ownerContent, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
264
407
|
await rename(candidatePath, claimPath);
|
|
265
408
|
claimPublished = true;
|
|
266
409
|
const initialClaims = await readClaims(basePath);
|
|
@@ -269,11 +412,10 @@ export async function acquireManagedSessionPolicyLock(options) {
|
|
|
269
412
|
const maxTicket = initialClaims.reduce((max, claim) => claim.ticket === null ? max : Math.max(max, claim.ticket), 0);
|
|
270
413
|
if (!Number.isSafeInteger(maxTicket + 1))
|
|
271
414
|
return undefined;
|
|
272
|
-
const ticket = { ticket: maxTicket + 1, token, version:
|
|
415
|
+
const ticket = { ticket: maxTicket + 1, token, version: 4 };
|
|
273
416
|
const ticketCandidatePath = join(claimPath, `.ticket-${token}.tmp`);
|
|
274
417
|
await writeFile(ticketCandidatePath, JSON.stringify(ticket), { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
275
418
|
await rename(ticketCandidatePath, join(claimPath, LOCK_TICKET_FILE));
|
|
276
|
-
const deadline = Date.now() + (options.timeoutMs ?? POLICY_LOCK_WAIT_MS);
|
|
277
419
|
while (!options.signal?.aborted) {
|
|
278
420
|
const claims = await readClaims(basePath);
|
|
279
421
|
if (!claims)
|
|
@@ -283,13 +425,15 @@ export async function acquireManagedSessionPolicyLock(options) {
|
|
|
283
425
|
return undefined;
|
|
284
426
|
let blocked = false;
|
|
285
427
|
for (const claim of claims) {
|
|
286
|
-
|
|
428
|
+
const disjoint = owner.sessionNames !== null && claim.owner.sessionNames !== null
|
|
429
|
+
&& !owner.sessionNames.some(session => claim.owner.sessionNames.includes(session));
|
|
430
|
+
if (claim.owner.token === token || disjoint || !claimPrecedes(claim, ownClaim))
|
|
287
431
|
continue;
|
|
288
432
|
// Choosing is transient: a later published ticket must not make
|
|
289
433
|
// its predecessor wait on it using an earlier null snapshot.
|
|
290
434
|
if (await hasPublishedLaterTicket(claim, ownClaim))
|
|
291
435
|
continue;
|
|
292
|
-
const alive = await ownerAlive(claim.owner);
|
|
436
|
+
const alive = await ownerAlive(claim.owner, budget);
|
|
293
437
|
if (alive === false) {
|
|
294
438
|
await removeClaimOwnedBy(claim.path, claim.owner.token);
|
|
295
439
|
continue;
|
|
@@ -311,7 +455,9 @@ export async function acquireManagedSessionPolicyLock(options) {
|
|
|
311
455
|
break;
|
|
312
456
|
}
|
|
313
457
|
if (!blocked) {
|
|
314
|
-
await cleanDeadPolicyArtifacts(directory);
|
|
458
|
+
await cleanDeadPolicyArtifacts(directory, budget);
|
|
459
|
+
if (options.signal?.aborted || (budget.deadline !== undefined && Date.now() >= budget.deadline))
|
|
460
|
+
return undefined;
|
|
315
461
|
lockAcquired = true;
|
|
316
462
|
return { release: async () => { await removeClaimOwnedBy(claimPath, token, Date.now() + POLICY_LOCK_WAIT_MS); } };
|
|
317
463
|
}
|
|
@@ -432,7 +432,7 @@ export function buildSourceLookupElectronNextActions(sourceLookup) {
|
|
|
432
432
|
if (sessionName)
|
|
433
433
|
actions.push({ id: "snapshot-electron-session", params: { args: withOptionalSessionArgs(sessionName, ["snapshot", "-i"]) }, reason: "Refresh interactive refs in the attached Electron session before retrying source lookup with a narrower target.", safety: "Read-only snapshot; no app mutation.", tool: "agent_browser" });
|
|
434
434
|
if (launchId)
|
|
435
|
-
actions.push({ id: "probe-electron-launch", params: {
|
|
435
|
+
actions.push({ id: "probe-electron-launch", params: { action: "probe", launchId }, reason: "Collect bounded wrapper/session context for the packaged Electron launch after sourceLookup found no candidates.", safety: "Read-only probe of title, URL, focus, tabs, and compact snapshot metadata.", tool: "agent_browser_electron" });
|
|
436
436
|
if (sessionName)
|
|
437
437
|
actions.push({ id: "list-electron-tabs", params: { args: withOptionalSessionArgs(sessionName, ["tab", "list"]) }, reason: "Check current Electron tabs/targets before choosing a narrower selector or @ref.", safety: "Read-only tab listing.", tool: "agent_browser" });
|
|
438
438
|
return actions;
|
|
@@ -901,6 +901,18 @@ function sanitizeCurrentPageUrlForTimeoutDiagnostic(url) {
|
|
|
901
901
|
return redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(url));
|
|
902
902
|
}
|
|
903
903
|
}
|
|
904
|
+
export function redactTimeoutPartialProgress(progress) {
|
|
905
|
+
const redact = (value) => {
|
|
906
|
+
if (typeof value === "string")
|
|
907
|
+
return /^https?:\/\//i.test(value) ? sanitizeCurrentPageUrlForTimeoutDiagnostic(value) : redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(value));
|
|
908
|
+
if (Array.isArray(value))
|
|
909
|
+
return value.map(redact);
|
|
910
|
+
if (isRecord(value))
|
|
911
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item)]));
|
|
912
|
+
return value;
|
|
913
|
+
};
|
|
914
|
+
return redact(progress);
|
|
915
|
+
}
|
|
904
916
|
export function formatTimeoutPartialProgressText(progress, pageTargetUnknown = false) {
|
|
905
917
|
const lines = [`Timeout partial progress: ${progress.summary}`];
|
|
906
918
|
const currentPageTitle = progress.currentPage?.title ? redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(progress.currentPage.title)) : undefined;
|