@underactive/pi-topping-web-tools 0.2.6 → 0.2.8

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,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.8] - 2026-10-12
6
+
7
+ ### Added
8
+
9
+ - 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.
10
+ - 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.
11
+
12
+ ### Changed
13
+
14
+ - 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.
15
+ - 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.
16
+ - `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.
17
+
18
+ ### Fixed
19
+
20
+ - `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.
21
+ - `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.
22
+ - `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.
23
+
24
+ ## [0.2.7] - 2026-10-05
25
+
26
+ ### Changed
27
+
28
+ - Tested against pi 1.0.3. No break: the extension-facing declaration surface is unchanged apart from additive `registerToolRenderer`/`ToolRenderers`, pi-ai adds `samplingParamsByThinkingLevel` and renames the Azure provider (unused here), and pi-tui's Home/End default change does not reach these tools.
29
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 1.0.0 to 1.0.3.
30
+ - `web_browser`: the truncated-output file and `toFile` screenshots are written with mode `0600`, matching pi 1.0.3's user-only output files. Both were already unreadable by other users because each sits in a `0700` `mkdtemp` directory.
31
+
5
32
  ## [0.2.6] - 2026-10-02
6
33
 
7
34
  ### Changed
package/README.md CHANGED
@@ -61,6 +61,17 @@ All three tools are `direct`-exposure and pi activates those on registration (`d
61
61
  - 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
62
  - 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
63
 
64
+ ### Codemode tool listing
65
+
66
+ 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()`. 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` with these tools; real sessions that load more extensions need more.
67
+
68
+ ```json
69
+ {
70
+ "codemode.mode": "only",
71
+ "codemode.inlineBudget": 4096
72
+ }
73
+ ```
74
+
64
75
  ## `fetch_markdown`
65
76
 
66
77
  Complements pi's built-in search-backed `web_fetch` with a lightweight, keyless fetch path for public documentation, READMEs, and articles:
@@ -142,7 +153,7 @@ When `screenshot` returns an inline result (`toFile` omitted or `false`), `web_b
142
153
 
143
154
  `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
155
 
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.
156
+ 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
157
 
147
158
  The resize fields are:
148
159
 
@@ -186,7 +197,7 @@ This matches pi's documented example. It leaves a default 1280×720 viewport scr
186
197
  - 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
198
  - 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
199
  - 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.
200
+ - 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
201
  - `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
202
  - 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
203
 
@@ -245,13 +256,13 @@ Commands:
245
256
 
246
257
  ## Security model (shared)
247
258
 
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.
259
+ - **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
260
  - **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
261
  - **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
262
  - **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
263
  - **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
264
  - **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.
265
+ - **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
266
  - **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
267
 
257
268
  ## Limitations
@@ -263,10 +274,6 @@ Commands:
263
274
  - Playwright routing covers HTTP(S) and WebSockets, not browser transports such as WebRTC; OS-level egress sandboxing remains outside this extension's scope.
264
275
  - `pdf_extract` reads only an existing text layer; scanned documents need OCR, which is out of scope.
265
276
 
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
277
  ## Development
271
278
 
272
279
  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.6",
3
+ "version": "0.2.8",
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.0",
57
- "@earendil-works/pi-coding-agent": "1.0.0",
58
- "@earendil-works/pi-tui": "1.0.0",
56
+ "@earendil-works/pi-ai": "1.1.0",
57
+ "@earendil-works/pi-coding-agent": "1.1.0",
58
+ "@earendil-works/pi-tui": "1.1.0",
59
59
  "@types/node": "^24.0.0",
60
60
  "@types/turndown": "^5.0.5",
61
61
  "typebox": "^1.3.3",
@@ -513,7 +513,7 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
513
513
 
514
514
  const offset = Math.max(0, Math.floor(params.offset ?? 0));
515
515
  const slice = sliceContent(response.content, offset);
516
- const sanitizedText = slice.text.replaceAll("</untrusted-content", "");
516
+ const sanitizedText = slice.text.replaceAll(/<\/(untrusted-content)/gi, "<\\/$1");
517
517
  const safeUrl = params.url.replaceAll('"', "%22");
518
518
  const wrappedText = `<untrusted-content url="${safeUrl}">\n${sanitizedText}\n</untrusted-content>`;
519
519
 
@@ -3,13 +3,13 @@
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
15
  import { getWithPermittedRedirects, isPermittedRedirect, sliceContent, validateURL } from "./fetch-markdown.ts";
@@ -110,11 +110,6 @@ export function parsePageRange(spec: string, totalPages: number): number[] {
110
110
  return [...selected].sort((a, b) => a - b);
111
111
  }
112
112
 
113
- export function isInsideCwd(resolvedPath: string): boolean {
114
- const rel = relative(process.cwd(), resolvedPath);
115
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
116
- }
117
-
118
113
  type LocalPdfTarget = {
119
114
  path: string;
120
115
  mtimeMs: number;
@@ -369,7 +364,7 @@ Usage notes:
369
364
 
370
365
  const offset = Math.max(0, Math.floor(params.offset ?? 0));
371
366
  const slice = sliceContent(body, offset);
372
- const sanitizedText = slice.text.replaceAll(/<\/untrusted-content/gi, "");
367
+ const sanitizedText = slice.text.replaceAll(/<\/(untrusted-content)/gi, "<\\/$1");
373
368
  const safeSource = source.replaceAll('"', "%22");
374
369
 
375
370
  return {
@@ -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))
@@ -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,8 +31,7 @@ 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";
40
37
 
@@ -135,9 +132,11 @@ function formatTabs(tabs: TabInfo[]): string {
135
132
  .join("\n");
136
133
  }
137
134
 
135
+ const sanitizeTitle = (title: string) => clipField(title.replace(/[\u0000-\u001f\u007f]/g, " "));
136
+
138
137
  function wrapUntrustedContent(url: string, text: string): string {
139
138
  const safeUrl = url.replaceAll('"', "%22");
140
- const safeText = text.replaceAll(/<\/untrusted-content/gi, "");
139
+ const safeText = text.replaceAll(/<\/(untrusted-content)/gi, "<\\/$1");
141
140
  return `<untrusted-content url="${safeUrl}">\n${safeText}\n</untrusted-content>`;
142
141
  }
143
142
 
@@ -157,6 +156,13 @@ async function resolveLocalFilePath(input: string): Promise<string> {
157
156
  }
158
157
  }
159
158
 
159
+ async function writeTempFile(name: string, data: string | Buffer): Promise<string> {
160
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-web-browser-"));
161
+ const tempFile = join(tempDir, name);
162
+ await writeFile(tempFile, data, { mode: 0o600 });
163
+ return tempFile;
164
+ }
165
+
160
166
  async function truncateTextResult(
161
167
  text: string,
162
168
  details: WebBrowserDetails,
@@ -170,12 +176,7 @@ async function truncateTextResult(
170
176
  const outDetails = { ...details, bytes: truncation.totalBytes };
171
177
 
172
178
  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, "utf-8");
177
- });
178
-
179
+ const tempFile = await writeTempFile("output.txt", text);
179
180
  outDetails.fullOutputPath = tempFile;
180
181
 
181
182
  const truncatedLines = truncation.totalLines - truncation.outputLines;
@@ -190,16 +191,6 @@ async function truncateTextResult(
190
191
  return { text: resultText, details: outDetails };
191
192
  }
192
193
 
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);
199
- });
200
- return tempFile;
201
- }
202
-
203
194
  function browserStatus(ctx: ExtensionContext, text: string, tone: "success" | "muted"): string {
204
195
  return ctx.ui.theme.fg(tone, `browser: ${text}`);
205
196
  }
@@ -293,7 +284,7 @@ export default function (pi: ExtensionAPI) {
293
284
  "Web Browser:",
294
285
  ` Status: ${info.isOpen ? "open" : "closed"}`,
295
286
  ` URL: ${info.url ? clipField(info.url) : "(none)"}`,
296
- ` Title: ${info.title ? clipField(info.title.replace(/[\u0000-\u001f\u007f]/g, " ")) : "(none)"}`,
287
+ ` Title: ${info.title ? sanitizeTitle(info.title) : "(none)"}`,
297
288
  ` Tabs open: ${info.tabCount}`,
298
289
  ` Console messages: ${info.consoleCount}`,
299
290
  ` Network issues: ${info.networkCount}`,
@@ -325,7 +316,7 @@ export default function (pi: ExtensionAPI) {
325
316
  }
326
317
 
327
318
  const shot = await browser.screenshot({ fullPage: true });
328
- const tempFile = await saveScreenshotToTempFile(shot);
319
+ const tempFile = await writeTempFile("screenshot.png", Buffer.from(shot.data, "base64"));
329
320
  const dims = `${shot.width}×${shot.height}`;
330
321
  ctx.ui.notify(`Screenshot saved to: ${tempFile}\nDimensions: ${dims} (full page)`, "info");
331
322
  },
@@ -343,7 +334,7 @@ export default function (pi: ExtensionAPI) {
343
334
  "Use get_content only when DOM structure or attributes are needed; it returns raw HTML.",
344
335
  "On SPA sites, use wait_for after navigate (selector/state or networkidle) before click/type.",
345
336
  "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.",
337
+ "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
338
  "Use get_console_logs when JavaScript errors or console output may explain page behavior.",
348
339
  "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
340
  "Use set_cookies to inject cookies for authenticated flows after manual login.",
@@ -475,7 +466,7 @@ export default function (pi: ExtensionAPI) {
475
466
  annotations: { readOnlyHint: false, openWorldHint: true },
476
467
 
477
468
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
478
- const action = params.action as WebBrowserAction;
469
+ const action = params.action;
479
470
  const timeout = params.timeout;
480
471
 
481
472
  onUpdate?.({ content: [{ type: "text", text: `${action}…` }], details: { action } });
@@ -513,13 +504,14 @@ export default function (pi: ExtensionAPI) {
513
504
  const result: NavigateResult = await mgr.navigate(upgraded, { timeout, signal });
514
505
  syncBrowserOpen(ctx, result.url);
515
506
 
516
- const text = `Navigated to ${result.url}\nTitle: ${result.title}${result.statusCode ? `\nStatus: ${result.statusCode}` : ""}`;
507
+ const title = sanitizeTitle(result.title);
508
+ const text = `Navigated to ${result.url}${result.statusCode ? `\nStatus: ${result.statusCode}` : ""}\n${wrapUntrustedContent(result.url, `Title: ${title}`)}`;
517
509
  return {
518
510
  content: [{ type: "text", text }],
519
511
  details: {
520
512
  action,
521
513
  url: result.url,
522
- title: result.title,
514
+ title,
523
515
  // A same-document navigation has no response; omit the key rather than storing undefined.
524
516
  ...(result.statusCode !== undefined ? { statusCode: result.statusCode } : {}),
525
517
  bytes: Buffer.byteLength(text),
@@ -541,14 +533,14 @@ export default function (pi: ExtensionAPI) {
541
533
  const dimsNote = `${dims}${shot.fullPage ? " (full page)" : ""}`;
542
534
 
543
535
  if (params.toFile) {
544
- const tempFile = await saveScreenshotToTempFile(shot);
536
+ const tempFile = await writeTempFile("screenshot.png", Buffer.from(shot.data, "base64"));
545
537
  const text = `Screenshot saved to: ${tempFile}\nDimensions: ${dimsNote}`;
546
538
  return {
547
539
  content: [{ type: "text", text }],
548
540
  details: {
549
541
  action,
550
542
  url: info.url,
551
- title: info.title,
543
+ title: sanitizeTitle(info.title),
552
544
  screenshotDims: dims,
553
545
  fullOutputPath: tempFile,
554
546
  } satisfies WebBrowserDetails,
@@ -570,7 +562,7 @@ export default function (pi: ExtensionAPI) {
570
562
  details: {
571
563
  action,
572
564
  url: info.url,
573
- title: info.title,
565
+ title: sanitizeTitle(info.title),
574
566
  screenshotDims: dims,
575
567
  } satisfies WebBrowserDetails,
576
568
  };
@@ -603,7 +595,7 @@ export default function (pi: ExtensionAPI) {
603
595
  const msg = await mgr.fill(params.selector, params.text, {
604
596
  timeout,
605
597
  signal,
606
- mode: params.mode as "fill" | "press" | undefined,
598
+ mode: params.mode,
607
599
  frame: params.frame,
608
600
  });
609
601
  return {
@@ -734,7 +726,7 @@ export default function (pi: ExtensionAPI) {
734
726
  }
735
727
  const msg = await mgr.waitFor({
736
728
  selector: params.selector,
737
- state: params.state as "visible" | "hidden" | "attached" | "detached" | undefined,
729
+ state: params.state,
738
730
  networkidle: params.networkidle,
739
731
  timeout,
740
732
  signal,
@@ -829,7 +821,7 @@ export default function (pi: ExtensionAPI) {
829
821
  };
830
822
  }
831
823
  }
832
- await mgr.setCookies(params.cookies as SetCookieParam[], { signal });
824
+ await mgr.setCookies(params.cookies, { signal });
833
825
  const text = `Set ${params.cookies.length} cookie(s).`;
834
826
  return {
835
827
  content: [{ type: "text", text }],
@@ -936,7 +928,7 @@ export default function (pi: ExtensionAPI) {
936
928
  }
937
929
 
938
930
  case "set_dialog_behavior": {
939
- const dialogAction = (params.dialogAction as "accept" | "dismiss" | undefined) ?? "dismiss";
931
+ const dialogAction = params.dialogAction ?? "dismiss";
940
932
  mgr.setDialogBehavior(dialogAction, params.promptText);
941
933
  const text = `Dialogs will be ${dialogAction}ed${params.promptText ? ` (prompt text: "${params.promptText}")` : ""}.`;
942
934
  return {
@@ -962,8 +954,9 @@ export default function (pi: ExtensionAPI) {
962
954
  case "list_tabs": {
963
955
  const tabs = await mgr.listTabs();
964
956
  const text = formatTabs(tabs);
957
+ const info = await mgr.getPageInfoAsync();
965
958
  return {
966
- content: [{ type: "text", text }],
959
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, text) }],
967
960
  details: {
968
961
  action,
969
962
  tabCount: tabs.length,
@@ -1044,7 +1037,7 @@ export default function (pi: ExtensionAPI) {
1044
1037
  }
1045
1038
 
1046
1039
  if (details.error) {
1047
- return new Text(theme.fg("error", clipField(details.error)), 0, 0);
1040
+ return new Text(theme.fg("error", clipField(details.error.replace(/[\u0000-\u001f\u007f]/g, " "))), 0, 0);
1048
1041
  }
1049
1042
 
1050
1043
  const imageBlock = result.content.find((c) => c.type === "image");
@@ -1069,7 +1062,7 @@ export default function (pi: ExtensionAPI) {
1069
1062
  "success",
1070
1063
  `Navigated — ${
1071
1064
  details.title
1072
- ? clipField(details.title.replace(/[\u0000-\u001f\u007f]/g, " "))
1065
+ ? sanitizeTitle(details.title)
1073
1066
  : clipField(details.url ?? "ok")
1074
1067
  }`,
1075
1068
  );