@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 +37 -0
- package/README.md +68 -9
- package/package.json +4 -4
- package/src/fetch-markdown.ts +4 -2
- package/src/pdf-extract.ts +6 -9
- package/src/permissions.ts +7 -0
- package/src/render-preview.ts +2 -1
- package/src/tool-exposure.ts +137 -0
- package/src/web-browser/browser-manager.ts +1 -1
- package/src/web-browser/index.ts +39 -41
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
57
|
-
"@earendil-works/pi-coding-agent": "1.0
|
|
58
|
-
"@earendil-works/pi-tui": "1.0
|
|
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",
|
package/src/fetch-markdown.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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;
|
package/src/pdf-extract.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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;
|
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))
|
|
@@ -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(
|
|
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,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
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
?
|
|
1070
|
+
? sanitizeTitle(details.title)
|
|
1073
1071
|
: clipField(details.url ?? "ok")
|
|
1074
1072
|
}`,
|
|
1075
1073
|
);
|