@underactive/pi-topping-web-tools 0.2.0 → 0.2.2

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,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.2.2] - 2026-09-02
6
+
7
+ ### Added
8
+
9
+ - `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.
10
+
11
+ ### Changed
12
+
13
+ - `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.
14
+ - `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.
15
+
16
+ ### Fixed
17
+
18
+ - `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.
19
+ - `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.
20
+
21
+ ## [0.2.1] - 2026-08-31
22
+
23
+ ### Added
24
+
25
+ - `web_browser`: publishes browser state through the `pi-topping-web-tools/browser` custom session feed for statusline consumers. Truthy `PI_SUPPRESS_NOTIFICATIONS` values hide the native browser footer without suppressing feed entries.
26
+
27
+ ### Changed
28
+
29
+ - `fetch_markdown`, `pdf_extract`: send browser-like HTTP headers (Accept, Accept-Language, User-Agent, Sec-Fetch-*, etc.) to reduce blocking by User-Agent-based filters; `PI_FETCH_USER_AGENT` overrides the default User-Agent.
30
+
3
31
  ## [0.2.0] - 2026-08-18
4
32
 
5
33
  ### 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
 
@@ -143,12 +158,33 @@ Commands:
143
158
 
144
159
  Status line: a footer segment (`browser: example.com`) appears while the browser is open.
145
160
 
161
+ ### Statusline feed
162
+
163
+ `web_browser` also publishes its current state as a pi custom session entry, so [pi-topping-statusline](https://github.com/underactive/pi-topping-statusline) can render it without depending on this package:
164
+
165
+ - Type: `pi-topping-web-tools/browser`
166
+ - `label` (`text`): current hostname, `local file`, `ready`, or an empty string when closed
167
+ - `open` (`number`): `1` when open, `0` when closed
168
+
169
+ For example, subscribe to `label` with prefix `browser: ` and `text` format:
170
+
171
+ ```json
172
+ {
173
+ "type": "pi-topping-web-tools/browser",
174
+ "field": "label",
175
+ "prefix": "browser: ",
176
+ "format": "text"
177
+ }
178
+ ```
179
+
180
+ Entries are published only when the state changes, except that the current state is republished immediately at session start and once after the first turn. Closing the browser publishes `{ "label": "", "open": 0 }` so consumers can clear stale labels. Set `PI_SUPPRESS_NOTIFICATIONS` to `1`, `true`, or `yes` (case-insensitive) to hide the native footer; feed publication continues.
181
+
146
182
  ## Saved permissions
147
183
 
148
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.
149
185
 
150
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.
151
- - **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.
152
188
  - **Max 30 days.** All grants expire; there is no indefinite option. Expired entries are pruned automatically.
153
189
  - **Local files excluded.** `file://` prompts never offer durable options; local-file access needs a path-scope design that is out of scope here.
154
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.
@@ -160,10 +196,10 @@ Commands:
160
196
  ## Security model (shared)
161
197
 
162
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.
163
- - **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.
164
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.
165
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.
166
- - **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.
167
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.
168
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.
169
205
 
@@ -171,12 +207,14 @@ Commands:
171
207
 
172
208
  - `web_browser` requires the Chromium download.
173
209
  - Screenshots consume significant context on vision models — prefer `get_text`, `get_markdown`, or `get_accessibility_snapshot`.
174
- - 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.
175
213
  - `pdf_extract` reads only an existing text layer; scanned documents need OCR, which is out of scope.
176
214
 
177
215
  ### Popups and navigation
178
216
 
179
- 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.
180
218
 
181
219
  ## Development
182
220
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-web-tools",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Browser-like HTTP headers shared by fetch_markdown and pdf_extract.
3
+ *
4
+ * Sending these instead of bare-minimum tool-identified headers reduces
5
+ * the chance of being blocked by WAFs and CDNs that filter non-browser
6
+ * User-Agents or missing auxiliary headers.
7
+ *
8
+ * The header set mimics Chrome ~122 on macOS, omitting Sec-Ch-Ua* (which
9
+ * would need to stay in sync with the Chrome version) and Accept-Encoding
10
+ * (Node's native fetch() adds its own).
11
+ */
12
+
13
+ const DEFAULT_HEADERS: Record<string, string> = {
14
+ Accept:
15
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
16
+ "Accept-Language": "en-US,en;q=0.9",
17
+ "Upgrade-Insecure-Requests": "1",
18
+ "User-Agent":
19
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
20
+ "Sec-Fetch-Dest": "document",
21
+ "Sec-Fetch-Mode": "navigate",
22
+ "Sec-Fetch-Site": "none",
23
+ "Sec-Fetch-User": "?1",
24
+ DNT: "1",
25
+ Priority: "u=0, i",
26
+ };
27
+
28
+ const USER_AGENT_OVERRIDE: string | undefined = process.env.PI_FETCH_USER_AGENT || undefined;
29
+
30
+ /**
31
+ * Return a set of browser-like HTTP headers.
32
+ *
33
+ * When `PI_FETCH_USER_AGENT` is set in the environment, its value replaces
34
+ * the default Chrome User-Agent while all other headers remain unchanged.
35
+ *
36
+ * @param userAgentOverride - optional explicit User-Agent to use instead of
37
+ * the default Chrome macOS UA. Takes precedence over the env var when both
38
+ * are present.
39
+ */
40
+ export function getBrowserHeaders(userAgentOverride?: string): Record<string, string> {
41
+ const ua = userAgentOverride ?? USER_AGENT_OVERRIDE;
42
+ if (!ua) return DEFAULT_HEADERS;
43
+ return { ...DEFAULT_HEADERS, "User-Agent": ua };
44
+ }
package/src/env.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Same truthiness rules as pi core's isTruthyEnvFlag.
2
+ export function isTruthyEnvFlag(value: string | undefined): boolean {
3
+ if (!value) return false;
4
+ return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
5
+ }
6
+
7
+ /** Set when pi suppresses non-essential notifications. */
8
+ export function suppressNotifications(env: NodeJS.ProcessEnv = process.env): boolean {
9
+ return isTruthyEnvFlag(env.PI_SUPPRESS_NOTIFICATIONS);
10
+ }
@@ -9,6 +9,7 @@ import { Readability } from "@mozilla/readability";
9
9
  import { parseHTML } from "linkedom";
10
10
  import { STATUS_CODES } from "node:http";
11
11
  import { combineSignals } from "./abort-utils.ts";
12
+ import { getBrowserHeaders } from "./browser-headers.ts";
12
13
  import { isPreapprovedHost, permissionKey } from "./permissions.ts";
13
14
  import { requestHostPermission } from "./permission-prompt.ts";
14
15
  import { MAX_URL_LENGTH, isLocalOrPrivateHost, upgradeHttpToHttps } from "./web-browser/permissions.ts";
@@ -207,8 +208,7 @@ export async function getWithPermittedRedirects(
207
208
  signal,
208
209
  redirect: "manual",
209
210
  headers: {
210
- Accept: "text/markdown, text/html, */*",
211
- "User-Agent": "pi-fetch-markdown/1.0",
211
+ ...getBrowserHeaders(),
212
212
  ...extraHeaders,
213
213
  },
214
214
  });
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const BROWSER_FEED_TYPE = "pi-topping-web-tools/browser";
4
+
5
+ export interface BrowserFeedPayload {
6
+ label: string;
7
+ open: 0 | 1;
8
+ }
9
+
10
+ let extension: ExtensionAPI | undefined;
11
+ let latestPayload: BrowserFeedPayload | undefined;
12
+ let emittedPayload: BrowserFeedPayload | undefined;
13
+ let publishAfterFirstTurn = false;
14
+
15
+ function samePayload(left: BrowserFeedPayload | undefined, right: BrowserFeedPayload): boolean {
16
+ return left?.label === right.label && left.open === right.open;
17
+ }
18
+
19
+ function emitBrowserState(force = false): void {
20
+ if (!extension || !latestPayload || (!force && samePayload(emittedPayload, latestPayload))) return;
21
+ try {
22
+ extension.appendEntry(BROWSER_FEED_TYPE, latestPayload);
23
+ emittedPayload = latestPayload;
24
+ } catch {
25
+ // Feed publication is best effort; leave emitted state unchanged for retry.
26
+ }
27
+ }
28
+
29
+ export function registerBrowserFeeds(pi: ExtensionAPI): void {
30
+ extension = pi;
31
+ pi.on("session_start", () => {
32
+ emittedPayload = undefined;
33
+ publishAfterFirstTurn = true;
34
+ });
35
+ pi.on("turn_end", () => {
36
+ if (!publishAfterFirstTurn) return;
37
+ publishAfterFirstTurn = false;
38
+ emitBrowserState(true);
39
+ });
40
+ }
41
+
42
+ export function publishBrowserState(label: string, open: boolean | number): void {
43
+ latestPayload = { label, open: open ? 1 : 0 };
44
+ emitBrowserState();
45
+ }
46
+
47
+ export function resetBrowserFeedState(): void {
48
+ extension = undefined;
49
+ latestPayload = undefined;
50
+ emittedPayload = undefined;
51
+ publishAfterFirstTurn = false;
52
+ }
@@ -28,10 +28,14 @@ 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";
37
+ import { suppressNotifications } from "../env.ts";
38
+ import { publishBrowserState, registerBrowserFeeds } from "./feeds.ts";
35
39
 
36
40
  const ACTIONS = [
37
41
  "navigate",
@@ -132,7 +136,8 @@ function formatTabs(tabs: TabInfo[]): string {
132
136
 
133
137
  function wrapUntrustedContent(url: string, text: string): string {
134
138
  const safeUrl = url.replaceAll('"', "%22");
135
- 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>`;
136
141
  }
137
142
 
138
143
  function isAbortError(err: unknown): boolean {
@@ -198,21 +203,47 @@ function browserStatus(ctx: ExtensionContext, text: string, tone: "success" | "m
198
203
  return ctx.ui.theme.fg(tone, `browser: ${text}`);
199
204
  }
200
205
 
201
- function updateBrowserStatus(ctx: ExtensionContext, info: { url: string; title?: string }): void {
206
+ export function browserLabel(url: string): string {
202
207
  try {
203
- const parsed = new URL(info.url);
204
- const label = parsed.protocol === "file:" ? "local file" : parsed.hostname;
205
- ctx.ui.setStatus("browser", browserStatus(ctx, label, "success"));
208
+ const parsed = new URL(url);
209
+ return parsed.protocol === "file:" ? "local file" : parsed.hostname || "ready";
206
210
  } catch {
207
- ctx.ui.setStatus("browser", browserStatus(ctx, "ready", "muted"));
211
+ return "ready";
212
+ }
213
+ }
214
+
215
+ function syncBrowserOpen(ctx: ExtensionContext, url: string): void {
216
+ const label = browserLabel(url);
217
+ publishBrowserState(label, 1);
218
+ if (!suppressNotifications()) {
219
+ ctx.ui.setStatus("browser", browserStatus(ctx, label, label === "ready" ? "muted" : "success"));
220
+ }
221
+ }
222
+
223
+ function syncBrowserClosed(ctx: ExtensionContext): void {
224
+ publishBrowserState("", 0);
225
+ if (!suppressNotifications()) {
226
+ ctx.ui.setStatus("browser", undefined);
208
227
  }
209
228
  }
210
229
 
211
230
  export default function (pi: ExtensionAPI) {
212
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
+ };
238
+ registerBrowserFeeds(pi);
213
239
 
214
240
  pi.on("session_start", async (_event, ctx) => {
215
- ctx.ui.setStatus("browser", undefined);
241
+ const info = await getConfiguredBrowserManager().getPageInfoAsync();
242
+ if (info.isOpen) {
243
+ syncBrowserOpen(ctx, info.url);
244
+ } else {
245
+ syncBrowserClosed(ctx);
246
+ }
216
247
  });
217
248
 
218
249
  pi.on("session_shutdown", async () => {
@@ -226,16 +257,22 @@ export default function (pi: ExtensionAPI) {
226
257
  const input = event.input as { action?: string; url?: string };
227
258
 
228
259
  if (input.action === "navigate" && input.url) {
229
- const permission = await checkUrlPermission(input.url, ctx, sessionPermissions);
260
+ const upgraded = upgradeHttpToHttps(input.url);
261
+ const permission = await checkUrlPermission(upgraded, ctx, sessionPermissions);
230
262
  if (permission !== "allow") {
231
263
  return { block: true, reason: blockReason(permission) };
232
264
  }
233
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
+ }
234
271
  return undefined;
235
272
  }
236
273
 
237
274
  if (input.action === "evaluate") {
238
- const info = await getBrowserManager().getPageInfoAsync();
275
+ const info = await getConfiguredBrowserManager().getPageInfoAsync();
239
276
  const permission = await checkUrlPermission(info.url, ctx, sessionPermissions);
240
277
  if (permission !== "allow") {
241
278
  return { block: true, reason: blockReason(permission) };
@@ -249,7 +286,7 @@ export default function (pi: ExtensionAPI) {
249
286
  description:
250
287
  "Show headless browser status (open/closed, URL, title, console and network counts, session-approved hosts)",
251
288
  handler: async (_args, ctx) => {
252
- const browser = getBrowserManager();
289
+ const browser = getConfiguredBrowserManager();
253
290
  const info = await browser.getPageInfoAsync();
254
291
  const lines = [
255
292
  "Web Browser:",
@@ -271,7 +308,7 @@ export default function (pi: ExtensionAPI) {
271
308
  description: "Force close the headless browser",
272
309
  handler: async (_args, ctx) => {
273
310
  await closeBrowserManager();
274
- ctx.ui.setStatus("browser", undefined);
311
+ syncBrowserClosed(ctx);
275
312
  ctx.ui.notify("Browser closed", "info");
276
313
  },
277
314
  });
@@ -279,7 +316,7 @@ export default function (pi: ExtensionAPI) {
279
316
  pi.registerCommand("browser-screenshot", {
280
317
  description: "Save a full-page screenshot to a temp PNG file",
281
318
  handler: async (_args, ctx) => {
282
- const browser = getBrowserManager();
319
+ const browser = getConfiguredBrowserManager();
283
320
  const info = await browser.getPageInfoAsync();
284
321
  if (!info.isOpen) {
285
322
  ctx.ui.notify("Browser is closed — navigate to a page first.", "warning");
@@ -306,13 +343,13 @@ export default function (pi: ExtensionAPI) {
306
343
  "On SPA sites, use wait_for after navigate (selector/state or networkidle) before click/type.",
307
344
  "Use screenshot for visual verification (requires a vision-capable model); use toFile=true to save PNG to disk instead of base64.",
308
345
  "Use get_console_logs when JavaScript errors or console output may explain page behavior.",
309
- "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.",
310
347
  "Use set_cookies to inject cookies for authenticated flows after manual login.",
311
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.",
312
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.",
313
350
  "Use frame to target an element inside an iframe: pass a CSS selector for the iframe itself, separate from the element selector.",
314
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.",
315
- "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.",
316
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>.",
317
354
  ],
318
355
  description:
@@ -442,14 +479,14 @@ export default function (pi: ExtensionAPI) {
442
479
  try {
443
480
  if (action === "close") {
444
481
  await closeBrowserManager();
445
- ctx.ui.setStatus("browser", undefined);
482
+ syncBrowserClosed(ctx);
446
483
  return {
447
484
  content: [{ type: "text", text: "Browser closed." }],
448
485
  details: { action } satisfies WebBrowserDetails,
449
486
  };
450
487
  }
451
488
 
452
- const mgr = getBrowserManager();
489
+ const mgr = getConfiguredBrowserManager();
453
490
 
454
491
  switch (action) {
455
492
  case "navigate": {
@@ -470,7 +507,7 @@ export default function (pi: ExtensionAPI) {
470
507
 
471
508
  const upgraded = upgradeHttpToHttps(params.url);
472
509
  const result: NavigateResult = await mgr.navigate(upgraded, { timeout, signal });
473
- updateBrowserStatus(ctx, result);
510
+ syncBrowserOpen(ctx, result.url);
474
511
 
475
512
  const text = `Navigated to ${result.url}\nTitle: ${result.title}${result.statusCode ? `\nStatus: ${result.statusCode}` : ""}`;
476
513
  return {
@@ -494,7 +531,7 @@ export default function (pi: ExtensionAPI) {
494
531
  signal,
495
532
  });
496
533
  const info = await mgr.getPageInfoAsync();
497
- updateBrowserStatus(ctx, info);
534
+ syncBrowserOpen(ctx, info.url);
498
535
  const dims = `${shot.width}×${shot.height}`;
499
536
  const dimsNote = `${dims}${shot.fullPage ? " (full page)" : ""}`;
500
537
 
@@ -940,7 +977,7 @@ export default function (pi: ExtensionAPI) {
940
977
  }
941
978
  const msg = await mgr.switchTab(params.index);
942
979
  const info = await mgr.getPageInfoAsync();
943
- updateBrowserStatus(ctx, info);
980
+ syncBrowserOpen(ctx, info.url);
944
981
  return {
945
982
  content: [{ type: "text", text: msg }],
946
983
  details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
@@ -966,7 +1003,9 @@ export default function (pi: ExtensionAPI) {
966
1003
  }
967
1004
  const message = err instanceof Error ? err.message : String(err);
968
1005
  return {
969
- content: [{ type: "text", text: `Browser error: ${message}` }],
1006
+ content: [
1007
+ { type: "text", text: message.startsWith("Blocked:") ? message : `Browser error: ${message}` },
1008
+ ],
970
1009
  details: { action, error: message } satisfies WebBrowserDetails,
971
1010
  isError: true,
972
1011
  };