@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 +28 -0
- package/README.md +46 -8
- package/package.json +1 -1
- package/src/browser-headers.ts +44 -0
- package/src/env.ts +10 -0
- package/src/fetch-markdown.ts +2 -2
- package/src/permission-store.ts +24 -1
- package/src/web-browser/browser-manager.ts +387 -40
- package/src/web-browser/egress-policy.ts +145 -0
- package/src/web-browser/feeds.ts +52 -0
- package/src/web-browser/index.ts +59 -20
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;
|
|
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`
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/fetch-markdown.ts
CHANGED
|
@@ -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
|
-
|
|
211
|
-
"User-Agent": "pi-fetch-markdown/1.0",
|
|
211
|
+
...getBrowserHeaders(),
|
|
212
212
|
...extraHeaders,
|
|
213
213
|
},
|
|
214
214
|
});
|
package/src/permission-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
|
862
|
-
*
|
|
863
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/web-browser/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
206
|
+
export function browserLabel(url: string): string {
|
|
202
207
|
try {
|
|
203
|
-
const parsed = new URL(
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
};
|