@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 +27 -0
- package/README.md +15 -8
- package/package.json +4 -4
- package/src/fetch-markdown.ts +1 -1
- package/src/pdf-extract.ts +3 -8
- package/src/permissions.ts +7 -0
- package/src/render-preview.ts +2 -1
- package/src/web-browser/browser-manager.ts +1 -1
- package/src/web-browser/index.ts +30 -37
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
|
|
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
|
|
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.
|
|
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.
|
|
57
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
58
|
-
"@earendil-works/pi-tui": "1.
|
|
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",
|
package/src/fetch-markdown.ts
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/pdf-extract.ts
CHANGED
|
@@ -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,
|
|
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 {
|
package/src/permissions.ts
CHANGED
|
@@ -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);
|
package/src/render-preview.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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> {
|
package/src/web-browser/index.ts
CHANGED
|
@@ -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
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
?
|
|
1065
|
+
? sanitizeTitle(details.title)
|
|
1073
1066
|
: clipField(details.url ?? "ok")
|
|
1074
1067
|
}`,
|
|
1075
1068
|
);
|