pi-agent-browser-native 0.3.0 → 0.6.5

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 (89) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/README.md +130 -54
  3. package/dist/extensions/agent-browser/index.js +781 -169
  4. package/dist/extensions/agent-browser/lib/argv-descriptor.js +35 -3
  5. package/dist/extensions/agent-browser/lib/argv-grammar.js +50 -2
  6. package/dist/extensions/agent-browser/lib/batch-lifecycle.js +71 -0
  7. package/dist/extensions/agent-browser/lib/command-policy.js +5 -8
  8. package/dist/extensions/agent-browser/lib/command-taxonomy.js +53 -12
  9. package/dist/extensions/agent-browser/lib/config-policy.js +25 -1
  10. package/dist/extensions/agent-browser/lib/config.js +1 -1
  11. package/dist/extensions/agent-browser/lib/input-modes/job.js +61 -13
  12. package/dist/extensions/agent-browser/lib/input-modes/lookups.js +2 -2
  13. package/dist/extensions/agent-browser/lib/input-modes/params.js +23 -24
  14. package/dist/extensions/agent-browser/lib/input-modes/script.js +462 -0
  15. package/dist/extensions/agent-browser/lib/input-modes/semantic-action.js +51 -12
  16. package/dist/extensions/agent-browser/lib/launch-scoped-flags.js +26 -4
  17. package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +6 -139
  18. package/dist/extensions/agent-browser/lib/managed-session-restore.js +26 -116
  19. package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +2 -4
  20. package/dist/extensions/agent-browser/lib/managed-session-storage.js +54 -25
  21. package/dist/extensions/agent-browser/lib/orchestration/batch-stdin.js +26 -5
  22. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +110 -30
  23. package/dist/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.js +2 -1
  24. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +54 -48
  25. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +71 -5
  26. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +2 -1
  27. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +6 -7
  28. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/snapshot-filter.js +119 -2
  29. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/wait-timeouts.js +7 -6
  30. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +152 -64
  31. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +244 -102
  32. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +63 -37
  33. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +20 -21
  34. package/dist/extensions/agent-browser/lib/orchestration/input-plan.js +36 -18
  35. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +41 -21
  36. package/dist/extensions/agent-browser/lib/orchestration/script-mode.js +299 -0
  37. package/dist/extensions/agent-browser/lib/page-target-validation.js +270 -0
  38. package/dist/extensions/agent-browser/lib/pi-tool-rendering.js +32 -10
  39. package/dist/extensions/agent-browser/lib/playbook.js +29 -25
  40. package/dist/extensions/agent-browser/lib/process-environment.js +14 -0
  41. package/dist/extensions/agent-browser/lib/process-identity.js +5 -12
  42. package/dist/extensions/agent-browser/lib/process.js +130 -104
  43. package/dist/extensions/agent-browser/lib/recording-reservations.js +116 -0
  44. package/dist/extensions/agent-browser/lib/results/action-recommendations.js +63 -6
  45. package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +62 -4
  46. package/dist/extensions/agent-browser/lib/results/categories.js +6 -1
  47. package/dist/extensions/agent-browser/lib/results/next-actions.js +19 -5
  48. package/dist/extensions/agent-browser/lib/results/presentation/artifacts.js +85 -38
  49. package/dist/extensions/agent-browser/lib/results/presentation/batch.js +86 -18
  50. package/dist/extensions/agent-browser/lib/results/presentation/common.js +38 -2
  51. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +18 -17
  52. package/dist/extensions/agent-browser/lib/results/presentation/errors.js +2 -1
  53. package/dist/extensions/agent-browser/lib/results/presentation/navigation.js +38 -20
  54. package/dist/extensions/agent-browser/lib/results/presentation/registry.js +60 -15
  55. package/dist/extensions/agent-browser/lib/results/presentation/semantic-action.js +1 -10
  56. package/dist/extensions/agent-browser/lib/results/presentation.js +36 -6
  57. package/dist/extensions/agent-browser/lib/results/recovery-actions.js +3 -1
  58. package/dist/extensions/agent-browser/lib/results/recovery-next-actions.js +9 -0
  59. package/dist/extensions/agent-browser/lib/results/selector-recovery.js +54 -11
  60. package/dist/extensions/agent-browser/lib/results/snapshot-high-value-controls.js +13 -7
  61. package/dist/extensions/agent-browser/lib/results/snapshot-spill.js +2 -1
  62. package/dist/extensions/agent-browser/lib/results/snapshot.js +4 -4
  63. package/dist/extensions/agent-browser/lib/runtime.js +186 -108
  64. package/dist/extensions/agent-browser/lib/session-page-state.js +71 -10
  65. package/dist/extensions/agent-browser/lib/temp.js +1 -2
  66. package/dist/extensions/agent-browser/lib/upstream-version.js +14 -0
  67. package/dist/extensions/agent-browser/lib/web-search.js +108 -24
  68. package/dist/extensions/agent-browser/script-worker.js +169 -0
  69. package/dist/scripts/agent-browser-target.mjs +21 -0
  70. package/docs/ARCHITECTURE.md +57 -34
  71. package/docs/COMMAND_REFERENCE.md +255 -68
  72. package/docs/ELECTRON.md +2 -2
  73. package/docs/RELEASE.md +12 -10
  74. package/docs/REQUIREMENTS.md +11 -8
  75. package/docs/SUPPORT_MATRIX.md +36 -24
  76. package/docs/TOOL_CONTRACT.md +169 -95
  77. package/package.json +3 -1
  78. package/platform-smoke.config.mjs +2 -2
  79. package/scripts/agent-browser-capability-baseline.mjs +87 -9
  80. package/scripts/agent-browser-target.mjs +21 -0
  81. package/scripts/build.mjs +41 -0
  82. package/scripts/config.mjs +1 -0
  83. package/scripts/doctor.mjs +16 -9
  84. package/scripts/platform-smoke/browser-dogfood-windows.ps1 +9 -3
  85. package/scripts/platform-smoke/targets.mjs +12 -6
  86. package/dist/extensions/agent-browser/lib/managed-session-capabilities.js +0 -20
  87. package/dist/extensions/agent-browser/lib/managed-session-state-policy.js +0 -583
  88. package/dist/extensions/agent-browser/lib/navigation-policy.js +0 -78
  89. package/dist/extensions/agent-browser/lib/results/presentation/managed-list-filter.js +0 -37
@@ -3,8 +3,12 @@ import { linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, parse, resolve, win32 } from "node:path";
5
5
  import { canonicalizeAgentBrowserNamespace } from "./argv-grammar.js";
6
- export { isManagedSessionRestoreKey } from "./managed-session-capabilities.js";
6
+ const MANAGED_SESSION_RESTORE_KEY_PATTERN = /^piab-r2-[a-f\d]{32}$/i;
7
7
  const MANAGED_SESSION_NAME_PREFIX = "piab-r2-";
8
+ export function isManagedSessionRestoreKey(value) {
9
+ return typeof value === "string" && MANAGED_SESSION_RESTORE_KEY_PATTERN.test(value);
10
+ }
11
+ const MANAGED_SESSION_FRESH_SUFFIX_PATTERN = /-fresh-[a-f\d]{10}$/i;
8
12
  const MANAGED_SESSION_RESTORE_KEY_HASH_LENGTH = 32;
9
13
  const PROJECT_GENERATION_MARKER_NAME = "pi-agent-browser-project-generation-v1.json";
10
14
  const PROJECT_GENERATION_MARKER_MAX_BYTES = 1_024;
@@ -15,7 +19,7 @@ function isAbsoluteHome(path, platform) {
15
19
  function currentUid() {
16
20
  return typeof process.getuid === "function" ? process.getuid() : undefined;
17
21
  }
18
- function isTrustedPosixDirectory(path, requireCurrentOwner) {
22
+ function isTrustedPosixDirectory(path, requireCurrentOwner, platform = process.platform) {
19
23
  const uid = currentUid();
20
24
  if (uid === undefined)
21
25
  return false;
@@ -32,11 +36,13 @@ function isTrustedPosixDirectory(path, requireCurrentOwner) {
32
36
  }
33
37
  if (entry.isSymbolicLink() || !entry.isDirectory())
34
38
  return false;
35
- if (entry.uid !== 0 && entry.uid !== uid)
39
+ const androidSystemAncestor = platform === "android" && (cursor === "/data" || cursor === "/data/data") && entry.uid === 1000 && (entry.mode & 0o002) === 0;
40
+ const androidAppDirectory = platform === "android" && entry.uid === uid && entry.gid === uid && (entry.mode & 0o002) === 0;
41
+ if (!androidSystemAncestor && entry.uid !== 0 && entry.uid !== uid)
36
42
  return false;
37
43
  const writableByOthers = (entry.mode & 0o022) !== 0;
38
44
  const rootOwnedStickyDirectory = entry.uid === 0 && (entry.mode & 0o1000) !== 0;
39
- if (writableByOthers && !rootOwnedStickyDirectory)
45
+ if (writableByOthers && !rootOwnedStickyDirectory && !androidSystemAncestor && !androidAppDirectory)
40
46
  return false;
41
47
  }
42
48
  try {
@@ -56,7 +62,7 @@ export function resolveManagedSessionRestoreHome(parentEnv, platform = process.p
56
62
  return candidate;
57
63
  try {
58
64
  const canonical = realpathSync(candidate);
59
- return isTrustedPosixDirectory(canonical, true) ? canonical : undefined;
65
+ return isTrustedPosixDirectory(canonical, true, platform) ? canonical : undefined;
60
66
  }
61
67
  catch {
62
68
  return undefined;
@@ -138,12 +144,15 @@ function readProjectGenerationMarker(path, platform) {
138
144
  return undefined;
139
145
  }
140
146
  }
141
- function getDirectoryFilesystemIdentity(path) {
147
+ function getDirectoryFilesystemIdentity(path, platform) {
142
148
  try {
143
149
  const entry = statSync(path, { bigint: true });
144
- return entry.isDirectory() && entry.dev > 0n && entry.ino > 0n && entry.birthtimeNs > 0n
145
- ? `${entry.dev}:${entry.ino}:${entry.birthtimeNs}`
146
- : undefined;
150
+ if (!entry.isDirectory() || entry.dev <= 0n || entry.ino <= 0n)
151
+ return undefined;
152
+ // ponytail: Android reports mutable ctime as birthtime; use statx birthtime/inode generation when Node exposes either reliably.
153
+ return platform === "android"
154
+ ? `${entry.dev}:${entry.ino}`
155
+ : entry.birthtimeNs > 0n ? `${entry.dev}:${entry.ino}:${entry.birthtimeNs}` : undefined;
147
156
  }
148
157
  catch {
149
158
  return undefined;
@@ -157,13 +166,13 @@ function resolveManagedSessionRestoreProjectCheckout(cwd, platform) {
157
166
  catch {
158
167
  return undefined;
159
168
  }
160
- if (platform !== "win32" && !isTrustedPosixDirectory(canonicalCwd, false))
169
+ if (platform !== "win32" && !isTrustedPosixDirectory(canonicalCwd, false, platform))
161
170
  return undefined;
162
171
  const checkout = resolveGitCheckout(canonicalCwd, platform);
163
172
  if (!checkout)
164
173
  return undefined;
165
- if (platform !== "win32" && (!isTrustedPosixDirectory(checkout.worktreeDirectory, true)
166
- || !isTrustedPosixDirectory(checkout.gitDirectory, true)))
174
+ if (platform !== "win32" && (!isTrustedPosixDirectory(checkout.worktreeDirectory, true, platform)
175
+ || !isTrustedPosixDirectory(checkout.gitDirectory, true, platform)))
167
176
  return undefined;
168
177
  return { canonicalCwd, ...checkout };
169
178
  }
@@ -175,8 +184,8 @@ function resolveProjectGenerationIdentity(cwd, platform = process.platform) {
175
184
  if (!checkout)
176
185
  return undefined;
177
186
  const { canonicalCwd } = checkout;
178
- const gitFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.gitDirectory);
179
- const worktreeFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.worktreeDirectory);
187
+ const gitFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.gitDirectory, platform);
188
+ const worktreeFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.worktreeDirectory, platform);
180
189
  if (!gitFilesystemIdentity || !worktreeFilesystemIdentity)
181
190
  return undefined;
182
191
  const markerPath = join(checkout.gitDirectory, PROJECT_GENERATION_MARKER_NAME);
@@ -192,22 +201,35 @@ function resolveProjectGenerationIdentity(cwd, platform = process.platform) {
192
201
  projectGenerationCache.delete(canonicalCwd);
193
202
  try {
194
203
  if (!marker) {
195
- const candidatePath = `${markerPath}.candidate-${process.pid}-${randomUUID()}`;
196
- try {
197
- writeFileSync(candidatePath, JSON.stringify({ id: randomUUID(), version: 1 }), { encoding: "utf8", flag: "wx", mode: 0o600 });
204
+ const content = JSON.stringify({ id: randomUUID(), version: 1 });
205
+ if (platform === "android") {
206
+ // ponytail: Android denies hard links in app storage; use renameat2(RENAME_NOREPLACE) if Node exposes it.
198
207
  try {
199
- linkSync(candidatePath, markerPath);
208
+ writeFileSync(markerPath, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
200
209
  }
201
210
  catch (error) {
202
211
  if (error.code !== "EEXIST")
203
212
  return undefined;
204
213
  }
205
214
  }
206
- finally {
215
+ else {
216
+ const candidatePath = `${markerPath}.candidate-${process.pid}-${randomUUID()}`;
207
217
  try {
208
- unlinkSync(candidatePath);
218
+ writeFileSync(candidatePath, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
219
+ try {
220
+ linkSync(candidatePath, markerPath);
221
+ }
222
+ catch (error) {
223
+ if (error.code !== "EEXIST")
224
+ return undefined;
225
+ }
226
+ }
227
+ finally {
228
+ try {
229
+ unlinkSync(candidatePath);
230
+ }
231
+ catch { }
209
232
  }
210
- catch { }
211
233
  }
212
234
  marker = readProjectGenerationMarker(markerPath, platform);
213
235
  }
@@ -231,16 +253,23 @@ function resolveProjectGenerationIdentity(cwd, platform = process.platform) {
231
253
  export function hasManagedSessionRestoreProjectIdentity(cwd) {
232
254
  return resolveProjectGenerationIdentity(cwd) !== undefined;
233
255
  }
234
- /** Stable for one checkout generation; deliberately changes when a path is replaced by another checkout. */
235
- export function createManagedSessionRestoreKey(cwd) {
256
+ /** Keep fresh rotations from one Pi transcript in one private upstream restore pool. */
257
+ export function getManagedSessionRestoreScope(sessionName) {
258
+ return sessionName.replace(MANAGED_SESSION_FRESH_SUFFIX_PATTERN, "");
259
+ }
260
+ /** Stable for one Pi transcript and checkout generation; isolated from other concurrent transcripts. */
261
+ export function createManagedSessionRestoreKey(cwd, restoreScope = "", platform = process.platform) {
236
262
  let canonicalCwd = resolve(cwd);
237
263
  try {
238
264
  canonicalCwd = realpathSync(canonicalCwd);
239
265
  }
240
266
  catch { }
241
- const identity = resolveProjectGenerationIdentity(canonicalCwd);
267
+ const identity = resolveProjectGenerationIdentity(canonicalCwd, platform);
242
268
  const material = identity ?? `unavailable:${canonicalCwd}`;
243
- const digest = createHash("sha256").update(`restore-v2:${material}`).digest("hex").slice(0, MANAGED_SESSION_RESTORE_KEY_HASH_LENGTH);
269
+ const digest = createHash("sha256")
270
+ .update(`restore-v3:${material}:scope:${restoreScope}`)
271
+ .digest("hex")
272
+ .slice(0, MANAGED_SESSION_RESTORE_KEY_HASH_LENGTH);
244
273
  return `${MANAGED_SESSION_NAME_PREFIX}${digest}`;
245
274
  }
246
275
  function hasValidEncryptionKey(parentEnv) {
@@ -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"],
@@ -3,14 +3,12 @@ import { isAbsolute, resolve } from "node:path";
3
3
  import { isCloseCommand, isOpenNavigationCommand } from "../../command-taxonomy.js";
4
4
  import { boundElectronProbeString } from "../../electron/cdp.js";
5
5
  import { executableExistsOnPath } from "../../executable-path.js";
6
- import { isHttpOrHttpsUrl } from "../../input-modes/job.js";
7
6
  import { formatSessionArtifactRetentionSummary } from "../../results/artifact-manifest.js";
8
7
  import { buildNextToolAction, withOptionalSessionArgs } from "../../results/next-actions.js";
9
8
  import { buildVisibleRefFallbackDiagnosticFromSnapshot, getVisibleRefFallbackTarget } from "../../results/selector-recovery.js";
10
- import { extractRefSnapshotFromData, normalizeComparableUrl } from "../../session-page-state.js";
11
- import { redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
9
+ import { extractRefSnapshotFromData, isAboutBlankUrl, normalizeComparableUrl } from "../../session-page-state.js";
10
+ import { extractUpstreamCommandTokens, parseWaitCommandTokens, redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
12
11
  import { isRecord } from "../../parsing.js";
13
- import { getManagedSessionStateAccessValidationError, isFileUrl } from "../../managed-session-state-policy.js";
14
12
  import { extractBatchResultCommand, extractNavigationSummaryFromData, extractStringResultField, findElectronLaunchRecordForSession, runSessionCommandData, } from "./session-state.js";
15
13
  import { parseValidBatchStepEntries } from "../batch-stdin.js";
16
14
  import { getScreenshotPathTokenIndex } from "./artifact-paths.js";
@@ -22,10 +20,17 @@ export async function collectNavigationSummary(options) {
22
20
  const url = extractStringResultField(await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal }), "url");
23
21
  if (!url || !/^[a-z][a-z0-9+.-]*:/i.test(url))
24
22
  return undefined;
25
- if (isFileUrl(url))
26
- return { url };
23
+ const urlChanged = options.priorTarget?.url ? normalizeComparableUrl(options.priorTarget.url) !== normalizeComparableUrl(url) : undefined;
24
+ if (isAboutBlankUrl(url))
25
+ return { url, ...(urlChanged !== undefined ? { urlChanged } : {}) };
26
+ // Reuse the title already observed for this exact URL instead of spending a second probe. Titles can
27
+ // change without a URL change on SPAs, but this summary is only a "last observed" page label; the URL
28
+ // stays live-probed on every call.
29
+ if (options.reusePriorTitle !== false && options.priorTarget?.title && normalizeComparableUrl(options.priorTarget.url) === normalizeComparableUrl(url)) {
30
+ return { title: options.priorTarget.title, url, urlChanged: false };
31
+ }
27
32
  const title = extractStringResultField(await runSessionCommandData({ args: ["get", "title"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal }), "title");
28
- return { title, url };
33
+ return { title, url, ...(urlChanged !== undefined ? { urlChanged } : {}) };
29
34
  }
30
35
  function extractScrollPositionSnapshot(data) {
31
36
  const result = isRecord(data) && isRecord(data.result) ? data.result : data;
@@ -83,6 +88,21 @@ function sameScrollPositionSnapshot(left, right) {
83
88
  return other?.id === container.id && other.scrollTop === container.scrollTop && other.scrollLeft === container.scrollLeft;
84
89
  });
85
90
  }
91
+ export function buildUnsupportedScrollIntoViewRecovery(options) {
92
+ if (!["scrollintoview", "scrollinto"].includes(options.commandTokens[0] ?? "") || options.commandTokens.some((token) => token === "--help" || token === "-h"))
93
+ return undefined;
94
+ const match = /^text=(.+)$/s.exec(options.commandTokens[1] ?? "");
95
+ if (!match)
96
+ return undefined;
97
+ const text = redactSensitiveText(match[1]);
98
+ return {
99
+ error: "scrollintoview accepts a CSS selector, xpath=..., or a current @e… ref; text=... is not supported and can falsely report success without moving the page.",
100
+ nextActions: [
101
+ { id: "scroll-semantic-text-target", params: { args: withOptionalSessionArgs(options.sessionName, ["find", "text", text, "hover"]) }, reason: "Use the upstream semantic text locator; hover resolves and scrolls the matched element into view.", safety: "Hover may open a tooltip or menu. Use the snapshot/ref recovery instead when hover state could affect the workflow.", tool: "agent_browser" },
102
+ { id: "refresh-refs-for-scroll-target", params: { args: withOptionalSessionArgs(options.sessionName, ["snapshot", "-i"]) }, reason: "Capture a current element ref, then retry scrollintoview with that @e… ref.", safety: "Read-only snapshot; choose the intended current ref before retrying the scroll.", tool: "agent_browser" },
103
+ ],
104
+ };
105
+ }
86
106
  export function buildScrollNoopDiagnostic(before, after) {
87
107
  if (!before || !after || !sameScrollPositionSnapshot(before, after))
88
108
  return undefined;
@@ -390,14 +410,14 @@ function isBroadGetTextSelector(selector) {
390
410
  return normalized === "body" || normalized === "html" || normalized === ":root" || normalized === "*" || normalized === "main" || normalized === "div" || normalized === "section" || normalized === "article" || /^\[role=(?:"application"|'application'|application)\]$/i.test(normalized);
391
411
  }
392
412
  function getElectronTextScopeContext(options) {
393
- const record = findElectronLaunchRecordForSession(options.sessionName, options.electronLaunchRecords);
413
+ const record = findElectronLaunchRecordForSession(options.sessionName, options.electronLaunchRecords, options.namespace);
394
414
  if (!record)
395
415
  return undefined;
396
416
  const url = options.currentTarget?.url ?? options.priorTarget?.url;
397
417
  return { launchId: record.launchId, sessionName: record.sessionName ?? options.sessionName, url };
398
418
  }
399
419
  export function getSourceLookupElectronContext(options) {
400
- const record = findElectronLaunchRecordForSession(options.sessionName, options.electronLaunchRecords);
420
+ const record = findElectronLaunchRecordForSession(options.sessionName, options.electronLaunchRecords, options.namespace);
401
421
  if (!record)
402
422
  return undefined;
403
423
  const url = options.currentTarget?.url ?? options.priorTarget?.url;
@@ -469,9 +489,11 @@ export function formatEvalResultWarningText(warning) {
469
489
  return warning ? `Eval result warning: ${warning.reason} ${warning.suggestion}` : undefined;
470
490
  }
471
491
  export async function getArtifactCleanupGuidance(options) {
472
- if (!options.succeeded || !isCloseCommand(options.command) || !options.manifest || options.manifest.entries.length === 0)
492
+ if (!options.succeeded || !isCloseCommand(options.command) || !options.manifest)
473
493
  return undefined;
474
494
  const explicitEntries = options.manifest.entries.filter((entry) => entry.storageScope === "explicit-path");
495
+ if (explicitEntries.length === 0)
496
+ return undefined;
475
497
  const explicitArtifactPaths = [];
476
498
  const seenPaths = new Set();
477
499
  for (const entry of explicitEntries) {
@@ -490,16 +512,15 @@ export async function getArtifactCleanupGuidance(options) {
490
512
  seenPaths.add(displayPath);
491
513
  explicitArtifactPaths.push(displayPath);
492
514
  }
515
+ if (explicitArtifactPaths.length === 0)
516
+ return undefined;
493
517
  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
518
  }
495
519
  export function formatArtifactCleanupGuidanceText(guidance) {
496
- if (!guidance)
520
+ if (!guidance || guidance.explicitArtifactPaths.length === 0)
497
521
  return undefined;
498
522
  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.`;
523
+ 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
524
  }
504
525
  async function collectManagedSessionCommandData(options) {
505
526
  try {
@@ -524,14 +545,11 @@ async function collectElectronManagedSessionUrl(options) {
524
545
  export async function collectElectronManagedSessionTarget(options) {
525
546
  if (!options.sessionName)
526
547
  return undefined;
527
- const urlResult = await collectManagedSessionCommandData({ allowManagedSessionTarget: options.allowManagedSessionTarget, args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs });
548
+ const urlResult = await collectManagedSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs });
528
549
  const url = boundElectronProbeString(extractStringResultField(urlResult.data, "result") ?? extractStringResultField(urlResult.data, "url"), 300);
529
550
  if (urlResult.error || !url)
530
551
  return { error: urlResult.error ?? "get url returned no active page URL.", sessionName: options.sessionName };
531
- const fileAccessError = getManagedSessionStateAccessValidationError({ args: ["get", "title"], currentPageUrl: url, cwd: options.cwd });
532
- if (fileAccessError)
533
- return { error: fileAccessError, sessionName: options.sessionName, url };
534
- const titleResult = await collectManagedSessionCommandData({ allowManagedSessionTarget: options.allowManagedSessionTarget, args: ["get", "title"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs });
552
+ const titleResult = await collectManagedSessionCommandData({ args: ["get", "title"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs });
535
553
  const title = boundElectronProbeString(extractStringResultField(titleResult.data, "result") ?? extractStringResultField(titleResult.data, "title"), 160);
536
554
  return { sessionName: options.sessionName, title, url, ...(titleResult.error ? { error: titleResult.error } : {}) };
537
555
  }
@@ -559,7 +577,7 @@ export function buildQaAttachedRecoveryNextActions(sessionName) {
559
577
  buildNextToolAction({
560
578
  args: sessionArgs(["snapshot", "-i"]),
561
579
  id: "snapshot-before-qa-attached",
562
- reason: "Capture interactive refs on the active http(s) page before retrying qa.attached.",
580
+ reason: "Capture interactive refs on the active page before retrying qa.attached.",
563
581
  safety: "Read-only snapshot; confirms a renderable page is selected.",
564
582
  }),
565
583
  ];
@@ -581,13 +599,7 @@ export async function validateQaAttachedPrecondition(options) {
581
599
  const url = urlProbe.url?.trim();
582
600
  if (!url) {
583
601
  return {
584
- error: "qa.attached requires an attached session with a readable http(s) page URL. Run tab list, select a stable tab, then snapshot -i before retrying.",
585
- nextActions: buildQaAttachedRecoveryNextActions(options.sessionName),
586
- };
587
- }
588
- if (!isHttpOrHttpsUrl(url)) {
589
- return {
590
- error: `qa.attached requires an http(s) page URL; the current attached URL is "${url}". Use tab list and snapshot -i to recover a web surface before retrying.`,
602
+ error: "qa.attached requires an attached session with a readable page URL. Run tab list, select a stable tab, then snapshot -i before retrying.",
591
603
  nextActions: buildQaAttachedRecoveryNextActions(options.sessionName),
592
604
  };
593
605
  }
@@ -679,9 +691,6 @@ export async function collectElectronHandoff(options) {
679
691
  const url = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
680
692
  if (!url)
681
693
  throw new Error("Electron handoff get url returned no active page URL.");
682
- const fileAccessError = getManagedSessionStateAccessValidationError({ args: ["snapshot", "-i"], currentPageUrl: url, cwd: options.cwd });
683
- if (fileAccessError)
684
- return { error: fileAccessError, failureCategory: "validation-error", handoff: options.handoff };
685
694
  const tabs = await runSessionCommandData({ args: ["tab", "list"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, throwOnFailure: true });
686
695
  if (options.signal?.aborted)
687
696
  throw new Error("Electron handoff was aborted.");
@@ -718,24 +727,18 @@ function getLastPositionalToken(args, startIndex = 1) {
718
727
  return undefined;
719
728
  }
720
729
  function getTimeoutStepArtifactPath(args) {
721
- const [command] = args;
730
+ const commandArgs = extractUpstreamCommandTokens(args);
731
+ const [command] = commandArgs;
722
732
  if (command === "screenshot") {
723
- const index = getScreenshotPathTokenIndex(args);
724
- return index === undefined ? undefined : args[index];
733
+ const index = getScreenshotPathTokenIndex(commandArgs);
734
+ return index === undefined ? undefined : commandArgs[index];
725
735
  }
726
736
  if (command === "pdf")
727
- return getLastPositionalToken(args);
737
+ return getLastPositionalToken(commandArgs);
728
738
  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
- }
739
+ return getLastPositionalToken(commandArgs, 2);
740
+ if (command === "wait")
741
+ return parseWaitCommandTokens(commandArgs).downloadPath;
739
742
  return undefined;
740
743
  }
741
744
  async function statTimeoutArtifactPath(absolutePath) {
@@ -874,7 +877,7 @@ export async function collectTimeoutPartialProgress(options) {
874
877
  const artifacts = await collectTimeoutArtifactEvidence(options.cwd, rawSteps);
875
878
  const urlData = await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName });
876
879
  const recoveredUrl = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
877
- const titleData = recoveredUrl && !isFileUrl(recoveredUrl)
880
+ const titleData = recoveredUrl
878
881
  ? await runSessionCommandData({ args: ["get", "title"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName })
879
882
  : undefined;
880
883
  const title = extractStringResultField(titleData, "result") ?? extractStringResultField(titleData, "title");
@@ -909,7 +912,7 @@ function sanitizeCurrentPageUrlForTimeoutDiagnostic(url) {
909
912
  return redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(url));
910
913
  }
911
914
  }
912
- export function formatTimeoutPartialProgressText(progress) {
915
+ export function formatTimeoutPartialProgressText(progress, pageTargetUnknown = false) {
913
916
  const lines = [`Timeout partial progress: ${progress.summary}`];
914
917
  const currentPageTitle = progress.currentPage?.title ? redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(progress.currentPage.title)) : undefined;
915
918
  const currentPageUrl = progress.currentPage?.url ? sanitizeCurrentPageUrlForTimeoutDiagnostic(progress.currentPage.url) : undefined;
@@ -927,7 +930,10 @@ export function formatTimeoutPartialProgressText(progress) {
927
930
  lines.push(`- ... ${progress.steps.length - shownSteps.length} more step${progress.steps.length - shownSteps.length === 1 ? "" : "s"} omitted`);
928
931
  }
929
932
  if (progress.retryStep?.retry?.args) {
930
- lines.push(`Retry failed step: ${JSON.stringify({ args: redactInvocationArgs(progress.retryStep.retry.args) })}`);
933
+ const payload = JSON.stringify({ args: redactInvocationArgs(progress.retryStep.retry.args) });
934
+ lines.push(pageTargetUnknown
935
+ ? `Retry candidate for step ${progress.retryStep.index}: ${payload}. Verify the current URL before running it.`
936
+ : `Retry failed step: ${payload}`);
931
937
  }
932
938
  for (const artifact of progress.artifacts)
933
939
  lines.push(`Artifact from step ${artifact.stepIndex}: ${redactSensitivePathSegmentsForDiagnostic(artifact.path)} (${artifact.exists ? `exists${typeof artifact.sizeBytes === "number" ? `, ${artifact.sizeBytes} bytes` : ""}` : "missing"})`);