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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-10-21
6
+
7
+ ### Changed
8
+
9
+ - Tested against pi 1.4.0. No break: the extension-facing declaration surface (`ExtensionAPI`, `ToolDefinition`, `ExtensionContext`, `ExtensionUIContext`) is unchanged, pi-ai and pi-tui changes are additive or internal, and the provider-header, summary and classifier changes do not reach these tools. Concurrent extension dialogs no longer replace each other on 1.4.0, so parallel host-permission prompts from several tool calls all settle.
10
+ - Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 1.3.0 to 1.4.0.
11
+ - Depend on `@underactive/pi-topping-primitives` `^0.10.0` (was `^0.9.0`). Nothing it imports changed in between: 0.10.0 removed only the `pi-topping-compact` entry names from `./contracts`, which it never referenced. Every topping now asks for the same range, so an install of several shares one copy of the library. No visible change.
12
+ - Tool-exposure settings are read through the shared `@underactive/pi-topping-primitives` 0.2.0 settings store, and the permission grant store writes through its `atomicWriteFile` (unique temp names, cleaned up on failure). An empty `PI_WEB_TOOLS_SETTINGS_FILE` is now treated as unset instead of as a path. The grant store keeps its own lock file and format.
13
+ - `web_browser`: source the session-feed entry type and payload from `@underactive/pi-topping-primitives` 0.1.0; the feed's wire format is unchanged.
14
+ - Depend on `@underactive/pi-topping-primitives` `^0.9.0`. The environment-flag helpers (`isTruthyEnvFlag`, `suppressNotifications`) and `combineSignals` now come from its `./env` and `./abort` subpaths. Behaviour is unchanged except that a timeout above 2^31 - 1 ms is clamped to that instead of aborting the request after about a millisecond, and a timeout that `AbortSignal.timeout` rejects no longer leaves a stale entry in the active-fetch set.
15
+ - `web_browser`: keep the event loop open while `close()` waits on a stalled cleanup, preventing the process from hanging on shutdown.
16
+
5
17
  ## [0.2.9] - 2026-10-16
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -309,7 +309,7 @@ Commands:
309
309
  ## Security model (shared)
310
310
 
311
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.
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.
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. Parallel tool calls that each need a prompt rely on pi 1.4.0, which stacks concurrent extension dialogs so each one settles; on older pi a second dialog replaced the first and the first prompt never settled.
313
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.
314
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.
315
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-web-tools",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
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",
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "typecheck": "tsc --noEmit",
31
- "test": "node --test test/*.test.ts"
31
+ "test": "node -C compiled --test test/*.test.ts"
32
32
  },
33
33
  "pi": {
34
34
  "extensions": [
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@mozilla/readability": "^0.6.0",
43
+ "@underactive/pi-topping-primitives": "^0.10.0",
43
44
  "linkedom": "^0.18.13",
44
45
  "lru-cache": "^11.0.0",
45
46
  "playwright": "^1.52.0",
@@ -53,9 +54,9 @@
53
54
  "typebox": "*"
54
55
  },
55
56
  "devDependencies": {
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",
57
+ "@earendil-works/pi-ai": "1.4.0",
58
+ "@earendil-works/pi-coding-agent": "1.4.0",
59
+ "@earendil-works/pi-tui": "1.4.0",
59
60
  "@types/node": "^24.0.0",
60
61
  "@types/turndown": "^5.0.5",
61
62
  "typebox": "^1.3.3",
@@ -1,17 +1,3 @@
1
- export function combineSignals(
2
- userSignal: AbortSignal | undefined,
3
- timeoutMs: number,
4
- activeFetches: Set<AbortController>,
5
- ): { signal: AbortSignal; cleanup: () => void } {
6
- const controller = new AbortController();
7
- // Register so the caller's cleanup can abort this request during shutdown/reload.
8
- activeFetches.add(controller);
9
- const signals: AbortSignal[] = [controller.signal, AbortSignal.timeout(timeoutMs)];
10
- if (userSignal) signals.push(userSignal);
11
- return {
12
- signal: AbortSignal.any(signals),
13
- cleanup: () => {
14
- activeFetches.delete(controller);
15
- },
16
- };
17
- }
1
+ // A request signal that aborts on the caller, a timeout, or shutdown, shared. The callers here
2
+ // keep importing from this file.
3
+ export { combineSignals } from "@underactive/pi-topping-primitives/abort";
package/src/env.ts CHANGED
@@ -1,10 +1,6 @@
1
- // Same truthiness rules as pi core's isTruthyEnvFlag.
2
- export function isTruthyEnvFlag(value: string | undefined): boolean {
3
- if (!value) return false;
4
- return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
5
- }
6
-
7
- /** Set when pi suppresses non-essential notifications. */
8
- export function suppressNotifications(env: NodeJS.ProcessEnv = process.env): boolean {
9
- return isTruthyEnvFlag(env.PI_SUPPRESS_NOTIFICATIONS);
10
- }
1
+ // Pi's environment-flag rule and the notification switch, shared. The callers here keep
2
+ // importing from this file.
3
+ export {
4
+ isTruthyEnvFlag,
5
+ suppressNotifications,
6
+ } from "@underactive/pi-topping-primitives/env";
@@ -8,8 +8,9 @@
8
8
 
9
9
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
10
  import { readFileSync } from "node:fs";
11
- import { mkdir, rename, stat, unlink, writeFile } from "node:fs/promises";
11
+ import { stat, unlink, writeFile } from "node:fs/promises";
12
12
  import { join } from "node:path";
13
+ import { atomicWriteFile } from "@underactive/pi-topping-primitives/settings";
13
14
  import { permissionKey } from "./permissions.ts";
14
15
 
15
16
  export type PermissionScope = "fetch_markdown" | "pdf_extract" | "web_browser";
@@ -169,13 +170,8 @@ async function releaseLock(): Promise<void> {
169
170
  }
170
171
 
171
172
  async function writeStore(grants: Grant[]): Promise<void> {
172
- const dir = join(storePath(), "..");
173
- await mkdir(dir, { recursive: true });
174
-
175
173
  const file: StoreFile = { version: 1, grants };
176
- const tmp = `${storePath()}.tmp`;
177
- await writeFile(tmp, JSON.stringify(file, null, "\t"), { mode: 0o600 });
178
- await rename(tmp, storePath());
174
+ atomicWriteFile(storePath(), JSON.stringify(file, null, "\t"));
179
175
  }
180
176
 
181
177
  /** Add a durable grant. Returns false if the lock could not be acquired. */
@@ -3,9 +3,8 @@
3
3
  * Default remains Pi's direct registration. Other modes are opt-in via settings.json.
4
4
  */
5
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";
6
+ import type { ExtensionAPI, ToolDefinition, ToolExposure, ToolNamespace } from "@earendil-works/pi-coding-agent";
7
+ import { createSettingsStore, sanitize, toppingSettingsPath } from "@underactive/pi-topping-primitives/settings";
9
8
 
10
9
  export type WebToolsExposureMode = "direct" | "codemode" | "deferred";
11
10
 
@@ -23,8 +22,13 @@ export const WEB_TOOLS_NAMESPACE: ToolNamespace = {
23
22
 
24
23
  const MAX_SETTINGS_BYTES = 64 * 1024;
25
24
 
25
+ const defaultSettingsPath = toppingSettingsPath("pi-topping-web-tools");
26
+ const SETTINGS_PATH_ENV = "PI_WEB_TOOLS_SETTINGS_FILE";
27
+
28
+ /** `PI_WEB_TOOLS_SETTINGS_FILE` wins when non-empty; otherwise the agent-dir default. */
26
29
  export function settingsPath(): string {
27
- return process.env.PI_WEB_TOOLS_SETTINGS_FILE ?? join(getAgentDir(), "pi-topping-web-tools", "settings.json");
30
+ const override = process.env[SETTINGS_PATH_ENV];
31
+ return override ? override : defaultSettingsPath();
28
32
  }
29
33
 
30
34
  export function parseToolExposureMode(value: unknown): WebToolsExposureMode {
@@ -34,30 +38,20 @@ export function parseToolExposureMode(value: unknown): WebToolsExposureMode {
34
38
  return "direct";
35
39
  }
36
40
 
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
- }
41
+ function exposureStore(path?: string) {
42
+ return createSettingsStore<WebToolsExposureMode>({
43
+ path: path === undefined ? defaultSettingsPath : () => path,
44
+ ...(path === undefined ? { pathEnv: SETTINGS_PATH_ENV } : {}),
45
+ maxBytes: MAX_SETTINGS_BYTES,
46
+ codec: {
47
+ sanitize: (raw) => (sanitize.isRecord(raw) ? parseToolExposureMode(raw.toolExposure) : "direct"),
48
+ },
49
+ });
50
+ }
59
51
 
60
- return parseToolExposureMode((parsed as { toolExposure?: unknown }).toolExposure);
52
+ /** A missing, oversized, corrupt or unrecognised file means Pi's direct registration. */
53
+ export function loadToolExposureMode(path?: string): WebToolsExposureMode {
54
+ return exposureStore(path).read();
61
55
  }
62
56
 
63
57
  export function webToolPresentation(mode: WebToolsExposureMode = loadToolExposureMode()): {
@@ -1,11 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
-
3
- export const BROWSER_FEED_TYPE = "pi-topping-web-tools/browser";
4
-
5
- export interface BrowserFeedPayload {
6
- label: string;
7
- open: 0 | 1;
8
- }
2
+ import { ENTRY_TYPES, type BrowserFeedPayload } from "@underactive/pi-topping-primitives/contracts";
9
3
 
10
4
  let extension: ExtensionAPI | undefined;
11
5
  let latestPayload: BrowserFeedPayload | undefined;
@@ -19,7 +13,7 @@ function samePayload(left: BrowserFeedPayload | undefined, right: BrowserFeedPay
19
13
  function emitBrowserState(force = false): void {
20
14
  if (!extension || !latestPayload || (!force && samePayload(emittedPayload, latestPayload))) return;
21
15
  try {
22
- extension.appendEntry(BROWSER_FEED_TYPE, latestPayload);
16
+ extension.appendEntry(ENTRY_TYPES.browser, latestPayload);
23
17
  emittedPayload = latestPayload;
24
18
  } catch {
25
19
  // Feed publication is best effort; leave emitted state unchanged for retry.