pi-agent-browser-native 0.2.42 → 0.2.43

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 (31) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +8 -8
  3. package/docs/COMMAND_REFERENCE.md +9 -10
  4. package/docs/SUPPORT_MATRIX.md +6 -5
  5. package/docs/TOOL_CONTRACT.md +27 -24
  6. package/extensions/agent-browser/index.ts +71 -2
  7. package/extensions/agent-browser/lib/input-modes/params.ts +1 -1
  8. package/extensions/agent-browser/lib/input-modes/types.ts +1 -1
  9. package/extensions/agent-browser/lib/navigation-policy.ts +95 -0
  10. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +2 -7
  11. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +1 -0
  12. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +2 -2
  13. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +103 -12
  14. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +20 -3
  15. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +6 -1
  16. package/extensions/agent-browser/lib/playbook.ts +4 -4
  17. package/extensions/agent-browser/lib/results/action-recommendations.ts +15 -0
  18. package/extensions/agent-browser/lib/results/contracts.ts +17 -0
  19. package/extensions/agent-browser/lib/results/network-routes.ts +80 -0
  20. package/extensions/agent-browser/lib/results/network.ts +10 -2
  21. package/extensions/agent-browser/lib/results/presentation/artifacts.ts +14 -0
  22. package/extensions/agent-browser/lib/results/presentation/batch.ts +36 -13
  23. package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +154 -16
  24. package/extensions/agent-browser/lib/results/presentation/errors.ts +62 -2
  25. package/extensions/agent-browser/lib/results/presentation/semantic-action.ts +2 -4
  26. package/extensions/agent-browser/lib/results/presentation.ts +31 -1
  27. package/extensions/agent-browser/lib/results/selector-recovery.ts +11 -3
  28. package/extensions/agent-browser/lib/results/shared.ts +1 -0
  29. package/extensions/agent-browser/lib/results.ts +3 -0
  30. package/extensions/agent-browser/lib/runtime.ts +6 -0
  31. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { readFile, rm } from "node:fs/promises";
3
3
  import { isCloseCommand, isOpenNavigationCommand } from "../../command-taxonomy.js";
4
4
  import { cleanupElectronLaunchResources, inspectElectronLaunchStatus, type ElectronCleanupResult } from "../../electron/cleanup.js";
5
5
  import type { ElectronLaunchRecord } from "../../electron/launch.js";
6
+ import { getAllowedDomainsViolation, parseAllowedDomainsPolicyFromArgs } from "../../navigation-policy.js";
6
7
  import {
7
8
  analyzeNetworkSourceLookupResults,
8
9
  analyzeQaPresetResults,
@@ -12,6 +13,8 @@ import {
12
13
  redactNetworkSourceLookupAnalysis,
13
14
  } from "../../input-modes.js";
14
15
  import {
16
+ applyNetworkRouteRecords,
17
+ buildNetworkRouteDiagnostics,
15
18
  buildToolPresentation,
16
19
  getAgentBrowserErrorText,
17
20
  parseAgentBrowserEnvelope,
@@ -22,7 +25,8 @@ import {
22
25
  formatSessionArtifactRetentionSummary,
23
26
  mergeSessionArtifactManifest,
24
27
  } from "../../results/artifact-manifest.js";
25
- import type { SessionArtifactManifest } from "../../results/contracts.js";
28
+ import type { NetworkRouteRecord, SessionArtifactManifest } from "../../results/contracts.js";
29
+ import { getClipboardWritePayloadCandidates, redactClipboardPermissionEcho, redactClipboardPermissionErrorValue } from "../../results/presentation/errors.js";
26
30
  import { shouldCaptureSemanticActionNavigationSummary } from "../../results/presentation/semantic-action.js";
27
31
  import {
28
32
  commandExplicitlyTargetsAboutBlank,
@@ -39,7 +43,7 @@ import {
39
43
  import type { PersistentSessionArtifactEviction, PersistentSessionArtifactStore } from "../../temp.js";
40
44
  import { writePersistentSessionArtifactFile, writeSecureTempFile } from "../../temp.js";
41
45
  import { isRecord } from "../../parsing.js";
42
- import { createFreshSessionName, hasLaunchScopedTabCorrectionFlag, resolveManagedSessionState } from "../../runtime.js";
46
+ import { createFreshSessionName, extractCommandTokens, hasLaunchScopedTabCorrectionFlag, resolveManagedSessionState } from "../../runtime.js";
43
47
  import {
44
48
  applyOpenResultTabCorrection,
45
49
  buildAboutBlankRecoveryHint,
@@ -142,6 +146,44 @@ async function repairBatchScreenshotArtifacts(options: {
142
146
  return { envelope: { ...envelope, data: repairedData }, requests: repairedRequests };
143
147
  }
144
148
 
149
+ function getEnvelopeErrorString(envelope: AgentBrowserEnvelope | undefined): string | undefined {
150
+ if (!envelope?.error) return undefined;
151
+ if (typeof envelope.error === "string") return envelope.error;
152
+ if (isRecord(envelope.error) && typeof envelope.error.message === "string") return envelope.error.message;
153
+ return String(envelope.error);
154
+ }
155
+
156
+ function isStreamEnableAlreadyEnabledNoop(options: { command: string | undefined; envelope: AgentBrowserEnvelope | undefined; processSucceeded: boolean; subcommand: string | undefined }): boolean {
157
+ if (!options.processSucceeded || options.command !== "stream" || options.subcommand !== "enable" || options.envelope?.success !== false) return false;
158
+ const message = (getEnvelopeErrorString(options.envelope) ?? "").trim().replace(/[.!]+$/, "").toLowerCase();
159
+ return message === "streaming is already enabled for this session" || message === "streaming is already enabled" || message === "stream already enabled";
160
+ }
161
+
162
+ function setNetworkRouteState(options: { routes?: NetworkRouteRecord[]; routesBySession: Map<string, NetworkRouteRecord[]>; sessionName: string | undefined }): Map<string, NetworkRouteRecord[]> {
163
+ if (!options.sessionName) return options.routesBySession;
164
+ const previousRoutes = options.routesBySession.get(options.sessionName);
165
+ if (options.routes === previousRoutes) return options.routesBySession;
166
+ const next = new Map(options.routesBySession);
167
+ if (options.routes && options.routes.length > 0) next.set(options.sessionName, options.routes);
168
+ else next.delete(options.sessionName);
169
+ return next;
170
+ }
171
+
172
+ function applyNetworkRouteState(options: { commandTokens: string[]; routesBySession: Map<string, NetworkRouteRecord[]>; sessionName: string | undefined; succeeded: boolean }): Map<string, NetworkRouteRecord[]> {
173
+ const routes = options.sessionName ? applyNetworkRouteRecords(options.routesBySession.get(options.sessionName), options.commandTokens, options.succeeded) : undefined;
174
+ return setNetworkRouteState({ routes, routesBySession: options.routesBySession, sessionName: options.sessionName });
175
+ }
176
+
177
+ function applyBatchNetworkRouteState(options: { data: unknown; routesBySession: Map<string, NetworkRouteRecord[]>; sessionName: string | undefined; succeeded: boolean }): Map<string, NetworkRouteRecord[]> {
178
+ if (!options.succeeded || !options.sessionName || !Array.isArray(options.data)) return options.routesBySession;
179
+ let routes = options.routesBySession.get(options.sessionName);
180
+ for (const item of options.data) {
181
+ if (!isRecord(item) || !Array.isArray(item.command) || !item.command.every((token) => typeof token === "string")) continue;
182
+ routes = applyNetworkRouteRecords(routes, extractCommandTokens(item.command as string[]), item.success !== false);
183
+ }
184
+ return setNetworkRouteState({ routes, routesBySession: options.routesBySession, sessionName: options.sessionName });
185
+ }
186
+
145
187
  export async function preserveParseFailureOutput(options: {
146
188
  artifactManifest?: SessionArtifactManifest;
147
189
  exactSensitiveValues?: string[];
@@ -180,11 +222,13 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
180
222
  const { ctx, cwd, electronPostCommandStatusSettleMs, implicitSessionCloseTimeoutMs, sessionPageStateUpdate, signal, state } = input;
181
223
  const { prepared, processResult } = input;
182
224
  const { electronChildProcesses, electronLaunchRecords, sessionPageState, traceOwners } = state;
225
+ let allowedDomainsBySession = state.allowedDomainsBySession;
183
226
  let artifactManifest = state.artifactManifest;
184
227
  let freshSessionOrdinal = state.freshSessionOrdinal;
185
228
  let managedSessionActive = state.managedSessionActive;
186
229
  let managedSessionCwd = state.managedSessionCwd;
187
230
  let managedSessionName = state.managedSessionName;
231
+ let networkRoutesBySession = state.networkRoutesBySession;
188
232
  try {
189
233
  const persistentArtifactStore = getPersistentSessionArtifactStore(ctx);
190
234
  const parsed = await parseAgentBrowserEnvelope({ stdout: processResult.stdout, stdoutPath: processResult.stdoutSpillPath });
@@ -208,6 +252,9 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
208
252
  const processSucceeded = !processResult.aborted && !processResult.spawnError && processResult.exitCode === 0;
209
253
  const plainTextInspection = prepared.executionPlan.plainTextInspection && processSucceeded;
210
254
  const parseSucceeded = plainTextInspection || parseError === undefined;
255
+ if (isStreamEnableAlreadyEnabledNoop({ command: prepared.executionPlan.commandInfo.command, envelope: presentationEnvelope, processSucceeded, subcommand: prepared.executionPlan.commandInfo.subcommand })) {
256
+ presentationEnvelope = { success: true, data: { alreadyEnabled: true, enabled: true, message: getEnvelopeErrorString(presentationEnvelope) ?? "Stream already enabled" } };
257
+ }
211
258
  const envelopeSuccess = plainTextInspection ? true : presentationEnvelope?.success !== false;
212
259
  let succeeded = processSucceeded && parseSucceeded && envelopeSuccess;
213
260
  const inspectionText = plainTextInspection ? processResult.stdout.trim() : undefined;
@@ -222,15 +269,21 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
222
269
  }
223
270
  }
224
271
 
272
+ const parsedAllowedDomainsPolicy = parseAllowedDomainsPolicyFromArgs(prepared.runtimeToolArgs);
273
+ const sessionAllowedDomainsPolicy = prepared.executionPlan.sessionName
274
+ ? parsedAllowedDomainsPolicy ?? allowedDomainsBySession.get(prepared.executionPlan.sessionName)
275
+ : parsedAllowedDomainsPolicy;
276
+ const shouldCaptureAllowedDomainNavigationSummary = prepared.executionPlan.commandInfo.command === "batch" && sessionAllowedDomainsPolicy !== undefined;
225
277
  if (
226
278
  succeeded &&
227
279
  !navigationSummary &&
228
280
  (shouldCaptureNavigationSummary(prepared.executionPlan.commandInfo.command, presentationEnvelope?.data) ||
229
- shouldCaptureSemanticActionNavigationSummary(prepared.compiledSemanticAction, presentationEnvelope?.data))
281
+ shouldCaptureSemanticActionNavigationSummary(prepared.compiledSemanticAction, presentationEnvelope?.data) ||
282
+ shouldCaptureAllowedDomainNavigationSummary)
230
283
  ) {
231
284
  navigationSummary = await collectNavigationSummary({ cwd, sessionName: prepared.executionPlan.sessionName, signal });
232
285
  }
233
- if (navigationSummary && presentationEnvelope) presentationEnvelope = { ...presentationEnvelope, data: mergeNavigationSummaryIntoData(presentationEnvelope.data, navigationSummary) };
286
+ if (navigationSummary && presentationEnvelope && !Array.isArray(presentationEnvelope.data)) presentationEnvelope = { ...presentationEnvelope, data: mergeNavigationSummaryIntoData(presentationEnvelope.data, navigationSummary) };
234
287
  let overlayBlockerDiagnostic: Awaited<ReturnType<typeof collectOverlayBlockerDiagnostic>>;
235
288
 
236
289
  let openResultTabCorrection: Awaited<ReturnType<typeof collectOpenResultTabCorrection>>;
@@ -270,6 +323,19 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
270
323
  if (appliedPostCommandCorrection && !sessionTabCorrection) sessionTabCorrection = appliedPostCommandCorrection;
271
324
  }
272
325
  }
326
+ if (succeeded && prepared.executionPlan.sessionName && parsedAllowedDomainsPolicy) {
327
+ allowedDomainsBySession = new Map(allowedDomainsBySession);
328
+ allowedDomainsBySession.set(prepared.executionPlan.sessionName, parsedAllowedDomainsPolicy);
329
+ }
330
+ const allowedDomainsViolation = succeeded ? getAllowedDomainsViolation({
331
+ policy: sessionAllowedDomainsPolicy,
332
+ url: currentSessionTabTarget?.url ?? observedSessionTabTarget?.url ?? navigationSummary?.url,
333
+ }) : undefined;
334
+ if (allowedDomainsViolation) {
335
+ succeeded = false;
336
+ presentationEnvelope = { ...(presentationEnvelope ?? {}), error: allowedDomainsViolation.summary, success: false };
337
+ }
338
+
273
339
  const electronRecordForCommand = findElectronLaunchRecordForSession(prepared.executionPlan.sessionName, electronLaunchRecords);
274
340
  if (succeeded && electronRecordForCommand && shouldInspectElectronPostCommandHealth(prepared.executionPlan.commandInfo.command)) {
275
341
  electronStatusAfterCommand ??= await inspectElectronLaunchStatus(electronRecordForCommand);
@@ -292,8 +358,13 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
292
358
  if (succeeded && !sessionTabCorrection && !aboutBlankSessionMismatch && !electronRecordForCommand && !clickDispatchDiagnostic) overlayBlockerDiagnostic = await collectOverlayBlockerDiagnostic({ command: prepared.executionPlan.commandInfo.command, cwd, data: presentationEnvelope?.data, navigationSummary, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName, signal });
293
359
  if (succeeded) {
294
360
  selectorTextVisibilityDiagnostics = await collectSelectorTextVisibilityDiagnostics({ commandInfo: prepared.executionPlan.commandInfo, commandTokens: prepared.commandTokens, cwd, data: presentationEnvelope?.data, sessionName: prepared.executionPlan.sessionName, signal });
295
- electronBroadGetTextScopeDiagnostics = collectElectronBroadGetTextScopeDiagnostics({ commandInfo: prepared.executionPlan.commandInfo, commandTokens: prepared.commandTokens, currentTarget: currentSessionTabTarget, data: presentationEnvelope?.data, electronLaunchRecords, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName });
361
+ if (electronRecordForCommand) electronBroadGetTextScopeDiagnostics = collectElectronBroadGetTextScopeDiagnostics({ commandInfo: prepared.executionPlan.commandInfo, commandTokens: prepared.commandTokens, currentTarget: currentSessionTabTarget, data: presentationEnvelope?.data, electronLaunchRecords, priorTarget: prepared.priorSessionTabTarget, sessionName: prepared.executionPlan.sessionName });
296
362
  }
363
+ const activeNetworkRoutes = prepared.executionPlan.sessionName ? networkRoutesBySession.get(prepared.executionPlan.sessionName) : undefined;
364
+ const networkRouteDiagnostics = succeeded && prepared.executionPlan.commandInfo.command === "network" && prepared.executionPlan.commandInfo.subcommand === "requests" && prepared.executionPlan.sessionName
365
+ ? buildNetworkRouteDiagnostics(presentationEnvelope?.data, activeNetworkRoutes)
366
+ : undefined;
367
+ networkRoutesBySession = applyNetworkRouteState({ commandTokens: prepared.commandTokens, routesBySession: networkRoutesBySession, sessionName: prepared.executionPlan.sessionName, succeeded });
297
368
  const comboboxFocusDiagnostic = succeeded ? await collectComboboxFocusDiagnostic({ command: prepared.executionPlan.commandInfo.command, commandTokens: prepared.commandTokens, cwd, semanticAction: prepared.compiledSemanticAction, sessionName: prepared.executionPlan.sessionName, signal }) : undefined;
298
369
  const recordingDependencyWarning = await collectRecordingDependencyWarning({ command: prepared.executionPlan.commandInfo.command, commandTokens: prepared.commandTokens, succeeded });
299
370
  const scrollNoopDiagnostic = succeeded && prepared.shouldProbeScrollNoop ? buildScrollNoopDiagnostic(prepared.scrollPositionBefore, await collectScrollPositionSnapshot({ cwd, sessionName: prepared.executionPlan.sessionName, signal })) : undefined;
@@ -302,6 +373,10 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
302
373
  const batchRefSnapshotState = prepared.executionPlan.commandInfo.command === "batch" ? extractLatestRefSnapshotStateFromBatchResults(presentationEnvelope?.data) : undefined;
303
374
  if (prepared.executionPlan.sessionName) {
304
375
  if (isCloseCommand(prepared.executionPlan.commandInfo.command) && succeeded) {
376
+ allowedDomainsBySession = new Map(allowedDomainsBySession);
377
+ allowedDomainsBySession.delete(prepared.executionPlan.sessionName);
378
+ networkRoutesBySession = new Map(networkRoutesBySession);
379
+ networkRoutesBySession.delete(prepared.executionPlan.sessionName);
305
380
  sessionPageState.clearSession(prepared.executionPlan.sessionName);
306
381
  state.closedManagedSessionNames.add(prepared.executionPlan.sessionName);
307
382
  } else if (currentSessionTabTarget) {
@@ -327,7 +402,9 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
327
402
  const managedCloseSessionName = commandClosesSession && succeeded && prepared.executionPlan.sessionName === priorManagedSessionName
328
403
  ? prepared.executionPlan.sessionName
329
404
  : prepared.executionPlan.managedSessionName;
330
- const managedSessionState = resolveManagedSessionState({ command: prepared.executionPlan.commandInfo.command, managedSessionName: managedCloseSessionName, priorActive: priorManagedSessionActive, priorSessionName: priorManagedSessionName, succeeded });
405
+ const policyBlockedFreshManagedSession = allowedDomainsViolation !== undefined && prepared.sessionMode === "fresh" && prepared.executionPlan.managedSessionName === prepared.executionPlan.sessionName;
406
+ const managedTransitionSucceeded = succeeded || policyBlockedFreshManagedSession;
407
+ const managedSessionState = resolveManagedSessionState({ command: prepared.executionPlan.commandInfo.command, managedSessionName: managedCloseSessionName, priorActive: priorManagedSessionActive, priorSessionName: priorManagedSessionName, succeeded: managedTransitionSucceeded });
331
408
  const replacedManagedSessionName = managedSessionState.replacedSessionName;
332
409
  managedSessionActive = managedSessionState.active;
333
410
  managedSessionName = managedSessionState.sessionName;
@@ -335,13 +412,17 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
335
412
  freshSessionOrdinal += 1;
336
413
  managedSessionName = createFreshSessionName(state.managedSessionBaseName, state.ephemeralSessionSeed, freshSessionOrdinal);
337
414
  }
338
- let managedSessionOutcome = buildManagedSessionOutcome({ activeAfter: managedSessionActive, activeBefore: priorManagedSessionActive, attemptedSessionName: managedCloseSessionName, command: prepared.executionPlan.commandInfo.command, currentSessionName: managedSessionName, previousSessionName: priorManagedSessionName, replacedSessionName: replacedManagedSessionName, sessionMode: prepared.sessionMode, succeeded });
415
+ let managedSessionOutcome = buildManagedSessionOutcome({ activeAfter: managedSessionActive, activeBefore: priorManagedSessionActive, attemptedSessionName: managedCloseSessionName, command: prepared.executionPlan.commandInfo.command, currentSessionName: managedSessionName, previousSessionName: priorManagedSessionName, replacedSessionName: replacedManagedSessionName, sessionMode: prepared.sessionMode, succeeded: managedTransitionSucceeded });
339
416
  if (prepared.executionPlan.managedSessionName && succeeded) managedSessionCwd = cwd;
340
417
  if (prepared.executionPlan.sessionName && succeeded) {
341
418
  if (openResultTabCorrection || sessionTabCorrection || aboutBlankSessionMismatch?.recoveryApplied) sessionPageState.markPinning(prepared.executionPlan.sessionName, "drift");
342
419
  else if (prepared.sessionTabPinningReason === "restore") sessionPageState.clearRestorePinning(prepared.executionPlan.sessionName);
343
420
  }
344
421
  if (replacedManagedSessionName) {
422
+ allowedDomainsBySession = new Map(allowedDomainsBySession);
423
+ allowedDomainsBySession.delete(replacedManagedSessionName);
424
+ networkRoutesBySession = new Map(networkRoutesBySession);
425
+ networkRoutesBySession.delete(replacedManagedSessionName);
345
426
  sessionPageState.clearSession(replacedManagedSessionName);
346
427
  const replacedCloseError = await closeManagedSession({ cwd: priorManagedSessionCwd, sessionName: replacedManagedSessionName, timeoutMs: implicitSessionCloseTimeoutMs });
347
428
  if (!replacedCloseError) state.closedManagedSessionNames.add(replacedManagedSessionName);
@@ -373,8 +454,18 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
373
454
  }
374
455
  }
375
456
 
376
- const errorText = getAgentBrowserErrorText({ aborted: processResult.aborted, command: prepared.executionPlan.commandInfo.command, effectiveArgs: prepared.redactedProcessArgs, envelope: presentationEnvelope, exitCode: processResult.exitCode, parseError, plainTextInspection, staleRefArgs: getStaleRefArgs(prepared.commandTokens, prepared.runtimeToolStdin), spawnError: processResult.spawnError, stderr: processResult.stderr, timedOut: processResult.timedOut, timeoutMs: processResult.timeoutMs, wrapperRecoveryHint: buildWrapperRecoveryHint({ pinnedBatchUnwrapMode: prepared.pinnedBatchUnwrapMode, sessionTabCorrection }) });
377
- const presentation = plainTextInspection ? { artifacts: undefined, batchFailure: undefined, batchSteps: undefined, content: [{ type: "text" as const, text: inspectionText ?? "" }], data: undefined, fullOutputPath: undefined, fullOutputPaths: undefined, imagePath: undefined, imagePaths: undefined, savedFile: undefined, savedFilePath: undefined, summary: `${prepared.redactedArgs.join(" ")} completed` } : await buildToolPresentation({ args: prepared.redactedProcessArgs, artifactManifest, artifactRequest: screenshotArtifactRequest, batchArtifactRequests: batchScreenshotArtifactRequests, commandInfo: prepared.executionPlan.commandInfo, compiledSemanticAction: prepared.compiledSemanticAction, cwd, envelope: presentationEnvelope, errorText, persistentArtifactStore, sessionName: prepared.executionPlan.sessionName });
457
+ let errorText = getAgentBrowserErrorText({ aborted: processResult.aborted, command: prepared.executionPlan.commandInfo.command, effectiveArgs: prepared.redactedProcessArgs, envelope: presentationEnvelope, exitCode: processResult.exitCode, parseError, plainTextInspection, staleRefArgs: getStaleRefArgs(prepared.commandTokens, prepared.runtimeToolStdin), spawnError: processResult.spawnError, stderr: processResult.stderr, timedOut: processResult.timedOut, timeoutMs: processResult.timeoutMs, wrapperRecoveryHint: buildWrapperRecoveryHint({ pinnedBatchUnwrapMode: prepared.pinnedBatchUnwrapMode, sessionTabCorrection }) });
458
+ if (errorText) {
459
+ const clipboardWritePayloadCandidates = getClipboardWritePayloadCandidates(prepared.commandTokens);
460
+ errorText = redactClipboardPermissionEcho(prepared.executionPlan.commandInfo, errorText);
461
+ if (presentationEnvelope?.error !== undefined) presentationEnvelope = { ...presentationEnvelope, error: redactClipboardPermissionErrorValue(prepared.executionPlan.commandInfo, presentationEnvelope.error, clipboardWritePayloadCandidates) };
462
+ }
463
+ const presentation = plainTextInspection ? { artifacts: undefined, batchFailure: undefined, batchSteps: undefined, content: [{ type: "text" as const, text: inspectionText ?? "" }], data: undefined, fullOutputPath: undefined, fullOutputPaths: undefined, imagePath: undefined, imagePaths: undefined, savedFile: undefined, savedFilePath: undefined, summary: `${prepared.redactedArgs.join(" ")} completed` } : await buildToolPresentation({ args: prepared.redactedProcessArgs, artifactManifest, artifactRequest: screenshotArtifactRequest, batchArtifactRequests: batchScreenshotArtifactRequests, commandInfo: prepared.executionPlan.commandInfo, compiledSemanticAction: prepared.compiledSemanticAction, cwd, envelope: presentationEnvelope, errorText, networkRouteDiagnostics, networkRoutes: activeNetworkRoutes, persistentArtifactStore, sessionName: prepared.executionPlan.sessionName });
464
+ networkRoutesBySession = applyBatchNetworkRouteState({ data: presentationEnvelope?.data, routesBySession: networkRoutesBySession, sessionName: prepared.executionPlan.sessionName, succeeded });
465
+ if (presentation.failureCategory === "artifact-missing") {
466
+ succeeded = false;
467
+ presentationEnvelope = { ...(presentationEnvelope ?? {}), error: presentation.summary, success: false };
468
+ }
378
469
  if (parseFailureOutput.artifactManifest) { presentation.artifactManifest = parseFailureOutput.artifactManifest; presentation.artifactRetentionSummary = parseFailureOutput.artifactRetentionSummary; }
379
470
  if (parseFailureOutput.fullOutputPath || parseFailureOutput.fullOutputUnavailable) {
380
471
  const existingText = presentation.content[0]?.type === "text" ? presentation.content[0].text : "";
@@ -394,13 +485,13 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
394
485
  else if (networkSourceLookup) presentation.content.unshift({ type: "text", text: networkSourceLookup.summary });
395
486
  if (sourceLookup && presentation.content[0]?.type === "text") presentation.content[0] = { ...presentation.content[0], text: `${sourceLookup.summary}\n\n${presentation.content[0].text}` };
396
487
  else if (sourceLookup) presentation.content.unshift({ type: "text", text: sourceLookup.summary });
397
- if (qaPreset && !qaPreset.passed) {
488
+ if (qaPreset && !qaPreset.passed && presentation.failureCategory !== "artifact-missing") {
398
489
  succeeded = false;
399
490
  presentation.failureCategory = "qa-failure";
400
491
  presentation.summary = qaPreset.summary;
401
492
  if (presentation.content[0]?.type === "text") presentation.content[0] = { ...presentation.content[0], text: `${qaPreset.summary}\n\n${presentation.content[0].text}` };
402
493
  else presentation.content.unshift({ type: "text", text: qaPreset.summary });
403
- } else if (qaPreset?.passed && prepared.compiledQaPreset) {
494
+ } else if (qaPreset?.passed && prepared.compiledQaPreset && succeeded) {
404
495
  const compactText = buildQaCompactPassText({
405
496
  artifactVerification: presentation.artifactVerification,
406
497
  batchStepCount: presentation.batchSteps?.length ?? prepared.compiledQaPreset.steps.length,
@@ -434,7 +525,7 @@ export async function processBrowserOutput(input: ProcessBrowserOutputInput): Pr
434
525
  currentRefSnapshot = finalRecoveryState.currentRefSnapshot;
435
526
  currentRefSnapshotInvalidation = finalRecoveryState.currentRefSnapshotInvalidation;
436
527
  const result = buildFinalAgentBrowserToolResult({ aboutBlankSessionMismatch, artifactCleanup, categoryDetails: finalRecoveryState.categoryDetails, clickDispatchDiagnostic, commandTokens: prepared.commandTokens, comboboxFocusDiagnostic, compiledNetworkSourceLookup: prepared.compiledNetworkSourceLookup, compiledSemanticAction: prepared.compiledSemanticAction, compatibilityWorkaround: prepared.compatibilityWorkaround, currentRefSnapshot, currentRefSnapshotInvalidation, currentSessionTabTarget, electronBroadGetTextScopeDiagnostics, electronFailedConnectCleanup, electronHandoff, electronLaunch: prepared.electronLaunch, electronLaunchRecord, electronLaunchRecords, electronPostCommandHealth, electronProfileIsolationDetails: input.electronProfileIsolationDetails, electronRefFreshnessDiagnostic, electronSessionMismatch, errorText, evalResultWarning, evalStdinHint, exactSensitiveValues: prepared.exactSensitiveValues, executionPlan: prepared.executionPlan, fillVerificationDiagnostic, inspectionText, managedSessionOutcome, navigationSummary, networkSourceLookup, noActivePageSnapshotFailure: finalRecoveryState.noActivePageSnapshotFailure, openResultTabCorrection, overlayBlockerDiagnostic, parseError, parseFailureOutput, parseSucceeded, plainTextInspection, presentation, presentationEnvelope, priorSessionTabTarget: prepared.priorSessionTabTarget, processResult, qaAttachedTarget, qaPreset, recordingDependencyWarning, redactedArgs: prepared.redactedArgs, redactedCompiledElectron: prepared.redactedCompiledElectron, redactedCompiledJob: prepared.redactedCompiledJob, redactedCompiledNetworkSourceLookup: prepared.redactedCompiledNetworkSourceLookup, redactedCompiledQaPreset: prepared.redactedCompiledQaPreset, redactedCompiledSemanticAction: prepared.redactedCompiledSemanticAction, redactedCompiledSourceLookup: prepared.redactedCompiledSourceLookup, redactedContent, redactedProcessArgs: prepared.redactedProcessArgs, redactedRecoveryHint: prepared.redactedRecoveryHint, resultArtifactManifest, richInputRecoveryDiagnostic: finalRecoveryState.richInputRecoveryDiagnostic, scrollNoopDiagnostic, selectorTextVisibilityDiagnostics, sessionMode: prepared.sessionMode, sessionTabCorrection, sourceLookup, succeeded, timeoutPartialProgress, userRequestedJson: prepared.userRequestedJson, visibleRefFallbackDiagnostic: finalRecoveryState.visibleRefFallbackDiagnostic, visibleRefFallbackSessionName: finalRecoveryState.visibleRefFallbackSessionName });
437
- const statePatch: BrowserRunStatePatch = { artifactManifest, freshSessionOrdinal, managedSessionActive, managedSessionCwd, managedSessionName };
528
+ const statePatch: BrowserRunStatePatch = { allowedDomainsBySession, artifactManifest, freshSessionOrdinal, managedSessionActive, managedSessionCwd, managedSessionName, networkRoutesBySession };
438
529
  return { result, statePatch };
439
530
  } finally {
440
531
  if (processResult.stdoutSpillPath) await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
@@ -50,11 +50,13 @@ export const NAVIGATION_SUMMARY_EVAL = `({ title: document.title, url: location.
50
50
 
51
51
  export function applyBrowserRunStatePatch(state: BrowserRunState, patch: BrowserRunStatePatch | undefined): void {
52
52
  if (!patch) return;
53
+ if (patch.allowedDomainsBySession) state.allowedDomainsBySession = patch.allowedDomainsBySession;
53
54
  if ("artifactManifest" in patch) state.artifactManifest = patch.artifactManifest;
54
55
  if (patch.freshSessionOrdinal !== undefined) state.freshSessionOrdinal = patch.freshSessionOrdinal;
55
56
  if (patch.managedSessionActive !== undefined) state.managedSessionActive = patch.managedSessionActive;
56
57
  if (patch.managedSessionCwd !== undefined) state.managedSessionCwd = patch.managedSessionCwd;
57
58
  if (patch.managedSessionName !== undefined) state.managedSessionName = patch.managedSessionName;
59
+ if (patch.networkRoutesBySession) state.networkRoutesBySession = patch.networkRoutesBySession;
58
60
  }
59
61
 
60
62
  export function buildSessionDetailFields(sessionName: string | undefined, usedImplicitSession: boolean): Record<string, unknown> {
@@ -321,7 +323,22 @@ export function getGuardedRefUsage(commandTokens: string[], stdin?: string, opti
321
323
  return refsBeforeInBatchSnapshot;
322
324
  }
323
325
 
324
- function getBatchRefInvalidationMessage(commandTokens: string[], stdin?: string): string | undefined {
326
+ function getSnapshotRefRole(refSnapshot: SessionRefSnapshot | undefined, refId: string): string | undefined {
327
+ return refSnapshot?.refs?.[refId]?.role?.toLowerCase();
328
+ }
329
+
330
+ function isSafeSameSnapshotFormBatchStep(step: string[], refSnapshot: SessionRefSnapshot | undefined): boolean {
331
+ const command = step[0];
332
+ const refIds = collectRefsFromTokens(step);
333
+ if (refIds.length === 0 || !refSnapshot) return false;
334
+ const roles = refIds.map((refId) => getSnapshotRefRole(refSnapshot, refId));
335
+ if (roles.some((role) => role === undefined)) return false;
336
+ if (command === "check" || command === "uncheck") return roles.every((role) => role === "checkbox" || role === "radio");
337
+ if (command === "select") return roles.every((role) => role === "combobox");
338
+ return false;
339
+ }
340
+
341
+ function getBatchRefInvalidationMessage(commandTokens: string[], stdin?: string, refSnapshot?: SessionRefSnapshot): string | undefined {
325
342
  if (commandTokens[0] !== "batch" || stdin === undefined) return undefined;
326
343
  const parsed = parseUserBatchStdin(stdin);
327
344
  if (parsed.error || parsed.steps === undefined) return undefined;
@@ -334,7 +351,7 @@ function getBatchRefInvalidationMessage(commandTokens: string[], stdin?: string)
334
351
  if (refIds.length > 0 && isRefGuardedCommand(step[0]) && priorStepInvalidatesRefs) {
335
352
  return `Batch step ${step[0]} uses page-scoped ref ${refIds.map((refId) => `@${refId}`).join(", ")} after an earlier batch step can navigate or mutate the page. Split the batch, run snapshot -i after the page-changing step, then retry with current refs.`;
336
353
  }
337
- if (isRefInvalidatingBatchCommand(step[0])) {
354
+ if (isRefInvalidatingBatchCommand(step[0]) && !isSafeSameSnapshotFormBatchStep(step, refSnapshot)) {
338
355
  priorStepInvalidatesRefs = true;
339
356
  }
340
357
  }
@@ -352,7 +369,7 @@ export function buildStaleRefPreflight(options: {
352
369
  const usedRefIds = options.refSnapshotInvalidation
353
370
  ? [...new Set(getGuardedRefUsage(options.commandTokens, options.stdin, { includeRefsAfterBatchSnapshot: true }))]
354
371
  : guardedRefIds;
355
- const batchInvalidationMessage = getBatchRefInvalidationMessage(options.commandTokens, options.stdin);
372
+ const batchInvalidationMessage = getBatchRefInvalidationMessage(options.commandTokens, options.stdin, options.refSnapshot);
356
373
  if (batchInvalidationMessage && guardedRefIds.length > 0) {
357
374
  return {
358
375
  message: batchInvalidationMessage,
@@ -16,10 +16,11 @@ import type {
16
16
  } from "../../input-modes.js";
17
17
  import type { runAgentBrowserProcess } from "../../process.js";
18
18
  import type { AgentBrowserEnvelope, AgentBrowserNextAction, buildAgentBrowserResultCategoryDetails, buildToolPresentation } from "../../results.js";
19
- import type { SessionArtifactManifest } from "../../results/contracts.js";
19
+ import type { NetworkRouteRecord, SessionArtifactManifest } from "../../results/contracts.js";
20
20
  import type { RichInputRecoveryDiagnostic, VisibleRefFallbackDiagnostic } from "../../results/selector-recovery.js";
21
21
  import type { SessionPageState, SessionRefSnapshot, SessionRefSnapshotInvalidation, SessionTabTarget } from "../../session-page-state.js";
22
22
  import type { buildExecutionPlan, CompatibilityWorkaround, OpenResultTabCorrection } from "../../runtime.js";
23
+ import type { AllowedDomainsPolicy } from "../../navigation-policy.js";
23
24
  import type { PromptPolicy } from "../../prompt-policy.js";
24
25
  import type { AgentBrowserExecuteParams, ResolvedAgentBrowserValidInput } from "../input-plan.js";
25
26
  import type { BatchCommandStep } from "../batch-stdin.js";
@@ -62,6 +63,7 @@ export interface BrowserRunInputFields {
62
63
  }
63
64
 
64
65
  export interface BrowserRunState {
66
+ allowedDomainsBySession: Map<string, AllowedDomainsPolicy>;
65
67
  artifactManifest?: SessionArtifactManifest;
66
68
  closedManagedSessionNames: Set<string>;
67
69
  electronChildProcesses: Map<string, ChildProcess>;
@@ -72,16 +74,19 @@ export interface BrowserRunState {
72
74
  managedSessionBaseName: string;
73
75
  managedSessionCwd: string;
74
76
  managedSessionName: string;
77
+ networkRoutesBySession: Map<string, NetworkRouteRecord[]>;
75
78
  sessionPageState: SessionPageState;
76
79
  traceOwners: Map<string, TraceOwner>;
77
80
  }
78
81
 
79
82
  export interface BrowserRunStatePatch {
83
+ allowedDomainsBySession?: Map<string, AllowedDomainsPolicy>;
80
84
  artifactManifest?: SessionArtifactManifest;
81
85
  freshSessionOrdinal?: number;
82
86
  managedSessionActive?: boolean;
83
87
  managedSessionCwd?: string;
84
88
  managedSessionName?: string;
89
+ networkRoutesBySession?: Map<string, NetworkRouteRecord[]>;
85
90
  }
86
91
 
87
92
  export interface BrowserRunOptions {
@@ -23,7 +23,7 @@ export const QUICK_START_GUIDELINES = [
23
23
  `Quick start mental model: use exactly one of args (exact agent-browser CLI args after the binary), semanticAction (a thin shorthand compiled to find argv for locator actions or select argv for native dropdowns), job (a constrained short-workflow schema compiled to batch), qa (a lightweight QA preset built on job/batch, including qa.attached for current sessions), electron (desktop Electron list/launch/status/cleanup/probe), or the experimental sourceLookup / networkSourceLookup helpers (candidates only; each compiled to batch); stdin is only for batch, eval --stdin, auth save --password-stdin, and wrapper-generated batch stdin from job, qa, sourceLookup, or networkSourceLookup, and is rejected with electron; sessionMode=fresh switches the extension-managed pi-scoped session to a fresh upstream launch when you need new launch-scoped flags (${LAUNCH_SCOPED_FLAG_LABEL}) to apply. Do not pass --json in args; the wrapper injects it.`,
24
24
  "There is no first-class reusable named browser recipe runtime above top-level job, the qa preset, and raw batch stdin; keep recurring flows in documentation examples or those inputs (closed RQ-0068; see docs/ARCHITECTURE.md#no-reusable-recipe-layer-yet).",
25
25
  "Common first calls (first-call recipe): { args: [\"open\", \"<url>\"] } → { args: [\"snapshot\", \"-i\"] } → { args: [\"click\", \"@eN\"] } or { args: [\"fill\", \"@eN\", \"<text>\"] } using @refs and visible labels from that snapshot, then { args: [\"snapshot\", \"-i\"] } after navigation or DOM changes. On https://example.com/ the main link label is Learn more (use exact snapshot text, not guessed link copy).",
26
- "Locator-first clicks/fills and native select changes without hand-building argv: { semanticAction: { action: \"click\", locator: \"text\", value: \"Close\" } }, { semanticAction: { action: \"fill\", locator: \"label\", value: \"Email\", text: \"user@example.com\" } }, or { semanticAction: { action: \"select\", selector: \"#flavor\", value: \"chocolate\" } }; add semanticAction.session when targeting a named upstream browser session; details.compiledSemanticAction shows the semantic target, while details.effectiveArgs may show a resolved current @ref for active-session role/name click/check/uncheck actions to avoid hidden duplicate matches; selector-not-found failures may append bounded click try-*-candidate next actions or, for fill misses with current editable refs, details.richInputRecovery with focus/click actions that do not copy fill text; stale-ref failures can return retry-semantic-action-after-stale-ref for compiled find actions when retry safety is provable.",
26
+ "Locator-first clicks/fills and native select changes without hand-building argv: { semanticAction: { action: \"click\", locator: \"text\", value: \"Close\" } }, { semanticAction: { action: \"fill\", locator: \"label\", value: \"Email\", text: \"user@example.com\" } }, or { semanticAction: { action: \"select\", selector: \"#flavor\", value: \"chocolate\" } }; add semanticAction.session when targeting a named upstream browser session; details.compiledSemanticAction shows the semantic target, while details.effectiveArgs may show a resolved current @ref for active-session role/name click/check/fill actions to avoid hidden duplicate matches; semanticAction does not expose uncheck while upstream find ... uncheck is not runtime-supported, so use raw uncheck with a stable selector or current ref; selector-not-found failures may append bounded click try-*-candidate next actions or, for fill misses with current editable refs, details.richInputRecovery with focus/click actions that do not copy fill text; stale-ref failures can return retry-semantic-action-after-stale-ref for compiled find actions when retry safety is provable.",
27
27
  `Common advanced calls: { args: ["batch"], stdin: "[[\"open\",\"https://example.com\"],[\"snapshot\",\"-i\"]]" }, { job: { steps: [{ action: "open", url: "https://example.com" }, { action: "assertText", text: "Example Domain" }, { action: "screenshot", path: ".dogfood/example.png" }] } }, { qa: { url: "https://example.com", expectedText: "Example Domain", screenshotPath: ".dogfood/qa-example.png" } } (example.com smoke only; elsewhere match exact visible text from snapshot -i), { electron: { action: "list", query: "code" } }, { electron: { action: "launch", appName: "Visual Studio Code", handoff: "snapshot" } }, { electron: { action: "probe" } }, { qa: { attached: true, expectedText: "Explorer" } }, { args: ["eval", "--stdin"], stdin: "document.title" }, { args: ["auth", "save", "name", "--password-stdin"], stdin: "<password from user-approved secret source>" }, { args: ["--profile", "Default", "open", "https://example.com/account"], sessionMode: "fresh" }, and { args: ["open", "--enable", "react-devtools", "https://example.com"], sessionMode: "fresh" }. For app pages with a native dropdown, job steps can include { action: "select", selector: "#flavor", value: "chocolate" } before the dependent assertion.`,
28
28
  "Constrained job navigation is explicit only: click (and select/submit flows that may navigate) does not prove the next page loaded; add assertUrl and/or assertText after navigation-prone steps before screenshot or later interactions. Example: { job: { steps: [{ action: \"open\", url: \"https://shop.example/checkout\" }, { action: \"fill\", selector: \"#email\", text: \"user@example.com\" }, { action: \"click\", selector: \"#continue\" }, { action: \"assertUrl\", url: \"**/shipping\" }, { action: \"assertText\", text: \"Shipping address\" }, { action: \"screenshot\", path: \".dogfood/shipping.png\" }] } }. Top-level click may add navigationSummary hints, but job never auto-inserts post-click asserts.",
29
29
  "High-value command reference: click <selector> --new-tab opens link-like targets in a new tab; select <selector> <value...> changes native dropdown values; scroll <dir> [px] --selector <sel> targets nested scrollers; download <selector> <path> saves a file triggered by a click; get title/url/text/html/value/attr/count reads page state; screenshot [selector] [path] captures a page or element image; pdf <path> saves a PDF; tab list and tab <tab-id-or-label> inspect or recover the active tab; react tree/inspect/renders/suspense introspect React after --enable react-devtools; vitals [url] measures Core Web Vitals; pushstate <url> performs SPA navigation; tap <selector> and swipe <direction> [distance] support iOS/provider touch flows.",
@@ -48,9 +48,9 @@ export const SHARED_BROWSER_PLAYBOOK_GUIDELINES = [
48
48
  `If you already used the implicit session and now need launch-scoped flags (${LAUNCH_SCOPED_FLAG_LABEL}), retry with top-level sessionMode set to fresh or pass an explicit --session for the new launch; never pass --session-mode inside args. After a successful unnamed fresh launch, later auto calls follow that new session.`,
49
49
  "For React introspection, launch the page with --enable react-devtools before first navigation, then use react tree, react inspect <fiberId>, sourceLookup candidates for local UI source hints, react renders start/stop, or react suspense; sourceLookup is experimental and reports confidence/evidence instead of guaranteed DOM-to-file mappings. For failed fetches and APIs, networkSourceLookup (experimental) correlates failed network requests with initiator metadata and bounded workspace URL literals—candidates only, not definitive blame. Use vitals [url] for Core Web Vitals and hydration timing, and pushstate <url> for client-side SPA navigation.",
50
50
  "For first-navigation setup, use open without a URL plus network route --resource-type <csv>, cookies set --curl <file>, or --init-script/--enable before navigate/opening the target page.",
51
- "For stateful browser context work, prefer purpose-specific page actions before dumping browser data: use auth save --password-stdin with the tool stdin field for credentials, auth list/show/delete/remove for local auth-profile maintenance, auth login when you need the browser to fill a saved profile, state save/load for portable test state, state list/show/rename/clear/clear -a/clean for saved-state lifecycle cleanup, cookies get/set/clear and storage local|session only when the task needs those values, and expect cookie/storage/auth/state summaries to redact credential-like fields.",
51
+ "For stateful browser context work, prefer purpose-specific page actions before dumping browser data: use auth save --password-stdin with the tool stdin field for credentials, auth list/show/delete/remove for local auth-profile maintenance, auth login when you need the browser to fill a saved profile, state save/load for portable test state, state list/show/rename/clear/clear -a/clean for saved-state lifecycle cleanup, cookies get/set/clear and storage local|session only when the task needs those values, and expect cookie/storage/auth/state summaries to redact credential-like fields while allowing benign primitive storage values when useful for local QA.",
52
52
  "For batch chains that touch cookies, storage, auth, or other secret-bearing commands, use details.batchSteps for per-step artifacts, categories, spill paths, and full structured errors; top-level details.data on batch is only a compact redacted step matrix (success, argv-redacted command, redacted result or scrubbed error text) built from the same presentation rules as standalone calls.",
53
- "For non-core families, pass current upstream commands through the native tool directly: network route/requests/har (including request filters like --type/--method/--status), diff snapshot/screenshot/url with scoped/baseline options, trace/profiler/record, console/errors/highlight/inspect/clipboard, stream enable/disable/status, dashboard start/stop, device list for iOS simulator inventory, and chat. For compact network requests output, prefer details.nextActions for request detail, actionable failed-request networkSourceLookup, filtering, or HAR capture follow-ups instead of guessing request-id syntax. Artifact-producing commands report details.artifacts and verification state; long-running starts such as stream, dashboard, trace/profiler, and record should be paired with the matching stop/disable command when the task is done.",
53
+ "For non-core families, pass current upstream commands through the native tool directly: network route/requests/har (including request filters like --type/--method/--status), diff snapshot/screenshot/url with scoped/baseline options, trace/profiler/record, console/errors/highlight/inspect/clipboard, stream enable/disable/status, dashboard start/stop, device list for iOS simulator inventory, and chat. For compact network requests output, prefer details.nextActions for request detail, route-mock diagnostics, actionable failed-request networkSourceLookup, filtering, or HAR capture follow-ups instead of guessing request-id syntax. Artifact-producing commands report details.artifacts and verification state; long-running starts such as stream, dashboard, trace/profiler, and record should be paired with the matching stop/disable command when the task is done; stream enable already-enabled outcomes are treated as idempotent success with status/disable follow-ups.",
54
54
  "For Electron desktop apps, prefer top-level electron for wrapper-owned discovery, isolated launch, status, compact probe, and cleanup: list first, treat likely-sensitive annotations as hints rather than enforcement, launch with the default snapshot handoff unless handoff: \"tabs\" is the safer diagnostic starting point, use electron.probe or snapshot -i/qa.attached for current-session state, and always cleanup the returned launchId when done. electron.launch uses an isolated temporary profile; it does not reuse the app's normal signed-in profile or attach to an already-running authenticated app. For signed-in local app state, host-launch the normal app with --remote-debugging-port when appropriate, then use raw args connect <port|url>; after connect, inspect tab list, select the stable tab id such as tab t2, then run a condition wait or snapshot -i before using refs. close commands (`close`, `quit`, or `exit`) only close the browser/CDP session; leave manually launched app shutdown, profile cleanup, and explicit artifacts to the host owner.",
55
55
  "For provider or specialized app workflows, load version-matched upstream guidance with skills get agentcore|electron|slack|dogfood|vercel-sandbox through the native tool; add --full when you need references/templates, and use skills get --all only for broad skill audits. Provider launches such as -p ios, --provider browserbase/kernel/browseruse/browserless/agentcore, and iOS --device are upstream-owned setup paths; use sessionMode fresh when switching providers and expect external credentials or local Appium/Xcode setup to be required.",
56
56
  "For dialogs and frames, use dialog status/accept/dismiss and frame <selector|main> through native args; when --confirm-actions produces a pending confirmation, use details.nextActions or exact confirm <id> / deny <id> calls instead of inventing ids.",
@@ -101,7 +101,7 @@ export function buildSharedBrowserPlaybookGuidelines(options: { includeWebSearch
101
101
  /** Tier A: always-on tool promptGuidelines (keep small; Tier B lives in SHARED_BROWSER_PLAYBOOK_GUIDELINES and docs). */
102
102
  export const RUNTIME_PROMPT_GUIDELINES = [
103
103
  "Use exactly one input mode: args, semanticAction, job, qa, sourceLookup/networkSourceLookup, or electron. stdin only for batch/eval/auth or wrapper batch; electron rejects stdin. Do not pass --json in args; wrapper injects it.",
104
- "Common flow: open, snapshot -i, use current @refs or semanticAction, then re-snapshot after navigation/scroll/rerender/DOM change. Batch same-snapshot fills unless they may submit/navigate/rerender. Respect explicit stop boundaries: stop before order/post/purchase/submit.",
104
+ "Common flow: open, snapshot -i, use current @refs or semanticAction, then re-snapshot after navigation/scroll/rerender/DOM change. Batch same-snapshot forms unless they may submit/navigate/rerender. Respect explicit stop boundaries: stop before order/post/purchase/submit.",
105
105
  "Use top-level sessionMode=fresh for launch-scoped flags; never put --session-mode in args. For signed-in/account-specific content, use requested/configured profiles, never assume --profile Default; on profile failures, run profiles/doctor and tell the user what to configure. Use --executable-path for configured Chromium. Profile content is model-visible.",
106
106
  "For artifacts, save the exact user path and verify details.artifactVerification/details.artifacts before claiming success. If close is blocked by details.promptGuard, save the required artifact first. record stop needs ffmpeg; close does not delete saved files; waited:timeout is not proof.",
107
107
  "When details.nextActions is present, prefer exact payloads over prose/guessed selectors. For dense snapshots, check Omitted high-value controls/details.data.highValueControlRefIds. For dashboards, verify scroll with screenshot/snapshot; if nothing moved, target the real scroll region.",
@@ -192,6 +192,21 @@ export function buildAgentBrowserNextActions(options: {
192
192
  }
193
193
  } else {
194
194
  switch (options.failureCategory) {
195
+ case "artifact-missing":
196
+ for (const artifact of options.artifacts ?? []) {
197
+ if (isPendingRecordingArtifact(artifact) || artifact.exists !== false) continue;
198
+ if (artifact.kind === "download") {
199
+ actions.push(buildNextToolAction({
200
+ args: ["wait", "--download", artifact.path],
201
+ id: "wait-for-download",
202
+ reason: "The requested download artifact was not found on disk after upstream reported completion.",
203
+ safety: "Use a bounded wait timeout that stays below the native wrapper IPC budget.",
204
+ }));
205
+ } else {
206
+ actions.push(buildArtifactVerificationAction(artifact));
207
+ }
208
+ }
209
+ break;
195
210
  case "confirmation-required":
196
211
  if (options.confirmationId) {
197
212
  actions.push(
@@ -29,6 +29,7 @@ export type AgentBrowserSuccessCategory = "artifact-saved" | "artifact-unverifie
29
29
 
30
30
  export type AgentBrowserFailureCategory =
31
31
  | "aborted"
32
+ | "artifact-missing"
32
33
  | "cleanup-failed"
33
34
  | "confirmation-required"
34
35
  | "download-not-verified"
@@ -161,6 +162,7 @@ export interface BatchStepPresentationDetails {
161
162
  imagePath?: string;
162
163
  imagePaths?: string[];
163
164
  index: number;
165
+ networkRouteDiagnostics?: NetworkRouteDiagnostic[];
164
166
  nextActions?: AgentBrowserNextAction[];
165
167
  pageChangeSummary?: AgentBrowserPageChangeSummary;
166
168
  resultCategory: AgentBrowserResultCategory;
@@ -193,6 +195,7 @@ export interface ToolPresentation {
193
195
  fullOutputPaths?: string[];
194
196
  imagePath?: string;
195
197
  imagePaths?: string[];
198
+ networkRouteDiagnostics?: NetworkRouteDiagnostic[];
196
199
  nextActions?: AgentBrowserNextAction[];
197
200
  pageChangeSummary?: AgentBrowserPageChangeSummary;
198
201
  resultCategory?: AgentBrowserResultCategory;
@@ -218,3 +221,17 @@ export interface NetworkFailureSummary {
218
221
  failures: NetworkFailureClassification[];
219
222
  totalCount: number;
220
223
  }
224
+
225
+ export interface NetworkRouteRecord {
226
+ mode: "abort" | "body" | "handler" | "unknown";
227
+ pattern: string;
228
+ }
229
+
230
+ export interface NetworkRouteDiagnostic {
231
+ mode: NetworkRouteRecord["mode"];
232
+ reason: "pending-routed-request" | "cors-likely-routed-request";
233
+ requestId?: string;
234
+ requestUrl?: string;
235
+ routePattern: string;
236
+ summary: string;
237
+ }
@@ -0,0 +1,80 @@
1
+ import { isRecord } from "../parsing.js";
2
+ import { redactSensitiveText } from "../runtime.js";
3
+ import type { NetworkRouteDiagnostic, NetworkRouteRecord } from "./contracts.js";
4
+ import { getStringRecordField, isApiLikeNetworkRequest } from "./network.js";
5
+
6
+ function getArrayField(data: Record<string, unknown>, key: string): unknown[] | undefined {
7
+ const value = data[key];
8
+ return Array.isArray(value) ? value : undefined;
9
+ }
10
+
11
+ function networkRoutePatternMatchesUrl(pattern: string, url: string): boolean {
12
+ if (pattern === url) return true;
13
+ if (pattern.includes("*")) {
14
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
15
+ return new RegExp(`^${escaped}$`).test(url);
16
+ }
17
+ return pattern.length >= 4 && url.includes(pattern);
18
+ }
19
+
20
+ function getSafeRequestId(item: Record<string, unknown>): string | undefined {
21
+ const requestId = getStringRecordField(item, "requestId") ?? getStringRecordField(item, "id");
22
+ if (!requestId || redactSensitiveText(requestId) !== requestId) return undefined;
23
+ return requestId;
24
+ }
25
+
26
+ function getRouteDiagnosticReason(item: Record<string, unknown>): NetworkRouteDiagnostic["reason"] | undefined {
27
+ const statusMissing = typeof item.status !== "number";
28
+ const error = getStringRecordField(item, "error") ?? getStringRecordField(item, "failureText") ?? getStringRecordField(item, "errorText");
29
+ if (error && /(?:cors|cross-origin|preflight|access-control-allow-origin)/i.test(error)) return "cors-likely-routed-request";
30
+ if (statusMissing && isApiLikeNetworkRequest(item)) return "pending-routed-request";
31
+ return undefined;
32
+ }
33
+
34
+ export function getNetworkRouteMode(args: string[]): NetworkRouteRecord["mode"] {
35
+ if (args.includes("--abort")) return "abort";
36
+ if (args.includes("--body")) return "body";
37
+ return "handler";
38
+ }
39
+
40
+ export function applyNetworkRouteRecords(routes: NetworkRouteRecord[] | undefined, commandTokens: string[] | undefined, succeeded: boolean): NetworkRouteRecord[] | undefined {
41
+ if (!succeeded || commandTokens?.[0] !== "network") return routes;
42
+ const subcommand = commandTokens[1];
43
+ if (subcommand !== "route" && subcommand !== "unroute") return routes;
44
+ const existing = routes ?? [];
45
+ const pattern = commandTokens[2];
46
+ if (subcommand === "route" && pattern) return [...existing.filter((route) => route.pattern !== pattern), { mode: getNetworkRouteMode(commandTokens), pattern }];
47
+ if (!pattern) return undefined;
48
+ const next = existing.filter((route) => route.pattern !== pattern);
49
+ return next.length > 0 ? next : undefined;
50
+ }
51
+
52
+ export function buildNetworkRouteDiagnostics(data: unknown, routes: NetworkRouteRecord[] | undefined): NetworkRouteDiagnostic[] | undefined {
53
+ if (!routes || routes.length === 0 || !isRecord(data)) return undefined;
54
+ const requests = getArrayField(data, "requests");
55
+ if (!requests) return undefined;
56
+ const diagnostics: NetworkRouteDiagnostic[] = [];
57
+ for (const item of requests) {
58
+ if (!isRecord(item)) continue;
59
+ const url = getStringRecordField(item, "url");
60
+ if (!url) continue;
61
+ const reason = getRouteDiagnosticReason(item);
62
+ if (!reason) continue;
63
+ const route = routes.find((candidate) => networkRoutePatternMatchesUrl(candidate.pattern, url));
64
+ if (!route) continue;
65
+ const requestId = getSafeRequestId(item);
66
+ const requestUrl = redactSensitiveText(url);
67
+ const routePattern = redactSensitiveText(route.pattern);
68
+ diagnostics.push({
69
+ mode: route.mode,
70
+ reason,
71
+ ...(requestId ? { requestId } : {}),
72
+ requestUrl,
73
+ routePattern,
74
+ summary: reason === "cors-likely-routed-request"
75
+ ? `Routed request ${requestId ?? requestUrl} looks CORS/preflight-related for route ${routePattern}.`
76
+ : `Routed request ${requestId ?? requestUrl} is still pending/no-status for route ${routePattern}.`,
77
+ });
78
+ }
79
+ return diagnostics.length > 0 ? diagnostics.slice(0, 5) : undefined;
80
+ }
@@ -9,12 +9,12 @@
9
9
  import { isRecord } from "../parsing.js";
10
10
  import type { NetworkFailureClassification, NetworkFailureSummary } from "./contracts.js";
11
11
 
12
- function getStringRecordField(value: Record<string, unknown>, key: string): string | undefined {
12
+ export function getStringRecordField(value: Record<string, unknown>, key: string): string | undefined {
13
13
  const field = value[key];
14
14
  return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined;
15
15
  }
16
16
 
17
- function getNetworkRequestUrlPath(url: string | undefined): string | undefined {
17
+ export function getNetworkRequestUrlPath(url: string | undefined): string | undefined {
18
18
  if (!url) return undefined;
19
19
  try {
20
20
  return new URL(url).pathname;
@@ -37,6 +37,14 @@ function isBenignAssetFailure(request: Record<string, unknown>, url: string | un
37
37
  && (!normalizedResourceType || ["image", "img", "other"].includes(normalizedResourceType) || normalizedResourceType.startsWith("image/"));
38
38
  }
39
39
 
40
+ export function isApiLikeNetworkRequest(request: Record<string, unknown>): boolean {
41
+ const method = (getStringRecordField(request, "method") ?? "GET").toUpperCase();
42
+ const resourceType = (getStringRecordField(request, "resourceType") ?? "").toLowerCase();
43
+ const mimeType = (getStringRecordField(request, "mimeType") ?? "").toLowerCase();
44
+ const path = getNetworkRequestUrlPath(getStringRecordField(request, "url")) ?? "";
45
+ return resourceType === "fetch" || resourceType === "xhr" || mimeType.includes("json") || /\/(?:api|graphql|rpc)(?:\/|$)/i.test(path) || !["GET", "HEAD"].includes(method);
46
+ }
47
+
40
48
  export function classifyNetworkRequestFailure(request: Record<string, unknown>): NetworkFailureClassification | undefined {
41
49
  if (!isFailedNetworkRequest(request)) return undefined;
42
50
  const url = getStringRecordField(request, "url");
@@ -340,6 +340,20 @@ export function buildArtifactVerificationSummary(
340
340
  };
341
341
  }
342
342
 
343
+ export function hasMissingFileArtifact(artifacts: FileArtifactMetadata[] | undefined): boolean {
344
+ return (artifacts ?? []).some((artifact) => !isPendingRecordingArtifact(artifact) && artifact.exists === false);
345
+ }
346
+
347
+ export function formatMissingArtifactFailureText(artifacts: FileArtifactMetadata[] | undefined): string | undefined {
348
+ const missingArtifacts = (artifacts ?? []).filter((artifact) => !isPendingRecordingArtifact(artifact) && artifact.exists === false);
349
+ if (missingArtifacts.length === 0) return undefined;
350
+ if (missingArtifacts.length === 1) {
351
+ const artifact = missingArtifacts[0];
352
+ return `Artifact verification failed: requested ${artifact.kind} was not found at ${artifact.absolutePath}.`;
353
+ }
354
+ return `Artifact verification failed: ${missingArtifacts.length} requested artifacts were not found on disk.`;
355
+ }
356
+
343
357
  export function classifyPresentationSuccessCategory(options: {
344
358
  artifactVerification?: ArtifactVerificationSummary;
345
359
  artifacts?: FileArtifactMetadata[];