@narumitw/pi-chrome-devtools 0.53.2 → 0.53.4

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