pi-agent-browser-native 0.2.77 → 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 (118) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +69 -35
  3. package/dist/extensions/agent-browser/index.js +911 -121
  4. package/dist/extensions/agent-browser/lib/argv-descriptor.js +35 -8
  5. package/dist/extensions/agent-browser/lib/argv-grammar.js +48 -7
  6. package/dist/extensions/agent-browser/lib/batch-lifecycle.js +71 -0
  7. package/dist/extensions/agent-browser/lib/command-policy.js +1 -6
  8. package/dist/extensions/agent-browser/lib/command-taxonomy.js +35 -8
  9. package/dist/extensions/agent-browser/lib/config-policy.js +0 -5
  10. package/dist/extensions/agent-browser/lib/config.js +0 -6
  11. package/dist/extensions/agent-browser/lib/electron/cdp.js +6 -7
  12. package/dist/extensions/agent-browser/lib/electron/cleanup.js +0 -7
  13. package/dist/extensions/agent-browser/lib/electron/discovery.js +0 -7
  14. package/dist/extensions/agent-browser/lib/electron/launch.js +0 -7
  15. package/dist/extensions/agent-browser/lib/input-modes/electron.js +0 -5
  16. package/dist/extensions/agent-browser/lib/input-modes/job.js +61 -9
  17. package/dist/extensions/agent-browser/lib/input-modes/lookups.js +2 -7
  18. package/dist/extensions/agent-browser/lib/input-modes/params.js +23 -29
  19. package/dist/extensions/agent-browser/lib/input-modes/script.js +462 -0
  20. package/dist/extensions/agent-browser/lib/input-modes/semantic-action.js +51 -17
  21. package/dist/extensions/agent-browser/lib/input-modes/shared.js +0 -5
  22. package/dist/extensions/agent-browser/lib/input-modes/types.js +0 -5
  23. package/dist/extensions/agent-browser/lib/json-schema.js +0 -5
  24. package/dist/extensions/agent-browser/lib/launch-scoped-flags.js +12 -5
  25. package/dist/extensions/agent-browser/lib/managed-session-capabilities.js +0 -2
  26. package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +3 -4
  27. package/dist/extensions/agent-browser/lib/managed-session-restore.js +55 -48
  28. package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +2 -9
  29. package/dist/extensions/agent-browser/lib/managed-session-state-policy.js +48 -36
  30. package/dist/extensions/agent-browser/lib/managed-session-storage.js +50 -29
  31. package/dist/extensions/agent-browser/lib/navigation-policy.js +0 -5
  32. package/dist/extensions/agent-browser/lib/orchestration/batch-stdin.js +26 -5
  33. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +110 -30
  34. package/dist/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.js +2 -1
  35. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +27 -26
  36. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +58 -33
  37. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +2 -1
  38. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +22 -14
  39. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/direct-anchor-download.js +1 -1
  40. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/network-page-filter.js +1 -1
  41. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/scroll-shims.js +1 -1
  42. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/snapshot-filter.js +1 -1
  43. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +148 -34
  44. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +225 -61
  45. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +74 -30
  46. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +47 -26
  47. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +40 -19
  48. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +42 -1
  49. package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +299 -0
  50. package/dist/extensions/agent-browser/lib/parsing.js +0 -7
  51. package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +36 -11
  52. package/dist/extensions/agent-browser/lib/playbook.js +20 -24
  53. package/dist/extensions/agent-browser/lib/process-environment.js +14 -0
  54. package/dist/extensions/agent-browser/lib/process-identity.js +4 -9
  55. package/dist/extensions/agent-browser/lib/process.js +131 -48
  56. package/dist/extensions/agent-browser/lib/prompt-policy.js +171 -27
  57. package/dist/extensions/agent-browser/lib/recording-reservations.js +183 -0
  58. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +63 -13
  59. package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +68 -11
  60. package/dist/extensions/agent-browser/lib/results/categories.js +7 -9
  61. package/dist/extensions/agent-browser/lib/results/confirmation.js +0 -7
  62. package/dist/extensions/agent-browser/lib/results/contracts.js +0 -7
  63. package/dist/extensions/agent-browser/lib/results/editable-ref-evidence.js +0 -7
  64. package/dist/extensions/agent-browser/lib/results/envelope.js +0 -7
  65. package/dist/extensions/agent-browser/lib/results/network.js +0 -7
  66. package/dist/extensions/agent-browser/lib/results/next-actions.js +19 -35
  67. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +85 -44
  68. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +66 -11
  69. package/dist/extensions/agent-browser/lib/results/presentation/browser-profile-recovery.js +0 -5
  70. package/dist/extensions/agent-browser/lib/results/presentation/common.js +18 -5
  71. package/dist/extensions/agent-browser/lib/results/presentation/content.js +0 -5
  72. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +7 -6
  73. package/dist/extensions/agent-browser/lib/results/presentation/errors.js +2 -1
  74. package/dist/extensions/agent-browser/lib/results/presentation/large-output.js +0 -5
  75. package/dist/extensions/agent-browser/lib/results/presentation/managed-list-filter.js +0 -5
  76. package/dist/extensions/agent-browser/lib/results/presentation/navigation.js +26 -16
  77. package/dist/extensions/agent-browser/lib/results/presentation/registry.js +58 -13
  78. package/dist/extensions/agent-browser/lib/results/presentation/semantic-action.js +1 -15
  79. package/dist/extensions/agent-browser/lib/results/presentation/skills.js +0 -5
  80. package/dist/extensions/agent-browser/lib/results/presentation.js +6 -8
  81. package/dist/extensions/agent-browser/lib/results/recovery-actions.js +2 -7
  82. package/dist/extensions/agent-browser/lib/results/recovery-next-actions.js +0 -7
  83. package/dist/extensions/agent-browser/lib/results/selector-recovery.js +51 -15
  84. package/dist/extensions/agent-browser/lib/results/snapshot-high-value-controls.js +13 -14
  85. package/dist/extensions/agent-browser/lib/results/snapshot-refs.js +0 -7
  86. package/dist/extensions/agent-browser/lib/results/snapshot-segments.js +0 -7
  87. package/dist/extensions/agent-browser/lib/results/snapshot-spill.js +0 -7
  88. package/dist/extensions/agent-browser/lib/results/snapshot.js +0 -7
  89. package/dist/extensions/agent-browser/lib/results/text.js +0 -7
  90. package/dist/extensions/agent-browser/lib/runtime.js +119 -52
  91. package/dist/extensions/agent-browser/lib/session-page-state.js +62 -17
  92. package/dist/extensions/agent-browser/lib/string-enum-schema.js +0 -5
  93. package/dist/extensions/agent-browser/lib/temp.js +0 -7
  94. package/dist/extensions/agent-browser/lib/upstream-version.js +14 -0
  95. package/dist/extensions/agent-browser/lib/web-search.js +0 -5
  96. package/dist/extensions/agent-browser/script-worker.js +169 -0
  97. package/dist/scripts/agent-browser-target.mjs +3 -0
  98. package/docs/ARCHITECTURE.md +50 -31
  99. package/docs/COMMAND_REFERENCE.md +97 -41
  100. package/docs/ELECTRON.md +3 -19
  101. package/docs/RELEASE.md +8 -20
  102. package/docs/REQUIREMENTS.md +8 -6
  103. package/docs/SUPPORT_MATRIX.md +32 -25
  104. package/docs/TOOL_CONTRACT.md +107 -64
  105. package/docs/platform-smoke.md +1 -1
  106. package/package.json +7 -6
  107. package/platform-smoke.config.mjs +2 -2
  108. package/scripts/agent-browser-capability-baseline.mjs +24 -6
  109. package/scripts/agent-browser-target.mjs +3 -0
  110. package/scripts/build.mjs +41 -0
  111. package/scripts/doctor.mjs +8 -7
  112. package/scripts/platform-smoke/browser-dogfood-windows.ps1 +9 -3
  113. package/scripts/platform-smoke/targets.mjs +12 -6
  114. package/dist/extensions/agent-browser/lib/electron/text.js +0 -13
  115. package/dist/extensions/agent-browser/lib/input-modes.js +0 -10
  116. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-artifacts.js +0 -5
  117. package/dist/extensions/agent-browser/lib/results/artifact-state.js +0 -13
  118. package/dist/extensions/agent-browser/lib/results.js +0 -14
@@ -1,8 +1,3 @@
1
- /**
2
- * Purpose: Resolve and secure the filesystem identity used by automatic managed-session restore.
3
- * Responsibilities: Bind restore keys to one Git checkout generation, validate trusted HOME ancestry, and prepare owner-only upstream state directories.
4
- * Scope: Filesystem policy only; argv/session ownership and snapshot retention live in sibling modules.
5
- */
6
1
  import { createHash, randomUUID } from "node:crypto";
7
2
  import { linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
8
3
  import { homedir } from "node:os";
@@ -10,6 +5,7 @@ import { dirname, isAbsolute, join, parse, resolve, win32 } from "node:path";
10
5
  import { canonicalizeAgentBrowserNamespace } from "./argv-grammar.js";
11
6
  export { isManagedSessionRestoreKey } from "./managed-session-capabilities.js";
12
7
  const MANAGED_SESSION_NAME_PREFIX = "piab-r2-";
8
+ const MANAGED_SESSION_FRESH_SUFFIX_PATTERN = /-fresh-[a-f\d]{10}$/i;
13
9
  const MANAGED_SESSION_RESTORE_KEY_HASH_LENGTH = 32;
14
10
  const PROJECT_GENERATION_MARKER_NAME = "pi-agent-browser-project-generation-v1.json";
15
11
  const PROJECT_GENERATION_MARKER_MAX_BYTES = 1_024;
@@ -20,7 +16,7 @@ function isAbsoluteHome(path, platform) {
20
16
  function currentUid() {
21
17
  return typeof process.getuid === "function" ? process.getuid() : undefined;
22
18
  }
23
- function isTrustedPosixDirectory(path, requireCurrentOwner) {
19
+ function isTrustedPosixDirectory(path, requireCurrentOwner, platform = process.platform) {
24
20
  const uid = currentUid();
25
21
  if (uid === undefined)
26
22
  return false;
@@ -37,11 +33,13 @@ function isTrustedPosixDirectory(path, requireCurrentOwner) {
37
33
  }
38
34
  if (entry.isSymbolicLink() || !entry.isDirectory())
39
35
  return false;
40
- if (entry.uid !== 0 && entry.uid !== uid)
36
+ const androidSystemAncestor = platform === "android" && (cursor === "/data" || cursor === "/data/data") && entry.uid === 1000 && (entry.mode & 0o002) === 0;
37
+ const androidAppDirectory = platform === "android" && entry.uid === uid && entry.gid === uid && (entry.mode & 0o002) === 0;
38
+ if (!androidSystemAncestor && entry.uid !== 0 && entry.uid !== uid)
41
39
  return false;
42
40
  const writableByOthers = (entry.mode & 0o022) !== 0;
43
41
  const rootOwnedStickyDirectory = entry.uid === 0 && (entry.mode & 0o1000) !== 0;
44
- if (writableByOthers && !rootOwnedStickyDirectory)
42
+ if (writableByOthers && !rootOwnedStickyDirectory && !androidSystemAncestor && !androidAppDirectory)
45
43
  return false;
46
44
  }
47
45
  try {
@@ -61,7 +59,7 @@ export function resolveManagedSessionRestoreHome(parentEnv, platform = process.p
61
59
  return candidate;
62
60
  try {
63
61
  const canonical = realpathSync(candidate);
64
- return isTrustedPosixDirectory(canonical, true) ? canonical : undefined;
62
+ return isTrustedPosixDirectory(canonical, true, platform) ? canonical : undefined;
65
63
  }
66
64
  catch {
67
65
  return undefined;
@@ -143,12 +141,15 @@ function readProjectGenerationMarker(path, platform) {
143
141
  return undefined;
144
142
  }
145
143
  }
146
- function getDirectoryFilesystemIdentity(path) {
144
+ function getDirectoryFilesystemIdentity(path, platform) {
147
145
  try {
148
146
  const entry = statSync(path, { bigint: true });
149
- return entry.isDirectory() && entry.dev > 0n && entry.ino > 0n && entry.birthtimeNs > 0n
150
- ? `${entry.dev}:${entry.ino}:${entry.birthtimeNs}`
151
- : undefined;
147
+ if (!entry.isDirectory() || entry.dev <= 0n || entry.ino <= 0n)
148
+ return undefined;
149
+ // ponytail: Android reports mutable ctime as birthtime; use statx birthtime/inode generation when Node exposes either reliably.
150
+ return platform === "android"
151
+ ? `${entry.dev}:${entry.ino}`
152
+ : entry.birthtimeNs > 0n ? `${entry.dev}:${entry.ino}:${entry.birthtimeNs}` : undefined;
152
153
  }
153
154
  catch {
154
155
  return undefined;
@@ -162,13 +163,13 @@ function resolveManagedSessionRestoreProjectCheckout(cwd, platform) {
162
163
  catch {
163
164
  return undefined;
164
165
  }
165
- if (platform !== "win32" && !isTrustedPosixDirectory(canonicalCwd, false))
166
+ if (platform !== "win32" && !isTrustedPosixDirectory(canonicalCwd, false, platform))
166
167
  return undefined;
167
168
  const checkout = resolveGitCheckout(canonicalCwd, platform);
168
169
  if (!checkout)
169
170
  return undefined;
170
- if (platform !== "win32" && (!isTrustedPosixDirectory(checkout.worktreeDirectory, true)
171
- || !isTrustedPosixDirectory(checkout.gitDirectory, true)))
171
+ if (platform !== "win32" && (!isTrustedPosixDirectory(checkout.worktreeDirectory, true, platform)
172
+ || !isTrustedPosixDirectory(checkout.gitDirectory, true, platform)))
172
173
  return undefined;
173
174
  return { canonicalCwd, ...checkout };
174
175
  }
@@ -180,8 +181,8 @@ function resolveProjectGenerationIdentity(cwd, platform = process.platform) {
180
181
  if (!checkout)
181
182
  return undefined;
182
183
  const { canonicalCwd } = checkout;
183
- const gitFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.gitDirectory);
184
- const worktreeFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.worktreeDirectory);
184
+ const gitFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.gitDirectory, platform);
185
+ const worktreeFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.worktreeDirectory, platform);
185
186
  if (!gitFilesystemIdentity || !worktreeFilesystemIdentity)
186
187
  return undefined;
187
188
  const markerPath = join(checkout.gitDirectory, PROJECT_GENERATION_MARKER_NAME);
@@ -197,22 +198,35 @@ function resolveProjectGenerationIdentity(cwd, platform = process.platform) {
197
198
  projectGenerationCache.delete(canonicalCwd);
198
199
  try {
199
200
  if (!marker) {
200
- const candidatePath = `${markerPath}.candidate-${process.pid}-${randomUUID()}`;
201
- try {
202
- writeFileSync(candidatePath, JSON.stringify({ id: randomUUID(), version: 1 }), { encoding: "utf8", flag: "wx", mode: 0o600 });
201
+ const content = JSON.stringify({ id: randomUUID(), version: 1 });
202
+ if (platform === "android") {
203
+ // ponytail: Android denies hard links in app storage; use renameat2(RENAME_NOREPLACE) if Node exposes it.
203
204
  try {
204
- linkSync(candidatePath, markerPath);
205
+ writeFileSync(markerPath, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
205
206
  }
206
207
  catch (error) {
207
208
  if (error.code !== "EEXIST")
208
209
  return undefined;
209
210
  }
210
211
  }
211
- finally {
212
+ else {
213
+ const candidatePath = `${markerPath}.candidate-${process.pid}-${randomUUID()}`;
212
214
  try {
213
- unlinkSync(candidatePath);
215
+ writeFileSync(candidatePath, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
216
+ try {
217
+ linkSync(candidatePath, markerPath);
218
+ }
219
+ catch (error) {
220
+ if (error.code !== "EEXIST")
221
+ return undefined;
222
+ }
223
+ }
224
+ finally {
225
+ try {
226
+ unlinkSync(candidatePath);
227
+ }
228
+ catch { }
214
229
  }
215
- catch { }
216
230
  }
217
231
  marker = readProjectGenerationMarker(markerPath, platform);
218
232
  }
@@ -236,16 +250,23 @@ function resolveProjectGenerationIdentity(cwd, platform = process.platform) {
236
250
  export function hasManagedSessionRestoreProjectIdentity(cwd) {
237
251
  return resolveProjectGenerationIdentity(cwd) !== undefined;
238
252
  }
239
- /** Stable for one checkout generation; deliberately changes when a path is replaced by another checkout. */
240
- export function createManagedSessionRestoreKey(cwd) {
253
+ /** Keep fresh rotations from one Pi transcript in one private upstream restore pool. */
254
+ export function getManagedSessionRestoreScope(sessionName) {
255
+ return sessionName.replace(MANAGED_SESSION_FRESH_SUFFIX_PATTERN, "");
256
+ }
257
+ /** Stable for one Pi transcript and checkout generation; isolated from other concurrent transcripts. */
258
+ export function createManagedSessionRestoreKey(cwd, restoreScope = "", platform = process.platform) {
241
259
  let canonicalCwd = resolve(cwd);
242
260
  try {
243
261
  canonicalCwd = realpathSync(canonicalCwd);
244
262
  }
245
263
  catch { }
246
- const identity = resolveProjectGenerationIdentity(canonicalCwd);
264
+ const identity = resolveProjectGenerationIdentity(canonicalCwd, platform);
247
265
  const material = identity ?? `unavailable:${canonicalCwd}`;
248
- const digest = createHash("sha256").update(`restore-v2:${material}`).digest("hex").slice(0, MANAGED_SESSION_RESTORE_KEY_HASH_LENGTH);
266
+ const digest = createHash("sha256")
267
+ .update(`restore-v3:${material}:scope:${restoreScope}`)
268
+ .digest("hex")
269
+ .slice(0, MANAGED_SESSION_RESTORE_KEY_HASH_LENGTH);
249
270
  return `${MANAGED_SESSION_NAME_PREFIX}${digest}`;
250
271
  }
251
272
  function hasValidEncryptionKey(parentEnv) {
@@ -1,8 +1,3 @@
1
- /**
2
- * Purpose: Keep wrapper-side navigation policy parsing and evaluation small and explicit.
3
- * Responsibilities: Parse allowed-domain argv values and detect final-page host escapes.
4
- * Scope: Wrapper diagnostics only; upstream remains responsible for browser-time enforcement.
5
- */
6
1
  function normalizeDomainEntry(value) {
7
2
  let candidate = value.trim().toLowerCase();
8
3
  if (!candidate)
@@ -1,3 +1,4 @@
1
+ const BATCH_STDIN_EXAMPLE = ' Example: { "args": ["batch"], "stdin": "[[\\"get\\",\\"title\\"],[\\"get\\",\\"url\\"]]" }';
1
2
  // Mirror upstream commands::shell_words_split so policy inspection sees the same argv.
2
3
  export function parseBatchCommandArgument(command) {
3
4
  const tokens = [];
@@ -36,20 +37,20 @@ export function parseBatchCommandArgument(command) {
36
37
  function validateUserBatchStep(step, index) {
37
38
  if (!Array.isArray(step)) {
38
39
  return {
39
- error: `agent_browser batch stdin step ${index} must be a non-empty array of string command tokens.`,
40
+ error: `agent_browser batch stdin step ${index} must be a non-empty array of string command tokens.${BATCH_STDIN_EXAMPLE}`,
40
41
  ok: false,
41
42
  };
42
43
  }
43
44
  if (step.length === 0) {
44
45
  return {
45
- error: `agent_browser batch stdin step ${index} must not be empty.`,
46
+ error: `agent_browser batch stdin step ${index} must not be empty.${BATCH_STDIN_EXAMPLE}`,
46
47
  ok: false,
47
48
  };
48
49
  }
49
50
  const invalidTokenIndex = step.findIndex((token) => typeof token !== "string");
50
51
  if (invalidTokenIndex !== -1) {
51
52
  return {
52
- error: `agent_browser batch stdin step ${index} token ${invalidTokenIndex} must be a string.`,
53
+ error: `agent_browser batch stdin step ${index} token ${invalidTokenIndex} must be a string.${BATCH_STDIN_EXAMPLE}`,
53
54
  ok: false,
54
55
  };
55
56
  }
@@ -62,13 +63,13 @@ export function parseBatchStdinJsonArray(stdin) {
62
63
  try {
63
64
  const parsed = JSON.parse(stdin);
64
65
  if (!Array.isArray(parsed)) {
65
- return { error: "agent_browser batch stdin must be a JSON array of command steps." };
66
+ return { error: `agent_browser batch stdin must be a JSON array of command steps.${BATCH_STDIN_EXAMPLE}` };
66
67
  }
67
68
  return { steps: parsed };
68
69
  }
69
70
  catch (error) {
70
71
  const message = error instanceof Error ? error.message : String(error);
71
- return { error: `agent_browser batch stdin could not be parsed as JSON: ${message}` };
72
+ return { error: `agent_browser batch stdin could not be parsed as JSON: ${message}.${BATCH_STDIN_EXAMPLE}` };
72
73
  }
73
74
  }
74
75
  export function parseUserBatchStdin(stdin) {
@@ -86,6 +87,26 @@ export function parseUserBatchStdin(stdin) {
86
87
  }
87
88
  return { steps };
88
89
  }
90
+ /**
91
+ * The batch steps upstream will actually execute: run_batch uses raw batch
92
+ * arguments exclusively when any exist and reads stdin only otherwise.
93
+ * Upstream filters only the exact `--bail` token, so an equals form such as
94
+ * `--bail=true` stays a raw command (an unknown-command row) and keeps stdin
95
+ * ignored.
96
+ */
97
+ export function getUpstreamEffectiveBatchSteps(commandTokens, stdin) {
98
+ if (commandTokens[0] !== "batch")
99
+ return [];
100
+ const argumentSteps = commandTokens.slice(1).flatMap((command) => {
101
+ if (command === "--bail")
102
+ return [];
103
+ const step = parseBatchCommandArgument(command).step;
104
+ return step ? [step] : [];
105
+ });
106
+ if (argumentSteps.length > 0)
107
+ return argumentSteps;
108
+ return parseUserBatchStdin(stdin).steps ?? [];
109
+ }
89
110
  export function parseValidBatchStepEntries(stdin) {
90
111
  const parsed = parseBatchStdinJsonArray(stdin);
91
112
  if (parsed.error || parsed.steps === undefined)
@@ -1,46 +1,126 @@
1
- import { extname, isAbsolute } from "node:path";
2
- import { GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, VALUE_FLAGS } from "../../argv-grammar.js";
1
+ import { lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { foldAgentBrowserFilesystemIdentity } from "../../argv-grammar.js";
4
+ import { parseWaitCommandTokens } from "../../argv-descriptor.js";
3
5
  const SCREENSHOT_BOOLEAN_FLAGS = new Set(["--annotate", "--full", "-f"]);
4
6
  const SCREENSHOT_VALUE_FLAGS = new Set(["--screenshot-dir", "--screenshot-format", "--screenshot-quality"]);
5
- const SCREENSHOT_IMAGE_EXTENSIONS = new Set([".jpeg", ".jpg", ".png", ".webp"]);
6
- function isImagePathToken(token) {
7
- const extension = extname(token).toLowerCase();
8
- return SCREENSHOT_IMAGE_EXTENSIONS.has(extension);
7
+ const SCREENSHOT_IMAGE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".webp"];
8
+ function isSingleScreenshotPathToken(token) {
9
+ const explicitlyRelative = token.startsWith("./") || token.startsWith("../");
10
+ if (token.startsWith("#") || token.startsWith("@") || (token.startsWith(".") && !explicitlyRelative && !token.includes("/")))
11
+ return false;
12
+ return explicitlyRelative || token.includes("/") || SCREENSHOT_IMAGE_EXTENSIONS.some((extension) => token.endsWith(extension));
13
+ }
14
+ function getScreenshotPositionalIndices(commandTokens) {
15
+ if (commandTokens[0] !== "screenshot")
16
+ return [];
17
+ const positionalIndices = [];
18
+ for (let index = 1; index < commandTokens.length; index += 1) {
19
+ const token = commandTokens[index];
20
+ if (SCREENSHOT_VALUE_FLAGS.has(token)) {
21
+ index += 1;
22
+ continue;
23
+ }
24
+ if (SCREENSHOT_BOOLEAN_FLAGS.has(token))
25
+ continue;
26
+ positionalIndices.push(index);
27
+ }
28
+ return positionalIndices;
9
29
  }
10
30
  export function getScreenshotPathTokenIndex(commandTokens) {
11
- if (commandTokens[0] !== "screenshot") {
31
+ const positionalIndices = getScreenshotPositionalIndices(commandTokens);
32
+ if (positionalIndices.length === 0)
12
33
  return undefined;
34
+ const candidateIndex = positionalIndices.length >= 2 ? positionalIndices[1] : positionalIndices[0];
35
+ const candidate = commandTokens[candidateIndex];
36
+ if (positionalIndices.length >= 2 || isSingleScreenshotPathToken(candidate)) {
37
+ return candidateIndex;
13
38
  }
14
- const positionalIndices = [];
15
- for (let index = 1; index < commandTokens.length; index += 1) {
39
+ return undefined;
40
+ }
41
+ const DIFF_SCREENSHOT_VALUE_FLAGS = new Set(["-b", "--baseline", "-o", "--output", "-s", "--selector", "-t", "--threshold"]);
42
+ function getDiffScreenshotOutputPath(commandTokens) {
43
+ let outputPath;
44
+ for (let index = 2; index < commandTokens.length; index += 1) {
16
45
  const token = commandTokens[index];
17
- if (token === "--") {
18
- for (let positionalIndex = index + 1; positionalIndex < commandTokens.length; positionalIndex += 1) {
19
- positionalIndices.push(positionalIndex);
46
+ if (!DIFF_SCREENSHOT_VALUE_FLAGS.has(token))
47
+ continue;
48
+ const value = commandTokens[index + 1];
49
+ if (value === undefined)
50
+ return undefined;
51
+ if (token === "-o" || token === "--output")
52
+ outputPath = value;
53
+ index += 1;
54
+ }
55
+ return outputPath;
56
+ }
57
+ function foldArtifactPath(path, platform) {
58
+ return foldAgentBrowserFilesystemIdentity(path, platform);
59
+ }
60
+ function canonicalizeArtifactPath(absolutePath, platform, seenSymlinks) {
61
+ let cursor = absolutePath;
62
+ const suffix = [];
63
+ while (true) {
64
+ try {
65
+ const canonicalPath = join(realpathSync.native(cursor), ...suffix);
66
+ try {
67
+ const stats = statSync(canonicalPath, { bigint: true });
68
+ if (stats.ino > 0n)
69
+ return `inode:${stats.dev}:${stats.ino}`;
70
+ }
71
+ catch {
72
+ // The destination does not exist yet; canonical ancestry still catches aliases.
20
73
  }
21
- break;
74
+ return foldArtifactPath(canonicalPath, platform);
22
75
  }
23
- if (token.startsWith("-")) {
24
- const normalizedToken = token.split("=", 1)[0] ?? token;
25
- if ((SCREENSHOT_VALUE_FLAGS.has(normalizedToken) || VALUE_FLAGS.has(normalizedToken)) && !token.includes("=")) {
26
- index += 1;
27
- continue;
76
+ catch {
77
+ let symlinkTarget;
78
+ try {
79
+ if (lstatSync(cursor).isSymbolicLink())
80
+ symlinkTarget = resolve(dirname(cursor), readlinkSync(cursor));
28
81
  }
29
- if (SCREENSHOT_BOOLEAN_FLAGS.has(normalizedToken) || GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES.has(normalizedToken)) {
30
- if (["true", "false"].includes(commandTokens[index + 1] ?? ""))
31
- index += 1;
32
- continue;
82
+ catch { }
83
+ if (symlinkTarget) {
84
+ if (seenSymlinks.has(cursor))
85
+ throw new Error(`Artifact destination contains a symlink loop: ${absolutePath}`);
86
+ if (seenSymlinks.size >= 32)
87
+ throw new Error(`Artifact destination has too many symlink hops: ${absolutePath}`);
88
+ seenSymlinks.add(cursor);
89
+ return canonicalizeArtifactPath(join(symlinkTarget, ...suffix), platform, seenSymlinks);
33
90
  }
91
+ const parent = dirname(cursor);
92
+ if (parent === cursor)
93
+ return foldArtifactPath(absolutePath, platform);
94
+ suffix.unshift(basename(cursor));
95
+ cursor = parent;
34
96
  }
35
- positionalIndices.push(index);
36
97
  }
37
- if (positionalIndices.length === 0) {
38
- return undefined;
39
- }
40
- const candidateIndex = positionalIndices[positionalIndices.length - 1];
41
- const candidate = commandTokens[candidateIndex];
42
- if (positionalIndices.length >= 2 || isImagePathToken(candidate) || isAbsolute(candidate) || candidate.startsWith("./") || candidate.startsWith("../")) {
43
- return candidateIndex;
98
+ }
99
+ export function canonicalizeExplicitArtifactDestination(cwd, destination, platform = process.platform) {
100
+ return canonicalizeArtifactPath(resolve(cwd, destination), platform, new Set());
101
+ }
102
+ export function getExplicitArtifactDestination(commandTokens) {
103
+ const command = commandTokens[0];
104
+ const subcommand = commandTokens[1];
105
+ if (command === "screenshot") {
106
+ const index = getScreenshotPathTokenIndex(commandTokens);
107
+ return index === undefined ? undefined : commandTokens[index];
44
108
  }
109
+ if (command === "download")
110
+ return commandTokens[2];
111
+ if (command === "pdf")
112
+ return commandTokens[1];
113
+ if (command === "wait")
114
+ return parseWaitCommandTokens(commandTokens).downloadPath;
115
+ if (command === "state" && subcommand === "save")
116
+ return commandTokens[2];
117
+ if (command === "diff" && subcommand === "screenshot")
118
+ return getDiffScreenshotOutputPath(commandTokens);
119
+ if (command === "network" && subcommand === "har" && commandTokens[2] === "stop")
120
+ return commandTokens[3];
121
+ if ((command === "trace" || command === "profiler") && subcommand === "stop")
122
+ return commandTokens[2];
123
+ if (command === "record" && (subcommand === "start" || subcommand === "restart"))
124
+ return commandTokens[2];
45
125
  return undefined;
46
126
  }
@@ -245,6 +245,7 @@ export async function collectClickDispatchDiagnostic(options) {
245
245
  const result = getEvalResultRecord(data);
246
246
  if (!result)
247
247
  return undefined;
248
+ options.probe.cleaned = true;
248
249
  const status = typeof result.status === "string" ? result.status : undefined;
249
250
  if (status !== "no-native-event-observed")
250
251
  return undefined;
@@ -264,7 +265,7 @@ export async function collectClickDispatchDiagnostic(options) {
264
265
  };
265
266
  }
266
267
  export async function cleanupClickDispatchProbe(options) {
267
- if (!options.probe || !options.sessionName)
268
+ if (!options.probe || options.probe.cleaned || !options.sessionName)
268
269
  return;
269
270
  await runSessionCommandData({
270
271
  args: ["eval", "--stdin"],
@@ -1,14 +1,14 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import { isCloseCommand, isOpenNavigationCommand } from "../../command-taxonomy.js";
4
- import { boundElectronProbeString } from "../../electron/text.js";
4
+ import { boundElectronProbeString } from "../../electron/cdp.js";
5
5
  import { executableExistsOnPath } from "../../executable-path.js";
6
6
  import { isHttpOrHttpsUrl } from "../../input-modes/job.js";
7
7
  import { formatSessionArtifactRetentionSummary } from "../../results/artifact-manifest.js";
8
8
  import { buildNextToolAction, withOptionalSessionArgs } from "../../results/next-actions.js";
9
9
  import { buildVisibleRefFallbackDiagnosticFromSnapshot, getVisibleRefFallbackTarget } from "../../results/selector-recovery.js";
10
- import { extractRefSnapshotFromData, normalizeComparableUrl } from "../../session-page-state.js";
11
- import { redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
10
+ import { extractRefSnapshotFromData, isAboutBlankUrl, normalizeComparableUrl } from "../../session-page-state.js";
11
+ import { extractUpstreamCommandTokens, parseWaitCommandTokens, redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
12
12
  import { isRecord } from "../../parsing.js";
13
13
  import { getManagedSessionStateAccessValidationError, isFileUrl } from "../../managed-session-state-policy.js";
14
14
  import { extractBatchResultCommand, extractNavigationSummaryFromData, extractStringResultField, findElectronLaunchRecordForSession, runSessionCommandData, } from "./session-state.js";
@@ -22,8 +22,14 @@ export async function collectNavigationSummary(options) {
22
22
  const url = extractStringResultField(await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal }), "url");
23
23
  if (!url || !/^[a-z][a-z0-9+.-]*:/i.test(url))
24
24
  return undefined;
25
- if (isFileUrl(url))
25
+ if (isFileUrl(url) || isAboutBlankUrl(url))
26
26
  return { url };
27
+ // Reuse the title already observed for this exact URL instead of spending a second probe. Titles can
28
+ // change without a URL change on SPAs, but this summary is only a "last observed" page label; the URL
29
+ // stays live-probed on every call.
30
+ if (options.priorTarget?.title && normalizeComparableUrl(options.priorTarget.url) === normalizeComparableUrl(url)) {
31
+ return { title: options.priorTarget.title, url };
32
+ }
27
33
  const title = extractStringResultField(await runSessionCommandData({ args: ["get", "title"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal }), "title");
28
34
  return { title, url };
29
35
  }
@@ -390,14 +396,14 @@ function isBroadGetTextSelector(selector) {
390
396
  return normalized === "body" || normalized === "html" || normalized === ":root" || normalized === "*" || normalized === "main" || normalized === "div" || normalized === "section" || normalized === "article" || /^\[role=(?:"application"|'application'|application)\]$/i.test(normalized);
391
397
  }
392
398
  function getElectronTextScopeContext(options) {
393
- const record = findElectronLaunchRecordForSession(options.sessionName, options.electronLaunchRecords);
399
+ const record = findElectronLaunchRecordForSession(options.sessionName, options.electronLaunchRecords, options.namespace);
394
400
  if (!record)
395
401
  return undefined;
396
402
  const url = options.currentTarget?.url ?? options.priorTarget?.url;
397
403
  return { launchId: record.launchId, sessionName: record.sessionName ?? options.sessionName, url };
398
404
  }
399
405
  export function getSourceLookupElectronContext(options) {
400
- const record = findElectronLaunchRecordForSession(options.sessionName, options.electronLaunchRecords);
406
+ const record = findElectronLaunchRecordForSession(options.sessionName, options.electronLaunchRecords, options.namespace);
401
407
  if (!record)
402
408
  return undefined;
403
409
  const url = options.currentTarget?.url ?? options.priorTarget?.url;
@@ -469,9 +475,11 @@ export function formatEvalResultWarningText(warning) {
469
475
  return warning ? `Eval result warning: ${warning.reason} ${warning.suggestion}` : undefined;
470
476
  }
471
477
  export async function getArtifactCleanupGuidance(options) {
472
- if (!options.succeeded || !isCloseCommand(options.command) || !options.manifest || options.manifest.entries.length === 0)
478
+ if (!options.succeeded || !isCloseCommand(options.command) || !options.manifest)
473
479
  return undefined;
474
480
  const explicitEntries = options.manifest.entries.filter((entry) => entry.storageScope === "explicit-path");
481
+ if (explicitEntries.length === 0)
482
+ return undefined;
475
483
  const explicitArtifactPaths = [];
476
484
  const seenPaths = new Set();
477
485
  for (const entry of explicitEntries) {
@@ -490,16 +498,15 @@ export async function getArtifactCleanupGuidance(options) {
490
498
  seenPaths.add(displayPath);
491
499
  explicitArtifactPaths.push(displayPath);
492
500
  }
501
+ if (explicitArtifactPaths.length === 0)
502
+ return undefined;
493
503
  return { explicitArtifactPaths, note: "Closing the browser session does not delete explicit screenshots, downloads, PDFs, traces, HAR files, or recordings; clean existing paths with host file tools when no longer needed.", owner: "host-file-tools", summary: formatSessionArtifactRetentionSummary(options.manifest) };
494
504
  }
495
505
  export function formatArtifactCleanupGuidanceText(guidance) {
496
- if (!guidance)
506
+ if (!guidance || guidance.explicitArtifactPaths.length === 0)
497
507
  return undefined;
498
508
  const explicitCount = guidance.explicitArtifactPaths.length;
499
- const explicitSummary = explicitCount === 0
500
- ? "No existing explicit artifact paths were found in the recent manifest."
501
- : `${explicitCount} explicit artifact${explicitCount === 1 ? "" : "s"} remain${explicitCount === 1 ? "s" : ""}; expand or inspect details.artifactCleanup.explicitArtifactPaths for paths.`;
502
- return `Artifact lifecycle: ${explicitSummary} Browser close does not delete explicit screenshots, downloads, PDFs, traces, HAR files, or recordings; use host file tools for cleanup.`;
509
+ return `Artifact lifecycle: ${explicitCount} explicit artifact${explicitCount === 1 ? "" : "s"} remain${explicitCount === 1 ? "s" : ""}; expand or inspect details.artifactCleanup.explicitArtifactPaths for paths. Browser close does not delete explicit screenshots, downloads, PDFs, traces, HAR files, or recordings; use host file tools for cleanup.`;
503
510
  }
504
511
  async function collectManagedSessionCommandData(options) {
505
512
  try {
@@ -718,24 +725,18 @@ function getLastPositionalToken(args, startIndex = 1) {
718
725
  return undefined;
719
726
  }
720
727
  function getTimeoutStepArtifactPath(args) {
721
- const [command] = args;
728
+ const commandArgs = extractUpstreamCommandTokens(args);
729
+ const [command] = commandArgs;
722
730
  if (command === "screenshot") {
723
- const index = getScreenshotPathTokenIndex(args);
724
- return index === undefined ? undefined : args[index];
731
+ const index = getScreenshotPathTokenIndex(commandArgs);
732
+ return index === undefined ? undefined : commandArgs[index];
725
733
  }
726
734
  if (command === "pdf")
727
- return getLastPositionalToken(args);
735
+ return getLastPositionalToken(commandArgs);
728
736
  if (command === "download")
729
- return getLastPositionalToken(args, 2);
730
- if (command === "wait") {
731
- const inlineDownload = args.find((token) => token.startsWith("--download="));
732
- if (inlineDownload)
733
- return inlineDownload.slice("--download=".length) || undefined;
734
- const downloadIndex = args.indexOf("--download");
735
- const downloadPath = downloadIndex >= 0 ? args[downloadIndex + 1] : undefined;
736
- if (downloadPath && !downloadPath.startsWith("-"))
737
- return downloadPath;
738
- }
737
+ return getLastPositionalToken(commandArgs, 2);
738
+ if (command === "wait")
739
+ return parseWaitCommandTokens(commandArgs).downloadPath;
739
740
  return undefined;
740
741
  }
741
742
  async function statTimeoutArtifactPath(absolutePath) {