@narumitw/pi-chrome-devtools 0.53.2 → 0.53.3
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/README.md +1 -0
- package/dist/chunks/{browser-settings-menu-IMB6HXSU.js → browser-settings-menu-SR7KLZT6.js} +16 -42
- package/dist/chunks/browser-settings-menu-SR7KLZT6.js.map +7 -0
- package/dist/chunks/{chunk-GTTYS5GT.js → chunk-4KMN4SFF.js} +2 -2
- package/dist/chunks/chunk-4KMN4SFF.js.map +7 -0
- package/dist/chunks/{chunk-SRT3UUIP.js → chunk-F5VG35UI.js} +23 -74
- package/dist/chunks/chunk-F5VG35UI.js.map +7 -0
- package/dist/chunks/{chunk-LLXOCRNF.js → chunk-MZCGM6D3.js} +12 -33
- package/dist/chunks/chunk-MZCGM6D3.js.map +7 -0
- package/dist/chunks/{menu-UJETYMSB.js → menu-YR4VRT7D.js} +10 -23
- package/dist/chunks/menu-YR4VRT7D.js.map +7 -0
- package/dist/chunks/{tools-6P7EUX37.js → tools-6R7Z6HWJ.js} +17 -59
- package/dist/chunks/tools-6R7Z6HWJ.js.map +7 -0
- package/dist/index.ts +18 -42
- package/dist/index.ts.map +2 -2
- package/package.json +54 -55
- package/src/browser-manager.ts +872 -942
- package/src/browser-settings-menu.ts +315 -350
- package/src/cdp-client.ts +381 -398
- package/src/chrome-devtools.ts +205 -232
- package/src/display.ts +26 -29
- package/src/lazy-tools.ts +158 -181
- package/src/menu.ts +409 -446
- package/src/render.ts +72 -81
- package/src/runtime.ts +137 -140
- package/src/screenshot.ts +170 -186
- package/src/settings.ts +472 -523
- package/src/tool-names.ts +8 -14
- package/src/tool-selector.ts +242 -271
- package/src/tools.ts +218 -226
- package/src/webmcp/discovery.ts +184 -188
- package/src/webmcp/policy.ts +296 -329
- package/src/webmcp/protocol.ts +324 -337
- package/src/webmcp/tools.ts +136 -147
- package/dist/chunks/browser-settings-menu-IMB6HXSU.js.map +0 -7
- package/dist/chunks/chunk-GTTYS5GT.js.map +0 -7
- package/dist/chunks/chunk-LLXOCRNF.js.map +0 -7
- package/dist/chunks/chunk-SRT3UUIP.js.map +0 -7
- package/dist/chunks/menu-UJETYMSB.js.map +0 -7
- package/dist/chunks/tools-6P7EUX37.js.map +0 -7
package/src/screenshot.ts
CHANGED
|
@@ -6,252 +6,236 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
import type { DevToolsPage } from "./runtime.js";
|
|
7
7
|
|
|
8
8
|
function unique<T>(values: T[]) {
|
|
9
|
-
|
|
9
|
+
return Array.from(new Set(values));
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
13
|
-
|
|
13
|
+
return error instanceof Error && "code" in error;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface ResolvedScreenshotPath {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
path: string;
|
|
18
|
+
allowedRoots: string[];
|
|
19
|
+
isDefault: boolean;
|
|
20
20
|
}
|
|
21
21
|
export interface ScreenshotSaveResult {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
savedPath: string;
|
|
23
|
+
bytes: number;
|
|
24
|
+
isDefaultPath: boolean;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export async function saveScreenshot(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
base64Png: string,
|
|
29
|
+
savePath: string | undefined,
|
|
30
|
+
cwd: string,
|
|
31
|
+
signal: AbortSignal | undefined,
|
|
32
32
|
): Promise<ScreenshotSaveResult> {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function resolveScreenshotPath(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return { path, allowedRoots, isDefault: false };
|
|
33
|
+
const resolvedPath = resolveScreenshotPath(savePath, cwd);
|
|
34
|
+
const pngBytes = Buffer.from(base64Png, "base64");
|
|
35
|
+
|
|
36
|
+
await withFileMutationQueue(resolvedPath.path, async () => {
|
|
37
|
+
throwIfAborted(signal);
|
|
38
|
+
await ensureSafeScreenshotParent(resolvedPath);
|
|
39
|
+
await assertSafeScreenshotTargetPath(resolvedPath);
|
|
40
|
+
await writeScreenshotFileSafely(resolvedPath, pngBytes, signal);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
savedPath: resolvedPath.path,
|
|
45
|
+
bytes: pngBytes.byteLength,
|
|
46
|
+
isDefaultPath: resolvedPath.isDefault,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveScreenshotPath(savePath: string | undefined, cwd: string): ResolvedScreenshotPath {
|
|
51
|
+
const cwdRoot = resolve(cwd);
|
|
52
|
+
const tempRoot = resolve(tmpdir());
|
|
53
|
+
|
|
54
|
+
if (savePath === undefined) {
|
|
55
|
+
return {
|
|
56
|
+
path: join(tempRoot, `pi-chrome-devtools-screenshot-${randomUUID()}.png`),
|
|
57
|
+
allowedRoots: [tempRoot],
|
|
58
|
+
isDefault: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const normalizedPath = stripLeadingAtPath(savePath);
|
|
63
|
+
if (!normalizedPath.trim()) {
|
|
64
|
+
throw new Error("Screenshot savePath must not be empty.");
|
|
65
|
+
}
|
|
66
|
+
if (normalizedPath.includes("\0")) {
|
|
67
|
+
throw new Error("Screenshot savePath must not contain NUL bytes.");
|
|
68
|
+
}
|
|
69
|
+
if (hasParentPathSegment(normalizedPath)) {
|
|
70
|
+
throw new Error("Screenshot savePath must not contain '..' path segments.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isAbsolutePath = isAbsolute(normalizedPath);
|
|
74
|
+
const path = isAbsolutePath ? resolve(normalizedPath) : resolve(cwdRoot, normalizedPath);
|
|
75
|
+
const allowedRoots = isAbsolutePath ? unique([cwdRoot, tempRoot]) : [cwdRoot];
|
|
76
|
+
if (!allowedRoots.some((root) => isPathInsideRoot(path, root))) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"Screenshot savePath must be relative to the current working directory, or an absolute path inside the current working directory or OS temp directory.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { path, allowedRoots, isDefault: false };
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
function stripLeadingAtPath(path: string) {
|
|
89
|
-
|
|
86
|
+
return path.startsWith("@") ? path.slice(1) : path;
|
|
90
87
|
}
|
|
91
88
|
|
|
92
89
|
export function hasParentPathSegment(path: string) {
|
|
93
|
-
|
|
90
|
+
return path.split(/[\\/]+/).some((part) => part === "..");
|
|
94
91
|
}
|
|
95
92
|
|
|
96
93
|
async function ensureSafeScreenshotParent(resolvedPath: ResolvedScreenshotPath) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
);
|
|
103
|
-
}
|
|
94
|
+
const parentPath = dirname(resolvedPath.path);
|
|
95
|
+
const rootPath = selectAllowedRoot(parentPath, resolvedPath.allowedRoots);
|
|
96
|
+
if (!rootPath) {
|
|
97
|
+
throw new Error("Screenshot savePath parent must stay inside the current working directory or OS temp directory.");
|
|
98
|
+
}
|
|
104
99
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
100
|
+
const realRootPath = await realpath(rootPath);
|
|
101
|
+
let currentPath = rootPath;
|
|
102
|
+
const parentSegments = relative(rootPath, parentPath)
|
|
103
|
+
.split(/[\\/]+/)
|
|
104
|
+
.filter((part) => part.length > 0);
|
|
110
105
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
106
|
+
for (const segment of parentSegments) {
|
|
107
|
+
currentPath = join(currentPath, segment);
|
|
108
|
+
await ensureSafeDirectorySegment(currentPath, realRootPath);
|
|
109
|
+
}
|
|
115
110
|
}
|
|
116
111
|
|
|
117
112
|
export function selectAllowedRoot(path: string, roots: readonly string[]) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return matchingRoots[0];
|
|
113
|
+
const matchingRoots = roots.filter((root) => isPathInsideRoot(path, root));
|
|
114
|
+
matchingRoots.sort(
|
|
115
|
+
(left, right) =>
|
|
116
|
+
normalizePathForComparison(resolve(right)).length - normalizePathForComparison(resolve(left)).length,
|
|
117
|
+
);
|
|
118
|
+
return matchingRoots[0];
|
|
125
119
|
}
|
|
126
120
|
|
|
127
121
|
async function ensureSafeDirectorySegment(path: string, realRootPath: string) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
122
|
+
const existingDirectory = await lstat(path).catch(async (error: unknown) => {
|
|
123
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
124
|
+
await mkdir(path).catch((mkdirError: unknown) => {
|
|
125
|
+
if (!isNodeError(mkdirError) || mkdirError.code !== "EEXIST") throw mkdirError;
|
|
126
|
+
});
|
|
127
|
+
return lstat(path);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (existingDirectory.isSymbolicLink()) {
|
|
131
|
+
throw new Error("Screenshot savePath parent directories must not contain symbolic links.");
|
|
132
|
+
}
|
|
133
|
+
if (!existingDirectory.isDirectory()) {
|
|
134
|
+
throw new Error("Screenshot savePath parent must be a directory.");
|
|
135
|
+
}
|
|
136
|
+
await assertPathWithinRealRoot(path, realRootPath);
|
|
143
137
|
}
|
|
144
138
|
|
|
145
139
|
async function assertSafeScreenshotTargetPath(resolvedPath: ResolvedScreenshotPath) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
);
|
|
167
|
-
}
|
|
140
|
+
const existingTarget = await lstat(resolvedPath.path).catch((error: unknown) => {
|
|
141
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
142
|
+
throw error;
|
|
143
|
+
});
|
|
144
|
+
if (existingTarget?.isSymbolicLink()) {
|
|
145
|
+
throw new Error("Screenshot savePath must not point to a symbolic link.");
|
|
146
|
+
}
|
|
147
|
+
if (existingTarget?.isDirectory()) {
|
|
148
|
+
throw new Error("Screenshot savePath must point to a file, not a directory.");
|
|
149
|
+
}
|
|
150
|
+
if (existingTarget && !existingTarget.isFile()) {
|
|
151
|
+
throw new Error("Screenshot savePath may only replace regular files.");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const realAllowedRoots = await Promise.all(resolvedPath.allowedRoots.map(realpathOrResolvedPath));
|
|
155
|
+
const realParent = await realpath(dirname(resolvedPath.path));
|
|
156
|
+
const realTargetPath = join(realParent, basename(resolvedPath.path));
|
|
157
|
+
if (!realAllowedRoots.some((root) => isPathInsideRoot(realTargetPath, root))) {
|
|
158
|
+
throw new Error("Screenshot savePath resolves outside the current working directory or OS temp directory.");
|
|
159
|
+
}
|
|
168
160
|
}
|
|
169
161
|
|
|
170
162
|
async function assertPathWithinRealRoot(path: string, realRootPath: string) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
);
|
|
176
|
-
}
|
|
163
|
+
const realPath = await realpath(path);
|
|
164
|
+
if (!isPathInsideRoot(realPath, realRootPath)) {
|
|
165
|
+
throw new Error("Screenshot savePath parent resolves outside the current working directory or OS temp directory.");
|
|
166
|
+
}
|
|
177
167
|
}
|
|
178
168
|
|
|
179
169
|
async function writeScreenshotFileSafely(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
170
|
+
resolvedPath: ResolvedScreenshotPath,
|
|
171
|
+
pngBytes: Buffer,
|
|
172
|
+
signal: AbortSignal | undefined,
|
|
183
173
|
) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
174
|
+
const tempFile = join(
|
|
175
|
+
dirname(resolvedPath.path),
|
|
176
|
+
`.${basename(resolvedPath.path)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`,
|
|
177
|
+
);
|
|
178
|
+
try {
|
|
179
|
+
await writeFile(tempFile, pngBytes, { flag: "wx", signal });
|
|
180
|
+
throwIfAborted(signal);
|
|
181
|
+
await replaceScreenshotFile(resolvedPath, tempFile, signal);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
196
186
|
}
|
|
197
187
|
|
|
198
188
|
async function replaceScreenshotFile(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
189
|
+
resolvedPath: ResolvedScreenshotPath,
|
|
190
|
+
tempFile: string,
|
|
191
|
+
signal: AbortSignal | undefined,
|
|
202
192
|
) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
193
|
+
try {
|
|
194
|
+
await rename(tempFile, resolvedPath.path);
|
|
195
|
+
return;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (!shouldRetryRenameAfterRemovingDestination(error)) throw error;
|
|
198
|
+
}
|
|
209
199
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
200
|
+
// Some Windows filesystems reject renaming over an existing file. Revalidate before
|
|
201
|
+
// removing the destination so the fallback still refuses directories and symlinks.
|
|
202
|
+
await assertSafeScreenshotTargetPath(resolvedPath);
|
|
203
|
+
throwIfAborted(signal);
|
|
204
|
+
await rm(resolvedPath.path, { force: true });
|
|
205
|
+
await rename(tempFile, resolvedPath.path);
|
|
216
206
|
}
|
|
217
207
|
|
|
218
208
|
function shouldRetryRenameAfterRemovingDestination(error: unknown) {
|
|
219
|
-
|
|
220
|
-
process.platform === "win32" &&
|
|
221
|
-
isNodeError(error) &&
|
|
222
|
-
["EACCES", "EEXIST", "EPERM"].includes(error.code ?? "")
|
|
223
|
-
);
|
|
209
|
+
return process.platform === "win32" && isNodeError(error) && ["EACCES", "EEXIST", "EPERM"].includes(error.code ?? "");
|
|
224
210
|
}
|
|
225
211
|
|
|
226
212
|
async function realpathOrResolvedPath(path: string) {
|
|
227
|
-
|
|
213
|
+
return realpath(path).catch(() => resolve(path));
|
|
228
214
|
}
|
|
229
215
|
|
|
230
216
|
export function isPathInsideRoot(path: string, root: string) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
: `${normalizedRoot}${sep}`;
|
|
237
|
-
return normalizedPath.startsWith(rootWithSeparator);
|
|
217
|
+
const normalizedPath = normalizePathForComparison(resolve(path));
|
|
218
|
+
const normalizedRoot = normalizePathForComparison(resolve(root));
|
|
219
|
+
if (normalizedPath === normalizedRoot) return true;
|
|
220
|
+
const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
|
|
221
|
+
return normalizedPath.startsWith(rootWithSeparator);
|
|
238
222
|
}
|
|
239
223
|
|
|
240
224
|
function normalizePathForComparison(path: string) {
|
|
241
|
-
|
|
225
|
+
return process.platform === "win32" ? path.toLowerCase() : path;
|
|
242
226
|
}
|
|
243
227
|
|
|
244
228
|
export function throwIfAborted(signal: AbortSignal | undefined) {
|
|
245
|
-
|
|
246
|
-
|
|
229
|
+
if (!signal?.aborted) return;
|
|
230
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("Screenshot capture cancelled.");
|
|
247
231
|
}
|
|
248
232
|
|
|
249
233
|
export function formatScreenshotText(page: DevToolsPage, screenshot: ScreenshotSaveResult) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
234
|
+
const pathLabel = screenshot.isDefaultPath ? "Saved to temp file" : "Saved to";
|
|
235
|
+
return [
|
|
236
|
+
`Captured PNG screenshot from ${page.title || page.url || page.id}.`,
|
|
237
|
+
`${pathLabel}: ${screenshot.savedPath}`,
|
|
238
|
+
`Bytes: ${screenshot.bytes}`,
|
|
239
|
+
`Use read({ path: ${JSON.stringify(screenshot.savedPath)} }) to inspect the saved screenshot if inline image content is not available.`,
|
|
240
|
+
].join("\n");
|
|
257
241
|
}
|