pi-agent-browser-native 0.2.44 → 0.2.45

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 (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +20 -15
  3. package/docs/ARCHITECTURE.md +12 -10
  4. package/docs/COMMAND_REFERENCE.md +49 -27
  5. package/docs/ELECTRON.md +1 -1
  6. package/docs/RELEASE.md +6 -5
  7. package/docs/REQUIREMENTS.md +6 -3
  8. package/docs/SUPPORT_MATRIX.md +17 -13
  9. package/docs/TOOL_CONTRACT.md +87 -46
  10. package/docs/platform-smoke.md +4 -3
  11. package/extensions/agent-browser/index.ts +29 -445
  12. package/extensions/agent-browser/lib/bash-guard.ts +205 -0
  13. package/extensions/agent-browser/lib/electron/cdp.ts +69 -0
  14. package/extensions/agent-browser/lib/electron/cleanup.ts +5 -58
  15. package/extensions/agent-browser/lib/electron/discovery.ts +2 -9
  16. package/extensions/agent-browser/lib/electron/launch.ts +11 -65
  17. package/extensions/agent-browser/lib/electron/text.ts +13 -0
  18. package/extensions/agent-browser/lib/fs-utils.ts +18 -0
  19. package/extensions/agent-browser/lib/input-modes/job.ts +207 -21
  20. package/extensions/agent-browser/lib/input-modes/params.ts +17 -7
  21. package/extensions/agent-browser/lib/input-modes/semantic-action.ts +22 -2
  22. package/extensions/agent-browser/lib/input-modes/types.ts +5 -1
  23. package/extensions/agent-browser/lib/input-modes.ts +1 -0
  24. package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +82 -11
  25. package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +153 -30
  26. package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +53 -2
  27. package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +1 -0
  28. package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +751 -32
  29. package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +38 -7
  30. package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +0 -46
  31. package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +10 -1
  32. package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +28 -1
  33. package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +1 -6
  34. package/extensions/agent-browser/lib/orchestration/input-plan.ts +15 -3
  35. package/extensions/agent-browser/lib/orchestration/output-file.ts +86 -0
  36. package/extensions/agent-browser/lib/pi-tool-rendering.ts +231 -0
  37. package/extensions/agent-browser/lib/playbook.ts +26 -26
  38. package/extensions/agent-browser/lib/process.ts +1 -1
  39. package/extensions/agent-browser/lib/prompt-policy.ts +1 -18
  40. package/extensions/agent-browser/lib/results/artifact-manifest.ts +1 -4
  41. package/extensions/agent-browser/lib/results/artifact-state.ts +7 -3
  42. package/extensions/agent-browser/lib/results/contracts.ts +6 -2
  43. package/extensions/agent-browser/lib/results/envelope.ts +11 -2
  44. package/extensions/agent-browser/lib/results/network-routes.ts +7 -4
  45. package/extensions/agent-browser/lib/results/network.ts +7 -1
  46. package/extensions/agent-browser/lib/results/presentation/artifacts.ts +88 -20
  47. package/extensions/agent-browser/lib/results/presentation/batch.ts +84 -12
  48. package/extensions/agent-browser/lib/results/presentation/diagnostics.ts +81 -26
  49. package/extensions/agent-browser/lib/results/presentation/errors.ts +13 -0
  50. package/extensions/agent-browser/lib/results/presentation/registry.ts +60 -0
  51. package/extensions/agent-browser/lib/results/presentation.ts +10 -1
  52. package/extensions/agent-browser/lib/results/snapshot-high-value-controls.ts +16 -5
  53. package/extensions/agent-browser/lib/results/snapshot.ts +2 -0
  54. package/extensions/agent-browser/lib/runtime.ts +10 -1
  55. package/extensions/agent-browser/lib/session-page-state.ts +15 -6
  56. package/extensions/agent-browser/lib/web-search.ts +1 -1
  57. package/package.json +2 -2
  58. package/platform-smoke.config.mjs +5 -2
  59. package/scripts/platform-smoke/build-ubuntu-image.mjs +25 -0
  60. package/scripts/platform-smoke/crabbox-runner.mjs +5 -1
  61. package/scripts/platform-smoke/doctor.mjs +6 -2
  62. package/scripts/platform-smoke/linux-image/Dockerfile +3 -5
  63. package/scripts/platform-smoke/targets.mjs +2 -1
  64. package/extensions/agent-browser/lib/orchestration/browser-run/browser-action-model.ts +0 -154
@@ -0,0 +1,205 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const DIRECT_AGENT_BROWSER_BASH_BYPASS_ENV = "PI_AGENT_BROWSER_ALLOW_DIRECT_BASH";
5
+ const DIRECT_AGENT_BROWSER_EXECUTABLE_PATTERN = /^(?:[.~]|\.\.?|\/)?(?:[^\s;&|]+\/)?agent-browser$/;
6
+ const HARMLESS_AGENT_BROWSER_INSPECTION_PATTERN = /^\s*(?:command\s+-v|which|type\s+-P)\s+agent-browser\s*$/;
7
+ const PACKAGE_NAME = "pi-agent-browser-native";
8
+
9
+ type ShellQuoteState = "double" | "single" | undefined;
10
+
11
+ function isShellAssignmentToken(token: string): boolean {
12
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
13
+ }
14
+
15
+ function stripOuterQuotes(token: string): string {
16
+ if (token.length >= 2 && ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'")))) {
17
+ return token.slice(1, -1);
18
+ }
19
+ return token;
20
+ }
21
+
22
+ function segmentLaunchesAgentBrowser(tokens: string[]): boolean {
23
+ let index = 0;
24
+ while (index < tokens.length && isShellAssignmentToken(tokens[index])) {
25
+ index += 1;
26
+ }
27
+ if (index >= tokens.length) {
28
+ return false;
29
+ }
30
+
31
+ let executableToken = tokens[index];
32
+ if (executableToken === "env") {
33
+ index += 1;
34
+ while (index < tokens.length && isShellAssignmentToken(tokens[index])) {
35
+ index += 1;
36
+ }
37
+ executableToken = tokens[index] ?? "";
38
+ }
39
+ if (executableToken === "npx" || executableToken === "bunx") {
40
+ index += 1;
41
+ while (index < tokens.length && tokens[index].startsWith("-")) {
42
+ index += 1;
43
+ }
44
+ executableToken = tokens[index] ?? "";
45
+ }
46
+ if (executableToken === "pnpm" || executableToken === "yarn") {
47
+ index += 1;
48
+ if (tokens[index] !== "dlx") {
49
+ return false;
50
+ }
51
+ index += 1;
52
+ while (index < tokens.length && tokens[index].startsWith("-")) {
53
+ index += 1;
54
+ }
55
+ executableToken = tokens[index] ?? "";
56
+ }
57
+ return DIRECT_AGENT_BROWSER_EXECUTABLE_PATTERN.test(executableToken);
58
+ }
59
+
60
+ // Best-effort detection for common direct launches only. This is an ergonomics guard,
61
+ // not a general-purpose bash parser or security boundary.
62
+ export function looksLikeDirectAgentBrowserBash(command: string): boolean {
63
+ let currentToken = "";
64
+ let quoteState: ShellQuoteState;
65
+ let awaitingHeredocDelimiter: { stripTabs: boolean } | undefined;
66
+ let pendingHeredoc: { delimiter: string; stripTabs: boolean } | undefined;
67
+ let pendingHeredocLine = "";
68
+ let segmentTokens: string[] = [];
69
+
70
+ const acceptToken = (token: string) => {
71
+ if (token.length === 0) {
72
+ return;
73
+ }
74
+ if (awaitingHeredocDelimiter) {
75
+ pendingHeredoc = {
76
+ delimiter: stripOuterQuotes(token),
77
+ stripTabs: awaitingHeredocDelimiter.stripTabs,
78
+ };
79
+ awaitingHeredocDelimiter = undefined;
80
+ return;
81
+ }
82
+ segmentTokens.push(token);
83
+ };
84
+ const flushToken = () => {
85
+ acceptToken(currentToken);
86
+ currentToken = "";
87
+ };
88
+ const flushSegment = () => {
89
+ const launchesAgentBrowser = segmentLaunchesAgentBrowser(segmentTokens);
90
+ segmentTokens = [];
91
+ return launchesAgentBrowser;
92
+ };
93
+
94
+ for (let index = 0; index < command.length; index += 1) {
95
+ const char = command[index];
96
+ if (pendingHeredoc) {
97
+ if (char === "\n") {
98
+ const candidate = pendingHeredoc.stripTabs ? pendingHeredocLine.replace(/^\t+/, "") : pendingHeredocLine;
99
+ if (candidate === pendingHeredoc.delimiter) {
100
+ pendingHeredoc = undefined;
101
+ }
102
+ pendingHeredocLine = "";
103
+ continue;
104
+ }
105
+ pendingHeredocLine += char;
106
+ continue;
107
+ }
108
+
109
+ if (quoteState === "single") {
110
+ currentToken += char;
111
+ if (char === "'") {
112
+ quoteState = undefined;
113
+ }
114
+ continue;
115
+ }
116
+ if (quoteState === "double") {
117
+ currentToken += char;
118
+ if (char === "\\" && index + 1 < command.length) {
119
+ currentToken += command[index + 1];
120
+ index += 1;
121
+ continue;
122
+ }
123
+ if (char === '"') {
124
+ quoteState = undefined;
125
+ }
126
+ continue;
127
+ }
128
+ if (char === "'" || char === '"') {
129
+ currentToken += char;
130
+ quoteState = char === "'" ? "single" : "double";
131
+ continue;
132
+ }
133
+ if (char === "\\" && index + 1 < command.length) {
134
+ currentToken += char;
135
+ currentToken += command[index + 1];
136
+ index += 1;
137
+ continue;
138
+ }
139
+ if (char === "\n") {
140
+ flushToken();
141
+ if (flushSegment()) {
142
+ return true;
143
+ }
144
+ continue;
145
+ }
146
+ if (/\s/.test(char)) {
147
+ flushToken();
148
+ continue;
149
+ }
150
+ const threeCharOperator = command.slice(index, index + 3);
151
+ if (threeCharOperator === "<<-") {
152
+ flushToken();
153
+ awaitingHeredocDelimiter = { stripTabs: true };
154
+ index += 2;
155
+ continue;
156
+ }
157
+ const twoCharOperator = command.slice(index, index + 2);
158
+ if (twoCharOperator === "<<") {
159
+ flushToken();
160
+ awaitingHeredocDelimiter = { stripTabs: false };
161
+ index += 1;
162
+ continue;
163
+ }
164
+ if (twoCharOperator === "&&" || twoCharOperator === "||") {
165
+ flushToken();
166
+ if (flushSegment()) {
167
+ return true;
168
+ }
169
+ index += 1;
170
+ continue;
171
+ }
172
+ if (char === "|" || char === ";" || char === "&") {
173
+ flushToken();
174
+ if (flushSegment()) {
175
+ return true;
176
+ }
177
+ continue;
178
+ }
179
+ currentToken += char;
180
+ }
181
+
182
+ flushToken();
183
+ return flushSegment();
184
+ }
185
+
186
+ export function isHarmlessAgentBrowserInspectionCommand(command: string): boolean {
187
+ return HARMLESS_AGENT_BROWSER_INSPECTION_PATTERN.test(command);
188
+ }
189
+
190
+ function isTruthyEnvValue(value: string | undefined): boolean {
191
+ return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";
192
+ }
193
+
194
+ async function isPackageDevelopmentCwd(cwd: string): Promise<boolean> {
195
+ try {
196
+ const packageJson = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")) as { name?: unknown };
197
+ return packageJson.name === PACKAGE_NAME;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ export async function isDirectAgentBrowserBashAllowed(cwd: string): Promise<boolean> {
204
+ return isTruthyEnvValue(process.env[DIRECT_AGENT_BROWSER_BASH_BYPASS_ENV]) || await isPackageDevelopmentCwd(cwd);
205
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Purpose: Parse and fetch Chrome DevTools Protocol metadata for wrapper-owned Electron launches.
3
+ * Responsibilities: Normalize CDP version/target JSON and perform bounded localhost CDP JSON fetches.
4
+ * Scope: Tiny Electron CDP boundary helpers only; launch, status, cleanup, and target selection stay in their owning modules.
5
+ * Usage: Imported by Electron launch and cleanup paths when polling `/json/version` and `/json/list`.
6
+ * Invariants/Assumptions: Malformed or unavailable CDP endpoints return undefined/empty metadata rather than throwing, matching prior caller behavior.
7
+ */
8
+
9
+ import { isRecord } from "../parsing.js";
10
+
11
+ const ELECTRON_CDP_FETCH_TIMEOUT_MS = 1_000;
12
+
13
+ export interface ElectronCdpVersion {
14
+ browser?: string;
15
+ protocolVersion?: string;
16
+ userAgent?: string;
17
+ v8Version?: string;
18
+ webKitVersion?: string;
19
+ webSocketDebuggerUrl?: string;
20
+ }
21
+
22
+ export interface ElectronCdpTarget {
23
+ id?: string;
24
+ title?: string;
25
+ type?: string;
26
+ url?: string;
27
+ webSocketDebuggerUrl?: string;
28
+ }
29
+
30
+ function asString(value: unknown): string | undefined {
31
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
32
+ }
33
+
34
+ export function parseCdpVersion(value: unknown): ElectronCdpVersion | undefined {
35
+ if (!isRecord(value)) return undefined;
36
+ return {
37
+ browser: asString(value.Browser) ?? asString(value.browser),
38
+ protocolVersion: asString(value["Protocol-Version"]) ?? asString(value.protocolVersion),
39
+ userAgent: asString(value["User-Agent"]) ?? asString(value.userAgent),
40
+ v8Version: asString(value["V8-Version"]) ?? asString(value.v8Version),
41
+ webKitVersion: asString(value["WebKit-Version"]) ?? asString(value.webKitVersion),
42
+ webSocketDebuggerUrl: asString(value.webSocketDebuggerUrl),
43
+ };
44
+ }
45
+
46
+ export function parseCdpTargets(value: unknown): ElectronCdpTarget[] {
47
+ if (!Array.isArray(value)) return [];
48
+ return value.filter(isRecord).map((target) => ({
49
+ id: asString(target.id),
50
+ title: asString(target.title),
51
+ type: asString(target.type),
52
+ url: asString(target.url),
53
+ webSocketDebuggerUrl: asString(target.webSocketDebuggerUrl),
54
+ }));
55
+ }
56
+
57
+ export async function fetchCdpJson(url: string): Promise<unknown | undefined> {
58
+ const controller = new AbortController();
59
+ const timeout = setTimeout(() => controller.abort(), ELECTRON_CDP_FETCH_TIMEOUT_MS);
60
+ try {
61
+ const response = await fetch(url, { signal: controller.signal });
62
+ if (!response.ok) return undefined;
63
+ return await response.json() as unknown;
64
+ } catch {
65
+ return undefined;
66
+ } finally {
67
+ clearTimeout(timeout);
68
+ }
69
+ }
@@ -7,13 +7,14 @@
7
7
  */
8
8
 
9
9
  import { execFile, type ChildProcess } from "node:child_process";
10
- import { access, rm } from "node:fs/promises";
10
+ import { rm } from "node:fs/promises";
11
11
  import { promisify } from "node:util";
12
12
 
13
+ import { fetchCdpJson, parseCdpTargets, parseCdpVersion } from "./cdp.js";
13
14
  import { ELECTRON_PROFILE_DIR_PREFIX, type ElectronCdpTarget, type ElectronCdpVersion, type ElectronLaunchRecord } from "./launch.js";
15
+ import { pathExists } from "../fs-utils.js";
14
16
  import { getSecureTempChildDirectoryValidationError } from "../temp.js";
15
17
 
16
- const ELECTRON_STATUS_FETCH_TIMEOUT_MS = 1_000;
17
18
  const ELECTRON_CLEANUP_DEFAULT_TIMEOUT_MS = 5_000;
18
19
  const ELECTRON_CLEANUP_POLL_INTERVAL_MS = 100;
19
20
  const RESTORED_PROCESS_COMMAND_TIMEOUT_MS = 1_000;
@@ -50,51 +51,6 @@ function sleep(ms: number): Promise<void> {
50
51
  return new Promise((resolve) => setTimeout(resolve, ms));
51
52
  }
52
53
 
53
- function isRecord(value: unknown): value is Record<string, unknown> {
54
- return typeof value === "object" && value !== null;
55
- }
56
-
57
- function asString(value: unknown): string | undefined {
58
- return typeof value === "string" && value.trim().length > 0 ? value : undefined;
59
- }
60
-
61
- function parseCdpVersion(value: unknown): ElectronCdpVersion | undefined {
62
- if (!isRecord(value)) return undefined;
63
- return {
64
- browser: asString(value.Browser) ?? asString(value.browser),
65
- protocolVersion: asString(value["Protocol-Version"]) ?? asString(value.protocolVersion),
66
- userAgent: asString(value["User-Agent"]) ?? asString(value.userAgent),
67
- v8Version: asString(value["V8-Version"]) ?? asString(value.v8Version),
68
- webKitVersion: asString(value["WebKit-Version"]) ?? asString(value.webKitVersion),
69
- webSocketDebuggerUrl: asString(value.webSocketDebuggerUrl),
70
- };
71
- }
72
-
73
- function parseCdpTargets(value: unknown): ElectronCdpTarget[] {
74
- if (!Array.isArray(value)) return [];
75
- return value.filter(isRecord).map((target) => ({
76
- id: asString(target.id),
77
- title: asString(target.title),
78
- type: asString(target.type),
79
- url: asString(target.url),
80
- webSocketDebuggerUrl: asString(target.webSocketDebuggerUrl),
81
- }));
82
- }
83
-
84
- async function fetchJson(url: string): Promise<unknown | undefined> {
85
- const controller = new AbortController();
86
- const timeout = setTimeout(() => controller.abort(), ELECTRON_STATUS_FETCH_TIMEOUT_MS);
87
- try {
88
- const response = await fetch(url, { signal: controller.signal });
89
- if (!response.ok) return undefined;
90
- return await response.json() as unknown;
91
- } catch {
92
- return undefined;
93
- } finally {
94
- clearTimeout(timeout);
95
- }
96
- }
97
-
98
54
  function isPidAlive(pid: number | undefined): boolean | undefined {
99
55
  if (!pid || !Number.isSafeInteger(pid) || pid <= 0) return undefined;
100
56
  try {
@@ -106,19 +62,10 @@ function isPidAlive(pid: number | undefined): boolean | undefined {
106
62
  }
107
63
  }
108
64
 
109
- async function pathExists(path: string): Promise<boolean> {
110
- try {
111
- await access(path);
112
- return true;
113
- } catch {
114
- return false;
115
- }
116
- }
117
-
118
65
  async function isPortAlive(port: number): Promise<{ targets: ElectronCdpTarget[]; version?: ElectronCdpVersion }> {
119
- const version = parseCdpVersion(await fetchJson(`http://127.0.0.1:${port}/json/version`));
66
+ const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version`));
120
67
  if (!version) return { targets: [] };
121
- const targets = parseCdpTargets(await fetchJson(`http://127.0.0.1:${port}/json/list`));
68
+ const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list`));
122
69
  return { targets, version };
123
70
  }
124
71
 
@@ -11,6 +11,8 @@ import { access, readdir, readFile, realpath, stat } from "node:fs/promises";
11
11
  import { homedir } from "node:os";
12
12
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
13
13
 
14
+ import { pathExists } from "../fs-utils.js";
15
+
14
16
  export const ELECTRON_DISCOVERY_DEFAULT_MAX_RESULTS = 50;
15
17
  export const ELECTRON_DISCOVERY_MAX_RESULTS = 200;
16
18
 
@@ -150,15 +152,6 @@ function resolveLocations(locations: ElectronDiscoveryScanLocations | undefined)
150
152
  };
151
153
  }
152
154
 
153
- async function pathExists(path: string): Promise<boolean> {
154
- try {
155
- await access(path, fsConstants.F_OK);
156
- return true;
157
- } catch {
158
- return false;
159
- }
160
- }
161
-
162
155
  async function isDirectory(path: string): Promise<boolean> {
163
156
  try {
164
157
  return (await stat(path)).isDirectory();
@@ -11,6 +11,13 @@ import { randomUUID } from "node:crypto";
11
11
  import { readFile, rm } from "node:fs/promises";
12
12
  import { dirname } from "node:path";
13
13
 
14
+ import {
15
+ fetchCdpJson,
16
+ parseCdpTargets,
17
+ parseCdpVersion,
18
+ type ElectronCdpTarget,
19
+ type ElectronCdpVersion,
20
+ } from "./cdp.js";
14
21
  import {
15
22
  discoverElectronApps,
16
23
  inspectElectronAppPath,
@@ -19,6 +26,8 @@ import {
19
26
  } from "./discovery.js";
20
27
  import { createSecureTempDirectory } from "../temp.js";
21
28
 
29
+ export type { ElectronCdpTarget, ElectronCdpVersion } from "./cdp.js";
30
+
22
31
  export const ELECTRON_LAUNCH_RECORD_VERSION = 1;
23
32
  export const ELECTRON_LAUNCH_DEFAULT_TIMEOUT_MS = 15_000;
24
33
  export const ELECTRON_LAUNCH_MAX_TIMEOUT_MS = 120_000;
@@ -27,7 +36,6 @@ const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
27
36
  export const ELECTRON_PROFILE_DIR_PREFIX = "electron-profile-";
28
37
  const ELECTRON_DEFAULT_APP_ARGS = ["--disable-extensions", "--no-first-run", "--no-default-browser-check"] as const;
29
38
  const ELECTRON_DEVTOOLS_POLL_INTERVAL_MS = 100;
30
- const ELECTRON_CDP_FETCH_TIMEOUT_MS = 1_000;
31
39
 
32
40
  export interface ElectronDevToolsActivePortRead {
33
41
  error?: string;
@@ -59,23 +67,6 @@ export type ElectronLaunchFailureReason =
59
67
  | "spawn-error"
60
68
  | "timeout";
61
69
 
62
- export interface ElectronCdpVersion {
63
- browser?: string;
64
- protocolVersion?: string;
65
- userAgent?: string;
66
- v8Version?: string;
67
- webKitVersion?: string;
68
- webSocketDebuggerUrl?: string;
69
- }
70
-
71
- export interface ElectronCdpTarget {
72
- id?: string;
73
- title?: string;
74
- type?: string;
75
- url?: string;
76
- webSocketDebuggerUrl?: string;
77
- }
78
-
79
70
  export interface ElectronLaunchRecord {
80
71
  appName: string;
81
72
  appPath?: string;
@@ -200,37 +191,6 @@ export async function resolveElectronLaunchTarget(options: ResolveElectronTarget
200
191
  return undefined;
201
192
  }
202
193
 
203
- function isRecord(value: unknown): value is Record<string, unknown> {
204
- return typeof value === "object" && value !== null;
205
- }
206
-
207
- function asString(value: unknown): string | undefined {
208
- return typeof value === "string" && value.trim().length > 0 ? value : undefined;
209
- }
210
-
211
- function parseCdpVersion(value: unknown): ElectronCdpVersion | undefined {
212
- if (!isRecord(value)) return undefined;
213
- return {
214
- browser: asString(value.Browser) ?? asString(value.browser),
215
- protocolVersion: asString(value["Protocol-Version"]) ?? asString(value.protocolVersion),
216
- userAgent: asString(value["User-Agent"]) ?? asString(value.userAgent),
217
- v8Version: asString(value["V8-Version"]) ?? asString(value.v8Version),
218
- webKitVersion: asString(value["WebKit-Version"]) ?? asString(value.webKitVersion),
219
- webSocketDebuggerUrl: asString(value.webSocketDebuggerUrl),
220
- };
221
- }
222
-
223
- function parseCdpTargets(value: unknown): ElectronCdpTarget[] {
224
- if (!Array.isArray(value)) return [];
225
- return value.filter(isRecord).map((target) => ({
226
- id: asString(target.id),
227
- title: asString(target.title),
228
- type: asString(target.type),
229
- url: asString(target.url),
230
- webSocketDebuggerUrl: asString(target.webSocketDebuggerUrl),
231
- }));
232
- }
233
-
234
194
  function targetMatchesType(target: ElectronCdpTarget, targetType: "any" | "page" | "webview" | undefined): boolean {
235
195
  return targetType === undefined || targetType === "any" || target.type === targetType;
236
196
  }
@@ -245,20 +205,6 @@ function selectElectronConnectArg(options: {
245
205
  return targetWebSocket ?? options.version.webSocketDebuggerUrl ?? String(options.port);
246
206
  }
247
207
 
248
- async function fetchJson(url: string): Promise<unknown | undefined> {
249
- const controller = new AbortController();
250
- const timeout = setTimeout(() => controller.abort(), ELECTRON_CDP_FETCH_TIMEOUT_MS);
251
- try {
252
- const response = await fetch(url, { signal: controller.signal });
253
- if (!response.ok) return undefined;
254
- return await response.json() as unknown;
255
- } catch {
256
- return undefined;
257
- } finally {
258
- clearTimeout(timeout);
259
- }
260
- }
261
-
262
208
  async function readDevToolsActivePort(userDataDir: string): Promise<ElectronDevToolsActivePortRead> {
263
209
  const path = `${userDataDir}/${DEVTOOLS_ACTIVE_PORT_FILE}`;
264
210
  try {
@@ -304,9 +250,9 @@ async function pollDevToolsActivePort(options: {
304
250
 
305
251
  async function pollCdpMetadata(port: number, deadlineMs: number): Promise<{ targets: ElectronCdpTarget[]; version: ElectronCdpVersion } | undefined> {
306
252
  while (Date.now() <= deadlineMs) {
307
- const version = parseCdpVersion(await fetchJson(`http://127.0.0.1:${port}/json/version`));
253
+ const version = parseCdpVersion(await fetchCdpJson(`http://127.0.0.1:${port}/json/version`));
308
254
  if (version) {
309
- const targets = parseCdpTargets(await fetchJson(`http://127.0.0.1:${port}/json/list`));
255
+ const targets = parseCdpTargets(await fetchCdpJson(`http://127.0.0.1:${port}/json/list`));
310
256
  return { targets, version };
311
257
  }
312
258
  await sleep(ELECTRON_DEVTOOLS_POLL_INTERVAL_MS);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Purpose: Keep small Electron presentation string helpers in one place.
3
+ * Responsibilities: Trim optional probe strings and bound them to model-visible lengths.
4
+ * Scope: Electron diagnostics/probe text helpers only; redaction and result formatting stay with their owning modules.
5
+ * Usage: Imported by Electron host probes and browser-run Electron diagnostics.
6
+ * Invariants/Assumptions: Empty or whitespace-only strings stay omitted, and truncation keeps the prior ellipsis behavior.
7
+ */
8
+
9
+ export function boundElectronProbeString(value: string | undefined, maxLength = 240): string | undefined {
10
+ const trimmed = value?.trim();
11
+ if (!trimmed) return undefined;
12
+ return trimmed.length > maxLength ? `${trimmed.slice(0, Math.max(0, maxLength - 3))}...` : trimmed;
13
+ }
@@ -0,0 +1,18 @@
1
+ import { access, stat } from "node:fs/promises";
2
+
3
+ export async function pathExists(path: string): Promise<boolean> {
4
+ try {
5
+ await access(path);
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
12
+ export async function directoryExists(path: string): Promise<boolean> {
13
+ try {
14
+ return (await stat(path)).isDirectory();
15
+ } catch {
16
+ return false;
17
+ }
18
+ }