pi-agent-browser-native 0.2.72 → 0.2.74

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 (51) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +14 -12
  3. package/dist/extensions/agent-browser/index.js +104 -16
  4. package/dist/extensions/agent-browser/lib/argv-grammar.js +122 -0
  5. package/dist/extensions/agent-browser/lib/command-taxonomy.js +11 -0
  6. package/dist/extensions/agent-browser/lib/electron/cdp.js +2 -2
  7. package/dist/extensions/agent-browser/lib/electron/launch.js +48 -12
  8. package/dist/extensions/agent-browser/lib/input-modes/params.js +96 -98
  9. package/dist/extensions/agent-browser/lib/launch-scoped-flags.js +88 -2
  10. package/dist/extensions/agent-browser/lib/managed-session-capabilities.js +22 -0
  11. package/dist/extensions/agent-browser/lib/managed-session-policy-lock.js +432 -0
  12. package/dist/extensions/agent-browser/lib/managed-session-restore.js +367 -0
  13. package/dist/extensions/agent-browser/lib/managed-session-snapshots.js +367 -0
  14. package/dist/extensions/agent-browser/lib/managed-session-state-policy.js +589 -0
  15. package/dist/extensions/agent-browser/lib/managed-session-storage.js +299 -0
  16. package/dist/extensions/agent-browser/lib/orchestration/batch-stdin.js +35 -0
  17. package/dist/extensions/agent-browser/lib/orchestration/browser-run/artifact-paths.js +9 -2
  18. package/dist/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.js +40 -22
  19. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result.js +15 -6
  20. package/dist/extensions/agent-browser/lib/orchestration/browser-run/index.js +54 -33
  21. package/dist/extensions/agent-browser/lib/orchestration/browser-run/managed-session-daemon-policy.js +182 -0
  22. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/direct-anchor-download.js +1 -1
  23. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/network-page-filter.js +1 -1
  24. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/scroll-shims.js +1 -1
  25. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/snapshot-filter.js +1 -1
  26. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare.js +625 -429
  27. package/dist/extensions/agent-browser/lib/orchestration/browser-run/process-output.js +136 -56
  28. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state.js +28 -40
  29. package/dist/extensions/agent-browser/lib/orchestration/electron-host/index.js +102 -19
  30. package/dist/extensions/agent-browser/lib/orchestration/output-file.js +13 -1
  31. package/dist/extensions/agent-browser/lib/playbook.js +9 -8
  32. package/dist/extensions/agent-browser/lib/process-identity.js +82 -0
  33. package/dist/extensions/agent-browser/lib/process.js +270 -34
  34. package/dist/extensions/agent-browser/lib/results/artifact-manifest.js +5 -3
  35. package/dist/extensions/agent-browser/lib/results/presentation/common.js +2 -1
  36. package/dist/extensions/agent-browser/lib/results/presentation/diagnostics.js +35 -12
  37. package/dist/extensions/agent-browser/lib/results/presentation/managed-list-filter.js +42 -0
  38. package/dist/extensions/agent-browser/lib/results/recovery-actions.js +7 -0
  39. package/dist/extensions/agent-browser/lib/runtime.js +85 -85
  40. package/dist/extensions/agent-browser/lib/session-page-state.js +48 -17
  41. package/dist/extensions/agent-browser/lib/temp.js +13 -25
  42. package/docs/ARCHITECTURE.md +9 -8
  43. package/docs/COMMAND_REFERENCE.md +43 -23
  44. package/docs/ELECTRON.md +10 -10
  45. package/docs/RELEASE.md +3 -2
  46. package/docs/SUPPORT_MATRIX.md +19 -18
  47. package/docs/TOOL_CONTRACT.md +28 -25
  48. package/docs/platform-smoke.md +2 -2
  49. package/package.json +1 -1
  50. package/platform-smoke.config.mjs +1 -1
  51. package/scripts/agent-browser-capability-baseline.mjs +11 -3
@@ -0,0 +1,299 @@
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
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, isAbsolute, join, parse, resolve, win32 } from "node:path";
10
+ import { canonicalizeAgentBrowserNamespace } from "./argv-grammar.js";
11
+ export { isManagedSessionRestoreKey } from "./managed-session-capabilities.js";
12
+ const MANAGED_SESSION_NAME_PREFIX = "piab-r2-";
13
+ const MANAGED_SESSION_RESTORE_KEY_HASH_LENGTH = 32;
14
+ const PROJECT_GENERATION_MARKER_NAME = "pi-agent-browser-project-generation-v1.json";
15
+ const PROJECT_GENERATION_MARKER_MAX_BYTES = 1_024;
16
+ const projectGenerationCache = new Map();
17
+ function isAbsoluteHome(path, platform) {
18
+ return platform === "win32" ? win32.isAbsolute(path) : isAbsolute(path);
19
+ }
20
+ function currentUid() {
21
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
22
+ }
23
+ function isTrustedPosixDirectory(path, requireCurrentOwner) {
24
+ const uid = currentUid();
25
+ if (uid === undefined)
26
+ return false;
27
+ const root = parse(path).root;
28
+ let cursor = root;
29
+ for (const component of path.slice(root.length).split("/").filter(Boolean)) {
30
+ cursor = join(cursor, component);
31
+ let entry;
32
+ try {
33
+ entry = lstatSync(cursor);
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ if (entry.isSymbolicLink() || !entry.isDirectory())
39
+ return false;
40
+ if (entry.uid !== 0 && entry.uid !== uid)
41
+ return false;
42
+ const writableByOthers = (entry.mode & 0o022) !== 0;
43
+ const rootOwnedStickyDirectory = entry.uid === 0 && (entry.mode & 0o1000) !== 0;
44
+ if (writableByOthers && !rootOwnedStickyDirectory)
45
+ return false;
46
+ }
47
+ try {
48
+ const leaf = lstatSync(path);
49
+ return !requireCurrentOwner || leaf.uid === uid;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ export function resolveManagedSessionRestoreHome(parentEnv, platform = process.platform) {
56
+ const configuredHome = platform === "win32" ? parentEnv.USERPROFILE : parentEnv.HOME;
57
+ const candidate = configuredHome ?? homedir();
58
+ if (!candidate || candidate.trim() !== candidate || !isAbsoluteHome(candidate, platform))
59
+ return undefined;
60
+ if (platform === "win32")
61
+ return candidate;
62
+ try {
63
+ const canonical = realpathSync(candidate);
64
+ return isTrustedPosixDirectory(canonical, true) ? canonical : undefined;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ export function ensureOwnerOnlyDirectory(path, platform = process.platform) {
71
+ try {
72
+ try {
73
+ mkdirSync(path, { mode: 0o700 });
74
+ }
75
+ catch (error) {
76
+ if (error.code !== "EEXIST")
77
+ return false;
78
+ }
79
+ const entry = lstatSync(path);
80
+ if (entry.isSymbolicLink() || !entry.isDirectory())
81
+ return false;
82
+ if (platform === "win32")
83
+ return true;
84
+ const uid = currentUid();
85
+ return uid !== undefined && entry.uid === uid && (entry.mode & 0o077) === 0;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ export function directoryContainsSymlink(path) {
92
+ try {
93
+ return readdirSync(path, { withFileTypes: true }).some((entry) => entry.isSymbolicLink());
94
+ }
95
+ catch {
96
+ return true;
97
+ }
98
+ }
99
+ function resolveGitCheckout(cwd, platform) {
100
+ let directory = cwd;
101
+ while (true) {
102
+ const dotGit = join(directory, ".git");
103
+ try {
104
+ const entry = lstatSync(dotGit);
105
+ if (entry.isDirectory() && !entry.isSymbolicLink())
106
+ return { gitDirectory: realpathSync(dotGit), worktreeDirectory: realpathSync(directory) };
107
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > PROJECT_GENERATION_MARKER_MAX_BYTES)
108
+ return undefined;
109
+ if (platform !== "win32") {
110
+ const uid = currentUid();
111
+ if (uid === undefined || entry.uid !== uid || (entry.mode & 0o022) !== 0)
112
+ return undefined;
113
+ }
114
+ const match = /^gitdir:\s*(.+)\s*$/i.exec(readFileSync(dotGit, "utf8"));
115
+ if (!match?.[1])
116
+ return undefined;
117
+ return { gitDirectory: realpathSync(resolve(directory, match[1])), worktreeDirectory: realpathSync(directory) };
118
+ }
119
+ catch (error) {
120
+ if (error.code !== "ENOENT")
121
+ return undefined;
122
+ }
123
+ const parent = dirname(directory);
124
+ if (parent === directory)
125
+ return undefined;
126
+ directory = parent;
127
+ }
128
+ }
129
+ function readProjectGenerationMarker(path, platform) {
130
+ try {
131
+ const entry = lstatSync(path);
132
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > PROJECT_GENERATION_MARKER_MAX_BYTES)
133
+ return undefined;
134
+ if (platform !== "win32") {
135
+ const uid = currentUid();
136
+ if (uid === undefined || entry.uid !== uid || (entry.mode & 0o177) !== 0)
137
+ return undefined;
138
+ }
139
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
140
+ return parsed.version === 1 && typeof parsed.id === "string" && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(parsed.id) ? parsed.id : undefined;
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ }
146
+ function getDirectoryFilesystemIdentity(path) {
147
+ try {
148
+ 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;
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ }
157
+ function resolveManagedSessionRestoreProjectCheckout(cwd, platform) {
158
+ let canonicalCwd;
159
+ try {
160
+ canonicalCwd = realpathSync(cwd);
161
+ }
162
+ catch {
163
+ return undefined;
164
+ }
165
+ if (platform !== "win32" && !isTrustedPosixDirectory(canonicalCwd, false))
166
+ return undefined;
167
+ const checkout = resolveGitCheckout(canonicalCwd, platform);
168
+ if (!checkout)
169
+ return undefined;
170
+ if (platform !== "win32" && (!isTrustedPosixDirectory(checkout.worktreeDirectory, true)
171
+ || !isTrustedPosixDirectory(checkout.gitDirectory, true)))
172
+ return undefined;
173
+ return { canonicalCwd, ...checkout };
174
+ }
175
+ export function resolveManagedSessionRestoreCheckoutRoot(cwd, platform = process.platform) {
176
+ return resolveManagedSessionRestoreProjectCheckout(cwd, platform)?.worktreeDirectory;
177
+ }
178
+ function resolveProjectGenerationIdentity(cwd, platform = process.platform) {
179
+ const checkout = resolveManagedSessionRestoreProjectCheckout(cwd, platform);
180
+ if (!checkout)
181
+ return undefined;
182
+ const { canonicalCwd } = checkout;
183
+ const gitFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.gitDirectory);
184
+ const worktreeFilesystemIdentity = getDirectoryFilesystemIdentity(checkout.worktreeDirectory);
185
+ if (!gitFilesystemIdentity || !worktreeFilesystemIdentity)
186
+ return undefined;
187
+ const markerPath = join(checkout.gitDirectory, PROJECT_GENERATION_MARKER_NAME);
188
+ let marker = readProjectGenerationMarker(markerPath, platform);
189
+ const cached = projectGenerationCache.get(canonicalCwd);
190
+ if (cached
191
+ && cached.gitDirectory === checkout.gitDirectory
192
+ && cached.worktreeDirectory === checkout.worktreeDirectory
193
+ && cached.gitFilesystemIdentity === gitFilesystemIdentity
194
+ && cached.worktreeFilesystemIdentity === worktreeFilesystemIdentity
195
+ && cached.marker === marker)
196
+ return cached.identity;
197
+ projectGenerationCache.delete(canonicalCwd);
198
+ try {
199
+ 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 });
203
+ try {
204
+ linkSync(candidatePath, markerPath);
205
+ }
206
+ catch (error) {
207
+ if (error.code !== "EEXIST")
208
+ return undefined;
209
+ }
210
+ }
211
+ finally {
212
+ try {
213
+ unlinkSync(candidatePath);
214
+ }
215
+ catch { }
216
+ }
217
+ marker = readProjectGenerationMarker(markerPath, platform);
218
+ }
219
+ if (!marker)
220
+ return undefined;
221
+ const identity = `${platform}:${worktreeFilesystemIdentity}:${gitFilesystemIdentity}:${marker}`;
222
+ projectGenerationCache.set(canonicalCwd, {
223
+ gitDirectory: checkout.gitDirectory,
224
+ gitFilesystemIdentity,
225
+ identity,
226
+ marker,
227
+ worktreeDirectory: checkout.worktreeDirectory,
228
+ worktreeFilesystemIdentity,
229
+ });
230
+ return identity;
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ }
236
+ export function hasManagedSessionRestoreProjectIdentity(cwd) {
237
+ return resolveProjectGenerationIdentity(cwd) !== undefined;
238
+ }
239
+ /** Stable for one checkout generation; deliberately changes when a path is replaced by another checkout. */
240
+ export function createManagedSessionRestoreKey(cwd) {
241
+ let canonicalCwd = resolve(cwd);
242
+ try {
243
+ canonicalCwd = realpathSync(canonicalCwd);
244
+ }
245
+ catch { }
246
+ const identity = resolveProjectGenerationIdentity(canonicalCwd);
247
+ const material = identity ?? `unavailable:${canonicalCwd}`;
248
+ const digest = createHash("sha256").update(`restore-v2:${material}`).digest("hex").slice(0, MANAGED_SESSION_RESTORE_KEY_HASH_LENGTH);
249
+ return `${MANAGED_SESSION_NAME_PREFIX}${digest}`;
250
+ }
251
+ function hasValidEncryptionKey(parentEnv) {
252
+ const value = parentEnv.AGENT_BROWSER_ENCRYPTION_KEY;
253
+ return typeof value === "string" && /^[a-f\d]{64}$/i.test(value);
254
+ }
255
+ export function getManagedRestoreSessionsDirectory(home, namespace) {
256
+ const canonicalNamespace = canonicalizeAgentBrowserNamespace(namespace);
257
+ return canonicalNamespace
258
+ ? join(home, ".agent-browser", "namespaces", canonicalNamespace, "state", "sessions")
259
+ : join(home, ".agent-browser", "sessions");
260
+ }
261
+ /** Require the upstream 256-bit key format and secure every directory that can receive restore snapshots. */
262
+ export function ensureManagedSessionRestoreStorageIsSecure(parentEnv = process.env, platform = process.platform, namespace) {
263
+ const encryptionKey = parentEnv.AGENT_BROWSER_ENCRYPTION_KEY;
264
+ if (encryptionKey !== undefined && !hasValidEncryptionKey(parentEnv))
265
+ return false;
266
+ if (platform === "win32")
267
+ return hasValidEncryptionKey(parentEnv);
268
+ const home = resolveManagedSessionRestoreHome(parentEnv, platform);
269
+ if (!home)
270
+ return false;
271
+ const root = join(home, ".agent-browser");
272
+ if (!ensureOwnerOnlyDirectory(root, platform))
273
+ return false;
274
+ const canonicalNamespace = canonicalizeAgentBrowserNamespace(namespace);
275
+ const stateComponents = canonicalNamespace
276
+ ? ["namespaces", canonicalNamespace, "state", "sessions"]
277
+ : ["sessions"];
278
+ let path = root;
279
+ for (const component of stateComponents) {
280
+ path = join(path, component);
281
+ if (!ensureOwnerOnlyDirectory(path, platform))
282
+ return false;
283
+ }
284
+ if (directoryContainsSymlink(path))
285
+ return false;
286
+ const temporaryDirectory = join(path, ".tmp");
287
+ return ensureOwnerOnlyDirectory(temporaryDirectory, platform) && !directoryContainsSymlink(temporaryDirectory);
288
+ }
289
+ export function getManagedSessionRestoreProtectedStorageEnv(restoreEnabled, parentEnv, platform = process.platform) {
290
+ if (!restoreEnabled)
291
+ return {};
292
+ const home = resolveManagedSessionRestoreHome(parentEnv, platform);
293
+ if (!home)
294
+ return {};
295
+ return {
296
+ AGENT_BROWSER_ENCRYPTION_KEY: parentEnv.AGENT_BROWSER_ENCRYPTION_KEY,
297
+ ...(platform === "win32" ? { USERPROFILE: home } : { HOME: home }),
298
+ };
299
+ }
@@ -1,3 +1,38 @@
1
+ // Mirror upstream commands::shell_words_split so policy inspection sees the same argv.
2
+ export function parseBatchCommandArgument(command) {
3
+ const tokens = [];
4
+ let token = "";
5
+ let inDoubleQuote = false;
6
+ let inSingleQuote = false;
7
+ for (let index = 0; index < command.length; index += 1) {
8
+ const character = command[index];
9
+ if (character === "\\" && !inSingleQuote) {
10
+ const next = command[index + 1];
11
+ if (next !== undefined) {
12
+ token += next;
13
+ index += 1;
14
+ }
15
+ }
16
+ else if (character === '"' && !inSingleQuote) {
17
+ inDoubleQuote = !inDoubleQuote;
18
+ }
19
+ else if (character === "'" && !inDoubleQuote) {
20
+ inSingleQuote = !inSingleQuote;
21
+ }
22
+ else if (character === " " && !inDoubleQuote && !inSingleQuote) {
23
+ if (token !== "") {
24
+ tokens.push(token);
25
+ token = "";
26
+ }
27
+ }
28
+ else {
29
+ token += character;
30
+ }
31
+ }
32
+ if (token !== "")
33
+ tokens.push(token);
34
+ return tokens.length > 0 ? { step: tokens } : { error: "batch command is empty" };
35
+ }
1
36
  function validateUserBatchStep(step, index) {
2
37
  if (!Array.isArray(step)) {
3
38
  return {
@@ -1,4 +1,6 @@
1
1
  import { extname, isAbsolute } from "node:path";
2
+ import { GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, VALUE_FLAGS } from "../../argv-grammar.js";
3
+ const SCREENSHOT_BOOLEAN_FLAGS = new Set(["--annotate", "--full", "-f"]);
2
4
  const SCREENSHOT_VALUE_FLAGS = new Set(["--screenshot-dir", "--screenshot-format", "--screenshot-quality"]);
3
5
  const SCREENSHOT_IMAGE_EXTENSIONS = new Set([".jpeg", ".jpg", ".png", ".webp"]);
4
6
  function isImagePathToken(token) {
@@ -20,10 +22,15 @@ export function getScreenshotPathTokenIndex(commandTokens) {
20
22
  }
21
23
  if (token.startsWith("-")) {
22
24
  const normalizedToken = token.split("=", 1)[0] ?? token;
23
- if (SCREENSHOT_VALUE_FLAGS.has(normalizedToken) && !token.includes("=")) {
25
+ if ((SCREENSHOT_VALUE_FLAGS.has(normalizedToken) || VALUE_FLAGS.has(normalizedToken)) && !token.includes("=")) {
24
26
  index += 1;
27
+ continue;
28
+ }
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;
25
33
  }
26
- continue;
27
34
  }
28
35
  positionalIndices.push(index);
29
36
  }
@@ -10,6 +10,7 @@ import { buildVisibleRefFallbackDiagnosticFromSnapshot, getVisibleRefFallbackTar
10
10
  import { extractRefSnapshotFromData, normalizeComparableUrl } from "../../session-page-state.js";
11
11
  import { redactInvocationArgs, redactSensitiveText } from "../../runtime.js";
12
12
  import { isRecord } from "../../parsing.js";
13
+ import { getManagedSessionStateAccessValidationError, isFileUrl } from "../../managed-session-state-policy.js";
13
14
  import { extractBatchResultCommand, extractNavigationSummaryFromData, extractStringResultField, findElectronLaunchRecordForSession, runSessionCommandData, } from "./session-state.js";
14
15
  import { parseValidBatchStepEntries } from "../batch-stdin.js";
15
16
  import { getScreenshotPathTokenIndex } from "./artifact-paths.js";
@@ -19,17 +20,12 @@ export function sleepMs(ms) {
19
20
  }
20
21
  export async function collectNavigationSummary(options) {
21
22
  const url = extractStringResultField(await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal }), "url");
23
+ if (!url || !/^[a-z][a-z0-9+.-]*:/i.test(url))
24
+ return undefined;
25
+ if (isFileUrl(url))
26
+ return { url };
22
27
  const title = extractStringResultField(await runSessionCommandData({ args: ["get", "title"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal }), "title");
23
- if (url && /^[a-z][a-z0-9+.-]*:/i.test(url))
24
- return { title, url };
25
- return extractNavigationSummaryFromData(await runSessionCommandData({
26
- args: ["eval", "--stdin"],
27
- cwd: options.cwd,
28
- namespace: options.namespace,
29
- sessionName: options.sessionName,
30
- signal: options.signal,
31
- stdin: `({ title: document.title, url: location.href })`,
32
- }));
28
+ return { title, url };
33
29
  }
34
30
  function extractScrollPositionSnapshot(data) {
35
31
  const result = isRecord(data) && isRecord(data.result) ? data.result : data;
@@ -507,7 +503,7 @@ export function formatArtifactCleanupGuidanceText(guidance) {
507
503
  }
508
504
  async function collectManagedSessionCommandData(options) {
509
505
  try {
510
- return { data: await runSessionCommandData(options) };
506
+ return { data: await runSessionCommandData({ ...options, pinNamespace: true }) };
511
507
  }
512
508
  catch (error) {
513
509
  return { error: error instanceof Error ? error.message : String(error) };
@@ -528,14 +524,16 @@ async function collectElectronManagedSessionUrl(options) {
528
524
  export async function collectElectronManagedSessionTarget(options) {
529
525
  if (!options.sessionName)
530
526
  return undefined;
531
- const [titleResult, urlResult] = await Promise.all([
532
- collectManagedSessionCommandData({ args: ["get", "title"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs }),
533
- collectManagedSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs }),
534
- ]);
535
- const title = boundElectronProbeString(extractStringResultField(titleResult.data, "result") ?? extractStringResultField(titleResult.data, "title"), 160);
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 });
536
528
  const url = boundElectronProbeString(extractStringResultField(urlResult.data, "result") ?? extractStringResultField(urlResult.data, "url"), 300);
537
- const errors = [titleResult.error, urlResult.error].filter((value) => value !== undefined);
538
- return { sessionName: options.sessionName, title, url, ...(errors.length > 0 ? { error: errors.join("; ") } : {}) };
529
+ if (urlResult.error || !url)
530
+ 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 });
535
+ const title = boundElectronProbeString(extractStringResultField(titleResult.data, "result") ?? extractStringResultField(titleResult.data, "title"), 160);
536
+ return { sessionName: options.sessionName, title, url, ...(titleResult.error ? { error: titleResult.error } : {}) };
539
537
  }
540
538
  export async function collectQaAttachedTarget(options) {
541
539
  if (!options.sessionName)
@@ -673,16 +671,33 @@ export async function collectVisibleRefFallbackDiagnostic(options) {
673
671
  export async function collectElectronHandoff(options) {
674
672
  if (options.handoff === "connect")
675
673
  return { handoff: "connect" };
676
- const tabs = await runSessionCommandData({ args: ["tab", "list"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal });
674
+ if (options.signal?.aborted)
675
+ throw new Error("Electron handoff was aborted.");
676
+ const urlData = await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, throwOnFailure: true });
677
+ if (options.signal?.aborted)
678
+ throw new Error("Electron handoff was aborted.");
679
+ const url = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
680
+ if (!url)
681
+ 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
+ const tabs = await runSessionCommandData({ args: ["tab", "list"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, throwOnFailure: true });
686
+ if (options.signal?.aborted)
687
+ throw new Error("Electron handoff was aborted.");
677
688
  if (options.handoff === "tabs")
678
689
  return { handoff: "tabs", tabs };
679
- let snapshot = await runSessionCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal });
690
+ let snapshot = await runSessionCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, throwOnFailure: true });
680
691
  let refSnapshot = extractRefSnapshotFromData(snapshot);
681
692
  let snapshotRetryCount = 0;
682
693
  while ((!refSnapshot || refSnapshot.refIds.length === 0) && snapshotRetryCount < 2) {
694
+ if (options.signal?.aborted)
695
+ throw new Error("Electron handoff was aborted.");
683
696
  snapshotRetryCount += 1;
684
697
  await sleepMs(250);
685
- snapshot = await runSessionCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal });
698
+ if (options.signal?.aborted)
699
+ throw new Error("Electron handoff was aborted.");
700
+ snapshot = await runSessionCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName, signal: options.signal, throwOnFailure: true });
686
701
  refSnapshot = extractRefSnapshotFromData(snapshot);
687
702
  }
688
703
  return { handoff: "snapshot", refSnapshot, snapshot, ...(snapshotRetryCount > 0 ? { snapshotRetryCount } : {}), tabs };
@@ -857,8 +872,11 @@ function buildTimeoutProgressSteps(options) {
857
872
  export async function collectTimeoutPartialProgress(options) {
858
873
  const rawSteps = getTimeoutProgressSteps(options.compiledJob, options.command, options.stdin);
859
874
  const artifacts = await collectTimeoutArtifactEvidence(options.cwd, rawSteps);
860
- const [urlData, titleData] = await Promise.all([runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName }), runSessionCommandData({ args: ["get", "title"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName })]);
875
+ const urlData = await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName });
861
876
  const recoveredUrl = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
877
+ const titleData = recoveredUrl && !isFileUrl(recoveredUrl)
878
+ ? await runSessionCommandData({ args: ["get", "title"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName })
879
+ : undefined;
862
880
  const title = extractStringResultField(titleData, "result") ?? extractStringResultField(titleData, "title");
863
881
  const plannedUrl = recoveredUrl ? undefined : getPlannedCurrentPageUrl(rawSteps);
864
882
  const url = recoveredUrl ?? plannedUrl;
@@ -6,7 +6,8 @@ import { AgentBrowserNextActionCollector, alignPageChangeSummaryNextActionIds, a
6
6
  import { buildConnectedSessionNextActions, buildNoActivePageNextActions, buildSessionAwareStaleRefNextActions, buildSessionTabRecoveryNextActions, } from "../../results/recovery-next-actions.js";
7
7
  import { buildRichInputRecoveryDiagnostic, buildRichInputRecoveryNextActions, buildVisibleRefFallbackNextActions, formatRichInputRecoveryText, formatVisibleRefFallbackText, sanitizeVisibleRefFallbackDiagnostic, } from "../../results/selector-recovery.js";
8
8
  import { buildNoActivePageRefSnapshotInvalidation, isNoActivePageSnapshotFailure, } from "../../session-page-state.js";
9
- import { extractExplicitSessionName, redactInvocationArgs, redactSensitiveText, redactSensitiveValue } from "../../runtime.js";
9
+ import { extractExplicitSessionName } from "../../argv-grammar.js";
10
+ import { redactInvocationArgs, redactSensitiveText, redactSensitiveValue } from "../../runtime.js";
10
11
  import { isRecord } from "../../parsing.js";
11
12
  import { buildClickDispatchNextActions, formatClickDispatchDiagnosticText } from "./click-dispatch.js";
12
13
  import { buildComboboxFocusNextActions, buildElectronBroadGetTextScopeNextActions, buildFillVerificationNextActions, buildOverlayBlockerNextActions, buildScrollNoopNextActions, buildSelectorTextVisibilityNextActions, buildSourceLookupElectronNextActions, collectVisibleRefFallbackDiagnostic, formatArtifactCleanupGuidanceText, formatComboboxFocusDiagnosticText, formatElectronBroadGetTextScopeText, formatEvalResultWarningText, formatEvalStdinHintText, formatFillVerificationText, formatOverlayBlockerText, formatRecordingDependencyWarningText, formatScrollNoopDiagnosticText, formatSelectorTextVisibilityText, formatTimeoutPartialProgressText, } from "./diagnostics.js";
@@ -90,6 +91,8 @@ export function buildJsonVisibleContent(options) {
90
91
  return [{ type: "text", text: JSON.stringify(payload, null, 2) }, ...images];
91
92
  }
92
93
  export function getElectronLaunchFailureCategory(failure) {
94
+ if (failure.reason === "aborted")
95
+ return "aborted";
93
96
  if (failure.reason === "policy-blocked")
94
97
  return "policy-blocked";
95
98
  if (failure.reason === "timeout")
@@ -122,7 +125,8 @@ function formatElectronLaunchFailureDiagnostics(failure) {
122
125
  lines.push(`- Timing: ${diagnostics.elapsedMs ?? "unknown"}ms elapsed${diagnostics.timeoutMs !== undefined ? ` of ${diagnostics.timeoutMs}ms timeout` : ""}.`);
123
126
  if (diagnostics.outputCaptured === false)
124
127
  lines.push("- App stdout/stderr: not captured by this wrapper launch path.");
125
- lines.push("Retry guidance: increase electron.timeoutMs, try targetType:'any', pass an explicit appPath/executablePath, quit any already-running singleton instance, then retry launch.");
128
+ if (failure?.reason !== "aborted")
129
+ lines.push("Retry guidance: increase electron.timeoutMs, try targetType:'any', pass an explicit appPath/executablePath, quit any already-running singleton instance, then retry launch.");
126
130
  return lines.join("\n");
127
131
  }
128
132
  export function buildElectronHostFailureResult(options) {
@@ -385,14 +389,15 @@ function buildAgentBrowserResultDetails(options, nextActions) {
385
389
  sessionMode: options.sessionMode,
386
390
  sessionTabCorrection: options.sessionTabCorrection,
387
391
  sessionTabTarget: options.currentSessionTabTarget,
392
+ sessionTabTargetUnknown: options.currentSessionTabTargetUnknown,
388
393
  refSnapshot: options.currentRefSnapshot,
389
394
  refSnapshotInvalidation: options.currentRefSnapshotInvalidation,
390
395
  namespace: options.executionPlan.namespace,
391
- ...buildSessionDetailFields(options.executionPlan.sessionName, options.executionPlan.usedImplicitSession),
396
+ ...buildSessionDetailFields(options.executionPlan.sessionName, options.executionPlan.usedImplicitSession, options.executionPlan.namespace, options.managedSessionRestoreDisabled),
392
397
  sessionRecoveryHint: options.redactedRecoveryHint,
393
398
  startupScopedFlags: options.executionPlan.startupScopedFlags,
394
399
  stderr: options.processResult.stderr,
395
- stdout: options.plainTextInspection ? options.inspectionText ?? "" : options.parseSucceeded ? undefined : options.processResult.stdout,
400
+ stdout: options.plainTextInspection ? options.inspectionText ?? "" : undefined,
396
401
  summary: options.presentation.summary,
397
402
  timedOut: options.processResult.timedOut || undefined,
398
403
  timeoutMs: options.processResult.timeoutMs,
@@ -428,9 +433,13 @@ export function buildFinalAgentBrowserToolResult(options) {
428
433
  const result = { content, details: redactToolDetails(details, options.exactSensitiveValues), isError: !options.succeeded };
429
434
  return options.compiledNetworkSourceLookup ? redactNetworkSourceLookupSurface(result) : result;
430
435
  }
436
+ export function isMissingAgentBrowserBinary(processResult) {
437
+ return processResult.spawnError?.message.includes("ENOENT") === true;
438
+ }
431
439
  export async function buildMissingBinaryFailureResult(options) {
432
- if (!options.processResult.spawnError?.message.includes("ENOENT"))
440
+ if (!isMissingAgentBrowserBinary(options.processResult))
433
441
  return undefined;
442
+ const spawnError = options.processResult.spawnError.message;
434
443
  const errorText = buildMissingBinaryMessage();
435
444
  const managedSessionOutcome = buildManagedSessionOutcome({ activeAfter: options.managedSessionActive, activeBefore: options.managedSessionActive, attemptedSessionName: options.executionPlan.managedSessionName, command: options.executionPlan.commandInfo.command, currentSessionName: options.managedSessionName, currentSessionNamespace: options.managedSessionNamespace, previousSessionName: options.managedSessionName, sessionMode: options.sessionMode, succeeded: false });
436
445
  const managedSessionOutcomeText = formatManagedSessionOutcomeText(managedSessionOutcome);
@@ -442,5 +451,5 @@ export async function buildMissingBinaryFailureResult(options) {
442
451
  missingBinaryElectronRecord = missingBinaryElectronCleanup.record;
443
452
  }
444
453
  const textParts = [errorText, managedSessionOutcomeText, missingBinaryElectronCleanup ? `Electron cleanup after failed attach: ${missingBinaryElectronCleanup.summary}` : undefined].filter((part) => part !== undefined && part.length > 0);
445
- return { content: [{ type: "text", text: textParts.join("\n\n") }], details: { args: options.redactedArgs, compatibilityWorkaround: options.compatibilityWorkaround, effectiveArgs: options.redactedProcessArgs, electron: missingBinaryElectronRecord ? { action: "launch", cleanup: missingBinaryElectronCleanup, launch: missingBinaryElectronRecord, status: "failed", targets: options.electronLaunch?.targets, version: options.electronLaunch?.version } : undefined, managedSessionOutcome, namespace: options.executionPlan.namespace, nextActions: managedSessionRecoveryNextActions.length > 0 ? managedSessionRecoveryNextActions : undefined, sessionMode: options.sessionMode, sessionTabCorrection: options.sessionTabCorrection, ...buildAgentBrowserResultCategoryDetails({ args: options.redactedProcessArgs, command: options.executionPlan.commandInfo.command, errorText, failureCategory: "missing-binary", spawnError: options.processResult.spawnError.message, succeeded: false }), spawnError: options.processResult.spawnError.message }, isError: true };
454
+ return { content: [{ type: "text", text: textParts.join("\n\n") }], details: { args: options.redactedArgs, compatibilityWorkaround: options.compatibilityWorkaround, effectiveArgs: options.redactedProcessArgs, electron: missingBinaryElectronRecord ? { action: "launch", cleanup: missingBinaryElectronCleanup, launch: missingBinaryElectronRecord, status: "failed", targets: options.electronLaunch?.targets, version: options.electronLaunch?.version } : undefined, managedSessionOutcome, namespace: options.executionPlan.namespace, nextActions: managedSessionRecoveryNextActions.length > 0 ? managedSessionRecoveryNextActions : undefined, sessionMode: options.sessionMode, sessionTabCorrection: options.sessionTabCorrection, ...buildAgentBrowserResultCategoryDetails({ args: options.redactedProcessArgs, command: options.executionPlan.commandInfo.command, errorText, failureCategory: "missing-binary", spawnError, succeeded: false }), spawnError }, isError: true };
446
455
  }
@@ -1,10 +1,12 @@
1
1
  import { runAgentBrowserProcess } from "../../process.js";
2
+ import { withOwnedManagedSessionContext } from "../../managed-session-restore.js";
2
3
  import { cleanupClickDispatchProbe } from "./click-dispatch.js";
3
4
  import { applyBrowserRunStatePatch } from "./session-state.js";
4
5
  import { buildMissingBinaryFailureResult } from "./final-result.js";
5
6
  import { prepareBrowserRun } from "./prepare.js";
6
7
  import { processBrowserOutput } from "./process-output.js";
7
- export { closeManagedSession, getSessionContextKey } from "./session-state.js";
8
+ export { closeManagedSession } from "./managed-session-daemon-policy.js";
9
+ export { getSessionContextKey } from "./session-state.js";
8
10
  export async function runAgentBrowserTool(options) {
9
11
  const preparedResult = await prepareBrowserRun(options);
10
12
  applyBrowserRunStatePatch(options.state, preparedResult.kind === "ready" ? preparedResult.prepared.statePatch : preparedResult.statePatch);
@@ -12,37 +14,56 @@ export async function runAgentBrowserTool(options) {
12
14
  return preparedResult.result;
13
15
  }
14
16
  const { prepared } = preparedResult;
15
- try {
16
- const processResult = await runAgentBrowserProcess({
17
- args: prepared.processArgs,
18
- cwd: options.cwd,
19
- env: prepared.executionPlan.managedSessionName ? { AGENT_BROWSER_IDLE_TIMEOUT_MS: options.implicitSessionIdleTimeoutMs } : undefined,
20
- signal: options.signal,
21
- stdin: prepared.processStdin,
22
- timeoutMs: prepared.processTimeoutMs,
23
- });
24
- const missingBinaryResult = await buildMissingBinaryFailureResult({
25
- compatibilityWorkaround: prepared.compatibilityWorkaround,
26
- electronLaunch: prepared.electronLaunch,
27
- executionPlan: prepared.executionPlan,
28
- implicitSessionCloseTimeoutMs: options.implicitSessionCloseTimeoutMs,
29
- managedSessionActive: options.state.managedSessionActive,
30
- managedSessionName: options.state.managedSessionName,
31
- managedSessionNamespace: options.state.managedSessionNamespace,
32
- processResult,
33
- redactedArgs: prepared.redactedArgs,
34
- redactedProcessArgs: prepared.redactedProcessArgs,
35
- sessionMode: prepared.sessionMode,
36
- sessionTabCorrection: prepared.sessionTabCorrection,
37
- });
38
- if (missingBinaryResult) {
39
- return missingBinaryResult;
17
+ const ownedManagedSession = prepared.ownedManagedSessionContext;
18
+ return await withOwnedManagedSessionContext(ownedManagedSession, async () => {
19
+ try {
20
+ const processResult = await runAgentBrowserProcess({
21
+ args: prepared.processArgs,
22
+ cwd: options.cwd,
23
+ env: ownedManagedSession
24
+ ? { AGENT_BROWSER_IDLE_TIMEOUT_MS: options.implicitSessionIdleTimeoutMs }
25
+ : undefined,
26
+ managedSessionRestoreState: options.state.managedSessionRestoreState,
27
+ managedStateCurrentPageUrl: prepared.priorSessionTabTarget?.url,
28
+ managedStatePageUrlUnknown: prepared.priorSessionTabTargetUnknown === true,
29
+ ownedManagedSession: ownedManagedSession !== undefined,
30
+ signal: options.signal,
31
+ stdin: prepared.processStdin,
32
+ timeoutMs: prepared.processTimeoutMs,
33
+ trustedFirstBatchTabSelection: prepared.pinnedBatchUnwrapMode !== undefined,
34
+ });
35
+ const missingBinaryResult = await buildMissingBinaryFailureResult({
36
+ compatibilityWorkaround: prepared.compatibilityWorkaround,
37
+ electronLaunch: prepared.electronLaunch,
38
+ executionPlan: prepared.executionPlan,
39
+ implicitSessionCloseTimeoutMs: options.implicitSessionCloseTimeoutMs,
40
+ managedSessionActive: options.state.managedSessionActive,
41
+ managedSessionName: options.state.managedSessionName,
42
+ managedSessionNamespace: options.state.managedSessionNamespace,
43
+ processResult,
44
+ redactedArgs: prepared.redactedArgs,
45
+ redactedProcessArgs: prepared.redactedProcessArgs,
46
+ sessionMode: prepared.sessionMode,
47
+ sessionTabCorrection: prepared.sessionTabCorrection,
48
+ });
49
+ if (missingBinaryResult)
50
+ return missingBinaryResult;
51
+ const output = await processBrowserOutput({ ...options, prepared, processResult });
52
+ applyBrowserRunStatePatch(options.state, output.statePatch);
53
+ return output.result;
40
54
  }
41
- const output = await processBrowserOutput({ ...options, prepared, processResult });
42
- applyBrowserRunStatePatch(options.state, output.statePatch);
43
- return output.result;
44
- }
45
- finally {
46
- await cleanupClickDispatchProbe({ cwd: options.cwd, namespace: prepared.executionPlan.namespace, probe: prepared.clickDispatchProbe, sessionName: prepared.executionPlan.sessionName });
47
- }
55
+ finally {
56
+ try {
57
+ await cleanupClickDispatchProbe({
58
+ cwd: options.cwd,
59
+ namespace: prepared.executionPlan.namespace,
60
+ probe: prepared.clickDispatchProbe,
61
+ sessionName: prepared.executionPlan.sessionName,
62
+ });
63
+ }
64
+ finally {
65
+ await prepared.managedSessionPolicyLock?.release();
66
+ }
67
+ }
68
+ });
48
69
  }