pi-agent-browser-native 0.9.4 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +58 -813
  3. package/dist/extensions/agent-browser/index.js +927 -883
  4. package/dist/extensions/agent-browser/lib/browser-execution-claims.js +5 -5
  5. package/dist/extensions/agent-browser/lib/config-policy.js +1 -1
  6. package/dist/extensions/agent-browser/lib/config-validation.js +21 -8
  7. package/dist/extensions/agent-browser/lib/config.js +1 -1
  8. package/dist/extensions/agent-browser/lib/electron/cdp.js +5 -1
  9. package/dist/extensions/agent-browser/lib/electron/cleanup.js +13 -47
  10. package/dist/extensions/agent-browser/lib/electron/discovery-evidence.js +1 -0
  11. package/dist/extensions/agent-browser/lib/electron/launch.js +12 -1
  12. package/dist/extensions/agent-browser/lib/fs-utils.js +1 -9
  13. package/dist/extensions/agent-browser/lib/input-modes/job.js +7 -4
  14. package/dist/extensions/agent-browser/lib/input-modes/lookup-network-evidence.js +15 -3
  15. package/dist/extensions/agent-browser/lib/input-modes/lookups.js +3 -2
  16. package/dist/extensions/agent-browser/lib/input-modes/params.js +23 -23
  17. package/dist/extensions/agent-browser/lib/orchestration/browser-run/electron-observation.js +9 -8
  18. package/dist/extensions/agent-browser/lib/orchestration/browser-run/final-result-electron.js +8 -0
  19. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/scroll-shims.js +2 -2
  20. package/dist/extensions/agent-browser/lib/orchestration/browser-run/prepare/snapshot-filter.js +9 -0
  21. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state-electron.js +4 -2
  22. package/dist/extensions/agent-browser/lib/orchestration/browser-run/session-state-refs.js +9 -11
  23. package/dist/extensions/agent-browser/lib/orchestration/electron-host/branch.js +1 -0
  24. package/dist/extensions/agent-browser/lib/orchestration/electron-host/probe-collection.js +5 -6
  25. package/dist/extensions/agent-browser/lib/orchestration/electron-host/probe-data.js +4 -2
  26. package/dist/extensions/agent-browser/lib/orchestration/electron-host/probe-presentation.js +20 -2
  27. package/dist/extensions/agent-browser/lib/orchestration/electron-host/status.js +4 -2
  28. package/dist/extensions/agent-browser/lib/orchestration/execution-queue.js +1 -1
  29. package/dist/extensions/agent-browser/lib/results/presentation/skills.js +7 -2
  30. package/dist/extensions/agent-browser/lib/temp-artifact-budget.js +4 -1
  31. package/dist/extensions/agent-browser/lib/temp-root.js +22 -18
  32. package/dist/extensions/agent-browser/lib/temp.js +1 -1
  33. package/dist/extensions/agent-browser/lib/web-search-providers.js +0 -36
  34. package/dist/extensions/agent-browser/lib/web-search-request.js +20 -26
  35. package/dist/extensions/agent-browser/lib/web-search-schema.js +5 -5
  36. package/dist/extensions/agent-browser/lib/web-search.js +22 -4
  37. package/docs/ARCHITECTURE.md +3 -2
  38. package/docs/CODE_QUALITY.md +28 -3
  39. package/docs/COMMAND_REFERENCE.md +20 -20
  40. package/docs/ELECTRON.md +24 -2
  41. package/docs/REFERENCE.md +869 -0
  42. package/docs/RELEASE.md +9 -9
  43. package/docs/REQUIREMENTS.md +1 -1
  44. package/docs/SUPPORT_MATRIX.md +9 -8
  45. package/docs/TOOL_CONTRACT.md +13 -9
  46. package/docs/platform-smoke.md +3 -3
  47. package/package.json +3 -2
  48. package/scripts/config.mjs +23 -19
  49. package/scripts/doctor.mjs +2 -2
  50. package/scripts/platform-smoke/lease-evidence.mjs +2 -4
  51. package/scripts/platform-smoke/linux-image/Dockerfile +8 -2
  52. package/scripts/platform-smoke/targets.mjs +1 -5
@@ -87,7 +87,7 @@ function isSafeSameSnapshotFormBatchStep(step, refSnapshot) {
87
87
  return false;
88
88
  }
89
89
  }
90
- function getBatchRefInvalidationMessage(commandTokens, stdin, refSnapshot) {
90
+ function getBatchRefInvalidationFailure(commandTokens, stdin, refSnapshot) {
91
91
  if (commandTokens[0] !== "batch") {
92
92
  return;
93
93
  }
@@ -98,7 +98,11 @@ function getBatchRefInvalidationMessage(commandTokens, stdin, refSnapshot) {
98
98
  }
99
99
  const refIds = collectRefsFromTokens(step);
100
100
  if (refIds.length > 0 && isRefGuardedCommand(step[0]) && priorStepInvalidatesRefs) {
101
- 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.`;
101
+ return {
102
+ message: `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.`,
103
+ refIds,
104
+ snapshot: refSnapshot,
105
+ };
102
106
  }
103
107
  if (isRefInvalidatingBatchCommand(step) &&
104
108
  !isSafeSameSnapshotFormBatchStep(step, refSnapshot)) {
@@ -151,15 +155,9 @@ export function buildStaleRefPreflight(facts) {
151
155
  })),
152
156
  ]
153
157
  : guardedRefIds;
154
- const batchInvalidationMessage = getBatchRefInvalidationMessage(facts.commandTokens, facts.stdin, facts.refSnapshot);
155
- if (batchInvalidationMessage !== undefined &&
156
- batchInvalidationMessage.length > 0 &&
157
- guardedRefIds.length > 0) {
158
- return {
159
- message: batchInvalidationMessage,
160
- refIds: guardedRefIds,
161
- snapshot: facts.refSnapshot,
162
- };
158
+ const batchInvalidationFailure = getBatchRefInvalidationFailure(facts.commandTokens, facts.stdin, facts.refSnapshot);
159
+ if (batchInvalidationFailure) {
160
+ return batchInvalidationFailure;
163
161
  }
164
162
  if (usedRefIds.length === 0) {
165
163
  return;
@@ -21,6 +21,7 @@ function hasValidOptionalFields(value) {
21
21
  "packageSource",
22
22
  "platform",
23
23
  "sessionName",
24
+ "startIdentity",
24
25
  "webSocketDebuggerUrl",
25
26
  ];
26
27
  return (strings.every((field) => value[field] === undefined || typeof value[field] === "string") &&
@@ -17,9 +17,6 @@ function probeCommandError(result, label) {
17
17
  ? `${label}: ${result.error}`
18
18
  : undefined;
19
19
  }
20
- function probeString(data, field, limit) {
21
- return boundElectronProbeString(extractStringResultField(data, "result") ?? extractStringResultField(data, field), limit);
22
- }
23
20
  export async function collectElectronProbe(options) {
24
21
  // Each read completes before the next: native session commands share a pending
25
22
  // slot, and the final snapshot owns the refs published by this probe.
@@ -28,8 +25,9 @@ export async function collectElectronProbe(options) {
28
25
  if (urlError !== undefined) {
29
26
  throw new Error(urlError);
30
27
  }
31
- const url = probeString(urlResult.data, "url", 300);
32
- if (url === undefined || url.length === 0) {
28
+ const url = extractStringResultField(urlResult.data, "result") ??
29
+ extractStringResultField(urlResult.data, "url");
30
+ if (url === undefined || url.trim().length === 0) {
33
31
  throw new Error("get url returned no active page URL.");
34
32
  }
35
33
  const titleResult = await runElectronProbeCommandData({ ...options, args: ["get", "title"] });
@@ -62,7 +60,8 @@ export async function collectElectronProbe(options) {
62
60
  snapshot,
63
61
  status: errors.length === 0 ? "succeeded" : "partial",
64
62
  tabs,
65
- title: probeString(titleResult.data, "title", 160),
63
+ title: boundElectronProbeString(extractStringResultField(titleResult.data, "result") ??
64
+ extractStringResultField(titleResult.data, "title"), 160),
66
65
  url,
67
66
  };
68
67
  return { ...probe, summary: getElectronProbeSummary(probe) };
@@ -92,7 +92,7 @@ export function extractElectronProbeTabs(data) {
92
92
  tabId: getTrimmedString(tab.tabId) ?? getTrimmedString(tab.id),
93
93
  title: getTrimmedString(tab.title) ?? getTrimmedString(tab.label),
94
94
  type: getTrimmedString(tab.type),
95
- url: getTrimmedString(tab.url),
95
+ url: typeof tab.url === "string" ? tab.url : undefined,
96
96
  }));
97
97
  if (allTabs.length === 0) {
98
98
  return {};
@@ -156,7 +156,9 @@ export function summarizeElectronProbeSnapshot(data) {
156
156
  export function getElectronProbeSummary(probe) {
157
157
  const parts = [
158
158
  probe.title !== undefined && probe.title.length > 0 ? `title "${probe.title}"` : undefined,
159
- probe.url !== undefined && probe.url.length > 0 ? `url ${probe.url}` : undefined,
159
+ probe.url !== undefined && probe.url.length > 0
160
+ ? `url ${boundElectronProbeString(probe.url, 300) ?? ""}`
161
+ : undefined,
160
162
  probe.focusedElement ? "focused element" : undefined,
161
163
  probe.tabs ? `${probe.tabs.total} tab(s)` : undefined,
162
164
  probe.snapshot ? `${probe.snapshot.refCount} ref(s)` : undefined,
@@ -1,3 +1,4 @@
1
+ import { boundElectronProbeString } from "../../electron/cdp.js";
1
2
  import { buildAgentBrowserNextActions } from "../../results/action-recommendations.js";
2
3
  import { buildAgentBrowserResultCategoryDetails } from "../../results/categories.js";
3
4
  import { appendUniqueAgentBrowserNextActions } from "../../results/next-actions.js";
@@ -5,6 +6,17 @@ import { redactSensitiveText } from "../../runtime-redaction.js";
5
6
  import { isAboutBlankUrl } from "../../session-page-state.js";
6
7
  import { redactToolDetails } from "../browser-run/final-result.js";
7
8
  import { buildElectronIdentifiers, buildElectronMismatchNextActions, formatElectronSessionMismatchText, getLiveElectronRendererTargets, } from "../browser-run/session-state.js";
9
+ function presentProbeTab(tab) {
10
+ return { ...tab, url: boundElectronProbeString(tab.url) };
11
+ }
12
+ function presentProbe(probe) {
13
+ return {
14
+ ...probe,
15
+ activeTab: probe.activeTab ? presentProbeTab(probe.activeTab) : undefined,
16
+ tabs: probe.tabs ? { ...probe.tabs, shown: probe.tabs.shown.map(presentProbeTab) } : undefined,
17
+ url: boundElectronProbeString(probe.url, 300),
18
+ };
19
+ }
8
20
  function presentStrings(values) {
9
21
  return values.filter((value) => value !== undefined && value.length > 0);
10
22
  }
@@ -130,7 +142,8 @@ function probeNextActions(options) {
130
142
  : appendUniqueAgentBrowserNextActions([...base], mismatch);
131
143
  }
132
144
  export function buildElectronProbeResult(options) {
133
- const { refSnapshot: _refSnapshot, ...boundedProbe } = options.probe;
145
+ const probe = presentProbe(options.probe);
146
+ const { refSnapshot: _refSnapshot, ...boundedProbe } = probe;
134
147
  const nextActions = probeNextActions(options);
135
148
  const details = {
136
149
  args: [],
@@ -157,7 +170,12 @@ export function buildElectronProbeResult(options) {
157
170
  usedImplicitSession: options.probeContext.mode === "current-managed-session",
158
171
  };
159
172
  return {
160
- content: [{ type: "text", text: redactSensitiveText(formatElectronProbeVisibleText(options)) }],
173
+ content: [
174
+ {
175
+ type: "text",
176
+ text: redactSensitiveText(formatElectronProbeVisibleText({ ...options, probe })),
177
+ },
178
+ ],
161
179
  details: redactToolDetails(details, []),
162
180
  isError: false,
163
181
  };
@@ -9,6 +9,7 @@ import { buildElectronHostFailureResult, formatElectronTargetLines, redactToolDe
9
9
  import { buildElectronIdentifiers, buildElectronMismatchNextActions, buildElectronSessionMismatch, formatElectronSessionMismatchText, } from "../browser-run/session-state.js";
10
10
  import { selectElectronRecords } from "./branch.js";
11
11
  import { collectOwnedElectronManagedSessionTarget } from "./policy.js";
12
+ import { presentElectronManagedSessionTarget } from "../browser-run/final-result-electron.js";
12
13
  function pidStatusText(pidAlive) {
13
14
  if (pidAlive === undefined) {
14
15
  return "";
@@ -72,6 +73,7 @@ function statusNextActions(options) {
72
73
  }
73
74
  function buildElectronStatusResult(options) {
74
75
  const nextActions = statusNextActions(options);
76
+ const managedSessions = options.managedSessions.map(presentElectronManagedSessionTarget);
75
77
  const soleRecord = options.records.length === 1 ? options.records[0] : undefined;
76
78
  const details = {
77
79
  args: [],
@@ -81,8 +83,8 @@ function buildElectronStatusResult(options) {
81
83
  identifierList: options.records.length > 1 ? options.records.map(buildElectronIdentifiers) : undefined,
82
84
  identifiers: soleRecord ? buildElectronIdentifiers(soleRecord) : undefined,
83
85
  launches: options.records,
84
- managedSession: options.managedSessions.length === 1 ? options.managedSessions[0] : undefined,
85
- managedSessions: options.managedSessions.length > 0 ? options.managedSessions : undefined,
86
+ managedSession: managedSessions.length === 1 ? managedSessions[0] : undefined,
87
+ managedSessions: managedSessions.length > 0 ? managedSessions : undefined,
86
88
  sessionMismatch: options.mismatches.length === 1 ? options.mismatches[0] : undefined,
87
89
  sessionMismatches: options.mismatches.length > 1 ? options.mismatches : undefined,
88
90
  status: "succeeded",
@@ -54,7 +54,7 @@ export class KeyedAsyncExecutionQueue {
54
54
  async run(key, namespace, work, signal) {
55
55
  const entry = this.entries.get(key) ?? { queue: new AsyncExecutionQueue(), users: 0 };
56
56
  if (entry.queue.isCurrent()) {
57
- return work();
57
+ return entry.queue.run(work, signal);
58
58
  }
59
59
  const barrier = this.barriers.get(getAgentBrowserSessionIdentityKey("", namespace)) ?? Promise.resolve();
60
60
  entry.users += 1;
@@ -33,6 +33,7 @@ function getSkillContent(data) {
33
33
  class SkillShellWords {
34
34
  words = [];
35
35
  current = "";
36
+ wordStarted = false;
36
37
  quote;
37
38
  index = 0;
38
39
  input;
@@ -40,9 +41,10 @@ class SkillShellWords {
40
41
  this.input = input;
41
42
  }
42
43
  flush() {
43
- if (this.current.length > 0) {
44
+ if (this.wordStarted) {
44
45
  this.words.push(this.current);
45
46
  this.current = "";
47
+ this.wordStarted = false;
46
48
  }
47
49
  }
48
50
  appendEscape() {
@@ -68,18 +70,21 @@ class SkillShellWords {
68
70
  }
69
71
  consumeUnquoted(char) {
70
72
  if (char === "'" || char === '"') {
73
+ this.wordStarted = true;
71
74
  this.quote = char === "'" ? "single" : "double";
72
75
  }
73
76
  else if (char === "\\") {
77
+ this.wordStarted = true;
74
78
  this.appendEscape();
75
79
  }
76
- else if (char === "#" && this.current.length === 0) {
80
+ else if (char === "#" && !this.wordStarted) {
77
81
  return false;
78
82
  }
79
83
  else if (/\s/.test(char)) {
80
84
  this.flush();
81
85
  }
82
86
  else {
87
+ this.wordStarted = true;
83
88
  this.current += char;
84
89
  }
85
90
  return true;
@@ -67,7 +67,10 @@ export async function prunePersistentSessionArtifactsToBudget(sessionArtifactDir
67
67
  }
68
68
  // Evict oldest first and stop immediately once the required budget is available.
69
69
  // oxlint-disable-next-line no-await-in-loop
70
- await rm(file.path, { force: true }).catch(() => undefined);
70
+ const removed = await rm(file.path, { force: true }).then(() => true, () => false);
71
+ if (!removed) {
72
+ continue;
73
+ }
71
74
  evictedArtifacts.push({ mtimeMs: file.mtimeMs, path: file.path, sizeBytes: file.size });
72
75
  totalBytes -= file.size;
73
76
  if (totalBytes + additionalBytes <= maxBytes) {
@@ -161,19 +161,29 @@ export async function cleanupSecureTempArtifacts(options = {}) {
161
161
  });
162
162
  });
163
163
  }
164
- export async function getSessionTempRoot() {
165
- if (!sessionTempRootPromise) {
166
- sessionTempRootPromise = (async () => {
167
- const tempRoot = await mkdtemp(join(tmpdir(), TEMP_ROOT_PREFIX));
168
- await chmod(tempRoot, 0o700).catch(() => {
169
- /* mkdtemp already creates a private root. */
170
- });
171
- await writeSecureTempRootOwnershipMarker(tempRoot);
172
- ownedTempRoots.add(tempRoot);
173
- registerExitCleanup();
174
- return tempRoot;
175
- })();
164
+ async function allocateSessionTempRoot() {
165
+ const tempRoot = await mkdtemp(join(tmpdir(), TEMP_ROOT_PREFIX));
166
+ try {
167
+ await chmod(tempRoot, 0o700).catch(() => {
168
+ /* mkdtemp already creates a private root. */
169
+ });
170
+ await writeSecureTempRootOwnershipMarker(tempRoot);
171
+ ownedTempRoots.add(tempRoot);
172
+ registerExitCleanup();
173
+ return tempRoot;
174
+ }
175
+ catch (error) {
176
+ await rm(tempRoot, { force: true, recursive: true }).catch(() => {
177
+ /* Preserve the allocation failure if private-root cleanup also fails. */
178
+ });
179
+ throw error;
176
180
  }
181
+ }
182
+ export async function getSessionTempRoot() {
183
+ sessionTempRootPromise ??= allocateSessionTempRoot().catch((error) => {
184
+ sessionTempRootPromise = undefined;
185
+ throw error;
186
+ });
177
187
  const tempRoot = await sessionTempRootPromise;
178
188
  await refreshSecureTempRootLease(tempRoot).catch(() => {
179
189
  /* A failed refresh leaves the existing marker. */
@@ -199,9 +209,3 @@ export async function getSecureTempChildDirectoryValidationError(path, childPref
199
209
  }
200
210
  return undefined;
201
211
  }
202
- export async function getSecureTempDebugState() {
203
- return {
204
- currentTempRoot: await sessionTempRootPromise?.catch(() => undefined),
205
- ownedTempRoots: [...ownedTempRoots].sort(),
206
- };
207
- }
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { enqueueTempMutation } from "./temp-mutation-queue.js";
5
5
  import { getSessionTempRoot, refreshSecureTempRootLease } from "./temp-root.js";
6
6
  import { assertSecureTempRootBudget, getTempArtifactByteLength, prunePersistentSessionArtifactsToBudget, } from "./temp-artifact-budget.js";
7
- export { cleanupSecureTempArtifacts, getSecureTempChildDirectoryValidationError, getSecureTempDebugState, preserveSecureTempDirectory, writeSecureTempRootOwnershipMarker, } from "./temp-root.js";
7
+ export { cleanupSecureTempArtifacts, getSecureTempChildDirectoryValidationError, preserveSecureTempDirectory, writeSecureTempRootOwnershipMarker, } from "./temp-root.js";
8
8
  export { getPersistentSessionArtifactMaxBytes, getSecureTempRootMaxBytes, } from "./temp-artifact-budget.js";
9
9
  async function ensurePersistentSessionArtifactDir(store) {
10
10
  const rootDir = join(store.sessionDir, ".pi-agent-browser-artifacts");
@@ -76,7 +76,6 @@ export function buildExaSearchRequestBody(params, now = () => new Date()) {
76
76
  }
77
77
  export async function fetchBraveSearchJson(url, apiKey, signal) {
78
78
  const data = await fetchSearchJson({
79
- apiKey,
80
79
  cancelMessage: "Brave search cancelled",
81
80
  init: { headers: { Accept: "application/json", "X-Subscription-Token": apiKey } },
82
81
  invalidJsonMessage: "Brave search returned invalid JSON",
@@ -113,7 +112,6 @@ function usesDynamicHighlights(body) {
113
112
  }
114
113
  export async function fetchExaSearchJson(body, apiKey, signal, timeoutMs = SEARCH_REQUEST_TIMEOUT_MS) {
115
114
  const data = await fetchSearchJson({
116
- apiKey,
117
115
  cancelMessage: "Exa search cancelled",
118
116
  init: {
119
117
  body: JSON.stringify(body),
@@ -178,40 +176,6 @@ const EXA_WEB_SEARCH_ADAPTER = {
178
176
  };
179
177
  },
180
178
  };
181
- // Dynamic public callers supply unknown inputs; validate them before entering typed providers.
182
- const WEB_SEARCH_PROVIDER_ADAPTERS = {
183
- brave: {
184
- provider: "brave",
185
- buildRequest: BRAVE_WEB_SEARCH_ADAPTER.buildRequest,
186
- fetchJson(request, apiKey, signal) {
187
- if (!(request instanceof URL)) {
188
- throw new Error("Brave search request must be a URL");
189
- }
190
- return BRAVE_WEB_SEARCH_ADAPTER.fetchJson(request, apiKey, signal);
191
- },
192
- normalizeResponse(response, params) {
193
- return BRAVE_WEB_SEARCH_ADAPTER.normalizeResponse(parseBraveSearchResponse(response), params);
194
- },
195
- },
196
- exa: {
197
- provider: "exa",
198
- buildRequest: EXA_WEB_SEARCH_ADAPTER.buildRequest,
199
- fetchJson(request, apiKey, signal) {
200
- if (!isRecord(request) ||
201
- !isRecord(request.body) ||
202
- typeof request.timeoutMs !== "number") {
203
- throw new Error("Exa search request must contain a body and timeout");
204
- }
205
- return fetchExaSearchJson(request.body, apiKey, signal, request.timeoutMs);
206
- },
207
- normalizeResponse(response, params) {
208
- return EXA_WEB_SEARCH_ADAPTER.normalizeResponse(parseExaSearchResponse(response), params);
209
- },
210
- },
211
- };
212
- export function getWebSearchProviderAdapter(provider) {
213
- return WEB_SEARCH_PROVIDER_ADAPTERS[provider];
214
- }
215
179
  /** Build before queueing so local validation fails without consuming a request slot. */
216
180
  export function prepareWebSearchRequest(provider, params, apiKey, signal) {
217
181
  if (provider === "exa") {
@@ -1,4 +1,4 @@
1
- import { cleanSearchText, getProviderLabel } from "./web-search-results.js";
1
+ import { getProviderLabel } from "./web-search-results.js";
2
2
  export const SEARCH_REQUEST_TIMEOUT_MS = 15_000;
3
3
  export const WEB_SEARCH_MIN_REQUEST_INTERVAL_MS = 1_100;
4
4
  function sleepWithAbort(ms, signal) {
@@ -29,27 +29,21 @@ function sleepWithAbort(ms, signal) {
29
29
  });
30
30
  }
31
31
  export class WebSearchRequestGate {
32
- now;
33
- sleep;
34
32
  lastRequestStartedAt = 0;
35
33
  tail = Promise.resolve();
36
- constructor(now = Date.now, sleep = sleepWithAbort) {
37
- this.now = now;
38
- this.sleep = sleep;
39
- }
40
34
  run(signal, task) {
41
35
  const runTask = async () => {
42
36
  const elapsedMs = this.lastRequestStartedAt === 0
43
37
  ? WEB_SEARCH_MIN_REQUEST_INTERVAL_MS
44
- : this.now() - this.lastRequestStartedAt;
38
+ : Date.now() - this.lastRequestStartedAt;
45
39
  const waitMs = Math.max(0, WEB_SEARCH_MIN_REQUEST_INTERVAL_MS - elapsedMs);
46
40
  if (waitMs > 0) {
47
- await this.sleep(waitMs, signal);
41
+ await sleepWithAbort(waitMs, signal);
48
42
  }
49
43
  if (signal?.aborted === true) {
50
44
  signal.throwIfAborted();
51
45
  }
52
- this.lastRequestStartedAt = this.now();
46
+ this.lastRequestStartedAt = Date.now();
53
47
  return task();
54
48
  };
55
49
  const result = this.tail.then(runTask, runTask);
@@ -59,20 +53,19 @@ export class WebSearchRequestGate {
59
53
  return result;
60
54
  }
61
55
  }
62
- function redactSearchSecret(text, apiKey) {
63
- return apiKey.length > 0 ? text.split(apiKey).join("[REDACTED]") : text;
64
- }
65
56
  function formatSearchHttpError(options) {
66
- const { provider, status, statusText, body, apiKey } = options;
57
+ const { provider, status } = options;
67
58
  const providerLabel = getProviderLabel(provider);
68
- const errorPreview = cleanSearchText(redactSearchSecret(body, apiKey), 300);
69
59
  if (status === 429) {
70
- const preview = errorPreview !== undefined
71
- ? ` Upstream details: ${redactSearchSecret(errorPreview, apiKey)}`
72
- : "";
73
- return `${providerLabel} search rate limit exceeded (HTTP 429). Do not issue parallel or repeated agent_browser_web_search calls; use one high-signal query, inspect those results, then wait before retrying or ask the user to adjust their ${providerLabel} API plan/limits.${preview}`;
60
+ return `${providerLabel} search rate limit exceeded (HTTP 429). Do not issue parallel or repeated agent_browser_web_search calls; use one high-signal query, inspect those results, then wait before retrying or ask the user to adjust their ${providerLabel} API plan/limits.`;
61
+ }
62
+ if (status === 401 || status === 403) {
63
+ return `${providerLabel} search failed with HTTP ${status}: Check your API credential and permissions.`;
74
64
  }
75
- return `${providerLabel} search failed with HTTP ${status}: ${errorPreview !== undefined ? redactSearchSecret(errorPreview, apiKey) : statusText}`;
65
+ if (status >= 500) {
66
+ return `${providerLabel} search failed with HTTP ${status}: Provider unavailable; retry later.`;
67
+ }
68
+ return `${providerLabel} search failed with HTTP ${status}: Request rejected; check the search parameters.`;
76
69
  }
77
70
  export async function fetchSearchJson(options) {
78
71
  if (options.signal?.aborted === true) {
@@ -84,21 +77,22 @@ export async function fetchSearchJson(options) {
84
77
  options.signal?.addEventListener("abort", abort, { once: true });
85
78
  try {
86
79
  const response = await fetch(options.request, { ...options.init, signal: controller.signal });
87
- const text = await response.text();
88
80
  if (!response.ok) {
81
+ await response.body?.cancel().catch(() => {
82
+ // Disposal errors must not replace the source-free HTTP diagnostic.
83
+ });
89
84
  throw new Error(formatSearchHttpError({
90
85
  provider: options.provider,
91
86
  status: response.status,
92
- statusText: response.statusText,
93
- body: text,
94
- apiKey: options.apiKey,
95
87
  }));
96
88
  }
89
+ const text = await response.text();
97
90
  try {
98
91
  return JSON.parse(text);
99
92
  }
100
- catch (error) {
101
- throw new Error(`${options.invalidJsonMessage}: ${error instanceof Error ? error.message : "Unknown JSON parse failure"}`, { cause: error });
93
+ catch {
94
+ // Parser messages and causes can include partial credentials from the response body.
95
+ throw new Error(options.invalidJsonMessage);
102
96
  }
103
97
  }
104
98
  finally {
@@ -1,5 +1,5 @@
1
- import { JsonSchema } from "./json-schema.js";
2
- import { StringEnum as localStringEnum } from "./string-enum-schema.js";
1
+ import { JsonSchema as Type } from "./json-schema.js";
2
+ import { StringEnum } from "./string-enum-schema.js";
3
3
  import { DEFAULT_WEB_SEARCH_PROVIDER, EXA_SEARCH_TYPES, WEB_SEARCH_PROVIDERS, } from "./config.js";
4
4
  export const DEFAULT_SEARCH_RESULT_COUNT = 5;
5
5
  export const MAX_SEARCH_RESULT_COUNT = 10;
@@ -14,7 +14,7 @@ const EXA_SEARCH_CATEGORIES = [
14
14
  ];
15
15
  const MAX_EXA_DOMAIN_FILTERS = 20;
16
16
  const MAX_EXA_ADDITIONAL_QUERIES = 10;
17
- function createExaFilterProperties(Type, StringEnum) {
17
+ function createExaFilterProperties() {
18
18
  return {
19
19
  includeDomains: Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
20
20
  minItems: 1,
@@ -39,7 +39,7 @@ function createExaFilterProperties(Type, StringEnum) {
39
39
  })),
40
40
  };
41
41
  }
42
- export function createAgentBrowserWebSearchParamsSchema(Type = JsonSchema, StringEnum = localStringEnum) {
42
+ export function createAgentBrowserWebSearchParamsSchema() {
43
43
  return Type.Object({
44
44
  query: Type.String({
45
45
  minLength: 1,
@@ -51,7 +51,7 @@ export function createAgentBrowserWebSearchParamsSchema(Type = JsonSchema, Strin
51
51
  searchType: Type.Optional(StringEnum(EXA_SEARCH_TYPES, {
52
52
  description: "Exa mode; omitted uses webSearch.defaultSearchType, then auto. instant (~250ms) is only for trivial lookups; fast (~450ms) favors latency; auto (~1s) is balanced. Pass searchType: deep-lite (~4s) for research before implementation unless config already defaults it; do not assume auto is deep enough. deep (4–15s) handles hard multi-source research; deep-reasoning (12–40s) is only for the hardest work. Brave ignores this field.",
53
53
  })),
54
- ...createExaFilterProperties(Type, StringEnum),
54
+ ...createExaFilterProperties(),
55
55
  count: Type.Optional(Type.Integer({
56
56
  minimum: 1,
57
57
  maximum: MAX_SEARCH_RESULT_COUNT,
@@ -1,6 +1,9 @@
1
1
  import { AGENT_BROWSER_NAMESPACE, AGENT_BROWSER_OUTPUT_SCHEMA, finalizeAgentBrowserNativeResult, } from "./native-output.js";
2
2
  import { redactSensitiveText } from "./runtime.js";
3
- import { resolvePreferredWebSearchCredential, } from "./config.js";
3
+ import { isRecord } from "./parsing.js";
4
+ import { isStringArray } from "./results/presentation/content.js";
5
+ import { redactPresentationData } from "./results/presentation/diagnostic-redaction.js";
6
+ import { isWebSearchProvider, resolvePreferredWebSearchCredential, } from "./config.js";
4
7
  import { AgentBrowserWebSearchParams, DEFAULT_SEARCH_RESULT_COUNT, MAX_SEARCH_RESULT_COUNT, } from "./web-search-schema.js";
5
8
  import { WebSearchRequestGate } from "./web-search-request.js";
6
9
  import { WebSearchLocalError, prepareWebSearchRequest } from "./web-search-providers.js";
@@ -8,7 +11,7 @@ import { dedupeSearchResults, formatSearchResults, } from "./web-search-results.
8
11
  export { EXA_SEARCH_TYPES } from "./config.js";
9
12
  export { AgentBrowserWebSearchParams, createAgentBrowserWebSearchParamsSchema, } from "./web-search-schema.js";
10
13
  export { WEB_SEARCH_MIN_REQUEST_INTERVAL_MS, WebSearchRequestGate } from "./web-search-request.js";
11
- export { EXA_SEARCH_SYSTEM_PROMPT, buildBraveSearchUrl, buildExaSearchRequestBody, fetchBraveSearchJson, fetchExaSearchJson, getWebSearchProviderAdapter, } from "./web-search-providers.js";
14
+ export { EXA_SEARCH_SYSTEM_PROMPT, buildBraveSearchUrl, buildExaSearchRequestBody, fetchBraveSearchJson, fetchExaSearchJson, } from "./web-search-providers.js";
12
15
  export { cleanSearchText, decodeHtmlEntities, normalizeBraveSearchResult, normalizeExaSearchResult, } from "./web-search-results.js";
13
16
  export const AGENT_BROWSER_WEB_SEARCH_TOOL_NAME = "agent_browser_web_search";
14
17
  function buildMissingCredentialError(provider) {
@@ -91,8 +94,16 @@ async function runWebSearch(state, params, gate, signal) {
91
94
  duplicatesRemoved: duplicatesRemoved > 0 ? duplicatesRemoved : undefined,
92
95
  };
93
96
  }
94
- async function presentSearchSuccess(details, params) {
95
- const duplicateNotice = details.duplicatesRemoved !== undefined
97
+ async function presentSearchSuccess(rawDetails, params) {
98
+ const details = redactPresentationData({ command: "web-search" }, rawDetails);
99
+ if (!isRecord(details) ||
100
+ !isWebSearchProvider(details.provider) ||
101
+ typeof details.returnedQuery !== "string" ||
102
+ !Array.isArray(details.results) ||
103
+ !details.results.every(isNormalizedSearchResult)) {
104
+ throw new Error("Web search presentation has invalid result structure.");
105
+ }
106
+ const duplicateNotice = typeof details.duplicatesRemoved === "number"
96
107
  ? `\n\nDuplicate URLs removed: ${details.duplicatesRemoved}.`
97
108
  : "";
98
109
  const result = await finalizeAgentBrowserNativeResult({
@@ -110,6 +121,13 @@ async function presentSearchSuccess(details, params) {
110
121
  ? { ...result, details }
111
122
  : { ...result, details: { ...details, artifactManifest: finalizedManifest } };
112
123
  }
124
+ function isNormalizedSearchResult(value) {
125
+ if (!isRecord(value) || typeof value.title !== "string" || typeof value.url !== "string") {
126
+ return false;
127
+ }
128
+ return (["description", "source", "age", "pageDate", "language"].every((field) => value[field] === undefined || typeof value[field] === "string") &&
129
+ (value.highlights === undefined || isStringArray(value.highlights)));
130
+ }
113
131
  function failureCategory(error, signal) {
114
132
  if (signal?.aborted === true) {
115
133
  return "aborted";
@@ -197,6 +197,7 @@ Practical policy:
197
197
  - record successful `connect`, `--cdp`, enabled `--auto-connect`, environment-configured CDP/auto-connect, and wrapper Electron attachment identities in branch-visible state. First-use and later content-bearing calls live-check `get url` because attached targets can drift outside Pi. Caller config, file access, launch arguments, and environment pass through unchanged; only wrapper-injected compatibility launch arguments are omitted on active attachments. A terminal successful close removes the marker; a close followed by a later step whose lifecycle reports a browser launch preserves it, while a non-launching diagnostic such as `stream status` leaves the close terminal
198
198
  - when a successful close targets the current extension-managed session, including an explicit `--session <current> close` or an `electron.cleanup` managed-session step, clear page/ref state, mark that session inactive, untrack cleanup ownership, and rotate the next default auto call to a fresh wrapper-generated session name rather than reusing the closed name
199
199
  - on non-quit shutdown such as `/reload`, close off-branch owned managed sessions and off-branch owned Electron launches before clearing process-local ownership, but preserve the current branch-visible active managed session and Electron launch plus that launch's isolated `userDataDir` so reload continuity still works from the active transcript branch. First reuse of a restored Electron attachment checks the recorded namespace/session, live PID/profile presence, and exact browser WebSocket identity against current CDP metadata, then uses upstream `get cdp-url` to verify that the named daemon still targets that browser or one of its current pages. The metadata probe cannot commit daemon provenance merely by spawning; only a matching result permits the existing policy to record it. Ordinary calls, status, and probe share this path under the existing lock. Generic restore-disabled sessions, mismatched connections, quit, and off-branch cleanup are unchanged
200
+ - require a nonempty, non-whitespace `Browser`/`browser` string in `/json/version` for Electron CDP readiness and debug-port liveness; other version metadata is optional. Before signaling a live restored Electron PID without a tracked child handle, require the process start identity recorded at launch to match the live process (the same `process-identity.ts` PID-reuse proof used for execution claims and temp-root ownership); a missing, unreadable, or mismatched identity retains the process/profile
200
201
  - expose still-owned off-branch Electron launch records to `electron.status { launchId }`, `electron.status { all: true }`, `electron.probe { launchId }`, and `electron.cleanup`, while leaving default `electron.probe` scoped to the current managed session
201
202
  - if an unnamed fresh launch replaces an active extension-managed session, best-effort close the old managed session after the switch succeeds; `managedSessionOutcome.replacedSessionClosed` records whether that cleanup succeeded, and a failed close keeps the older identity wrapper-owned across transcript resume for explicit follow-up or cleanup
202
203
  - expose `details.browserWindow` and one visible login handoff only when a successful first/fresh local wrapper-managed headed result, including `batch`, is not an attachment and has upstream `lifecycle.effectiveLaunch.browserLaunched: true` and a `created`/`replaced` managed-session outcome. Keep `visibility: "unverified"`: this is launch evidence, never a claim about the user's OS desktop
@@ -205,12 +206,12 @@ Practical policy:
205
206
  - once the wrapper observes tab-drift risk for a session (profile restore correction, overlapping stale opens, or restored session state), later active-tab commands verify the intended tab under the existing session queue before semantic/ref helpers and user commands. Targets retain native CDP `targetId` across transcript replay; exact identity distinguishes duplicate URL/title tabs, while older targets without an ID keep URL-based matching. A cold managed reopen replaces the old ID. Native selection runs only when the intended tab is not already active, because upstream selection clears refs and frame scope even on same-tab reselection. Missing targets, failed selection, and post-selection target mismatches fail before user commands. Caller argv/stdin and native `--pin-tab` / `--no-pin-tab` preferences remain unchanged. Local commands, live `get url`, explicit HTTP `read <url>` (including its flags), URL `a11y`/`vitals`/`web-vitals`, `diff url`, `window new`, URL-bearing recording commands, and explicit tab/navigation/`connect`/`state load` recovery do not require the old target; history back/forward/reload, `pushstate`, and page-content operations still do. The same classifier scans effective batch rows past non-page prefixes until a page dependency or explicit context change, without rewriting user rows or changing bail behavior. For `window new` and `diff url`, observe the resulting URL instead of retaining the old target or treating the requested second URL as redirect evidence. Fold only reached native batch rows when available, discard targets before failed navigation/context transitions and live-verify the final page, discard observations and refs from before window/diff transitions, and let later successful snapshots rebuild refs even when another batch row fails. Retain an observed blank destination after either command instead of recovering the old page; if no final target is observed, use the existing unknown-target state. Caller batch arguments, stdin and bail behavior stay unchanged. Successful batches containing navigation-observable actions use the same final live page/identity observation as direct actions, so same-address popup tabs replace the pre-batch target before later pinning. Terminal batch closes do not trigger a page probe, and pending WebMCP targets remain unknown. Routine same-session commands avoid `tab list` preflights
206
207
  - for sessions with observed tab-drift risk, after a successful command on a known tab target, the wrapper may best-effort restore that same target again if restored/background tabs steal focus after the command returns; routine same-session commands skip focus correction, while observed pages without a returned native ID use `tab list` to establish current identity rather than inheriting an earlier tab's ID
207
208
  - after successful tab creation, selection, or close, including dispatched native batch rows, read the now-active URL and fresh non-blank title—even when two tabs share a URL—before updating per-session page state; retain the deliberate blank target instead of treating it as accidental drift
208
- - keep a per-session `refSnapshot` aligned with the last successful `snapshot` (including refs merged from a successful `batch` by taking the last successful `snapshot` step in batch result order): restore its once-stored ancestral definition when reloading, resuming, or moving to a different Pi session-tree branch, retain full membership and accessible metadata from that capture for wrapper-side diagnostics, drop it on successful close commands (`close`, `quit`, or `exit`), replace it with a persisted `page-transition` invalidation after any upstream-executed `record start` attempt (direct or batch; older supported natives can swap the page before their already-active check, so failed starts count; the warning and persisted summary explicitly describe conservative ref invalidation, not an observed page change), a `record restart` with a URL operand, `window new`, `diff url`, or WebMCP `invoke` / `result` / `cancel` (these page-provided tools can mutate, rerender, or navigate; when a spawned `batch` yields no parseable result rows, for example after a wrapper timeout, planned transition steps still record the invalidation), or after a failed transition command (`eval`, `back`, `forward`, `reload`, `connect`, `state load`, `tab` selection), including reached batch transitions and failed batch `open` rows whose live URL re-verification probe observed the page (a failed transition can still have mutated or replaced the document before throwing, so keeping the verified URL must not keep the prior refs; transcript replay preserves the persisted invalidation summary), and refuse page-scoped `@e…` argv before spawn when the active tab URL no longer matches the snapshot URL, when a ref id was never in that snapshot, when the snapshot state is invalidated, or when a `batch` step would reuse `@e…` on a guarded getter or mutation step after an earlier invalidating step (including `record start`, URL-bearing `record restart`, and WebMCP `invoke` / `result` / `cancel`) without a later `snapshot` step in the same plan; batch steps come from the source upstream actually executes (raw batch argument strings exclusively when any exist — filtering only the exact `--bail` token like upstream — stdin only otherwise, via `getUpstreamEffectiveBatchSteps` in `extensions/agent-browser/lib/orchestration/batch-stdin.ts`); tab recovery (which leaves user argv/stdin and continue-on-error control flow unchanged), artifact/recording preflight, batch screenshot path preparation (parent directories are created for effective raw rows too, without rewriting raw strings), and stale-ref echo args use that same selection so pinning and preflights cannot act on upstream-ignored stdin, while the pre-spawn state-policy validator deliberately keeps scanning parseable stdin alongside argv as a fail-closed content superset and treats stdin parse failures as fatal only when upstream would actually read stdin (its raw-token filter also uses the exact `--bail` token only). Same-snapshot `fill @e…` rows are guarded but do not themselves set that invalidation latch, so ordinary form fills can precede a click/submit row in one batch—see [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md#details) for the agent-visible contract and failure text; typed per-session tab/ref/pinning state lives in `extensions/agent-browser/lib/session-page-state.ts` and is updated by `orchestration/extension-observation.ts` after each tool result
209
+ - keep a per-session `refSnapshot` aligned with the last successful `snapshot` (including refs merged from a successful `batch` by taking the last successful `snapshot` step in batch result order): restore its once-stored ancestral definition when reloading, resuming, or moving to a different Pi session-tree branch, retain full membership and accessible metadata from that capture for wrapper-side diagnostics, drop it on successful close commands (`close`, `quit`, or `exit`), replace it with a persisted `page-transition` invalidation after any upstream-executed `record start` attempt (direct or batch; older supported natives can swap the page before their already-active check, so failed starts count; the warning and persisted summary explicitly describe conservative ref invalidation, not an observed page change), a `record restart` with a URL operand, `window new`, `diff url`, or WebMCP `invoke` / `result` / `cancel` (these page-provided tools can mutate, rerender, or navigate; when a spawned `batch` yields no parseable result rows, for example after a wrapper timeout, planned transition steps still record the invalidation), or after a failed transition command (`eval`, `back`, `forward`, `reload`, `connect`, `state load`, `tab` selection), including reached batch transitions and failed batch `open` rows whose live URL re-verification probe observed the page (a failed transition can still have mutated or replaced the document before throwing, so keeping the verified URL must not keep the prior refs; transcript replay preserves the persisted invalidation summary), and refuse page-scoped `@e…` argv before spawn when the active tab URL no longer matches the snapshot URL, when a ref id was never in that snapshot, when the snapshot state is invalidated, or when a `batch` step would reuse `@e…` on a guarded getter or mutation step after an earlier invalidating step (including `record start`, URL-bearing `record restart`, and WebMCP `invoke` / `result` / `cancel`) without a later `snapshot` step in the same plan; the latch scan covers the whole plan, including rows after its first snapshot even when no earlier refs exist, so a non-form click between snapshot and ref getter still requires another snapshot. Batch steps come from the source upstream actually executes (raw batch argument strings exclusively when any exist — filtering only the exact `--bail` token like upstream — stdin only otherwise, via `getUpstreamEffectiveBatchSteps` in `extensions/agent-browser/lib/orchestration/batch-stdin.ts`); tab recovery (which leaves user argv/stdin and continue-on-error control flow unchanged), artifact/recording preflight, batch screenshot path preparation (parent directories are created for effective raw rows too, without rewriting raw strings), and stale-ref echo args use that same selection so pinning and preflights cannot act on upstream-ignored stdin, while the pre-spawn state-policy validator deliberately keeps scanning parseable stdin alongside argv as a fail-closed content superset and treats stdin parse failures as fatal only when upstream would actually read stdin (its raw-token filter also uses the exact `--bail` token only). Same-snapshot `fill @e…` rows are guarded but do not themselves set that invalidation latch, so ordinary form fills can precede a click/submit row in one batch—see [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md#details) for the agent-visible contract and failure text; typed per-session tab/ref/pinning state lives in `extensions/agent-browser/lib/session-page-state.ts` and is updated by `orchestration/extension-observation.ts` after each tool result
209
210
  - native 0.37 owns first-document setup inheritance for new tabs; the wrapper does not copy headers, user agents or other browser setup itself. The page-summary formatter displays native positive WebMCP availability while retaining raw details; it adds no discovery probe
210
211
  - when a direct or batched WebMCP call returns `status: "pending"`, or `result` / `cancel` fails while that target is unknown, keep its tab target unknown and discard same-call snapshot evidence instead of treating an immediate post-dispatch URL probe as stable; `webmcp result` / `cancel`, `get url`, and explicit navigation remain available while unknown; replace the generic blocked snapshot action with `verify-page-target-after-pending-webmcp` (`get url`), and let a completed `batch --bail` use that verification before `snapshot -i` to re-establish both target and refs
211
212
  - for top-level non-Electron direct `click` commands with an eligible target, install a bounded in-page target-specific event probe before upstream runs; if upstream reports success but no trusted pointer/mouse/click event reached the resolved target, fail the tool and report `details.clickDispatch` with explicit retry/inspect next actions (the wrapper does not replay clicks in-page). The probe covers `xpath=` targets and current `@e…` / `ref=` refs whose latest stored `refSnapshot.refs` role is `button`, `checkbox`, `menuitem`, `radio`, `switch`, or `tab`; it requires a unique role/name in the saved snapshot and the live candidates instead of taking a fresh pre-click snapshot that could recycle upstream refs. Ref and XPath candidates must additionally prove identity through a per-probe temporary DOM attribute read back by native `get attr <original-selector> <marker>`. Missing or mismatched identity, including XPath/frame scope mismatches, cleans up the probe and lets native click run without dispatch verification; candidate naming heuristics alone are never proof. Duplicate-name refs pass through without a probe: their old ordinal is not target identity. The probe is intentionally skipped when the effective native confirmation list guards `evaluate`, avoiding an optional-eval approval loop without bypassing native click policy. Dynamic action-policy files stay native-owned. It is also skipped for CSS selector clicks, unresolved `find … click` locators, and `batch`/`qa` click steps
212
213
  - skip unrelated tools and harmless Bash inspections before building prompt policy. Seed the latest raw user message on start/tree, update it on user `message_end`, and re-read its current content before parsing policy so native content replacement cannot leave stale intent. Capture that policy at queued admission without a whole-branch scan on every event. Derive narrow prompt guards only for concrete evidence invariants: explicitly requested screenshot/recording output paths block browser close until the artifact manifest verifies those paths, while bare inbound attachment paths remain inputs. The wrapper intentionally does not infer broad business/user intent from prompt text such as order/payment/post boundaries; agents must follow those instructions themselves. The artifact guard is bounded preflight policy (`details.promptGuard`, `failureCategory: "policy-blocked"`), not a reusable browser recipe layer
213
- - keep the existing page/selector scroll API; explicit CSS-container movement uses `Element.scrollBy` with `behavior: "instant"` before measuring offsets so smooth-scroll CSS cannot cause a premature no-movement failure
214
+ - keep the existing page/selector scroll API; shims intercept only complete recognized shapes (`scroll to end|top` with exactly three tokens, or CSS-container scroll with three/four), passing surplus operands through to native execution without dropping them. Explicit CSS-container movement uses `Element.scrollBy` with `behavior: "instant"` before measuring offsets so smooth-scroll CSS cannot cause a premature no-movement failure
214
215
  - reject direct and effective batch `scrollintoview text=...` / `scrollinto text=...` before dispatch because current upstream can falsely report success without movement, while leaving help forms untouched; return only native recovery (`find text ... hover` or fresh snapshot/ref), leaving CSS, XPath, and current-ref behavior upstream-owned
215
216
  - after successful `get text` on a qualifying non-ref CSS selector, optionally issue one read-only `eval --stdin` probe per selector when multiple DOM matches or a hidden first match with visible peers could misread tabbed or off-screen content; simple id selectors and sensitive-looking literals skip this probe. Merge `details.selectorTextVisibility` / `selectorTextVisibilityAll`, visible warning lines, and `inspect-visible-text-candidates*` next actions as documented in [`TOOL_CONTRACT.md`](TOOL_CONTRACT.md#details) and `RQ-0074` in [`SUPPORT_MATRIX.md`](SUPPORT_MATRIX.md)
216
217
  - caller-owned sessions honor native `AGENT_BROWSER_SOCKET_DIR` unless `PI_AGENT_BROWSER_SOCKET_DIR` overrides it, with the same socket integrity checks. For other local Unix launches, set a short private socket directory so extension-generated session names do not fail on the upstream Unix socket-path length limit; require the selected path to be absolute, owned by the current uid, mode `0700`, under checked ancestry, and free of symlink, foreign-owner, or special planted entries; reject pre-existing unsafe modes instead of repairing them, then recheck before spawn. The actual filesystem root `/` is supplied by the trusted operating environment: a directory without group/other write bits is accepted regardless of its reported owner, which can be unmapped in a Linux user namespace. Existing root-owned sticky-directory acceptance is unchanged. This boundary does not protect against whoever controls the root filesystem. Every non-root ancestor still needs trusted ownership and permissions, including the destination ancestry of root-owned aliases; a matching overflow UID is not trusted. Android/Termux uses `/data/data/<package>/piab`, treats the owner-only app-data directory as the trust anchor, permits the app's matching private uid/gid ancestry, compacts generated managed identities to one 80-bit digest so ordinary namespace plus fresh-session paths remain within the limit, and places policy-lock coordination under `os.tmpdir()` because Android `/tmp` is shell-owned and inaccessible