diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index 946114421..53b506205 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -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 { + 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 { 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 { + const version = await fetchChromeVersion(cdpPort, timeoutMs); + const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); + return wsUrl ? wsUrl : null; +} + +async function canOpenWebSocket( + wsUrl: string, + timeoutMs = 800, +): Promise { + return await new Promise((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 { + const wsUrl = await getChromeWebSocketUrl(cdpPort, timeoutMs); + if (!wsUrl) return false; + return await canOpenWebSocket(wsUrl, handshakeTimeoutMs); +} + export async function launchClawdChrome( resolved: ResolvedBrowserConfig, ): Promise { diff --git a/src/browser/client.ts b/src/browser/client.ts index 66f6f8d41..9ab851e5a 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -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 { }); } +export async function browserResetProfile( + baseUrl: string, +): Promise { + return await fetchBrowserJson( + `${baseUrl}/reset-profile`, + { + method: "POST", + timeoutMs: 20000, + }, + ); +} + export async function browserTabs(baseUrl: string): Promise { const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>( `${baseUrl}/tabs`, diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 610f200ad..18f34f110 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -100,20 +100,33 @@ async function connectBrowser(endpoint: string): Promise { 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 => { + 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; } diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index 202d81ee8..dbf279cd3 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -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)); + } + }); } diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 255eb9127..3b1244a53 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -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; ensureTabAvailable: (targetId?: string) => Promise; + isHttpReachable: (timeoutMs?: number) => Promise; isReachable: (timeoutMs?: number) => Promise; listTabs: () => Promise; openTab: (url: string) => Promise; focusTab: (targetId: string) => Promise; closeTab: (targetId: string) => Promise; 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 => { 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 => { @@ -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 { + 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; + } +} diff --git a/src/browser/server.test.ts b/src/browser/server.test.ts index 0e4a98392..d7206728a 100644 --- a/src/browser/server.test.ts +++ b/src/browser/server.test.ts @@ -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; }), diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index b7dbaee03..53b4b795b 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -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")