pi-agent-browser-native 0.6.15 → 0.6.17

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.
@@ -316,9 +316,9 @@ export function isSessionTabPinningExcludedCommand(command) {
316
316
  export function isSessionTabPostCommandCorrectionExcludedCommand(command) {
317
317
  return hasCommandCapability(command, "excludedFromPostCommandCorrection");
318
318
  }
319
- export function getRecordCommandOperands(tokens) {
319
+ export function getRecordCommandOperandIndices(tokens) {
320
320
  if (tokens[0] !== "record" || !["start", "restart"].includes(tokens[1] ?? ""))
321
- return {};
321
+ return [];
322
322
  const operands = [];
323
323
  for (let index = 2; index < tokens.length && operands.length < 2; index += 1) {
324
324
  // Native validates the range; bare/non-numeric --fps keeps its old literal meaning.
@@ -329,9 +329,13 @@ export function getRecordCommandOperands(tokens) {
329
329
  else if (tokens[index] === "--contact-sheet-threshold" && tokens[index + 1] !== undefined)
330
330
  index += 1;
331
331
  else
332
- operands.push(tokens[index]);
332
+ operands.push(index);
333
333
  }
334
- return operands.length > 0 ? { path: operands[0], url: operands[1] } : { path: tokens[2], url: tokens[3] };
334
+ return operands.length > 0 ? operands : [2, 3];
335
+ }
336
+ export function getRecordCommandOperands(tokens) {
337
+ const [path, url] = getRecordCommandOperandIndices(tokens);
338
+ return path === undefined ? {} : { path: tokens[path], url: url === undefined ? undefined : tokens[url] };
335
339
  }
336
340
  /** Starts conservatively invalidate refs because older supported natives replace the page, even on failure. Restarts invalidate only when they have a URL. */
337
341
  export function isRecordPageTransitionCommand(tokens) {
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { lstat, rm } from "node:fs/promises";
3
+ import { win32 } from "node:path";
3
4
  import { promisify } from "node:util";
4
5
  import { fetchCdpJson, parseCdpTargets, parseCdpVersion } from "./cdp.js";
5
6
  import { ELECTRON_PROFILE_DIR_PREFIX } from "./launch.js";
@@ -53,21 +54,35 @@ export async function inspectElectronLaunchStatus(record, signal) {
53
54
  version: cdp.version,
54
55
  };
55
56
  }
57
+ function hasProcessExited(child, pid) {
58
+ // A Windows PID probe can report ESRCH during termination, before the
59
+ // tracked process has exited and released its executable/resources.
60
+ return child ? child.exitCode !== null || child.signalCode !== null : isPidAlive(pid) === false;
61
+ }
56
62
  async function waitForProcessExit(child, pid, deadlineMs) {
57
63
  while (Date.now() <= deadlineMs) {
58
- if (child && (child.exitCode !== null || child.signalCode !== null))
59
- return true;
60
- if (isPidAlive(pid) === false)
64
+ if (hasProcessExited(child, pid))
61
65
  return true;
62
66
  await sleep(ELECTRON_CLEANUP_POLL_INTERVAL_MS);
63
67
  }
64
- return isPidAlive(pid) === false;
68
+ return hasProcessExited(child, pid);
65
69
  }
66
70
  async function readPidCommandLine(pid) {
67
71
  if (!pid || !Number.isSafeInteger(pid) || pid <= 0)
68
72
  return undefined;
69
73
  try {
70
- const { stdout } = await execFileAsync("ps", ["-ww", "-p", String(pid), "-o", "command="], {
74
+ // Win32_Process.CommandLine is the native ownership evidence; process
75
+ // start time (used by daemon-policy locks) cannot prove profile ownership.
76
+ const systemRoot = process.env.SystemRoot;
77
+ const windowsRoot = systemRoot && win32.isAbsolute(systemRoot) ? systemRoot : "C:\\Windows";
78
+ const file = process.platform === "win32"
79
+ ? win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
80
+ : "ps";
81
+ const args = process.platform === "win32"
82
+ ? ["-NoProfile", "-NonInteractive", "-Command",
83
+ `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); $p = [wmi]"Win32_Process.Handle='${pid}'"; [Console]::WriteLine($p.CommandLine)`]
84
+ : ["-ww", "-p", String(pid), "-o", "command="];
85
+ const { stdout } = await execFileAsync(file, args, {
71
86
  timeout: RESTORED_PROCESS_COMMAND_TIMEOUT_MS,
72
87
  });
73
88
  return stdout.trim() || undefined;
@@ -117,7 +132,7 @@ function signalRestoredLaunchProcess(record, signal) {
117
132
  async function cleanupProcess(record, child, deadlineMs) {
118
133
  if (!record.pid)
119
134
  return { resource: "process", state: "skipped" };
120
- if (isPidAlive(record.pid) === false)
135
+ if (hasProcessExited(child, record.pid))
121
136
  return { resource: "process", state: "already-gone" };
122
137
  if (!child) {
123
138
  const verificationError = await getRestoredProcessVerificationError(record);
@@ -0,0 +1,64 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import { isRecord } from "./parsing.js";
4
+ import { getAgentBrowserSessionIdentityKey } from "./argv-grammar.js";
5
+ function isDirectoryOwner(source) {
6
+ if (/^(?:npm:pi-change-working-dir|git:github\.com\/fitchmultz\/pi-change-working-dir(?:\.git)?)(?:@.+)?$/.test(source.source))
7
+ return true;
8
+ const directories = [source.baseDir, isAbsolute(source.path) ? dirname(source.path) : undefined];
9
+ return directories.some(directory => {
10
+ if (!directory || !isAbsolute(directory))
11
+ return false;
12
+ try {
13
+ return JSON.parse(readFileSync(join(directory, "package.json"), "utf8")).name === "pi-change-working-dir";
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ });
19
+ }
20
+ /** The synchronous owner reply is captured before any browser policy, queue, or child await. */
21
+ export function resolveExecutionCwd(pi, ctx) {
22
+ const request = { sessionManager: ctx.sessionManager };
23
+ pi.events.emit("pi-change-working-dir:resolve-execution-cwd", request);
24
+ const result = request.result;
25
+ if (result !== undefined) {
26
+ const invalid = "pi-change-working-dir returned an invalid execution directory. Update the extension and restart Pi.";
27
+ if (!isRecord(result) || Array.isArray(result))
28
+ throw new Error(invalid);
29
+ if (result.error !== undefined)
30
+ throw new Error(typeof result.error === "string" && result.error.length > 0 ? result.error : invalid);
31
+ if (typeof result.cwd !== "string" || !isAbsolute(result.cwd) || result.cwd.includes("\0"))
32
+ throw new Error(invalid);
33
+ return result.cwd;
34
+ }
35
+ if (pi.getAllTools().some(tool => tool.name === "change_dir" && isDirectoryOwner(tool.sourceInfo))
36
+ || pi.getCommands().some(command => /^cwd(?::\d+)?$/.test(command.name) && isDirectoryOwner(command.sourceInfo))) {
37
+ throw new Error("Update pi-change-working-dir and restart Pi to use agent_browser with the selected working directory.");
38
+ }
39
+ return ctx.cwd;
40
+ }
41
+ export function getBrowserCwdError(cwd) {
42
+ try {
43
+ if (statSync(cwd).isDirectory())
44
+ return undefined;
45
+ }
46
+ catch (error) {
47
+ if (!["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
48
+ throw error;
49
+ }
50
+ return `Browser launch directory is unavailable: ${cwd}. Restore that directory, or explicitly use sessionMode: "fresh" without --session, or --config from the selected execution directory. The existing browser was left untouched.`;
51
+ }
52
+ export function restoreManagedSessionCwd(branch, sessionName, namespace, fallback) {
53
+ const key = getAgentBrowserSessionIdentityKey(sessionName, namespace);
54
+ for (const entry of [...branch].reverse()) {
55
+ if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message) || entry.message.toolName !== "agent_browser")
56
+ continue;
57
+ const details = entry.message.details;
58
+ if (!isRecord(details) || typeof details.sessionName !== "string" || typeof details.managedSessionCwd !== "string" || !isAbsolute(details.managedSessionCwd))
59
+ continue;
60
+ if (getAgentBrowserSessionIdentityKey(details.sessionName, typeof details.namespace === "string" ? details.namespace : undefined) === key)
61
+ return details.managedSessionCwd;
62
+ }
63
+ return fallback;
64
+ }
@@ -232,7 +232,7 @@ export async function analyzeSourceLookupResults(data, compiled, cwd, context) {
232
232
  const electronContext = status === "no-candidates" ? context?.electronContext : undefined;
233
233
  const workspaceRoot = context?.workspaceRoot ?? cwd;
234
234
  if (electronContext) {
235
- limitations.push(`Workspace source scan is limited to the Pi tool session cwd: ${workspaceRoot}.`, "Packaged Electron app code may live inside installed app resources or app.asar outside the workspace; the wrapper does not unpack asar files or scan app bundle resources.");
235
+ limitations.push(`Workspace source scan is limited to the captured execution directory: ${workspaceRoot}.`, "Packaged Electron app code may live inside installed app resources or app.asar outside the workspace; the wrapper does not unpack asar files or scan app bundle resources.");
236
236
  }
237
237
  return {
238
238
  candidates,
@@ -164,16 +164,29 @@ async function ownerAlive(owner) {
164
164
  const current = await readProcessStartIdentity(owner.pid);
165
165
  return current === undefined ? undefined : processStartIdentitiesMatch(owner.startIdentity, current);
166
166
  }
167
- async function removeClaimOwnedBy(path, token) {
168
- const current = await readClaim(path);
169
- if (current?.owner.token !== token)
170
- return false;
167
+ async function removeClaimOwnedBy(path, token, deadline = 0) {
171
168
  const movedPath = join(dirname(path), `.pi-agent-browser-policy-remove-${token}-${randomUUID()}`);
172
- try {
173
- await rename(path, movedPath);
174
- }
175
- catch (error) {
176
- return error.code === "ENOENT";
169
+ while (true) {
170
+ const current = await readClaim(path);
171
+ if (current?.owner.token !== token)
172
+ return false;
173
+ try {
174
+ await rename(path, movedPath);
175
+ break;
176
+ }
177
+ catch (error) {
178
+ const code = error.code;
179
+ if (code === "ENOENT")
180
+ return true;
181
+ // Windows may refuse a directory move while a contender reads its
182
+ // metadata. Revalidate before retrying. Owned-claim cleanup gets
183
+ // its own bounded window, independent of the acquisition wait.
184
+ if (process.platform !== "win32" || code !== "EPERM" || Date.now() >= deadline)
185
+ return false;
186
+ await waitForRetry();
187
+ if (Date.now() >= deadline)
188
+ return false;
189
+ }
177
190
  }
178
191
  const moved = await readClaim(movedPath);
179
192
  if (moved?.owner.token !== token) {
@@ -209,6 +222,12 @@ function claimPrecedes(left, right) {
209
222
  return false;
210
223
  return left.ticket < right.ticket || (left.ticket === right.ticket && left.owner.token < right.owner.token);
211
224
  }
225
+ async function hasPublishedLaterTicket(claim, ownClaim) {
226
+ if (claim.ticket !== null)
227
+ return false;
228
+ const current = await readClaim(claim.path);
229
+ return current?.owner.token === claim.owner.token && !claimPrecedes(current, ownClaim);
230
+ }
212
231
  function waitForRetry(signal) {
213
232
  return new Promise((resolve) => {
214
233
  if (signal?.aborted)
@@ -266,18 +285,35 @@ export async function acquireManagedSessionPolicyLock(options) {
266
285
  for (const claim of claims) {
267
286
  if (claim.owner.token === token || !claimPrecedes(claim, ownClaim))
268
287
  continue;
288
+ // Choosing is transient: a later published ticket must not make
289
+ // its predecessor wait on it using an earlier null snapshot.
290
+ if (await hasPublishedLaterTicket(claim, ownClaim))
291
+ continue;
269
292
  const alive = await ownerAlive(claim.owner);
270
293
  if (alive === false) {
271
294
  await removeClaimOwnedBy(claim.path, claim.owner.token);
272
295
  continue;
273
296
  }
297
+ // A native identity query can outlive the owner's entire critical
298
+ // section. Its result (including unknown after exit) does not make
299
+ // an already released immutable claim a current blocker.
300
+ try {
301
+ await lstat(claim.path);
302
+ }
303
+ catch (error) {
304
+ if (error.code === "ENOENT")
305
+ continue;
306
+ }
307
+ // Publication can also complete during the native identity query.
308
+ if (await hasPublishedLaterTicket(claim, ownClaim))
309
+ continue;
274
310
  blocked = true;
275
311
  break;
276
312
  }
277
313
  if (!blocked) {
278
314
  await cleanDeadPolicyArtifacts(directory);
279
315
  lockAcquired = true;
280
- return { release: async () => { await removeClaimOwnedBy(claimPath, token); } };
316
+ return { release: async () => { await removeClaimOwnedBy(claimPath, token, Date.now() + POLICY_LOCK_WAIT_MS); } };
281
317
  }
282
318
  if (Date.now() >= deadline)
283
319
  return undefined;
@@ -291,6 +327,6 @@ export async function acquireManagedSessionPolicyLock(options) {
291
327
  finally {
292
328
  await rm(candidatePath, { force: true, recursive: true }).catch(() => undefined);
293
329
  if (claimPublished && !lockAcquired)
294
- await removeClaimOwnedBy(claimPath, token);
330
+ await removeClaimOwnedBy(claimPath, token, Date.now() + POLICY_LOCK_WAIT_MS);
295
331
  }
296
332
  }
@@ -2,7 +2,7 @@ import { lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
2
2
  import { basename, dirname, extname, join, resolve } from "node:path";
3
3
  import { foldAgentBrowserFilesystemIdentity } from "../../argv-grammar.js";
4
4
  import { parseWaitCommandTokens } from "../../argv-descriptor.js";
5
- import { getRecordCommandOperands } from "../../command-taxonomy.js";
5
+ import { getRecordCommandOperandIndices, getRecordCommandOperands } from "../../command-taxonomy.js";
6
6
  const SCREENSHOT_IMAGE_EXTENSIONS = [".jpeg", ".jpg", ".png", ".webp"];
7
7
  function isSingleScreenshotPathToken(token) {
8
8
  const explicitlyRelative = token.startsWith("./") || token.startsWith("../");
@@ -38,20 +38,25 @@ export function getScreenshotPathTokenIndex(commandTokens) {
38
38
  return undefined;
39
39
  }
40
40
  const DIFF_SCREENSHOT_VALUE_FLAGS = new Set(["-b", "--baseline", "-o", "--output", "-s", "--selector", "-t", "--threshold"]);
41
- function getDiffScreenshotOutputPath(commandTokens) {
42
- let outputPath;
41
+ export function getDiffFilePathIndices(commandTokens) {
42
+ if (commandTokens[0] !== "diff" || !["snapshot", "screenshot"].includes(commandTokens[1]))
43
+ return {};
44
+ const valueFlags = commandTokens[1] === "screenshot" ? DIFF_SCREENSHOT_VALUE_FLAGS : new Set(["-b", "--baseline", "-s", "--selector", "-d", "--depth"]);
45
+ const paths = {};
43
46
  for (let index = 2; index < commandTokens.length; index += 1) {
44
47
  const token = commandTokens[index];
45
- if (!DIFF_SCREENSHOT_VALUE_FLAGS.has(token))
48
+ if (!valueFlags.has(token))
46
49
  continue;
47
50
  const value = commandTokens[index + 1];
48
51
  if (value === undefined)
49
- return undefined;
52
+ return {};
50
53
  if (token === "-o" || token === "--output")
51
- outputPath = value;
54
+ paths.output = index + 1;
55
+ if (token === "-b" || token === "--baseline")
56
+ paths.baseline = index + 1;
52
57
  index += 1;
53
58
  }
54
- return outputPath;
59
+ return paths;
55
60
  }
56
61
  function canonicalizeArtifactPath(absolutePath, platform, seenSymlinks) {
57
62
  let cursor = absolutePath;
@@ -102,28 +107,30 @@ export function getRecordContactSheetDestination(commandTokens) {
102
107
  const extension = extname(path);
103
108
  return extension ? `${path.slice(0, -extension.length)}.contact-sheet.png` : undefined;
104
109
  }
105
- export function getExplicitArtifactDestination(commandTokens) {
110
+ export function getExplicitArtifactDestinationIndex(commandTokens) {
106
111
  const command = commandTokens[0];
107
112
  const subcommand = commandTokens[1];
108
- if (command === "screenshot") {
109
- const index = getScreenshotPathTokenIndex(commandTokens);
110
- return index === undefined ? undefined : commandTokens[index];
111
- }
113
+ if (command === "screenshot")
114
+ return getScreenshotPathTokenIndex(commandTokens);
112
115
  if (command === "download")
113
- return commandTokens[2];
116
+ return 2;
114
117
  if (command === "pdf")
115
- return commandTokens[1];
118
+ return 1;
116
119
  if (command === "wait")
117
- return parseWaitCommandTokens(commandTokens).downloadPath;
120
+ return parseWaitCommandTokens(commandTokens).downloadPathIndex;
118
121
  if (command === "state" && subcommand === "save")
119
- return commandTokens[2];
122
+ return 2;
120
123
  if (command === "diff" && subcommand === "screenshot")
121
- return getDiffScreenshotOutputPath(commandTokens);
124
+ return getDiffFilePathIndices(commandTokens).output;
122
125
  if (command === "network" && subcommand === "har" && commandTokens[2] === "stop")
123
- return commandTokens[3];
126
+ return 3;
124
127
  if ((command === "trace" || command === "profiler") && subcommand === "stop")
125
- return commandTokens[2];
128
+ return 2;
126
129
  if (command === "record")
127
- return getRecordCommandOperands(commandTokens).path;
130
+ return getRecordCommandOperandIndices(commandTokens)[0];
128
131
  return undefined;
129
132
  }
133
+ export function getExplicitArtifactDestination(commandTokens) {
134
+ const index = getExplicitArtifactDestinationIndex(commandTokens);
135
+ return index === undefined ? undefined : commandTokens[index];
136
+ }
@@ -863,7 +863,7 @@ export async function collectTimeoutPartialProgress(options) {
863
863
  if ((options.commandTokens[0] === "session" && options.commandTokens[1] === "info") || isBrowserIndependentRead(options.commandTokens, options.stdin))
864
864
  return undefined;
865
865
  const rawSteps = getTimeoutProgressSteps(options.compiledJob, options.commandTokens, options.stdin);
866
- const artifacts = await collectTimeoutArtifactEvidence(options.cwd, rawSteps);
866
+ const artifacts = await collectTimeoutArtifactEvidence(options.operationCwd ?? options.cwd, rawSteps);
867
867
  const urlData = await runSessionCommandData({ args: ["get", "url"], cwd: options.cwd, namespace: options.namespace, sessionName: options.sessionName });
868
868
  const recoveredUrl = extractStringResultField(urlData, "result") ?? extractStringResultField(urlData, "url");
869
869
  const titleData = recoveredUrl
@@ -147,6 +147,7 @@ function applyBatchNetworkRouteState(options) {
147
147
  }
148
148
  export async function processBrowserOutput(input) {
149
149
  const { ctx, cwd, electronPostCommandStatusSettleMs, implicitSessionCloseTimeoutMs, sessionPageStateUpdate, signal, state } = input;
150
+ const operationCwd = input.operationCwd ?? cwd;
150
151
  const { prepared, processResult } = input;
151
152
  const { electronChildProcesses, electronLaunchRecords, sessionPageState, traceOwners } = state;
152
153
  let artifactManifest = state.artifactManifest;
@@ -395,7 +396,7 @@ export async function processBrowserOutput(input) {
395
396
  let fillVerificationDiagnostic;
396
397
  let selectorTextVisibilityDiagnostics = [];
397
398
  let electronBroadGetTextScopeDiagnostics = [];
398
- const timeoutPartialProgress = processResult.timedOut && !recordingStopRecovery && !prepared.readConfirmation ? await collectTimeoutPartialProgress({ commandTokens: prepared.commandTokens, compiledJob: prepared.compiledJob, cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, stdin: prepared.runtimeToolStdin }) : undefined;
399
+ const timeoutPartialProgress = processResult.timedOut && !recordingStopRecovery && !prepared.readConfirmation ? await collectTimeoutPartialProgress({ commandTokens: prepared.commandTokens, compiledJob: prepared.compiledJob, cwd, operationCwd: input.operationCwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, stdin: prepared.runtimeToolStdin }) : undefined;
399
400
  if (!currentSessionTabTarget && timeoutPartialProgress?.currentPage?.source === "live") {
400
401
  currentSessionTabTarget = normalizeSessionTabTarget(timeoutPartialProgress.currentPage);
401
402
  }
@@ -574,7 +575,8 @@ export async function processBrowserOutput(input) {
574
575
  }
575
576
  let managedSessionOutcome = buildManagedSessionOutcome({ activeAfter: managedSessionActive, activeBefore: priorManagedSessionActive, attemptedSessionName: managedCloseSessionName, command: commandClosesSession ? "close" : prepared.executionPlan.commandInfo.command, currentSessionName: managedSessionName, currentSessionNamespace: managedSessionNamespace, previousSessionName: priorManagedSessionName, replacedSessionName: replacedManagedSessionName, replacedSessionNamespace: priorManagedSessionNamespace, sessionMode: prepared.sessionMode, succeeded: managedTransitionSucceeded });
576
577
  if (prepared.executionPlan.managedSessionName && managedTransitionSucceeded && managedSessionActive) {
577
- managedSessionCwd = cwd;
578
+ if (!priorManagedSessionActive || replacedManagedSessionName)
579
+ managedSessionCwd = cwd;
578
580
  managedSessionNamespace = prepared.executionPlan.namespace;
579
581
  }
580
582
  if (sessionStateKey && succeeded) {
@@ -681,7 +683,7 @@ export async function processBrowserOutput(input) {
681
683
  }
682
684
  if (plainTextUpgrade && errorText)
683
685
  presentationEnvelope = { ...presentationEnvelope, success: false, error: errorText };
684
- let presentation = plainTextInspection ? { artifacts: undefined, batchFailure: undefined, batchSteps: undefined, content: [{ type: "text", text: inspectionText ?? "" }], data: undefined, fullOutputPath: undefined, fullOutputPaths: undefined, imagePath: undefined, imagePaths: undefined, savedFile: undefined, savedFilePath: undefined, summary: `${prepared.redactedArgs.join(" ")} completed` } : recordingStopRecovery && !recordingStopRecovery.batch ? recordingStopRecovery.presentation : await buildToolPresentation({ args: prepared.redactedProcessArgs, artifactManifest, artifactMaxUpdatedAtMs: Date.now(), artifactMinUpdatedAtMs: input.artifactRunStartedAtMs, artifactRequest: screenshotArtifactRequest, batchArtifactRequests: batchScreenshotArtifactRequests, commandInfo: prepared.executionPlan.commandInfo, compiledSemanticAction: prepared.compiledSemanticAction, cwd, envelope: presentationEnvelope, errorText, namespace: prepared.executionPlan.namespace, networkRouteDiagnostics, networkRoutes: activeNetworkRoutes, persistentArtifactStore, previousRecordingContactSheetPath: sessionStateKey ? state.activeRecordingReservations?.get(sessionStateKey)?.contactSheetPath : undefined, piCleanupOwnership: sessionStateKey && (state.ownedManagedSessions.has(sessionStateKey) || prepared.executionPlan.managedSessionName !== undefined) ? "wrapper-managed" : "caller-owned", sessionName: prepared.executionPlan.sessionName });
686
+ let presentation = plainTextInspection ? { artifacts: undefined, batchFailure: undefined, batchSteps: undefined, content: [{ type: "text", text: inspectionText ?? "" }], data: undefined, fullOutputPath: undefined, fullOutputPaths: undefined, imagePath: undefined, imagePaths: undefined, savedFile: undefined, savedFilePath: undefined, summary: `${prepared.redactedArgs.join(" ")} completed` } : recordingStopRecovery && !recordingStopRecovery.batch ? recordingStopRecovery.presentation : await buildToolPresentation({ args: prepared.redactedProcessArgs, artifactManifest, artifactMaxUpdatedAtMs: Date.now(), artifactMinUpdatedAtMs: input.artifactRunStartedAtMs, artifactRequest: screenshotArtifactRequest, batchArtifactRequests: batchScreenshotArtifactRequests, commandInfo: prepared.executionPlan.commandInfo, compiledSemanticAction: prepared.compiledSemanticAction, cwd: operationCwd, envelope: presentationEnvelope, errorText, namespace: prepared.executionPlan.namespace, networkRouteDiagnostics, networkRoutes: activeNetworkRoutes, persistentArtifactStore, previousRecordingContactSheetPath: sessionStateKey ? state.activeRecordingReservations?.get(sessionStateKey)?.contactSheetPath : undefined, piCleanupOwnership: sessionStateKey && (state.ownedManagedSessions.has(sessionStateKey) || prepared.executionPlan.managedSessionName !== undefined) ? "wrapper-managed" : "caller-owned", sessionName: prepared.executionPlan.sessionName });
685
687
  if (recordingStopRecovery)
686
688
  presentation = mergeRecordingRecoveryPresentation(presentation, recordingStopRecovery);
687
689
  const confirmation = readConfirmationEvent ?? prepared.readConfirmation;
@@ -745,8 +747,8 @@ export async function processBrowserOutput(input) {
745
747
  ? await collectQaAttachedTarget({ currentTarget: currentSessionTabTarget ?? prepared.priorSessionTabTarget, cwd, namespace: prepared.executionPlan.namespace, sessionName: prepared.executionPlan.sessionName, signal })
746
748
  : undefined;
747
749
  const sourceLookupElectronContext = prepared.compiledSourceLookup ? getSourceLookupElectronContext({ currentTarget: currentSessionTabTarget, electronLaunchRecords, namespace: prepared.executionPlan.namespace, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName }) : undefined;
748
- const sourceLookup = prepared.compiledSourceLookup ? await analyzeSourceLookupResults(presentationEnvelope?.data, prepared.compiledSourceLookup, cwd, { electronContext: sourceLookupElectronContext, workspaceRoot: cwd }) : undefined;
749
- const networkSourceLookup = prepared.compiledNetworkSourceLookup ? redactNetworkSourceLookupAnalysis(await analyzeNetworkSourceLookupResults(presentationEnvelope?.data, prepared.compiledNetworkSourceLookup, cwd)) : undefined;
750
+ const sourceLookup = prepared.compiledSourceLookup ? await analyzeSourceLookupResults(presentationEnvelope?.data, prepared.compiledSourceLookup, operationCwd, { electronContext: sourceLookupElectronContext, workspaceRoot: operationCwd }) : undefined;
751
+ const networkSourceLookup = prepared.compiledNetworkSourceLookup ? redactNetworkSourceLookupAnalysis(await analyzeNetworkSourceLookupResults(presentationEnvelope?.data, prepared.compiledNetworkSourceLookup, operationCwd)) : undefined;
750
752
  if (networkSourceLookup && presentation.content[0]?.type === "text")
751
753
  presentation.content[0] = { ...presentation.content[0], text: `${networkSourceLookup.summary}\n\n${presentation.content[0].text}` };
752
754
  else if (networkSourceLookup)
@@ -0,0 +1,76 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import { projectUpstreamGlobalFlags, scanUpstreamGlobalFlagOccurrences } from "../argv-grammar.js";
3
+ import { getDiffFilePathIndices, getExplicitArtifactDestinationIndex } from "./browser-run/artifact-paths.js";
4
+ import { parseBatchCommandArgument, parseUserBatchStdin } from "./batch-stdin.js";
5
+ /** Bind caller file operands without moving the native process/config or interpreting literal form data. */
6
+ export function resolveOperationPaths(args, stdin, cwd, batchStep = false) {
7
+ let resolved = args;
8
+ const setPath = (index, prefix = "") => {
9
+ const path = index === undefined ? undefined : args[index]?.slice(prefix.length);
10
+ if (index === undefined || !path || isAbsolute(path))
11
+ return;
12
+ if (resolved === args)
13
+ resolved = [...args];
14
+ resolved[index] = prefix + resolve(cwd, path);
15
+ };
16
+ const { tokens, indices } = batchStep ? { tokens: args, indices: args.map((_, index) => index) } : projectUpstreamGlobalFlags(args);
17
+ if (!batchStep) {
18
+ for (const flag of ["--config", "--ca-cert", "--executable-path", "--extension", "--init-script", "--download-path", "--screenshot-dir", "--action-policy", "--state"]) {
19
+ for (const occurrence of scanUpstreamGlobalFlagOccurrences(args, flag)) {
20
+ if (flag === "--state" && tokens[0] === "wait" && occurrence.index > indices[0])
21
+ continue;
22
+ setPath(occurrence.index + 1);
23
+ }
24
+ }
25
+ for (const occurrence of scanUpstreamGlobalFlagOccurrences(args, "--profile")) {
26
+ // Native Chrome profile names are identities, not workspace file paths.
27
+ if (occurrence.value && /[\\/]/.test(occurrence.value) && !occurrence.value.startsWith("~"))
28
+ setPath(occurrence.index + 1);
29
+ }
30
+ }
31
+ if (tokens[0] === "batch") {
32
+ if (tokens.slice(1).some(token => token !== "--bail")) {
33
+ for (let index = 1; index < tokens.length; index++) {
34
+ if (tokens[index] === "--bail")
35
+ continue;
36
+ const row = parseBatchCommandArgument(tokens[index]).step;
37
+ if (!row)
38
+ continue;
39
+ const bound = resolveOperationPaths(row, undefined, cwd, true).args;
40
+ if (bound === row)
41
+ continue;
42
+ if (resolved === args)
43
+ resolved = [...args];
44
+ resolved[indices[index]] = bound.map(token => `'${token.replaceAll("'", "'\\''")}'`).join(" ");
45
+ }
46
+ return { args: resolved, stdin }; // Native ignores stdin when raw rows exist.
47
+ }
48
+ const rows = parseUserBatchStdin(stdin).steps;
49
+ if (!rows?.length)
50
+ return { args: resolved, stdin };
51
+ const bound = rows.map(row => resolveOperationPaths(row, undefined, cwd, true).args);
52
+ return { args: resolved, stdin: bound.some((row, index) => row !== rows[index]) ? JSON.stringify(bound) : stdin };
53
+ }
54
+ setPath(indices[getExplicitArtifactDestinationIndex(tokens) ?? -1]);
55
+ if (tokens[0] === "upload")
56
+ for (let index = 2; index < tokens.length; index++)
57
+ setPath(indices[index]);
58
+ if (tokens[0] === "state" && tokens[1] === "load")
59
+ setPath(indices[2]);
60
+ setPath(indices[getDiffFilePathIndices(tokens).baseline ?? -1]);
61
+ if (tokens[0] === "cookies" && tokens[1] === "set") {
62
+ const index = tokens.indexOf("--curl");
63
+ if (index >= 0)
64
+ setPath(indices[index + 1]);
65
+ }
66
+ if (tokens[0] === "webmcp" && tokens[1] === "invoke") {
67
+ for (let index = 3; index < tokens.length; index++) {
68
+ if (!["--params", "--frame", "--timeout"].includes(tokens[index]))
69
+ continue;
70
+ if (tokens[index] === "--params" && tokens[index + 1]?.startsWith("@"))
71
+ setPath(indices[index + 1], "@");
72
+ index++;
73
+ }
74
+ }
75
+ return { args: resolved, stdin };
76
+ }
@@ -1,7 +1,8 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { dirname, join, win32 } from "node:path";
3
3
  const WINDOWS_PROCESS_START_IDENTITY_PREFIX = "win32-powershell-ticks-v1:";
4
- const PROCESS_START_IDENTITY_TIMEOUT_MS = 5_000;
4
+ // Native Windows PowerShell startup can exhaust the former five-second probe budget.
5
+ const PROCESS_START_IDENTITY_TIMEOUT_MS = process.platform === "win32" ? 10_000 : 5_000;
5
6
  const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows";
6
7
  export function buildProcessStartIdentityCommand(pid, platform = process.platform) {
7
8
  if (!Number.isSafeInteger(pid) || pid <= 0)
@@ -11,6 +11,7 @@ import { getPageTargetValidationError, } from "./page-target-validation.js";
11
11
  import { getImplicitSessionIdleTimeoutMs } from "./runtime.js";
12
12
  import { getAgentBrowserProcessEnvironment } from "./process-environment.js";
13
13
  import { openSecureTempFile, writeSecureTempChunk } from "./temp.js";
14
+ import { resolveWindowsStockLauncher } from "./windows-stock-launcher.js";
14
15
  const MAX_BUFFERED_STDOUT_BYTES = 512 * 1_024;
15
16
  const MAX_BUFFERED_STDERR_CHARS = 32_000;
16
17
  const MAX_BUFFERED_STDOUT_TAIL_CHARS = 32_000;
@@ -52,11 +53,17 @@ export function prepareAgentBrowserSpawnArgs(args, wrapperCompatibilityUserAgent
52
53
  normalized[occurrence.index + 1] = customArgs;
53
54
  return normalized;
54
55
  }
55
- function terminateSpawnedChild(child, signal) {
56
+ async function terminateSpawnedChild(child, signal) {
56
57
  if (processPlatform === "win32" && child.pid) {
57
- const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
58
- killer.on("error", () => undefined);
59
- killer.unref();
58
+ // Keep the shell alive until taskkill has traversed its descendants, and
59
+ // observe the killer before allowing the browser call to finish.
60
+ const killed = await new Promise((resolve) => {
61
+ const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
62
+ killer.once("error", () => resolve(false));
63
+ killer.once("close", (code) => resolve(code === 0));
64
+ });
65
+ if (killed)
66
+ return;
60
67
  }
61
68
  child.kill(signal);
62
69
  }
@@ -224,9 +231,29 @@ async function socketDirEntriesAreOwned(socketDir, uid, visited = { count: 0 })
224
231
  }
225
232
  return true;
226
233
  }
227
- export async function getAgentBrowserSocketDirValidationError(socketDir, uid = typeof process.getuid === "function" ? process.getuid() : undefined) {
234
+ export async function getAgentBrowserSocketDirValidationError(socketDir, uid = typeof process.getuid === "function" ? process.getuid() : undefined, platform = processPlatform) {
228
235
  if (!isAbsolute(socketDir))
229
236
  return "the path is not absolute";
237
+ // Windows uses native ACLs and named pipes, not POSIX uid/mode metadata.
238
+ // Still require a real directory; never accept a file or a redirected root.
239
+ if (platform === "win32") {
240
+ try {
241
+ try {
242
+ await mkdir(socketDir);
243
+ }
244
+ catch (error) {
245
+ if (error.code !== "EEXIST")
246
+ throw error;
247
+ }
248
+ const metadata = await lstat(socketDir);
249
+ if (metadata.isSymbolicLink())
250
+ return "the directory is a symlink";
251
+ return metadata.isDirectory() ? undefined : "the path is not a directory";
252
+ }
253
+ catch (error) {
254
+ return `the directory could not be inspected (${error.code ?? "unknown error"})`;
255
+ }
256
+ }
230
257
  if (typeof uid !== "number")
231
258
  return "POSIX ownership metadata is unavailable";
232
259
  try {
@@ -378,6 +405,8 @@ export async function runAgentBrowserProcess(options) {
378
405
  }
379
406
  effectiveEnv = { ...effectiveEnv, [AGENT_BROWSER_SOCKET_DIR_ENV]: requestedSocketDir };
380
407
  }
408
+ const childEnv = buildAgentBrowserProcessEnv(parentEnv, effectiveEnv);
409
+ const stockLauncher = resolveWindowsStockLauncher(cwd, childEnv);
381
410
  if (signal?.aborted) {
382
411
  return { aborted: true, agentBrowserStarted: false, exitCode: 1, stderr: "", stdout: "", timedOut: false };
383
412
  }
@@ -395,6 +424,7 @@ export async function runAgentBrowserProcess(options) {
395
424
  let stdoutSpillPending = false;
396
425
  let pendingStdoutWrite = Promise.resolve();
397
426
  let stdoutSpillError;
427
+ let pendingTermination;
398
428
  let killTimer;
399
429
  let timeoutTimer;
400
430
  let abortListener;
@@ -444,15 +474,16 @@ export async function runAgentBrowserProcess(options) {
444
474
  if (settled)
445
475
  return;
446
476
  settled = true;
477
+ removeAbortListener();
478
+ if (killTimer) {
479
+ clearTimeout(killTimer);
480
+ }
481
+ if (timeoutTimer) {
482
+ clearTimeout(timeoutTimer);
483
+ }
484
+ completionWatcher?.clear();
447
485
  void pendingStdoutWrite.finally(async () => {
448
- removeAbortListener();
449
- if (killTimer) {
450
- clearTimeout(killTimer);
451
- }
452
- if (timeoutTimer) {
453
- clearTimeout(timeoutTimer);
454
- }
455
- completionWatcher?.clear();
486
+ await pendingTermination;
456
487
  if (stdoutSpillHandle) {
457
488
  await stdoutSpillHandle.close().catch(() => undefined);
458
489
  }
@@ -478,14 +509,13 @@ export async function runAgentBrowserProcess(options) {
478
509
  });
479
510
  });
480
511
  };
481
- const childEnv = buildAgentBrowserProcessEnv(parentEnv, effectiveEnv);
482
512
  const spawnPolicyError = getManagedPreSpawnPolicyError(managedSessionRestoreOptions, managedStateCurrentPageUrl, managedStatePageUrlUnknown, options.browserIndependentReadConfirmation);
483
513
  if (spawnPolicyError) {
484
514
  resolve({ aborted: false, agentBrowserStarted: false, exitCode: 1, spawnError: new Error(spawnPolicyError), stderr: "", stdout: "", timedOut: false });
485
515
  return;
486
516
  }
487
- const spawnBrowser = processPlatform === "win32" ? crossSpawn : spawn;
488
- const child = spawnBrowser("agent-browser", prepareAgentBrowserSpawnArgs(args, ownedManagedSessionCompatibilityEnv.AGENT_BROWSER_USER_AGENT, preserveAttachedBrowserSession, chromeStartupArgsContext.getStore()), {
517
+ const spawnBrowser = processPlatform === "win32" && !stockLauncher ? crossSpawn : spawn;
518
+ const child = spawnBrowser(stockLauncher ?? "agent-browser", prepareAgentBrowserSpawnArgs(args, ownedManagedSessionCompatibilityEnv.AGENT_BROWSER_USER_AGENT, preserveAttachedBrowserSession, chromeStartupArgsContext.getStore()), {
489
519
  cwd,
490
520
  env: childEnv,
491
521
  stdio: ["pipe", "pipe", "pipe"],
@@ -505,10 +535,16 @@ export async function runAgentBrowserProcess(options) {
505
535
  else {
506
536
  timedOut = true;
507
537
  }
508
- terminateSpawnedChild(child, "SIGTERM");
509
- killTimer = setTimeout(() => {
510
- terminateSpawnedChild(child, "SIGKILL");
511
- }, 2_000);
538
+ if (pendingTermination)
539
+ return;
540
+ pendingTermination = terminateSpawnedChild(child, "SIGTERM");
541
+ // Windows taskkill already forces the entire tree. A concurrent direct
542
+ // kill would remove its root before traversal finishes.
543
+ if (processPlatform !== "win32") {
544
+ killTimer = setTimeout(() => {
545
+ pendingTermination = terminateSpawnedChild(child, "SIGKILL");
546
+ }, 2_000);
547
+ }
512
548
  };
513
549
  const recordStdinError = (error) => {
514
550
  const stdinError = error instanceof Error ? error : new Error(String(error));
@@ -251,7 +251,7 @@ function redactBearerCredentials(text) {
251
251
  return match;
252
252
  return formatRedactedCredential(label, credential, trailing);
253
253
  })
254
- .replace(/\b(Bearer)\s+([^\s"',)\[\]]+)([),.]?)/gi, (match, label, credential, trailing) => {
254
+ .replace(/\b(Bearer)\s+([^\s\\"',)\[\]]+)([),.]?)/gi, (match, label, credential, trailing) => {
255
255
  // Without a credential field/header, require a bearer-token shape, not prose, HTML or a URL.
256
256
  const token = credential.slice(0, credential.length - credentialTrailingPunctuation(credential).length);
257
257
  if (!/^[A-Za-z0-9._~+/-]+=*$/.test(token) || !/[0-9._~+/=-]/.test(token))