From 233f29aedf004699a97dbd9aee087e239f4a9e47 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Tue, 28 Jul 2026 21:46:00 +0100 Subject: feat(cdp): BU_CDP_HTTP to attach to a browser on another host Upstream can only drive a browser on localhost. The pi extension always goes through the daemon (src/index.ts uses createDaemonTransport), the daemon resolves Chrome with discoverWsUrl() (src/daemon/bridge.ts), and that probe is hardcoded to 127.0.0.1 (src/cdp/discovery.ts). BU_CDP_WS is read only by the in-process client (src/client.ts), which that path never touches, so it has no effect on the extension - the only workaround is relaying the remote browser onto localhost first. BU_CDP_HTTP="host:port" points at the remote CDP HTTP endpoint. It is resolved inside discoverEndpoint(), so the daemon and the client both honour it. Deliberately an HTTP endpoint rather than a WebSocket URL: it is re-resolved on every discovery call, so the browser UUID is re-read after the remote browser restarts. Chrome re-mints that UUID on every launch, so a pinned WebSocket URL goes stale as soon as the remote browser is recycled. Also skips performSetup's local-process precondition when BU_CDP_HTTP is set: isBrowserRunning() enumerates local processes and can never see a remote browser. Step 4 still fails loudly if it is unreachable. No userDataDir is returned for remote endpoints, since the profile registry lives on the remote host - profile enumeration degrades to the pre-profile behaviour rather than reading the wrong machine's Local State. --- src/cdp/discovery.ts | 33 +++++++++++++++++++++++++++++++-- src/setup.ts | 12 +++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts index 3de6e97..41bcfe5 100644 --- a/src/cdp/discovery.ts +++ b/src/cdp/discovery.ts @@ -63,10 +63,10 @@ const waitForPort = async (port: number): Promise> => { const isPortLive = async (port: number): Promise => (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): Promise => +const queryLiveWsUrl = (port: number, timeoutMs = 1_500, host = "127.0.0.1"): Promise => new Promise((resolve) => { const req = httpRequest( - { host: "127.0.0.1", port, path: "/json/version", method: "GET", timeout: timeoutMs }, + { host, port, path: "/json/version", method: "GET", timeout: timeoutMs }, (res) => { if (res.statusCode !== 200) { res.resume(); @@ -114,6 +114,35 @@ export const discoverEndpoint = async (): Promise> const override = endpointFromEnv(); if (override) return ok(override); + // LOCAL PATCH: direct remote browser support. + // + // 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. + // + // 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) { + 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?`, + )); + } + return ok({ wsUrl: ws }); + } + const dirs = userDataDirCandidates(); const candidates: Candidate[] = []; diff --git a/src/setup.ts b/src/setup.ts index 395fff4..44dfc2c 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -22,9 +22,15 @@ export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): v export type SetupResult = { success: true; data: string } | { success: false; error: string }; export async function performSetup(client: BrowserClient, ctx?: ExtensionContext): Promise { - const chromeRunning = await isBrowserRunning(); - if (!chromeRunning) { - return { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." }; + // Step 1: Check Chrome is running + // LOCAL PATCH: isBrowserRunning() enumerates *local* processes, so it can + // never see a browser reached via BU_CDP_HTTP. Skip the precondition in that + // case — step 4 still fails loudly if the remote browser is unreachable. + if (!process.env["BU_CDP_HTTP"]?.trim()) { + const chromeRunning = await isBrowserRunning(); + if (!chromeRunning) { + return { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." }; + } } const daemonReady = await ensureDaemon(); -- cgit v1.2.3