@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.
- package/CHANGELOG.md +18 -1
- package/README.md +23 -9
- package/package.json +3 -2
- package/src/abort-utils.ts +17 -0
- package/src/fetch-markdown.ts +42 -96
- 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/index.ts +59 -18
- package/src/web-browser/permissions.ts +55 -53
- package/src/web-permissions.ts +73 -0
|
@@ -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
|
+
}
|
package/src/permissions.ts
CHANGED
|
@@ -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
|
|
718
|
-
(
|
|
719
|
-
new
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
|
943
|
-
this.consoleBuffer.
|
|
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
|
|
950
|
-
this.networkBuffer.
|
|
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
|
|
957
|
-
this.dialogBuffer.
|
|
943
|
+
if (this.dialogBuffer.length >= MAX_BUFFER_ENTRIES) {
|
|
944
|
+
this.dialogBuffer = this.dialogBuffer.slice(MAX_BUFFER_ENTRIES >> 1);
|
|
958
945
|
}
|
|
959
946
|
}
|
|
960
947
|
|
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,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
|
-
|
|
132
|
-
const
|
|
133
|
-
return `<untrusted-content url="${
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
763
|
-
details: { action, error: permission
|
|
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:
|
|
915
|
+
content: [{ type: "text", text: wrapUntrustedContent(info.url, truncated.text) }],
|
|
875
916
|
details: truncated.details,
|
|
876
917
|
};
|
|
877
918
|
}
|