@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/pdf-extract.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Fills a gap the other web tools cannot: fetch_markdown classifies application/pdf
|
|
5
|
-
* as binary and pi's read tool cannot parse PDFs at all.
|
|
6
|
-
*
|
|
7
|
-
* Security: remote URLs reuse fetch_markdown's validation, redirect rules, and the
|
|
8
|
-
* preapproved-host allowlist. Local paths are resolved through realpath before any
|
|
9
|
-
* check so symlinks cannot escape, and only files inside cwd skip the prompt.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { defineTool, formatSize, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
/** Security: remote URLs reuse fetch_markdown's validation, redirect rules, and preapproved-host allowlist; local paths are realpath-resolved and only files inside cwd skip the prompt. */
|
|
2
|
+
|
|
3
|
+
import { defineTool, formatSize, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
4
|
import { Text } from "@earendil-works/pi-tui";
|
|
14
5
|
import { LRUCache } from "lru-cache";
|
|
15
6
|
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
@@ -17,11 +8,11 @@ import { readFile, realpath, stat } from "node:fs/promises";
|
|
|
17
8
|
import { fileURLToPath } from "node:url";
|
|
18
9
|
import { Type } from "typebox";
|
|
19
10
|
import { extractText, getDocumentProxy } from "unpdf";
|
|
20
|
-
import {
|
|
11
|
+
import { combineSignals } from "./abort-utils.ts";
|
|
12
|
+
import { isPreapprovedHost, permissionKey } from "./permissions.ts";
|
|
13
|
+
import { requestHostPermission } from "./permission-prompt.ts";
|
|
21
14
|
import { getWithPermittedRedirects, isPermittedRedirect, sliceContent, validateURL } from "./fetch-markdown.ts";
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
// --- Constants ---
|
|
15
|
+
import { upgradeHttpToHttps } from "./web-browser/permissions.ts";
|
|
25
16
|
|
|
26
17
|
const MAX_PDF_BYTES = 25 * 1024 * 1024;
|
|
27
18
|
const MAX_PDF_PAGES = 2000;
|
|
@@ -34,8 +25,6 @@ const PDF_MAGIC = "%PDF-";
|
|
|
34
25
|
// The PDF spec tolerates leading bytes before the header, and so does PDF.js.
|
|
35
26
|
const MAGIC_SEARCH_WINDOW = 1024;
|
|
36
27
|
|
|
37
|
-
// --- Cache ---
|
|
38
|
-
|
|
39
28
|
type PdfCacheEntry = {
|
|
40
29
|
pages: string[];
|
|
41
30
|
totalPages: number;
|
|
@@ -71,24 +60,6 @@ export function _cleanup(): void {
|
|
|
71
60
|
_activeFetches.clear();
|
|
72
61
|
}
|
|
73
62
|
|
|
74
|
-
// --- Helpers ---
|
|
75
|
-
|
|
76
|
-
function combineSignals(userSignal: AbortSignal | undefined): { signal: AbortSignal; cleanup: () => void } {
|
|
77
|
-
const controller = new AbortController();
|
|
78
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
79
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
80
|
-
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
|
81
|
-
_activeFetches.add(controller);
|
|
82
|
-
const signal = userSignal ? AbortSignal.any([userSignal, controller.signal]) : controller.signal;
|
|
83
|
-
return {
|
|
84
|
-
signal,
|
|
85
|
-
cleanup: () => {
|
|
86
|
-
clearTimeout(timer);
|
|
87
|
-
_activeFetches.delete(controller);
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
63
|
async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
93
64
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
94
65
|
try {
|
|
@@ -96,7 +67,7 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, message: string):
|
|
|
96
67
|
promise,
|
|
97
68
|
new Promise<never>((_resolve, reject) => {
|
|
98
69
|
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
99
|
-
|
|
70
|
+
timer.unref();
|
|
100
71
|
}),
|
|
101
72
|
]);
|
|
102
73
|
} finally {
|
|
@@ -177,7 +148,7 @@ export async function resolveLocalPdf(input: string): Promise<LocalPdfTarget> {
|
|
|
177
148
|
}
|
|
178
149
|
|
|
179
150
|
async function fetchPdfBytes(url: string, userSignal: AbortSignal | undefined): Promise<Uint8Array> {
|
|
180
|
-
const { signal, cleanup } = combineSignals(userSignal);
|
|
151
|
+
const { signal, cleanup } = combineSignals(userSignal, FETCH_TIMEOUT_MS, _activeFetches);
|
|
181
152
|
let response: Awaited<ReturnType<typeof getWithPermittedRedirects>>;
|
|
182
153
|
try {
|
|
183
154
|
response = await getWithPermittedRedirects(url, signal, isPermittedRedirect, {
|
|
@@ -289,8 +260,6 @@ async function loadPdf(
|
|
|
289
260
|
return { ...entry, cached: false, source };
|
|
290
261
|
}
|
|
291
262
|
|
|
292
|
-
// --- Tool ---
|
|
293
|
-
|
|
294
263
|
interface PdfExtractDetails {
|
|
295
264
|
source: string;
|
|
296
265
|
totalPages: number;
|
|
@@ -399,9 +368,10 @@ Usage notes:
|
|
|
399
368
|
const offset = Math.max(0, Math.floor(params.offset ?? 0));
|
|
400
369
|
const slice = sliceContent(body, offset);
|
|
401
370
|
const sanitizedText = slice.text.replaceAll("</untrusted-content", "");
|
|
371
|
+
const safeSource = source.replaceAll('"', "%22");
|
|
402
372
|
|
|
403
373
|
return {
|
|
404
|
-
content: [{ type: "text" as const, text: `<untrusted-content source="${
|
|
374
|
+
content: [{ type: "text" as const, text: `<untrusted-content source="${safeSource}">\n${sanitizedText}\n</untrusted-content>` }],
|
|
405
375
|
details: {
|
|
406
376
|
...baseDetails,
|
|
407
377
|
totalChars: slice.totalChars,
|
|
@@ -468,39 +438,6 @@ Usage notes:
|
|
|
468
438
|
},
|
|
469
439
|
});
|
|
470
440
|
|
|
471
|
-
// --- Permission gating ---
|
|
472
|
-
|
|
473
|
-
async function promptForAccess(
|
|
474
|
-
ctx: ExtensionContext,
|
|
475
|
-
label: string,
|
|
476
|
-
key: string,
|
|
477
|
-
sessionPermissions: Map<string, "allow" | "deny">,
|
|
478
|
-
): Promise<{ block: true; reason: string } | undefined> {
|
|
479
|
-
const sessionDecision = sessionPermissions.get(key);
|
|
480
|
-
if (sessionDecision === "allow") return undefined;
|
|
481
|
-
if (sessionDecision === "deny") return { block: true, reason: `Denied by user for ${key}` };
|
|
482
|
-
|
|
483
|
-
if (!ctx.hasUI) {
|
|
484
|
-
return { block: true, reason: `pdf_extract from ${key} blocked (no UI for confirmation)` };
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const choice = await ctx.ui.select(label, ["Allow once", "Allow for this session", "Deny"]);
|
|
488
|
-
|
|
489
|
-
if (choice === "Allow for this session") {
|
|
490
|
-
sessionPermissions.set(key, "allow");
|
|
491
|
-
return undefined;
|
|
492
|
-
}
|
|
493
|
-
if (choice === "Allow once") {
|
|
494
|
-
return undefined;
|
|
495
|
-
}
|
|
496
|
-
if (choice === "Deny") {
|
|
497
|
-
sessionPermissions.set(key, "deny");
|
|
498
|
-
}
|
|
499
|
-
return { block: true, reason: `Denied by user for ${key}` };
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// --- Extension factory ---
|
|
503
|
-
|
|
504
441
|
export default function (pi: ExtensionAPI) {
|
|
505
442
|
const sessionPermissions = new Map<string, "allow" | "deny">();
|
|
506
443
|
|
|
@@ -522,12 +459,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
522
459
|
return undefined;
|
|
523
460
|
}
|
|
524
461
|
if (isPreapprovedHost(hostname, pathname)) return undefined;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
`Allow pdf_extract from ${hostname}?`,
|
|
528
|
-
permissionKey(input.url),
|
|
462
|
+
const result = await requestHostPermission(ctx, {
|
|
463
|
+
scope: "pdf_extract",
|
|
464
|
+
label: `Allow pdf_extract from ${hostname}?`,
|
|
465
|
+
key: permissionKey(input.url),
|
|
529
466
|
sessionPermissions,
|
|
530
|
-
|
|
467
|
+
durable: true,
|
|
468
|
+
});
|
|
469
|
+
if (!result.allowed) {
|
|
470
|
+
return { block: true, reason: result.reason };
|
|
471
|
+
}
|
|
472
|
+
return undefined;
|
|
531
473
|
}
|
|
532
474
|
|
|
533
475
|
if (input.path) {
|
|
@@ -539,12 +481,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
539
481
|
}
|
|
540
482
|
// Files in the working directory carry the same trust as the read tool.
|
|
541
483
|
if (isInsideCwd(target.path)) return undefined;
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
`Allow pdf_extract to read local file ${target.path}?`,
|
|
545
|
-
`file://${target.path}`,
|
|
484
|
+
const result = await requestHostPermission(ctx, {
|
|
485
|
+
scope: "pdf_extract",
|
|
486
|
+
label: `Allow pdf_extract to read local file ${target.path}?`,
|
|
487
|
+
key: `file://${target.path}`,
|
|
546
488
|
sessionPermissions,
|
|
547
|
-
|
|
489
|
+
durable: false,
|
|
490
|
+
});
|
|
491
|
+
if (!result.allowed) {
|
|
492
|
+
return { block: true, reason: result.reason };
|
|
493
|
+
}
|
|
494
|
+
return undefined;
|
|
548
495
|
}
|
|
549
496
|
|
|
550
497
|
return undefined;
|
|
@@ -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
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const BROWSER_FEED_TYPE = "pi-topping-web-tools/browser";
|
|
4
|
+
|
|
5
|
+
export interface BrowserFeedPayload {
|
|
6
|
+
label: string;
|
|
7
|
+
open: 0 | 1;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let extension: ExtensionAPI | undefined;
|
|
11
|
+
let latestPayload: BrowserFeedPayload | undefined;
|
|
12
|
+
let emittedPayload: BrowserFeedPayload | undefined;
|
|
13
|
+
let publishAfterFirstTurn = false;
|
|
14
|
+
|
|
15
|
+
function samePayload(left: BrowserFeedPayload | undefined, right: BrowserFeedPayload): boolean {
|
|
16
|
+
return left?.label === right.label && left.open === right.open;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function emitBrowserState(force = false): void {
|
|
20
|
+
if (!extension || !latestPayload || (!force && samePayload(emittedPayload, latestPayload))) return;
|
|
21
|
+
try {
|
|
22
|
+
extension.appendEntry(BROWSER_FEED_TYPE, latestPayload);
|
|
23
|
+
emittedPayload = latestPayload;
|
|
24
|
+
} catch {
|
|
25
|
+
// Feed publication is best effort; leave emitted state unchanged for retry.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerBrowserFeeds(pi: ExtensionAPI): void {
|
|
30
|
+
extension = pi;
|
|
31
|
+
pi.on("session_start", () => {
|
|
32
|
+
emittedPayload = undefined;
|
|
33
|
+
publishAfterFirstTurn = true;
|
|
34
|
+
});
|
|
35
|
+
pi.on("turn_end", () => {
|
|
36
|
+
if (!publishAfterFirstTurn) return;
|
|
37
|
+
publishAfterFirstTurn = false;
|
|
38
|
+
emitBrowserState(true);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function publishBrowserState(label: string, open: boolean | number): void {
|
|
43
|
+
latestPayload = { label, open: open ? 1 : 0 };
|
|
44
|
+
emitBrowserState();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resetBrowserFeedState(): void {
|
|
48
|
+
extension = undefined;
|
|
49
|
+
latestPayload = undefined;
|
|
50
|
+
emittedPayload = undefined;
|
|
51
|
+
publishAfterFirstTurn = false;
|
|
52
|
+
}
|