aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/cdp/discovery.ts49
-rw-r--r--src/cdp/endpoint.ts84
-rw-r--r--src/index.ts28
-rw-r--r--test/cdp/endpoint-test.ts99
4 files changed, 237 insertions, 23 deletions
diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts
index 41bcfe5..53d4ad2 100644
--- a/src/cdp/discovery.ts
+++ b/src/cdp/discovery.ts
@@ -6,6 +6,7 @@ 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";
const PORT_PROBE_DEADLINE_MS = 30_000;
const PORT_PROBE_INTERVAL_MS = 1_000;
@@ -63,7 +64,7 @@ const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
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.
-const queryLiveWsUrl = (port: number, timeoutMs = 1_500, host = "127.0.0.1"): Promise<string | null> =>
+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 },
@@ -114,33 +115,37 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
const override = endpointFromEnv();
if (override) return ok(override);
- // LOCAL PATCH: direct remote browser support.
+ // 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.
//
- // BU_CDP_HTTP="host:port" points at a CDP *HTTP* endpoint on another machine.
- // We ask it for its canonical webSocketDebuggerUrl on every discovery call,
- // so the browser UUID is re-read after the remote Chrome restarts. That is
- // the difference from the stock BU_CDP_WS (checked just above, so an explicit
- // WebSocket URL still wins), which pins a UUID that Chrome re-mints on every
- // launch.
+ // 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.
//
- // Returns no userDataDir: the profile registry lives on the remote host, so
- // profile enumeration correctly degrades to the pre-profile behaviour.
- const remote = process.env["BU_CDP_HTTP"]?.trim();
- if (remote) {
- const sep = remote.lastIndexOf(":");
- const host = sep === -1 ? remote : remote.slice(0, sep);
- const port = sep === -1 ? 9222 : Number(remote.slice(sep + 1));
- if (!host || !Number.isInteger(port) || port <= 0 || port >= 65536) {
- return err(cdpError("discovery_failed", `BU_CDP_HTTP is not a valid host:port — got "${remote}"`));
- }
- const ws = await queryLiveWsUrl(port, 5_000, host);
- if (!ws) {
+ // 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();
+ 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://${host}:${port}/json/version (BU_CDP_HTTP). Is the remote browser running and reachable?`,
+ `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.`,
));
}
- return ok({ wsUrl: ws });
+ // 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();
diff --git a/src/cdp/endpoint.ts b/src/cdp/endpoint.ts
new file mode 100644
index 0000000..1be5322
--- /dev/null
+++ b/src/cdp/endpoint.ts
@@ -0,0 +1,84 @@
+/**
+ * Which browser this harness should drive.
+ *
+ * Upstream only ever drives a browser on localhost. This fork adds a remote
+ * target so pi can drive a shared Chrome on another host (see FORK.md).
+ *
+ * Resolution is pure — no I/O — so the status command can report the configured
+ * intent without touching the network, and the daemon can resolve the same way
+ * in its own process.
+ */
+
+/** Default remote CDP endpoint: castle's Chrome, exposed on the wire network. */
+export const DEFAULT_REMOTE_CDP = "10.88.0.25:9223";
+
+/** Values of BU_CDP_HTTP that mean "ignore the default, use this machine". */
+const LOCAL_ALIASES = new Set(["local", "localhost", "off", "none", "0", "no"]);
+
+export type CdpTarget =
+ | {
+ readonly kind: "remote";
+ readonly host: string;
+ readonly port: number;
+ /** "env" was asked for explicitly; "default" is this fork's built-in. */
+ readonly source: "env" | "default";
+ }
+ | { readonly kind: "local"; readonly source: "env" }
+ | { readonly kind: "invalid"; readonly raw: string; readonly reason: string };
+
+/**
+ * Precedence:
+ * BU_CDP_HTTP="host:port" -> that remote, and a failure to reach it is fatal
+ * BU_CDP_HTTP=local|off|… -> upstream behaviour, this machine's own browser
+ * unset -> DEFAULT_REMOTE_CDP, falling back to local when
+ * it isn't reachable
+ *
+ * The asymmetry is deliberate. An explicit endpoint is an instruction, so
+ * silently driving a different browser instead would be wrong. The built-in
+ * default is only a preference, so a laptop away from the wire network still
+ * gets the local browser the extension was originally written for.
+ */
+export const resolveCdpTarget = (env: NodeJS.ProcessEnv = process.env): CdpTarget => {
+ const raw = env["BU_CDP_HTTP"]?.trim();
+
+ if (raw === undefined || raw === "") {
+ const parsed = parseHostPort(DEFAULT_REMOTE_CDP);
+ // Unreachable in practice: the constant above is validated by its own test.
+ if (!parsed) return { kind: "local", source: "env" };
+ return { kind: "remote", host: parsed.host, port: parsed.port, source: "default" };
+ }
+
+ if (LOCAL_ALIASES.has(raw.toLowerCase())) return { kind: "local", source: "env" };
+
+ const parsed = parseHostPort(raw);
+ if (!parsed) {
+ return {
+ kind: "invalid",
+ raw,
+ reason: `expected "host:port" or one of ${[...LOCAL_ALIASES].join(", ")}`,
+ };
+ }
+ return { kind: "remote", host: parsed.host, port: parsed.port, source: "env" };
+};
+
+const parseHostPort = (value: string): { host: string; port: number } | null => {
+ const sep = value.lastIndexOf(":");
+ if (sep === -1) return null;
+ const host = value.slice(0, sep).trim();
+ const port = Number(value.slice(sep + 1).trim());
+ if (!host) return null;
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null;
+ return { host, port };
+};
+
+/** One-line description for /browser-status and error messages. */
+export const describeCdpTarget = (target: CdpTarget): string => {
+ switch (target.kind) {
+ case "remote":
+ return `${target.host}:${target.port}` + (target.source === "default" ? " (built-in default)" : " (BU_CDP_HTTP)");
+ case "local":
+ return "this machine's own browser (BU_CDP_HTTP=local)";
+ case "invalid":
+ return `invalid BU_CDP_HTTP "${target.raw}" — ${target.reason}`;
+ }
+};
diff --git a/src/index.ts b/src/index.ts
index 5818a5d..2b95697 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,6 +11,8 @@ import { restoreDownloadBehavior } from "./domains/files";
import { createDaemonTransport } from "./daemon/transport";
import { setDebugClicks } from "./util/debug";
import { asBoolean, asString } from "./util/guards";
+import { describeCdpTarget, resolveCdpTarget } from "./cdp/endpoint";
+import { queryLiveWsUrl } from "./cdp/discovery";
export default function browserHarnessExtension(pi: ExtensionAPI): void {
const flagNs = asString(pi.getFlag("browser-namespace"));
@@ -40,8 +42,32 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
}
const s = client.status();
const pin = client.profilePin();
+
+ // FORK: the connection is lazy — nothing dials the browser until the
+ // first browser_* tool runs. A plain "Disconnected" reads as a fault
+ // when it is really just "not needed yet", so say which it is, and show
+ // what we would connect to.
+ const target = resolveCdpTarget();
+ const reachable =
+ target.kind === "remote" ? (await queryLiveWsUrl(target.port, 2_000, target.host)) !== null : null;
+
+ const browserLine = s.alive
+ ? "🟢 Connected"
+ : reachable === false
+ ? "🔴 Not connected — target unreachable"
+ : "🟡 Not connected yet — connects on the first browser tool call";
+
+ const targetLine =
+ target.kind === "remote"
+ ? `${describeCdpTarget(target)}${reachable ? " — reachable ✓" : " — unreachable ✗"}` +
+ (reachable === false && target.source === "default"
+ ? "\n will fall back to this machine's browser"
+ : "")
+ : describeCdpTarget(target);
+
const lines = [
- `Browser: ${s.alive ? "🟢 Connected" : "🔴 Disconnected"}`,
+ `Browser: ${browserLine}`,
+ `Target: ${targetLine}`,
`Profile: ${pin ? pin.label : "not selected (uses whichever window is focused) — run /browser-profile"}`,
`Session: ${s.sessionId ?? "none"}`,
];
diff --git a/test/cdp/endpoint-test.ts b/test/cdp/endpoint-test.ts
new file mode 100644
index 0000000..d4b696f
--- /dev/null
+++ b/test/cdp/endpoint-test.ts
@@ -0,0 +1,99 @@
+/**
+ * Unit tests for CDP target resolution — no browser and no network required.
+ *
+ * This is fork-specific logic (see FORK.md): upstream only ever drives a
+ * browser on localhost. The precedence here is deliberately asymmetric — an
+ * explicitly configured endpoint must never silently fall back to a different
+ * browser, while the built-in default must, so a machine off the wire network
+ * still gets the local browser the extension was originally written for.
+ *
+ * Run: npx tsx test/cdp/endpoint-test.ts
+ */
+import { DEFAULT_REMOTE_CDP, describeCdpTarget, resolveCdpTarget } from "../../src/cdp/endpoint";
+
+let passed = 0;
+let failed = 0;
+const check = (cond: boolean, label: string): void => {
+ if (cond) {
+ passed++;
+ console.log(` ✓ ${label}`);
+ } else {
+ failed++;
+ console.error(` ✗ ${label}`);
+ }
+};
+
+const env = (value?: string): NodeJS.ProcessEnv => (value === undefined ? {} : { BU_CDP_HTTP: value });
+
+function main(): void {
+ // E1: the built-in default, used when nothing is configured.
+ {
+ const t = resolveCdpTarget(env());
+ check(t.kind === "remote", "E1: unset BU_CDP_HTTP resolves to a remote target");
+ if (t.kind === "remote") {
+ check(t.source === "default", "E1: it is marked as the built-in default, not an instruction");
+ check(`${t.host}:${t.port}` === DEFAULT_REMOTE_CDP, "E1: it matches DEFAULT_REMOTE_CDP");
+ }
+ }
+
+ // E2: DEFAULT_REMOTE_CDP must itself be parseable, or the default silently
+ // degrades to local for everyone.
+ {
+ const t = resolveCdpTarget(env());
+ check(t.kind !== "local", "E2: the built-in default constant is well-formed");
+ }
+
+ // E3: an explicit endpoint is an instruction — source must reflect that, so
+ // discovery fails loudly rather than falling back.
+ {
+ const t = resolveCdpTarget(env("10.0.0.9:9333"));
+ check(t.kind === "remote", "E3: an explicit host:port resolves to a remote target");
+ if (t.kind === "remote") {
+ check(t.host === "10.0.0.9" && t.port === 9333, "E3: host and port are parsed");
+ check(t.source === "env", "E3: it is marked as explicitly configured");
+ }
+ }
+
+ // E4: opting back into upstream behaviour.
+ for (const alias of ["local", "LOCAL", "off", "none", "0", "no"]) {
+ const t = resolveCdpTarget(env(alias));
+ check(t.kind === "local", `E4: "${alias}" selects this machine's own browser`);
+ }
+
+ // E5: empty or whitespace is treated as unset rather than as an error — a
+ // shell exporting an empty var should not break the browser.
+ for (const blank of ["", " "]) {
+ const t = resolveCdpTarget(env(blank));
+ check(t.kind === "remote" && t.source === "default", `E5: ${JSON.stringify(blank)} falls back to the default`);
+ }
+
+ // E6: malformed values are rejected, not coerced. Silently reinterpreting
+ // these would drive an unexpected browser.
+ for (const bad of ["nonsense", "host:", ":9222", "host:0", "host:70000", "host:abc"]) {
+ const t = resolveCdpTarget(env(bad));
+ check(t.kind === "invalid", `E6: "${bad}" is rejected as malformed`);
+ }
+
+ // E7: IPv6 and hostnames still work — the port is taken from the LAST colon.
+ {
+ const t = resolveCdpTarget(env("castle.local:9223"));
+ check(t.kind === "remote" && t.host === "castle.local", "E7: hostnames are accepted");
+ const v6 = resolveCdpTarget(env("[::1]:9223"));
+ check(v6.kind === "remote" && v6.host === "[::1]" && v6.port === 9223, "E7: bracketed IPv6 keeps its host");
+ }
+
+ // E8: descriptions are for humans in /browser-status; they must distinguish
+ // a default from an instruction, since only one of them falls back.
+ {
+ const d = describeCdpTarget(resolveCdpTarget(env()));
+ const e = describeCdpTarget(resolveCdpTarget(env("10.0.0.9:9333")));
+ check(d.includes("default"), "E8: the default is labelled as such");
+ check(e.includes("BU_CDP_HTTP"), "E8: an explicit endpoint names the variable that set it");
+ check(describeCdpTarget(resolveCdpTarget(env("local"))).includes("own browser"), "E8: local is described plainly");
+ }
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ if (failed > 0) process.exit(1);
+}
+
+main();