fix(browser): harden CDP readiness
This commit is contained in:
@@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { ensurePortAvailable } from "../infra/ports.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
@@ -306,20 +307,86 @@ export async function isChromeReachable(
|
||||
cdpPort: number,
|
||||
timeoutMs = 500,
|
||||
): Promise<boolean> {
|
||||
const version = await fetchChromeVersion(cdpPort, timeoutMs);
|
||||
return Boolean(version);
|
||||
}
|
||||
|
||||
type ChromeVersion = {
|
||||
webSocketDebuggerUrl?: string;
|
||||
Browser?: string;
|
||||
"User-Agent"?: string;
|
||||
};
|
||||
|
||||
async function fetchChromeVersion(
|
||||
cdpPort: number,
|
||||
timeoutMs = 500,
|
||||
): Promise<ChromeVersion | null> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
return res.ok;
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as ChromeVersion;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
return data;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChromeWebSocketUrl(
|
||||
cdpPort: number,
|
||||
timeoutMs = 500,
|
||||
): Promise<string | null> {
|
||||
const version = await fetchChromeVersion(cdpPort, timeoutMs);
|
||||
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||
return wsUrl ? wsUrl : null;
|
||||
}
|
||||
|
||||
async function canOpenWebSocket(
|
||||
wsUrl: string,
|
||||
timeoutMs = 800,
|
||||
): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs });
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(false);
|
||||
}, Math.max(50, timeoutMs + 25));
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(true);
|
||||
});
|
||||
ws.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function isChromeCdpReady(
|
||||
cdpPort: number,
|
||||
timeoutMs = 500,
|
||||
handshakeTimeoutMs = 800,
|
||||
): Promise<boolean> {
|
||||
const wsUrl = await getChromeWebSocketUrl(cdpPort, timeoutMs);
|
||||
if (!wsUrl) return false;
|
||||
return await canOpenWebSocket(wsUrl, handshakeTimeoutMs);
|
||||
}
|
||||
|
||||
export async function launchClawdChrome(
|
||||
resolved: ResolvedBrowserConfig,
|
||||
): Promise<RunningChrome> {
|
||||
|
||||
@@ -6,6 +6,8 @@ export type BrowserStatus = {
|
||||
enabled: boolean;
|
||||
controlUrl: string;
|
||||
running: boolean;
|
||||
cdpReady?: boolean;
|
||||
cdpHttp?: boolean;
|
||||
pid: number | null;
|
||||
cdpPort: number;
|
||||
chosenBrowser: string | null;
|
||||
@@ -15,6 +17,13 @@ export type BrowserStatus = {
|
||||
attachOnly: boolean;
|
||||
};
|
||||
|
||||
export type BrowserResetProfileResult = {
|
||||
ok: true;
|
||||
moved: boolean;
|
||||
from: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type BrowserTab = {
|
||||
targetId: string;
|
||||
title: string;
|
||||
@@ -75,6 +84,18 @@ export async function browserStop(baseUrl: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function browserResetProfile(
|
||||
baseUrl: string,
|
||||
): Promise<BrowserResetProfileResult> {
|
||||
return await fetchBrowserJson<BrowserResetProfileResult>(
|
||||
`${baseUrl}/reset-profile`,
|
||||
{
|
||||
method: "POST",
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
|
||||
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
||||
`${baseUrl}/tabs`,
|
||||
|
||||
@@ -100,20 +100,33 @@ async function connectBrowser(endpoint: string): Promise<ConnectedBrowser> {
|
||||
if (cached?.endpoint === endpoint) return cached;
|
||||
if (connecting) return await connecting;
|
||||
|
||||
connecting = chromium
|
||||
.connectOverCDP(endpoint, { timeout: 5000 })
|
||||
.then((browser) => {
|
||||
const connected: ConnectedBrowser = { browser, endpoint };
|
||||
cached = connected;
|
||||
observeBrowser(browser);
|
||||
browser.on("disconnected", () => {
|
||||
if (cached?.browser === browser) cached = null;
|
||||
});
|
||||
return connected;
|
||||
})
|
||||
.finally(() => {
|
||||
connecting = null;
|
||||
});
|
||||
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const timeout = 5000 + attempt * 2000;
|
||||
const browser = await chromium.connectOverCDP(endpoint, { timeout });
|
||||
const connected: ConnectedBrowser = { browser, endpoint };
|
||||
cached = connected;
|
||||
observeBrowser(browser);
|
||||
browser.on("disconnected", () => {
|
||||
if (cached?.browser === browser) cached = null;
|
||||
});
|
||||
return connected;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const delay = 250 + attempt * 250;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error
|
||||
? lastErr
|
||||
: new Error(String(lastErr ?? "CDP connect failed"));
|
||||
};
|
||||
|
||||
connecting = connectWithRetry().finally(() => {
|
||||
connecting = null;
|
||||
});
|
||||
|
||||
return await connecting;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@ export function registerBrowserBasicRoutes(
|
||||
return jsonError(res, 503, "browser server not started");
|
||||
}
|
||||
|
||||
const reachable = await ctx.isReachable(300);
|
||||
const [cdpHttp, cdpReady] = await Promise.all([
|
||||
ctx.isHttpReachable(300),
|
||||
ctx.isReachable(600),
|
||||
]);
|
||||
res.json({
|
||||
enabled: current.resolved.enabled,
|
||||
controlUrl: current.resolved.controlUrl,
|
||||
running: reachable,
|
||||
running: cdpReady,
|
||||
cdpReady,
|
||||
cdpHttp,
|
||||
pid: current.running?.pid ?? null,
|
||||
cdpPort: current.cdpPort,
|
||||
chosenBrowser: current.running?.exe.kind ?? null,
|
||||
@@ -47,4 +52,13 @@ export function registerBrowserBasicRoutes(
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/reset-profile", async (_req, res) => {
|
||||
try {
|
||||
const result = await ctx.resetProfile();
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
import { createTargetViaCdp } from "./cdp.js";
|
||||
import {
|
||||
isChromeCdpReady,
|
||||
isChromeReachable,
|
||||
launchClawdChrome,
|
||||
type RunningChrome,
|
||||
resolveClawdUserDataDir,
|
||||
stopClawdChrome,
|
||||
} from "./chrome.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||
import { runExec } from "../process/exec.js";
|
||||
|
||||
export type BrowserTab = {
|
||||
targetId: string;
|
||||
@@ -30,12 +36,18 @@ export type BrowserRouteContext = {
|
||||
state: () => BrowserServerState;
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string) => Promise<BrowserTab>;
|
||||
focusTab: (targetId: string) => Promise<void>;
|
||||
closeTab: (targetId: string) => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
resetProfile: () => Promise<{
|
||||
moved: boolean;
|
||||
from: string;
|
||||
to?: string;
|
||||
}>;
|
||||
mapTabError: (err: unknown) => { status: number; message: string } | null;
|
||||
};
|
||||
|
||||
@@ -157,27 +169,64 @@ export function createBrowserRouteContext(
|
||||
};
|
||||
|
||||
const isReachable = async (timeoutMs = 300) => {
|
||||
const current = state();
|
||||
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
|
||||
return await isChromeCdpReady(current.cdpPort, timeoutMs, wsTimeout);
|
||||
};
|
||||
|
||||
const isHttpReachable = async (timeoutMs = 300) => {
|
||||
const current = state();
|
||||
return await isChromeReachable(current.cdpPort, timeoutMs);
|
||||
};
|
||||
|
||||
const attachRunning = (running: RunningChrome) => {
|
||||
opts.setRunning(running);
|
||||
running.proc.on("exit", () => {
|
||||
const live = opts.getState();
|
||||
if (live?.running?.pid === running.pid) {
|
||||
opts.setRunning(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||
const current = state();
|
||||
const httpReachable = await isHttpReachable();
|
||||
if (!httpReachable) {
|
||||
if (current.resolved.attachOnly) {
|
||||
throw new Error(
|
||||
"Browser attachOnly is enabled and no browser is running.",
|
||||
);
|
||||
}
|
||||
const launched = await launchClawdChrome(current.resolved);
|
||||
attachRunning(launched);
|
||||
}
|
||||
|
||||
if (await isReachable()) return;
|
||||
|
||||
if (current.resolved.attachOnly) {
|
||||
throw new Error(
|
||||
"Browser attachOnly is enabled and no browser is running.",
|
||||
"Browser attachOnly is enabled and CDP websocket is not reachable.",
|
||||
);
|
||||
}
|
||||
|
||||
const launched = await launchClawdChrome(current.resolved);
|
||||
opts.setRunning(launched);
|
||||
launched.proc.on("exit", () => {
|
||||
const live = opts.getState();
|
||||
if (live?.running?.pid === launched.pid) {
|
||||
opts.setRunning(null);
|
||||
}
|
||||
});
|
||||
if (!current.running) {
|
||||
throw new Error(
|
||||
"CDP port responds but websocket handshake failed. Ensure the clawd browser owns the port or stop the conflicting process.",
|
||||
);
|
||||
}
|
||||
|
||||
await stopClawdChrome(current.running);
|
||||
opts.setRunning(null);
|
||||
|
||||
const relaunched = await launchClawdChrome(current.resolved);
|
||||
attachRunning(relaunched);
|
||||
|
||||
if (!(await isReachable(600))) {
|
||||
throw new Error(
|
||||
"Chrome CDP websocket is not reachable after restart.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||
@@ -244,6 +293,36 @@ export function createBrowserRouteContext(
|
||||
return { stopped: true };
|
||||
};
|
||||
|
||||
const resetProfile = async () => {
|
||||
const current = state();
|
||||
const userDataDir = resolveClawdUserDataDir();
|
||||
|
||||
const httpReachable = await isHttpReachable(300);
|
||||
if (httpReachable && !current.running) {
|
||||
throw new Error(
|
||||
"Browser appears to be running but is not owned by clawd. Stop it before resetting the profile.",
|
||||
);
|
||||
}
|
||||
|
||||
if (current.running) {
|
||||
await stopRunningBrowser();
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!fs.existsSync(userDataDir)) {
|
||||
return { moved: false, from: userDataDir };
|
||||
}
|
||||
|
||||
const moved = await movePathToTrash(userDataDir);
|
||||
return { moved: true, from: userDataDir, to: moved };
|
||||
};
|
||||
|
||||
const mapTabError = (err: unknown) => {
|
||||
const msg = String(err);
|
||||
if (msg.includes("ambiguous target id prefix")) {
|
||||
@@ -259,12 +338,31 @@ export function createBrowserRouteContext(
|
||||
state,
|
||||
ensureBrowserAvailable,
|
||||
ensureTabAvailable,
|
||||
isHttpReachable,
|
||||
isReachable,
|
||||
listTabs,
|
||||
openTab,
|
||||
focusTab,
|
||||
closeTab,
|
||||
stopRunningBrowser,
|
||||
resetProfile,
|
||||
mapTabError,
|
||||
};
|
||||
}
|
||||
|
||||
async function movePathToTrash(targetPath: string): Promise<string> {
|
||||
try {
|
||||
await runExec("trash", [targetPath], { timeoutMs: 10_000 });
|
||||
return targetPath;
|
||||
} catch {
|
||||
const trashDir = path.join(os.homedir(), ".Trash");
|
||||
fs.mkdirSync(trashDir, { recursive: true });
|
||||
const base = path.basename(targetPath);
|
||||
let dest = path.join(trashDir, `${base}-${Date.now()}`);
|
||||
if (fs.existsSync(dest)) {
|
||||
dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`);
|
||||
}
|
||||
fs.renameSync(targetPath, dest);
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ vi.mock("../config/config.js", () => ({
|
||||
|
||||
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||
vi.mock("./chrome.js", () => ({
|
||||
isChromeCdpReady: vi.fn(async () => reachable),
|
||||
isChromeReachable: vi.fn(async () => reachable),
|
||||
launchClawdChrome: vi.fn(async (resolved: { cdpPort: number }) => {
|
||||
launchCalls.push({ port: resolved.cdpPort });
|
||||
@@ -90,6 +91,7 @@ vi.mock("./chrome.js", () => ({
|
||||
proc,
|
||||
};
|
||||
}),
|
||||
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
|
||||
stopClawdChrome: vi.fn(async () => {
|
||||
reachable = false;
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
browserCloseTab,
|
||||
browserFocusTab,
|
||||
browserOpenTab,
|
||||
browserResetProfile,
|
||||
browserStart,
|
||||
browserStatus,
|
||||
browserStop,
|
||||
@@ -87,6 +88,32 @@ export function registerBrowserManageCommands(
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("reset-profile")
|
||||
.description("Reset clawd browser profile (moves it to Trash)")
|
||||
.action(async (_opts, cmd) => {
|
||||
const parent = parentOpts(cmd);
|
||||
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||
try {
|
||||
const result = await browserResetProfile(baseUrl);
|
||||
if (parent?.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (!result.moved) {
|
||||
defaultRuntime.log(info("🦞 clawd browser profile already missing."));
|
||||
return;
|
||||
}
|
||||
const dest = result.to ?? result.from;
|
||||
defaultRuntime.log(
|
||||
info(`🦞 clawd browser profile moved to Trash (${dest})`),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
browser
|
||||
.command("tabs")
|
||||
.description("List open tabs")
|
||||
|
||||
Reference in New Issue
Block a user