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.
Files changed (73) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +63 -20
  3. package/dist/extensions/agent-browser/index.js +787 -105
  4. package/dist/extensions/agent-browser/lib/argv-descriptor.js +35 -3
  5. package/dist/extensions/agent-browser/lib/argv-grammar.js +44 -2
  6. package/dist/extensions/agent-browser/lib/batch-lifecycle.js +71 -0
  7. package/dist/extensions/agent-browser/lib/command-policy.js +1 -1
  8. package/dist/extensions/agent-browser/lib/command-taxonomy.js +35 -2
  9. package/dist/extensions/agent-browser/lib/input-modes/job.js +61 -4
  10. package/dist/extensions/agent-browser/lib/input-modes/lookups.js +2 -2
  11. package/dist/extensions/agent-browser/lib/input-modes/params.js +22 -23
  12. package/dist/extensions/agent-browser/lib/input-modes/script.js +462 -0
  13. package/dist/extensions/agent-browser/lib/input-modes/semantic-action.js +51 -12
  14. package/dist/extensions/agent-browser/lib/launch-scoped-flags.js +8 -0
  15. package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +3 -1
  16. package/dist/extensions/agent-browser/lib/managed-session-restore.js +26 -36
  17. package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +2 -4
  18. package/dist/extensions/agent-browser/lib/managed-session-state-policy.js +47 -29
  19. package/dist/extensions/agent-browser/lib/managed-session-storage.js +50 -24
  20. package/dist/extensions/agent-browser/lib/orchestration/batch-stdin.js +26 -5
  21. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +110 -30
  22. package/dist/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.js +2 -1
  23. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +26 -25
  24. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +17 -3
  25. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +2 -1
  26. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +6 -4
  27. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +129 -32
  28. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +191 -55
  29. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +57 -29
  30. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +15 -11
  31. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +36 -18
  32. package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +299 -0
  33. package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +32 -10
  34. package/dist/extensions/agent-browser/lib/playbook.js +18 -15
  35. package/dist/extensions/agent-browser/lib/process-environment.js +14 -0
  36. package/dist/extensions/agent-browser/lib/process-identity.js +4 -4
  37. package/dist/extensions/agent-browser/lib/process.js +131 -41
  38. package/dist/extensions/agent-browser/lib/recording-reservations.js +183 -0
  39. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +62 -5
  40. package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +62 -4
  41. package/dist/extensions/agent-browser/lib/results/categories.js +6 -1
  42. package/dist/extensions/agent-browser/lib/results/next-actions.js +19 -5
  43. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +85 -38
  44. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +66 -11
  45. package/dist/extensions/agent-browser/lib/results/presentation/common.js +18 -0
  46. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +7 -1
  47. package/dist/extensions/agent-browser/lib/results/presentation/errors.js +2 -1
  48. package/dist/extensions/agent-browser/lib/results/presentation/navigation.js +26 -11
  49. package/dist/extensions/agent-browser/lib/results/presentation/registry.js +58 -13
  50. package/dist/extensions/agent-browser/lib/results/presentation/semantic-action.js +1 -10
  51. package/dist/extensions/agent-browser/lib/results/presentation.js +6 -3
  52. package/dist/extensions/agent-browser/lib/results/recovery-actions.js +2 -0
  53. package/dist/extensions/agent-browser/lib/results/selector-recovery.js +51 -8
  54. package/dist/extensions/agent-browser/lib/results/snapshot-high-value-controls.js +13 -7
  55. package/dist/extensions/agent-browser/lib/runtime.js +116 -39
  56. package/dist/extensions/agent-browser/lib/session-page-state.js +62 -10
  57. package/dist/extensions/agent-browser/lib/upstream-version.js +14 -0
  58. package/dist/extensions/agent-browser/script-worker.js +169 -0
  59. package/dist/scripts/agent-browser-target.mjs +3 -0
  60. package/docs/ARCHITECTURE.md +40 -21
  61. package/docs/COMMAND_REFERENCE.md +90 -35
  62. package/docs/RELEASE.md +3 -3
  63. package/docs/REQUIREMENTS.md +4 -2
  64. package/docs/SUPPORT_MATRIX.md +26 -19
  65. package/docs/TOOL_CONTRACT.md +93 -52
  66. package/package.json +3 -1
  67. package/platform-smoke.config.mjs +2 -2
  68. package/scripts/agent-browser-capability-baseline.mjs +24 -6
  69. package/scripts/agent-browser-target.mjs +3 -0
  70. package/scripts/build.mjs +41 -0
  71. package/scripts/doctor.mjs +7 -6
  72. package/scripts/platform-smoke/browser-dogfood-windows.ps1 +9 -3
  73. package/scripts/platform-smoke/targets.mjs +12 -6
@@ -6,10 +6,11 @@ import { env as processEnv, platform as processPlatform } from "node:process";
6
6
  import { parseArgvDescriptor } from "./argv-descriptor.js";
7
7
  import { needsManagedSession } from "./command-policy.js";
8
8
  import { isKnownCommandToken } from "./command-taxonomy.js";
9
- import { getFlagName, GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, GLOBAL_VALUE_FLAGS, optionalGlobalValueFlagConsumesNext, } from "./argv-grammar.js";
10
- import { canonicalizeOwnedManagedSessionCloseArgs, commitManagedSessionRestoreSuppression, getManagedSessionRestoreConfigEnv, getManagedSessionRestoreEnv, getManagedSessionRestoreProtectedEnv, getOwnedManagedSessionCompatibilityEnv, getOwnedManagedSessionNamespaceEnv, isOwnedManagedSessionTarget, shouldOmitOwnedManagedSessionRestoreEnv, validateManagedSessionRestoreContextForSpawn, } from "./managed-session-restore.js";
11
- import { getManagedSessionStateAccessValidationError, getManagedSessionTargetAccessValidationError, } from "./managed-session-state-policy.js";
9
+ import { extractExplicitSessionName, getFlagName, GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, GLOBAL_VALUE_FLAGS, optionalGlobalValueFlagConsumesNext, resolveAgentBrowserNamespace, } from "./argv-grammar.js";
10
+ import { canonicalizeOwnedManagedSessionCloseArgs, commitManagedSessionRestoreSuppression, getManagedSessionRestoreConfigEnv, getManagedSessionRestoreEnv, getOwnedManagedSessionRestoreKey, getManagedSessionRestoreProtectedEnv, getOwnedManagedSessionCompatibilityEnv, getOwnedManagedSessionNamespaceEnv, isOwnedManagedSessionTarget, shouldOmitOwnedManagedSessionRestoreEnv, validateManagedSessionRestoreContextForSpawn, } from "./managed-session-restore.js";
11
+ import { getManagedSessionStateAccessValidationError, getManagedSessionTargetAccessValidationError, invocationMayNavigateToLocalFile, } from "./managed-session-state-policy.js";
12
12
  import { getImplicitSessionIdleTimeoutMs, isPlainTextInspectionArgs } from "./runtime.js";
13
+ import { getAgentBrowserProcessEnvironment } from "./process-environment.js";
13
14
  import { openSecureTempFile, writeSecureTempChunk } from "./temp.js";
14
15
  const MAX_BUFFERED_STDOUT_BYTES = 512 * 1_024;
15
16
  const MAX_BUFFERED_STDERR_CHARS = 32_000;
@@ -20,16 +21,29 @@ const AGENT_BROWSER_ARGS_ENV = "AGENT_BROWSER_ARGS";
20
21
  const AGENT_BROWSER_DEFAULT_TIMEOUT_ENV = "AGENT_BROWSER_DEFAULT_TIMEOUT";
21
22
  const AGENT_BROWSER_IDLE_TIMEOUT_ENV = "AGENT_BROWSER_IDLE_TIMEOUT_MS";
22
23
  const PI_AGENT_BROWSER_PROCESS_TIMEOUT_ENV = "PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS";
24
+ const PI_AGENT_BROWSER_SOCKET_DIR_ENV = "PI_AGENT_BROWSER_SOCKET_DIR";
23
25
  const DEFAULT_AGENT_BROWSER_SOCKET_DIR_PREFIX = "/tmp/piab";
26
+ const TERMUX_PACKAGE_NAME_PATTERN = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+$/;
24
27
  export const SAFE_AGENT_BROWSER_OPERATION_TIMEOUT_MS = 25_000;
25
28
  const DEFAULT_AGENT_BROWSER_PROCESS_TIMEOUT_MS = 35_000;
26
29
  /** Grace period after `exit` before resolving when `close` is delayed by inherited stdio handles. */
27
30
  const EXIT_STDIO_GRACE_MS = 100;
28
31
  const WINDOWS_AGENT_BROWSER_MISSING_MARKER = "PI_AGENT_BROWSER_COMMAND_NOT_FOUND:agent-browser.cmd";
32
+ const UNTRUSTED_LOCAL_FILE_SESSION_MESSAGE = "Local file navigation requires a wrapper-managed local browser because caller-owned or attached sessions can retain unsafe file-access launch flags. Omit the explicit session or attachment and retry with sessionMode fresh.";
29
33
  const attachedBrowserSessionContext = new AsyncLocalStorage();
34
+ const WINDOWS_COMMANDS_WITH_ADJACENT_SUBCOMMAND = new Set([
35
+ "auth", "clipboard", "cookies", "dashboard", "device", "dialog", "diff", "find", "get", "is", "keyboard",
36
+ "mouse", "network", "plugin", "profiler", "react", "record", "session", "set", "skills", "state", "storage",
37
+ "stream", "tab", "trace", "window",
38
+ ]);
30
39
  export function withAttachedBrowserSessionContext(preserve, run) {
31
40
  return attachedBrowserSessionContext.run(preserve || attachedBrowserSessionContext.getStore() === true, run);
32
41
  }
42
+ export function getWindowsExplicitDefaultNamespaceEnv(args, parentNamespace, platform = processPlatform) {
43
+ return platform === "win32" && resolveAgentBrowserNamespace(args, parentNamespace) === ""
44
+ ? { AGENT_BROWSER_NAMESPACE: "" }
45
+ : {};
46
+ }
33
47
  function appendTail(text, addition, maxChars) {
34
48
  const combined = text + addition;
35
49
  return combined.length <= maxChars ? combined : combined.slice(combined.length - maxChars);
@@ -43,7 +57,12 @@ export function reorderWindowsLeadingGlobalArgs(args) {
43
57
  for (let index = 0; index < args.length; index += 1) {
44
58
  const token = args[index];
45
59
  if (isKnownCommandToken(token)) {
46
- return index === 0 ? args : [token, ...leadingGlobals, ...args.slice(index + 1)];
60
+ if (index === 0)
61
+ return args;
62
+ const firstPositional = args[index + 1];
63
+ return WINDOWS_COMMANDS_WITH_ADJACENT_SUBCOMMAND.has(token) && firstPositional && !firstPositional.startsWith("-")
64
+ ? [token, firstPositional, ...leadingGlobals, ...args.slice(index + 2)]
65
+ : [token, ...leadingGlobals, ...args.slice(index + 1)];
47
66
  }
48
67
  if (!token.startsWith("-"))
49
68
  return args;
@@ -77,6 +96,13 @@ export function reorderWindowsLeadingGlobalArgs(args) {
77
96
  const value = args[index + 1];
78
97
  if (value === undefined)
79
98
  return args;
99
+ // PowerShell -> .cmd drops empty argv values. Planning rejects empty
100
+ // caller --args; keep this defensive skip so an unexpected empty value
101
+ // cannot turn the next flag into its accidental value on native Windows.
102
+ if (value === "" && (flag === "--args" || flag === "--namespace")) {
103
+ index += 1;
104
+ continue;
105
+ }
80
106
  leadingGlobals.push(token, value);
81
107
  index += 1;
82
108
  continue;
@@ -103,9 +129,9 @@ export function pinAgentBrowserFileAccessDisabled(args, wrapperCompatibilityUser
103
129
  return filtered;
104
130
  // Upstream's flag overrides only the active CDP target; the Chrome arg covers new tabs. Its --args parser splits commas/newlines.
105
131
  const browserArgs = wrapperCompatibilityUserAgent
106
- ? `--user-agent=${wrapperCompatibilityUserAgent.replaceAll(/[\r\n,]/g, "")}`
107
- : "";
108
- return ["--args", browserArgs, "--allow-file-access", "false", ...filtered];
132
+ ? ["--args", `--user-agent=${wrapperCompatibilityUserAgent.replaceAll(/[\r\n,]/g, "")}`]
133
+ : [];
134
+ return [...browserArgs, "--allow-file-access", "false", ...filtered];
109
135
  }
110
136
  export function buildAgentBrowserSpawnCommand(args, platform = processPlatform) {
111
137
  if (platform !== "win32") {
@@ -216,33 +242,43 @@ function clampUpstreamDefaultTimeout(childEnv) {
216
242
  export function getAgentBrowserProcessTimeoutMs(env = processEnv) {
217
243
  return parsePositiveIntegerEnv(env[PI_AGENT_BROWSER_PROCESS_TIMEOUT_ENV]) ?? DEFAULT_AGENT_BROWSER_PROCESS_TIMEOUT_MS;
218
244
  }
219
- export function getAgentBrowserSocketDir(platform = processPlatform, uid = typeof process.getuid === "function" ? process.getuid() : undefined) {
245
+ export function getAgentBrowserSocketDir(platform = processPlatform, uid = typeof process.getuid === "function" ? process.getuid() : undefined, termuxPackageName = processEnv.TERMUX_APP__PACKAGE_NAME) {
220
246
  if (platform === "win32") {
221
247
  return undefined;
222
248
  }
223
- const prefix = platform === "darwin" ? "/private/tmp/piab" : DEFAULT_AGENT_BROWSER_SOCKET_DIR_PREFIX;
224
- return `${prefix}${typeof uid === "number" ? `-${uid}` : ""}`;
249
+ const termuxAppRoot = platform === "android" && termuxPackageName && TERMUX_PACKAGE_NAME_PATTERN.test(termuxPackageName);
250
+ const prefix = platform === "darwin"
251
+ ? "/private/tmp/piab"
252
+ : termuxAppRoot
253
+ ? `/data/data/${termuxPackageName}/piab`
254
+ : DEFAULT_AGENT_BROWSER_SOCKET_DIR_PREFIX;
255
+ return `${prefix}${!termuxAppRoot && typeof uid === "number" ? `-${uid}` : ""}`;
256
+ }
257
+ export function isTrustedAndroidAppDataRoot(path, metadata, uid, platform = processPlatform) {
258
+ if (platform !== "android" || metadata.uid !== uid || metadata.isSymbolicLink() || !metadata.isDirectory() || (metadata.mode & 0o777) !== 0o700)
259
+ return false;
260
+ const parent = dirname(path);
261
+ return parent === "/data/data" || /^\/data\/user\/\d+$/.test(parent);
262
+ }
263
+ export function isTrustedSocketDirAncestor(metadata, uid, platform = processPlatform) {
264
+ if (metadata.isSymbolicLink())
265
+ return metadata.uid === 0;
266
+ if (!metadata.isDirectory())
267
+ return false;
268
+ const mode = metadata.mode & 0o7777;
269
+ if (platform === "android" && uid !== 0 && metadata.uid === uid && metadata.gid === uid)
270
+ return (mode & 0o002) === 0;
271
+ if (metadata.uid === uid && uid !== 0)
272
+ return (mode & 0o022) === 0;
273
+ return metadata.uid === 0 && ((mode & 0o022) === 0 || (mode & 0o1000) !== 0);
225
274
  }
226
275
  async function hasTrustedSocketDirAncestry(socketDir, uid) {
227
276
  for (let current = dirname(socketDir);;) {
228
277
  const metadata = await lstat(current);
229
- if (metadata.isSymbolicLink()) {
230
- if (metadata.uid !== 0)
231
- return false;
232
- }
233
- else if (!metadata.isDirectory()) {
278
+ if (isTrustedAndroidAppDataRoot(current, metadata, uid))
279
+ return true;
280
+ if (!isTrustedSocketDirAncestor(metadata, uid))
234
281
  return false;
235
- }
236
- if (!metadata.isSymbolicLink()) {
237
- const mode = metadata.mode & 0o7777;
238
- if (metadata.uid === uid) {
239
- if ((mode & 0o022) !== 0)
240
- return false;
241
- }
242
- else if (metadata.uid !== 0 || ((mode & 0o022) !== 0 && (mode & 0o1000) === 0)) {
243
- return false;
244
- }
245
- }
246
282
  const parent = dirname(current);
247
283
  if (parent === current)
248
284
  return true;
@@ -273,28 +309,64 @@ async function socketDirEntriesAreOwned(socketDir, uid, visited = { count: 0 })
273
309
  }
274
310
  return true;
275
311
  }
276
- export async function ensureAgentBrowserSocketDir(socketDir, uid = typeof process.getuid === "function" ? process.getuid() : undefined) {
277
- if (!isAbsolute(socketDir) || typeof uid !== "number")
278
- return false;
312
+ export async function getAgentBrowserSocketDirValidationError(socketDir, uid = typeof process.getuid === "function" ? process.getuid() : undefined) {
313
+ if (!isAbsolute(socketDir))
314
+ return "the path is not absolute";
315
+ if (typeof uid !== "number")
316
+ return "POSIX ownership metadata is unavailable";
279
317
  try {
280
318
  if (!await hasTrustedSocketDirAncestry(socketDir, uid))
281
- return false;
319
+ return "an ancestor is writable, foreign-owned, a non-directory, or an untrusted symlink";
282
320
  try {
283
321
  await mkdir(socketDir, { mode: 0o700 });
284
322
  }
285
323
  catch (error) {
286
324
  if (error.code !== "EEXIST")
287
- return false;
325
+ return `the directory could not be created (${error.code ?? "unknown error"})`;
288
326
  }
289
327
  const metadata = await lstat(socketDir);
290
- if (!metadata.isDirectory() || metadata.isSymbolicLink() || metadata.uid !== uid || (metadata.mode & 0o777) !== 0o700)
291
- return false;
292
- return await hasTrustedSocketDirAncestry(socketDir, uid) && await socketDirEntriesAreOwned(socketDir, uid);
328
+ if (!metadata.isDirectory())
329
+ return "the path is not a directory";
330
+ if (metadata.isSymbolicLink())
331
+ return "the directory is a symlink";
332
+ if (metadata.uid !== uid)
333
+ return `the directory is owned by uid ${metadata.uid}, not uid ${uid}`;
334
+ if ((metadata.mode & 0o777) !== 0o700)
335
+ return `the directory mode is ${(metadata.mode & 0o777).toString(8)}, not 700`;
336
+ if (!await hasTrustedSocketDirAncestry(socketDir, uid))
337
+ return "an ancestor became untrusted during validation";
338
+ if (!await socketDirEntriesAreOwned(socketDir, uid))
339
+ return "the directory contains a foreign-owned, symlink, special, or excessively deep entry";
340
+ return undefined;
293
341
  }
294
- catch {
295
- return false;
342
+ catch (error) {
343
+ return `the directory could not be inspected (${error.code ?? "unknown error"})`;
296
344
  }
297
345
  }
346
+ export async function ensureAgentBrowserSocketDir(socketDir, uid = typeof process.getuid === "function" ? process.getuid() : undefined) {
347
+ return await getAgentBrowserSocketDirValidationError(socketDir, uid) === undefined;
348
+ }
349
+ export function getAgentBrowserSocketPathValidationError(options) {
350
+ if ((options.platform ?? processPlatform) === "win32")
351
+ return undefined;
352
+ const descriptor = parseArgvDescriptor(options.args);
353
+ const { command } = descriptor.commandInfo;
354
+ // Preflight commands that can start or navigate a browser. Follow-up reads and
355
+ // cleanup may target a daemon created by an earlier wrapper version, so let
356
+ // upstream inspect those identities instead of rejecting them from path math.
357
+ if (!command || !["batch", "connect", "goto", "navigate", "open", "visit"].includes(command))
358
+ return undefined;
359
+ const sessionName = extractExplicitSessionName(options.args);
360
+ if (!sessionName)
361
+ return undefined;
362
+ const namespace = resolveAgentBrowserNamespace(options.args, options.env?.AGENT_BROWSER_NAMESPACE);
363
+ const socketRoot = namespace ? join(options.socketDir, "namespaces", namespace, "run") : options.socketDir;
364
+ const socketPath = join(socketRoot, `${sessionName}.sock`);
365
+ const pathBytes = Buffer.byteLength(socketPath);
366
+ if (pathBytes <= 103)
367
+ return undefined;
368
+ return `Agent-browser Unix socket path would be ${pathBytes} bytes (max 103) for session ${JSON.stringify(sessionName)} under ${JSON.stringify(options.socketDir)}. Set PI_AGENT_BROWSER_SOCKET_DIR to a shorter absolute private directory such as /tmp/piab-<uid> with mode 0700; retrying sessionMode \"fresh\" cannot shorten this configured root.`;
369
+ }
298
370
  export function buildAgentBrowserProcessEnv(baseEnv = processEnv, overrides = undefined) {
299
371
  const childEnv = {};
300
372
  for (const [name, value] of Object.entries(baseEnv)) {
@@ -325,6 +397,7 @@ function getManagedPreSpawnPolicyError(options, effectiveEnv, allowManagedSessio
325
397
  currentPageUrl,
326
398
  cwd: options.cwd,
327
399
  env: effectiveEnv ?? options.env,
400
+ managedSessionRestoreKey: getOwnedManagedSessionRestoreKey(),
328
401
  pageUrlUnknown,
329
402
  parentEnv: effectiveEnv ? {} : options.parentEnv ?? processEnv,
330
403
  stdin: options.stdin,
@@ -348,11 +421,24 @@ export async function runAgentBrowserProcess(options) {
348
421
  if (signal?.aborted) {
349
422
  return { aborted: true, agentBrowserStarted: false, exitCode: 1, stderr: "", stdout: "", timedOut: false };
350
423
  }
424
+ if (invocationMayNavigateToLocalFile(args, stdin) && (!ownedManagedSession || preserveAttachedBrowserSession)) {
425
+ return {
426
+ aborted: false,
427
+ agentBrowserStarted: false,
428
+ exitCode: 1,
429
+ spawnError: new Error(UNTRUSTED_LOCAL_FILE_SESSION_MESSAGE),
430
+ stderr: "",
431
+ stdout: "",
432
+ timedOut: false,
433
+ };
434
+ }
435
+ const parentEnv = getAgentBrowserProcessEnvironment();
351
436
  const managedSessionRestoreOptions = {
352
437
  args,
353
438
  cwd,
354
439
  env,
355
440
  ownedManagedSession,
441
+ parentEnv,
356
442
  restoreState: managedSessionRestoreState,
357
443
  stdin,
358
444
  };
@@ -391,6 +477,7 @@ export async function runAgentBrowserProcess(options) {
391
477
  ...managedSessionRestoreConfigEnv,
392
478
  ...getManagedSessionRestoreProtectedEnv(managedSessionRestoreOptions, managedSessionRestoreEnv),
393
479
  ...getOwnedManagedSessionNamespaceEnv(managedSessionRestoreOptions),
480
+ ...getWindowsExplicitDefaultNamespaceEnv(args, parentEnv.AGENT_BROWSER_NAMESPACE),
394
481
  ...ownedManagedSessionCompatibilityEnv,
395
482
  AGENT_BROWSER_ALLOW_FILE_ACCESS: undefined,
396
483
  [AGENT_BROWSER_ARGS_ENV]: undefined,
@@ -399,18 +486,21 @@ export async function runAgentBrowserProcess(options) {
399
486
  let effectiveEnv = explicitSocketDir === undefined ? { ...processOverrides, [AGENT_BROWSER_SOCKET_DIR_ENV]: undefined } : processOverrides;
400
487
  if (ownedManagedSessionClose)
401
488
  effectiveEnv = { ...effectiveEnv, AGENT_BROWSER_RESTORE: undefined };
402
- const requestedSocketDir = explicitSocketDir ?? getAgentBrowserSocketDir();
489
+ const requestedSocketDir = explicitSocketDir ?? parentEnv[PI_AGENT_BROWSER_SOCKET_DIR_ENV] ?? getAgentBrowserSocketDir();
403
490
  if (requestedSocketDir !== undefined) {
404
- const socketDirIsSecure = requestedSocketDir.length > 0 && await ensureAgentBrowserSocketDir(requestedSocketDir);
491
+ const socketDirError = requestedSocketDir.length > 0
492
+ ? await getAgentBrowserSocketDirValidationError(requestedSocketDir)
493
+ : "the configured path is empty";
405
494
  if (signal?.aborted) {
406
495
  return { aborted: true, agentBrowserStarted: false, exitCode: 1, stderr: "", stdout: "", timedOut: false };
407
496
  }
408
- if (!socketDirIsSecure) {
497
+ const socketPathError = socketDirError ? undefined : getAgentBrowserSocketPathValidationError({ args, env: effectiveEnv, socketDir: requestedSocketDir });
498
+ if (socketDirError || socketPathError) {
409
499
  return {
410
500
  aborted: false,
411
501
  agentBrowserStarted: false,
412
502
  exitCode: 1,
413
- spawnError: new Error("Agent-browser socket storage must be an absolute, non-symlink directory owned by the current user with mode 0700."),
503
+ spawnError: new Error(socketPathError ?? `Agent-browser socket storage ${JSON.stringify(requestedSocketDir)} is unusable: ${socketDirError}. Use an absolute directory owned by the current uid with mode 0700 and remove foreign, symlink, or special entries.`),
414
504
  stderr: "",
415
505
  stdout: "",
416
506
  timedOut: false,
@@ -521,7 +611,7 @@ export async function runAgentBrowserProcess(options) {
521
611
  });
522
612
  });
523
613
  };
524
- const childEnv = buildAgentBrowserProcessEnv(processEnv, effectiveEnv);
614
+ const childEnv = buildAgentBrowserProcessEnv(parentEnv, effectiveEnv);
525
615
  const spawnPolicyError = getManagedPreSpawnPolicyError(managedSessionRestoreOptions, childEnv, allowManagedSessionTarget, managedStateCurrentPageUrl, managedStatePageUrlUnknown, trustedFirstBatchTabSelection, managedSessionRestoreConfigEnv.AGENT_BROWSER_CONFIG !== undefined);
526
616
  if (spawnPolicyError) {
527
617
  resolve({ aborted: false, agentBrowserStarted: false, exitCode: 1, spawnError: new Error(spawnPolicyError), stderr: "", stdout: "", timedOut: false });
@@ -0,0 +1,183 @@
1
+ import { extractUpstreamCommandTokens } from "./argv-descriptor.js";
2
+ import { getAgentBrowserSessionIdentityKey, isAgentBrowserSessionIdentityKeyInNamespace } from "./argv-grammar.js";
3
+ import { batchHasSuccessfulCloseAll, getSuccessfulBatchCloseLifecycle } from "./batch-lifecycle.js";
4
+ import { isCloseAllCommand, isCloseCommand } from "./command-taxonomy.js";
5
+ import { isRecord } from "./parsing.js";
6
+ import { isPendingRecordingArtifact, isPendingRecordingCommand, isSessionArtifactManifest } from "./results/artifact-manifest.js";
7
+ export const RECORDING_RESERVATION_ENTRY_TYPE = "agent-browser-recording-reservation";
8
+ function getReservationKey(reservation) {
9
+ return getAgentBrowserSessionIdentityKey(reservation.sessionName, reservation.namespace);
10
+ }
11
+ function getArtifactReservation(artifact) {
12
+ if (!artifact.session || artifact.command !== "record" || artifact.kind !== "video")
13
+ return undefined;
14
+ return {
15
+ absolutePath: artifact.absolutePath,
16
+ cwd: artifact.cwd ?? process.cwd(),
17
+ namespace: artifact.namespace,
18
+ path: artifact.path,
19
+ sessionName: artifact.session,
20
+ };
21
+ }
22
+ export function applyRecordingArtifactsToReservations(reservations, artifacts) {
23
+ const pendingBySession = new Map();
24
+ const terminalBySession = new Map();
25
+ for (const artifact of artifacts) {
26
+ const reservation = getArtifactReservation(artifact);
27
+ if (!reservation)
28
+ continue;
29
+ const key = getReservationKey(reservation);
30
+ if (isPendingRecordingArtifact(artifact))
31
+ pendingBySession.set(key, reservation);
32
+ else
33
+ terminalBySession.set(key, reservation);
34
+ }
35
+ const transitions = [];
36
+ for (const key of terminalBySession.keys()) {
37
+ if (pendingBySession.has(key))
38
+ continue;
39
+ const existing = reservations.get(key);
40
+ if (!existing)
41
+ continue;
42
+ reservations.delete(key);
43
+ transitions.push({ reservation: existing, state: "closed" });
44
+ }
45
+ for (const [key, pending] of pendingBySession) {
46
+ const existing = reservations.get(key);
47
+ reservations.set(key, pending);
48
+ if (!existing || existing.absolutePath !== pending.absolutePath || existing.cwd !== pending.cwd) {
49
+ transitions.push({ reservation: pending, state: "active" });
50
+ }
51
+ }
52
+ return transitions;
53
+ }
54
+ export function retireRecordingReservation(reservations, sessionName, namespace) {
55
+ const key = getAgentBrowserSessionIdentityKey(sessionName, namespace);
56
+ const reservation = reservations.get(key);
57
+ if (reservation)
58
+ reservations.delete(key);
59
+ return reservation;
60
+ }
61
+ export function appendRecordingReservationTransition(pi, transition) {
62
+ const { reservation, state } = transition;
63
+ pi.appendEntry(RECORDING_RESERVATION_ENTRY_TYPE, {
64
+ absolutePath: state === "active" ? reservation.absolutePath : undefined,
65
+ cwd: state === "active" ? reservation.cwd : undefined,
66
+ namespace: reservation.namespace,
67
+ path: state === "active" ? reservation.path : undefined,
68
+ sessionName: reservation.sessionName,
69
+ state,
70
+ version: 1,
71
+ });
72
+ }
73
+ function parseReservationTransition(data) {
74
+ if (!isRecord(data) || data.version !== 1 || (data.state !== "active" && data.state !== "closed"))
75
+ return undefined;
76
+ if (typeof data.sessionName !== "string" || data.sessionName.length === 0)
77
+ return undefined;
78
+ if (data.namespace !== undefined && typeof data.namespace !== "string")
79
+ return undefined;
80
+ if (data.state === "closed") {
81
+ return {
82
+ reservation: { absolutePath: "", cwd: "", namespace: data.namespace, path: "", sessionName: data.sessionName },
83
+ state: "closed",
84
+ };
85
+ }
86
+ if (typeof data.absolutePath !== "string" || typeof data.cwd !== "string" || typeof data.path !== "string")
87
+ return undefined;
88
+ return {
89
+ reservation: {
90
+ absolutePath: data.absolutePath,
91
+ cwd: data.cwd,
92
+ namespace: data.namespace,
93
+ path: data.path,
94
+ sessionName: data.sessionName,
95
+ },
96
+ state: "active",
97
+ };
98
+ }
99
+ export function restoreRecordingReservationStateFromBranch(branch) {
100
+ const reservations = new Map();
101
+ const terminal = new Map();
102
+ for (const entry of branch) {
103
+ if (!isRecord(entry))
104
+ continue;
105
+ if (entry.type === "custom" && entry.customType === RECORDING_RESERVATION_ENTRY_TYPE) {
106
+ const transition = parseReservationTransition(entry.data);
107
+ if (!transition)
108
+ continue;
109
+ const key = getReservationKey(transition.reservation);
110
+ if (transition.state === "active") {
111
+ reservations.set(key, transition.reservation);
112
+ terminal.delete(key);
113
+ }
114
+ else {
115
+ reservations.delete(key);
116
+ terminal.set(key, transition.reservation);
117
+ }
118
+ continue;
119
+ }
120
+ if (entry.type !== "message")
121
+ continue;
122
+ const message = isRecord(entry.message) ? entry.message : undefined;
123
+ const details = message?.toolName === "agent_browser" && isRecord(message.details) ? message.details : undefined;
124
+ if (!details)
125
+ continue;
126
+ if (isSessionArtifactManifest(details.artifactManifest)) {
127
+ const artifacts = [...details.artifactManifest.entries]
128
+ .sort((left, right) => left.createdAtMs - right.createdAtMs
129
+ || Number(isPendingRecordingCommand(left.command, left.subcommand, left.kind)) - Number(isPendingRecordingCommand(right.command, right.subcommand, right.kind))
130
+ || left.path.localeCompare(right.path))
131
+ .filter((manifestEntry) => manifestEntry.command === "record" && manifestEntry.kind === "video" && manifestEntry.session)
132
+ .map((manifestEntry) => ({
133
+ absolutePath: manifestEntry.absolutePath ?? manifestEntry.path,
134
+ command: manifestEntry.command,
135
+ cwd: manifestEntry.cwd,
136
+ kind: "video",
137
+ namespace: manifestEntry.namespace,
138
+ path: manifestEntry.path,
139
+ session: manifestEntry.session,
140
+ status: isPendingRecordingCommand(manifestEntry.command, manifestEntry.subcommand, "video") ? "pending" : "saved",
141
+ subcommand: manifestEntry.subcommand,
142
+ }));
143
+ for (const artifact of artifacts) {
144
+ const reservation = getArtifactReservation(artifact);
145
+ if (!reservation)
146
+ continue;
147
+ const key = getReservationKey(reservation);
148
+ applyRecordingArtifactsToReservations(reservations, [artifact]);
149
+ if (isPendingRecordingArtifact(artifact))
150
+ terminal.delete(key);
151
+ else
152
+ terminal.set(key, reservation);
153
+ }
154
+ }
155
+ const messageSucceeded = typeof message?.isError === "boolean"
156
+ ? !message.isError
157
+ : typeof details.exitCode !== "number" || details.exitCode === 0;
158
+ const args = Array.isArray(details.args) && details.args.every((arg) => typeof arg === "string") ? details.args : [];
159
+ const namespace = typeof details.namespace === "string" ? details.namespace : undefined;
160
+ const closeAllApplied = details.closeAllApplied === true
161
+ || (messageSucceeded && isCloseAllCommand(extractUpstreamCommandTokens(args)))
162
+ || batchHasSuccessfulCloseAll(details.batchSteps);
163
+ if (closeAllApplied) {
164
+ for (const [key, reservation] of reservations) {
165
+ if (!isAgentBrowserSessionIdentityKeyInNamespace(key, namespace))
166
+ continue;
167
+ terminal.set(key, reservation);
168
+ reservations.delete(key);
169
+ }
170
+ continue;
171
+ }
172
+ const directCloseSucceeded = messageSucceeded && isCloseCommand(typeof details.command === "string" ? details.command : undefined);
173
+ const batchRecordingClosed = getSuccessfulBatchCloseLifecycle(details.batchSteps)?.recordingClosedAfterBatch === true;
174
+ if ((directCloseSucceeded || batchRecordingClosed) && typeof details.sessionName === "string") {
175
+ const key = getAgentBrowserSessionIdentityKey(details.sessionName, namespace);
176
+ const reservation = reservations.get(key);
177
+ if (reservation)
178
+ terminal.set(key, reservation);
179
+ reservations.delete(key);
180
+ }
181
+ }
182
+ return { active: reservations, terminal };
183
+ }
@@ -1,6 +1,6 @@
1
1
  import { isOpenNavigationCommand, isPageMutationCommand } from "../command-taxonomy.js";
2
2
  import { isPendingRecordingArtifact } from "./artifact-manifest.js";
3
- import { buildNextToolAction } from "./next-actions.js";
3
+ import { applySessionToNextActions, buildNextToolAction } from "./next-actions.js";
4
4
  import { AGENT_BROWSER_RECOVERY_NEXT_ACTION_IDS, buildRecoveryNextActions, } from "./recovery-actions.js";
5
5
  function buildArtifactAction(path) {
6
6
  return {
@@ -120,9 +120,8 @@ export function buildAgentBrowserNextActions(options) {
120
120
  actions.push(buildArtifactAction(options.savedFilePath));
121
121
  }
122
122
  for (const artifact of artifacts) {
123
- if (isPendingRecordingArtifact(artifact)) {
123
+ if (isPendingRecordingArtifact(artifact))
124
124
  continue;
125
- }
126
125
  if (artifact.exists === false) {
127
126
  if (artifact.kind === "download") {
128
127
  actions.push(buildNextToolAction({
@@ -146,7 +145,7 @@ export function buildAgentBrowserNextActions(options) {
146
145
  switch (options.failureCategory) {
147
146
  case "artifact-missing":
148
147
  for (const artifact of options.artifacts ?? []) {
149
- if (isPendingRecordingArtifact(artifact) || artifact.exists !== false)
148
+ if (isPendingRecordingArtifact(artifact) || (artifact.exists !== false && artifact.status !== "stale"))
150
149
  continue;
151
150
  if (artifact.kind === "download") {
152
151
  actions.push(buildNextToolAction({
@@ -196,6 +195,56 @@ export function buildAgentBrowserNextActions(options) {
196
195
  }));
197
196
  }
198
197
  break;
198
+ case "timeout":
199
+ {
200
+ const textAssertion = options.command === "wait" && options.args?.includes("--text") === true;
201
+ const urlAssertion = options.command === "wait" && options.args?.includes("--url") === true;
202
+ actions.push(buildNextToolAction({
203
+ args: ["snapshot", "-i"],
204
+ id: textAssertion ? "inspect-after-text-assertion-failure" : "inspect-after-timeout",
205
+ reason: textAssertion
206
+ ? "Inspect the current page after the text assertion failed before concluding the expected text is absent."
207
+ : options.command === "wait"
208
+ ? "Inspect the current page after the wait condition timed out before retrying with a different selector or timeout."
209
+ : "Inspect the current page after the timed-out browser operation.",
210
+ safety: textAssertion
211
+ ? "Read-only snapshot; use current refs or visible text from this page before retrying the assertion."
212
+ : "Read-only snapshot; do not assume the timed-out interaction completed.",
213
+ }));
214
+ if (urlAssertion) {
215
+ actions.push(buildNextToolAction({
216
+ args: ["open", "about:blank"],
217
+ id: "fresh-session-after-url-wait-timeout",
218
+ reason: "If a preceding click or form submit reported success but the page never navigated, upstream click dispatch may have silently missed (observed with agent-browser 0.34 after many spaced commands); replace about:blank with the target URL and replay the flow as one batch in a fresh session instead of retrying the wait.",
219
+ safety: "Abandons the current browser session; capture page evidence with the inspect action first, and only abandon when the wait target is not simply wrong or slow.",
220
+ sessionMode: "fresh",
221
+ }));
222
+ }
223
+ }
224
+ break;
225
+ case "upstream-error":
226
+ if (isOpenNavigationCommand(options.command)) {
227
+ actions.push(buildNextToolAction({
228
+ args: ["get", "url"],
229
+ id: "inspect-page-after-navigation-error",
230
+ reason: "Check which page, if any, remains active after the navigation or network error.",
231
+ safety: "Read-only URL inspection; verify connectivity and the target URL before retrying navigation.",
232
+ }));
233
+ }
234
+ break;
235
+ case "tab-gone":
236
+ actions.push(buildNextToolAction({
237
+ args: ["tab", "list"],
238
+ id: AGENT_BROWSER_RECOVERY_NEXT_ACTION_IDS.tabGoneListTabs,
239
+ reason: "The pinned bound tab is gone; inspect remaining tabs before acting on a neighbor.",
240
+ safety: "Read-only. Prefer a listed tab id, label, or CDP targetId, or open a new tab to rebind.",
241
+ }), buildNextToolAction({
242
+ args: ["tab", "new"],
243
+ id: AGENT_BROWSER_RECOVERY_NEXT_ACTION_IDS.tabGoneNewTab,
244
+ reason: "Bind a fresh tab after tab_gone instead of continuing on another session's page.",
245
+ safety: "Opens a new tab in this session and rebinds the pin; pass a URL if you know the intended page.",
246
+ }));
247
+ break;
199
248
  case "tab-drift":
200
249
  if (options.recovery?.kind === "about-blank" || options.recovery?.kind === "tab-drift") {
201
250
  break;
@@ -209,5 +258,13 @@ export function buildAgentBrowserNextActions(options) {
209
258
  break;
210
259
  }
211
260
  }
212
- return actions.length > 0 ? actions : undefined;
261
+ if ((options.artifacts ?? []).some(isPendingRecordingArtifact)) {
262
+ actions.push(buildNextToolAction({
263
+ args: ["record", "stop"],
264
+ id: "stop-pending-recording",
265
+ reason: "Stop the active recording so the requested video can be finalized and verified on disk.",
266
+ safety: "The file remains pending until record stop succeeds; verify details.artifactVerification afterward.",
267
+ }));
268
+ }
269
+ return applySessionToNextActions(actions.length > 0 ? actions : undefined, options.sessionName);
213
270
  }