@underactive/pi-topping-web-tools 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.9] - 2026-10-16
6
+
7
+ ### Added
8
+
9
+ - Optional `toolExposure` setting (`direct` default, or `codemode` / `deferred`) in `~/.pi/agent/pi-topping-web-tools/settings.json`. All three tools share the `pi-topping-web-tools` namespace. At each session start, a non-`direct` mode activates the loader it needs with `pi.setActiveTools()`: `codemode` for `codemode`, and `tool_search` (or `codemode` when `tool_search` is not registered) for `deferred`. `defaultTools` needs no `+codemode` / `+tool_search` entry. When `--tools` or `--exclude-tools` leaves the loader out, the extension notifies once instead.
10
+ - README settings profiles for default direct tools, codemode-minimal (`inlineBudget: 0`), and deferred + `tool_search`. Clarified that a large `inlineBudget` moves declarations into the `codemode` description instead of shrinking them.
11
+
12
+ ### Changed
13
+
14
+ - Tested against pi 1.2.0. No break: the 1.2.0 breaking changes (`before_agent_start` for `triggerTurn` runs, busy session during run start, theme schema validation, `pi mcp list --json` shape) touch APIs and resources this package does not use.
15
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 1.1.0 to 1.2.0.
16
+ - Tested against pi 1.3.0. No break: the extension-facing declaration surface (`ExtensionAPI`, `ToolDefinition`, `ExtensionContext`, `ExtensionUIContext`) is unchanged, pi-ai only adds optional `ToolCall.argumentsError`, and pi-tui is unchanged. Provider, `/share`, and model-switching changes do not reach these tools.
17
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 1.2.0 to 1.3.0.
18
+
19
+ ### Fixed
20
+
21
+ - `web_browser`: the `mode`, `state`, `cookies[].sameSite`, and `dialogAction` parameter schemas now pass `as const` arrays to `StringEnum`, so their inferred types are the literal unions instead of `string`. This fixes four `tsc` errors; runtime validation is unchanged.
22
+
23
+ ## [0.2.8] - 2026-10-12
24
+
25
+ ### Added
26
+
27
+ - Documented that the default `codemode.inlineBudget` (3000 estimated tokens) leaves `web_browser` out of the `codemode.mode: "only"` tool listing when it is loaded with `fetch_markdown`, `pdf_extract`, and the built-in `read`, `bash`, `edit`, and `write`, and the budget that fits them.
28
+ - Documented how a codemode script inspects a saved screenshot with `tools.read` and `image()` on pi 1.0.4+, and the fallback on older hosts.
29
+
30
+ ### Changed
31
+
32
+ - Tested against pi 1.1.0. No break: the 1.0.4 and 1.1.0 extension-facing changes are additive (`ToolLoadout.getPromptGuidelines()`, and `durationMs` and `outputPad` on the tool render context), and codemode now lists each tool's prompt guidelines with its declaration, which is why `web_browser` no longer fits the default `codemode.inlineBudget` in `only` mode.
33
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 1.0.3 to 1.1.0.
34
+ - `web_browser`: the codemode screenshot guideline now tells a script to save the screenshot with `toFile=true`, read the file with `tools.read({ path })`, and pass the resulting image block to `image()`. This needs pi 1.0.4+, where `read` returns an image block for image files. A script that calls `web_browser` still receives only text, because the tool declares no output schema.
35
+
36
+ ### Fixed
37
+
38
+ - `fetch_markdown`, `pdf_extract`, `web_browser`: a forged `</untrusted-content>` closing tag in fetched or page text is now escaped to `<\/untrusted-content` instead of deleted. Deletion could reassemble a real closer from split text such as `</untrusted-</untrusted-contentcontent>`, so hostile text could end the fence early and be read as trusted text. The match is case-insensitive, and the fence-escape tests now assert exactly one closing tag.
39
+ - `fetch_markdown`, `pdf_extract`, `web_browser`: an expanded result preview drops terminal control characters from its body before styling, so a page cannot emit an OSC 52 clipboard write or another escape sequence while a result is drawn. `web_browser` also replaces control characters with spaces in a page title and in a tool error message.
40
+ - `web_browser`: the `navigate` result now puts the status line before an `<untrusted-content>` fence that holds only the page title, and the `list_tabs` result is fenced the same way, because a tab title comes from the page. Both results held page-controlled text outside a fence before.
41
+
5
42
  ## [0.2.7] - 2026-10-05
6
43
 
7
44
  ### Changed
package/README.md CHANGED
@@ -54,13 +54,76 @@ Start with `fetch_markdown` for efficient static content; use `web_browser` when
54
54
  | `pdf_extract` | Extract the text layer from a remote or local PDF, with page markers |
55
55
  | `web_browser` | Headless Chromium automation — navigate, click, type, screenshot, evaluate, logs, cookies |
56
56
 
57
- All three tools are `direct`-exposure and pi activates those on registration (`defaultActive` defaults to true), so nothing needs to be enabled.
57
+ By default all three tools use `direct` exposure. Pi activates those on registration (`defaultActive` defaults to true), so a default install needs no extra `defaultTools` entry.
58
+
59
+ ### Tool exposure (optional)
60
+
61
+ A single setting controls how the model reaches all three tools. It does not change host allowlists, permission prompts, or `--exclude-tools` / `defaultTools` filtering. Set it in `~/.pi/agent/pi-topping-web-tools/settings.json` (no TUI control):
62
+
63
+ ```json
64
+ {
65
+ "toolExposure": "direct"
66
+ }
67
+ ```
68
+
69
+ | `toolExposure` | Tool `exposure` | Loader the extension activates |
70
+ |----------------|-----------------|--------------------------------|
71
+ | `direct` (default) | `direct`, active on register | None |
72
+ | `codemode` | `codemode` | `codemode` |
73
+ | `deferred` | `deferred` | `tool_search`, or `codemode` when `tool_search` is not available |
74
+
75
+ All three tools share the `pi-topping-web-tools` namespace so `searchTools()` and `describeNamespace("pi-topping-web-tools")` can find them when they are not listed inline.
76
+
77
+ Needs pi ≥0.99.0 for `codemode` / `tool_search` / `exposure`. Screenshot inspection from a script needs pi ≥1.0.4 (`toFile=true`, then `tools.read` and `image()`).
78
+
79
+ At every session start (startup, reload, new, resume, and fork), the extension activates the loader with `pi.setActiveTools()`, as Pi's MCP support does for MCP servers. You do not need `+codemode` or `+tool_search` in `defaultTools`. The loader is a session-wide Pi tool, so the model can also use it for the other tools in the session, not only for these three. `--tools` and `--exclude-tools` unregister a loader they leave out. The extension does not activate that loader. It notifies once at session start instead, because the model cannot reach the tools.
80
+
81
+ `codemode.mode: "only"` is a session-wide Pi setting. While it is on, the model must call bash/read/edit/write through `codemode` as well, not only these web tools. `--tools` still applies, for example `pi --tools +codemode,-web_browser`.
58
82
 
59
83
  ### Enabling and disabling tools
60
84
 
61
85
  - To hide a tool for a session, name it in `--exclude-tools` (or start with `--no-tools` and re-add what you want with `--tools`).
62
86
  - To change the startup set in your `settings.json`, use `defaultTools`. Bare names **replace** pi's defaults, so write `"+fetch_markdown"` to add a tool without dropping `read`, `bash`, `edit`, and `write`. On pi 0.99.2+, `/reload` activates tools **newly added** to `defaultTools` without a restart. The reverse does not follow: a tool **removed** from `defaultTools` stays active until you restart, and a tool you turned off during the session stays off. `--tools`, `--no-tools`, and `--no-builtin-tools` still override the setting, on reload as well.
63
87
 
88
+ ### Settings profiles for smaller prompts
89
+
90
+ These snippets go in `~/.pi/agent/settings.json`. They are optional. Default installs can omit them.
91
+
92
+ **Default (unchanged).** Direct tools. No codemode. Full tool schemas stay in the prompt.
93
+
94
+ **Codemode-minimal.** Hide direct declarations and keep the `codemode` description small. `inlineBudget: 0` lists namespaces only. Scripts find tools with `searchTools()` / `describeTool()` / `describeNamespace()`. `inlineBudget: 4096` (or more) instead copies the full seven-tool catalog into the `codemode` description. That **moves** the schemas; it does not shrink them. Use `0` (or a small budget such as `512` for `fetch_markdown` / `pdf_extract` only) when the goal is fewer prompt tokens.
95
+
96
+ ```json
97
+ {
98
+ "defaultTools": ["+codemode"],
99
+ "codemode.mode": "only",
100
+ "codemode.inlineBudget": 0
101
+ }
102
+ ```
103
+
104
+ With `toolExposure` still `direct`, `mode: "only"` hides the active declarations while they stay callable from scripts. Set `"toolExposure": "codemode"` in the extension settings file if you also want these three tools off the active set unless a loader activates them.
105
+
106
+ **Deferred + tool_search.** Register the tools as `deferred` and let `tool_search` load a schema on the next turn. The extension activates `tool_search`, so `~/.pi/agent/settings.json` needs no change.
107
+
108
+ `~/.pi/agent/pi-topping-web-tools/settings.json`:
109
+
110
+ ```json
111
+ {
112
+ "toolExposure": "deferred"
113
+ }
114
+ ```
115
+
116
+ ### Codemode tool listing
117
+
118
+ With `codemode.mode: "only"`, pi hides the direct declarations of active tools, so the model calls them from `codemode` scripts, and the `codemode` description lists each callable tool instead, with its prompt guidelines on pi 1.0.4+. Listed tools share `codemode.inlineBudget`, which defaults to 3000 estimated tokens (characters / 4); tools that do not fit are left out and are reached with `searchTools()` or `describeTool()`. `0` lists only namespaces. On pi 1.0.4+, the seven-tool catalog `fetch_markdown`, `pdf_extract`, `web_browser`, plus built-in `read`, `bash`, `edit`, and `write` needs about 3,700 tokens as of pi 1.1.0 — `web_browser` alone is about 1,600, the largest section — so the default budget drops `web_browser` from that list. Set the budget to 4096 or more if you use `only` and want every tool inline; real sessions that load more extensions need more. Use `0` when you want the smallest `codemode` description.
119
+
120
+ ```json
121
+ {
122
+ "codemode.mode": "only",
123
+ "codemode.inlineBudget": 4096
124
+ }
125
+ ```
126
+
64
127
  ## `fetch_markdown`
65
128
 
66
129
  Complements pi's built-in search-backed `web_fetch` with a lightweight, keyless fetch path for public documentation, READMEs, and articles:
@@ -142,7 +205,7 @@ When `screenshot` returns an inline result (`toFile` omitted or `false`), `web_b
142
205
 
143
206
  `toFile=true` and `/browser-screenshot` save the original PNG to disk and put only the path and dimensions in the tool result. The file is resized only if a later `read` or `@file` attachment sends it to the model.
144
207
 
145
- In codemode, a tool result exposes only its text blocks, so an inline `screenshot` reaches the script as its caption with no image bytes. Call `screenshot` directly from the session, or pass `toFile=true` and read the saved path from the script.
208
+ In codemode, a tool without an output schema, such as `web_browser`, resolves to its text blocks only, so an inline `screenshot` reaches the script as its caption with no image bytes. To let a script inspect a screenshot, take it with `toFile=true` and read the saved path: on pi 1.0.4+, `tools.read({ path })` resolves an image file to an image block, and `image()` adds that block to the script output. On pi before 1.0.4, `read` resolves an image file to only its `Read image file [...]` caption, so the pixels are not reachable from a script; on those hosts, call `screenshot` as a direct tool call instead of from a script.
146
209
 
147
210
  The resize fields are:
148
211
 
@@ -186,7 +249,7 @@ This matches pi's documented example. It leaves a default 1280×720 viewport scr
186
249
  - 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.
187
250
  - 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.
188
251
  - WebSocket handshakes use the same live permission check. Service workers are disabled so they cannot bypass request routing.
189
- - 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.
252
+ - 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. Approved popups become active only after their main-frame navigation commits.
190
253
  - `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.
191
254
  - 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`.
192
255
 
@@ -245,13 +308,13 @@ Commands:
245
308
 
246
309
  ## Security model (shared)
247
310
 
248
- - **Preapproved hosts**: common documentation and dev sites are allowed without prompting. The allowlist lives in `src/permissions.ts` and is shared by both tools.
311
+ - **Preapproved hosts**: common documentation and dev sites are allowed without prompting. The allowlist lives in `src/permissions.ts` and is shared by all three tools.
249
312
  - **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.
250
313
  - **Tool annotations**: on pi 0.99+, `fetch_markdown` and `pdf_extract` declare `readOnlyHint` and `openWorldHint`, and `web_browser` declares `openWorldHint` with `readOnlyHint: false`, for permission extensions that confirm calls by hint. The host confirmation above applies regardless of how another extension treats the hints.
251
314
  - **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.
252
315
  - **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.
253
316
  - **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.
254
- - **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.
317
+ - **Untrusted content boundary**: `fetch_markdown` results and `web_browser` page-derived 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.
255
318
  - **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.
256
319
 
257
320
  ## Limitations
@@ -263,10 +326,6 @@ Commands:
263
326
  - Playwright routing covers HTTP(S) and WebSockets, not browser transports such as WebRTC; OS-level egress sandboxing remains outside this extension's scope.
264
327
  - `pdf_extract` reads only an existing text layer; scanned documents need OCR, which is out of scope.
265
328
 
266
- ### Popups and navigation
267
-
268
- 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.
269
-
270
329
  ## Development
271
330
 
272
331
  Requires Node >= 22.19 (tests run TypeScript directly via `node --test`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-web-tools",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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": "1.0.3",
57
- "@earendil-works/pi-coding-agent": "1.0.3",
58
- "@earendil-works/pi-tui": "1.0.3",
56
+ "@earendil-works/pi-ai": "1.3.0",
57
+ "@earendil-works/pi-coding-agent": "1.3.0",
58
+ "@earendil-works/pi-tui": "1.3.0",
59
59
  "@types/node": "^24.0.0",
60
60
  "@types/turndown": "^5.0.5",
61
61
  "typebox": "^1.3.3",
@@ -13,6 +13,7 @@ import { getBrowserHeaders } from "./browser-headers.ts";
13
13
  import { isPreapprovedHost, permissionKey } from "./permissions.ts";
14
14
  import { requestHostPermission } from "./permission-prompt.ts";
15
15
  import { BoundedPreview, clipField, resultText } from "./render-preview.ts";
16
+ import { attachExposureLoader, registerWebTool } from "./tool-exposure.ts";
16
17
  import { MAX_URL_LENGTH, isLocalOrPrivateHost, upgradeHttpToHttps } from "./web-browser/permissions.ts";
17
18
 
18
19
  const MAX_HTTP_CONTENT_LENGTH = 25 * 1024 * 1024;
@@ -513,7 +514,7 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
513
514
 
514
515
  const offset = Math.max(0, Math.floor(params.offset ?? 0));
515
516
  const slice = sliceContent(response.content, offset);
516
- const sanitizedText = slice.text.replaceAll("</untrusted-content", "");
517
+ const sanitizedText = slice.text.replaceAll(/<\/(untrusted-content)/gi, "<\\/$1");
517
518
  const safeUrl = params.url.replaceAll('"', "%22");
518
519
  const wrappedText = `<untrusted-content url="${safeUrl}">\n${sanitizedText}\n</untrusted-content>`;
519
520
 
@@ -582,7 +583,8 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
582
583
  export default function (pi: ExtensionAPI) {
583
584
  const sessionPermissions = new Map<string, "allow" | "deny">();
584
585
 
585
- pi.registerTool(fetchMarkdownTool);
586
+ registerWebTool(pi, fetchMarkdownTool);
587
+ attachExposureLoader(pi);
586
588
 
587
589
  pi.on("tool_call", async (event, ctx) => {
588
590
  if (event.toolName !== "fetch_markdown") return undefined;
@@ -3,15 +3,16 @@
3
3
  import { defineTool, formatSize, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import { LRUCache } from "lru-cache";
6
- import { basename, isAbsolute, relative, resolve } from "node:path";
6
+ import { basename, resolve } from "node:path";
7
7
  import { readFile, realpath, stat } from "node:fs/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { Type } from "typebox";
10
10
  import { extractText, getDocumentProxy } from "unpdf";
11
11
  import { combineSignals } from "./abort-utils.ts";
12
- import { isPreapprovedHost, permissionKey } from "./permissions.ts";
12
+ import { isInsideCwd, isPreapprovedHost, permissionKey } from "./permissions.ts";
13
13
  import { requestHostPermission } from "./permission-prompt.ts";
14
14
  import { BoundedPreview, clipField, resultText } from "./render-preview.ts";
15
+ import { attachExposureLoader, registerWebTool } from "./tool-exposure.ts";
15
16
  import { getWithPermittedRedirects, isPermittedRedirect, sliceContent, validateURL } from "./fetch-markdown.ts";
16
17
  import { upgradeHttpToHttps } from "./web-browser/permissions.ts";
17
18
 
@@ -110,11 +111,6 @@ export function parsePageRange(spec: string, totalPages: number): number[] {
110
111
  return [...selected].sort((a, b) => a - b);
111
112
  }
112
113
 
113
- export function isInsideCwd(resolvedPath: string): boolean {
114
- const rel = relative(process.cwd(), resolvedPath);
115
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
116
- }
117
-
118
114
  type LocalPdfTarget = {
119
115
  path: string;
120
116
  mtimeMs: number;
@@ -369,7 +365,7 @@ Usage notes:
369
365
 
370
366
  const offset = Math.max(0, Math.floor(params.offset ?? 0));
371
367
  const slice = sliceContent(body, offset);
372
- const sanitizedText = slice.text.replaceAll(/<\/untrusted-content/gi, "");
368
+ const sanitizedText = slice.text.replaceAll(/<\/(untrusted-content)/gi, "<\\/$1");
373
369
  const safeSource = source.replaceAll('"', "%22");
374
370
 
375
371
  return {
@@ -433,7 +429,8 @@ Usage notes:
433
429
  export default function (pi: ExtensionAPI) {
434
430
  const sessionPermissions = new Map<string, "allow" | "deny">();
435
431
 
436
- pi.registerTool(pdfExtractTool);
432
+ registerWebTool(pi, pdfExtractTool);
433
+ attachExposureLoader(pi);
437
434
 
438
435
  pi.on("tool_call", async (event, ctx) => {
439
436
  if (event.toolName !== "pdf_extract") return undefined;
@@ -6,6 +6,8 @@
6
6
  * hostname/path-prefix (matches the prefix and its descendants).
7
7
  */
8
8
 
9
+ import { isAbsolute, relative } from "node:path";
10
+
9
11
  export const PREAPPROVED_HOSTS = new Set([
10
12
  "platform.claude.com",
11
13
  "code.claude.com",
@@ -115,6 +117,11 @@ const { HOSTNAME_ONLY, PATH_PREFIXES } = (() => {
115
117
  return { HOSTNAME_ONLY: hosts, PATH_PREFIXES: paths };
116
118
  })();
117
119
 
120
+ export function isInsideCwd(resolvedPath: string): boolean {
121
+ const rel = relative(process.cwd(), resolvedPath);
122
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
123
+ }
124
+
118
125
  export function permissionKey(url: string): string {
119
126
  try {
120
127
  const parsed = new URL(url);
@@ -70,9 +70,10 @@ export class BoundedPreview implements Component {
70
70
  const { theme } = options;
71
71
  this.header = options.header;
72
72
  this.cut = options.body.length > MAX_PREVIEW_CHARS;
73
- // Slice the raw text before styling so the cap never cuts an escape sequence. Every logical
73
+ // Slice the text before styling so the cap never cuts an escape sequence. Every logical
74
74
  // line takes at least one row, so one line past the cap is enough for the skip to register.
75
75
  this.body = options.body
76
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "")
76
77
  .slice(0, MAX_PREVIEW_CHARS)
77
78
  .split("\n", MAX_VISUAL_PREVIEW_LINES + 1)
78
79
  .map((line) => theme.fg("dim", line))
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Shared ToolExposure + namespace for fetch_markdown, pdf_extract, and web_browser.
3
+ * Default remains Pi's direct registration. Other modes are opt-in via settings.json.
4
+ */
5
+
6
+ import { getAgentDir, type ExtensionAPI, type ToolDefinition, type ToolExposure, type ToolNamespace } from "@earendil-works/pi-coding-agent";
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ export type WebToolsExposureMode = "direct" | "codemode" | "deferred";
11
+
12
+ export const WEB_TOOLS_NAMESPACE: ToolNamespace = {
13
+ name: "pi-topping-web-tools",
14
+ description: "Keyless fetch_markdown, pdf_extract, and headless web_browser.",
15
+ instructions: [
16
+ "Use fetch_markdown for public HTML documentation, READMEs, and articles when you want turndown markdown and a 15-minute cache.",
17
+ "Use pdf_extract for a remote or local PDF. fetch_markdown and web_fetch cannot read PDF text.",
18
+ "Use web_browser for JavaScript-rendered pages, local servers, screenshots, and interaction.",
19
+ "Host allowlist and permission prompts still apply. Exposure only changes how the model sees the tools, not what they may execute.",
20
+ "See the package README sections for fetch_markdown, pdf_extract, and web_browser.",
21
+ ].join("\n"),
22
+ };
23
+
24
+ const MAX_SETTINGS_BYTES = 64 * 1024;
25
+
26
+ export function settingsPath(): string {
27
+ return process.env.PI_WEB_TOOLS_SETTINGS_FILE ?? join(getAgentDir(), "pi-topping-web-tools", "settings.json");
28
+ }
29
+
30
+ export function parseToolExposureMode(value: unknown): WebToolsExposureMode {
31
+ if (value === "codemode" || value === "deferred" || value === "direct") {
32
+ return value;
33
+ }
34
+ return "direct";
35
+ }
36
+
37
+ export function loadToolExposureMode(path: string = settingsPath()): WebToolsExposureMode {
38
+ let raw: string;
39
+ try {
40
+ raw = readFileSync(path, "utf-8");
41
+ } catch {
42
+ return "direct";
43
+ }
44
+
45
+ if (Buffer.byteLength(raw) > MAX_SETTINGS_BYTES) {
46
+ return "direct";
47
+ }
48
+
49
+ let parsed: unknown;
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ } catch {
53
+ return "direct";
54
+ }
55
+
56
+ if (typeof parsed !== "object" || parsed === null) {
57
+ return "direct";
58
+ }
59
+
60
+ return parseToolExposureMode((parsed as { toolExposure?: unknown }).toolExposure);
61
+ }
62
+
63
+ export function webToolPresentation(mode: WebToolsExposureMode = loadToolExposureMode()): {
64
+ exposure: ToolExposure;
65
+ namespace: ToolNamespace;
66
+ } {
67
+ return {
68
+ exposure: mode,
69
+ namespace: WEB_TOOLS_NAMESPACE,
70
+ };
71
+ }
72
+
73
+ export function registerWebTool(pi: ExtensionAPI, tool: ToolDefinition): void {
74
+ pi.registerTool({ ...tool, ...webToolPresentation() });
75
+ }
76
+
77
+ let didNotifyPrerequisites = false;
78
+
79
+ export function _resetExposureNoticeForTests(): void {
80
+ didNotifyPrerequisites = false;
81
+ }
82
+
83
+ /** Loader tools that reach tools with this exposure, in the order to activate them. */
84
+ function exposureLoaders(mode: WebToolsExposureMode): readonly string[] {
85
+ if (mode === "codemode") return ["codemode"];
86
+ if (mode === "deferred") return ["tool_search", "codemode"];
87
+ return [];
88
+ }
89
+
90
+ /**
91
+ * The registered loader tool to activate so the model can reach the web tools, or undefined when a
92
+ * loader is already active or none is registered. `--tools` and `--exclude-tools` unregister tools,
93
+ * so an excluded loader is never activated.
94
+ */
95
+ export function exposureLoaderToActivate(
96
+ mode: WebToolsExposureMode,
97
+ activeTools: readonly string[],
98
+ registeredTools: readonly string[],
99
+ ): string | undefined {
100
+ const loaders = exposureLoaders(mode);
101
+ if (loaders.some((name) => activeTools.includes(name))) return undefined;
102
+ return loaders.find((name) => registeredTools.includes(name));
103
+ }
104
+
105
+ export function exposurePrerequisiteNotice(
106
+ mode: WebToolsExposureMode,
107
+ activeTools: readonly string[],
108
+ ): string | undefined {
109
+ if (mode === "direct") return undefined;
110
+ if (exposureLoaders(mode).some((name) => activeTools.includes(name))) return undefined;
111
+ if (mode === "codemode") {
112
+ return 'pi-topping-web-tools toolExposure is "codemode", but the codemode tool is not available, so the model cannot reach the tools. Check --tools and --exclude-tools, or set toolExposure to "direct".';
113
+ }
114
+ return 'pi-topping-web-tools toolExposure is "deferred", but neither tool_search nor codemode is available, so the model cannot reach the tools. Check --tools and --exclude-tools, or set toolExposure to "direct".';
115
+ }
116
+
117
+ /**
118
+ * On every session start, activate the loader tool the configured exposure needs, as Pi's MCP support
119
+ * does for MCP servers. Notify once when no loader can be activated.
120
+ */
121
+ export function attachExposureLoader(pi: ExtensionAPI): void {
122
+ pi.on("session_start", (_event, ctx) => {
123
+ const mode = loadToolExposureMode();
124
+ if (mode === "direct") return;
125
+ if (typeof pi.getActiveTools !== "function") return;
126
+ const active = pi.getActiveTools();
127
+ const loader = exposureLoaderToActivate(mode, active, pi.getAllTools().map((tool) => tool.name));
128
+ if (loader) pi.setActiveTools([...active, loader]);
129
+ if (didNotifyPrerequisites) return;
130
+ const message = exposurePrerequisiteNotice(mode, pi.getActiveTools());
131
+ if (!message) return;
132
+ didNotifyPrerequisites = true;
133
+ if (ctx.hasUI) {
134
+ ctx.ui.notify(message, "warning");
135
+ }
136
+ });
137
+ }
@@ -866,7 +866,7 @@ export class BrowserManager {
866
866
  const snippet = await this.pageSnippet(page);
867
867
  const frameNote = frame ? ` within frame "${frame}"` : "";
868
868
  const safeUrl = page.url().replaceAll('"', "%22");
869
- return `No element matches selector "${selector}"${frameNote}. ${diagnostics}\nPage snippet:\n<untrusted-content url="${safeUrl}">\n${snippet.replaceAll("</untrusted-content", "")}\n</untrusted-content>`;
869
+ return `No element matches selector "${selector}"${frameNote}. ${diagnostics}\nPage snippet:\n<untrusted-content url="${safeUrl}">\n${snippet.replaceAll(/<\/(untrusted-content)/gi, "<\\/$1")}\n</untrusted-content>`;
870
870
  }
871
871
 
872
872
  private async ensureLaunched(): Promise<void> {
@@ -9,7 +9,6 @@ import {
9
9
  DEFAULT_MAX_LINES,
10
10
  formatSize,
11
11
  truncateHead,
12
- withFileMutationQueue,
13
12
  } from "@earendil-works/pi-coding-agent";
14
13
  import { StringEnum } from "@earendil-works/pi-ai";
15
14
  import { Text } from "@earendil-works/pi-tui";
@@ -25,7 +24,6 @@ import {
25
24
  type NavigateResult,
26
25
  type NetworkEntry,
27
26
  type ScreenshotResult,
28
- type SetCookieParam,
29
27
  type TabInfo,
30
28
  } from "./browser-manager.ts";
31
29
  import { buildOriginChecker } from "./egress-policy.ts";
@@ -33,10 +31,10 @@ import { checkUrlPermission, upgradeHttpToHttps, validateURL } from "./permissio
33
31
  import { requestHostPermission } from "../permission-prompt.ts";
34
32
  import { BoundedPreview, clipField, resultText } from "../render-preview.ts";
35
33
  import { listGrants } from "../permission-store.ts";
36
- import { permissionKey } from "../permissions.ts";
37
- import { isInsideCwd } from "../pdf-extract.ts";
34
+ import { isInsideCwd, permissionKey } from "../permissions.ts";
38
35
  import { suppressNotifications } from "../env.ts";
39
36
  import { publishBrowserState, registerBrowserFeeds } from "./feeds.ts";
37
+ import { attachExposureLoader, webToolPresentation } from "../tool-exposure.ts";
40
38
 
41
39
  const ACTIONS = [
42
40
  "navigate",
@@ -135,9 +133,11 @@ function formatTabs(tabs: TabInfo[]): string {
135
133
  .join("\n");
136
134
  }
137
135
 
136
+ const sanitizeTitle = (title: string) => clipField(title.replace(/[\u0000-\u001f\u007f]/g, " "));
137
+
138
138
  function wrapUntrustedContent(url: string, text: string): string {
139
139
  const safeUrl = url.replaceAll('"', "%22");
140
- const safeText = text.replaceAll(/<\/untrusted-content/gi, "");
140
+ const safeText = text.replaceAll(/<\/(untrusted-content)/gi, "<\\/$1");
141
141
  return `<untrusted-content url="${safeUrl}">\n${safeText}\n</untrusted-content>`;
142
142
  }
143
143
 
@@ -157,6 +157,13 @@ async function resolveLocalFilePath(input: string): Promise<string> {
157
157
  }
158
158
  }
159
159
 
160
+ async function writeTempFile(name: string, data: string | Buffer): Promise<string> {
161
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-web-browser-"));
162
+ const tempFile = join(tempDir, name);
163
+ await writeFile(tempFile, data, { mode: 0o600 });
164
+ return tempFile;
165
+ }
166
+
160
167
  async function truncateTextResult(
161
168
  text: string,
162
169
  details: WebBrowserDetails,
@@ -170,12 +177,7 @@ async function truncateTextResult(
170
177
  const outDetails = { ...details, bytes: truncation.totalBytes };
171
178
 
172
179
  if (truncation.truncated) {
173
- const tempDir = await mkdtemp(join(tmpdir(), "pi-web-browser-"));
174
- const tempFile = join(tempDir, "output.txt");
175
- await withFileMutationQueue(tempFile, async () => {
176
- await writeFile(tempFile, text, { encoding: "utf-8", mode: 0o600 });
177
- });
178
-
180
+ const tempFile = await writeTempFile("output.txt", text);
179
181
  outDetails.fullOutputPath = tempFile;
180
182
 
181
183
  const truncatedLines = truncation.totalLines - truncation.outputLines;
@@ -190,16 +192,6 @@ async function truncateTextResult(
190
192
  return { text: resultText, details: outDetails };
191
193
  }
192
194
 
193
- async function saveScreenshotToTempFile(shot: ScreenshotResult): Promise<string> {
194
- const tempDir = await mkdtemp(join(tmpdir(), "pi-web-browser-"));
195
- const tempFile = join(tempDir, "screenshot.png");
196
- const buf = Buffer.from(shot.data, "base64");
197
- await withFileMutationQueue(tempFile, async () => {
198
- await writeFile(tempFile, buf, { mode: 0o600 });
199
- });
200
- return tempFile;
201
- }
202
-
203
195
  function browserStatus(ctx: ExtensionContext, text: string, tone: "success" | "muted"): string {
204
196
  return ctx.ui.theme.fg(tone, `browser: ${text}`);
205
197
  }
@@ -237,6 +229,7 @@ export default function (pi: ExtensionAPI) {
237
229
  return manager;
238
230
  };
239
231
  registerBrowserFeeds(pi);
232
+ attachExposureLoader(pi);
240
233
 
241
234
  pi.on("session_start", async (_event, ctx) => {
242
235
  const info = await getConfiguredBrowserManager().getPageInfoAsync();
@@ -293,7 +286,7 @@ export default function (pi: ExtensionAPI) {
293
286
  "Web Browser:",
294
287
  ` Status: ${info.isOpen ? "open" : "closed"}`,
295
288
  ` URL: ${info.url ? clipField(info.url) : "(none)"}`,
296
- ` Title: ${info.title ? clipField(info.title.replace(/[\u0000-\u001f\u007f]/g, " ")) : "(none)"}`,
289
+ ` Title: ${info.title ? sanitizeTitle(info.title) : "(none)"}`,
297
290
  ` Tabs open: ${info.tabCount}`,
298
291
  ` Console messages: ${info.consoleCount}`,
299
292
  ` Network issues: ${info.networkCount}`,
@@ -325,15 +318,18 @@ export default function (pi: ExtensionAPI) {
325
318
  }
326
319
 
327
320
  const shot = await browser.screenshot({ fullPage: true });
328
- const tempFile = await saveScreenshotToTempFile(shot);
321
+ const tempFile = await writeTempFile("screenshot.png", Buffer.from(shot.data, "base64"));
329
322
  const dims = `${shot.width}×${shot.height}`;
330
323
  ctx.ui.notify(`Screenshot saved to: ${tempFile}\nDimensions: ${dims} (full page)`, "info");
331
324
  },
332
325
  });
333
326
 
327
+ const presentation = webToolPresentation();
334
328
  pi.registerTool({
335
329
  name: "web_browser",
336
330
  label: "Web Browser",
331
+ exposure: presentation.exposure,
332
+ namespace: presentation.namespace,
337
333
  promptSnippet:
338
334
  "Automate a headless browser to navigate sites, click/type, capture screenshots, evaluate JS, and read console logs.",
339
335
  promptGuidelines: [
@@ -343,7 +339,7 @@ export default function (pi: ExtensionAPI) {
343
339
  "Use get_content only when DOM structure or attributes are needed; it returns raw HTML.",
344
340
  "On SPA sites, use wait_for after navigate (selector/state or networkidle) before click/type.",
345
341
  "Use screenshot for visual verification (requires a vision-capable model); use toFile=true to save PNG to disk instead of base64.",
346
- "Image content is not visible to a codemode script: a script receives only a tool result's text blocks, so a screenshot taken from one returns its caption without the pixels. Call screenshot directly, or with toFile=true to get a file path the script can read.",
342
+ "Image content is not visible to a codemode script for tools without an output schema, such as web_browser. For a screenshot, call web_browser with action screenshot and toFile=true; its result starts with 'Screenshot saved to: <path>'. On pi 1.0.4+, pass that path to tools.read({ path }), then pass its image block to image(); on earlier hosts, call screenshot directly instead of from a script.",
347
343
  "Use get_console_logs when JavaScript errors or console output may explain page behavior.",
348
344
  "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.",
349
345
  "Use set_cookies to inject cookies for authenticated flows after manual login.",
@@ -398,7 +394,7 @@ export default function (pi: ExtensionAPI) {
398
394
  Type.Number({ description: "Tab index to switch to, from list_tabs (action: switch_tab)" }),
399
395
  ),
400
396
  dialogAction: Type.Optional(
401
- StringEnum(["accept", "dismiss"], {
397
+ StringEnum(["accept", "dismiss"] as const, {
402
398
  description:
403
399
  "How to resolve future JS dialogs (alert/confirm/prompt) for the rest of the session (action: set_dialog_behavior, default dismiss)",
404
400
  }),
@@ -427,13 +423,13 @@ export default function (pi: ExtensionAPI) {
427
423
  Type.Number({ description: "Viewport height in pixels (action: set_viewport)" }),
428
424
  ),
429
425
  mode: Type.Optional(
430
- StringEnum(["fill", "press"], {
426
+ StringEnum(["fill", "press"] as const, {
431
427
  description:
432
428
  "Typing mode (action: type, default fill). fill clears the field first; press types key-by-key and appends to existing input.",
433
429
  }),
434
430
  ),
435
431
  state: Type.Optional(
436
- StringEnum(["visible", "hidden", "attached", "detached"], {
432
+ StringEnum(["visible", "hidden", "attached", "detached"] as const, {
437
433
  description: "Wait state (action: wait_for, default visible)",
438
434
  }),
439
435
  ),
@@ -462,7 +458,7 @@ export default function (pi: ExtensionAPI) {
462
458
  path: Type.Optional(Type.String()),
463
459
  httpOnly: Type.Optional(Type.Boolean()),
464
460
  secure: Type.Optional(Type.Boolean()),
465
- sameSite: Type.Optional(StringEnum(["Strict", "Lax", "None"])),
461
+ sameSite: Type.Optional(StringEnum(["Strict", "Lax", "None"] as const)),
466
462
  }),
467
463
  { description: "Cookies to set (action: set_cookies); each needs url or domain+path" },
468
464
  ),
@@ -475,7 +471,7 @@ export default function (pi: ExtensionAPI) {
475
471
  annotations: { readOnlyHint: false, openWorldHint: true },
476
472
 
477
473
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
478
- const action = params.action as WebBrowserAction;
474
+ const action = params.action;
479
475
  const timeout = params.timeout;
480
476
 
481
477
  onUpdate?.({ content: [{ type: "text", text: `${action}…` }], details: { action } });
@@ -513,13 +509,14 @@ export default function (pi: ExtensionAPI) {
513
509
  const result: NavigateResult = await mgr.navigate(upgraded, { timeout, signal });
514
510
  syncBrowserOpen(ctx, result.url);
515
511
 
516
- const text = `Navigated to ${result.url}\nTitle: ${result.title}${result.statusCode ? `\nStatus: ${result.statusCode}` : ""}`;
512
+ const title = sanitizeTitle(result.title);
513
+ const text = `Navigated to ${result.url}${result.statusCode ? `\nStatus: ${result.statusCode}` : ""}\n${wrapUntrustedContent(result.url, `Title: ${title}`)}`;
517
514
  return {
518
515
  content: [{ type: "text", text }],
519
516
  details: {
520
517
  action,
521
518
  url: result.url,
522
- title: result.title,
519
+ title,
523
520
  // A same-document navigation has no response; omit the key rather than storing undefined.
524
521
  ...(result.statusCode !== undefined ? { statusCode: result.statusCode } : {}),
525
522
  bytes: Buffer.byteLength(text),
@@ -541,14 +538,14 @@ export default function (pi: ExtensionAPI) {
541
538
  const dimsNote = `${dims}${shot.fullPage ? " (full page)" : ""}`;
542
539
 
543
540
  if (params.toFile) {
544
- const tempFile = await saveScreenshotToTempFile(shot);
541
+ const tempFile = await writeTempFile("screenshot.png", Buffer.from(shot.data, "base64"));
545
542
  const text = `Screenshot saved to: ${tempFile}\nDimensions: ${dimsNote}`;
546
543
  return {
547
544
  content: [{ type: "text", text }],
548
545
  details: {
549
546
  action,
550
547
  url: info.url,
551
- title: info.title,
548
+ title: sanitizeTitle(info.title),
552
549
  screenshotDims: dims,
553
550
  fullOutputPath: tempFile,
554
551
  } satisfies WebBrowserDetails,
@@ -570,7 +567,7 @@ export default function (pi: ExtensionAPI) {
570
567
  details: {
571
568
  action,
572
569
  url: info.url,
573
- title: info.title,
570
+ title: sanitizeTitle(info.title),
574
571
  screenshotDims: dims,
575
572
  } satisfies WebBrowserDetails,
576
573
  };
@@ -603,7 +600,7 @@ export default function (pi: ExtensionAPI) {
603
600
  const msg = await mgr.fill(params.selector, params.text, {
604
601
  timeout,
605
602
  signal,
606
- mode: params.mode as "fill" | "press" | undefined,
603
+ mode: params.mode,
607
604
  frame: params.frame,
608
605
  });
609
606
  return {
@@ -734,7 +731,7 @@ export default function (pi: ExtensionAPI) {
734
731
  }
735
732
  const msg = await mgr.waitFor({
736
733
  selector: params.selector,
737
- state: params.state as "visible" | "hidden" | "attached" | "detached" | undefined,
734
+ state: params.state,
738
735
  networkidle: params.networkidle,
739
736
  timeout,
740
737
  signal,
@@ -829,7 +826,7 @@ export default function (pi: ExtensionAPI) {
829
826
  };
830
827
  }
831
828
  }
832
- await mgr.setCookies(params.cookies as SetCookieParam[], { signal });
829
+ await mgr.setCookies(params.cookies, { signal });
833
830
  const text = `Set ${params.cookies.length} cookie(s).`;
834
831
  return {
835
832
  content: [{ type: "text", text }],
@@ -936,7 +933,7 @@ export default function (pi: ExtensionAPI) {
936
933
  }
937
934
 
938
935
  case "set_dialog_behavior": {
939
- const dialogAction = (params.dialogAction as "accept" | "dismiss" | undefined) ?? "dismiss";
936
+ const dialogAction = params.dialogAction ?? "dismiss";
940
937
  mgr.setDialogBehavior(dialogAction, params.promptText);
941
938
  const text = `Dialogs will be ${dialogAction}ed${params.promptText ? ` (prompt text: "${params.promptText}")` : ""}.`;
942
939
  return {
@@ -962,8 +959,9 @@ export default function (pi: ExtensionAPI) {
962
959
  case "list_tabs": {
963
960
  const tabs = await mgr.listTabs();
964
961
  const text = formatTabs(tabs);
962
+ const info = await mgr.getPageInfoAsync();
965
963
  return {
966
- content: [{ type: "text", text }],
964
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, text) }],
967
965
  details: {
968
966
  action,
969
967
  tabCount: tabs.length,
@@ -1044,7 +1042,7 @@ export default function (pi: ExtensionAPI) {
1044
1042
  }
1045
1043
 
1046
1044
  if (details.error) {
1047
- return new Text(theme.fg("error", clipField(details.error)), 0, 0);
1045
+ return new Text(theme.fg("error", clipField(details.error.replace(/[\u0000-\u001f\u007f]/g, " "))), 0, 0);
1048
1046
  }
1049
1047
 
1050
1048
  const imageBlock = result.content.find((c) => c.type === "image");
@@ -1069,7 +1067,7 @@ export default function (pi: ExtensionAPI) {
1069
1067
  "success",
1070
1068
  `Navigated — ${
1071
1069
  details.title
1072
- ? clipField(details.title.replace(/[\u0000-\u001f\u007f]/g, " "))
1070
+ ? sanitizeTitle(details.title)
1073
1071
  : clipField(details.url ?? "ok")
1074
1072
  }`,
1075
1073
  );