Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 113 additions & 3 deletions packages/cli/src/__tests__/daemon-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -101,6 +107,75 @@ function startFakeCdpServer(port: number): Promise<http.Server> {
});
}

function startFakeBrowserWebSocket(port: number): Promise<WebSocketServer> {
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<string, unknown>) => boolean,
timeoutMs = 8000,
): Promise<Record<string, unknown>> {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = async () => {
try {
const status = await new Promise<Record<string, unknown>>((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<string, unknown>);
} 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 {}
}
Expand All @@ -117,6 +192,7 @@ describe("daemon startup without Chrome", () => {
beforeEach(() => {
cleanupDaemonJson();
cleanupManagedPortFile();
cleanupDevToolsActivePortFile();
});

it("daemon exits with error when no CDP is available", async () => {
Expand Down Expand Up @@ -176,6 +252,7 @@ describe("daemon startup with CDP available", () => {
}
daemonPid = null;
cleanupDaemonJson();
cleanupDevToolsActivePortFile();
if (fakeCdp) {
await new Promise<void>(resolve => fakeCdp!.close(() => resolve()));
fakeCdp = null;
Expand Down Expand Up @@ -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<string, unknown>).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();
}
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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",
});
Expand Down
89 changes: 85 additions & 4 deletions packages/cli/src/cdp-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { encoding: "utf8", timeout }, (error, stdout) => {
Expand All @@ -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<CdpEndpoint | null> {
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) {
Expand Down Expand Up @@ -84,6 +90,66 @@ async function canConnect(host: string, port: number): Promise<boolean> {
}
}

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<CdpEndpoint | null> {
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 = [
Expand Down Expand Up @@ -145,7 +211,7 @@ export async function isManagedBrowserRunning(): Promise<boolean> {
}
}

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<CdpEndpoint | null> {
const executable = findBrowserExecutable();
if (!executable) {
return null;
Expand Down Expand Up @@ -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<CdpEndpoint | null> {
// 优先级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 {}
}

Expand All @@ -223,13 +296,21 @@ 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");
const managedPort = Number.parseInt(rawPort.trim(), 10);
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 {
}

Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/daemon-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ export async function ensureDaemon(): Promise<void> {
);
}

// 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
Expand Down Expand Up @@ -122,6 +122,9 @@ export async function ensureDaemon(): Promise<void> {
// 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;
Expand Down
15 changes: 10 additions & 5 deletions packages/daemon/src/cdp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
}

Expand Down Expand Up @@ -349,10 +351,13 @@ export class CdpConnection {
}

private async doConnect(): Promise<void> {
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");
}
Expand Down
Loading