From b2b898c2a8bce33a91f31403832eff1aa5f0b55e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 12:29:47 +0100 Subject: [PATCH] feat(browser): configure local startup timeouts --- CHANGELOG.md | 3 ++ docs/gateway/configuration-reference.md | 4 ++ docs/tools/browser-linux-troubleshooting.md | 26 +++++++---- docs/tools/browser.md | 7 +++ .../src/browser/bridge-server.auth.test.ts | 2 + .../browser/src/browser/cdp-timeouts.ts | 4 +- .../src/browser/chrome.internal.test.ts | 43 ++++++++++++++++++- extensions/browser/src/browser/chrome.test.ts | 2 + extensions/browser/src/browser/chrome.ts | 3 +- extensions/browser/src/browser/config.test.ts | 29 +++++++++++++ extensions/browser/src/browser/config.ts | 25 +++++++++++ extensions/browser/src/browser/constants.ts | 2 + .../browser/server-context.availability.ts | 3 +- .../src/browser/server-context.constants.ts | 4 +- ...wser-available.waits-for-cdp-ready.test.ts | 16 +++++++ .../server-context.existing-session.test.ts | 2 + .../server-context.remote-tab-ops.harness.ts | 2 + .../browser/server-context.test-harness.ts | 2 + src/agents/sandbox/browser.create.test.ts | 2 + src/agents/sandbox/browser.ts | 2 + src/config/config.schema-regressions.test.ts | 22 ++++++++++ src/config/schema.base.generated.ts | 26 +++++++++++ src/config/schema.help.ts | 4 ++ src/config/schema.labels.ts | 2 + src/config/types.browser.ts | 4 ++ src/config/zod-schema.ts | 2 + src/plugin-sdk/browser-profiles.ts | 2 + 27 files changed, 231 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd034d56c46..89b5188c74b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Docs: https://docs.openclaw.ai ### Changes +- Browser/config: allow local managed Chrome launch discovery and post-launch + CDP readiness timeouts to be raised for slower hosts such as Raspberry Pi. + Fixes #66803. Thanks @beat843796. - Browser/CLI: add `openclaw browser start --headless` as a one-shot local managed browser launch override without rewriting persisted browser config. Thanks @BenediktSchackenberg. - CLI/Crestodian: open interactive Crestodian in the full OpenClaw TUI shell instead of a basic readline prompt. - CLI/Crestodian: shorten the startup greeting to the active planner/model, config state, Gateway probe result, and next debug action instead of dumping every discovered backend. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 7ed3704c76f..b3a84b621b6 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -266,6 +266,10 @@ See [Plugins](/tools/plugin). - Local managed profiles can set `executablePath` to override the global `browser.executablePath` for that profile. Use this to run one profile in Chrome and another in Brave. +- Local managed profiles use `browser.localLaunchTimeoutMs` for Chrome CDP HTTP + discovery after process start and `browser.localCdpReadyTimeoutMs` for + post-launch CDP websocket readiness. Raise them on slower hosts where Chrome + starts successfully but readiness checks race startup. - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - `browser.executablePath` accepts `~` for your OS home directory. - Control service: loopback only (port derived from `gateway.port`, default `18791`). diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 0f909c7405c..0b9a9767cce 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -125,15 +125,23 @@ curl -s http://127.0.0.1:18791/tabs ### Config Reference -| Option | Description | Default | -| --------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------- | -| `browser.enabled` | Enable browser control | `true` | -| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers default browser when Chromium-based) | -| `browser.headless` | Run without GUI | `false` | -| `OPENCLAW_BROWSER_HEADLESS` | Per-process override for local managed browser headless mode | unset | -| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` | -| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` | -| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` | +| Option | Description | Default | +| -------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------- | +| `browser.enabled` | Enable browser control | `true` | +| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers default browser when Chromium-based) | +| `browser.headless` | Run without GUI | `false` | +| `OPENCLAW_BROWSER_HEADLESS` | Per-process override for local managed browser headless mode | unset | +| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` | +| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` | +| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` | +| `browser.localLaunchTimeoutMs` | Local managed Chrome discovery timeout | `15000` | +| `browser.localCdpReadyTimeoutMs` | Local managed post-launch CDP readiness timeout | `8000` | + +On Raspberry Pi, older VPS hosts, or slow storage, raise +`browser.localLaunchTimeoutMs` when Chrome needs more time to expose its CDP HTTP +endpoint. Raise `browser.localCdpReadyTimeoutMs` when launch succeeds but +`openclaw browser start` still reports `not reachable after start`. Values are +capped at 120000 ms. ### Problem: "No Chrome tabs found for profile=\"user\"" diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 02e80c58e53..e094b246bc8 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -129,6 +129,8 @@ Browser settings live in `~/.openclaw/openclaw.json`. // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms) remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms) + localLaunchTimeoutMs: 15000, // local managed Chrome discovery timeout (ms) + localCdpReadyTimeoutMs: 8000, // local managed post-launch CDP readiness timeout (ms) actionTimeoutMs: 60000, // default browser act timeout (ms) tabCleanup: { enabled: true, // default: true @@ -174,6 +176,11 @@ Browser settings live in `~/.openclaw/openclaw.json`. - Control service binds to loopback on a port derived from `gateway.port` (default `18791` = gateway + 2). Overriding `gateway.port` or `OPENCLAW_GATEWAY_PORT` shifts the derived ports in the same family. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; set those only for remote CDP. `cdpUrl` defaults to the managed local CDP port when unset. - `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP HTTP reachability checks; `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket handshakes. +- `localLaunchTimeoutMs` is the budget for a locally launched managed Chrome + process to expose its CDP HTTP endpoint. `localCdpReadyTimeoutMs` is the + follow-up budget for CDP websocket readiness after the process is discovered. + Raise these on Raspberry Pi, low-end VPS, or older hardware where Chromium + starts slowly. Values are capped at 120000 ms. - `actionTimeoutMs` is the default budget for browser `act` requests when the caller does not pass `timeoutMs`. The client transport adds a small slack window so long waits can finish instead of timing out at the HTTP boundary. - `tabCleanup` is best-effort cleanup for tabs opened by primary-agent browser sessions. Subagent, cron, and ACP lifecycle cleanup still closes their explicit tracked tabs at session end; primary sessions keep active tabs reusable, then close idle or excess tracked tabs in the background. diff --git a/extensions/browser/src/browser/bridge-server.auth.test.ts b/extensions/browser/src/browser/bridge-server.auth.test.ts index 9166b2fede4..743fe7693bd 100644 --- a/extensions/browser/src/browser/bridge-server.auth.test.ts +++ b/extensions/browser/src/browser/bridge-server.auth.test.ts @@ -18,6 +18,8 @@ function buildResolvedConfig(): ResolvedBrowserConfig { cdpIsLoopback: true, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + localLaunchTimeoutMs: 15_000, + localCdpReadyTimeoutMs: 8_000, extraArgs: [], color: DEFAULT_OPENCLAW_BROWSER_COLOR, executablePath: undefined, diff --git a/extensions/browser/src/browser/cdp-timeouts.ts b/extensions/browser/src/browser/cdp-timeouts.ts index af4ad14882a..0dbd616e08b 100644 --- a/extensions/browser/src/browser/cdp-timeouts.ts +++ b/extensions/browser/src/browser/cdp-timeouts.ts @@ -1,3 +1,5 @@ +import { DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS } from "./constants.js"; + export const CDP_HTTP_REQUEST_TIMEOUT_MS = 1500; export const CDP_WS_HANDSHAKE_TIMEOUT_MS = 5000; export const CDP_JSON_NEW_TIMEOUT_MS = 1500; @@ -8,7 +10,7 @@ export const CHROME_BOOTSTRAP_PREFS_TIMEOUT_MS = 10_000; export const CHROME_BOOTSTRAP_PREFS_POLL_MS = 100; export const CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS = 5000; export const CHROME_BOOTSTRAP_EXIT_POLL_MS = 50; -export const CHROME_LAUNCH_READY_WINDOW_MS = 15_000; +export const CHROME_LAUNCH_READY_WINDOW_MS = DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS; export const CHROME_LAUNCH_READY_POLL_MS = 200; export const CHROME_STOP_TIMEOUT_MS = 2500; export const CHROME_STOP_PROBE_TIMEOUT_MS = 200; diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index 3f95d2a8b1f..da10c9b2304 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -374,6 +374,8 @@ describe("chrome.ts internal", () => { headless: true, noSandbox: true, extraArgs: [], + localLaunchTimeoutMs: 15_000, + localCdpReadyTimeoutMs: 8_000, }) as unknown as ResolvedBrowserConfig; it("rejects a remote profile before attempting to spawn", async () => { @@ -544,7 +546,11 @@ describe("chrome.ts internal", () => { try { vi.spyOn(fs, "existsSync").mockImplementation((p) => { const s = String(p); - if (s.includes("google-chrome")) { + if ( + s.includes("Google Chrome") || + s.includes("google-chrome") || + s.includes("/usr/bin/chromium") + ) { return true; } if (s.endsWith("Local State") || s.endsWith("Preferences")) { @@ -574,6 +580,41 @@ describe("chrome.ts internal", () => { Object.defineProperty(process, "platform", { value: originalPlatform }); } }); + + it("uses the configured local launch timeout while waiting for CDP discovery", async () => { + vi.useFakeTimers(); + try { + const executablePath = path.join(tmpDir, "chrome"); + await fsp.writeFile(executablePath, ""); + const existsSync = fs.existsSync.bind(fs); + vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.endsWith("Local State") || s.endsWith("Preferences")) { + return true; + } + return existsSync(p); + }); + const fakeProc = makeFakeProc(); + spawnMock.mockReturnValue(fakeProc); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + + const resolved = { + ...makeResolved(), + executablePath, + localLaunchTimeoutMs: 1, + }; + const profile = makeProfile(55556); + const rejection = expect(launchOpenClawChrome(resolved, profile)).rejects.toThrow( + /Failed to start Chrome CDP/, + ); + + await vi.advanceTimersByTimeAsync(10); + await rejection; + expect(fakeProc.kill).toHaveBeenCalledWith("SIGKILL"); + } finally { + vi.useRealTimers(); + } + }); }); describe("stopOpenClawChrome SIGKILL fallback", () => { diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index d0c4db6fa23..96c78316e94 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -768,6 +768,8 @@ describe("browser chrome launch args", () => { evaluateEnabled: false, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + localLaunchTimeoutMs: 15_000, + localCdpReadyTimeoutMs: 8_000, actionTimeoutMs: 60_000, extraArgs: [], color: "#FF4500", diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 12e33a5b040..b035eb3f373 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -520,7 +520,8 @@ export async function launchOpenClawChrome( proc.stderr?.on("data", onStderr); try { - const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS; + const readyDeadline = + Date.now() + (resolved.localLaunchTimeoutMs ?? CHROME_LAUNCH_READY_WINDOW_MS); while (Date.now() < readyDeadline) { if (await isChromeReachable(profile.cdpUrl)) { break; diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index 17550515163..44ba25180a0 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -445,6 +445,35 @@ describe("browser config", () => { }); }); + describe("managed browser startup timeouts", () => { + it("uses defaults for local launch and post-launch readiness windows", () => { + const resolved = resolveBrowserConfig({}); + + expect(resolved.localLaunchTimeoutMs).toBe(15_000); + expect(resolved.localCdpReadyTimeoutMs).toBe(8_000); + }); + + it("accepts custom local startup timeout values", () => { + const resolved = resolveBrowserConfig({ + localLaunchTimeoutMs: 45_000, + localCdpReadyTimeoutMs: 30_000, + }); + + expect(resolved.localLaunchTimeoutMs).toBe(45_000); + expect(resolved.localCdpReadyTimeoutMs).toBe(30_000); + }); + + it("clamps oversized local startup timeout values", () => { + const resolved = resolveBrowserConfig({ + localLaunchTimeoutMs: 999_999, + localCdpReadyTimeoutMs: 999_999, + }); + + expect(resolved.localLaunchTimeoutMs).toBe(120_000); + expect(resolved.localCdpReadyTimeoutMs).toBe(120_000); + }); + }); + it("inherits executablePath from global browser config when profile override is not set", () => { const resolved = resolveBrowserConfig({ executablePath: "~/bin/chrome-global", diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 4ba3c4e10a7..2f7ebba6dd9 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -24,6 +24,8 @@ import { DEFAULT_BROWSER_ACTION_TIMEOUT_MS, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_BROWSER_EVALUATE_ENABLED, + DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS, + DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS, DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES, DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION, DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES, @@ -39,6 +41,8 @@ export { DEFAULT_BROWSER_ACTION_TIMEOUT_MS, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_BROWSER_EVALUATE_ENABLED, + DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS, + DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS, DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_ENABLED, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, @@ -69,6 +73,8 @@ export type ResolvedBrowserConfig = { cdpIsLoopback: boolean; remoteCdpTimeoutMs: number; remoteCdpHandshakeTimeoutMs: number; + localLaunchTimeoutMs: number; + localCdpReadyTimeoutMs: number; actionTimeoutMs: number; color: string; executablePath?: string; @@ -106,6 +112,7 @@ export type ResolvedBrowserProfile = { }; const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800; +const MAX_BROWSER_STARTUP_TIMEOUT_MS = 120_000; export const OPENCLAW_BROWSER_HEADLESS_ENV = "OPENCLAW_BROWSER_HEADLESS"; export type ManagedBrowserHeadlessSource = @@ -144,6 +151,14 @@ function normalizeTimeoutMs(raw: number | undefined, fallback: number): number { return value < 0 ? fallback : value; } +function normalizeStartupTimeoutMs(raw: number | undefined, fallback: number): number { + const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback; + if (value <= 0) { + return fallback; + } + return Math.min(value, MAX_BROWSER_STARTUP_TIMEOUT_MS); +} + function normalizeNonNegativeInteger(raw: number | undefined, fallback: number): number { const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback; return value < 0 ? fallback : value; @@ -297,6 +312,14 @@ export function resolveBrowserConfig( cfg?.remoteCdpHandshakeTimeoutMs, Math.max(2000, remoteCdpTimeoutMs * 2), ); + const localLaunchTimeoutMs = normalizeStartupTimeoutMs( + cfg?.localLaunchTimeoutMs, + DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS, + ); + const localCdpReadyTimeoutMs = normalizeStartupTimeoutMs( + cfg?.localCdpReadyTimeoutMs, + DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS, + ); const actionTimeoutMs = normalizeTimeoutMs( cfg?.actionTimeoutMs, DEFAULT_BROWSER_ACTION_TIMEOUT_MS, @@ -382,6 +405,8 @@ export function resolveBrowserConfig( cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), remoteCdpTimeoutMs, remoteCdpHandshakeTimeoutMs, + localLaunchTimeoutMs, + localCdpReadyTimeoutMs, actionTimeoutMs, color: defaultColor, executablePath, diff --git a/extensions/browser/src/browser/constants.ts b/extensions/browser/src/browser/constants.ts index 9aac58a140b..83093c96c58 100644 --- a/extensions/browser/src/browser/constants.ts +++ b/extensions/browser/src/browser/constants.ts @@ -4,6 +4,8 @@ export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500"; export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw"; export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw"; export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 60_000; +export const DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS = 15_000; +export const DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS = 8_000; export const DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS = 20_000; export const DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES = 120; export const DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION = 8; diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 4a868a749dc..268d7104c69 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -191,7 +191,8 @@ export function createProfileAvailability({ const waitForCdpReadyAfterLaunch = async (): Promise => { // launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS. // If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port. - const deadlineMs = Date.now() + CDP_READY_AFTER_LAUNCH_WINDOW_MS; + const deadlineMs = + Date.now() + (state().resolved.localCdpReadyTimeoutMs ?? CDP_READY_AFTER_LAUNCH_WINDOW_MS); while (Date.now() < deadlineMs) { const remainingMs = Math.max(0, deadlineMs - Date.now()); // Keep each attempt short; loopback profiles derive a WS timeout from this value. diff --git a/extensions/browser/src/browser/server-context.constants.ts b/extensions/browser/src/browser/server-context.constants.ts index 9026aba537f..d51e8e2f951 100644 --- a/extensions/browser/src/browser/server-context.constants.ts +++ b/extensions/browser/src/browser/server-context.constants.ts @@ -1,9 +1,11 @@ +import { DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS } from "./constants.js"; + export const MANAGED_BROWSER_PAGE_TAB_LIMIT = 8; export const OPEN_TAB_DISCOVERY_WINDOW_MS = 2000; export const OPEN_TAB_DISCOVERY_POLL_MS = 100; -export const CDP_READY_AFTER_LAUNCH_WINDOW_MS = 8000; +export const CDP_READY_AFTER_LAUNCH_WINDOW_MS = DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS; export const CDP_READY_AFTER_LAUNCH_POLL_MS = 100; export const CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS = 75; export const CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS = 250; diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index 4be27509a4e..fb073302005 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -90,6 +90,22 @@ describe("browser server-context ensureBrowserAvailable", () => { expect(stopOpenClawChrome).toHaveBeenCalledTimes(1); }); + it("uses configured local CDP readiness timeout after launching", async () => { + const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } = + setupEnsureBrowserAvailableHarness(); + state.resolved.localCdpReadyTimeoutMs = 250; + isChromeCdpReady.mockResolvedValue(false); + mockLaunchedChrome(launchOpenClawChrome, 322); + + const promise = profile.ensureBrowserAvailable(); + const rejected = expect(promise).rejects.toThrow("not reachable after start"); + await vi.advanceTimersByTimeAsync(300); + await rejected; + + expect(launchOpenClawChrome).toHaveBeenCalledTimes(1); + expect(stopOpenClawChrome).toHaveBeenCalledTimes(1); + }); + it("deduplicates concurrent lazy-start calls to prevent PortInUseError", async () => { const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } = setupEnsureBrowserAvailableHarness(); diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index f1e1eaa0d44..4ae28baf2c7 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -44,6 +44,8 @@ function makeState(): BrowserServerState { cdpIsLoopback: true, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + localLaunchTimeoutMs: 15_000, + localCdpReadyTimeoutMs: 8_000, actionTimeoutMs: 60_000, color: "#FF4500", headless: false, diff --git a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts index 9cddde0ca86..f92a7542a84 100644 --- a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts +++ b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts @@ -24,6 +24,8 @@ export function makeState( cdpIsLoopback: profile !== "remote", remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + localLaunchTimeoutMs: 15_000, + localCdpReadyTimeoutMs: 8_000, actionTimeoutMs: 60_000, evaluateEnabled: false, extraArgs: [], diff --git a/extensions/browser/src/browser/server-context.test-harness.ts b/extensions/browser/src/browser/server-context.test-harness.ts index 1b95d4b42ac..f8faa3476bd 100644 --- a/extensions/browser/src/browser/server-context.test-harness.ts +++ b/extensions/browser/src/browser/server-context.test-harness.ts @@ -37,6 +37,8 @@ export function makeBrowserServerState(params?: { evaluateEnabled: false, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + localLaunchTimeoutMs: 15_000, + localCdpReadyTimeoutMs: 8_000, actionTimeoutMs: 60_000, extraArgs: [], color: profile.color, diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 13129139b24..4d0997a6731 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -245,6 +245,8 @@ describe("ensureSandboxBrowser create args", () => { cdpPortRangeEnd: 18899, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + localLaunchTimeoutMs: 15_000, + localCdpReadyTimeoutMs: 8_000, color: "#FF4500", headless: false, noSandbox: false, diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 10ce5ef7c64..a026779fd52 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -97,6 +97,8 @@ function buildSandboxBrowserResolvedConfig(params: { cdpPortRangeEnd: cdpPortRange.end, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + localLaunchTimeoutMs: 15_000, + localCdpReadyTimeoutMs: 8_000, actionTimeoutMs: DEFAULT_BROWSER_ACTION_TIMEOUT_MS, color: DEFAULT_OPENCLAW_BROWSER_COLOR, executablePath: undefined, diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 653cf04c538..332ef25eb36 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -215,6 +215,28 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); + it("accepts browser local startup timeout settings", () => { + const res = validateConfigObject({ + browser: { + localLaunchTimeoutMs: 45_000, + localCdpReadyTimeoutMs: 30_000, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("rejects out-of-range browser local startup timeout settings", () => { + const res = validateConfigObject({ + browser: { + localLaunchTimeoutMs: 120_001, + localCdpReadyTimeoutMs: 0, + }, + }); + + expect(res.ok).toBe(false); + }); + it("rejects browser.extraArgs with non-array value", () => { const res = validateConfigObject({ browser: { diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 17a24d9fd22..a93d1bc8562 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -634,6 +634,22 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.", }, + localLaunchTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + title: "Browser Local Launch Timeout (ms)", + description: + "Timeout in milliseconds for locally launched managed Chrome to expose its CDP HTTP endpoint after process start. Raise this on slow single-board computers or older hosts.", + }, + localCdpReadyTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + title: "Browser Local CDP Ready Timeout (ms)", + description: + "Timeout in milliseconds for a locally launched managed browser to finish CDP websocket readiness after the process is discovered. Raise this when Chrome starts but browser start still reports CDP not reachable.", + }, actionTimeoutMs: { type: "integer", exclusiveMinimum: 0, @@ -23977,6 +23993,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Default timeout in milliseconds for browser act requests before the client gives up waiting. Raise this when healthy waits or UI interactions exceed the default request budget.", tags: ["performance"], }, + "browser.localLaunchTimeoutMs": { + label: "Browser Local Launch Timeout (ms)", + help: "Timeout in milliseconds for locally launched managed Chrome to expose its CDP HTTP endpoint after process start. Raise this on slow single-board computers or older hosts.", + tags: ["performance"], + }, + "browser.localCdpReadyTimeoutMs": { + label: "Browser Local CDP Ready Timeout (ms)", + help: "Timeout in milliseconds for a locally launched managed browser to finish CDP websocket readiness after the process is discovered. Raise this when Chrome starts but browser start still reports CDP not reachable.", + tags: ["performance"], + }, "browser.color": { label: "Browser Accent Color", help: "Default accent color used for browser profile/UI cues where colored identity hints are displayed. Use consistent colors to help operators identify active browser profile context quickly.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 5664f2d83d2..787747bd881 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -260,6 +260,10 @@ export const FIELD_HELP: Record = { "Remote CDP websocket URL used to attach to an externally managed browser instance. Use this for centralized browser hosts and keep URL access restricted to trusted network paths.", "browser.actionTimeoutMs": "Default timeout in milliseconds for browser act requests before the client gives up waiting. Raise this when healthy waits or UI interactions exceed the default request budget.", + "browser.localLaunchTimeoutMs": + "Timeout in milliseconds for locally launched managed Chrome to expose its CDP HTTP endpoint after process start. Raise this on slow single-board computers or older hosts.", + "browser.localCdpReadyTimeoutMs": + "Timeout in milliseconds for a locally launched managed browser to finish CDP websocket readiness after the process is discovered. Raise this when Chrome starts but browser start still reports CDP not reachable.", "browser.color": "Default accent color used for browser profile/UI cues where colored identity hints are displayed. Use consistent colors to help operators identify active browser profile context quickly.", "browser.executablePath": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 60148eed508..461b2883ec1 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -141,6 +141,8 @@ export const FIELD_LABELS: Record = { "browser.enabled": "Browser Enabled", "browser.cdpUrl": "Browser CDP URL", "browser.actionTimeoutMs": "Browser Action Timeout (ms)", + "browser.localLaunchTimeoutMs": "Browser Local Launch Timeout (ms)", + "browser.localCdpReadyTimeoutMs": "Browser Local CDP Ready Timeout (ms)", "browser.color": "Browser Accent Color", "browser.executablePath": "Browser Executable Path", "browser.headless": "Browser Headless Mode", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 9d9c159317a..55d542464cc 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -54,6 +54,10 @@ export type BrowserConfig = { remoteCdpTimeoutMs?: number; /** Remote CDP WebSocket handshake timeout (ms). Default: max(remoteCdpTimeoutMs * 2, 2000). */ remoteCdpHandshakeTimeoutMs?: number; + /** Local managed browser launch discovery timeout (ms). Default: 15000. */ + localLaunchTimeoutMs?: number; + /** Local managed browser post-launch CDP readiness timeout (ms). Default: 8000. */ + localCdpReadyTimeoutMs?: number; /** Default browser act timeout (ms). Default: 60000. */ actionTimeoutMs?: number; /** Accent color for the openclaw browser profile (hex). Default: #FF4500 */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index f922efb2cf9..2ab5678e980 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -396,6 +396,8 @@ export const OpenClawSchema = z cdpUrl: z.string().optional(), remoteCdpTimeoutMs: z.number().int().nonnegative().optional(), remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(), + localLaunchTimeoutMs: z.number().int().positive().max(120_000).optional(), + localCdpReadyTimeoutMs: z.number().int().positive().max(120_000).optional(), actionTimeoutMs: z.number().int().positive().optional(), color: z.string().optional(), executablePath: z.string().optional(), diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index ec452668232..d276d65b939 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -31,6 +31,8 @@ export type ResolvedBrowserConfig = { cdpIsLoopback: boolean; remoteCdpTimeoutMs: number; remoteCdpHandshakeTimeoutMs: number; + localLaunchTimeoutMs: number; + localCdpReadyTimeoutMs: number; actionTimeoutMs: number; color: string; executablePath?: string;