@underactive/pi-topping-web-tools 0.1.0 → 0.2.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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared host-permission prompt for web tools.
3
+ *
4
+ * Replaces the duplicated select/session-map logic in fetch_markdown,
5
+ * pdf_extract, and web_browser. Supports durable grants for http/https origins
6
+ * and session-only prompts for file:// origins.
7
+ */
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { isGranted, addGrant, type PermissionScope, DAY_MS, WEEK_MS, MONTH_MS } from "./permission-store.ts";
11
+
12
+ export type PermissionPromptResult = { allowed: true } | { allowed: false; reason: string };
13
+
14
+ type SessionPermissions = Map<string, "allow" | "deny">;
15
+
16
+ /** Prompt the user for host access; `durable` adds 1-day/1-week/30-day options, otherwise session-only. */
17
+ export async function requestHostPermission(
18
+ ctx: ExtensionContext,
19
+ {
20
+ scope,
21
+ label,
22
+ key,
23
+ sessionPermissions,
24
+ durable,
25
+ }: {
26
+ scope: PermissionScope;
27
+ label: string;
28
+ key: string;
29
+ sessionPermissions: SessionPermissions;
30
+ durable: boolean;
31
+ },
32
+ ): Promise<PermissionPromptResult> {
33
+ // Session deny
34
+ const sessionDecision = sessionPermissions.get(key);
35
+ if (sessionDecision === "deny") {
36
+ return { allowed: false, reason: `Denied by user for ${key}` };
37
+ }
38
+
39
+ // Session allow
40
+ if (sessionDecision === "allow") {
41
+ return { allowed: true };
42
+ }
43
+
44
+ // Durable grant
45
+ if (durable && isGranted(scope, key)) {
46
+ return { allowed: true };
47
+ }
48
+
49
+ // No UI — block
50
+ if (!ctx.hasUI) {
51
+ return { allowed: false, reason: `${scope} to ${key} blocked (no UI for confirmation)` };
52
+ }
53
+
54
+ // Prompt
55
+ const options = durable
56
+ ? ["Allow once", "Allow for this session", "Allow for 1 day", "Allow for 1 week", "Allow for 30 days", "Deny"]
57
+ : ["Allow once", "Allow for this session", "Deny"];
58
+
59
+ const choice = await ctx.ui.select(label, options);
60
+
61
+ if (choice === "Allow for this session") {
62
+ sessionPermissions.set(key, "allow");
63
+ return { allowed: true };
64
+ }
65
+
66
+ if (choice === "Allow once") {
67
+ return { allowed: true };
68
+ }
69
+
70
+ if (choice === "Deny") {
71
+ sessionPermissions.set(key, "deny");
72
+ return { allowed: false, reason: `Denied by user for ${key}` };
73
+ }
74
+
75
+ // Durable duration choices
76
+ let ttlMs: number | undefined;
77
+ if (choice === "Allow for 1 day") ttlMs = DAY_MS;
78
+ else if (choice === "Allow for 1 week") ttlMs = WEEK_MS;
79
+ else if (choice === "Allow for 30 days") ttlMs = MONTH_MS;
80
+
81
+ if (ttlMs !== undefined) {
82
+ const persisted = await addGrant(scope, key, ttlMs);
83
+ if (!persisted) {
84
+ // Persist failure — downgrade to session grant.
85
+ sessionPermissions.set(key, "allow");
86
+ ctx.ui.notify(
87
+ `Could not save durable permission; allowing for this session only.`,
88
+ "warning",
89
+ );
90
+ }
91
+ return { allowed: true };
92
+ }
93
+
94
+ // Fallback (unexpected choice)
95
+ sessionPermissions.set(key, "deny");
96
+ return { allowed: false, reason: `Denied by user for ${key}` };
97
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Durable permission store for web tools.
3
+ *
4
+ * Persists user-approved host grants to a JSON file in the agent config
5
+ * directory. Grants expire after their TTL and are pruned on read/write.
6
+ * Storage is global (not cwd-scoped) so cwd-scoped write tools cannot reach it.
7
+ */
8
+
9
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
+ import { readFileSync } from "node:fs";
11
+ import { mkdir, rename, stat, unlink, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { permissionKey } from "./permissions.ts";
14
+
15
+ export type PermissionScope = "fetch_markdown" | "pdf_extract" | "web_browser";
16
+
17
+ export type Grant = {
18
+ scope: PermissionScope;
19
+ origin: string;
20
+ grantedAt: number;
21
+ expiresAt: number;
22
+ };
23
+
24
+ type StoreFile = {
25
+ version: 1;
26
+ grants: Grant[];
27
+ };
28
+
29
+ export const DAY_MS = 24 * 60 * 60 * 1000;
30
+ export const WEEK_MS = 7 * DAY_MS;
31
+ export const MONTH_MS = 30 * DAY_MS;
32
+
33
+ const MAX_FILE_BYTES = 1024 * 1024; // 1 MB
34
+ const MAX_GRANTS = 500;
35
+ const LOCK_RETRY_MS = 50;
36
+ const LOCK_RETRIES = 20; // ~1 s total
37
+ const LOCK_STALE_MS = 10_000;
38
+
39
+ function storePath(): string {
40
+ return process.env.PI_WEB_TOOLS_PERMISSIONS_FILE ?? join(getAgentDir(), "web-permissions.json");
41
+ }
42
+
43
+ function lockPath(): string {
44
+ return `${storePath()}.lock`;
45
+ }
46
+
47
+ function normalizeOrigin(origin: string): string {
48
+ // Round-trip through permissionKey to enforce consistent format.
49
+ return permissionKey(origin);
50
+ }
51
+
52
+ function isValidScope(scope: string): scope is PermissionScope {
53
+ return scope === "fetch_markdown" || scope === "pdf_extract" || scope === "web_browser";
54
+ }
55
+
56
+ /** Read and validate the store file. Returns empty list on any parse/validation failure. */
57
+ export function listGrants(): Grant[] {
58
+ let raw: string;
59
+ try {
60
+ raw = readFileSync(storePath(), "utf-8");
61
+ } catch {
62
+ return [];
63
+ }
64
+
65
+ if (Buffer.byteLength(raw) > MAX_FILE_BYTES) {
66
+ return [];
67
+ }
68
+
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch {
73
+ return [];
74
+ }
75
+
76
+ if (typeof parsed !== "object" || parsed === null) return [];
77
+ const file = parsed as StoreFile;
78
+ if (file.version !== 1 || !Array.isArray(file.grants)) return [];
79
+
80
+ const now = Date.now();
81
+ const valid: Grant[] = [];
82
+ for (const entry of file.grants) {
83
+ if (typeof entry !== "object" || entry === null) continue;
84
+ const g = entry as Record<string, unknown>;
85
+ if (!isValidScope(g.scope as string)) continue;
86
+ if (typeof g.origin !== "string") continue;
87
+ if (typeof g.grantedAt !== "number" || !Number.isFinite(g.grantedAt)) continue;
88
+ if (typeof g.expiresAt !== "number" || !Number.isFinite(g.expiresAt)) continue;
89
+ if (g.expiresAt <= now) continue; // expired
90
+ if (normalizeOrigin(g.origin as string) !== g.origin) continue; // malformed
91
+ valid.push({
92
+ scope: g.scope as PermissionScope,
93
+ origin: g.origin as string,
94
+ grantedAt: g.grantedAt as number,
95
+ expiresAt: g.expiresAt as number,
96
+ });
97
+ }
98
+
99
+ return valid;
100
+ }
101
+
102
+ /** Check whether a specific scope+origin has an active durable grant. */
103
+ export function isGranted(scope: PermissionScope, origin: string): boolean {
104
+ const key = normalizeOrigin(origin);
105
+ return listGrants().some((g) => g.scope === scope && g.origin === key);
106
+ }
107
+
108
+ /** Prune expired entries from a grant list. */
109
+ function pruneExpired(grants: Grant[]): Grant[] {
110
+ const now = Date.now();
111
+ return grants.filter((g) => g.expiresAt > now);
112
+ }
113
+
114
+ /** Attempt to acquire a lock file. Returns true if acquired. */
115
+ async function acquireLock(): Promise<boolean> {
116
+ const lp = lockPath();
117
+ for (let i = 0; i < LOCK_RETRIES; i++) {
118
+ try {
119
+ // wx fails if the file already exists.
120
+ await writeFile(lp, String(process.pid), { flag: "wx" });
121
+ return true;
122
+ } catch {
123
+ // Check for stale lock.
124
+ try {
125
+ const { mtimeMs } = await stat(lp);
126
+ if (Date.now() - mtimeMs > LOCK_STALE_MS) {
127
+ // Stale lock — take it over.
128
+ await writeFile(lp, String(process.pid), { flag: "w" });
129
+ // Verify the takeover won (another process may have raced us).
130
+ if (readFileSync(lp, "utf-8") !== String(process.pid)) continue;
131
+ return true;
132
+ }
133
+ } catch {
134
+ // Lock file disappeared between check and stat — retry.
135
+ }
136
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
137
+ }
138
+ }
139
+ return false;
140
+ }
141
+
142
+ async function releaseLock(): Promise<void> {
143
+ try {
144
+ await unlink(lockPath());
145
+ } catch {
146
+ // ignore
147
+ }
148
+ }
149
+
150
+ async function writeStore(grants: Grant[]): Promise<void> {
151
+ const dir = join(storePath(), "..");
152
+ await mkdir(dir, { recursive: true });
153
+
154
+ const file: StoreFile = { version: 1, grants };
155
+ const tmp = `${storePath()}.tmp`;
156
+ await writeFile(tmp, JSON.stringify(file, null, "\t"), { mode: 0o600 });
157
+ await rename(tmp, storePath());
158
+ }
159
+
160
+ /** Add a durable grant. Returns false if the lock could not be acquired. */
161
+ export async function addGrant(scope: PermissionScope, origin: string, ttlMs: number): Promise<boolean> {
162
+ const normalized = normalizeOrigin(origin);
163
+ const locked = await acquireLock();
164
+ if (!locked) return false;
165
+
166
+ try {
167
+ const grants = pruneExpired(listGrants());
168
+
169
+ // Remove existing grant for same scope+origin.
170
+ const filtered = grants.filter((g) => !(g.scope === scope && g.origin === normalized));
171
+
172
+ const now = Date.now();
173
+ filtered.push({
174
+ scope,
175
+ origin: normalized,
176
+ grantedAt: now,
177
+ expiresAt: now + Math.min(ttlMs, MONTH_MS),
178
+ });
179
+
180
+ // Cap at MAX_GRANTS.
181
+ const capped = filtered.length > MAX_GRANTS ? filtered.slice(-MAX_GRANTS) : filtered;
182
+ await writeStore(capped);
183
+ return true;
184
+ } finally {
185
+ await releaseLock();
186
+ }
187
+ }
188
+
189
+ /** Remove a durable grant. Returns false if the lock could not be acquired. */
190
+ export async function removeGrant(scope: PermissionScope, origin: string): Promise<boolean> {
191
+ const normalized = normalizeOrigin(origin);
192
+ const locked = await acquireLock();
193
+ if (!locked) return false;
194
+
195
+ try {
196
+ const grants = pruneExpired(listGrants());
197
+ const filtered = grants.filter((g) => !(g.scope === scope && g.origin === normalized));
198
+ await writeStore(filtered);
199
+ return true;
200
+ } finally {
201
+ await releaseLock();
202
+ }
203
+ }
@@ -115,6 +115,18 @@ const { HOSTNAME_ONLY, PATH_PREFIXES } = (() => {
115
115
  return { HOSTNAME_ONLY: hosts, PATH_PREFIXES: paths };
116
116
  })();
117
117
 
118
+ export function permissionKey(url: string): string {
119
+ try {
120
+ const parsed = new URL(url);
121
+ if (parsed.protocol === "file:") {
122
+ return `file://${parsed.pathname}`;
123
+ }
124
+ return `${parsed.protocol}//${parsed.host}`;
125
+ } catch {
126
+ return url;
127
+ }
128
+ }
129
+
118
130
  export function isPreapprovedHost(hostname: string, pathname: string): boolean {
119
131
  if (HOSTNAME_ONLY.has(hostname)) return true;
120
132
  const prefixes = PATH_PREFIXES.get(hostname);
@@ -100,12 +100,6 @@ export type ScreenshotResult = {
100
100
  fullPage: boolean;
101
101
  };
102
102
 
103
- /**
104
- * Async mutex that serializes browser operations.
105
- *
106
- * NOTE: `close()` and `forceClose()` bypass this mutex so shutdown is
107
- * never blocked by a queued or in-flight operation.
108
- */
109
103
  export class AsyncMutex {
110
104
  private chain: Promise<void> = Promise.resolve();
111
105
 
@@ -714,15 +708,13 @@ export class BrowserManager {
714
708
  let timerId: ReturnType<typeof setTimeout> | undefined;
715
709
  const timer =
716
710
  timeoutMs > 0
717
- ? Promise.resolve().then(
718
- () =>
719
- new Promise<never>((_, reject) => {
720
- timerId = setTimeout(
721
- () => reject(new Error("Close timed out")),
722
- timeoutMs,
723
- );
724
- }),
725
- )
711
+ ? new Promise<never>((_, reject) => {
712
+ timerId = setTimeout(
713
+ () => reject(new Error("Close timed out")),
714
+ timeoutMs,
715
+ );
716
+ if (typeof timerId.unref === "function") timerId.unref();
717
+ })
726
718
  : Promise.resolve();
727
719
 
728
720
  try {
@@ -814,7 +806,8 @@ export class BrowserManager {
814
806
  const diagnostics = await this.selectorDiagnostics(page, selector, frame);
815
807
  const snippet = await this.pageSnippet(page);
816
808
  const frameNote = frame ? ` within frame "${frame}"` : "";
817
- return `No element matches selector "${selector}"${frameNote}. ${diagnostics}\nPage snippet:\n<untrusted-content url="${page.url()}">\n${snippet.replaceAll("</untrusted-content", "")}\n</untrusted-content>`;
809
+ const safeUrl = page.url().replaceAll('"', "%22");
810
+ return `No element matches selector "${selector}"${frameNote}. ${diagnostics}\nPage snippet:\n<untrusted-content url="${safeUrl}">\n${snippet.replaceAll("</untrusted-content", "")}\n</untrusted-content>`;
818
811
  }
819
812
 
820
813
  private async ensureLaunched(): Promise<void> {
@@ -910,12 +903,6 @@ export class BrowserManager {
910
903
  });
911
904
  }
912
905
 
913
- /**
914
- * Registering a `dialog` listener switches Playwright from auto-dismiss
915
- * to "must be resolved" — an unhandled dialog freezes all further
916
- * actions on the page, so this unconditionally accepts or dismisses
917
- * based on the configured session behavior.
918
- */
919
906
  private attachDialogListener(page: Page): void {
920
907
  page.on("dialog", (dialog) => {
921
908
  const entry: DialogEntry = {
@@ -939,22 +926,22 @@ export class BrowserManager {
939
926
 
940
927
  private pushConsole(entry: ConsoleEntry): void {
941
928
  this.consoleBuffer.push(entry);
942
- if (this.consoleBuffer.length > MAX_BUFFER_ENTRIES) {
943
- this.consoleBuffer.shift();
929
+ if (this.consoleBuffer.length >= MAX_BUFFER_ENTRIES) {
930
+ this.consoleBuffer = this.consoleBuffer.slice(MAX_BUFFER_ENTRIES >> 1);
944
931
  }
945
932
  }
946
933
 
947
934
  private pushNetwork(entry: NetworkEntry): void {
948
935
  this.networkBuffer.push(entry);
949
- if (this.networkBuffer.length > MAX_BUFFER_ENTRIES) {
950
- this.networkBuffer.shift();
936
+ if (this.networkBuffer.length >= MAX_BUFFER_ENTRIES) {
937
+ this.networkBuffer = this.networkBuffer.slice(MAX_BUFFER_ENTRIES >> 1);
951
938
  }
952
939
  }
953
940
 
954
941
  private pushDialog(entry: DialogEntry): void {
955
942
  this.dialogBuffer.push(entry);
956
- if (this.dialogBuffer.length > MAX_BUFFER_ENTRIES) {
957
- this.dialogBuffer.shift();
943
+ if (this.dialogBuffer.length >= MAX_BUFFER_ENTRIES) {
944
+ this.dialogBuffer = this.dialogBuffer.slice(MAX_BUFFER_ENTRIES >> 1);
958
945
  }
959
946
  }
960
947
 
@@ -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,9 @@ 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";
33
35
 
34
36
  const ACTIONS = [
35
37
  "navigate",
@@ -128,15 +130,27 @@ function formatTabs(tabs: TabInfo[]): string {
128
130
  .join("\n");
129
131
  }
130
132
 
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>`;
133
+ function wrapUntrustedContent(url: string, text: string): string {
134
+ const safeUrl = url.replaceAll('"', "%22");
135
+ return `<untrusted-content url="${safeUrl}">\n${text}\n</untrusted-content>`;
134
136
  }
135
137
 
136
138
  function isAbortError(err: unknown): boolean {
137
139
  return err instanceof Error && (err.name === "AbortError" || err.message.includes("Aborted"));
138
140
  }
139
141
 
142
+ function blockReason(permission: string): string {
143
+ return permission.startsWith("block:") ? permission.slice("block:".length) : permission;
144
+ }
145
+
146
+ async function resolveLocalFilePath(input: string): Promise<string> {
147
+ try {
148
+ return await realpath(resolve(input));
149
+ } catch {
150
+ throw new Error(`Cannot upload file: no such file "${input}"`);
151
+ }
152
+ }
153
+
140
154
  async function truncateTextResult(
141
155
  text: string,
142
156
  details: WebBrowserDetails,
@@ -214,7 +228,7 @@ export default function (pi: ExtensionAPI) {
214
228
  if (input.action === "navigate" && input.url) {
215
229
  const permission = await checkUrlPermission(input.url, ctx, sessionPermissions);
216
230
  if (permission !== "allow") {
217
- return { block: true, reason: permission.slice(6) };
231
+ return { block: true, reason: blockReason(permission) };
218
232
  }
219
233
 
220
234
  return undefined;
@@ -224,7 +238,7 @@ export default function (pi: ExtensionAPI) {
224
238
  const info = await getBrowserManager().getPageInfoAsync();
225
239
  const permission = await checkUrlPermission(info.url, ctx, sessionPermissions);
226
240
  if (permission !== "allow") {
227
- return { block: true, reason: permission.slice(6) };
241
+ return { block: true, reason: blockReason(permission) };
228
242
  }
229
243
  }
230
244
 
@@ -247,6 +261,7 @@ export default function (pi: ExtensionAPI) {
247
261
  ` Network issues: ${info.networkCount}`,
248
262
  ` Dialogs captured: ${info.dialogCount}`,
249
263
  ` Session-approved hosts: ${sessionPermissions.size}`,
264
+ ` Saved hosts: ${listGrants().filter((g) => g.scope === "web_browser").length}`,
250
265
  ];
251
266
  ctx.ui.notify(lines.join("\n"), "info");
252
267
  },
@@ -630,8 +645,9 @@ export default function (pi: ExtensionAPI) {
630
645
  const value = await mgr.evaluate(params.script, { signal });
631
646
  const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? String(value);
632
647
  const truncated = await truncateTextResult(text, { action });
648
+ const info = await mgr.getPageInfoAsync();
633
649
  return {
634
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
650
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
635
651
  details: truncated.details,
636
652
  };
637
653
  }
@@ -639,8 +655,9 @@ export default function (pi: ExtensionAPI) {
639
655
  case "get_content": {
640
656
  const html = await mgr.content({ signal });
641
657
  const truncated = await truncateTextResult(html, { action });
658
+ const info = await mgr.getPageInfoAsync();
642
659
  return {
643
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
660
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
644
661
  details: truncated.details,
645
662
  };
646
663
  }
@@ -648,8 +665,9 @@ export default function (pi: ExtensionAPI) {
648
665
  case "get_text": {
649
666
  const text = await mgr.getText(params.selector, { timeout, signal, frame: params.frame });
650
667
  const truncated = await truncateTextResult(text, { action });
668
+ const info = await mgr.getPageInfoAsync();
651
669
  return {
652
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
670
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
653
671
  details: truncated.details,
654
672
  };
655
673
  }
@@ -657,8 +675,9 @@ export default function (pi: ExtensionAPI) {
657
675
  case "get_markdown": {
658
676
  const md = await mgr.getMarkdown({ timeout, signal });
659
677
  const truncated = await truncateTextResult(md, { action });
678
+ const info = await mgr.getPageInfoAsync();
660
679
  return {
661
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
680
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
662
681
  details: truncated.details,
663
682
  };
664
683
  }
@@ -694,8 +713,9 @@ export default function (pi: ExtensionAPI) {
694
713
  frame: params.frame,
695
714
  });
696
715
  const truncated = await truncateTextResult(snapshot, { action });
716
+ const info = await mgr.getPageInfoAsync();
697
717
  return {
698
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
718
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
699
719
  details: truncated.details,
700
720
  };
701
721
  }
@@ -707,8 +727,9 @@ export default function (pi: ExtensionAPI) {
707
727
  action,
708
728
  consoleCount: entries.length,
709
729
  });
730
+ const info = await mgr.getPageInfoAsync();
710
731
  return {
711
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
732
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
712
733
  details: truncated.details,
713
734
  };
714
735
  }
@@ -720,8 +741,9 @@ export default function (pi: ExtensionAPI) {
720
741
  action,
721
742
  networkCount: entries.length,
722
743
  });
744
+ const info = await mgr.getPageInfoAsync();
723
745
  return {
724
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
746
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
725
747
  details: truncated.details,
726
748
  };
727
749
  }
@@ -759,8 +781,8 @@ export default function (pi: ExtensionAPI) {
759
781
  const permission = await checkUrlPermission(cookieUrl, ctx, sessionPermissions);
760
782
  if (permission !== "allow") {
761
783
  return {
762
- content: [{ type: "text", text: permission.slice(6) }],
763
- details: { action, error: permission.slice(6) } satisfies WebBrowserDetails,
784
+ content: [{ type: "text", text: blockReason(permission) }],
785
+ details: { action, error: blockReason(permission) } satisfies WebBrowserDetails,
764
786
  isError: true,
765
787
  };
766
788
  }
@@ -841,6 +863,24 @@ export default function (pi: ExtensionAPI) {
841
863
  isError: true,
842
864
  };
843
865
  }
866
+ for (const file of params.files) {
867
+ const resolved = await resolveLocalFilePath(file);
868
+ if (isInsideCwd(resolved)) continue;
869
+ const permission = await requestHostPermission(ctx, {
870
+ scope: "web_browser",
871
+ label: `Allow web_browser to upload local file ${resolved}?`,
872
+ key: `file://${resolved}`,
873
+ sessionPermissions,
874
+ durable: false,
875
+ });
876
+ if (!permission.allowed) {
877
+ return {
878
+ content: [{ type: "text", text: permission.reason }],
879
+ details: { action, error: permission.reason } satisfies WebBrowserDetails,
880
+ isError: true,
881
+ };
882
+ }
883
+ }
844
884
  const msg = await mgr.uploadFile(params.selector, params.files, {
845
885
  frame: params.frame,
846
886
  timeout,
@@ -870,8 +910,9 @@ export default function (pi: ExtensionAPI) {
870
910
  action,
871
911
  dialogCount: entries.length,
872
912
  });
913
+ const info = await mgr.getPageInfoAsync();
873
914
  return {
874
- content: [{ type: "text", text: await wrapUntrustedContent(mgr, truncated.text) }],
915
+ content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
875
916
  details: truncated.details,
876
917
  };
877
918
  }