1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
|
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { connect as netConnect } from "node:net";
import { request as httpRequest } from "node:http";
import { type Result, err, ok } from "../util/result";
import { userDataDirCandidates } from "../profile/paths";
import { type CdpError, cdpError } from "./errors";
import { asString, errnoCode, isRecord } from "../util/guards";
import { describeCdpTarget, resolveCdpTarget } from "./endpoint";
import { readTarget } from "./target-store";
const PORT_PROBE_DEADLINE_MS = 30_000;
const PORT_PROBE_INTERVAL_MS = 1_000;
// Hand-rolled on purpose: this file runs inside the daemon, a separate Node process that pi does not
// supply host-provided packages (typebox) to. Everything the daemon imports must be a runtime
// dependency or a builtin; test/daemon/entry-imports-test.ts enforces that.
export const parseVersionResponse = (raw: string): string | null => {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isRecord(parsed)) return null;
return asString(parsed["webSocketDebuggerUrl"]) ?? null;
};
export type CdpEndpoint = {
readonly wsUrl: string;
readonly userDataDir?: string;
};
const probePort = (port: number): Promise<Result<void, CdpError>> =>
new Promise((resolve) => {
const sock = netConnect({ host: "127.0.0.1", port });
let settled = false;
const finish = (r: Result<void, CdpError>): void => {
if (settled) return;
settled = true;
sock.setTimeout(0);
sock.destroy();
resolve(r);
};
sock.setTimeout(1000, () => finish(err(cdpError("discovery_failed", "probe timeout"))));
sock.once("error", (e) => finish(err(cdpError("discovery_failed", e.message))));
sock.once("connect", () => finish(ok(undefined)));
});
const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
const end = Date.now() + PORT_PROBE_DEADLINE_MS;
let lastMessage = "unknown";
while (Date.now() < end) {
const probe = await probePort(port);
if (probe.success) return probe;
lastMessage = probe.error.message;
await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS));
}
return err(cdpError(
"discovery_failed",
`Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastMessage})`,
));
};
const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
// /json/version is authoritative: a stale DevToolsActivePort file may name a browser that has since exited while another now owns the port.
export const queryLiveWsUrl = (port: number, timeoutMs = 1_500, host = "127.0.0.1"): Promise<string | null> =>
new Promise<string | null>((resolve) => {
const req = httpRequest(
{ host, port, path: "/json/version", method: "GET", timeout: timeoutMs },
(res) => {
if (res.statusCode !== 200) {
res.resume();
resolve(null);
return;
}
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => {
resolve(parseVersionResponse(Buffer.concat(chunks).toString("utf8")));
});
},
);
req.on("timeout", () => { req.destroy(); resolve(null); });
req.on("error", () => resolve(null));
req.end();
});
const fallbackPorts = (): ReadonlyArray<number> => {
const ports = new Set<number>([9222]);
const fromEnv = process.env["BU_CDP_PORTS"];
if (fromEnv) {
for (const tok of fromEnv.split(",")) {
const n = Number(tok.trim());
if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n);
}
}
return Array.from(ports);
};
type Candidate = {
readonly port: number;
readonly path: string;
readonly mtimeMs: number;
readonly userDataDir: string;
};
// Read here rather than in the client: the daemon owns the only socket to Chrome, so an override the daemon cannot see has no effect.
export const endpointFromEnv = (env: NodeJS.ProcessEnv = process.env): CdpEndpoint | undefined => {
const wsUrl = env["BU_CDP_WS"]?.trim();
return wsUrl ? { wsUrl } : undefined;
};
export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
const override = endpointFromEnv();
if (override) return ok(override);
// FORK: drive a browser on another host. See src/cdp/endpoint.ts for the
// precedence rules and FORK.md for why this exists at all.
//
// The remote is asked for its canonical webSocketDebuggerUrl on every
// discovery call, so the browser UUID is re-read after the remote browser
// restarts. That is the difference from upstream's BU_CDP_WS (checked just
// above, so an explicit WebSocket URL still wins), which pins a UUID that
// Chrome re-mints on every launch.
//
// No userDataDir is returned: the profile registry lives on the remote host,
// so profile enumeration degrades to the pre-profile behaviour rather than
// reading the wrong machine's Local State.
const target = resolveCdpTarget(process.env, await readTarget());
if (target.kind === "invalid") {
return err(cdpError("discovery_failed", describeCdpTarget(target)));
}
if (target.kind === "remote") {
const ws = await queryLiveWsUrl(target.port, 5_000, target.host);
if (ws) return ok({ wsUrl: ws });
// An explicitly configured endpoint is an instruction: fail rather than
// quietly automate a different browser than the one that was asked for.
if (target.source === "env") {
return err(cdpError(
"discovery_failed",
`No CDP endpoint answered at http://${target.host}:${target.port}/json/version (BU_CDP_HTTP). ` +
`Is the remote browser running and reachable? Set BU_CDP_HTTP=local to use this machine's browser instead.`,
));
}
// The built-in default is only a preference — off the wire network, fall
// through to the local browser the extension was originally written for.
}
const dirs = userDataDirCandidates();
const candidates: Candidate[] = [];
const readErrors: string[] = [];
for (const base of dirs) {
const portFile = join(base, "DevToolsActivePort");
let raw: string;
let mtimeMs = 0;
try {
raw = await readFile(portFile, "utf8");
try {
mtimeMs = (await stat(portFile)).mtimeMs;
} catch {}
} catch (e) {
// EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery.
const code = errnoCode(e);
if (code === "ENOENT" || code === undefined) continue;
if (code === "EPERM" || code === "EACCES") {
readErrors.push(`${portFile}: ${code}`);
continue;
}
return err(cdpError("discovery_failed", `failed to read ${portFile}: ${e instanceof Error ? e.message : String(e)}`));
}
const lines = raw.trim().split("\n");
if (lines.length < 2) continue;
const port = lines[0]?.trim();
const path = lines[1]?.trim();
if (!port || !path) continue;
// An out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT synchronously, escaping discoverWsUrl instead of surfacing as a Result error.
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
candidates.push({ port: portNum, path, mtimeMs, userDataDir: base });
}
if (candidates.length === 0) {
for (const port of fallbackPorts()) {
if (!(await isPortLive(port))) continue;
const live = await queryLiveWsUrl(port);
if (live) return ok({ wsUrl: live });
}
const permHint = readErrors.length > 0
? `\n\nDevToolsActivePort reads were denied (${readErrors.join(", ")}); if you're running in a sandbox, grant read access to those files or set BU_CDP_WS / BU_CDP_PORTS to bypass file discovery.`
: "";
return err(cdpError(
"discovery_failed",
`DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging (or brave://inspect, edge://inspect) in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.${permHint}`,
));
}
const byPort = new Map<number, Candidate>();
for (const c of candidates) {
const prev = byPort.get(c.port);
if (!prev || c.mtimeMs > prev.mtimeMs) byPort.set(c.port, c);
}
const unique = Array.from(byPort.values());
const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port)));
const liveCandidates = unique.filter((_, i) => liveChecks[i]);
const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
// Some browsers disable the /json/version endpoint when remote debugging is toggled via chrome://inspect rather than a launch flag, hence the DevToolsActivePort fallback.
let lastErr: Result<CdpEndpoint, CdpError> | null = null;
for (const c of ordered) {
const ready = await waitForPort(c.port);
if (!ready.success) {
lastErr = err(ready.error);
continue;
}
const liveUrl = await queryLiveWsUrl(c.port);
return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
}
for (const port of fallbackPorts()) {
if (byPort.has(port) || !(await isPortLive(port))) continue;
const liveUrl = await queryLiveWsUrl(port);
if (liveUrl) return ok({ wsUrl: liveUrl });
}
return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
};
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
const endpoint = await discoverEndpoint();
return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint;
};
|