@underactive/pi-topping-web-tools 0.2.1 → 0.2.3

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.2.3] - 2026-09-20
6
+
7
+ ### Changed
8
+
9
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 0.82.1 to 0.86.0 so `npm run typecheck` matches the 0.86.0 runtime API.
10
+
11
+ ### Fixed
12
+
13
+ - `web_browser`, `pdf_extract`: tool result `details` no longer carry undefined-valued properties, keeping them JSON-compatible as pi 0.86.0's `ToolResultMessage.details` requires. `web_browser` omits `statusCode` for same-document navigations; `pdf_extract` omits `firstPage`/`lastPage` for a zero-page PDF and no longer prints "pages undefined-undefined" in its no-text-layer message.
14
+ - `pdf_extract`: strip `</untrusted-content` fence breaks case-insensitively so mixed-case closing tags cannot bypass sanitization.
15
+
16
+ ## [0.2.2] - 2026-09-02
17
+
18
+ ### Added
19
+
20
+ - `web_browser`: fail-closed browser-context egress enforcement for HTTP(S) requests and WebSocket connections, with live preapproved/session/durable permission checks and blocked-request reporting in network logs. Service workers are disabled so they cannot bypass routing.
21
+
22
+ ### Changed
23
+
24
+ - `web_browser`: inspects redirect targets before contact, follows approved top-level redirects one gated hop at a time, blocks redirected subresources, closes unapproved popups before activation, and restores the prior page after blocked client-side navigation.
25
+ - `web_browser`: retains an Allow once navigation decision as a browser-session grant so the approved document and its later same-origin requests can pass request-level enforcement.
26
+
27
+ ### Fixed
28
+
29
+ - `web_browser`: treat an empty optional selector as the default `body` selector for `get_text` and `get_accessibility_snapshot`, avoiding invalid empty CSS-selector errors.
30
+ - `web_browser`: treats bare and `www.` variants of a remote hostname as equivalent for session and durable egress grants, allowing redirects such as `apple.com` → `www.apple.com` without a second approval while keeping other subdomains gated.
31
+
3
32
  ## [0.2.1] - 2026-08-31
4
33
 
5
34
  ### Added
package/README.md CHANGED
@@ -22,7 +22,7 @@ Web work has two distinct needs: clean context from public sources and browser-l
22
22
  - **Reach PDF-only sources.** `pdf_extract` reads the text layer of specifications, datasheets, and papers that markdown fetchers treat as undisplayable binary content, and that pi's `read` tool cannot parse.
23
23
  - **Validate the rendered product.** `web_browser` runs a fresh headless Chromium context for client-rendered pages, local development servers, and user journeys that static HTML cannot represent. It can inspect the accessibility tree, interact with controls, and surface console and failed-network diagnostics.
24
24
  - **Use the appropriate tool, not the heavier tool by default.** Fetch static public material first; move to browser automation when JavaScript, visual state, or user input is part of the question.
25
- - **Keep control over access.** The tools share a host allowlist and confirmation workflow; fetched page text is explicitly marked as untrusted, and browser sessions do not reuse the system browser profile.
25
+ - **Keep control over access.** The tools share a host allowlist and confirmation workflow; `web_browser` also enforces approved URLs on HTTP(S) requests and WebSocket connections inside its isolated browser context. Fetched page text is explicitly marked as untrusted, and browser sessions do not reuse the system browser profile.
26
26
 
27
27
  ## Examples
28
28
 
@@ -114,7 +114,7 @@ Single tool with an `action` parameter:
114
114
  | `wait_for` | `selector?`, `state?`, `networkidle?` | Wait for selector state and/or network idle |
115
115
  | `get_accessibility_snapshot` | `selector?` | Return ARIA accessibility tree (YAML) |
116
116
  | `get_console_logs` | — | Drain captured console/page errors |
117
- | `get_network_logs` | — | Drain captured failed requests and 4xx/5xx responses |
117
+ | `get_network_logs` | — | Drain captured egress blocks, failed requests, and 4xx/5xx responses |
118
118
  | `get_cookies` | — | Return cookie metadata as JSON (name, domain, path, flags; values are redacted) |
119
119
  | `set_cookies` | `cookies` | Set cookies (each needs `url` or `domain`+`path`) |
120
120
  | `go_back` / `go_forward` / `reload` | — | Browser history navigation / reload |
@@ -129,9 +129,24 @@ Single tool with an `action` parameter:
129
129
 
130
130
  Optional `timeout` (ms, default 30000) applies to navigation and to selector-based actions (click, type, hover, select_option, screenshot, get_text, get_markdown, wait_for, get_accessibility_snapshot, scroll, drag, upload_file). Other actions use Playwright defaults.
131
131
 
132
+ ### Browser network egress
133
+
134
+ `web_browser` enforces its permissions inside every fresh browser context:
135
+
136
+ - Every HTTP(S) document, script, image, XHR/fetch, beacon, and other routed request is checked against the live preapproved, session, and durable permissions before the request is sent. Path-scoped preapprovals remain path-scoped; session and durable grants remain exact-origin grants except that bare and `www.` host variants are treated as equivalent.
137
+ - Redirect responses are inspected before Chromium receives them. Approved top-level redirects, including simple bare-domain ↔ `www.` redirects, are followed one gated hop at a time. During `navigate`, a redirect to an unapproved URL returns `Blocked: redirect to <origin> requires approval`; redirects triggered by later page interactions are blocked and reported in network logs. Redirected subresources are blocked rather than followed and also appear in network logs.
138
+ - WebSocket handshakes use the same live permission check. Service workers are disabled so they cannot bypass request routing.
139
+ - Client-side navigation and popup destinations are gated. A blocked popup is closed without becoming active, and a blocked same-tab navigation returns to the prior approved page.
140
+ - `data:` and `blob:` subresources are allowed because they do not create network egress, while top-level `data:`/`blob:` documents are denied. `file:` documents require approval for the target path; local-file subresources are allowed only from an approved local-file page.
141
+ - Decision errors fail closed. Route handlers never prompt; call `navigate` on a blocked target to use the normal approval prompt. Blocks are reported by `get_network_logs` as `blocked by egress policy`.
142
+
143
+ A navigation approved with **Allow once** is retained as a browser-session origin grant so the document and its later same-origin requests can continue to load under request-level enforcement. Choosing a durable duration still controls whether that origin is available in later sessions.
144
+
145
+ Strict egress can make an approved page incomplete when it depends on an unapproved CDN, API, font, or analytics origin. Navigate directly to that origin to approve it, then return to the page and reload.
146
+
132
147
  Selectors are CSS selectors, or Playwright's `role=` and `text=` selector engines (e.g. `role=button[name='Submit']`, `text=Sign in`). Pass `frame` (a CSS selector for the containing `<iframe>`) to scope click/type/hover/select_option/screenshot/get_text/wait_for/get_accessibility_snapshot/scroll/upload_file/drag into that frame.
133
148
 
134
- Opening a link with `target=_blank` or `window.open` creates a popup that automatically becomes the active tab — subsequent actions target it until you `switch_tab` back. Use `list_tabs` to see all open tabs.
149
+ Opening a link with `target=_blank` or `window.open` opens a new tab whose destination is checked against live permissions. An approved popup becomes the active tab; a popup to an unapproved URL is closed without becoming active and the current tab stays active. Use `list_tabs` to see all open tabs.
135
150
 
136
151
  JS dialogs (`alert`/`confirm`/`prompt`) are dismissed by default so they never block automation; call `set_dialog_behavior` before the action that triggers one if you need `accept` instead (and, for `prompt`, a `promptText` value).
137
152
 
@@ -169,7 +184,7 @@ Entries are published only when the state changes, except that the current state
169
184
  When the host confirmation prompt offers a durable option ("Allow for 1 day", "Allow for 1 week", "Allow for 30 days"), the grant is persisted to `~/.pi/agent/web-permissions.json` (mode `0600`) and applies across all sessions and projects.
170
185
 
171
186
  - **Per-tool scope.** A `fetch_markdown` grant does not authorize `web_browser`, which executes JavaScript and drives interaction. Each tool scopes its grants independently.
172
- - **Exact origin.** Grants match `scheme://host[:port]` exactly — no subdomain wildcards, no cross-port or cross-scheme reuse.
187
+ - **Exact origin.** Grants are stored as `scheme://host[:port]` and do not cross ports or schemes. For `web_browser` egress, only the bare and `www.` variants of the same remote hostname are equivalent; no other subdomains inherit the grant.
173
188
  - **Max 30 days.** All grants expire; there is no indefinite option. Expired entries are pruned automatically.
174
189
  - **Local files excluded.** `file://` prompts never offer durable options; local-file access needs a path-scope design that is out of scope here.
175
190
  - **Headless behavior.** Active durable grants apply when there is no interactive UI (e.g. RPC or print mode). Without a grant, non-preapproved hosts remain blocked.
@@ -181,10 +196,10 @@ Commands:
181
196
  ## Security model (shared)
182
197
 
183
198
  - **Preapproved hosts**: common documentation and dev sites are allowed without prompting. The allowlist lives in `src/permissions.ts` and is shared by both tools.
184
- - **User confirmation**: other hosts prompt Allow once / Allow for this session / Allow for 1 day / Allow for 1 week / Allow for 30 days / Deny.
199
+ - **User confirmation**: other hosts prompt Allow once / Allow for this session / Allow for 1 day / Allow for 1 week / Allow for 30 days / Deny. For `web_browser` navigation, Allow once becomes a browser-session grant because request-level enforcement must authorize the loaded document's later requests.
185
200
  - **URL validation**: all tools reject embedded credentials and overlong URLs. `fetch_markdown` and `pdf_extract` additionally reject loopback, private, and link-local hosts and any non-HTTP(S) scheme on a `url`. `web_browser` accepts `localhost`, private IPs, and `file://` paths so it can drive local dev servers and local HTML — each still prompts for confirmation unless session-approved or covered by a saved durable grant.
186
201
  - **Local file resolution**: `pdf_extract` resolves a `path` through `realpath` before any check, so a symlink pointing outside the working directory is treated as outside and prompts. Directories, FIFOs, and devices are rejected, and the size limit is enforced from `stat` before any bytes are read. `web_browser` `upload_file` follows the same rule: files outside the working directory prompt for confirmation (session-scoped) before they are read.
187
- - **Isolated browser context**: each session uses a fresh Chromium context (no shared profile/cookies with your system browser).
202
+ - **Isolated browser context and routed egress**: each session uses a fresh Chromium context (no shared profile/cookies with your system browser). HTTP(S) requests, redirect targets, client navigations, popups, and WebSocket connections are checked against live `web_browser` permissions; service workers are disabled.
188
203
  - **Untrusted content boundary**: `fetch_markdown` results are wrapped in `<untrusted-content url="…">` tags, and `pdf_extract` results in `<untrusted-content source="…">`, so the model treats fetched page and document text as data, not instructions.
189
204
  - **PDF resource limits**: extraction is bounded by download size, page count, and a timeout. Because the bundled PDF.js runs on the event loop rather than a worker, the timeout bounds extraction across its await points but cannot interrupt a fully synchronous parse — the size and page caps are the primary defence.
190
205
 
@@ -192,12 +207,14 @@ Commands:
192
207
 
193
208
  - `web_browser` requires the Chromium download.
194
209
  - Screenshots consume significant context on vision models — prefer `get_text`, `get_markdown`, or `get_accessibility_snapshot`.
195
- - Network logs capture only failed requests and 4xx/5xx responses (not all traffic).
210
+ - Network logs capture blocked/failed requests and 4xx/5xx responses, not successful traffic.
211
+ - Request routing buffers HTTP(S) responses and blocks redirected subresources, so streaming endpoints and resources that rely on redirects may not behave like an unrestricted browser.
212
+ - Playwright routing covers HTTP(S) and WebSockets, not browser transports such as WebRTC; OS-level egress sandboxing remains outside this extension's scope.
196
213
  - `pdf_extract` reads only an existing text layer; scanned documents need OCR, which is out of scope.
197
214
 
198
215
  ### Popups and navigation
199
216
 
200
- Popups inherit the same security model as same-tab link clicks: opening a `target=_blank` link or calling `window.open` is not gated by the host allowlist, since it's JS-initiated navigation rather than a tool-driven `navigate` call. `evaluate` remains gated per active page URL — the permission check follows the active-tab cursor automatically, so switching to a popup and calling `evaluate` re-checks permission for that popup's URL.
217
+ Same-tab client navigation and popups are checked against the live `web_browser` permissions. Unapproved document requests are aborted before reaching the target; blocked popups are closed without becoming active, while a blocked same-tab navigation returns to the prior approved page. Approved popups become active only after their main-frame navigation commits.
201
218
 
202
219
  ## Development
203
220
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-web-tools",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pi extensions for the web: fetch_markdown (URL to markdown, cached, keyless), pdf_extract (PDF text extraction) and web_browser (headless Playwright automation), sharing a preapproved-host allowlist.",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
@@ -53,9 +53,9 @@
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-ai": "0.82.1",
57
- "@earendil-works/pi-coding-agent": "0.82.1",
58
- "@earendil-works/pi-tui": "0.82.1",
56
+ "@earendil-works/pi-ai": "0.86.0",
57
+ "@earendil-works/pi-coding-agent": "0.86.0",
58
+ "@earendil-works/pi-tui": "0.86.0",
59
59
  "@types/node": "^24.0.0",
60
60
  "@types/turndown": "^5.0.5",
61
61
  "typebox": "^1.3.3",
@@ -48,10 +48,6 @@ export function clearPdfExtractCache(): void {
48
48
 
49
49
  const _activeFetches = new Set<AbortController>();
50
50
 
51
- export function _activeFetchCountForTests(): number {
52
- return _activeFetches.size;
53
- }
54
-
55
51
  export function _cleanup(): void {
56
52
  PDF_CACHE.clear();
57
53
  for (const controller of _activeFetches) {
@@ -342,12 +338,14 @@ Usage notes:
342
338
  .join("\n\n");
343
339
 
344
340
  const hasText = trimmed.some((text) => text.length > 0);
341
+ const range =
342
+ selected.length > 0 ? { firstPage: selected[0], lastPage: selected[selected.length - 1] } : undefined;
345
343
  const baseDetails = {
346
344
  source,
347
345
  totalPages: loaded.totalPages,
348
346
  pageCount: selected.length,
349
- firstPage: selected[0],
350
- lastPage: selected[selected.length - 1],
347
+ // Empty only for a zero-page PDF; omit the keys rather than storing undefined.
348
+ ...(range ?? {}),
351
349
  bytes: loaded.bytes,
352
350
  durationMs: Date.now() - start,
353
351
  cached: loaded.cached,
@@ -358,7 +356,9 @@ Usage notes:
358
356
  content: [
359
357
  {
360
358
  type: "text" as const,
361
- text: `No extractable text layer found in ${source} (pages ${baseDetails.firstPage}-${baseDetails.lastPage} of ${loaded.totalPages}). The PDF is likely scanned or image-only. This tool does not perform OCR.`,
359
+ text: `No extractable text layer found in ${source}${
360
+ range ? ` (pages ${range.firstPage}-${range.lastPage} of ${loaded.totalPages})` : ` (0 of ${loaded.totalPages} page(s))`
361
+ }. The PDF is likely scanned or image-only. This tool does not perform OCR.`,
362
362
  },
363
363
  ],
364
364
  details: baseDetails satisfies PdfExtractDetails,
@@ -367,7 +367,7 @@ Usage notes:
367
367
 
368
368
  const offset = Math.max(0, Math.floor(params.offset ?? 0));
369
369
  const slice = sliceContent(body, offset);
370
- const sanitizedText = slice.text.replaceAll("</untrusted-content", "");
370
+ const sanitizedText = slice.text.replaceAll(/<\/untrusted-content/gi, "");
371
371
  const safeSource = source.replaceAll('"', "%22");
372
372
 
373
373
  return {
@@ -99,10 +99,31 @@ export function listGrants(): Grant[] {
99
99
  return valid;
100
100
  }
101
101
 
102
+ let grantCache: Map<string, number> | undefined;
103
+ let grantCacheStorePath: string | undefined;
104
+
105
+ function grantCacheKey(scope: PermissionScope, origin: string): string {
106
+ return `${scope}\0${origin}`;
107
+ }
108
+
109
+ function setGrantCache(grants: Grant[]): void {
110
+ grantCacheStorePath = storePath();
111
+ grantCache = new Map(grants.map((g) => [grantCacheKey(g.scope, g.origin), g.expiresAt]));
112
+ }
113
+
114
+ function ensureGrantCache(): Map<string, number> {
115
+ const currentPath = storePath();
116
+ if (!grantCache || grantCacheStorePath !== currentPath) {
117
+ setGrantCache(listGrants());
118
+ }
119
+ return grantCache!;
120
+ }
121
+
102
122
  /** Check whether a specific scope+origin has an active durable grant. */
103
123
  export function isGranted(scope: PermissionScope, origin: string): boolean {
104
124
  const key = normalizeOrigin(origin);
105
- return listGrants().some((g) => g.scope === scope && g.origin === key);
125
+ const expiresAt = ensureGrantCache().get(grantCacheKey(scope, key));
126
+ return expiresAt !== undefined && expiresAt > Date.now();
106
127
  }
107
128
 
108
129
  /** Prune expired entries from a grant list. */
@@ -180,6 +201,7 @@ export async function addGrant(scope: PermissionScope, origin: string, ttlMs: nu
180
201
  // Cap at MAX_GRANTS.
181
202
  const capped = filtered.length > MAX_GRANTS ? filtered.slice(-MAX_GRANTS) : filtered;
182
203
  await writeStore(capped);
204
+ setGrantCache(capped);
183
205
  return true;
184
206
  } finally {
185
207
  await releaseLock();
@@ -196,6 +218,7 @@ export async function removeGrant(scope: PermissionScope, origin: string): Promi
196
218
  const grants = pruneExpired(listGrants());
197
219
  const filtered = grants.filter((g) => !(g.scope === scope && g.origin === normalized));
198
220
  await writeStore(filtered);
221
+ setGrantCache(filtered);
199
222
  return true;
200
223
  } finally {
201
224
  await releaseLock();
@@ -3,19 +3,33 @@
3
3
  *
4
4
  * Lazily launches a single headless Chromium instance. `context.pages()` is
5
5
  * the source of truth for open tabs; `this.page` is only an "active tab"
6
- * cursor that new pages (including popups) claim automatically. Page
7
- * interactions are serialized via an async mutex.
6
+ * cursor. Popups claim it only after an approved main-frame navigation.
7
+ * Page interactions are serialized via an async mutex.
8
8
  */
9
9
 
10
- import type { Browser, BrowserContext, Cookie, Locator, Page } from "playwright";
10
+ import type {
11
+ APIResponse,
12
+ Browser,
13
+ BrowserContext,
14
+ Cookie,
15
+ Locator,
16
+ Page,
17
+ Response,
18
+ Route,
19
+ WebSocketRoute,
20
+ } from "playwright";
11
21
  import { chromium } from "playwright";
12
22
  import { getTurndownService } from "../fetch-markdown.ts";
23
+ import { permissionKey } from "../permissions.ts";
24
+ import { decideRequest, type UrlPermissionChecker } from "./egress-policy.ts";
13
25
 
14
26
  export const DEFAULT_VIEWPORT_WIDTH = 1280;
15
27
  export const DEFAULT_VIEWPORT_HEIGHT = 720;
16
28
  export const MAX_FULL_PAGE_WIDTH = 1920;
17
29
  export const MAX_BUFFER_ENTRIES = 500;
18
30
  export const DEFAULT_NAVIGATION_TIMEOUT_MS = 30_000;
31
+ const MAX_REDIRECT_HOPS = 20;
32
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
19
33
 
20
34
  type AbortableOpts = { timeout?: number; signal?: AbortSignal };
21
35
  type FrameOpts = { frame?: string };
@@ -133,6 +147,11 @@ export class BrowserManager {
133
147
  private networkBuffer: NetworkEntry[] = [];
134
148
  private dialogBuffer: DialogEntry[] = [];
135
149
  private dialogBehavior: DialogBehavior = { action: "dismiss" };
150
+ private originChecker: UrlPermissionChecker = () => false;
151
+ private lastBlockedDocumentUrl: string | undefined;
152
+ private navigationPage: Page | undefined;
153
+ private navigationSignal: AbortSignal | undefined;
154
+ private readonly pendingRedirects = new WeakMap<Page, string>();
136
155
  private readonly mutex = new AsyncMutex();
137
156
  private closed = false; // true only after explicit close(), not on initial/normal state
138
157
 
@@ -185,19 +204,56 @@ export class BrowserManager {
185
204
  await this.ensureLaunched();
186
205
  const page = this.page!;
187
206
  const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
207
+ this.lastBlockedDocumentUrl = undefined;
208
+ this.navigationPage = page;
209
+ this.navigationSignal = opts?.signal;
188
210
 
189
- const response = await this.withAbort(opts?.signal, async () =>
190
- page.goto(url, {
191
- timeout,
192
- waitUntil: "domcontentloaded",
193
- }),
194
- );
211
+ let currentUrl = url;
212
+ try {
213
+ for (let redirects = 0; redirects <= MAX_REDIRECT_HOPS; redirects++) {
214
+ this.pendingRedirects.delete(page);
215
+ let response: Response | null;
216
+ try {
217
+ response = await this.withAbort(opts?.signal, async () =>
218
+ page.goto(currentUrl, {
219
+ timeout,
220
+ waitUntil: "domcontentloaded",
221
+ }),
222
+ );
223
+ } catch (err) {
224
+ if (opts?.signal?.aborted) throw err;
225
+ const redirectTarget = this.pendingRedirects.get(page);
226
+ if (redirectTarget) {
227
+ currentUrl = redirectTarget;
228
+ continue;
229
+ }
230
+ const blockedError = this.blockedNavigationError(url);
231
+ if (blockedError) throw blockedError;
232
+ throw err;
233
+ }
195
234
 
196
- return {
197
- url: page.url(),
198
- title: await page.title(),
199
- statusCode: response?.status(),
200
- };
235
+ const redirectTarget = this.pendingRedirects.get(page);
236
+ if (redirectTarget) {
237
+ currentUrl = redirectTarget;
238
+ continue;
239
+ }
240
+
241
+ const blockedError = this.blockedNavigationError(url);
242
+ if (blockedError) throw blockedError;
243
+ return {
244
+ url: page.url(),
245
+ title: await page.title(),
246
+ statusCode: response?.status(),
247
+ };
248
+ }
249
+ throw new Error(`Blocked: more than ${MAX_REDIRECT_HOPS} redirects`);
250
+ } finally {
251
+ if (this.navigationPage === page) {
252
+ this.navigationPage = undefined;
253
+ this.navigationSignal = undefined;
254
+ }
255
+ this.pendingRedirects.delete(page);
256
+ }
201
257
  });
202
258
  }
203
259
 
@@ -389,7 +445,7 @@ export class BrowserManager {
389
445
  await this.ensureLaunched();
390
446
  const page = this.page!;
391
447
  const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
392
- const locator = this.resolveLocator(page, selector ?? "body", opts?.frame).first();
448
+ const locator = this.resolveLocator(page, selector || "body", opts?.frame).first();
393
449
  return this.withAbort(opts?.signal, async () => locator.innerText({ timeout }));
394
450
  });
395
451
  }
@@ -415,7 +471,7 @@ export class BrowserManager {
415
471
  await this.ensureLaunched();
416
472
  const page = this.page!;
417
473
  const timeout = opts?.timeout ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
418
- const selector = opts?.selector ?? "body";
474
+ const selector = opts?.selector || "body";
419
475
  const locator = this.resolveLocator(page, selector, opts?.frame);
420
476
  try {
421
477
  const snapshot = await this.withAbort(opts?.signal, async () =>
@@ -593,6 +649,10 @@ export class BrowserManager {
593
649
  });
594
650
  }
595
651
 
652
+ setOriginChecker(checker: UrlPermissionChecker): void {
653
+ this.originChecker = checker;
654
+ }
655
+
596
656
  setDialogBehavior(action: "accept" | "dismiss", promptText?: string): void {
597
657
  this.dialogBehavior = { action, promptText };
598
658
  }
@@ -685,15 +745,8 @@ export class BrowserManager {
685
745
  * (default 5000). Set to 0 for unbounded.
686
746
  */
687
747
  async close(timeoutMs = 5_000): Promise<void> {
688
- // Mark closed immediately so ensureLaunched() bails out.
689
748
  this.closed = true;
690
749
 
691
- // Drain the mutex chain (wait for any in-flight operation to finish),
692
- // then call cleanup directly — bypassing the mutex so no new
693
- // operation can re-trigger ensureLaunched() mid-shutdown.
694
- await this.mutex.drain();
695
-
696
- // Force-close with optional timeout.
697
750
  await this.forceClose(timeoutMs);
698
751
  }
699
752
 
@@ -705,17 +758,23 @@ export class BrowserManager {
705
758
  * than blocking indefinitely.
706
759
  */
707
760
  private async forceClose(timeoutMs: number): Promise<void> {
761
+ if (timeoutMs <= 0) {
762
+ try {
763
+ await this.cleanup();
764
+ } catch {
765
+ // Cleanup error — best effort; resources are cleaned up by OS.
766
+ }
767
+ return;
768
+ }
769
+
708
770
  let timerId: ReturnType<typeof setTimeout> | undefined;
709
- const timer =
710
- timeoutMs > 0
711
- ? new Promise<never>((_, reject) => {
712
- timerId = setTimeout(
713
- () => reject(new Error("Close timed out")),
714
- timeoutMs,
715
- );
716
- if (typeof timerId.unref === "function") timerId.unref();
717
- })
718
- : Promise.resolve();
771
+ const timer = new Promise<never>((_, reject) => {
772
+ timerId = setTimeout(
773
+ () => reject(new Error("Close timed out")),
774
+ timeoutMs,
775
+ );
776
+ if (typeof timerId.unref === "function") timerId.unref();
777
+ });
719
778
 
720
779
  try {
721
780
  await Promise.race([this.cleanup(), timer]);
@@ -841,7 +900,12 @@ export class BrowserManager {
841
900
  height: DEFAULT_VIEWPORT_HEIGHT,
842
901
  },
843
902
  acceptDownloads: false,
903
+ serviceWorkers: "block",
844
904
  });
905
+ await this.context.route("**/*", (route) => this.enforceEgress(route));
906
+ await this.context.routeWebSocket("**/*", (webSocket) =>
907
+ this.enforceWebSocketEgress(webSocket),
908
+ );
845
909
  this.context.on("page", (page) => this.registerPage(page));
846
910
  this.page = await this.context.newPage();
847
911
  this.closed = false;
@@ -858,16 +922,291 @@ export class BrowserManager {
858
922
  }
859
923
 
860
924
  /**
861
- * Attaches console/network/dialog listeners to every page in the context
862
- * (including the initial page and popups) and claims it as the active
863
- * tab. This is the single attachment point — `context.on("page")` also
864
- * fires for `context.newPage()`, so listeners must not be attached
865
- * anywhere else or they would be registered twice.
925
+ * Attaches listeners to every page in the context. The initial page is
926
+ * active immediately; popups become active only after an approved main-
927
+ * frame navigation commits.
866
928
  */
867
929
  private registerPage(page: Page): void {
868
930
  this.attachConsoleListeners(page);
869
931
  this.attachDialogListener(page);
870
- this.page = page;
932
+
933
+ let pendingActivation = this.page !== undefined && !this.page.isClosed();
934
+ if (!pendingActivation) {
935
+ this.page = page;
936
+ }
937
+
938
+ page.on("framenavigated", (frame) => {
939
+ if (frame !== page.mainFrame()) return;
940
+ this.handleMainFrameNavigation(page, frame.url(), pendingActivation)
941
+ .then((activate) => {
942
+ if (activate && pendingActivation && !page.isClosed()) {
943
+ this.page = page;
944
+ pendingActivation = false;
945
+ }
946
+ })
947
+ .catch(() => {
948
+ this.closeBlockedPage(page).catch(() => {});
949
+ });
950
+ });
951
+
952
+ // The context-level page event can arrive after the first navigation has
953
+ // committed, in which case no later framenavigated event will activate it.
954
+ const currentUrl = page.url();
955
+ if (
956
+ pendingActivation &&
957
+ currentUrl !== "about:blank" &&
958
+ decideRequest(currentUrl, "document", currentUrl, this.originChecker).allow
959
+ ) {
960
+ this.page = page;
961
+ pendingActivation = false;
962
+ }
963
+ }
964
+
965
+ private async enforceEgress(route: Route): Promise<void> {
966
+ const request = route.request();
967
+ let requestPage: Page | undefined;
968
+ let pageUrl = "";
969
+ let isMainFrameRequest = false;
970
+ try {
971
+ const frame = request.frame();
972
+ requestPage = frame.page();
973
+ pageUrl = requestPage.url();
974
+ isMainFrameRequest = frame === requestPage.mainFrame();
975
+ } catch {
976
+ // Requests without an issuing page are evaluated with an empty page URL.
977
+ }
978
+
979
+ const decision = decideRequest(
980
+ request.url(),
981
+ request.resourceType(),
982
+ pageUrl,
983
+ this.originChecker,
984
+ );
985
+ if (decision.allow) {
986
+ const protocol = new URL(request.url()).protocol;
987
+ if (protocol !== "http:" && protocol !== "https:") {
988
+ await route.continue();
989
+ return;
990
+ }
991
+
992
+ // route.continue() follows redirect hops without routing them again.
993
+ // Fetch one response without redirects so Location can be approved
994
+ // before Chromium receives a response that would contact the target.
995
+ let response: APIResponse;
996
+ try {
997
+ response = await route.fetch({
998
+ maxRedirects: 0,
999
+ signal: requestPage === this.navigationPage ? this.navigationSignal : undefined,
1000
+ });
1001
+ } catch {
1002
+ try {
1003
+ await route.abort(this.navigationSignal?.aborted ? "aborted" : "failed");
1004
+ } catch {
1005
+ // The request may already be gone after a page or context closes.
1006
+ }
1007
+ return;
1008
+ }
1009
+ const location = REDIRECT_STATUS_CODES.has(response.status())
1010
+ ? response.headers().location
1011
+ : undefined;
1012
+ if (!location) {
1013
+ try {
1014
+ await route.fulfill({ response });
1015
+ } finally {
1016
+ await response.dispose();
1017
+ }
1018
+ return;
1019
+ }
1020
+
1021
+ let redirectUrl: string;
1022
+ try {
1023
+ redirectUrl = new URL(location, request.url()).toString();
1024
+ } catch {
1025
+ await response.dispose();
1026
+ this.pushNetwork({
1027
+ type: "failed",
1028
+ url: request.url(),
1029
+ method: request.method(),
1030
+ status: 0,
1031
+ statusText: "",
1032
+ failure: "blocked by egress policy: Invalid redirect URL",
1033
+ timestamp: Date.now(),
1034
+ });
1035
+ await route.abort("blockedbyclient");
1036
+ return;
1037
+ }
1038
+
1039
+ const redirectDecision = decideRequest(
1040
+ redirectUrl,
1041
+ request.resourceType(),
1042
+ pageUrl,
1043
+ this.originChecker,
1044
+ );
1045
+ await response.dispose();
1046
+
1047
+ if (!redirectDecision.allow) {
1048
+ if (isMainFrameRequest && requestPage === this.navigationPage) {
1049
+ this.lastBlockedDocumentUrl = redirectUrl;
1050
+ }
1051
+ this.pushNetwork({
1052
+ type: "failed",
1053
+ url: redirectUrl,
1054
+ method: request.method(),
1055
+ status: 0,
1056
+ statusText: "",
1057
+ failure: `blocked by egress policy: ${redirectDecision.reason}`,
1058
+ timestamp: Date.now(),
1059
+ });
1060
+ await route.abort("blockedbyclient");
1061
+ if (isMainFrameRequest && requestPage && requestPage !== this.page) {
1062
+ await this.closeBlockedPage(requestPage);
1063
+ }
1064
+ return;
1065
+ }
1066
+
1067
+ if (request.resourceType() !== "document" || !isMainFrameRequest || !requestPage) {
1068
+ this.pushNetwork({
1069
+ type: "failed",
1070
+ url: redirectUrl,
1071
+ method: request.method(),
1072
+ status: 0,
1073
+ statusText: "",
1074
+ failure: "blocked by egress policy: Redirected subrequests are not followed",
1075
+ timestamp: Date.now(),
1076
+ });
1077
+ await route.abort("blockedbyclient");
1078
+ return;
1079
+ }
1080
+
1081
+ this.pendingRedirects.set(requestPage, redirectUrl);
1082
+ await route.fulfill({
1083
+ status: 200,
1084
+ contentType: "text/html",
1085
+ body: "<!doctype html><meta charset=\"utf-8\"><title>Redirecting</title>",
1086
+ });
1087
+ if (requestPage !== this.navigationPage) {
1088
+ setTimeout(() => {
1089
+ if (requestPage.isClosed()) return;
1090
+ this.pendingRedirects.delete(requestPage);
1091
+ requestPage.goto(redirectUrl, { waitUntil: "domcontentloaded" }).catch(() => {
1092
+ if (requestPage !== this.page) this.closeBlockedPage(requestPage).catch(() => {});
1093
+ });
1094
+ }, 0);
1095
+ }
1096
+ return;
1097
+ }
1098
+
1099
+ if (
1100
+ request.resourceType() === "document" &&
1101
+ isMainFrameRequest &&
1102
+ requestPage === this.page
1103
+ ) {
1104
+ this.lastBlockedDocumentUrl = request.url();
1105
+ }
1106
+ this.pushNetwork({
1107
+ type: "failed",
1108
+ url: request.url(),
1109
+ method: request.method(),
1110
+ status: 0,
1111
+ statusText: "",
1112
+ failure: `blocked by egress policy: ${decision.reason}`,
1113
+ timestamp: Date.now(),
1114
+ });
1115
+
1116
+ await route.abort("blockedbyclient");
1117
+ if (request.resourceType() === "document" && requestPage && requestPage !== this.page) {
1118
+ await this.closeBlockedPage(requestPage);
1119
+ }
1120
+ }
1121
+
1122
+ private blockedNavigationError(requestedUrl: string): Error | undefined {
1123
+ if (!this.lastBlockedDocumentUrl) return undefined;
1124
+ const blockedTarget = permissionKey(this.lastBlockedDocumentUrl);
1125
+ const navigationType = blockedTarget === permissionKey(requestedUrl) ? "navigation" : "redirect";
1126
+ return new Error(`Blocked: ${navigationType} to ${blockedTarget} requires approval`);
1127
+ }
1128
+
1129
+ private async enforceWebSocketEgress(webSocket: WebSocketRoute): Promise<void> {
1130
+ const url = webSocket.url();
1131
+ let permitted = false;
1132
+ try {
1133
+ permitted = this.originChecker(url);
1134
+ } catch {
1135
+ // Fail closed when the live permission source cannot be read.
1136
+ }
1137
+
1138
+ if (permitted) {
1139
+ webSocket.connectToServer();
1140
+ return;
1141
+ }
1142
+
1143
+ this.pushNetwork({
1144
+ type: "failed",
1145
+ url,
1146
+ method: "GET",
1147
+ status: 0,
1148
+ statusText: "",
1149
+ failure: "blocked by egress policy: WebSocket URL is not approved",
1150
+ timestamp: Date.now(),
1151
+ });
1152
+ await webSocket.close({ code: 1008, reason: "blocked by egress policy" });
1153
+ }
1154
+
1155
+ private async handleMainFrameNavigation(
1156
+ page: Page,
1157
+ url: string,
1158
+ pendingActivation: boolean,
1159
+ ): Promise<boolean> {
1160
+ if (pendingActivation && url === "about:blank") return false;
1161
+ if (
1162
+ url === "chrome-error://chromewebdata/" &&
1163
+ (page === this.navigationPage || this.pendingRedirects.has(page))
1164
+ ) {
1165
+ return false;
1166
+ }
1167
+
1168
+ const decision = decideRequest(url, "document", url, this.originChecker);
1169
+ if (decision.allow) return pendingActivation;
1170
+
1171
+ this.pushNetwork({
1172
+ type: "failed",
1173
+ url,
1174
+ method: "GET",
1175
+ status: 0,
1176
+ statusText: "",
1177
+ failure: `blocked by egress policy: ${decision.reason}`,
1178
+ timestamp: Date.now(),
1179
+ });
1180
+
1181
+ if (pendingActivation) {
1182
+ await this.closeBlockedPage(page);
1183
+ return false;
1184
+ }
1185
+
1186
+ try {
1187
+ await page.goBack({ waitUntil: "commit" });
1188
+ } catch {
1189
+ // Fall through and close a page that cannot return to an approved URL.
1190
+ }
1191
+
1192
+ if (!page.isClosed()) {
1193
+ const current = decideRequest(page.url(), "document", page.url(), this.originChecker);
1194
+ if (!current.allow) await this.closeBlockedPage(page);
1195
+ }
1196
+ return false;
1197
+ }
1198
+
1199
+ private async closeBlockedPage(page: Page): Promise<void> {
1200
+ try {
1201
+ await page.close({ runBeforeUnload: false });
1202
+ } catch {
1203
+ // The page may already have closed while its request was being aborted.
1204
+ }
1205
+
1206
+ if (this.page === page) {
1207
+ const fallback = this.context?.pages().filter((candidate) => !candidate.isClosed()).at(-1);
1208
+ this.page = fallback;
1209
+ }
871
1210
  }
872
1211
 
873
1212
  private attachConsoleListeners(page: Page): void {
@@ -878,13 +1217,15 @@ export class BrowserManager {
878
1217
  this.pushConsole({ type: "pageerror", text: err.message, timestamp: Date.now() });
879
1218
  });
880
1219
  page.on("requestfailed", (req) => {
1220
+ const failure = req.failure()?.errorText ?? "unknown";
1221
+ if (failure.includes("ERR_BLOCKED_BY_CLIENT")) return;
881
1222
  this.pushNetwork({
882
1223
  type: "failed",
883
1224
  url: req.url(),
884
1225
  method: req.method(),
885
1226
  status: 0,
886
1227
  statusText: "",
887
- failure: req.failure()?.errorText ?? "unknown",
1228
+ failure,
888
1229
  timestamp: Date.now(),
889
1230
  });
890
1231
  });
@@ -953,7 +1294,13 @@ export class BrowserManager {
953
1294
  this.consoleBuffer = [];
954
1295
  this.networkBuffer = [];
955
1296
  this.dialogBuffer = [];
1297
+ this.lastBlockedDocumentUrl = undefined;
956
1298
 
1299
+ try {
1300
+ await this.context?.unrouteAll({ behavior: "ignoreErrors" });
1301
+ } catch {
1302
+ // ignore
1303
+ }
957
1304
  try {
958
1305
  await this.page?.close({ runBeforeUnload: false });
959
1306
  } catch {
@@ -0,0 +1,145 @@
1
+ import { isIP } from "node:net";
2
+ import { isGranted } from "../permission-store.ts";
3
+ import { isPreapprovedHost, permissionKey } from "../permissions.ts";
4
+
5
+ export type EgressDecision = { allow: true } | { allow: false; reason: string };
6
+
7
+ export type UrlPermissionChecker = (url: string) => boolean;
8
+
9
+ function denied(reason: string): EgressDecision {
10
+ return { allow: false, reason };
11
+ }
12
+
13
+ function alternateWwwHostname(hostname: string): string | undefined {
14
+ const bareHostname = hostname.startsWith("www.") ? hostname.slice(4) : hostname;
15
+ const unbracketed = bareHostname.startsWith("[") && bareHostname.endsWith("]")
16
+ ? bareHostname.slice(1, -1)
17
+ : bareHostname;
18
+ if (
19
+ !bareHostname.includes(".") ||
20
+ bareHostname === "localhost" ||
21
+ bareHostname.endsWith(".local") ||
22
+ isIP(unbracketed) !== 0
23
+ ) {
24
+ return undefined;
25
+ }
26
+ return hostname.startsWith("www.") ? bareHostname : `www.${hostname}`;
27
+ }
28
+
29
+ /**
30
+ * Decide whether a browser request may leave the page.
31
+ *
32
+ * HTTP(S) requests require a live URL permission. The network-free internal
33
+ * about:blank page and data/blob subresources are allowed, but top-level
34
+ * data/blob documents are denied. Every file: request checks the approved
35
+ * target path. Local-file subresources additionally require a local-file page.
36
+ */
37
+ export function decideRequest(
38
+ requestUrl: string,
39
+ requestResourceType: string,
40
+ pageUrl: string,
41
+ isPermittedUrl: UrlPermissionChecker,
42
+ ): EgressDecision {
43
+ let request: URL;
44
+ try {
45
+ request = new URL(requestUrl);
46
+ } catch {
47
+ return denied("Invalid request URL");
48
+ }
49
+
50
+ if (request.protocol === "http:" || request.protocol === "https:") {
51
+ try {
52
+ return isPermittedUrl(requestUrl)
53
+ ? { allow: true }
54
+ : denied(`URL is not approved: ${permissionKey(requestUrl)}`);
55
+ } catch {
56
+ return denied("Permission check failed");
57
+ }
58
+ }
59
+
60
+ if (request.href === "about:blank") {
61
+ return { allow: true };
62
+ }
63
+
64
+ if (request.protocol === "data:" || request.protocol === "blob:") {
65
+ return requestResourceType === "document"
66
+ ? denied(`${request.protocol} document navigation is not allowed`)
67
+ : { allow: true };
68
+ }
69
+
70
+ if (request.protocol !== "file:") {
71
+ return denied(`Unsupported request protocol: ${request.protocol}`);
72
+ }
73
+
74
+ try {
75
+ if (requestResourceType === "document") {
76
+ return isPermittedUrl(requestUrl)
77
+ ? { allow: true }
78
+ : denied(`Local file is not approved: ${permissionKey(requestUrl)}`);
79
+ }
80
+
81
+ const page = new URL(pageUrl);
82
+ if (page.protocol !== "file:") {
83
+ return denied("Local-file subrequests require a local-file page");
84
+ }
85
+
86
+ return isPermittedUrl(requestUrl)
87
+ ? { allow: true }
88
+ : denied(`Local file is not approved: ${permissionKey(requestUrl)}`);
89
+ } catch {
90
+ return denied("Permission check failed");
91
+ }
92
+ }
93
+
94
+ /** Build a live checker over preapproved URLs plus session and durable grants. */
95
+ export function buildOriginChecker(
96
+ sessionPermissions: Map<string, "allow" | "deny">,
97
+ ): UrlPermissionChecker {
98
+ return (candidateUrl: string): boolean => {
99
+ let parsed: URL;
100
+ try {
101
+ parsed = new URL(candidateUrl);
102
+ } catch {
103
+ return false;
104
+ }
105
+
106
+ if (parsed.username || parsed.password) {
107
+ return false;
108
+ }
109
+
110
+ if (parsed.protocol === "ws:") parsed.protocol = "http:";
111
+ else if (parsed.protocol === "wss:") parsed.protocol = "https:";
112
+
113
+ if (
114
+ (parsed.protocol === "http:" || parsed.protocol === "https:") &&
115
+ isPreapprovedHost(parsed.hostname, parsed.pathname)
116
+ ) {
117
+ return true;
118
+ }
119
+
120
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" && parsed.protocol !== "file:") {
121
+ return false;
122
+ }
123
+
124
+ const key = permissionKey(parsed.toString());
125
+ const sessionDecision = sessionPermissions.get(key);
126
+ if (sessionDecision === "deny") return false;
127
+ if (sessionDecision === "allow") return true;
128
+ if (parsed.protocol !== "file:" && isGranted("web_browser", key)) return true;
129
+
130
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
131
+ const alternateHostname = alternateWwwHostname(parsed.hostname);
132
+ if (alternateHostname) {
133
+ const alternateUrl = new URL(parsed.toString());
134
+ alternateUrl.hostname = alternateHostname;
135
+ const alternateKey = permissionKey(alternateUrl.toString());
136
+ const alternateDecision = sessionPermissions.get(alternateKey);
137
+ if (alternateDecision === "deny") return false;
138
+ if (alternateDecision === "allow") return true;
139
+ if (isGranted("web_browser", alternateKey)) return true;
140
+ }
141
+ }
142
+
143
+ return false;
144
+ };
145
+ }
@@ -28,9 +28,11 @@ import {
28
28
  type SetCookieParam,
29
29
  type TabInfo,
30
30
  } from "./browser-manager.ts";
31
+ import { buildOriginChecker } from "./egress-policy.ts";
31
32
  import { checkUrlPermission, upgradeHttpToHttps, validateURL } from "./permissions.ts";
32
33
  import { requestHostPermission } from "../permission-prompt.ts";
33
34
  import { listGrants } from "../permission-store.ts";
35
+ import { permissionKey } from "../permissions.ts";
34
36
  import { isInsideCwd } from "../pdf-extract.ts";
35
37
  import { suppressNotifications } from "../env.ts";
36
38
  import { publishBrowserState, registerBrowserFeeds } from "./feeds.ts";
@@ -134,7 +136,8 @@ function formatTabs(tabs: TabInfo[]): string {
134
136
 
135
137
  function wrapUntrustedContent(url: string, text: string): string {
136
138
  const safeUrl = url.replaceAll('"', "%22");
137
- return `<untrusted-content url="${safeUrl}">\n${text}\n</untrusted-content>`;
139
+ const safeText = text.replaceAll(/<\/untrusted-content/gi, "");
140
+ return `<untrusted-content url="${safeUrl}">\n${safeText}\n</untrusted-content>`;
138
141
  }
139
142
 
140
143
  function isAbortError(err: unknown): boolean {
@@ -226,10 +229,16 @@ function syncBrowserClosed(ctx: ExtensionContext): void {
226
229
 
227
230
  export default function (pi: ExtensionAPI) {
228
231
  const sessionPermissions = new Map<string, "allow" | "deny">();
232
+ const originChecker = buildOriginChecker(sessionPermissions);
233
+ const getConfiguredBrowserManager = () => {
234
+ const manager = getBrowserManager();
235
+ manager.setOriginChecker(originChecker);
236
+ return manager;
237
+ };
229
238
  registerBrowserFeeds(pi);
230
239
 
231
240
  pi.on("session_start", async (_event, ctx) => {
232
- const info = await getBrowserManager().getPageInfoAsync();
241
+ const info = await getConfiguredBrowserManager().getPageInfoAsync();
233
242
  if (info.isOpen) {
234
243
  syncBrowserOpen(ctx, info.url);
235
244
  } else {
@@ -248,16 +257,22 @@ export default function (pi: ExtensionAPI) {
248
257
  const input = event.input as { action?: string; url?: string };
249
258
 
250
259
  if (input.action === "navigate" && input.url) {
251
- const permission = await checkUrlPermission(input.url, ctx, sessionPermissions);
260
+ const upgraded = upgradeHttpToHttps(input.url);
261
+ const permission = await checkUrlPermission(upgraded, ctx, sessionPermissions);
252
262
  if (permission !== "allow") {
253
263
  return { block: true, reason: blockReason(permission) };
254
264
  }
255
265
 
266
+ // A browser navigation must keep its approved origin available to
267
+ // request-level enforcement after the tool-call hook returns.
268
+ if (!originChecker(upgraded)) {
269
+ sessionPermissions.set(permissionKey(upgraded), "allow");
270
+ }
256
271
  return undefined;
257
272
  }
258
273
 
259
274
  if (input.action === "evaluate") {
260
- const info = await getBrowserManager().getPageInfoAsync();
275
+ const info = await getConfiguredBrowserManager().getPageInfoAsync();
261
276
  const permission = await checkUrlPermission(info.url, ctx, sessionPermissions);
262
277
  if (permission !== "allow") {
263
278
  return { block: true, reason: blockReason(permission) };
@@ -271,7 +286,7 @@ export default function (pi: ExtensionAPI) {
271
286
  description:
272
287
  "Show headless browser status (open/closed, URL, title, console and network counts, session-approved hosts)",
273
288
  handler: async (_args, ctx) => {
274
- const browser = getBrowserManager();
289
+ const browser = getConfiguredBrowserManager();
275
290
  const info = await browser.getPageInfoAsync();
276
291
  const lines = [
277
292
  "Web Browser:",
@@ -301,7 +316,7 @@ export default function (pi: ExtensionAPI) {
301
316
  pi.registerCommand("browser-screenshot", {
302
317
  description: "Save a full-page screenshot to a temp PNG file",
303
318
  handler: async (_args, ctx) => {
304
- const browser = getBrowserManager();
319
+ const browser = getConfiguredBrowserManager();
305
320
  const info = await browser.getPageInfoAsync();
306
321
  if (!info.isOpen) {
307
322
  ctx.ui.notify("Browser is closed — navigate to a page first.", "warning");
@@ -328,13 +343,13 @@ export default function (pi: ExtensionAPI) {
328
343
  "On SPA sites, use wait_for after navigate (selector/state or networkidle) before click/type.",
329
344
  "Use screenshot for visual verification (requires a vision-capable model); use toFile=true to save PNG to disk instead of base64.",
330
345
  "Use get_console_logs when JavaScript errors or console output may explain page behavior.",
331
- "Use get_network_logs when failed requests or 4xx/5xx responses may explain page behavior.",
346
+ "Use get_network_logs when failed, egress-blocked, or 4xx/5xx requests may explain page behavior. Navigate directly to a blocked origin to request approval.",
332
347
  "Use set_cookies to inject cookies for authenticated flows after manual login.",
333
348
  "Use hover to trigger tooltips/hover-menus before click; press for keyboard key events (Enter, Escape, Tab) — distinct from type mode:press which types characters; select_option to choose <select> dropdown values; set_viewport to resize the page for responsive/mobile checks.",
334
349
  "Selectors are CSS selectors, or Playwright's role= and text= selector engines (e.g. role=button[name='Submit'], text=Sign in). If click/type fails, read the returned diagnostics and page snippet and adjust the selector.",
335
350
  "Use frame to target an element inside an iframe: pass a CSS selector for the iframe itself, separate from the element selector.",
336
351
  "Call set_dialog_behavior before the action that triggers a JS dialog (alert/confirm/prompt) — dialogs are dismissed by default, which would silently cancel a confirm(). Use get_dialog_logs to see captured dialogs.",
337
- "Opening a link with target=_blank or window.open creates a popup that automatically becomes the active tab. Use list_tabs and switch_tab to manage multiple open tabs.",
352
+ "An approved target=_blank or window.open popup becomes the active tab; an unapproved popup is blocked and closed. Use list_tabs and switch_tab to manage open tabs.",
338
353
  "Use scroll to bring an off-screen element into view (selector) or to scroll the page/container by pixel deltas; use drag for drag-and-drop between two selectors; use upload_file to set files on an <input type=file>.",
339
354
  ],
340
355
  description:
@@ -471,7 +486,7 @@ export default function (pi: ExtensionAPI) {
471
486
  };
472
487
  }
473
488
 
474
- const mgr = getBrowserManager();
489
+ const mgr = getConfiguredBrowserManager();
475
490
 
476
491
  switch (action) {
477
492
  case "navigate": {
@@ -501,7 +516,8 @@ export default function (pi: ExtensionAPI) {
501
516
  action,
502
517
  url: result.url,
503
518
  title: result.title,
504
- statusCode: result.statusCode,
519
+ // A same-document navigation has no response; omit the key rather than storing undefined.
520
+ ...(result.statusCode !== undefined ? { statusCode: result.statusCode } : {}),
505
521
  bytes: Buffer.byteLength(text),
506
522
  } satisfies WebBrowserDetails,
507
523
  };
@@ -988,7 +1004,9 @@ export default function (pi: ExtensionAPI) {
988
1004
  }
989
1005
  const message = err instanceof Error ? err.message : String(err);
990
1006
  return {
991
- content: [{ type: "text", text: `Browser error: ${message}` }],
1007
+ content: [
1008
+ { type: "text", text: message.startsWith("Blocked:") ? message : `Browser error: ${message}` },
1009
+ ],
992
1010
  details: { action, error: message } satisfies WebBrowserDetails,
993
1011
  isError: true,
994
1012
  };