@openlines/opl-pi-sht 0.2.12 → 0.3.0

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.
Files changed (81) hide show
  1. package/README.md +92 -84
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package.json +4 -4
  12. package/extensions/opl-browser/validate.ts +313 -60
  13. package/extensions/opl-configurator/README.md +40 -0
  14. package/extensions/opl-configurator/configurator.ts +148 -0
  15. package/extensions/opl-configurator/index.ts +12 -0
  16. package/extensions/opl-ctxtrim/README.md +45 -14
  17. package/extensions/opl-ctxtrim/index.ts +33 -13
  18. package/extensions/opl-footer/README.md +31 -6
  19. package/extensions/opl-footer/config.ts +11 -10
  20. package/extensions/opl-footer/configure.ts +157 -115
  21. package/extensions/opl-footer/git-status.ts +58 -1
  22. package/extensions/opl-footer/icons.ts +3 -0
  23. package/extensions/opl-footer/index.ts +209 -105
  24. package/extensions/opl-footer/segments/caveman.ts +9 -1
  25. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  26. package/extensions/opl-footer/segments/compactions.ts +13 -0
  27. package/extensions/opl-footer/segments/context.ts +6 -5
  28. package/extensions/opl-footer/segments/index.ts +2 -0
  29. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  30. package/extensions/opl-footer/segments/path.ts +1 -1
  31. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  32. package/extensions/opl-footer/theme.ts +5 -2
  33. package/extensions/opl-footer/types.ts +4 -0
  34. package/extensions/opl-guardian/README.md +17 -5
  35. package/extensions/opl-guardian/config.ts +72 -6
  36. package/extensions/opl-guardian/guardian.ts +42 -10
  37. package/extensions/opl-guardian/index.ts +52 -17
  38. package/extensions/opl-guardian/policies.ts +52 -16
  39. package/extensions/opl-init/README.md +12 -4
  40. package/extensions/opl-init/index.ts +228 -27
  41. package/extensions/opl-input/README.md +17 -3
  42. package/extensions/opl-input/config.ts +59 -5
  43. package/extensions/opl-input/configure.ts +144 -0
  44. package/extensions/opl-input/index.ts +38 -30
  45. package/extensions/opl-input/mode-style.ts +6 -2
  46. package/extensions/opl-modes/README.md +16 -4
  47. package/extensions/opl-modes/config.ts +62 -8
  48. package/extensions/opl-modes/index.ts +48 -17
  49. package/extensions/opl-modes/state.ts +50 -2
  50. package/extensions/opl-modes/types.ts +6 -0
  51. package/extensions/opl-modes/utils.ts +18 -8
  52. package/extensions/opl-questionnaire/index.ts +10 -4
  53. package/extensions/opl-questionnaire/types.ts +4 -0
  54. package/extensions/opl-simplebench/README.md +21 -15
  55. package/extensions/opl-simplebench/artifact.ts +7 -4
  56. package/extensions/opl-simplebench/benchmark.ts +96 -35
  57. package/extensions/opl-simplebench/coding.ts +9 -4
  58. package/extensions/opl-simplebench/index.ts +19 -19
  59. package/extensions/opl-simplebench/llama-server.ts +3 -2
  60. package/extensions/opl-simplebench/report.ts +0 -6
  61. package/extensions/opl-simplebench/research.ts +5 -4
  62. package/extensions/opl-simplebench/scoring.ts +4 -2
  63. package/extensions/opl-simplebench/util/config.ts +15 -8
  64. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  65. package/extensions/opl-simplebench/util/format.ts +2 -2
  66. package/extensions/opl-simplebench/util/providers.ts +23 -194
  67. package/extensions/opl-todo/README.md +5 -5
  68. package/extensions/opl-todo/config.ts +7 -2
  69. package/extensions/opl-todo/index.ts +50 -9
  70. package/extensions/opl-webaccess/README.md +31 -5
  71. package/extensions/opl-webaccess/config.ts +36 -7
  72. package/extensions/opl-webaccess/extract.ts +37 -47
  73. package/extensions/opl-webaccess/http.ts +259 -0
  74. package/extensions/opl-webaccess/index.ts +12 -6
  75. package/extensions/opl-webaccess/package.json +4 -9
  76. package/extensions/opl-webaccess/pdf.ts +4 -4
  77. package/extensions/opl-webaccess/search.ts +19 -8
  78. package/extensions/opl-webaccess/storage.ts +134 -2
  79. package/extensions/opl-webaccess/types.ts +4 -0
  80. package/extensions/opl-webaccess/utils.ts +221 -33
  81. 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
- ~29 per-tool schemas. Keeps the cached prefix small.
38
- - **Handle + preview output.** Large results (snapshots, console/network logs,
39
- evaluate output) are kept out of context: the tool returns a truncated preview
40
- plus a `responseId`; call `action: "get"` with that id to page through the full
41
- text (`getChars` per page, pass `offset` to continue). Screenshots are written to
42
- a file, never inlined as base64.
43
- - **Structured extraction.** `extract` runs Readability + Turndown over the
44
- *rendered* (post-JS) DOM — the complement to `opl-webaccess`'s `fetch_content`,
45
- which only sees raw HTTP HTML. Static pages: `fetch_content`; rendered or
46
- interacted-with pages: `browser:extract`. With a `selector`, the matched element
47
- is converted verbatim (no article detection): Readability's candidate scoring is
48
- a whole-document heuristic and mispicks inside small subtrees. A selector that
49
- matches multiple elements returns a clear error instead of a strict-mode crash,
50
- and a selector with no match reports `(no elements match ...)`.
51
- - **Real Chromium via Playwright.** Navigation with `domcontentloaded` waits,
52
- CSS-selector interaction, viewport control, multi-page management.
53
- - **Scheme + path guards.** `navigate`/`new_page` accept http(s) only; `file:`,
54
- `data:`, and `javascript:` are rejected, private-range and link-local hosts are
55
- blocked by default, and cloud-metadata endpoints are always blocked (SSRF guard).
56
- Loopback (`localhost`/`127.0.0.0/8`/`::1`) is allowed by default for local
57
- development; set `allowPrivateNetwork: true` for private ranges. Redirect targets
58
- are re-checked on the final URL. Screenshot paths must use `.png`/`.jpg`, stay
59
- inside the project directory, and are refused when the file already exists, so a
60
- bad action cannot overwrite or plant arbitrary files.
61
- - **Per-page capture.** Console messages and network requests are buffered per
62
- page as they occur; `console` and `network` actions return the active page's
63
- buffer, capped at the most recent `200` entries per page so an active
64
- long-running page cannot grow the buffer without bound. `navigate: back`/
65
- `forward` on a fresh session reports `(no history to go back/forward)` instead of
66
- silently returning the unchanged page.
67
- - **One reused browser per session.** Launched on first use, closed automatically
68
- on `session_shutdown`, or on demand via `action: "close"`.
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 (loopback is always allowed; cloud metadata is always blocked). |
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, screenshot path confined
103
- to the project directory.
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 { assertHttpUrl, safeScreenshotPath } from "./validate.js";
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 activeIndex = 0;
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
- context = await browser.newContext({ viewport: { width: cfg.width, height: cfg.height } });
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
- activeIndex = 0;
72
+ selectedPage = page;
55
73
  return context;
56
74
  }
57
75
 
58
- function page(): Page {
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
- if (!pages.length) throw new Error("no open pages");
61
- return pages[activeIndex] ?? pages[pages.length - 1];
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
- /** Navigate with the host validated both before the request and on the final URL, so a
65
- * public URL that redirects to a private/link-local host is rejected. */
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
- await target.goto(assertHttpUrl(url, { allowPrivateNetwork: cfg.allowPrivateNetwork }), { waitUntil: "domcontentloaded" });
68
- assertHttpUrl(target.url(), { allowPrivateNetwork: cfg.allowPrivateNetwork });
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 (activeIndex, page(), ensure() all race). Each new
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
- export function runAction(p: BrowserParams, cfg: BrowserConfig): Promise<BrowserActionResult> {
103
- return serialize(() => runActionInternal(p, cfg));
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 page().goBack() : await page().goForward();
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: `${page().url()} — ${await page().title()}` };
299
+ return { text: `${target().url()} — ${await target().title()}` };
123
300
  }
124
- if (url === "reload") await page().reload();
125
- else if (url) await gotoAllowed(page(), url, cfg);
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: `${page().url()} — ${await page().title()}` };
304
+ return { text: `${target().url()} — ${await target().title()}` };
128
305
  }
129
306
  case "snapshot": {
130
- const tree = await page().locator("body").ariaSnapshot();
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 = page().locator(selector);
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 page().content();
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
- await page().screenshot({ path: file, fullPage: p.fullPage ?? false });
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 page().click(p.selector);
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 page().fill(p.selector, p.text);
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 page().hover(p.selector);
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 page().keyboard.press(p.key);
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 page().selectOption(p.selector, p.values);
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 page().evaluate(p.script);
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(page()) ?? [];
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(page()) ?? [];
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 page().waitForSelector(p.selector, { timeout });
199
- else if (p.text) await page().getByText(p.text).first().waitFor({ timeout });
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) => `${i === activeIndex ? "*" : " "} [${i}] ${pg.url()}`);
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
- activeIndex = ctx.pages().length - 1;
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
- return { text: `Opened page [${activeIndex}] ${pg.url()}` };
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 i = p.index ?? 0;
216
- if (i < 0 || i >= ctx.pages().length) throw new Error(`no page at index ${i}`);
217
- activeIndex = i;
218
- return { text: `Selected page [${i}] ${page().url()}` };
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
- if (i < 0 || i >= pages.length) throw new Error(`no page at index ${i}`);
224
- await pages[i].close();
225
- activeIndex = Math.max(0, ctx.pages().length - 1);
226
- return { text: `Closed page [${i}]` };
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 page().setViewportSize({ width: p.width, height: p.height });
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
- activeIndex = 0;
445
+ selectedPage = null;
252
446
  }