@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.
- package/CHANGELOG.md +28 -1
- package/README.md +44 -9
- package/package.json +3 -2
- package/src/abort-utils.ts +17 -0
- package/src/browser-headers.ts +44 -0
- package/src/env.ts +10 -0
- package/src/fetch-markdown.ts +44 -98
- package/src/pdf-extract.ts +31 -84
- package/src/permission-prompt.ts +97 -0
- package/src/permission-store.ts +203 -0
- package/src/permissions.ts +12 -0
- package/src/web-browser/browser-manager.ts +15 -28
- package/src/web-browser/feeds.ts +52 -0
- package/src/web-browser/index.ts +92 -29
- package/src/web-browser/permissions.ts +55 -53
- package/src/web-permissions.ts +73 -0
package/src/web-browser/index.ts
CHANGED
|
@@ -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
|
-
|
|
132
|
-
const
|
|
133
|
-
return `<untrusted-content url="${
|
|
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
|
|
203
|
+
export function browserLabel(url: string): string {
|
|
188
204
|
try {
|
|
189
|
-
const parsed = new URL(
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
763
|
-
details: { action, error: permission
|
|
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:
|
|
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
|
-
|
|
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)
|
|
89
|
+
if (/^(fc|fd|fe80:)/i.test(host)) {
|
|
58
90
|
return true;
|
|
59
91
|
}
|
|
60
92
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
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
|
+
}
|