@narumitw/pi-chrome-devtools 0.2.0 → 0.4.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/README.md CHANGED
@@ -15,9 +15,13 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
15
15
  - Navigates Chrome to a target URL, creating an inspectable page when none exists.
16
16
  - Recovers from stale active page selections by falling back to an available page.
17
17
  - Evaluates JavaScript in the selected page.
18
- - Captures PNG screenshots, including optional full-page screenshots.
18
+ - Captures PNG screenshots, including optional full-page screenshots, and saves them to disk.
19
19
  - Renders compact tool results that expand/collapse with Pi's default output toggle (`Ctrl+O`).
20
- - Uses a local Chrome DevTools Protocol endpoint.
20
+ - Reuses an existing Chrome DevTools Protocol endpoint when one is already available.
21
+ - Lazily auto-launches a Chromium-family browser for missing local endpoints, with Chrome,
22
+ Chromium, Brave, and Edge fallbacks.
23
+ - Uses a dynamic managed DevTools port by default to avoid port conflicts, while preserving
24
+ explicit endpoint overrides.
21
25
  - Retries briefly while Chrome is starting and reports actionable endpoint errors.
22
26
  - Shows statusline activity only while Chrome DevTools tools are running.
23
27
  - Provides a `/chrome-devtools` menu with quick-start help and tool controls.
@@ -42,9 +46,37 @@ Try this package locally from the repository root:
42
46
  pi -e ./extensions/pi-chrome-devtools
43
47
  ```
44
48
 
45
- ## 🚀 Start Chrome with CDP enabled
49
+ ## 🚀 Browser startup
46
50
 
47
- The extension connects to `127.0.0.1:9222` by default.
51
+ The extension first tries the configured endpoint, defaulting to `127.0.0.1:9222`. If that
52
+ local endpoint is unavailable, it lazily launches an extension-owned Chromium-family browser with
53
+ an isolated temp profile and then retries the CDP request. Existing endpoints are reused and are
54
+ not terminated by the extension.
55
+
56
+ When `PI_CHROME_DEVTOOLS_PORT` is not set, auto-launch uses Chrome's dynamic DevTools port mode
57
+ (`--remote-debugging-port=0`) and reads `DevToolsActivePort` from the temp profile. This avoids
58
+ forcing every Pi session onto port `9222`. If you set `PI_CHROME_DEVTOOLS_PORT` to a valid port
59
+ (`1`-`65535`), the extension uses that explicit port for both attach and auto-launch. Empty or
60
+ invalid port values are ignored and fall back to the default attach-first behavior.
61
+
62
+ When `PI_CHROME_DEVTOOLS_BROWSER` is set, that executable is the only auto-launch candidate; a
63
+ missing or unusable forced browser reports an error instead of falling back. Without that override,
64
+ browser discovery checks platform-specific Chrome, Chromium, Brave, and Microsoft Edge candidates.
65
+ Disable auto-launch to keep the older manual flow:
66
+
67
+ ```bash
68
+ PI_CHROME_DEVTOOLS_AUTO_LAUNCH=0 pi -e ./extensions/pi-chrome-devtools
69
+ ```
70
+
71
+ Force a browser executable or endpoint if needed:
72
+
73
+ ```bash
74
+ PI_CHROME_DEVTOOLS_BROWSER=/usr/bin/brave-browser pi -e ./extensions/pi-chrome-devtools
75
+ PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extensions/pi-chrome-devtools
76
+ ```
77
+
78
+ Manual launch still works and is required for remote endpoints, opt-out mode, or unsupported WSL
79
+ browser/profile path setups:
48
80
 
49
81
  ```bash
50
82
  google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/pi-chrome-devtools
@@ -58,11 +90,9 @@ On macOS:
58
90
  --user-data-dir=/tmp/pi-chrome-devtools
59
91
  ```
60
92
 
61
- Override the endpoint if needed:
62
-
63
- ```bash
64
- PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extensions/pi-chrome-devtools
65
- ```
93
+ On session shutdown, the extension terminates only browser processes it started itself and
94
+ best-effort removes their temp profiles. It never closes user-started browsers or remote
95
+ endpoints.
66
96
 
67
97
  ## 🛠️ Pi tools
68
98
 
@@ -70,7 +100,34 @@ PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extension
70
100
  - `chrome_devtools_select_page` — select the active page for later tool calls.
71
101
  - `chrome_devtools_navigate` — navigate a page to a URL; if no page exists, create one first.
72
102
  - `chrome_devtools_evaluate` — evaluate JavaScript in the selected page.
73
- - `chrome_devtools_screenshot` — capture a PNG screenshot.
103
+ - `chrome_devtools_screenshot` — capture a PNG screenshot and save it as a PNG file.
104
+
105
+ ### Screenshot files
106
+
107
+ `chrome_devtools_screenshot` always saves the captured PNG to disk. If `savePath` is omitted,
108
+ the extension writes a unique temp file such as:
109
+
110
+ ```text
111
+ /tmp/pi-chrome-devtools-screenshot-<uuid>.png
112
+ ```
113
+
114
+ Pass `savePath` to choose the output path:
115
+
116
+ ```js
117
+ chrome_devtools_screenshot({
118
+ fullPage: true,
119
+ savePath: "artifacts/homepage.png",
120
+ });
121
+ ```
122
+
123
+ Relative `savePath` values resolve from Pi's current working directory. A single leading `@`
124
+ is stripped to match Pi file-mention paths. Absolute paths are accepted only when they stay
125
+ inside the current working directory or the OS temp directory. Paths containing `..` segments,
126
+ NUL bytes, symlinked parent directories, directories as targets, final symbolic-link targets, or
127
+ other non-regular file targets are rejected. Existing regular files at the target path are
128
+ replaced. The tool result includes the resolved path, byte count, and an inline image block when
129
+ the active model/provider can consume images. If the model cannot inspect the inline image, ask it
130
+ to read the saved path, for example `read({ path: "artifacts/homepage.png" })`.
74
131
 
75
132
  ## 💬 Command
76
133
 
@@ -94,9 +151,10 @@ Direct subcommands are also available:
94
151
  ```
95
152
 
96
153
  - `help` shows command usage.
97
- - `quickstart` shows the configured CDP endpoint and launch hint.
98
- - `status` shows runtime tool state, persisted selection, settings file path, and active
99
- non-Chrome tool count.
154
+ - `quickstart` shows the configured CDP endpoint, endpoint source, auto-launch mode, browser
155
+ candidates, last launch attempt, and launch hints.
156
+ - `status` shows runtime tool state, persisted selection, settings file path, endpoint source,
157
+ launch mode, last launch attempt, and active non-Chrome tool count.
100
158
  - `tools` opens a Plan-mode-style selector for choosing individual `chrome_devtools_*` tools.
101
159
  - `toggle` is an alias for `tools`.
102
160
  - `enable` enables all `chrome_devtools_*` tools for future turns.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-chrome-devtools",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension that exposes Chrome DevTools Protocol tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,12 +1,16 @@
1
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import { access, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
7
  import {
5
8
  defineTool,
6
9
  type AgentToolResult,
7
10
  type ExtensionAPI,
8
11
  type ExtensionCommandContext,
9
12
  type ToolRenderResultOptions,
13
+ withFileMutationQueue,
10
14
  } from "@mariozechner/pi-coding-agent";
11
15
  import { Type } from "typebox";
12
16
 
@@ -16,6 +20,9 @@ const DEFAULT_TIMEOUT_MS = 10_000;
16
20
  const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
17
21
  const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
18
22
  const DEFAULT_ENDPOINT_RETRY_MS = 250;
23
+ const MANAGED_BROWSER_PROFILE_PREFIX = "pi-chrome-devtools-profile-";
24
+ const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
25
+ const BROWSER_SHUTDOWN_WAIT_MS = 1_500;
19
26
  const STATUS_KEY = "chrome-devtools";
20
27
  const SETTINGS_FILE = join(
21
28
  process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
@@ -61,6 +68,7 @@ type CommandAction =
61
68
  | "disable";
62
69
  type CommandContext = ExtensionCommandContext;
63
70
  type ToolSelectorAction = "enableAll" | "disableAll" | "done";
71
+ type BrowserCandidateSource = "env" | "path" | "wellKnownPath";
64
72
  type ToolSelectorRow =
65
73
  | { kind: "tool"; toolName: ChromeDevToolsToolName }
66
74
  | { kind: "action"; action: ToolSelectorAction; label: string };
@@ -90,7 +98,32 @@ interface DevToolsPage {
90
98
  interface ChromeDevToolsState {
91
99
  host: string;
92
100
  port: number;
101
+ configuredPort: number;
102
+ hostConfigured: boolean;
103
+ portConfigured: boolean;
104
+ autoLaunchEnabled: boolean;
105
+ browserExecutable?: string;
93
106
  activePageId?: string;
107
+ managedBrowser?: ManagedBrowser;
108
+ launchPromise?: Promise<void>;
109
+ lastLaunchAttempt?: BrowserLaunchAttempt;
110
+ shuttingDown: boolean;
111
+ }
112
+
113
+ interface ManagedBrowser {
114
+ process: ChildProcess;
115
+ userDataDir: string;
116
+ port?: number;
117
+ exited: boolean;
118
+ ready: boolean;
119
+ }
120
+
121
+ interface BrowserLaunchAttempt {
122
+ candidateLabels: string[];
123
+ mode: "dynamic-port" | "explicit-port";
124
+ selectedCandidate?: string;
125
+ userDataDir?: string;
126
+ lastError?: string;
94
127
  }
95
128
 
96
129
  interface ChromeDevToolsSettings {
@@ -114,9 +147,41 @@ interface CdpResponse<T = unknown> {
114
147
  };
115
148
  }
116
149
 
150
+ interface ResolvedScreenshotPath {
151
+ path: string;
152
+ allowedRoots: string[];
153
+ isDefault: boolean;
154
+ }
155
+
156
+ interface ScreenshotSaveResult {
157
+ savedPath: string;
158
+ bytes: number;
159
+ isDefaultPath: boolean;
160
+ }
161
+
162
+ interface BrowserCandidateDefinition {
163
+ label: string;
164
+ executable: string;
165
+ source: BrowserCandidateSource;
166
+ }
167
+
168
+ interface BrowserCandidate extends BrowserCandidateDefinition {
169
+ resolvedExecutable: string;
170
+ }
171
+
172
+ const configuredHost = process.env.PI_CHROME_DEVTOOLS_HOST ?? DEFAULT_HOST;
173
+ const configuredPortOverride = parseConfiguredPort(process.env.PI_CHROME_DEVTOOLS_PORT);
174
+ const configuredPort = configuredPortOverride ?? DEFAULT_PORT;
175
+
117
176
  const state: ChromeDevToolsState = {
118
- host: process.env.PI_CHROME_DEVTOOLS_HOST ?? DEFAULT_HOST,
119
- port: Number(process.env.PI_CHROME_DEVTOOLS_PORT ?? DEFAULT_PORT),
177
+ host: configuredHost,
178
+ port: configuredPort,
179
+ configuredPort,
180
+ hostConfigured: process.env.PI_CHROME_DEVTOOLS_HOST !== undefined,
181
+ portConfigured: configuredPortOverride !== undefined,
182
+ autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
183
+ browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
184
+ shuttingDown: false,
120
185
  };
121
186
 
122
187
  const listPagesTool = defineTool({
@@ -234,13 +299,20 @@ const screenshotTool = defineTool({
234
299
  fullPage: Type.Optional(
235
300
  Type.Boolean({ description: "Capture the full document, not just the viewport." }),
236
301
  ),
302
+ savePath: Type.Optional(
303
+ Type.String({
304
+ description:
305
+ "Screenshot is always saved as a PNG file. Optional output path; omitted defaults to a unique temp file. Relative paths resolve from the current working directory. A single leading @ is stripped to match Pi file-mention paths. Existing regular files are replaced.",
306
+ }),
307
+ ),
237
308
  }),
238
309
  renderCall: renderToolCall("screenshot"),
239
310
  renderResult: renderScreenshotResult,
240
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
311
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
241
312
  return withStatus(ctx, "🌐 screenshot", async () => {
242
313
  const page = await resolvePage(params.pageId);
243
314
  const result = await withCdp(page, async (client) => {
315
+ throwIfAborted(signal);
244
316
  await client.send("Page.enable");
245
317
 
246
318
  if (!params.fullPage) {
@@ -251,6 +323,7 @@ const screenshotTool = defineTool({
251
323
  contentSize: { x: number; y: number; width: number; height: number };
252
324
  }>("Page.getLayoutMetrics");
253
325
 
326
+ throwIfAborted(signal);
254
327
  return client.send<{ data: string }>("Page.captureScreenshot", {
255
328
  captureBeyondViewport: true,
256
329
  format: "png",
@@ -265,12 +338,21 @@ const screenshotTool = defineTool({
265
338
  });
266
339
 
267
340
  state.activePageId = page.id;
341
+ const savedScreenshot = await saveScreenshot(result.data, params.savePath, ctx.cwd, signal);
268
342
  return {
269
343
  content: [
270
- { type: "text", text: `Captured PNG screenshot from ${page.title || page.url}` },
344
+ {
345
+ type: "text",
346
+ text: formatScreenshotText(page, savedScreenshot),
347
+ },
271
348
  { type: "image", data: result.data, mimeType: "image/png" },
272
349
  ],
273
- details: { page: formatPage(page), bytes: Buffer.byteLength(result.data, "base64") },
350
+ details: {
351
+ page: formatPage(page),
352
+ bytes: savedScreenshot.bytes,
353
+ savedPath: savedScreenshot.savedPath,
354
+ isDefaultPath: savedScreenshot.isDefaultPath,
355
+ },
274
356
  };
275
357
  });
276
358
  },
@@ -292,6 +374,7 @@ export default function chromeDevtools(pi: ExtensionAPI) {
292
374
  });
293
375
 
294
376
  pi.on("session_start", async (_event, ctx) => {
377
+ state.shuttingDown = false;
295
378
  ctx.ui.setStatus(STATUS_KEY, undefined);
296
379
  const settings = await loadSettings();
297
380
  if (settings.kind === "loaded") {
@@ -303,8 +386,9 @@ export default function chromeDevtools(pi: ExtensionAPI) {
303
386
  }
304
387
  });
305
388
 
306
- pi.on("session_shutdown", (_event, ctx) => {
389
+ pi.on("session_shutdown", async (_event, ctx) => {
307
390
  ctx.ui.setStatus(STATUS_KEY, undefined);
391
+ await shutdownManagedBrowser(undefined, { cancelLaunch: true });
308
392
  });
309
393
  }
310
394
 
@@ -589,13 +673,20 @@ async function buildToolStatusMessage(pi: ExtensionAPI) {
589
673
  `Settings file: ${SETTINGS_FILE}`,
590
674
  `Other active tools preserved: ${summary.activeNonChromeToolCount}`,
591
675
  `Endpoint: ${devToolsEndpoint()}`,
676
+ `Endpoint source: ${endpointSourceLabel()}`,
677
+ `Launch mode: ${launchModeLabel()}`,
678
+ ...launchAttemptLines(),
592
679
  ].join("\n");
593
680
  }
594
681
 
595
682
  function buildQuickstartMessage() {
596
683
  return [
597
684
  `Chrome DevTools endpoint: ${devToolsEndpoint()}`,
685
+ `Endpoint source: ${endpointSourceLabel()}`,
686
+ `Launch mode: ${launchModeLabel()}`,
598
687
  launchHint(),
688
+ browserCandidateHint(),
689
+ ...launchAttemptLines(),
599
690
  endpointConfigHint(),
600
691
  ].join("\n");
601
692
  }
@@ -752,11 +843,23 @@ function unique<T>(values: T[]) {
752
843
  return Array.from(new Set(values));
753
844
  }
754
845
 
846
+ function parseConfiguredPort(value: string | undefined) {
847
+ if (value === undefined) return undefined;
848
+ const trimmedValue = value.trim();
849
+ if (!/^\d+$/.test(trimmedValue)) return undefined;
850
+
851
+ const port = Number(trimmedValue);
852
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
853
+ return port;
854
+ }
855
+
755
856
  async function listPages(options: { waitMs?: number } = {}) {
857
+ const waitMs = options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS;
858
+ await ensureDevToolsEndpoint(waitMs);
756
859
  return withEndpointRetry(async () => {
757
860
  const pages = await fetchDevToolsJson<DevToolsPage[]>("/json/list");
758
861
  return pages.filter((page) => page.type === "page" && page.webSocketDebuggerUrl);
759
- }, options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS);
862
+ }, waitMs);
760
863
  }
761
864
 
762
865
  async function getPage(pageId: string) {
@@ -817,10 +920,16 @@ function requirePage(pageId: string, pages: DevToolsPage[]) {
817
920
  );
818
921
  }
819
922
 
820
- async function createPage(url: string) {
821
- const page = await fetchDevToolsJson<DevToolsPage>(`/json/new?${encodeURIComponent(url)}`, {
822
- method: "PUT",
823
- });
923
+ async function createPage(url: string, options: { waitMs?: number } = {}) {
924
+ const waitMs = options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS;
925
+ await ensureDevToolsEndpoint(waitMs);
926
+ const page = await withEndpointRetry(
927
+ () =>
928
+ fetchDevToolsJson<DevToolsPage>(`/json/new?${encodeURIComponent(url)}`, {
929
+ method: "PUT",
930
+ }),
931
+ waitMs,
932
+ );
824
933
  if (page.type !== "page" || !page.webSocketDebuggerUrl) {
825
934
  throw new Error("Chrome DevTools created a target that is not an inspectable page.");
826
935
  }
@@ -828,6 +937,268 @@ async function createPage(url: string) {
828
937
  return page;
829
938
  }
830
939
 
940
+ async function ensureDevToolsEndpoint(waitMs = DEFAULT_ENDPOINT_WAIT_MS) {
941
+ if (canAutoLaunchBrowser()) {
942
+ try {
943
+ await withEndpointRetry(() => fetchDevToolsJson<unknown>("/json/version"), waitMs);
944
+ return;
945
+ } catch (error) {
946
+ if (shouldAutoLaunchAfterEndpointError(error)) {
947
+ await ensureManagedBrowserLaunched(waitMs);
948
+ return;
949
+ }
950
+ throw error;
951
+ }
952
+ }
953
+
954
+ try {
955
+ await fetchDevToolsJson<unknown>("/json/version");
956
+ } catch (error) {
957
+ if (isRetryableEndpointError(error)) return;
958
+ throw error;
959
+ }
960
+ }
961
+
962
+ async function ensureManagedBrowserLaunched(waitMs: number) {
963
+ if (state.launchPromise) return state.launchPromise;
964
+ if (state.managedBrowser && !state.managedBrowser.exited && state.managedBrowser.ready) return;
965
+ if (state.managedBrowser) {
966
+ await shutdownManagedBrowser(state.managedBrowser, { awaitLaunch: false });
967
+ }
968
+ throwIfBrowserLaunchCancelled();
969
+
970
+ state.launchPromise = launchManagedBrowser(waitMs).finally(() => {
971
+ state.launchPromise = undefined;
972
+ });
973
+ return state.launchPromise;
974
+ }
975
+
976
+ async function launchManagedBrowser(waitMs: number) {
977
+ throwIfBrowserLaunchCancelled();
978
+ const candidateDefinitions = browserCandidateDefinitions();
979
+ const candidates = await resolveBrowserCandidates(candidateDefinitions);
980
+ throwIfBrowserLaunchCancelled();
981
+ state.lastLaunchAttempt = {
982
+ candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
983
+ mode: state.portConfigured ? "explicit-port" : "dynamic-port",
984
+ };
985
+
986
+ if (candidates.length === 0) {
987
+ throw new DevToolsEndpointError(noBrowserCandidateMessage(candidateDefinitions));
988
+ }
989
+
990
+ let lastError: unknown;
991
+ for (const candidate of candidates) {
992
+ throwIfBrowserLaunchCancelled();
993
+ try {
994
+ await launchBrowserCandidate(candidate, waitMs);
995
+ state.lastLaunchAttempt = {
996
+ ...state.lastLaunchAttempt,
997
+ selectedCandidate: formatBrowserCandidate(candidate),
998
+ userDataDir: state.managedBrowser?.userDataDir,
999
+ };
1000
+ return;
1001
+ } catch (error) {
1002
+ lastError = error;
1003
+ state.lastLaunchAttempt = {
1004
+ ...state.lastLaunchAttempt,
1005
+ lastError: formatError(error),
1006
+ };
1007
+ }
1008
+ }
1009
+
1010
+ throw new DevToolsEndpointError(
1011
+ [
1012
+ "Unable to auto-launch a Chromium-family browser for Chrome DevTools.",
1013
+ `Tried: ${candidates.map(formatBrowserCandidate).join(", ")}`,
1014
+ lastError ? `Last error: ${formatError(lastError)}` : undefined,
1015
+ launchHint(),
1016
+ endpointConfigHint(),
1017
+ ]
1018
+ .filter(Boolean)
1019
+ .join("\n"),
1020
+ );
1021
+ }
1022
+
1023
+ async function launchBrowserCandidate(candidate: BrowserCandidate, waitMs: number) {
1024
+ throwIfBrowserLaunchCancelled();
1025
+ const userDataDir = await mkdtemp(join(tmpdir(), MANAGED_BROWSER_PROFILE_PREFIX));
1026
+ let managedBrowser: ManagedBrowser | undefined;
1027
+ try {
1028
+ const portArgument = state.portConfigured ? String(state.port) : "0";
1029
+ const args = [
1030
+ `--remote-debugging-port=${portArgument}`,
1031
+ `--user-data-dir=${userDataDir}`,
1032
+ "--no-first-run",
1033
+ "--no-default-browser-check",
1034
+ "about:blank",
1035
+ ];
1036
+ throwIfBrowserLaunchCancelled();
1037
+ const child = spawn(candidate.resolvedExecutable, args, { shell: false, stdio: "ignore" });
1038
+ const launchedBrowser: ManagedBrowser = { process: child, userDataDir, exited: false, ready: false };
1039
+ managedBrowser = launchedBrowser;
1040
+ state.managedBrowser = launchedBrowser;
1041
+
1042
+ child.once("exit", () => {
1043
+ launchedBrowser.exited = true;
1044
+ launchedBrowser.ready = false;
1045
+ if (!state.portConfigured && launchedBrowser.port === state.port) {
1046
+ state.port = state.configuredPort;
1047
+ }
1048
+ });
1049
+
1050
+ await waitForBrowserSpawn(child);
1051
+ if (state.portConfigured) {
1052
+ launchedBrowser.port = state.port;
1053
+ } else {
1054
+ launchedBrowser.port = await readManagedBrowserPort(userDataDir, launchedBrowser, waitMs);
1055
+ state.port = launchedBrowser.port;
1056
+ }
1057
+ await waitForDevToolsEndpoint(waitMs, launchedBrowser);
1058
+ launchedBrowser.ready = true;
1059
+ } catch (error) {
1060
+ if (managedBrowser) await shutdownManagedBrowser(managedBrowser, { awaitLaunch: false });
1061
+ else await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
1062
+ throw error;
1063
+ }
1064
+ }
1065
+
1066
+ function waitForBrowserSpawn(child: ChildProcess) {
1067
+ return new Promise<void>((resolve, reject) => {
1068
+ let settled = false;
1069
+ const settle = (callback: () => void) => {
1070
+ if (settled) return;
1071
+ settled = true;
1072
+ child.off("error", onError);
1073
+ child.off("spawn", onSpawn);
1074
+ callback();
1075
+ };
1076
+ const onError = (error: Error) => settle(() => reject(error));
1077
+ const onSpawn = () => settle(resolve);
1078
+ child.once("error", onError);
1079
+ child.once("spawn", onSpawn);
1080
+ });
1081
+ }
1082
+
1083
+ async function readManagedBrowserPort(
1084
+ userDataDir: string,
1085
+ managedBrowser: ManagedBrowser,
1086
+ waitMs: number,
1087
+ ) {
1088
+ const activePortFile = join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
1089
+ const deadline = Date.now() + waitMs;
1090
+ while (true) {
1091
+ throwIfManagedBrowserExited(managedBrowser);
1092
+ const text = await readFile(activePortFile, "utf8").catch((error: unknown) => {
1093
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
1094
+ throw error;
1095
+ });
1096
+ const portText = text?.split(/\r?\n/, 1)[0]?.trim();
1097
+ const port = Number(portText);
1098
+ if (Number.isInteger(port) && port > 0) return port;
1099
+
1100
+ throwIfBrowserLaunchCancelled();
1101
+ const remainingMs = deadline - Date.now();
1102
+ if (remainingMs <= 0) {
1103
+ throw new DevToolsEndpointError(
1104
+ [
1105
+ "Timed out waiting for auto-launched browser DevToolsActivePort.",
1106
+ `Expected file: ${activePortFile}`,
1107
+ launchHint(),
1108
+ ].join("\n"),
1109
+ );
1110
+ }
1111
+ await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
1112
+ }
1113
+ }
1114
+
1115
+ async function waitForDevToolsEndpoint(waitMs: number, managedBrowser: ManagedBrowser) {
1116
+ const deadline = Date.now() + waitMs;
1117
+ while (true) {
1118
+ throwIfManagedBrowserExited(managedBrowser);
1119
+ try {
1120
+ await fetchDevToolsJson<unknown>("/json/version");
1121
+ return;
1122
+ } catch (error) {
1123
+ if (!isRetryableEndpointError(error)) throw error;
1124
+ }
1125
+
1126
+ throwIfBrowserLaunchCancelled();
1127
+ const remainingMs = deadline - Date.now();
1128
+ if (remainingMs <= 0) {
1129
+ throw new DevToolsEndpointError(
1130
+ [
1131
+ `Timed out waiting for auto-launched browser at ${devToolsEndpoint()}.`,
1132
+ launchHint(),
1133
+ ].join("\n"),
1134
+ );
1135
+ }
1136
+ await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
1137
+ }
1138
+ }
1139
+
1140
+ function throwIfManagedBrowserExited(managedBrowser: ManagedBrowser) {
1141
+ if (!managedBrowser.exited) return;
1142
+ throw new DevToolsEndpointError("Auto-launched browser exited before DevTools became available.");
1143
+ }
1144
+
1145
+ function throwIfBrowserLaunchCancelled() {
1146
+ if (!state.shuttingDown) return;
1147
+ throw new DevToolsEndpointError("Chrome DevTools browser launch cancelled during shutdown.");
1148
+ }
1149
+
1150
+ async function shutdownManagedBrowser(
1151
+ managedBrowser = state.managedBrowser,
1152
+ options: { awaitLaunch?: boolean; cancelLaunch?: boolean } = {},
1153
+ ) {
1154
+ if (options.cancelLaunch) state.shuttingDown = true;
1155
+ if (options.awaitLaunch !== false) {
1156
+ await state.launchPromise?.catch(() => undefined);
1157
+ managedBrowser = managedBrowser ?? state.managedBrowser;
1158
+ }
1159
+ if (!managedBrowser) return;
1160
+ if (state.managedBrowser === managedBrowser) state.managedBrowser = undefined;
1161
+
1162
+ if (!managedBrowser.exited) {
1163
+ killManagedBrowserProcess(managedBrowser);
1164
+ await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(() => {
1165
+ killManagedBrowserProcess(managedBrowser, "SIGKILL");
1166
+ });
1167
+ }
1168
+ await rm(managedBrowser.userDataDir, { recursive: true, force: true }).catch(() => undefined);
1169
+ if (!state.portConfigured && managedBrowser.port === state.port) state.port = state.configuredPort;
1170
+ }
1171
+
1172
+ function killManagedBrowserProcess(managedBrowser: ManagedBrowser, signal?: NodeJS.Signals) {
1173
+ try {
1174
+ managedBrowser.process.kill(signal);
1175
+ } catch {
1176
+ // Best-effort shutdown: the browser may have already exited or failed to spawn.
1177
+ }
1178
+ }
1179
+
1180
+ function waitForManagedBrowserExit(managedBrowser: ManagedBrowser, waitMs: number) {
1181
+ if (managedBrowser.exited) return Promise.resolve();
1182
+ return new Promise<void>((resolve, reject) => {
1183
+ const settle = (callback: () => void) => {
1184
+ clearTimeout(timeout);
1185
+ managedBrowser.process.off("exit", onExitOrClose);
1186
+ managedBrowser.process.off("close", onExitOrClose);
1187
+ callback();
1188
+ };
1189
+ const onExitOrClose = () => {
1190
+ managedBrowser.exited = true;
1191
+ settle(resolve);
1192
+ };
1193
+ const timeout = setTimeout(
1194
+ () => settle(() => reject(new Error("Timed out waiting for browser shutdown."))),
1195
+ waitMs,
1196
+ );
1197
+ managedBrowser.process.once("exit", onExitOrClose);
1198
+ managedBrowser.process.once("close", onExitOrClose);
1199
+ });
1200
+ }
1201
+
831
1202
  async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
832
1203
  const url = `${devToolsEndpoint()}${path}`;
833
1204
  let response: Response;
@@ -837,7 +1208,10 @@ async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
837
1208
  signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
838
1209
  });
839
1210
  } catch (error) {
840
- throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), { retryable: true });
1211
+ throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), {
1212
+ launchable: true,
1213
+ retryable: true,
1214
+ });
841
1215
  }
842
1216
 
843
1217
  if (!response.ok) {
@@ -852,7 +1226,16 @@ async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
852
1226
  );
853
1227
  }
854
1228
 
855
- return (await response.json()) as T;
1229
+ try {
1230
+ return (await response.json()) as T;
1231
+ } catch (error) {
1232
+ throw new DevToolsEndpointError(
1233
+ [
1234
+ `Chrome DevTools endpoint ${url} returned invalid JSON: ${formatError(error)}.`,
1235
+ endpointConfigHint(),
1236
+ ].join("\n"),
1237
+ );
1238
+ }
856
1239
  }
857
1240
 
858
1241
  async function withEndpointRetry<T>(operation: () => Promise<T>, waitMs: number) {
@@ -875,6 +1258,23 @@ function isRetryableEndpointError(error: unknown) {
875
1258
  return error instanceof DevToolsEndpointError && error.retryable;
876
1259
  }
877
1260
 
1261
+ function isLaunchableEndpointError(error: unknown) {
1262
+ return error instanceof DevToolsEndpointError && error.launchable;
1263
+ }
1264
+
1265
+ function shouldAutoLaunchAfterEndpointError(error: unknown) {
1266
+ if (!canAutoLaunchBrowser()) return false;
1267
+ if (isLaunchableEndpointError(error)) return true;
1268
+
1269
+ // If the user did not pin a port, non-retryable unexpected responses on the
1270
+ // default port are conflicts we can avoid with a dynamic DevTools port.
1271
+ return !state.portConfigured && error instanceof DevToolsEndpointError && !error.retryable;
1272
+ }
1273
+
1274
+ function canAutoLaunchBrowser() {
1275
+ return state.autoLaunchEnabled && isLocalDevToolsHost(state.host);
1276
+ }
1277
+
878
1278
  function endpointConnectionErrorMessage(error: unknown) {
879
1279
  const reason = isTimeoutError(error) ? "request timed out" : "connection failed";
880
1280
  return [
@@ -889,26 +1289,260 @@ function isTimeoutError(error: unknown) {
889
1289
  }
890
1290
 
891
1291
  function devToolsEndpoint() {
892
- return `http://${state.host}:${state.port}`;
1292
+ return `http://${formatHostForUrl(state.host)}:${state.port}`;
1293
+ }
1294
+
1295
+ function formatHostForUrl(host: string) {
1296
+ if (host.startsWith("[") && host.endsWith("]")) return host;
1297
+ return host.includes(":") ? `[${host}]` : host;
1298
+ }
1299
+
1300
+ function endpointSourceLabel() {
1301
+ const hostSource = state.hostConfigured ? "PI_CHROME_DEVTOOLS_HOST" : "default host";
1302
+ const portSource = state.portConfigured ? "PI_CHROME_DEVTOOLS_PORT" : "default/dynamic port";
1303
+ return `${hostSource}; ${portSource}`;
1304
+ }
1305
+
1306
+ function launchModeLabel() {
1307
+ if (!isLocalDevToolsHost(state.host)) return "manual remote endpoint";
1308
+ if (!state.autoLaunchEnabled) return "manual; auto-launch disabled";
1309
+ if (state.managedBrowser && !state.managedBrowser.exited) {
1310
+ return state.portConfigured ? "auto-launched on explicit port" : "auto-launched on dynamic port";
1311
+ }
1312
+ return state.portConfigured ? "attach first; auto-launch explicit port" : "attach first; auto-launch dynamic port";
1313
+ }
1314
+
1315
+ function launchAttemptLines() {
1316
+ if (!state.lastLaunchAttempt) return [];
1317
+
1318
+ const lines = [`Last launch attempt: ${state.lastLaunchAttempt.mode}`];
1319
+ if (state.lastLaunchAttempt.selectedCandidate) {
1320
+ lines.push(`Launched browser: ${state.lastLaunchAttempt.selectedCandidate}`);
1321
+ } else {
1322
+ lines.push(`Tried browser candidates: ${state.lastLaunchAttempt.candidateLabels.join(", ")}`);
1323
+ }
1324
+ if (state.lastLaunchAttempt.userDataDir) {
1325
+ lines.push(`Managed browser profile: ${state.lastLaunchAttempt.userDataDir}`);
1326
+ }
1327
+ if (state.lastLaunchAttempt.lastError) {
1328
+ lines.push(`Last launch error: ${state.lastLaunchAttempt.lastError}`);
1329
+ }
1330
+ return lines;
893
1331
  }
894
1332
 
895
1333
  function launchHint() {
896
- return `Start Chrome with remote debugging enabled: ${chromeLaunchCommand()}`;
1334
+ if (!isLocalDevToolsHost(state.host)) {
1335
+ return `Remote/non-local endpoints are not auto-launched. Start a browser with CDP enabled at ${devToolsEndpoint()}.`;
1336
+ }
1337
+ if (!state.autoLaunchEnabled) {
1338
+ return `Auto-launch is disabled. Start a browser manually: ${chromeLaunchCommand()}`;
1339
+ }
1340
+ const managedMode = state.portConfigured ? `port ${state.port}` : "a dynamic DevTools port";
1341
+ return `If no endpoint is available, Pi will auto-launch a Chromium-family browser with ${managedMode} and an isolated temp profile. Manual command: ${chromeLaunchCommand()}`;
1342
+ }
1343
+
1344
+ function browserCandidateHint() {
1345
+ return `Browser candidates: ${browserCandidateDefinitions().map((candidate) => candidate.label).join(", ")}`;
897
1346
  }
898
1347
 
899
1348
  function chromeLaunchCommand() {
900
- const executable =
901
- process.platform === "darwin"
902
- ? "/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome"
903
- : process.platform === "win32"
904
- ? "chrome.exe"
905
- : "google-chrome";
906
- const dataDir = process.platform === "win32" ? "%TEMP%\\pi-chrome-devtools" : "/tmp/pi-chrome-devtools";
907
- return `${executable} --remote-debugging-port=${state.port} --user-data-dir=${dataDir}`;
1349
+ const executable = state.browserExecutable ?? defaultManualBrowserExecutable();
1350
+ const dataDir =
1351
+ process.platform === "win32" ? "%TEMP%\\pi-chrome-devtools" : "/tmp/pi-chrome-devtools";
1352
+ return `${quoteCommandPart(executable)} --remote-debugging-port=${state.port} --user-data-dir=${dataDir}`;
1353
+ }
1354
+
1355
+ function defaultManualBrowserExecutable() {
1356
+ return process.platform === "darwin"
1357
+ ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
1358
+ : process.platform === "win32"
1359
+ ? "chrome.exe"
1360
+ : "google-chrome";
1361
+ }
1362
+
1363
+ function quoteCommandPart(value: string) {
1364
+ return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
908
1365
  }
909
1366
 
910
1367
  function endpointConfigHint() {
911
- return "Set PI_CHROME_DEVTOOLS_HOST and PI_CHROME_DEVTOOLS_PORT if Chrome uses a different endpoint.";
1368
+ return "Set PI_CHROME_DEVTOOLS_HOST and PI_CHROME_DEVTOOLS_PORT for a manual endpoint, PI_CHROME_DEVTOOLS_BROWSER to choose an executable, or PI_CHROME_DEVTOOLS_AUTO_LAUNCH=0 to disable auto-launch.";
1369
+ }
1370
+
1371
+ function isLocalDevToolsHost(host: string) {
1372
+ const normalizedHost = host.toLowerCase().replace(/^\[(.*)]$/, "$1");
1373
+ return ["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"].includes(normalizedHost);
1374
+ }
1375
+
1376
+ function browserCandidateDefinitions(): BrowserCandidateDefinition[] {
1377
+ const explicitCandidate = explicitBrowserCandidateDefinition();
1378
+ if (explicitCandidate.length > 0) return explicitCandidate;
1379
+
1380
+ return uniqueBrowserCandidates(platformBrowserCandidateDefinitions());
1381
+ }
1382
+
1383
+ function explicitBrowserCandidateDefinition(): BrowserCandidateDefinition[] {
1384
+ if (!state.browserExecutable) return [];
1385
+ return [{ label: "PI_CHROME_DEVTOOLS_BROWSER", executable: state.browserExecutable, source: "env" }];
1386
+ }
1387
+
1388
+ function platformBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
1389
+ if (process.platform === "darwin") {
1390
+ return [
1391
+ {
1392
+ label: "Google Chrome",
1393
+ executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1394
+ source: "wellKnownPath",
1395
+ },
1396
+ {
1397
+ label: "Chromium",
1398
+ executable: "/Applications/Chromium.app/Contents/MacOS/Chromium",
1399
+ source: "wellKnownPath",
1400
+ },
1401
+ {
1402
+ label: "Brave Browser",
1403
+ executable: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
1404
+ source: "wellKnownPath",
1405
+ },
1406
+ {
1407
+ label: "Microsoft Edge",
1408
+ executable: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
1409
+ source: "wellKnownPath",
1410
+ },
1411
+ ];
1412
+ }
1413
+
1414
+ if (process.platform === "win32") {
1415
+ return windowsBrowserCandidateDefinitions();
1416
+ }
1417
+
1418
+ return [
1419
+ { label: "Google Chrome", executable: "google-chrome", source: "path" },
1420
+ { label: "Google Chrome Stable", executable: "google-chrome-stable", source: "path" },
1421
+ { label: "Chromium", executable: "chromium", source: "path" },
1422
+ { label: "Chromium Browser", executable: "chromium-browser", source: "path" },
1423
+ { label: "Brave Browser", executable: "brave-browser", source: "path" },
1424
+ { label: "Brave", executable: "brave", source: "path" },
1425
+ { label: "Microsoft Edge", executable: "microsoft-edge", source: "path" },
1426
+ { label: "Microsoft Edge Stable", executable: "microsoft-edge-stable", source: "path" },
1427
+ ];
1428
+ }
1429
+
1430
+ function windowsBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
1431
+ const programFiles = [
1432
+ process.env.PROGRAMFILES,
1433
+ process.env["PROGRAMFILES(X86)"],
1434
+ process.env.LOCALAPPDATA,
1435
+ ].filter((value): value is string => typeof value === "string" && value.length > 0);
1436
+ const wellKnownPaths = programFiles.flatMap((root) => [
1437
+ join(root, "Google", "Chrome", "Application", "chrome.exe"),
1438
+ join(root, "Chromium", "Application", "chrome.exe"),
1439
+ join(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
1440
+ join(root, "Microsoft", "Edge", "Application", "msedge.exe"),
1441
+ ]);
1442
+ return [
1443
+ ...wellKnownPaths.map((executable) => ({
1444
+ label: browserLabelFromExecutable(executable),
1445
+ executable,
1446
+ source: "wellKnownPath" as const,
1447
+ })),
1448
+ { label: "Google Chrome", executable: "chrome.exe", source: "path" },
1449
+ { label: "Chromium", executable: "chromium.exe", source: "path" },
1450
+ { label: "Brave Browser", executable: "brave.exe", source: "path" },
1451
+ { label: "Microsoft Edge", executable: "msedge.exe", source: "path" },
1452
+ ];
1453
+ }
1454
+
1455
+ function browserLabelFromExecutable(executable: string) {
1456
+ const normalizedExecutable = normalizePathForComparison(executable);
1457
+ if (normalizedExecutable.includes("brave")) return "Brave Browser";
1458
+ if (normalizedExecutable.includes("edge") || normalizedExecutable.includes("msedge")) {
1459
+ return "Microsoft Edge";
1460
+ }
1461
+ if (normalizedExecutable.includes("chromium")) return "Chromium";
1462
+ return "Google Chrome";
1463
+ }
1464
+
1465
+ function uniqueBrowserCandidates(candidates: BrowserCandidateDefinition[]) {
1466
+ const seen = new Set<string>();
1467
+ return candidates.filter((candidate) => {
1468
+ const key = normalizePathForComparison(candidate.executable);
1469
+ if (seen.has(key)) return false;
1470
+ seen.add(key);
1471
+ return true;
1472
+ });
1473
+ }
1474
+
1475
+ async function resolveBrowserCandidates(definitions: BrowserCandidateDefinition[]) {
1476
+ const candidates: BrowserCandidate[] = [];
1477
+ for (const definition of definitions) {
1478
+ const resolvedExecutable = await resolveBrowserExecutable(definition.executable);
1479
+ if (!resolvedExecutable) continue;
1480
+ candidates.push({ ...definition, resolvedExecutable });
1481
+ }
1482
+ return uniqueBrowserCandidatesByResolvedPath(candidates);
1483
+ }
1484
+
1485
+ function uniqueBrowserCandidatesByResolvedPath(candidates: BrowserCandidate[]) {
1486
+ const seen = new Set<string>();
1487
+ return candidates.filter((candidate) => {
1488
+ const key = normalizePathForComparison(resolve(candidate.resolvedExecutable));
1489
+ if (seen.has(key)) return false;
1490
+ seen.add(key);
1491
+ return true;
1492
+ });
1493
+ }
1494
+
1495
+ async function resolveBrowserExecutable(executable: string) {
1496
+ if (hasPathSeparator(executable) || isAbsolute(executable)) {
1497
+ const resolvedExecutable = isAbsolute(executable) ? executable : resolve(executable);
1498
+ return (await canAccessExecutable(resolvedExecutable)) ? resolvedExecutable : undefined;
1499
+ }
1500
+
1501
+ for (const directory of executableSearchPath()) {
1502
+ for (const executableName of executableSearchNames(executable)) {
1503
+ const candidate = join(directory, executableName);
1504
+ if (await canAccessExecutable(candidate)) return candidate;
1505
+ }
1506
+ }
1507
+ return undefined;
1508
+ }
1509
+
1510
+ function hasPathSeparator(path: string) {
1511
+ return path.includes("/") || path.includes("\\");
1512
+ }
1513
+
1514
+ function executableSearchPath() {
1515
+ return (process.env.PATH ?? "").split(delimiter).filter((part) => part.length > 0);
1516
+ }
1517
+
1518
+ function executableSearchNames(executable: string) {
1519
+ if (process.platform !== "win32" || /\.[a-z0-9]+$/i.test(executable)) return [executable];
1520
+ return [executable, `${executable}.exe`, `${executable}.cmd`, `${executable}.bat`];
1521
+ }
1522
+
1523
+ async function canAccessExecutable(path: string) {
1524
+ try {
1525
+ await access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
1526
+ return true;
1527
+ } catch {
1528
+ return false;
1529
+ }
1530
+ }
1531
+
1532
+ function formatBrowserCandidate(candidate: BrowserCandidate) {
1533
+ return `${candidate.label} (${candidate.resolvedExecutable})`;
1534
+ }
1535
+
1536
+ function formatBrowserCandidateDefinition(candidate: BrowserCandidateDefinition) {
1537
+ return `${candidate.label} (${candidate.executable})`;
1538
+ }
1539
+
1540
+ function noBrowserCandidateMessage(candidateDefinitions: BrowserCandidateDefinition[]) {
1541
+ return [
1542
+ "Cannot auto-launch Chrome DevTools because no Chromium-family browser executable was found.",
1543
+ `Tried: ${candidateDefinitions.map(formatBrowserCandidateDefinition).join(", ")}`,
1544
+ endpointConfigHint(),
1545
+ ].join("\n");
912
1546
  }
913
1547
 
914
1548
  function formatPageListItem(page: DevToolsPage) {
@@ -921,11 +1555,13 @@ function sleep(ms: number) {
921
1555
 
922
1556
  class DevToolsEndpointError extends Error {
923
1557
  readonly retryable: boolean;
1558
+ readonly launchable: boolean;
924
1559
 
925
- constructor(message: string, options: { retryable?: boolean } = {}) {
1560
+ constructor(message: string, options: { retryable?: boolean; launchable?: boolean } = {}) {
926
1561
  super(message);
927
1562
  this.name = "DevToolsEndpointError";
928
1563
  this.retryable = options.retryable ?? false;
1564
+ this.launchable = options.launchable ?? false;
929
1565
  }
930
1566
  }
931
1567
 
@@ -945,6 +1581,233 @@ function textResult(text: string, details: unknown) {
945
1581
  };
946
1582
  }
947
1583
 
1584
+ async function saveScreenshot(
1585
+ base64Png: string,
1586
+ savePath: string | undefined,
1587
+ cwd: string,
1588
+ signal: AbortSignal | undefined,
1589
+ ): Promise<ScreenshotSaveResult> {
1590
+ const resolvedPath = resolveScreenshotPath(savePath, cwd);
1591
+ const pngBytes = Buffer.from(base64Png, "base64");
1592
+
1593
+ await withFileMutationQueue(resolvedPath.path, async () => {
1594
+ throwIfAborted(signal);
1595
+ await ensureSafeScreenshotParent(resolvedPath);
1596
+ await assertSafeScreenshotTargetPath(resolvedPath);
1597
+ await writeScreenshotFileSafely(resolvedPath, pngBytes, signal);
1598
+ });
1599
+
1600
+ return {
1601
+ savedPath: resolvedPath.path,
1602
+ bytes: pngBytes.byteLength,
1603
+ isDefaultPath: resolvedPath.isDefault,
1604
+ };
1605
+ }
1606
+
1607
+ function resolveScreenshotPath(savePath: string | undefined, cwd: string): ResolvedScreenshotPath {
1608
+ const cwdRoot = resolve(cwd);
1609
+ const tempRoot = resolve(tmpdir());
1610
+
1611
+ if (savePath === undefined) {
1612
+ return {
1613
+ path: join(tempRoot, `pi-chrome-devtools-screenshot-${randomUUID()}.png`),
1614
+ allowedRoots: [tempRoot],
1615
+ isDefault: true,
1616
+ };
1617
+ }
1618
+
1619
+ const normalizedPath = stripLeadingAtPath(savePath);
1620
+ if (!normalizedPath.trim()) {
1621
+ throw new Error("Screenshot savePath must not be empty.");
1622
+ }
1623
+ if (normalizedPath.includes("\0")) {
1624
+ throw new Error("Screenshot savePath must not contain NUL bytes.");
1625
+ }
1626
+ if (hasParentPathSegment(normalizedPath)) {
1627
+ throw new Error("Screenshot savePath must not contain '..' path segments.");
1628
+ }
1629
+
1630
+ const isAbsolutePath = isAbsolute(normalizedPath);
1631
+ const path = isAbsolutePath ? resolve(normalizedPath) : resolve(cwdRoot, normalizedPath);
1632
+ const allowedRoots = isAbsolutePath ? unique([cwdRoot, tempRoot]) : [cwdRoot];
1633
+ if (!allowedRoots.some((root) => isPathInsideRoot(path, root))) {
1634
+ throw new Error(
1635
+ "Screenshot savePath must be relative to the current working directory, or an absolute path inside the current working directory or OS temp directory.",
1636
+ );
1637
+ }
1638
+
1639
+ return { path, allowedRoots, isDefault: false };
1640
+ }
1641
+
1642
+ function stripLeadingAtPath(path: string) {
1643
+ return path.startsWith("@") ? path.slice(1) : path;
1644
+ }
1645
+
1646
+ function hasParentPathSegment(path: string) {
1647
+ return path.split(/[\\/]+/).some((part) => part === "..");
1648
+ }
1649
+
1650
+ async function ensureSafeScreenshotParent(resolvedPath: ResolvedScreenshotPath) {
1651
+ const parentPath = dirname(resolvedPath.path);
1652
+ const rootPath = selectAllowedRoot(parentPath, resolvedPath.allowedRoots);
1653
+ if (!rootPath) {
1654
+ throw new Error(
1655
+ "Screenshot savePath parent must stay inside the current working directory or OS temp directory.",
1656
+ );
1657
+ }
1658
+
1659
+ const realRootPath = await realpath(rootPath);
1660
+ let currentPath = rootPath;
1661
+ const parentSegments = relative(rootPath, parentPath)
1662
+ .split(/[\\/]+/)
1663
+ .filter((part) => part.length > 0);
1664
+
1665
+ for (const segment of parentSegments) {
1666
+ currentPath = join(currentPath, segment);
1667
+ await ensureSafeDirectorySegment(currentPath, realRootPath);
1668
+ }
1669
+ }
1670
+
1671
+ function selectAllowedRoot(path: string, roots: readonly string[]) {
1672
+ const matchingRoots = roots.filter((root) => isPathInsideRoot(path, root));
1673
+ matchingRoots.sort(
1674
+ (left, right) =>
1675
+ normalizePathForComparison(resolve(right)).length -
1676
+ normalizePathForComparison(resolve(left)).length,
1677
+ );
1678
+ return matchingRoots[0];
1679
+ }
1680
+
1681
+ async function ensureSafeDirectorySegment(path: string, realRootPath: string) {
1682
+ const existingDirectory = await lstat(path).catch(async (error: unknown) => {
1683
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
1684
+ await mkdir(path).catch((mkdirError: unknown) => {
1685
+ if (!isNodeError(mkdirError) || mkdirError.code !== "EEXIST") throw mkdirError;
1686
+ });
1687
+ return lstat(path);
1688
+ });
1689
+
1690
+ if (existingDirectory.isSymbolicLink()) {
1691
+ throw new Error("Screenshot savePath parent directories must not contain symbolic links.");
1692
+ }
1693
+ if (!existingDirectory.isDirectory()) {
1694
+ throw new Error("Screenshot savePath parent must be a directory.");
1695
+ }
1696
+ await assertPathWithinRealRoot(path, realRootPath);
1697
+ }
1698
+
1699
+ async function assertSafeScreenshotTargetPath(resolvedPath: ResolvedScreenshotPath) {
1700
+ const existingTarget = await lstat(resolvedPath.path).catch((error: unknown) => {
1701
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
1702
+ throw error;
1703
+ });
1704
+ if (existingTarget?.isSymbolicLink()) {
1705
+ throw new Error("Screenshot savePath must not point to a symbolic link.");
1706
+ }
1707
+ if (existingTarget?.isDirectory()) {
1708
+ throw new Error("Screenshot savePath must point to a file, not a directory.");
1709
+ }
1710
+ if (existingTarget && !existingTarget.isFile()) {
1711
+ throw new Error("Screenshot savePath may only replace regular files.");
1712
+ }
1713
+
1714
+ const realAllowedRoots = await Promise.all(resolvedPath.allowedRoots.map(realpathOrResolvedPath));
1715
+ const realParent = await realpath(dirname(resolvedPath.path));
1716
+ const realTargetPath = join(realParent, basename(resolvedPath.path));
1717
+ if (!realAllowedRoots.some((root) => isPathInsideRoot(realTargetPath, root))) {
1718
+ throw new Error(
1719
+ "Screenshot savePath resolves outside the current working directory or OS temp directory.",
1720
+ );
1721
+ }
1722
+ }
1723
+
1724
+ async function assertPathWithinRealRoot(path: string, realRootPath: string) {
1725
+ const realPath = await realpath(path);
1726
+ if (!isPathInsideRoot(realPath, realRootPath)) {
1727
+ throw new Error(
1728
+ "Screenshot savePath parent resolves outside the current working directory or OS temp directory.",
1729
+ );
1730
+ }
1731
+ }
1732
+
1733
+ async function writeScreenshotFileSafely(
1734
+ resolvedPath: ResolvedScreenshotPath,
1735
+ pngBytes: Buffer,
1736
+ signal: AbortSignal | undefined,
1737
+ ) {
1738
+ const tempFile = join(
1739
+ dirname(resolvedPath.path),
1740
+ `.${basename(resolvedPath.path)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`,
1741
+ );
1742
+ try {
1743
+ await writeFile(tempFile, pngBytes, { flag: "wx", signal });
1744
+ throwIfAborted(signal);
1745
+ await replaceScreenshotFile(resolvedPath, tempFile, signal);
1746
+ } catch (error) {
1747
+ await rm(tempFile, { force: true }).catch(() => undefined);
1748
+ throw error;
1749
+ }
1750
+ }
1751
+
1752
+ async function replaceScreenshotFile(
1753
+ resolvedPath: ResolvedScreenshotPath,
1754
+ tempFile: string,
1755
+ signal: AbortSignal | undefined,
1756
+ ) {
1757
+ try {
1758
+ await rename(tempFile, resolvedPath.path);
1759
+ return;
1760
+ } catch (error) {
1761
+ if (!shouldRetryRenameAfterRemovingDestination(error)) throw error;
1762
+ }
1763
+
1764
+ // Some Windows filesystems reject renaming over an existing file. Revalidate before
1765
+ // removing the destination so the fallback still refuses directories and symlinks.
1766
+ await assertSafeScreenshotTargetPath(resolvedPath);
1767
+ throwIfAborted(signal);
1768
+ await rm(resolvedPath.path, { force: true });
1769
+ await rename(tempFile, resolvedPath.path);
1770
+ }
1771
+
1772
+ function shouldRetryRenameAfterRemovingDestination(error: unknown) {
1773
+ return (
1774
+ process.platform === "win32" &&
1775
+ isNodeError(error) &&
1776
+ ["EACCES", "EEXIST", "EPERM"].includes(error.code ?? "")
1777
+ );
1778
+ }
1779
+
1780
+ async function realpathOrResolvedPath(path: string) {
1781
+ return realpath(path).catch(() => resolve(path));
1782
+ }
1783
+
1784
+ function isPathInsideRoot(path: string, root: string) {
1785
+ const normalizedPath = normalizePathForComparison(resolve(path));
1786
+ const normalizedRoot = normalizePathForComparison(resolve(root));
1787
+ if (normalizedPath === normalizedRoot) return true;
1788
+ const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
1789
+ return normalizedPath.startsWith(rootWithSeparator);
1790
+ }
1791
+
1792
+ function normalizePathForComparison(path: string) {
1793
+ return process.platform === "win32" ? path.toLowerCase() : path;
1794
+ }
1795
+
1796
+ function throwIfAborted(signal: AbortSignal | undefined) {
1797
+ if (!signal?.aborted) return;
1798
+ throw signal.reason instanceof Error ? signal.reason : new Error("Screenshot capture cancelled.");
1799
+ }
1800
+
1801
+ function formatScreenshotText(page: DevToolsPage, screenshot: ScreenshotSaveResult) {
1802
+ const pathLabel = screenshot.isDefaultPath ? "Saved to temp file" : "Saved to";
1803
+ return [
1804
+ `Captured PNG screenshot from ${page.title || page.url || page.id}.`,
1805
+ `${pathLabel}: ${screenshot.savedPath}`,
1806
+ `Bytes: ${screenshot.bytes}`,
1807
+ `Use read({ path: ${JSON.stringify(screenshot.savedPath)} }) to inspect the saved screenshot if inline image content is not available.`,
1808
+ ].join("\n");
1809
+ }
1810
+
948
1811
  function renderToolCall(action: string) {
949
1812
  return () => new PiTextComponent(`Chrome DevTools: ${action}`);
950
1813
  }
@@ -963,7 +1826,7 @@ function renderScreenshotResult(
963
1826
  options: ToolRenderResultOptions,
964
1827
  theme: RenderTheme,
965
1828
  ): RenderComponent {
966
- const output = formatCollapsibleOutput(textContent(result), options);
1829
+ const output = formatCollapsibleOutput(screenshotTextContent(result), options);
967
1830
  return new PiTextComponent(output.text, theme, output.color);
968
1831
  }
969
1832
 
@@ -973,6 +1836,16 @@ function textContent(result: AgentToolResult<unknown>) {
973
1836
  .join("\n");
974
1837
  }
975
1838
 
1839
+ function screenshotTextContent(result: AgentToolResult<unknown>) {
1840
+ const text = textContent(result);
1841
+ if (text.trim()) return text;
1842
+
1843
+ const details = result.details as { savedPath?: unknown; bytes?: unknown } | undefined;
1844
+ if (typeof details?.savedPath !== "string") return text;
1845
+ const bytes = typeof details.bytes === "number" ? ` (${details.bytes} bytes)` : "";
1846
+ return `Saved screenshot to ${details.savedPath}${bytes}`;
1847
+ }
1848
+
976
1849
  function formatCollapsibleOutput(
977
1850
  text: string,
978
1851
  options: ToolRenderResultOptions,
@@ -984,11 +1857,15 @@ function formatCollapsibleOutput(
984
1857
  }
985
1858
 
986
1859
  class PiTextComponent implements RenderComponent {
987
- constructor(
988
- private text = "",
989
- private readonly theme?: RenderTheme,
990
- private readonly color?: string,
991
- ) {}
1860
+ private text: string;
1861
+ private readonly theme?: RenderTheme;
1862
+ private readonly color?: string;
1863
+
1864
+ constructor(text = "", theme?: RenderTheme, color?: string) {
1865
+ this.text = text;
1866
+ this.theme = theme;
1867
+ this.color = color;
1868
+ }
992
1869
 
993
1870
  setText(text: string) {
994
1871
  this.text = text;