diff --git a/packages/cli/src/__tests__/daemon-startup.test.ts b/packages/cli/src/__tests__/daemon-startup.test.ts index 6a5ce0b..c3aad9e 100644 --- a/packages/cli/src/__tests__/daemon-startup.test.ts +++ b/packages/cli/src/__tests__/daemon-startup.test.ts @@ -15,11 +15,12 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { spawn, execSync } from "node:child_process"; -import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import http from "node:http"; +import { WebSocketServer } from "ws"; // --------------------------------------------------------------------------- // Constants @@ -31,6 +32,7 @@ mkdirSync(DAEMON_DIR, { recursive: true }); process.env.BB_BROWSER_HOME = DAEMON_DIR; const DAEMON_JSON = path.join(DAEMON_DIR, "daemon.json"); const MANAGED_PORT_FILE = path.join(DAEMON_DIR, "browser", "cdp-port"); +const DEVTOOLS_ACTIVE_PORT_FILE = path.join(DAEMON_DIR, "DevToolsActivePort"); const DAEMON_ENTRY = path.resolve( import.meta.dirname, "../../../daemon/src/index.ts", @@ -60,6 +62,10 @@ function cleanupManagedPortFile(): void { try { unlinkSync(MANAGED_PORT_FILE); } catch {} } +function cleanupDevToolsActivePortFile(): void { + try { unlinkSync(DEVTOOLS_ACTIVE_PORT_FILE); } catch {} +} + /** Wait for daemon.json to appear (or timeout) */ async function waitForDaemonJson(timeoutMs = 8000): Promise<{ pid: number; host: string; port: number; token: string }> { const deadline = Date.now() + timeoutMs; @@ -101,6 +107,75 @@ function startFakeCdpServer(port: number): Promise { }); } +function startFakeBrowserWebSocket(port: number): Promise { + return new Promise((resolve, reject) => { + const wss = new WebSocketServer({ + host: "127.0.0.1", + port, + path: "/devtools/browser/fake", + }); + wss.on("connection", (socket) => { + socket.on("message", (raw) => { + const message = JSON.parse(String(raw)) as { id?: number; method?: string }; + if (!message.id) return; + const result = message.method === "Target.getTargets" ? { targetInfos: [] } : {}; + socket.send(JSON.stringify({ id: message.id, result })); + }); + }); + wss.on("error", reject); + wss.on("listening", () => resolve(wss)); + }); +} + +function waitForStatus( + host: string, + port: number, + token: string, + predicate: (status: Record) => boolean, + timeoutMs = 8000, +): Promise> { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = async () => { + try { + const status = await new Promise>((innerResolve, innerReject) => { + const req = http.request({ + hostname: host, + port, + path: "/status", + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + timeout: 1000, + }, res => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + try { + innerResolve(JSON.parse(Buffer.concat(chunks).toString()) as Record); + } catch (e) { + innerReject(e); + } + }); + }); + req.on("error", innerReject); + req.end(); + }); + if (predicate(status)) { + resolve(status); + return; + } + } catch {} + + if (Date.now() >= deadline) { + reject(new Error("status predicate not met in time")); + return; + } + setTimeout(tick, 200); + }; + tick(); + }); +} + function killProcess(pid: number): void { try { process.kill(pid, "SIGTERM"); } catch {} } @@ -117,6 +192,7 @@ describe("daemon startup without Chrome", () => { beforeEach(() => { cleanupDaemonJson(); cleanupManagedPortFile(); + cleanupDevToolsActivePortFile(); }); it("daemon exits with error when no CDP is available", async () => { @@ -176,6 +252,7 @@ describe("daemon startup with CDP available", () => { } daemonPid = null; cleanupDaemonJson(); + cleanupDevToolsActivePortFile(); if (fakeCdp) { await new Promise(resolve => fakeCdp!.close(() => resolve())); fakeCdp = null; @@ -250,6 +327,39 @@ describe("daemon startup with CDP available", () => { assert.equal(status.running, true, "/status should report running"); }); + + it("daemon connects via DevToolsActivePort browser WebSocket when /json/version is unavailable", async () => { + const wsPort = 39987; + const daemonPort = 39986; + const browserWs = await startFakeBrowserWebSocket(wsPort); + writeFileSync(DEVTOOLS_ACTIVE_PORT_FILE, `${wsPort}\n/devtools/browser/fake\n`); + + try { + const child = spawn(TSX, [ + DAEMON_ENTRY, + "--cdp-port", String(wsPort), + "--port", String(daemonPort), + "--no-chrome", + ], { + detached: true, + stdio: "ignore", + env: { + ...process.env, + BB_BROWSER_DEVTOOLS_ACTIVE_PORT_FILE: DEVTOOLS_ACTIVE_PORT_FILE, + }, + }); + child.unref(); + + const info = await waitForDaemonJson(); + daemonPid = info.pid; + + assert.equal((info as Record).cdpWsUrl, `ws://127.0.0.1:${wsPort}/devtools/browser/fake`); + const status = await waitForStatus(info.host, info.port, info.token, (s) => s.cdpConnected === true); + assert.equal(status.cdpConnected, true); + } finally { + browserWs.close(); + } + }); }); // --------------------------------------------------------------------------- @@ -285,7 +395,7 @@ describe("ensureDaemon passes CDP info to daemon", () => { it("daemon discovers CDP via managed port file", async () => { // Daemon should read ~/.bb-browser/browser/cdp-port and find our fake CDP - const child = spawn(TSX, [DAEMON_ENTRY, "--port", "39993"], { + const child = spawn(TSX, [DAEMON_ENTRY, "--port", "39993", "--no-chrome"], { detached: true, stdio: "ignore", }); diff --git a/packages/cli/src/cdp-discovery.ts b/packages/cli/src/cdp-discovery.ts index 4807d92..600299f 100644 --- a/packages/cli/src/cdp-discovery.ts +++ b/packages/cli/src/cdp-discovery.ts @@ -12,6 +12,12 @@ const MANAGED_PORT_FILE = path.join(MANAGED_BROWSER_DIR, "cdp-port"); const CDP_CACHE_FILE = path.join(os.tmpdir(), "bb-browser-cdp-cache.json"); const CACHE_TTL_MS = 30000; // 缓存有效期 30 秒 +export interface CdpEndpoint { + host: string; + port: number; + browserWebSocketUrl?: string; +} + function execFileAsync(command: string, args: string[], timeout: number): Promise { return new Promise((resolve, reject) => { execFile(command, args, { encoding: "utf8", timeout }, (error, stdout) => { @@ -30,12 +36,12 @@ function getArgValue(flag: string): string | undefined { return process.argv[index + 1]; } -async function tryOpenClaw(): Promise<{ host: string; port: number } | null> { +async function tryOpenClaw(): Promise { try { const raw = await execFileAsync("npx", ["openclaw", "browser", "status", "--json"], 30000); const parsed = parseOpenClawJson<{ cdpUrl?: string; cdpHost?: string; cdpPort?: number | string }>(raw); - let result: { host: string; port: number } | null = null; + let result: CdpEndpoint | null = null; // 优先使用完整的 cdpUrl if (parsed?.cdpUrl) { @@ -84,6 +90,66 @@ async function canConnect(host: string, port: number): Promise { } } +function isLocalHost(host: string): boolean { + return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]"; +} + +function defaultDevToolsActivePortFiles(): string[] { + if (process.env.BB_BROWSER_DEVTOOLS_ACTIVE_PORT_FILE) { + return [process.env.BB_BROWSER_DEVTOOLS_ACTIVE_PORT_FILE]; + } + + const home = os.homedir(); + if (process.platform === "darwin") { + return [ + path.join(home, "Library/Application Support/Google/Chrome/DevToolsActivePort"), + path.join(home, "Library/Application Support/Google/Chrome Beta/DevToolsActivePort"), + path.join(home, "Library/Application Support/Google/Chrome Canary/DevToolsActivePort"), + path.join(home, "Library/Application Support/Microsoft Edge/DevToolsActivePort"), + path.join(home, "Library/Application Support/BraveSoftware/Brave-Browser/DevToolsActivePort"), + ]; + } + + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA ?? ""; + return [ + path.join(localAppData, "Google/Chrome/User Data/DevToolsActivePort"), + path.join(localAppData, "Microsoft/Edge/User Data/DevToolsActivePort"), + path.join(localAppData, "BraveSoftware/Brave-Browser/User Data/DevToolsActivePort"), + ].filter((file) => !file.startsWith(path.sep)); + } + + return [ + path.join(home, ".config/google-chrome/DevToolsActivePort"), + path.join(home, ".config/chromium/DevToolsActivePort"), + path.join(home, ".config/microsoft-edge/DevToolsActivePort"), + path.join(home, ".config/BraveSoftware/Brave-Browser/DevToolsActivePort"), + ]; +} + +async function tryDevToolsActivePort(host: string, port: number): Promise { + if (!isLocalHost(host)) return null; + + for (const file of defaultDevToolsActivePortFiles()) { + try { + const [rawPort, rawPath] = (await readFile(file, "utf8")).trim().split(/\r?\n/); + const activePort = Number.parseInt(rawPort ?? "", 10); + const browserPath = rawPath?.trim(); + if (activePort !== port || !browserPath?.startsWith("/devtools/browser")) { + continue; + } + + return { + host, + port, + browserWebSocketUrl: `ws://${host}:${port}${browserPath}`, + }; + } catch {} + } + + return null; +} + export function findBrowserExecutable(): string | null { if (process.platform === "darwin") { const candidates = [ @@ -145,7 +211,7 @@ export async function isManagedBrowserRunning(): Promise { } } -export async function launchManagedBrowser(port: number = DEFAULT_CDP_PORT): Promise<{ host: string; port: number } | null> { +export async function launchManagedBrowser(port: number = DEFAULT_CDP_PORT): Promise { const executable = findBrowserExecutable(); if (!executable) { return null; @@ -205,16 +271,23 @@ export async function launchManagedBrowser(port: number = DEFAULT_CDP_PORT): Pro return null; } -export async function discoverCdpPort(): Promise<{ host: string; port: number } | null> { +export async function discoverCdpPort(): Promise { // 优先级1: 环境变量 BB_BROWSER_CDP_URL(最快,零延迟) const envUrl = process.env.BB_BROWSER_CDP_URL; if (envUrl) { try { const url = new URL(envUrl); const port = Number(url.port); + if ((url.protocol === "ws:" || url.protocol === "wss:") && Number.isInteger(port) && port > 0) { + return { host: url.hostname, port, browserWebSocketUrl: envUrl }; + } if (Number.isInteger(port) && port > 0 && await canConnect(url.hostname, port)) { return { host: url.hostname, port }; } + if (Number.isInteger(port) && port > 0) { + const viaActivePort = await tryDevToolsActivePort(url.hostname, port); + if (viaActivePort) return viaActivePort; + } } catch {} } @@ -223,6 +296,10 @@ export async function discoverCdpPort(): Promise<{ host: string; port: number } if (Number.isInteger(explicitPort) && explicitPort > 0 && await canConnect("127.0.0.1", explicitPort)) { return { host: "127.0.0.1", port: explicitPort }; } + if (Number.isInteger(explicitPort) && explicitPort > 0) { + const viaActivePort = await tryDevToolsActivePort("127.0.0.1", explicitPort); + if (viaActivePort) return viaActivePort; + } try { const rawPort = await readFile(MANAGED_PORT_FILE, "utf8"); @@ -230,6 +307,10 @@ export async function discoverCdpPort(): Promise<{ host: string; port: number } if (Number.isInteger(managedPort) && managedPort > 0 && await canConnect("127.0.0.1", managedPort)) { return { host: "127.0.0.1", port: managedPort }; } + if (Number.isInteger(managedPort) && managedPort > 0) { + const viaActivePort = await tryDevToolsActivePort("127.0.0.1", managedPort); + if (viaActivePort) return viaActivePort; + } } catch { } diff --git a/packages/cli/src/daemon-manager.ts b/packages/cli/src/daemon-manager.ts index 8a70d3d..02cd5db 100644 --- a/packages/cli/src/daemon-manager.ts +++ b/packages/cli/src/daemon-manager.ts @@ -92,8 +92,8 @@ export async function ensureDaemon(): Promise { ); } - // If existing daemon has wrong CDP port, stop it and respawn - if (info && info.cdpPort !== cdpInfo.port) { + // If existing daemon has wrong CDP endpoint, stop it and respawn + if (info && (info.cdpPort !== cdpInfo.port || info.cdpWsUrl !== cdpInfo.browserWebSocketUrl)) { await stopDaemon(); info = null; // Fall through to spawn new daemon @@ -122,6 +122,9 @@ export async function ensureDaemon(): Promise { // Spawn daemon process with discovered CDP endpoint const daemonPath = getDaemonPath(); const daemonArgs = [daemonPath, "--cdp-host", cdpInfo.host, "--cdp-port", String(cdpInfo.port)]; + if (cdpInfo.browserWebSocketUrl) { + daemonArgs.push("--cdp-ws-url", cdpInfo.browserWebSocketUrl); + } // Forward --hub flags from environment variables const hubUrl = process.env.BB_BROWSER_HUB_URL || process.env.PINIX_HUB_URL; diff --git a/packages/daemon/src/cdp-connection.ts b/packages/daemon/src/cdp-connection.ts index 1d1f5ae..5702185 100644 --- a/packages/daemon/src/cdp-connection.ts +++ b/packages/daemon/src/cdp-connection.ts @@ -295,6 +295,7 @@ export class CdpConnection { readonly host: string; readonly port: number; + readonly browserWebSocketUrl: string | undefined; readonly tabManager: TabStateManager; /** Current (most recently selected) target ID. */ @@ -309,9 +310,10 @@ export class CdpConnection { /** Resolvers for commands queued before CDP is ready. */ private readyWaiters: Array<{ resolve: () => void; reject: (err: Error) => void }> = []; - constructor(host: string, port: number, tabManager: TabStateManager) { + constructor(host: string, port: number, tabManager: TabStateManager, browserWebSocketUrl?: string) { this.host = host; this.port = port; + this.browserWebSocketUrl = browserWebSocketUrl; this.tabManager = tabManager; } @@ -349,10 +351,13 @@ export class CdpConnection { } private async doConnect(): Promise { - const versionData = (await fetchJson( - `http://${this.host}:${this.port}/json/version`, - )) as JsonObject; - const wsUrl = versionData.webSocketDebuggerUrl; + let wsUrl = this.browserWebSocketUrl; + if (!wsUrl) { + const versionData = (await fetchJson( + `http://${this.host}:${this.port}/json/version`, + )) as JsonObject; + wsUrl = versionData.webSocketDebuggerUrl as string | undefined; + } if (typeof wsUrl !== "string" || !wsUrl) { throw new Error("CDP endpoint missing webSocketDebuggerUrl"); } diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index ce69df8..f0eb9a1 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -32,6 +32,12 @@ const DAEMON_DIR = process.env.BB_BROWSER_HOME || path.join(os.homedir(), ".bb-b const DAEMON_JSON = path.join(DAEMON_DIR, "daemon.json"); const DEFAULT_CDP_PORT = 9222; +interface CdpEndpoint { + host: string; + port: number; + browserWebSocketUrl?: string; +} + // --------------------------------------------------------------------------- // CLI argument parsing // --------------------------------------------------------------------------- @@ -41,6 +47,7 @@ interface DaemonOptions { port: number; cdpHost: string; cdpPort: number; + cdpWsUrl?: string; token: string; hubUrl?: string; hubToken?: string; @@ -69,6 +76,10 @@ function parseOptions(): DaemonOptions { type: "string", default: String(DEFAULT_CDP_PORT), }, + "cdp-ws-url": { + type: "string", + default: "", + }, token: { type: "string", default: "", @@ -105,6 +116,7 @@ Options: -p, --port HTTP server port (default: ${DAEMON_PORT}) --cdp-host Chrome CDP host (default: 127.0.0.1) --cdp-port Chrome CDP port (default: ${DEFAULT_CDP_PORT}) + --cdp-ws-url Browser-level CDP WebSocket URL --token Bearer auth token (auto-generated if empty) --hub Pinix Hub gRPC URL (enables Hub mode) --hub-token Pinix Hub auth token @@ -147,6 +159,7 @@ Hub mode: port: parseInt(values.port ?? String(DAEMON_PORT), 10), cdpHost: values["cdp-host"] ?? "127.0.0.1", cdpPort: parseInt(values["cdp-port"] ?? String(DEFAULT_CDP_PORT), 10), + cdpWsUrl: values["cdp-ws-url"]?.trim() || undefined, token, hubUrl, hubToken: values["hub-token"]?.trim() || undefined, @@ -165,6 +178,7 @@ interface DaemonInfo { token: string; cdpHost: string; cdpPort: number; + cdpWsUrl?: string; } function writeDaemonJson(info: DaemonInfo): void { @@ -186,7 +200,71 @@ function cleanupDaemonJson(): void { // CDP port discovery (simplified — daemon is told the port) // --------------------------------------------------------------------------- -async function discoverCdpPort(host: string, port: number): Promise<{ host: string; port: number }> { +function isLocalHost(host: string): boolean { + return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]"; +} + +function defaultDevToolsActivePortFiles(): string[] { + if (process.env.BB_BROWSER_DEVTOOLS_ACTIVE_PORT_FILE) { + return [process.env.BB_BROWSER_DEVTOOLS_ACTIVE_PORT_FILE]; + } + + const home = os.homedir(); + if (process.platform === "darwin") { + return [ + path.join(home, "Library/Application Support/Google/Chrome/DevToolsActivePort"), + path.join(home, "Library/Application Support/Google/Chrome Beta/DevToolsActivePort"), + path.join(home, "Library/Application Support/Google/Chrome Canary/DevToolsActivePort"), + path.join(home, "Library/Application Support/Microsoft Edge/DevToolsActivePort"), + path.join(home, "Library/Application Support/BraveSoftware/Brave-Browser/DevToolsActivePort"), + ]; + } + + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA ?? ""; + return [ + path.join(localAppData, "Google/Chrome/User Data/DevToolsActivePort"), + path.join(localAppData, "Microsoft/Edge/User Data/DevToolsActivePort"), + path.join(localAppData, "BraveSoftware/Brave-Browser/User Data/DevToolsActivePort"), + ].filter((file) => !file.startsWith(path.sep)); + } + + return [ + path.join(home, ".config/google-chrome/DevToolsActivePort"), + path.join(home, ".config/chromium/DevToolsActivePort"), + path.join(home, ".config/microsoft-edge/DevToolsActivePort"), + path.join(home, ".config/BraveSoftware/Brave-Browser/DevToolsActivePort"), + ]; +} + +function tryDevToolsActivePort(host: string, port: number): CdpEndpoint | null { + if (!isLocalHost(host)) return null; + + for (const file of defaultDevToolsActivePortFiles()) { + try { + const [rawPort, rawPath] = readFileSync(file, "utf8").trim().split(/\r?\n/); + const activePort = parseInt(rawPort ?? "", 10); + const browserPath = rawPath?.trim(); + if (activePort !== port || !browserPath?.startsWith("/devtools/browser")) { + continue; + } + + return { + host, + port, + browserWebSocketUrl: `ws://${host}:${port}${browserPath}`, + }; + } catch {} + } + + return null; +} + +async function discoverCdpPort(host: string, port: number, browserWebSocketUrl?: string): Promise { + if (browserWebSocketUrl) { + return { host, port, browserWebSocketUrl }; + } + // Try connecting to the specified port first try { const controller = new AbortController(); @@ -203,6 +281,11 @@ async function discoverCdpPort(host: string, port: number): Promise<{ host: stri } } catch {} + const activePortEndpoint = tryDevToolsActivePort(host, port); + if (activePortEndpoint) { + return activePortEndpoint; + } + // Try reading managed browser port file const managedPortFile = path.join(DAEMON_DIR, "browser", "cdp-port"); try { @@ -223,6 +306,10 @@ async function discoverCdpPort(host: string, port: number): Promise<{ host: stri clearTimeout(timer); } } catch {} + const managedActivePortEndpoint = tryDevToolsActivePort("127.0.0.1", managedPort); + if (managedActivePortEndpoint) { + return managedActivePortEndpoint; + } } } catch {} @@ -333,7 +420,7 @@ async function main(): Promise { // Auto-manage Chrome headless-shell (unless --no-chrome) let chromeProcess: ChildProcess | null = null; - if (!options.noChrome) { + if (!options.noChrome && !options.cdpWsUrl) { const chrome = createChromeManager(); // Ensure headless-shell binary is available (download if needed) @@ -371,10 +458,10 @@ async function main(): Promise { // Create tab state manager and CDP connection const tabManager = new TabStateManager(); - let cdpEndpoint: { host: string; port: number }; + let cdpEndpoint: CdpEndpoint; try { - cdpEndpoint = await discoverCdpPort(options.cdpHost, options.cdpPort); + cdpEndpoint = await discoverCdpPort(options.cdpHost, options.cdpPort, options.cdpWsUrl); } catch (error) { console.error( `[Daemon] ${error instanceof Error ? error.message : String(error)}`, @@ -382,7 +469,7 @@ async function main(): Promise { process.exit(1); } - const cdp = new CdpConnection(cdpEndpoint.host, cdpEndpoint.port, tabManager); + const cdp = new CdpConnection(cdpEndpoint.host, cdpEndpoint.port, tabManager, cdpEndpoint.browserWebSocketUrl); // Hub bridge (created after CDP, started after CDP connects) let hubBridge: HubBridge | null = null; @@ -430,6 +517,7 @@ async function main(): Promise { token: options.token, cdpHost: cdpEndpoint.host, cdpPort: cdpEndpoint.port, + cdpWsUrl: cdpEndpoint.browserWebSocketUrl, }); console.error( diff --git a/packages/shared/src/daemon-client.ts b/packages/shared/src/daemon-client.ts index 1fc11d7..5a76475 100644 --- a/packages/shared/src/daemon-client.ts +++ b/packages/shared/src/daemon-client.ts @@ -28,6 +28,7 @@ export interface DaemonInfo { token: string; cdpHost?: string; cdpPort?: number; + cdpWsUrl?: string; } // ---------------------------------------------------------------------------