@underactive/pi-topping-web-tools 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +25 -8
- package/package.json +4 -4
- package/src/pdf-extract.ts +8 -8
- 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/index.ts +29 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.2.3] - 2026-09-20
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 0.82.1 to 0.86.0 so `npm run typecheck` matches the 0.86.0 runtime API.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `web_browser`, `pdf_extract`: tool result `details` no longer carry undefined-valued properties, keeping them JSON-compatible as pi 0.86.0's `ToolResultMessage.details` requires. `web_browser` omits `statusCode` for same-document navigations; `pdf_extract` omits `firstPage`/`lastPage` for a zero-page PDF and no longer prints "pages undefined-undefined" in its no-text-layer message.
|
|
14
|
+
- `pdf_extract`: strip `</untrusted-content` fence breaks case-insensitively so mixed-case closing tags cannot bypass sanitization.
|
|
15
|
+
|
|
16
|
+
## [0.2.2] - 2026-09-02
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `web_browser`: fail-closed browser-context egress enforcement for HTTP(S) requests and WebSocket connections, with live preapproved/session/durable permission checks and blocked-request reporting in network logs. Service workers are disabled so they cannot bypass routing.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `web_browser`: inspects redirect targets before contact, follows approved top-level redirects one gated hop at a time, blocks redirected subresources, closes unapproved popups before activation, and restores the prior page after blocked client-side navigation.
|
|
25
|
+
- `web_browser`: retains an Allow once navigation decision as a browser-session grant so the approved document and its later same-origin requests can pass request-level enforcement.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- `web_browser`: treat an empty optional selector as the default `body` selector for `get_text` and `get_accessibility_snapshot`, avoiding invalid empty CSS-selector errors.
|
|
30
|
+
- `web_browser`: treats bare and `www.` variants of a remote hostname as equivalent for session and durable egress grants, allowing redirects such as `apple.com` → `www.apple.com` without a second approval while keeping other subdomains gated.
|
|
31
|
+
|
|
3
32
|
## [0.2.1] - 2026-08-31
|
|
4
33
|
|
|
5
34
|
### Added
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Web work has two distinct needs: clean context from public sources and browser-l
|
|
|
22
22
|
- **Reach PDF-only sources.** `pdf_extract` reads the text layer of specifications, datasheets, and papers that markdown fetchers treat as undisplayable binary content, and that pi's `read` tool cannot parse.
|
|
23
23
|
- **Validate the rendered product.** `web_browser` runs a fresh headless Chromium context for client-rendered pages, local development servers, and user journeys that static HTML cannot represent. It can inspect the accessibility tree, interact with controls, and surface console and failed-network diagnostics.
|
|
24
24
|
- **Use the appropriate tool, not the heavier tool by default.** Fetch static public material first; move to browser automation when JavaScript, visual state, or user input is part of the question.
|
|
25
|
-
- **Keep control over access.** The tools share a host allowlist and confirmation workflow;
|
|
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
|
|
|
@@ -169,7 +184,7 @@ Entries are published only when the state changes, except that the current state
|
|
|
169
184
|
When the host confirmation prompt offers a durable option ("Allow for 1 day", "Allow for 1 week", "Allow for 30 days"), the grant is persisted to `~/.pi/agent/web-permissions.json` (mode `0600`) and applies across all sessions and projects.
|
|
170
185
|
|
|
171
186
|
- **Per-tool scope.** A `fetch_markdown` grant does not authorize `web_browser`, which executes JavaScript and drives interaction. Each tool scopes its grants independently.
|
|
172
|
-
- **Exact origin.** Grants
|
|
187
|
+
- **Exact origin.** Grants are stored as `scheme://host[:port]` and do not cross ports or schemes. For `web_browser` egress, only the bare and `www.` variants of the same remote hostname are equivalent; no other subdomains inherit the grant.
|
|
173
188
|
- **Max 30 days.** All grants expire; there is no indefinite option. Expired entries are pruned automatically.
|
|
174
189
|
- **Local files excluded.** `file://` prompts never offer durable options; local-file access needs a path-scope design that is out of scope here.
|
|
175
190
|
- **Headless behavior.** Active durable grants apply when there is no interactive UI (e.g. RPC or print mode). Without a grant, non-preapproved hosts remain blocked.
|
|
@@ -181,10 +196,10 @@ Commands:
|
|
|
181
196
|
## Security model (shared)
|
|
182
197
|
|
|
183
198
|
- **Preapproved hosts**: common documentation and dev sites are allowed without prompting. The allowlist lives in `src/permissions.ts` and is shared by both tools.
|
|
184
|
-
- **User confirmation**: other hosts prompt Allow once / Allow for this session / Allow for 1 day / Allow for 1 week / Allow for 30 days / Deny.
|
|
199
|
+
- **User confirmation**: other hosts prompt Allow once / Allow for this session / Allow for 1 day / Allow for 1 week / Allow for 30 days / Deny. For `web_browser` navigation, Allow once becomes a browser-session grant because request-level enforcement must authorize the loaded document's later requests.
|
|
185
200
|
- **URL validation**: all tools reject embedded credentials and overlong URLs. `fetch_markdown` and `pdf_extract` additionally reject loopback, private, and link-local hosts and any non-HTTP(S) scheme on a `url`. `web_browser` accepts `localhost`, private IPs, and `file://` paths so it can drive local dev servers and local HTML — each still prompts for confirmation unless session-approved or covered by a saved durable grant.
|
|
186
201
|
- **Local file resolution**: `pdf_extract` resolves a `path` through `realpath` before any check, so a symlink pointing outside the working directory is treated as outside and prompts. Directories, FIFOs, and devices are rejected, and the size limit is enforced from `stat` before any bytes are read. `web_browser` `upload_file` follows the same rule: files outside the working directory prompt for confirmation (session-scoped) before they are read.
|
|
187
|
-
- **Isolated browser context**: each session uses a fresh Chromium context (no shared profile/cookies with your system browser).
|
|
202
|
+
- **Isolated browser context and routed egress**: each session uses a fresh Chromium context (no shared profile/cookies with your system browser). HTTP(S) requests, redirect targets, client navigations, popups, and WebSocket connections are checked against live `web_browser` permissions; service workers are disabled.
|
|
188
203
|
- **Untrusted content boundary**: `fetch_markdown` results are wrapped in `<untrusted-content url="…">` tags, and `pdf_extract` results in `<untrusted-content source="…">`, so the model treats fetched page and document text as data, not instructions.
|
|
189
204
|
- **PDF resource limits**: extraction is bounded by download size, page count, and a timeout. Because the bundled PDF.js runs on the event loop rather than a worker, the timeout bounds extraction across its await points but cannot interrupt a fully synchronous parse — the size and page caps are the primary defence.
|
|
190
205
|
|
|
@@ -192,12 +207,14 @@ Commands:
|
|
|
192
207
|
|
|
193
208
|
- `web_browser` requires the Chromium download.
|
|
194
209
|
- Screenshots consume significant context on vision models — prefer `get_text`, `get_markdown`, or `get_accessibility_snapshot`.
|
|
195
|
-
- Network logs capture
|
|
210
|
+
- Network logs capture blocked/failed requests and 4xx/5xx responses, not successful traffic.
|
|
211
|
+
- Request routing buffers HTTP(S) responses and blocks redirected subresources, so streaming endpoints and resources that rely on redirects may not behave like an unrestricted browser.
|
|
212
|
+
- Playwright routing covers HTTP(S) and WebSockets, not browser transports such as WebRTC; OS-level egress sandboxing remains outside this extension's scope.
|
|
196
213
|
- `pdf_extract` reads only an existing text layer; scanned documents need OCR, which is out of scope.
|
|
197
214
|
|
|
198
215
|
### Popups and navigation
|
|
199
216
|
|
|
200
|
-
|
|
217
|
+
Same-tab client navigation and popups are checked against the live `web_browser` permissions. Unapproved document requests are aborted before reaching the target; blocked popups are closed without becoming active, while a blocked same-tab navigation returns to the prior approved page. Approved popups become active only after their main-frame navigation commits.
|
|
201
218
|
|
|
202
219
|
## Development
|
|
203
220
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping-web-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Pi extensions for the web: fetch_markdown (URL to markdown, cached, keyless), pdf_extract (PDF text extraction) and web_browser (headless Playwright automation), sharing a preapproved-host allowlist.",
|
|
5
5
|
"keywords": ["pi-package", "pi-extension"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"typebox": "*"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-ai": "0.
|
|
57
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
58
|
-
"@earendil-works/pi-tui": "0.
|
|
56
|
+
"@earendil-works/pi-ai": "0.86.0",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "0.86.0",
|
|
58
|
+
"@earendil-works/pi-tui": "0.86.0",
|
|
59
59
|
"@types/node": "^24.0.0",
|
|
60
60
|
"@types/turndown": "^5.0.5",
|
|
61
61
|
"typebox": "^1.3.3",
|
package/src/pdf-extract.ts
CHANGED
|
@@ -48,10 +48,6 @@ export function clearPdfExtractCache(): void {
|
|
|
48
48
|
|
|
49
49
|
const _activeFetches = new Set<AbortController>();
|
|
50
50
|
|
|
51
|
-
export function _activeFetchCountForTests(): number {
|
|
52
|
-
return _activeFetches.size;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
51
|
export function _cleanup(): void {
|
|
56
52
|
PDF_CACHE.clear();
|
|
57
53
|
for (const controller of _activeFetches) {
|
|
@@ -342,12 +338,14 @@ Usage notes:
|
|
|
342
338
|
.join("\n\n");
|
|
343
339
|
|
|
344
340
|
const hasText = trimmed.some((text) => text.length > 0);
|
|
341
|
+
const range =
|
|
342
|
+
selected.length > 0 ? { firstPage: selected[0], lastPage: selected[selected.length - 1] } : undefined;
|
|
345
343
|
const baseDetails = {
|
|
346
344
|
source,
|
|
347
345
|
totalPages: loaded.totalPages,
|
|
348
346
|
pageCount: selected.length,
|
|
349
|
-
|
|
350
|
-
|
|
347
|
+
// Empty only for a zero-page PDF; omit the keys rather than storing undefined.
|
|
348
|
+
...(range ?? {}),
|
|
351
349
|
bytes: loaded.bytes,
|
|
352
350
|
durationMs: Date.now() - start,
|
|
353
351
|
cached: loaded.cached,
|
|
@@ -358,7 +356,9 @@ Usage notes:
|
|
|
358
356
|
content: [
|
|
359
357
|
{
|
|
360
358
|
type: "text" as const,
|
|
361
|
-
text: `No extractable text layer found in ${source}
|
|
359
|
+
text: `No extractable text layer found in ${source}${
|
|
360
|
+
range ? ` (pages ${range.firstPage}-${range.lastPage} of ${loaded.totalPages})` : ` (0 of ${loaded.totalPages} page(s))`
|
|
361
|
+
}. The PDF is likely scanned or image-only. This tool does not perform OCR.`,
|
|
362
362
|
},
|
|
363
363
|
],
|
|
364
364
|
details: baseDetails satisfies PdfExtractDetails,
|
|
@@ -367,7 +367,7 @@ Usage notes:
|
|
|
367
367
|
|
|
368
368
|
const offset = Math.max(0, Math.floor(params.offset ?? 0));
|
|
369
369
|
const slice = sliceContent(body, offset);
|
|
370
|
-
const sanitizedText = slice.text.replaceAll(
|
|
370
|
+
const sanitizedText = slice.text.replaceAll(/<\/untrusted-content/gi, "");
|
|
371
371
|
const safeSource = source.replaceAll('"', "%22");
|
|
372
372
|
|
|
373
373
|
return {
|
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
|
+
}
|
package/src/web-browser/index.ts
CHANGED
|
@@ -28,9 +28,11 @@ import {
|
|
|
28
28
|
type SetCookieParam,
|
|
29
29
|
type TabInfo,
|
|
30
30
|
} from "./browser-manager.ts";
|
|
31
|
+
import { buildOriginChecker } from "./egress-policy.ts";
|
|
31
32
|
import { checkUrlPermission, upgradeHttpToHttps, validateURL } from "./permissions.ts";
|
|
32
33
|
import { requestHostPermission } from "../permission-prompt.ts";
|
|
33
34
|
import { listGrants } from "../permission-store.ts";
|
|
35
|
+
import { permissionKey } from "../permissions.ts";
|
|
34
36
|
import { isInsideCwd } from "../pdf-extract.ts";
|
|
35
37
|
import { suppressNotifications } from "../env.ts";
|
|
36
38
|
import { publishBrowserState, registerBrowserFeeds } from "./feeds.ts";
|
|
@@ -134,7 +136,8 @@ function formatTabs(tabs: TabInfo[]): string {
|
|
|
134
136
|
|
|
135
137
|
function wrapUntrustedContent(url: string, text: string): string {
|
|
136
138
|
const safeUrl = url.replaceAll('"', "%22");
|
|
137
|
-
|
|
139
|
+
const safeText = text.replaceAll(/<\/untrusted-content/gi, "");
|
|
140
|
+
return `<untrusted-content url="${safeUrl}">\n${safeText}\n</untrusted-content>`;
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
function isAbortError(err: unknown): boolean {
|
|
@@ -226,10 +229,16 @@ function syncBrowserClosed(ctx: ExtensionContext): void {
|
|
|
226
229
|
|
|
227
230
|
export default function (pi: ExtensionAPI) {
|
|
228
231
|
const sessionPermissions = new Map<string, "allow" | "deny">();
|
|
232
|
+
const originChecker = buildOriginChecker(sessionPermissions);
|
|
233
|
+
const getConfiguredBrowserManager = () => {
|
|
234
|
+
const manager = getBrowserManager();
|
|
235
|
+
manager.setOriginChecker(originChecker);
|
|
236
|
+
return manager;
|
|
237
|
+
};
|
|
229
238
|
registerBrowserFeeds(pi);
|
|
230
239
|
|
|
231
240
|
pi.on("session_start", async (_event, ctx) => {
|
|
232
|
-
const info = await
|
|
241
|
+
const info = await getConfiguredBrowserManager().getPageInfoAsync();
|
|
233
242
|
if (info.isOpen) {
|
|
234
243
|
syncBrowserOpen(ctx, info.url);
|
|
235
244
|
} else {
|
|
@@ -248,16 +257,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
248
257
|
const input = event.input as { action?: string; url?: string };
|
|
249
258
|
|
|
250
259
|
if (input.action === "navigate" && input.url) {
|
|
251
|
-
const
|
|
260
|
+
const upgraded = upgradeHttpToHttps(input.url);
|
|
261
|
+
const permission = await checkUrlPermission(upgraded, ctx, sessionPermissions);
|
|
252
262
|
if (permission !== "allow") {
|
|
253
263
|
return { block: true, reason: blockReason(permission) };
|
|
254
264
|
}
|
|
255
265
|
|
|
266
|
+
// A browser navigation must keep its approved origin available to
|
|
267
|
+
// request-level enforcement after the tool-call hook returns.
|
|
268
|
+
if (!originChecker(upgraded)) {
|
|
269
|
+
sessionPermissions.set(permissionKey(upgraded), "allow");
|
|
270
|
+
}
|
|
256
271
|
return undefined;
|
|
257
272
|
}
|
|
258
273
|
|
|
259
274
|
if (input.action === "evaluate") {
|
|
260
|
-
const info = await
|
|
275
|
+
const info = await getConfiguredBrowserManager().getPageInfoAsync();
|
|
261
276
|
const permission = await checkUrlPermission(info.url, ctx, sessionPermissions);
|
|
262
277
|
if (permission !== "allow") {
|
|
263
278
|
return { block: true, reason: blockReason(permission) };
|
|
@@ -271,7 +286,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
271
286
|
description:
|
|
272
287
|
"Show headless browser status (open/closed, URL, title, console and network counts, session-approved hosts)",
|
|
273
288
|
handler: async (_args, ctx) => {
|
|
274
|
-
const browser =
|
|
289
|
+
const browser = getConfiguredBrowserManager();
|
|
275
290
|
const info = await browser.getPageInfoAsync();
|
|
276
291
|
const lines = [
|
|
277
292
|
"Web Browser:",
|
|
@@ -301,7 +316,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
301
316
|
pi.registerCommand("browser-screenshot", {
|
|
302
317
|
description: "Save a full-page screenshot to a temp PNG file",
|
|
303
318
|
handler: async (_args, ctx) => {
|
|
304
|
-
const browser =
|
|
319
|
+
const browser = getConfiguredBrowserManager();
|
|
305
320
|
const info = await browser.getPageInfoAsync();
|
|
306
321
|
if (!info.isOpen) {
|
|
307
322
|
ctx.ui.notify("Browser is closed — navigate to a page first.", "warning");
|
|
@@ -328,13 +343,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
328
343
|
"On SPA sites, use wait_for after navigate (selector/state or networkidle) before click/type.",
|
|
329
344
|
"Use screenshot for visual verification (requires a vision-capable model); use toFile=true to save PNG to disk instead of base64.",
|
|
330
345
|
"Use get_console_logs when JavaScript errors or console output may explain page behavior.",
|
|
331
|
-
"Use get_network_logs when failed
|
|
346
|
+
"Use get_network_logs when failed, egress-blocked, or 4xx/5xx requests may explain page behavior. Navigate directly to a blocked origin to request approval.",
|
|
332
347
|
"Use set_cookies to inject cookies for authenticated flows after manual login.",
|
|
333
348
|
"Use hover to trigger tooltips/hover-menus before click; press for keyboard key events (Enter, Escape, Tab) — distinct from type mode:press which types characters; select_option to choose <select> dropdown values; set_viewport to resize the page for responsive/mobile checks.",
|
|
334
349
|
"Selectors are CSS selectors, or Playwright's role= and text= selector engines (e.g. role=button[name='Submit'], text=Sign in). If click/type fails, read the returned diagnostics and page snippet and adjust the selector.",
|
|
335
350
|
"Use frame to target an element inside an iframe: pass a CSS selector for the iframe itself, separate from the element selector.",
|
|
336
351
|
"Call set_dialog_behavior before the action that triggers a JS dialog (alert/confirm/prompt) — dialogs are dismissed by default, which would silently cancel a confirm(). Use get_dialog_logs to see captured dialogs.",
|
|
337
|
-
"
|
|
352
|
+
"An approved target=_blank or window.open popup becomes the active tab; an unapproved popup is blocked and closed. Use list_tabs and switch_tab to manage open tabs.",
|
|
338
353
|
"Use scroll to bring an off-screen element into view (selector) or to scroll the page/container by pixel deltas; use drag for drag-and-drop between two selectors; use upload_file to set files on an <input type=file>.",
|
|
339
354
|
],
|
|
340
355
|
description:
|
|
@@ -471,7 +486,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
471
486
|
};
|
|
472
487
|
}
|
|
473
488
|
|
|
474
|
-
const mgr =
|
|
489
|
+
const mgr = getConfiguredBrowserManager();
|
|
475
490
|
|
|
476
491
|
switch (action) {
|
|
477
492
|
case "navigate": {
|
|
@@ -501,7 +516,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
501
516
|
action,
|
|
502
517
|
url: result.url,
|
|
503
518
|
title: result.title,
|
|
504
|
-
|
|
519
|
+
// A same-document navigation has no response; omit the key rather than storing undefined.
|
|
520
|
+
...(result.statusCode !== undefined ? { statusCode: result.statusCode } : {}),
|
|
505
521
|
bytes: Buffer.byteLength(text),
|
|
506
522
|
} satisfies WebBrowserDetails,
|
|
507
523
|
};
|
|
@@ -988,7 +1004,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
988
1004
|
}
|
|
989
1005
|
const message = err instanceof Error ? err.message : String(err);
|
|
990
1006
|
return {
|
|
991
|
-
content: [
|
|
1007
|
+
content: [
|
|
1008
|
+
{ type: "text", text: message.startsWith("Blocked:") ? message : `Browser error: ${message}` },
|
|
1009
|
+
],
|
|
992
1010
|
details: { action, error: message } satisfies WebBrowserDetails,
|
|
993
1011
|
isError: true,
|
|
994
1012
|
};
|