@openlines/opl-pi-sht 0.2.12 → 0.3.1
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/README.md +101 -85
- package/configs/opl-browser.json.sample +3 -2
- package/configs/opl-footer.json.sample +3 -2
- package/configs/opl-guardian.json.sample +8 -2
- package/configs/opl-modes.json.sample +5 -0
- package/configs/opl-webaccess.json.sample +7 -1
- package/extensions/opl-browser/README.md +81 -55
- package/extensions/opl-browser/browser.ts +244 -50
- package/extensions/opl-browser/config.ts +15 -7
- package/extensions/opl-browser/index.ts +5 -4
- package/extensions/opl-browser/package-lock.json +3 -3
- package/extensions/opl-browser/package.json +4 -4
- package/extensions/opl-browser/validate.ts +313 -60
- package/extensions/opl-configurator/README.md +40 -0
- package/extensions/opl-configurator/configurator.ts +151 -0
- package/extensions/opl-configurator/index.ts +12 -0
- package/extensions/opl-ctxtrim/README.md +45 -14
- package/extensions/opl-ctxtrim/index.ts +33 -13
- package/extensions/opl-footer/README.md +31 -6
- package/extensions/opl-footer/config.ts +11 -10
- package/extensions/opl-footer/configure.ts +159 -115
- package/extensions/opl-footer/git-status.ts +58 -1
- package/extensions/opl-footer/icons.ts +3 -0
- package/extensions/opl-footer/index.ts +209 -105
- package/extensions/opl-footer/segments/caveman.ts +9 -1
- package/extensions/opl-footer/segments/chat-mode.ts +8 -1
- package/extensions/opl-footer/segments/compactions.ts +13 -0
- package/extensions/opl-footer/segments/context.ts +6 -5
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
- package/extensions/opl-footer/segments/path.ts +1 -1
- package/extensions/opl-footer/segments/plan-mode.ts +8 -1
- package/extensions/opl-footer/theme.ts +5 -2
- package/extensions/opl-footer/types.ts +4 -0
- package/extensions/opl-guardian/README.md +17 -5
- package/extensions/opl-guardian/config.ts +72 -6
- package/extensions/opl-guardian/guardian.ts +42 -10
- package/extensions/opl-guardian/index.ts +52 -17
- package/extensions/opl-guardian/policies.ts +52 -16
- package/extensions/opl-init/README.md +12 -4
- package/extensions/opl-init/index.ts +228 -27
- package/extensions/opl-input/README.md +17 -3
- package/extensions/opl-input/config.ts +59 -5
- package/extensions/opl-input/configure.ts +146 -0
- package/extensions/opl-input/index.ts +38 -30
- package/extensions/opl-input/mode-style.ts +6 -2
- package/extensions/opl-modes/README.md +22 -8
- package/extensions/opl-modes/config.ts +62 -8
- package/extensions/opl-modes/index.ts +87 -28
- package/extensions/opl-modes/state.ts +50 -2
- package/extensions/opl-modes/types.ts +6 -0
- package/extensions/opl-modes/utils.ts +18 -8
- package/extensions/opl-questionnaire/index.ts +10 -4
- package/extensions/opl-questionnaire/types.ts +4 -0
- package/extensions/opl-simplebench/README.md +21 -15
- package/extensions/opl-simplebench/artifact.ts +7 -4
- package/extensions/opl-simplebench/benchmark.ts +96 -35
- package/extensions/opl-simplebench/coding.ts +9 -4
- package/extensions/opl-simplebench/index.ts +19 -19
- package/extensions/opl-simplebench/llama-server.ts +3 -2
- package/extensions/opl-simplebench/report.ts +0 -6
- package/extensions/opl-simplebench/research.ts +5 -4
- package/extensions/opl-simplebench/scoring.ts +4 -2
- package/extensions/opl-simplebench/util/config.ts +15 -8
- package/extensions/opl-simplebench/util/exec-env.ts +36 -0
- package/extensions/opl-simplebench/util/format.ts +2 -2
- package/extensions/opl-simplebench/util/providers.ts +23 -194
- package/extensions/opl-todo/README.md +5 -5
- package/extensions/opl-todo/config.ts +7 -2
- package/extensions/opl-todo/index.ts +50 -9
- package/extensions/opl-webaccess/README.md +31 -5
- package/extensions/opl-webaccess/config.ts +36 -7
- package/extensions/opl-webaccess/extract.ts +37 -47
- package/extensions/opl-webaccess/http.ts +259 -0
- package/extensions/opl-webaccess/index.ts +12 -6
- package/extensions/opl-webaccess/package-lock.json +3 -3
- package/extensions/opl-webaccess/package.json +4 -9
- package/extensions/opl-webaccess/pdf.ts +4 -4
- package/extensions/opl-webaccess/search.ts +19 -8
- package/extensions/opl-webaccess/storage.ts +134 -2
- package/extensions/opl-webaccess/types.ts +4 -0
- package/extensions/opl-webaccess/utils.ts +221 -33
- package/package.json +19 -12
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
# opl-browser
|
|
2
2
|
|
|
3
|
-
Chromium browser automation for pi via [Playwright](https://playwright.dev). One
|
|
4
|
-
LLM-callable dispatcher tool covers navigation, accessibility snapshots, element
|
|
5
|
-
interaction, console/network capture, script evaluation, and screenshots —
|
|
6
|
-
enough to replace the `chrome-devtools` MCP server without running a separate
|
|
7
|
-
process.
|
|
3
|
+
Chromium browser automation for pi via [Playwright](https://playwright.dev). One LLM-callable dispatcher tool covers navigation, accessibility snapshots, element interaction, console/network capture, script evaluation, and screenshots — enough to replace the `chrome-devtools` MCP server without running a separate process.
|
|
8
4
|
|
|
9
5
|
## Commands, flags, and shortcuts
|
|
10
6
|
|
|
11
|
-
No slash commands. The extension registers a single tool, `browser`, chosen by an
|
|
12
|
-
`action` parameter:
|
|
7
|
+
No slash commands. The extension registers a single tool, `browser`, chosen by an `action` parameter:
|
|
13
8
|
|
|
14
9
|
```ts
|
|
15
10
|
browser({ action: "navigate", url: "https://example.com" })
|
|
@@ -26,46 +21,56 @@ browser({ action: "get", responseId: "br-..." }) // retrieve a stored large re
|
|
|
26
21
|
browser({ action: "close" })
|
|
27
22
|
```
|
|
28
23
|
|
|
29
|
-
Full action set: `navigate` (url, or `back`/`forward`/`reload`), `snapshot`,
|
|
30
|
-
`extract`, `screenshot`, `click`, `fill`, `hover`, `press`, `select`, `evaluate`,
|
|
31
|
-
`console`, `network`, `wait_for`, `pages`, `new_page`, `select_page`, `close_page`,
|
|
32
|
-
`resize`, `get`, `close`.
|
|
24
|
+
Full action set: `navigate` (url, or `back`/`forward`/`reload`), `snapshot`, `extract`, `screenshot`, `click`, `fill`, `hover`, `press`, `select`, `evaluate`, `console`, `network`, `wait_for`, `pages`, `new_page`, `select_page`, `close_page`, `resize`, `get`, `close`.
|
|
33
25
|
|
|
34
26
|
## Extension features
|
|
35
27
|
|
|
36
|
-
- **Single dispatcher tool.** One compact schema in the system prompt instead of
|
|
37
|
-
|
|
38
|
-
- **
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
- **
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- **
|
|
52
|
-
|
|
53
|
-
- **
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
28
|
+
- **Single dispatcher tool.** One compact schema in the system prompt instead of ~29 per-tool schemas. Keeps the cached prefix small.
|
|
29
|
+
- **Handle + preview output.** Large results (snapshots, console/network logs, evaluate output) are kept out of context: the tool returns a truncated preview plus a `responseId`; call `action: "get"` with that id to page through the full text (`getChars` per page, pass `offset` to continue). Screenshots are written to a file, never inlined as base64.
|
|
30
|
+
- **Structured extraction.** `extract` runs Readability + Turndown over the *rendered* (post-JS) DOM — the complement to `opl-webaccess`'s `fetch_content`, which only sees raw HTTP HTML. Static pages: `fetch_content`; rendered or interacted-with pages: `browser:extract`. With a `selector`, the matched element is converted verbatim (no article detection): Readability's candidate scoring is a whole-document heuristic and mispicks inside small subtrees. A selector that matches multiple elements returns a clear error instead of a strict-mode crash, and a selector with no match reports `(no elements match ...)`.
|
|
31
|
+
- **Real Chromium via Playwright.** Navigation with `domcontentloaded` waits, CSS-selector interaction, viewport control, multi-page management.
|
|
32
|
+
- **Scheme + host guards, applied to the whole page.** `navigate`/`new_page` accept http(s)
|
|
33
|
+
only; `file:`, `data:`, and `javascript:` are rejected. The policy is judged on **resolved
|
|
34
|
+
addresses**, not on the URL text, and enforced wherever a page can reach the network:
|
|
35
|
+
- **Navigation** — the requested URL, plus every live frame URL after the load.
|
|
36
|
+
- **Page requests** — a context route blocks subresources, script-driven fetches, JS/meta
|
|
37
|
+
redirects and popups (`net::ERR_BLOCKED_BY_CLIENT`), not only the URL the tool was given.
|
|
38
|
+
- **Page WebSockets** — `ws://`/`wss://` handshakes are classified as if they were
|
|
39
|
+
`http://`/`https://` and closed before they reach the target.
|
|
40
|
+
- **Redirect landings** — Playwright sees only the first URL of a redirect chain, so a page or
|
|
41
|
+
iframe that a redirect lands on a blocked host is sent back to `about:blank` before any
|
|
42
|
+
action can read it.
|
|
43
|
+
- **Service workers** — blocked on new contexts: routing does not see requests a worker has
|
|
44
|
+
already intercepted.
|
|
45
|
+
- **Toggles** — `allowPrivateNetwork` (default `false`) governs private/link-local/reserved,
|
|
46
|
+
`allowLoopback` (default `false`) governs `localhost`, `127.0.0.0/8` and `::1` — set it to `true`
|
|
47
|
+
when the browser must reach a local dev server. Cloud metadata and unspecified addresses
|
|
48
|
+
(`0.0.0.0/8`, `::`) are refused whatever they say.
|
|
49
|
+
- **Fail closed** — if a build cannot install either handler, the tool refuses to browse
|
|
50
|
+
instead of browsing half-guarded.
|
|
51
|
+
|
|
52
|
+
See [Network policy](#network-policy) for what this still cannot cover: DNS rebinding (the
|
|
53
|
+
`opl-webaccess` fetch path pins its socket; browsing cannot), blind redirect side effects, and
|
|
54
|
+
`evaluate` returning page-held data.
|
|
55
|
+
- **Screenshots are contained and reserved.** The target must use `.png`/`.jpg` and stay inside
|
|
56
|
+
the **session directory** (`ExtensionContext.cwd`, never the directory the harness was started
|
|
57
|
+
in). Containment is checked on the real path: every symlink in the existing part of the path is
|
|
58
|
+
followed first, so a `shots -> ~/.pi` entry inside the project cannot carry the write out of it,
|
|
59
|
+
and a symlink standing at the target is refused like any other existing file instead of being
|
|
60
|
+
followed onto whatever it points at. Then the file is created with `wx`, so "refuse to overwrite"
|
|
61
|
+
and "hand this path to Playwright" are one atomic operation rather than a check-then-write race.
|
|
62
|
+
The action returns the resolved absolute path, and a capture that throws leaves no empty file
|
|
63
|
+
behind.
|
|
64
|
+
- **Pages are addressed, not guessed.** Any page-scoped action accepts `index`; without it the
|
|
65
|
+
selected page is used. A stale selection is an error that names the page count, never a silent
|
|
66
|
+
retarget to the last page - so `close_page` cannot make a later `screenshot` or `navigate` act on
|
|
67
|
+
a different document than the model meant. `new_page` selects the page it actually created, and
|
|
68
|
+
`close_page` keeps the same page selected when what closed was a different one (stepping to the
|
|
69
|
+
nearest survivor when the selected page itself closes).
|
|
70
|
+
- **Per-page capture.** Console messages and network requests are buffered per page as they occur; `console` and `network` actions return the target page's buffer, capped at the most recent `200` entries per page so an active long-running page cannot grow the buffer without bound. `navigate: back`/ `forward` on a fresh session reports `(no history to go back/forward)` instead of silently returning the unchanged page.
|
|
71
|
+
- **One reused browser per session.** Launched on first use, closed automatically on `session_shutdown`, or on demand via `action: "close"`.
|
|
72
|
+
|
|
73
|
+
Selection follows the page object, so closing an earlier tab preserves the selected document. If the selected page closes externally (including `window.close()`), page-scoped actions without `index` fail until `select_page` or `new_page` establishes a selection; an explicit `index` targets the current live list without changing selection. Every action binds one page before validation and cannot switch documents if that page closes mid-action. Deliberate `close_page` keeps another selected page or selects the nearest survivor when closing the selection.
|
|
69
74
|
|
|
70
75
|
## Configuration
|
|
71
76
|
|
|
@@ -78,12 +83,35 @@ Optional `~/.pi/agent/configs/opl-browser.json` (see `opl-browser.json.sample`):
|
|
|
78
83
|
| `navigationTimeoutMs` | `30000` | Default navigation and `wait_for` timeout. |
|
|
79
84
|
| `previewChars` | `4000` | Inline threshold; larger outputs are stored and previewed. |
|
|
80
85
|
| `getChars` | `30000` | Characters returned by one `action: "get"` page. |
|
|
81
|
-
| `allowPrivateNetwork` | `false` | Allow `navigate`/`new_page` to reach private/link-local ranges
|
|
86
|
+
| `allowPrivateNetwork` | `false` | Allow `navigate`/`new_page` and page requests to reach private/link-local ranges, literal or resolved (cloud metadata and unspecified addresses are always blocked). |
|
|
87
|
+
| `allowLoopback` | `false` | Allow loopback — `localhost`, `127.0.0.0/8`, `::1`, literal or resolved — for navigations, page requests and page WebSockets. Set `true` when the browser must reach a local dev server; `0.0.0.0/8` and `::` stay blocked either way, because they reach local listeners too. |
|
|
88
|
+
|
|
89
|
+
### Network policy
|
|
90
|
+
|
|
91
|
+
The guard runs at three points, and each one answers a different question:
|
|
92
|
+
|
|
93
|
+
| Point | What it covers | Behaviour on a blocked host |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| Navigation (`navigate`, `new_page`) | the URL the model asked for, plus every live frame URL after the load | refuses before connecting; clears the page to `about:blank` if a redirect already landed it on a blocked host |
|
|
96
|
+
| Context route | every request the page itself makes: subresources, script fetches, JS/meta redirects, popups | `net::ERR_BLOCKED_BY_CLIENT` |
|
|
97
|
+
| WebSocket route | `ws://` / `wss://` handshakes from page code, classified as if they were `http://` / `https://` | socket closed before it reaches the target |
|
|
98
|
+
|
|
99
|
+
Hosts are classified after resolution, not from the URL text, so `http://169.254.169.254.nip.io/` is refused the same way the literal is. Cloud-metadata and unspecified addresses (`0.0.0.0/8`, `::`) are refused whatever the toggles say, because reaching them means reaching local listeners or instance credentials. IPv6 literals that carry an IPv4 inside them — NAT64 (`64:ff9b::/96`), 6to4 (`2002::/16`), mapped (`::ffff:`) and the deprecated IPv4-compatible form — are classified by the embedded address, so `[64:ff9b::a9fe:a9fe]` cannot stand in for the metadata endpoint; a DNS64 answer for a real public site still passes. Service workers are blocked: Playwright routing does not see requests a worker has already intercepted, so a worker would be a hole rather than a client.
|
|
100
|
+
|
|
101
|
+
Compressed and fully expanded IPv6 forms share the same policy, including DNS answers. The entire local-use translation prefix `64:ff9b:1::/48` is always blocked, regardless of `allowPrivateNetwork` or `allowLoopback`; this also blocks translations of public IPv4 addresses within that prefix. Public translations under the well-known `64:ff9b::/96` prefix remain permitted.
|
|
102
|
+
|
|
103
|
+
What is **not** covered, and the reason:
|
|
104
|
+
|
|
105
|
+
- **DNS rebinding.** Validation resolves, then Playwright connects with its own lookup. An attacker who runs DNS for the hostname (TTL 0, alternating answers) can still land on an internal address. Closing this needs a filtering proxy the browser connects through: Chromium resolves names internally and no Playwright API exposes a per-connection resolver, so the pin `opl-webaccess` uses for `fetch_content` is not available here. It is a documented residual rather than a bug: the defaults (`allowPrivateNetwork: false`, `allowLoopback: false`) are what narrows what a rebound name can reach.
|
|
106
|
+
- **Blind side effects through redirects.** Playwright hands a route handler only the first URL of a redirect chain, and `<img>` or no-cors `fetch` results are not readable anyway, so those legs are not re-checked. The page-level result *is* cleared by the frame check above.
|
|
107
|
+
- **`ENOTFOUND` for page requests.** A hostname the resolver does not know is let through, because hosts a page references willy-nilly (telemetry that is blocked in the hosts file, ad domains) would otherwise break every page. Navigations and `opl-webaccess` fetches fail closed on the same error; only page-initiated subresources and WebSockets fail open, and only for that one code.
|
|
108
|
+
- **`evaluate` returns whatever the page holds.** That is the feature: a page that can read an internal endpoint from its own origin can also hand it to the model.
|
|
109
|
+
|
|
110
|
+
Loopback is opt-in. `allowLoopback: true` reopens `localhost`, `127.0.0.0/8` and `::1` for local development; left at the default, a model cannot drive a dev server, a Docker socket proxy or an IDaaS callback on your machine through the browser.
|
|
82
111
|
|
|
83
112
|
### Dependencies
|
|
84
113
|
|
|
85
|
-
Playwright is a real third-party dependency, so install it in the extension
|
|
86
|
-
directory and download the Chromium binary once:
|
|
114
|
+
Playwright is a real third-party dependency, so install it in the extension directory and download the Chromium binary once:
|
|
87
115
|
|
|
88
116
|
```bash
|
|
89
117
|
cd extensions/opl-browser
|
|
@@ -99,17 +127,15 @@ index.ts Pi wiring: registers the single `browser` tool, TTL result store,
|
|
|
99
127
|
browser.ts Playwright driver: browser/context/page lifecycle, per-page console
|
|
100
128
|
and network buffers, and the action switch. Tool calls are serialized
|
|
101
129
|
on the single shared context.
|
|
102
|
-
validate.ts URL/path guards: http/https-only navigation
|
|
103
|
-
|
|
130
|
+
validate.ts URL/path guards: http/https-only navigation with resolved-address host
|
|
131
|
+
policy, page-request route decision, screenshot path confined
|
|
132
|
+
to the project directory. The host guard region is mirrored verbatim from
|
|
133
|
+
`opl-webaccess/utils.ts` (extensions install per directory, so it cannot be
|
|
134
|
+
a shared import); `tests/net-guard-parity.test.mjs` fails if the two drift.
|
|
104
135
|
paging.ts Bounded `get` pagination and continuation metadata.
|
|
105
136
|
extract.ts Pure rendered-HTML → markdown pipeline (linkedom + Readability +
|
|
106
137
|
turndown), duplicated from opl-webaccess to keep installs independent.
|
|
107
138
|
config.ts DEFAULT_CONFIG + loadUserConfig (user overrides win via ??).
|
|
108
139
|
```
|
|
109
140
|
|
|
110
|
-
Interaction is CSS-selector based. Snapshot-uid interaction (referencing elements
|
|
111
|
-
by ids returned from `snapshot`) is intentionally not implemented; use CSS
|
|
112
|
-
selectors, which are simpler and robust. The in-memory result store expires
|
|
113
|
-
entries after one hour or when the browser is closed; expiry is checked on read,
|
|
114
|
-
not only when another result is stored, so an idle entry past its TTL is never
|
|
115
|
-
served.
|
|
141
|
+
Interaction is CSS-selector based. Snapshot-uid interaction (referencing elements by ids returned from `snapshot`) is intentionally not implemented; use CSS selectors, which are simpler and robust. The in-memory result store expires entries after one hour or when the browser is closed; expiry is checked on read, not only when another result is stored, so an idle entry past its TTL is never served.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { chromium, type Browser, type BrowserContext, type Page, type ConsoleMessage } from "playwright";
|
|
1
|
+
import { chromium, type Browser, type BrowserContext, type Page, type Route, type ConsoleMessage } from "playwright";
|
|
2
2
|
import { extractMarkdown } from "./extract.js";
|
|
3
|
-
import {
|
|
3
|
+
import { assertSafeHttpUrl, decideSubresource, discardEmptyFile, safeScreenshotPath, type HttpUrlOptions } from "./validate.js";
|
|
4
4
|
import type { BrowserConfig } from "./config.js";
|
|
5
5
|
|
|
6
6
|
// ponytail: single module-level Chromium instance reused across tool calls.
|
|
7
7
|
// One browser per session is enough; add contexts only if parallel isolation matters.
|
|
8
8
|
let browser: Browser | null = null;
|
|
9
9
|
let context: BrowserContext | null = null;
|
|
10
|
-
let
|
|
10
|
+
let selectedPage: Page | null = null;
|
|
11
11
|
|
|
12
12
|
const consoleBuf = new WeakMap<Page, string[]>();
|
|
13
13
|
const networkBuf = new WeakMap<Page, string[]>();
|
|
@@ -48,26 +48,191 @@ function track(page: Page, cfg: BrowserConfig): void {
|
|
|
48
48
|
async function ensure(cfg: BrowserConfig): Promise<BrowserContext> {
|
|
49
49
|
if (context) return context;
|
|
50
50
|
browser = await chromium.launch({ headless: cfg.headless });
|
|
51
|
-
|
|
51
|
+
const created = await browser.newContext({
|
|
52
|
+
viewport: { width: cfg.width, height: cfg.height },
|
|
53
|
+
// Playwright documents that routing does not see requests a service worker already
|
|
54
|
+
// intercepted, so a worker would be a hole in the guard rather than another client of it.
|
|
55
|
+
serviceWorkers: "block",
|
|
56
|
+
});
|
|
57
|
+
// Install the guards before publishing the module-level context: if registration fails, the tool
|
|
58
|
+
// must not keep browsing through a context whose host guard is missing or only half installed.
|
|
59
|
+
try {
|
|
60
|
+
const wsGuarded = await installGuard(created, guardOf(cfg));
|
|
61
|
+
if (!wsGuarded) console.warn("[opl-browser] this build cannot intercept page WebSockets, so ws:// and wss:// from the page are unguarded");
|
|
62
|
+
} catch (err) {
|
|
63
|
+
await created.close().catch(() => {});
|
|
64
|
+
await browser.close().catch(() => {});
|
|
65
|
+
browser = null;
|
|
66
|
+
context = null;
|
|
67
|
+
throw new Error(`cannot enforce the network host guard in this browser build: ${err instanceof Error ? err.message : err}`);
|
|
68
|
+
}
|
|
69
|
+
context = created;
|
|
52
70
|
const page = await context.newPage();
|
|
53
71
|
track(page, cfg);
|
|
54
|
-
|
|
72
|
+
selectedPage = page;
|
|
55
73
|
return context;
|
|
56
74
|
}
|
|
57
75
|
|
|
58
|
-
|
|
76
|
+
/** Which page an action acts on. A stale or out-of-range selection is an error, never a silent
|
|
77
|
+
* retarget: `context.pages()` is creation order, so the index a previous `pages` call showed is
|
|
78
|
+
* the page the model means - unless something closed it, which is exactly when guessing the last
|
|
79
|
+
* page would navigate or screenshot the wrong document. */
|
|
80
|
+
export function resolvePageIndex(active: number, count: number, requested?: number): number {
|
|
81
|
+
if (count === 0) throw new Error("no open pages");
|
|
82
|
+
const i = requested ?? active;
|
|
83
|
+
if (!Number.isInteger(i) || i < 0 || i >= count) {
|
|
84
|
+
throw new Error(`no page at index ${i} (${count} page${count === 1 ? "" : "s"} open; use \`pages\` to list them)`);
|
|
85
|
+
}
|
|
86
|
+
return i;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The selection after closing `closed` from `count` pages: keep the same page when the closed one
|
|
90
|
+
* was before it (indices shift down), otherwise fall to the nearest surviving page. */
|
|
91
|
+
export function indexAfterClose(active: number, closed: number, count: number): number {
|
|
92
|
+
const remaining = count - 1;
|
|
93
|
+
if (remaining <= 0) return 0;
|
|
94
|
+
if (closed < active) return active - 1;
|
|
95
|
+
if (closed === active) return Math.min(active, remaining - 1);
|
|
96
|
+
return active;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The page for the current action: an explicit `index` argument wins, otherwise the selection. */
|
|
100
|
+
function pageAt(requested?: number): Page {
|
|
59
101
|
const pages = context!.pages();
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
const active = selectedPage ? pages.indexOf(selectedPage) : -1;
|
|
103
|
+
if (requested === undefined && active < 0 && pages.length > 0) {
|
|
104
|
+
throw new Error("selected page closed; use `select_page` or `new_page` to select another document");
|
|
105
|
+
}
|
|
106
|
+
return pages[resolvePageIndex(active, pages.length, requested)]!;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Budget for one DNS round-trip. Playwright has no timeout of its own on a route handler, so a
|
|
110
|
+
* stalled getaddrinfo would hold the navigation open; the guard cuts it instead. */
|
|
111
|
+
const DNS_BUDGET_MS = 5000;
|
|
112
|
+
|
|
113
|
+
/** The host policy applied to every navigation, frame re-check, page request and WebSocket. */
|
|
114
|
+
export function guardOf(cfg: BrowserConfig): HttpUrlOptions {
|
|
115
|
+
return {
|
|
116
|
+
allowPrivateNetwork: cfg.allowPrivateNetwork,
|
|
117
|
+
allowLoopback: cfg.allowLoopback,
|
|
118
|
+
dnsTimeoutMs: DNS_BUDGET_MS,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Route handler for the shared context: covers subresources, JS-driven redirects and
|
|
123
|
+
* popups, i.e. the requests a page makes that no tool argument ever named. Only http(s)
|
|
124
|
+
* is consulted — `data:`/`blob:` have no host to classify and cannot be re-issued. */
|
|
125
|
+
export function makeRouteHandler(guard: HttpUrlOptions): (route: Route) => Promise<void> {
|
|
126
|
+
return async (route) => {
|
|
127
|
+
const url = route.request().url();
|
|
128
|
+
if (url.startsWith("data:") || url.startsWith("blob:")) return;
|
|
129
|
+
const decision = await decideSubresource(url, guard);
|
|
130
|
+
if (decision === "abort") {
|
|
131
|
+
await route.abort("blockedbyclient");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await route.continue();
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Install both halves of the network guard on a context: http(s) requests and WebSocket
|
|
139
|
+
* handshakes. Returns false when this Playwright build cannot intercept WebSockets, which is
|
|
140
|
+
* reported rather than hidden: `route()` does not see `ws://`/`wss://`, so a page could otherwise
|
|
141
|
+
* open a socket straight to an internal service while the tool claims the boundary is enforced. */
|
|
142
|
+
export interface GuardTarget {
|
|
143
|
+
// `unknown` rather than `void`: Playwright's route registration hands back a Disposable, and a
|
|
144
|
+
// BrowserContext returns that where a Page returns void, so `void` would exclude contexts.
|
|
145
|
+
route(url: string, handler: (route: Route) => Promise<void>): Promise<unknown>;
|
|
146
|
+
routeWebSocket?(match: (url: URL) => boolean, handler: (ws: WebSocketRouteLike) => Promise<void>): Promise<unknown>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface WebSocketRouteLike {
|
|
150
|
+
url(): string;
|
|
151
|
+
close(): Promise<void>;
|
|
152
|
+
connectToServer(): unknown;
|
|
62
153
|
}
|
|
63
154
|
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
155
|
+
/** WebSockets are classified with the http(s) policy: `ws:` counts as `http:`, `wss:` as `https:`.
|
|
156
|
+
* Anything the guard refuses, and anything it cannot classify, is closed. */
|
|
157
|
+
export function makeWebSocketGuard(guard: HttpUrlOptions): (ws: WebSocketRouteLike) => Promise<void> {
|
|
158
|
+
return async (ws) => {
|
|
159
|
+
const asHttp = ws.url().replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
160
|
+
let decision: "abort" | "continue";
|
|
161
|
+
try {
|
|
162
|
+
decision = await decideSubresource(asHttp, guard);
|
|
163
|
+
} catch {
|
|
164
|
+
decision = "abort";
|
|
165
|
+
}
|
|
166
|
+
if (decision === "abort") {
|
|
167
|
+
await ws.close().catch(() => {});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
ws.connectToServer();
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function installGuard(ctx: GuardTarget, guard: HttpUrlOptions): Promise<boolean> {
|
|
175
|
+
await ctx.route("**/*", makeRouteHandler(guard));
|
|
176
|
+
if (typeof ctx.routeWebSocket !== "function") return false;
|
|
177
|
+
await ctx.routeWebSocket(() => true, makeWebSocketGuard(guard));
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Navigate with the host validated both before the request and on the live frames after it,
|
|
182
|
+
* so a public URL that redirects to a private/link-local host, or a hostname that answers
|
|
183
|
+
* with an internal address, is rejected and the landed page is cleared. */
|
|
66
184
|
async function gotoAllowed(target: Page, url: string, cfg: BrowserConfig): Promise<void> {
|
|
67
|
-
|
|
68
|
-
|
|
185
|
+
const guard = guardOf(cfg);
|
|
186
|
+
await target.goto(await assertSafeHttpUrl(url, guard), { waitUntil: "domcontentloaded" });
|
|
187
|
+
await assertFrameTargetsSafe(target, guard);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Anything `pageAt()` can hand back: its live frames plus a way to clear them. */
|
|
191
|
+
export interface FrameSafeTarget {
|
|
192
|
+
frames(): Array<{ url(): string }>;
|
|
193
|
+
goto(url: string, options?: { timeout?: number }): Promise<unknown>;
|
|
69
194
|
}
|
|
70
195
|
|
|
196
|
+
/** Playwright calls a route handler for the FIRST url of a redirect chain only, so a server-side
|
|
197
|
+
* redirect can land a page or iframe on a host the guard already rejected and the route never
|
|
198
|
+
* sees the second leg. Every read therefore re-checks the live frame URLs and clears the page
|
|
199
|
+
* before its content can be returned. */
|
|
200
|
+
export async function assertFrameTargetsSafe(target: FrameSafeTarget, guard: HttpUrlOptions): Promise<void> {
|
|
201
|
+
let failure: Error | undefined;
|
|
202
|
+
for (const frame of target.frames()) {
|
|
203
|
+
const url = frame.url();
|
|
204
|
+
if (!/^https?:/i.test(url)) continue; // about:blank, data:, blob:, empty: no host to classify
|
|
205
|
+
try {
|
|
206
|
+
await assertSafeHttpUrl(url, guard);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
failure = err instanceof Error ? err : new Error(String(err));
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (failure) {
|
|
213
|
+
await target.goto("about:blank", { timeout: 5000 }).catch(() => {});
|
|
214
|
+
throw new Error(`${failure.message} (page cleared)`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Actions whose result is derived from, or acts upon, a page that is already open. */
|
|
219
|
+
const PAGE_BOUND_ACTIONS = new Set([
|
|
220
|
+
"navigate",
|
|
221
|
+
"snapshot",
|
|
222
|
+
"extract",
|
|
223
|
+
"screenshot",
|
|
224
|
+
"click",
|
|
225
|
+
"fill",
|
|
226
|
+
"hover",
|
|
227
|
+
"press",
|
|
228
|
+
"select",
|
|
229
|
+
"evaluate",
|
|
230
|
+
"console",
|
|
231
|
+
"network",
|
|
232
|
+
"wait_for",
|
|
233
|
+
"resize",
|
|
234
|
+
]);
|
|
235
|
+
|
|
71
236
|
export interface BrowserActionResult {
|
|
72
237
|
text: string;
|
|
73
238
|
file?: string;
|
|
@@ -91,7 +256,7 @@ export interface BrowserParams {
|
|
|
91
256
|
|
|
92
257
|
let last: Promise<unknown> = Promise.resolve();
|
|
93
258
|
/** Serialize state-mutating browser work: a single shared context cannot service
|
|
94
|
-
* concurrent tool calls safely (
|
|
259
|
+
* concurrent tool calls safely (selection, pageAt(), ensure() all race). Each new
|
|
95
260
|
* action is queued behind the previous one, and failures never break the chain. */
|
|
96
261
|
function serialize<T>(task: () => Promise<T>): Promise<T> {
|
|
97
262
|
const run = last.then(task, task);
|
|
@@ -99,17 +264,29 @@ function serialize<T>(task: () => Promise<T>): Promise<T> {
|
|
|
99
264
|
return run;
|
|
100
265
|
}
|
|
101
266
|
|
|
102
|
-
|
|
103
|
-
|
|
267
|
+
/** `cwd` is the session directory from `ExtensionContext`, never the harness working
|
|
268
|
+
* directory: a screenshot
|
|
269
|
+
* name that arrives from the model has to be contained against the project the user is in, and
|
|
270
|
+
* the harness working directory is not that directory. */
|
|
271
|
+
export function runAction(p: BrowserParams, cfg: BrowserConfig, cwd: string): Promise<BrowserActionResult> {
|
|
272
|
+
return serialize(() => runActionInternal(p, cfg, cwd));
|
|
104
273
|
}
|
|
105
274
|
|
|
106
|
-
async function runActionInternal(p: BrowserParams, cfg: BrowserConfig): Promise<BrowserActionResult> {
|
|
275
|
+
async function runActionInternal(p: BrowserParams, cfg: BrowserConfig, cwd: string): Promise<BrowserActionResult> {
|
|
107
276
|
if (p.action === "close") {
|
|
108
277
|
await closeBrowserInternal();
|
|
109
278
|
return { text: "Browser closed." };
|
|
110
279
|
}
|
|
111
280
|
|
|
112
281
|
const ctx = await ensure(cfg);
|
|
282
|
+
const boundPage = PAGE_BOUND_ACTIONS.has(p.action) ? pageAt(p.index) : null;
|
|
283
|
+
const target = () => {
|
|
284
|
+
if (!boundPage || boundPage.isClosed()) throw new Error("target page closed; select another document before retrying");
|
|
285
|
+
return boundPage;
|
|
286
|
+
};
|
|
287
|
+
// A page can only have been landed on a blocked host through a path the route handler never
|
|
288
|
+
// saw (redirect chain, history, script). Re-check before touching or reading it.
|
|
289
|
+
if (PAGE_BOUND_ACTIONS.has(p.action)) await assertFrameTargetsSafe(target(), guardOf(cfg));
|
|
113
290
|
|
|
114
291
|
switch (p.action) {
|
|
115
292
|
case "navigate": {
|
|
@@ -117,24 +294,24 @@ async function runActionInternal(p: BrowserParams, cfg: BrowserConfig): Promise<
|
|
|
117
294
|
if (url === "back" || url === "forward") {
|
|
118
295
|
// Playwright returns null (no throw) when history is exhausted; report it
|
|
119
296
|
// clearly instead of silently returning the unchanged page.
|
|
120
|
-
const moved = url === "back" ? await
|
|
297
|
+
const moved = url === "back" ? await target().goBack() : await target().goForward();
|
|
121
298
|
if (!moved) return { text: `(no history to go ${url})` };
|
|
122
|
-
return { text: `${
|
|
299
|
+
return { text: `${target().url()} — ${await target().title()}` };
|
|
123
300
|
}
|
|
124
|
-
if (url === "reload") await
|
|
125
|
-
else if (url) await gotoAllowed(
|
|
301
|
+
if (url === "reload") await target().reload();
|
|
302
|
+
else if (url) await gotoAllowed(target(), url, cfg);
|
|
126
303
|
else throw new Error("navigate requires url (or back|forward|reload)");
|
|
127
|
-
return { text: `${
|
|
304
|
+
return { text: `${target().url()} — ${await target().title()}` };
|
|
128
305
|
}
|
|
129
306
|
case "snapshot": {
|
|
130
|
-
const tree = await
|
|
307
|
+
const tree = await target().locator("body").ariaSnapshot();
|
|
131
308
|
return { text: tree || "(empty snapshot)" };
|
|
132
309
|
}
|
|
133
310
|
case "extract": {
|
|
134
311
|
const selector = p.selector;
|
|
135
312
|
let html: string;
|
|
136
313
|
if (selector) {
|
|
137
|
-
const loc =
|
|
314
|
+
const loc = target().locator(selector);
|
|
138
315
|
const count = await loc.count();
|
|
139
316
|
if (count === 0) return { text: `(no elements match "${selector}")` };
|
|
140
317
|
if (count > 1) {
|
|
@@ -142,92 +319,109 @@ async function runActionInternal(p: BrowserParams, cfg: BrowserConfig): Promise<
|
|
|
142
319
|
}
|
|
143
320
|
html = await loc.evaluate((el: Element) => el.outerHTML);
|
|
144
321
|
} else {
|
|
145
|
-
html = await
|
|
322
|
+
html = await target().content();
|
|
146
323
|
}
|
|
147
324
|
const { markdown } = extractMarkdown(html, { raw: Boolean(selector) });
|
|
148
325
|
return { text: markdown || "(empty extraction)" };
|
|
149
326
|
}
|
|
150
327
|
case "screenshot": {
|
|
151
|
-
const file = safeScreenshotPath(p.path ?? `opl-browser-${Date.now()}.png
|
|
152
|
-
|
|
328
|
+
const file = safeScreenshotPath(p.path ?? `opl-browser-${Date.now()}.png`, cwd);
|
|
329
|
+
try {
|
|
330
|
+
await target().screenshot({ path: file, fullPage: p.fullPage ?? false });
|
|
331
|
+
} catch (err) {
|
|
332
|
+
// The reservation above created an empty file; a capture that threw must not leave it.
|
|
333
|
+
discardEmptyFile(file);
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
153
336
|
return { text: `Screenshot saved to ${file}`, file };
|
|
154
337
|
}
|
|
155
338
|
case "click": {
|
|
156
339
|
if (!p.selector) throw new Error("click requires selector");
|
|
157
|
-
await
|
|
340
|
+
await target().click(p.selector);
|
|
158
341
|
return { text: `Clicked ${p.selector}` };
|
|
159
342
|
}
|
|
160
343
|
case "fill": {
|
|
161
344
|
if (!p.selector || p.text == null) throw new Error("fill requires selector and text");
|
|
162
|
-
await
|
|
345
|
+
await target().fill(p.selector, p.text);
|
|
163
346
|
return { text: `Filled ${p.selector}` };
|
|
164
347
|
}
|
|
165
348
|
case "hover": {
|
|
166
349
|
if (!p.selector) throw new Error("hover requires selector");
|
|
167
|
-
await
|
|
350
|
+
await target().hover(p.selector);
|
|
168
351
|
return { text: `Hovered ${p.selector}` };
|
|
169
352
|
}
|
|
170
353
|
case "press": {
|
|
171
354
|
if (!p.key) throw new Error("press requires key");
|
|
172
|
-
await
|
|
355
|
+
await target().keyboard.press(p.key);
|
|
173
356
|
return { text: `Pressed ${p.key}` };
|
|
174
357
|
}
|
|
175
358
|
case "select": {
|
|
176
359
|
if (!p.selector || !p.values?.length) throw new Error("select requires selector and values");
|
|
177
|
-
const picked = await
|
|
360
|
+
const picked = await target().selectOption(p.selector, p.values);
|
|
178
361
|
return { text: `Selected ${picked.join(", ")} in ${p.selector}` };
|
|
179
362
|
}
|
|
180
363
|
case "evaluate": {
|
|
181
364
|
if (!p.script) throw new Error("evaluate requires script");
|
|
182
|
-
const value = await
|
|
365
|
+
const value = await target().evaluate(p.script);
|
|
183
366
|
if (value === undefined) return { text: "(undefined result)" };
|
|
184
367
|
return { text: typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "(undefined result)") };
|
|
185
368
|
}
|
|
186
369
|
case "console": {
|
|
187
|
-
const msgs = consoleBuf.get(
|
|
370
|
+
const msgs = consoleBuf.get(target()) ?? [];
|
|
188
371
|
const suffix = msgs.length >= MAX_LOG_ENTRIES ? `\n… (showing last ${MAX_LOG_ENTRIES} entries)` : "";
|
|
189
372
|
return { text: msgs.length ? msgs.join("\n") + suffix : "(no console messages)" };
|
|
190
373
|
}
|
|
191
374
|
case "network": {
|
|
192
|
-
const reqs = networkBuf.get(
|
|
375
|
+
const reqs = networkBuf.get(target()) ?? [];
|
|
193
376
|
const suffix = reqs.length >= MAX_LOG_ENTRIES ? `\n… (showing last ${MAX_LOG_ENTRIES} requests)` : "";
|
|
194
377
|
return { text: reqs.length ? reqs.join("\n") + suffix : "(no network requests)" };
|
|
195
378
|
}
|
|
196
379
|
case "wait_for": {
|
|
197
380
|
const timeout = p.timeoutMs ?? cfg.navigationTimeoutMs;
|
|
198
|
-
if (p.selector) await
|
|
199
|
-
else if (p.text) await
|
|
381
|
+
if (p.selector) await target().waitForSelector(p.selector, { timeout });
|
|
382
|
+
else if (p.text) await target().getByText(p.text).first().waitFor({ timeout });
|
|
200
383
|
else throw new Error("wait_for requires selector or text");
|
|
201
384
|
return { text: `Condition met` };
|
|
202
385
|
}
|
|
203
386
|
case "pages": {
|
|
204
|
-
const list = ctx.pages().map((pg, i) => `${
|
|
387
|
+
const list = ctx.pages().map((pg, i) => `${pg === selectedPage ? "*" : " "} [${i}] ${pg.url()}`);
|
|
205
388
|
return { text: list.join("\n") || "(no pages)" };
|
|
206
389
|
}
|
|
207
390
|
case "new_page": {
|
|
208
391
|
const pg = await ctx.newPage();
|
|
209
392
|
track(pg, cfg);
|
|
210
|
-
|
|
393
|
+
// Select the page that was actually created; `pages()` order is creation order but a popup
|
|
394
|
+
// from another page can land in the same tick, so ask instead of assuming it is last.
|
|
395
|
+
const created = ctx.pages().indexOf(pg);
|
|
396
|
+
selectedPage = pg;
|
|
397
|
+
if (created < 0 || pg.isClosed()) throw new Error("new page closed before it could be selected");
|
|
211
398
|
if (p.url) await gotoAllowed(pg, p.url, cfg);
|
|
212
|
-
|
|
399
|
+
if (pg.isClosed()) throw new Error("new page closed during its safety check");
|
|
400
|
+
return { text: `Opened page [${ctx.pages().indexOf(pg)}] ${pg.url()}` };
|
|
213
401
|
}
|
|
214
402
|
case "select_page": {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
403
|
+
const pg = pageAt(p.index ?? 0);
|
|
404
|
+
selectedPage = pg;
|
|
405
|
+
await assertFrameTargetsSafe(pg, guardOf(cfg));
|
|
406
|
+
if (pg.isClosed()) throw new Error("selected page closed during its safety check");
|
|
407
|
+
return { text: `Selected page [${ctx.pages().indexOf(pg)}] ${pg.url()}` };
|
|
219
408
|
}
|
|
220
409
|
case "close_page": {
|
|
221
|
-
const i = p.index ?? activeIndex;
|
|
222
410
|
const pages = ctx.pages();
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
411
|
+
const doomed = pageAt(p.index);
|
|
412
|
+
const i = pages.indexOf(doomed);
|
|
413
|
+
await doomed.close();
|
|
414
|
+
const remaining = ctx.pages();
|
|
415
|
+
if (doomed === selectedPage) {
|
|
416
|
+
const next = indexAfterClose(i, i, pages.length);
|
|
417
|
+
selectedPage = remaining[Math.min(next, remaining.length - 1)] ?? null;
|
|
418
|
+
}
|
|
419
|
+
const active = selectedPage ? remaining.indexOf(selectedPage) : -1;
|
|
420
|
+
return { text: `Closed page [${i}]; selection is now ${active >= 0 ? `[${active}]` : "none"}${remaining.length ? "" : " (no pages left)"}` };
|
|
227
421
|
}
|
|
228
422
|
case "resize": {
|
|
229
423
|
if (p.width == null || p.height == null) throw new Error("resize requires width and height");
|
|
230
|
-
await
|
|
424
|
+
await target().setViewportSize({ width: p.width, height: p.height });
|
|
231
425
|
return { text: `Resized to ${p.width}x${p.height}` };
|
|
232
426
|
}
|
|
233
427
|
default:
|
|
@@ -248,5 +442,5 @@ async function closeBrowserInternal(): Promise<void> {
|
|
|
248
442
|
}
|
|
249
443
|
context = null;
|
|
250
444
|
browser = null;
|
|
251
|
-
|
|
445
|
+
selectedPage = null;
|
|
252
446
|
}
|