@underactive/pi-topping-web-tools 0.1.0 → 0.2.1

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.
@@ -13,14 +13,13 @@ import {
13
13
  } from "@earendil-works/pi-coding-agent";
14
14
  import { StringEnum } from "@earendil-works/pi-ai";
15
15
  import { Text } from "@earendil-works/pi-tui";
16
- import { mkdtemp, writeFile } from "node:fs/promises";
16
+ import { mkdtemp, realpath, writeFile } from "node:fs/promises";
17
17
  import { tmpdir } from "node:os";
18
- import { join } from "node:path";
18
+ import { join, resolve } from "node:path";
19
19
  import { Type } from "typebox";
20
20
  import {
21
21
  closeBrowserManager,
22
22
  getBrowserManager,
23
- type BrowserManager,
24
23
  type ConsoleEntry,
25
24
  type DialogEntry,
26
25
  type NavigateResult,
@@ -30,6 +29,11 @@ import {
30
29
  type TabInfo,
31
30
  } from "./browser-manager.ts";
32
31
  import { checkUrlPermission, upgradeHttpToHttps, validateURL } from "./permissions.ts";
32
+ import { requestHostPermission } from "../permission-prompt.ts";
33
+ import { listGrants } from "../permission-store.ts";
34
+ import { isInsideCwd } from "../pdf-extract.ts";
35
+ import { suppressNotifications } from "../env.ts";
36
+ import { publishBrowserState, registerBrowserFeeds } from "./feeds.ts";
33
37
 
34
38
  const ACTIONS = [
35
39
  "navigate",
@@ -128,15 +132,27 @@ function formatTabs(tabs: TabInfo[]): string {
128
132
  .join("\n");
129
133
  }
130
134
 
131
- async function wrapUntrustedContent(mgr: BrowserManager, text: string): Promise<string> {
132
- const info = await mgr.getPageInfoAsync();
133
- return `<untrusted-content url="${info.url}">\n${text}\n</untrusted-content>`;
135
+ function wrapUntrustedContent(url: string, text: string): string {
136
+ const safeUrl = url.replaceAll('"', "%22");
137
+ return `<untrusted-content url="${safeUrl}">\n${text}\n</untrusted-content>`;
134
138
  }
135
139
 
136
140
  function isAbortError(err: unknown): boolean {
137
141
  return err instanceof Error && (err.name === "AbortError" || err.message.includes("Aborted"));
138
142
  }
139
143
 
144
+ function blockReason(permission: string): string {
145
+ return permission.startsWith("block:") ? permission.slice("block:".length) : permission;
146
+ }
147
+
148
+ async function resolveLocalFilePath(input: string): Promise<string> {
149
+ try {
150
+ return await realpath(resolve(input));
151
+ } catch {
152
+ throw new Error(`Cannot upload file: no such file "${input}"`);
153
+ }
154
+ }
155
+
140
156
  async function truncateTextResult(
141
157
  text: string,
142
158
  details: WebBrowserDetails,
@@ -184,21 +200,41 @@ function browserStatus(ctx: ExtensionContext, text: string, tone: "success" | "m
184
200
  return ctx.ui.theme.fg(tone, `browser: ${text}`);
185
201
  }
186
202
 
187
- function updateBrowserStatus(ctx: ExtensionContext, info: { url: string; title?: string }): void {
203
+ export function browserLabel(url: string): string {
188
204
  try {
189
- const parsed = new URL(info.url);
190
- const label = parsed.protocol === "file:" ? "local file" : parsed.hostname;
191
- ctx.ui.setStatus("browser", browserStatus(ctx, label, "success"));
205
+ const parsed = new URL(url);
206
+ return parsed.protocol === "file:" ? "local file" : parsed.hostname || "ready";
192
207
  } catch {
193
- ctx.ui.setStatus("browser", browserStatus(ctx, "ready", "muted"));
208
+ return "ready";
209
+ }
210
+ }
211
+
212
+ function syncBrowserOpen(ctx: ExtensionContext, url: string): void {
213
+ const label = browserLabel(url);
214
+ publishBrowserState(label, 1);
215
+ if (!suppressNotifications()) {
216
+ ctx.ui.setStatus("browser", browserStatus(ctx, label, label === "ready" ? "muted" : "success"));
217
+ }
218
+ }
219
+
220
+ function syncBrowserClosed(ctx: ExtensionContext): void {
221
+ publishBrowserState("", 0);
222
+ if (!suppressNotifications()) {
223
+ ctx.ui.setStatus("browser", undefined);
194
224
  }
195
225
  }
196
226
 
197
227
  export default function (pi: ExtensionAPI) {
198
228
  const sessionPermissions = new Map<string, "allow" | "deny">();
229
+ registerBrowserFeeds(pi);
199
230
 
200
231
  pi.on("session_start", async (_event, ctx) => {
201
- ctx.ui.setStatus("browser", undefined);
232
+ const info = await getBrowserManager().getPageInfoAsync();
233
+ if (info.isOpen) {
234
+ syncBrowserOpen(ctx, info.url);
235
+ } else {
236
+ syncBrowserClosed(ctx);
237
+ }
202
238
  });
203
239
 
204
240
  pi.on("session_shutdown", async () => {
@@ -214,7 +250,7 @@ export default function (pi: ExtensionAPI) {
214
250
  if (input.action === "navigate" && input.url) {
215
251
  const permission = await checkUrlPermission(input.url, ctx, sessionPermissions);
216
252
  if (permission !== "allow") {
217
- return { block: true, reason: permission.slice(6) };
253
+ return { block: true, reason: blockReason(permission) };
218
254
  }
219
255
 
220
256
  return undefined;
@@ -224,7 +260,7 @@ export default function (pi: ExtensionAPI) {
224
260
  const info = await getBrowserManager().getPageInfoAsync();
225
261
  const permission = await checkUrlPermission(info.url, ctx, sessionPermissions);
226
262
  if (permission !== "allow") {
227
- return { block: true, reason: permission.slice(6) };
263
+ return { block: true, reason: blockReason(permission) };
228
264
  }
229
265
  }
230
266
 
@@ -247,6 +283,7 @@ export default function (pi: ExtensionAPI) {
247
283
  ` Network issues: ${info.networkCount}`,
248
284
  ` Dialogs captured: ${info.dialogCount}`,
249
285
  ` Session-approved hosts: ${sessionPermissions.size}`,
286
+ ` Saved hosts: ${listGrants().filter((g) => g.scope === "web_browser").length}`,
250
287
  ];
251
288
  ctx.ui.notify(lines.join("\n"), "info");
252
289
  },
@@ -256,7 +293,7 @@ export default function (pi: ExtensionAPI) {
256
293
  description: "Force close the headless browser",
257
294
  handler: async (_args, ctx) => {
258
295
  await closeBrowserManager();
259
- ctx.ui.setStatus("browser", undefined);
296
+ syncBrowserClosed(ctx);
260
297
  ctx.ui.notify("Browser closed", "info");
261
298
  },
262
299
  });
@@ -427,7 +464,7 @@ export default function (pi: ExtensionAPI) {
427
464
  try {
428
465
  if (action === "close") {
429
466
  await closeBrowserManager();
430
- ctx.ui.setStatus("browser", undefined);
467
+ syncBrowserClosed(ctx);
431
468
  return {
432
469
  content: [{ type: "text", text: "Browser closed." }],
433
470
  details: { action } satisfies WebBrowserDetails,
@@ -455,7 +492,7 @@ export default function (pi: ExtensionAPI) {
455
492
 
456
493
  const upgraded = upgradeHttpToHttps(params.url);
457
494
  const result: NavigateResult = await mgr.navigate(upgraded, { timeout, signal });
458
- updateBrowserStatus(ctx, result);
495
+ syncBrowserOpen(ctx, result.url);
459
496
 
460
497
  const text = `Navigated to ${result.url}\nTitle: ${result.title}${result.statusCode ? `\nStatus: ${result.statusCode}` : ""}`;
461
498
  return {
@@ -479,7 +516,7 @@ export default function (pi: ExtensionAPI) {
479
516
  signal,
480
517
  });
481
518
  const info = await mgr.getPageInfoAsync();
482
- updateBrowserStatus(ctx, info);
519
+ syncBrowserOpen(ctx, info.url);
483
520
  const dims = `${shot.width}×${shot.height}`;
484
521
  const dimsNote = `${dims}${shot.fullPage ? " (full page)" : ""}`;
485
522
 
@@ -630,8 +667,9 @@ export default function (pi: ExtensionAPI) {
630
667
  const value = await mgr.evaluate(params.script, { signal });
631
668
  const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? String(value);
632
669
  const truncated = await truncateTextResult(text, { action });
670
+ const info = await mgr.getPageInfoAsync();
633
671
  return {
634
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
672
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
635
673
  details: truncated.details,
636
674
  };
637
675
  }
@@ -639,8 +677,9 @@ export default function (pi: ExtensionAPI) {
639
677
  case "get_content": {
640
678
  const html = await mgr.content({ signal });
641
679
  const truncated = await truncateTextResult(html, { action });
680
+ const info = await mgr.getPageInfoAsync();
642
681
  return {
643
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
682
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
644
683
  details: truncated.details,
645
684
  };
646
685
  }
@@ -648,8 +687,9 @@ export default function (pi: ExtensionAPI) {
648
687
  case "get_text": {
649
688
  const text = await mgr.getText(params.selector, { timeout, signal, frame: params.frame });
650
689
  const truncated = await truncateTextResult(text, { action });
690
+ const info = await mgr.getPageInfoAsync();
651
691
  return {
652
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
692
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
653
693
  details: truncated.details,
654
694
  };
655
695
  }
@@ -657,8 +697,9 @@ export default function (pi: ExtensionAPI) {
657
697
  case "get_markdown": {
658
698
  const md = await mgr.getMarkdown({ timeout, signal });
659
699
  const truncated = await truncateTextResult(md, { action });
700
+ const info = await mgr.getPageInfoAsync();
660
701
  return {
661
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
702
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
662
703
  details: truncated.details,
663
704
  };
664
705
  }
@@ -694,8 +735,9 @@ export default function (pi: ExtensionAPI) {
694
735
  frame: params.frame,
695
736
  });
696
737
  const truncated = await truncateTextResult(snapshot, { action });
738
+ const info = await mgr.getPageInfoAsync();
697
739
  return {
698
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
740
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
699
741
  details: truncated.details,
700
742
  };
701
743
  }
@@ -707,8 +749,9 @@ export default function (pi: ExtensionAPI) {
707
749
  action,
708
750
  consoleCount: entries.length,
709
751
  });
752
+ const info = await mgr.getPageInfoAsync();
710
753
  return {
711
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
754
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
712
755
  details: truncated.details,
713
756
  };
714
757
  }
@@ -720,8 +763,9 @@ export default function (pi: ExtensionAPI) {
720
763
  action,
721
764
  networkCount: entries.length,
722
765
  });
766
+ const info = await mgr.getPageInfoAsync();
723
767
  return {
724
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
768
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
725
769
  details: truncated.details,
726
770
  };
727
771
  }
@@ -759,8 +803,8 @@ export default function (pi: ExtensionAPI) {
759
803
  const permission = await checkUrlPermission(cookieUrl, ctx, sessionPermissions);
760
804
  if (permission !== "allow") {
761
805
  return {
762
- content: [{ type: "text", text: permission.slice(6) }],
763
- details: { action, error: permission.slice(6) } satisfies WebBrowserDetails,
806
+ content: [{ type: "text", text: blockReason(permission) }],
807
+ details: { action, error: blockReason(permission) } satisfies WebBrowserDetails,
764
808
  isError: true,
765
809
  };
766
810
  }
@@ -841,6 +885,24 @@ export default function (pi: ExtensionAPI) {
841
885
  isError: true,
842
886
  };
843
887
  }
888
+ for (const file of params.files) {
889
+ const resolved = await resolveLocalFilePath(file);
890
+ if (isInsideCwd(resolved)) continue;
891
+ const permission = await requestHostPermission(ctx, {
892
+ scope: "web_browser",
893
+ label: `Allow web_browser to upload local file ${resolved}?`,
894
+ key: `file://${resolved}`,
895
+ sessionPermissions,
896
+ durable: false,
897
+ });
898
+ if (!permission.allowed) {
899
+ return {
900
+ content: [{ type: "text", text: permission.reason }],
901
+ details: { action, error: permission.reason } satisfies WebBrowserDetails,
902
+ isError: true,
903
+ };
904
+ }
905
+ }
844
906
  const msg = await mgr.uploadFile(params.selector, params.files, {
845
907
  frame: params.frame,
846
908
  timeout,
@@ -870,8 +932,9 @@ export default function (pi: ExtensionAPI) {
870
932
  action,
871
933
  dialogCount: entries.length,
872
934
  });
935
+ const info = await mgr.getPageInfoAsync();
873
936
  return {
874
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
937
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
875
938
  details: truncated.details,
876
939
  };
877
940
  }
@@ -899,7 +962,7 @@ export default function (pi: ExtensionAPI) {
899
962
  }
900
963
  const msg = await mgr.switchTab(params.index);
901
964
  const info = await mgr.getPageInfoAsync();
902
- updateBrowserStatus(ctx, info);
965
+ syncBrowserOpen(ctx, info.url);
903
966
  return {
904
967
  content: [{ type: "text", text: msg }],
905
968
  details: { action, bytes: Buffer.byteLength(msg) } satisfies WebBrowserDetails,
@@ -7,7 +7,8 @@
7
7
  */
8
8
 
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { isPreapprovedHost } from "../permissions.ts";
10
+ import { isPreapprovedHost, permissionKey } from "../permissions.ts";
11
+ import { requestHostPermission } from "../permission-prompt.ts";
11
12
 
12
13
  export const MAX_URL_LENGTH = 2000;
13
14
 
@@ -44,6 +45,37 @@ export function validateURL(url: string): boolean {
44
45
  return parsed.protocol === "http:" || parsed.protocol === "https:";
45
46
  }
46
47
 
48
+ function isPrivateIpv4Octets(octets: number[]): boolean {
49
+ if (octets.some((n) => n > 255)) {
50
+ return false;
51
+ }
52
+ const [a, b] = octets;
53
+ return (
54
+ a === 127 ||
55
+ a === 0 ||
56
+ a === 10 ||
57
+ (a === 172 && b >= 16 && b <= 31) ||
58
+ (a === 192 && b === 168) ||
59
+ (a === 169 && b === 254)
60
+ );
61
+ }
62
+
63
+ function mappedIpv4Octets(suffix: string): number[] | undefined {
64
+ // Dotted-quad form: ::ffff:10.0.0.1
65
+ const dotted = /^(\d{1,3}(?:\.\d{1,3}){3})$/.exec(suffix);
66
+ if (dotted) {
67
+ return dotted[1].split(".").map((part) => Number.parseInt(part, 10));
68
+ }
69
+ // Two hex groups form: ::ffff:a00:1
70
+ const hex = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(suffix);
71
+ if (hex) {
72
+ const high = Number.parseInt(hex[1], 16);
73
+ const low = Number.parseInt(hex[2], 16);
74
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff];
75
+ }
76
+ return undefined;
77
+ }
78
+
47
79
  export function isLocalOrPrivateHost(hostname: string): boolean {
48
80
  const host =
49
81
  hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
@@ -54,28 +86,23 @@ export function isLocalOrPrivateHost(hostname: string): boolean {
54
86
  if (host.endsWith(".local")) {
55
87
  return true;
56
88
  }
57
- if (/^(fc|fd|fe80:)/i.test(host) || /^::ffff:7f/i.test(host)) {
89
+ if (/^(fc|fd|fe80:)/i.test(host)) {
58
90
  return true;
59
91
  }
60
92
 
61
- if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) {
62
- return false;
93
+ const mapped = /^::ffff:(.+)$/i.exec(host);
94
+ if (mapped) {
95
+ const octets = mappedIpv4Octets(mapped[1]);
96
+ if (octets && isPrivateIpv4Octets(octets)) {
97
+ return true;
98
+ }
63
99
  }
64
100
 
65
- const octets = host.split(".").map((part) => Number.parseInt(part, 10));
66
- if (octets.some((n) => n > 255)) {
101
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) {
67
102
  return false;
68
103
  }
69
104
 
70
- const [a, b] = octets;
71
- if (a === 127) return true;
72
- if (a === 0) return true;
73
- if (a === 10) return true;
74
- if (a === 172 && b >= 16 && b <= 31) return true;
75
- if (a === 192 && b === 168) return true;
76
- if (a === 169 && b === 254) return true;
77
-
78
- return false;
105
+ return isPrivateIpv4Octets(host.split(".").map((part) => Number.parseInt(part, 10)));
79
106
  }
80
107
 
81
108
  export function upgradeHttpToHttps(url: string): string {
@@ -91,18 +118,6 @@ export function upgradeHttpToHttps(url: string): string {
91
118
  return url;
92
119
  }
93
120
 
94
- export function permissionKey(url: string): string {
95
- try {
96
- const parsed = new URL(url);
97
- if (parsed.protocol === "file:") {
98
- return `file://${parsed.pathname}`;
99
- }
100
- return `${parsed.protocol}//${parsed.host}`;
101
- } catch {
102
- return url;
103
- }
104
- }
105
-
106
121
  export type PermissionResult = "allow" | `block:${string}`;
107
122
 
108
123
  export async function checkUrlPermission(
@@ -131,32 +146,19 @@ export async function checkUrlPermission(
131
146
  }
132
147
 
133
148
  const key = permissionKey(url);
134
- const sessionDecision = sessionPermissions.get(key);
135
- if (sessionDecision === "allow") {
149
+ const isDurable = protocol !== "file:";
150
+ const label = isDurable ? `Allow web_browser to ${hostname}?` : `Allow web_browser to local file ${pathname}?`;
151
+
152
+ const result = await requestHostPermission(ctx, {
153
+ scope: "web_browser",
154
+ label,
155
+ key,
156
+ sessionPermissions,
157
+ durable: isDurable,
158
+ });
159
+
160
+ if (result.allowed) {
136
161
  return "allow";
137
162
  }
138
- if (sessionDecision === "deny") {
139
- return `block:Denied by user for ${key}`;
140
- }
141
-
142
- if (!ctx.hasUI) {
143
- return `block:web_browser to ${key} blocked (no UI for confirmation)`;
144
- }
145
-
146
- const label = protocol === "file:" ? `Allow web_browser to local file ${pathname}?` : `Allow web_browser to ${hostname}?`;
147
- const choice = await ctx.ui.select(label, ["Allow once", "Allow for this session", "Deny"]);
148
-
149
- if (choice === "Allow for this session") {
150
- sessionPermissions.set(key, "allow");
151
- return "allow";
152
- }
153
- if (choice === "Deny") {
154
- sessionPermissions.set(key, "deny");
155
- return `block:Denied by user for ${key}`;
156
- }
157
- if (choice === "Allow once") {
158
- return "allow";
159
- }
160
-
161
- return `block:Denied by user for ${key}`;
163
+ return `block:${result.reason}`;
162
164
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * /web-permissions — list and revoke saved web-tool host permissions.
3
+ *
4
+ * Shows all active durable grants from the permission store.
5
+ * Users can select entries to revoke them. Bundled preapproved hosts
6
+ * (from the shared allowlist) are not listed and cannot be deleted here.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { listGrants, removeGrant, type Grant } from "./permission-store.ts";
11
+
12
+ function formatRemaining(expiresAt: number): string {
13
+ const ms = expiresAt - Date.now();
14
+ if (ms <= 0) return "expired";
15
+ const hours = Math.floor(ms / (60 * 60 * 1000));
16
+ const days = Math.floor(hours / 24);
17
+ const remHours = hours % 24;
18
+ if (days > 0) return `${days}d ${remHours}h`;
19
+ return `${hours}h`;
20
+ }
21
+
22
+ function formatEntry(grant: Grant): string {
23
+ return `${grant.origin} \u2014 ${grant.scope} \u2014 expires in ${formatRemaining(grant.expiresAt)}`;
24
+ }
25
+
26
+ export default function (pi: ExtensionAPI) {
27
+ pi.registerCommand("web-permissions", {
28
+ description: "List and revoke saved web-tool host permissions",
29
+ handler: async (_args, ctx) => {
30
+ if (!ctx.hasUI) {
31
+ ctx.ui.notify("The /web-permissions command requires an interactive session.", "warning");
32
+ return;
33
+ }
34
+
35
+ while (true) {
36
+ const grants = listGrants();
37
+
38
+ if (grants.length === 0) {
39
+ ctx.ui.notify(
40
+ "No saved host permissions. Bundled preapproved hosts still apply.",
41
+ "info",
42
+ );
43
+ return;
44
+ }
45
+
46
+ grants.sort((a, b) => a.origin.localeCompare(b.origin) || a.scope.localeCompare(b.scope));
47
+
48
+ const options = [...grants.map(formatEntry), "Done"];
49
+ const choice = await ctx.ui.select("Saved host permissions:", options);
50
+
51
+ if (choice === "Done" || choice === undefined) {
52
+ return;
53
+ }
54
+
55
+ // Find the selected grant by matching the formatted entry.
56
+ const index = grants.findIndex((g) => formatEntry(g) === choice);
57
+ if (index === -1) continue;
58
+
59
+ const grant = grants[index];
60
+ const confirmed = await ctx.ui.confirm(
61
+ "Remove permission?",
62
+ `${grant.scope} \u2192 ${grant.origin}`,
63
+ );
64
+
65
+ if (confirmed) {
66
+ await removeGrant(grant.scope, grant.origin);
67
+ ctx.ui.notify(`Removed permission for ${grant.origin}`, "info");
68
+ }
69
+ // Loop back to show updated list.
70
+ }
71
+ },
72
+ });
73
+ }