diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5c933caa5..f60159c2db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Gateway/nodes: add disabled-by-default `gateway.nodes.pairing.autoApproveCidrs` for first-time node pairing from explicit trusted CIDRs, while keeping operator/browser pairing and all upgrade flows manual. Fixes #60800. Thanks @sahilsatralkar. - Browser: add viewport coordinate clicks for managed and existing-session automation, plus `openclaw browser click-coords` for CLI use. (#54452) Thanks @dluttz. +- Browser: add `browser.actionTimeoutMs` and use a 60s default action budget so healthy long browser waits do not fail at the client transport boundary. (#62589) Thanks @andyylin. - Browser/config: support per-profile `browser.profiles..headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu. - Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc. - Dependencies/memory: stop installing `node-llama-cpp` by default; local embeddings now load it only when operators install the optional runtime package. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 1b448c49e4a..2175ddeed37 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -13b68287fec00108ca66032120909a0eac797ed541e026357e175e3fce5bacdd config-baseline.json -77ee66fb3b2cde94b393712bc03a132b096cf601c193bde1fe42902eecb0b66b config-baseline.core.json +f1fd4557473391980caf6d6b32f78e4de25f8504b29dfe083f7f9e325d0b204c config-baseline.json +68e0784ca0f9279d49b40ce4493e1cb2c416e1fb70a137a853a10a8c078c97ca config-baseline.core.json d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json -0d5ba81f0030bd39b7ae285096276cc18b150836c2252fd2217329fc6154e80e config-baseline.plugin.json +0504c4f38d4c753fffeb465c93540d829df6b0fcef921eb0e2226ac16bdbbe07 config-baseline.plugin.json diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 38ab18d4a99..8f47e892a10 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -242,6 +242,8 @@ This path is host-only. For Docker, headless servers, Browserless, or other remo Current existing-session limits: - snapshot-driven actions use refs, not CSS selectors +- `browser.actionTimeoutMs` defaults supported `act` requests to 60000 ms when + callers omit `timeoutMs`; per-call `timeoutMs` still wins. - `click` is left-click only - `type` does not support `slowly=true` - `press` does not support `delayMs` diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 50962d93fb4..cdf370e725e 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -129,6 +129,7 @@ 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) + actionTimeoutMs: 60000, // default browser act timeout (ms) tabCleanup: { enabled: true, // default: true idleMinutes: 120, // set 0 to disable idle cleanup @@ -173,6 +174,7 @@ 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. +- `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-tool.actions.ts b/extensions/browser/src/browser-tool.actions.ts index c3227beb7b0..1bc1565158b 100644 --- a/extensions/browser/src/browser-tool.actions.ts +++ b/extensions/browser/src/browser-tool.actions.ts @@ -15,6 +15,7 @@ import { resolveProfile, wrapExternalContent, } from "./browser-tool.runtime.js"; +import { DEFAULT_BROWSER_ACTION_TIMEOUT_MS } from "./browser/constants.js"; const browserToolActionDeps = { browserAct, @@ -25,6 +26,94 @@ const browserToolActionDeps = { loadConfig, }; +const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000; + +type BrowserActRequest = Parameters[1]; +type BrowserActRequestWithTimeout = BrowserActRequest & { timeoutMs?: number }; + +function normalizePositiveTimeoutMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +function supportsBrowserActTimeout(request: BrowserActRequest): boolean { + switch (request.kind) { + case "click": + case "type": + case "hover": + case "scrollIntoView": + case "drag": + case "select": + case "fill": + case "evaluate": + case "wait": + return true; + default: + return false; + } +} + +function existingSessionRejectsActTimeout(request: BrowserActRequest): boolean { + switch (request.kind) { + case "type": + case "hover": + case "scrollIntoView": + case "drag": + case "select": + case "fill": + case "evaluate": + return true; + default: + return false; + } +} + +function usesExistingSessionProfile(profileName: string | undefined): boolean { + const cfg = browserToolActionDeps.loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + const profile = resolveProfile(resolved, profileName ?? resolved.defaultProfile); + return profile ? getBrowserProfileCapabilities(profile).usesChromeMcp : false; +} + +function withConfiguredActTimeout( + request: BrowserActRequest, + profileName: string | undefined, +): BrowserActRequest { + const typedRequest = request as BrowserActRequestWithTimeout; + if (normalizePositiveTimeoutMs(typedRequest.timeoutMs) !== undefined) { + return request; + } + if (!supportsBrowserActTimeout(request)) { + return request; + } + if (existingSessionRejectsActTimeout(request) && usesExistingSessionProfile(profileName)) { + return request; + } + + const cfg = browserToolActionDeps.loadConfig(); + const configuredTimeout = + normalizePositiveTimeoutMs(cfg.browser?.actionTimeoutMs) ?? DEFAULT_BROWSER_ACTION_TIMEOUT_MS; + return { ...typedRequest, timeoutMs: configuredTimeout } as BrowserActRequest; +} + +function resolveActProxyTimeoutMs(request: BrowserActRequest): number | undefined { + const candidateTimeouts: number[] = []; + const explicitTimeout = normalizePositiveTimeoutMs( + (request as BrowserActRequestWithTimeout).timeoutMs, + ); + if (explicitTimeout !== undefined) { + candidateTimeouts.push(explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS); + } + if (request.kind === "wait") { + const waitDuration = normalizePositiveTimeoutMs(request.timeMs); + if (waitDuration !== undefined) { + candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS); + } + } + return candidateTimeouts.length ? Math.max(...candidateTimeouts) : undefined; +} + export const __testing = { setDepsForTest( overrides: Partial<{ @@ -408,32 +497,34 @@ export async function executeConsoleAction(params: { } export async function executeActAction(params: { - request: Parameters[1]; + request: BrowserActRequest; baseUrl?: string; profile?: string; proxyRequest: BrowserProxyRequest | null; onTabActivity?: (targetId: string | undefined) => void; }): Promise> { const { request, baseUrl, profile, proxyRequest } = params; + const effectiveRequest = withConfiguredActTimeout(request, profile); try { const result = proxyRequest ? await proxyRequest({ method: "POST", path: "/act", profile, - body: request, + body: effectiveRequest, + timeoutMs: resolveActProxyTimeoutMs(effectiveRequest), }) - : await browserToolActionDeps.browserAct(baseUrl, request, { + : await browserToolActionDeps.browserAct(baseUrl, effectiveRequest, { profile, }); params.onTabActivity?.( readStringValue((result as { targetId?: unknown }).targetId) ?? - readStringValue(request.targetId), + readStringValue(effectiveRequest.targetId), ); return jsonResult(result); } catch (err) { if (isChromeStaleTargetError(profile, err)) { - const retryRequest = stripTargetIdFromActRequest(request); + const retryRequest = stripTargetIdFromActRequest(effectiveRequest); const tabs = proxyRequest ? (( (await proxyRequest({ @@ -445,7 +536,7 @@ export async function executeActAction(params: { : await browserToolActionDeps.browserTabs(baseUrl, { profile }).catch(() => []); // Some user-browser targetIds can go stale between snapshots and actions. // Only retry safe read-only actions, and only when exactly one tab remains attached. - if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) { + if (retryRequest && canRetryChromeActWithoutTargetId(effectiveRequest) && tabs.length === 1) { try { const retryResult = proxyRequest ? await proxyRequest({ @@ -453,6 +544,7 @@ export async function executeActAction(params: { path: "/act", profile, body: retryRequest, + timeoutMs: resolveActProxyTimeoutMs(retryRequest), }) : await browserToolActionDeps.browserAct(baseUrl, retryRequest, { profile, diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index 08cdf66fc7f..723323d275f 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -69,6 +69,7 @@ const browserConfigMocks = vi.hoisted(() => ({ controlPort: 18791, profiles: {}, defaultProfile: "openclaw", + actionTimeoutMs: 60_000, })), resolveProfile: vi.fn((resolved: Record, name: string) => { const profile = (resolved.profiles as Record> | undefined)?.[ @@ -249,6 +250,7 @@ function resetBrowserToolMocks() { controlPort: 18791, profiles: {}, defaultProfile: "openclaw", + actionTimeoutMs: 60_000, }); nodesUtilsMocks.listNodes.mockResolvedValue([]); browserToolTesting.setDepsForTest({ @@ -292,6 +294,7 @@ function setResolvedBrowserProfiles( controlPort: 18791, profiles, defaultProfile, + actionTimeoutMs: 60_000, }); } @@ -1078,6 +1081,87 @@ describe("browser tool act compatibility", () => { expect.objectContaining({ profile: undefined }), ); }); + + it("applies configured browser action timeout when act timeout is omitted", async () => { + configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } }); + + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "act", + request: { + kind: "wait", + timeMs: 20_000, + }, + }); + + expect(browserActionsMocks.browserAct).toHaveBeenCalledWith( + undefined, + { + kind: "wait", + timeMs: 20_000, + timeoutMs: 45_000, + }, + expect.objectContaining({ profile: undefined }), + ); + }); + + it("does not inject unsupported action timeout for existing-session type actions", async () => { + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } }); + + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "act", + profile: "user", + target: "host", + request: { + kind: "type", + ref: "f1e3", + text: "Test Title", + }, + }); + + expect(browserActionsMocks.browserAct).toHaveBeenCalledWith( + undefined, + { + kind: "type", + ref: "f1e3", + text: "Test Title", + }, + expect.objectContaining({ profile: "user" }), + ); + }); + + it("passes configured act timeout through node proxy with transport slack", async () => { + mockSingleBrowserProxyNode(); + configMocks.loadConfig.mockReturnValue({ + browser: { + actionTimeoutMs: 45_000, + }, + gateway: { nodes: { browser: { node: "node-1" } } }, + }); + + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "act", + target: "node", + request: { kind: "wait", timeMs: 20_000 }, + }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + { timeoutMs: 55_000 }, + expect.objectContaining({ + params: expect.objectContaining({ + path: "/act", + body: { kind: "wait", timeMs: 20_000, timeoutMs: 45_000 }, + timeoutMs: 45_000 + 5_000, + }), + }), + ); + }); }); describe("browser tool snapshot labels", () => { diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index c9b2cebed09..dde16b2e39e 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -681,6 +681,7 @@ describe("browser chrome launch args", () => { evaluateEnabled: false, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: 60_000, extraArgs: [], color: "#FF4500", headless: false, diff --git a/extensions/browser/src/browser/client-actions-core.ts b/extensions/browser/src/browser/client-actions-core.ts index f4ebf0fee95..47d3aa97e0f 100644 --- a/extensions/browser/src/browser/client-actions-core.ts +++ b/extensions/browser/src/browser/client-actions-core.ts @@ -6,7 +6,10 @@ import type { import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js"; import type { BrowserActRequest, BrowserFormField } from "./client-actions.types.js"; import { fetchBrowserJson } from "./client-fetch.js"; -import { DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS } from "./constants.js"; +import { + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, + DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS, +} from "./constants.js"; export type { BrowserActRequest, BrowserFormField } from "./client-actions.types.js"; @@ -26,6 +29,29 @@ export type BrowserDownloadPayload = { type BrowserDownloadResult = { ok: true; targetId: string; download: BrowserDownloadPayload }; +const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000; + +function normalizePositiveTimeoutMs(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +function resolveBrowserActRequestTimeoutMs(req: BrowserActRequest): number { + const explicitTimeout = normalizePositiveTimeoutMs((req as { timeoutMs?: unknown }).timeoutMs); + const candidateTimeouts = + explicitTimeout === undefined + ? [DEFAULT_BROWSER_ACTION_TIMEOUT_MS] + : [explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS]; + if (req.kind === "wait") { + const waitDuration = normalizePositiveTimeoutMs(req.timeMs); + if (waitDuration !== undefined) { + candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS); + } + } + return Math.max(...candidateTimeouts); +} + async function postDownloadRequest( baseUrl: string | undefined, route: "/wait/download" | "/download", @@ -167,7 +193,7 @@ export async function browserAct( timeoutMs: typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) ? Math.max(1, Math.floor(opts.timeoutMs)) - : 20000, + : resolveBrowserActRequestTimeoutMs(req), }); } diff --git a/extensions/browser/src/browser/client.test.ts b/extensions/browser/src/browser/client.test.ts index 649eb4fa078..cdb608d9df6 100644 --- a/extensions/browser/src/browser/client.test.ts +++ b/extensions/browser/src/browser/client.test.ts @@ -334,4 +334,30 @@ describe("browser client", () => { timeoutMs: 20_000, }); }); + + it("gives browser act requests enough client timeout for long waits", async () => { + const calls: Array<{ url: string; init?: RequestInit & { timeoutMs?: number } }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit & { timeoutMs?: number }) => { + calls.push({ url, init }); + return { + ok: true, + json: async () => ({ ok: true, targetId: "t1" }), + } as unknown as Response; + }), + ); + + await browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" }); + await browserAct("http://127.0.0.1:18791", { + kind: "wait", + timeMs: 70_000, + }); + await browserAct("http://127.0.0.1:18791", { + kind: "wait", + timeoutMs: 45_000, + }); + + expect(calls.map((call) => call.init?.timeoutMs)).toEqual([60_000, 75_000, 50_000]); + }); }); diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index ec56f032766..084b506aa7b 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -60,6 +60,7 @@ describe("browser config", () => { expect(resolveProfile(resolved, "chrome-relay")).toBe(null); expect(resolved.remoteCdpTimeoutMs).toBe(1500); expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000); + expect(resolved.actionTimeoutMs).toBe(60_000); expect(resolved.tabCleanup).toEqual({ enabled: true, idleMinutes: 120, @@ -119,9 +120,11 @@ describe("browser config", () => { const resolved = resolveBrowserConfig({ remoteCdpTimeoutMs: 2200, remoteCdpHandshakeTimeoutMs: 5000, + actionTimeoutMs: 45_000, }); expect(resolved.remoteCdpTimeoutMs).toBe(2200); expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000); + expect(resolved.actionTimeoutMs).toBe(45_000); }); it("supports custom browser tab cleanup policy", () => { diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index ae4b68c7c53..f1099117bb5 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -20,6 +20,7 @@ import { resolveUserPath } from "../utils.js"; import { parseBrowserHttpUrl, redactCdpUrl, isLoopbackHost } from "./cdp.helpers.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS, + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES, @@ -66,6 +67,7 @@ export type ResolvedBrowserConfig = { cdpIsLoopback: boolean; remoteCdpTimeoutMs: number; remoteCdpHandshakeTimeoutMs: number; + actionTimeoutMs: number; color: string; executablePath?: string; headless: boolean; @@ -263,6 +265,10 @@ export function resolveBrowserConfig( cfg?.remoteCdpHandshakeTimeoutMs, Math.max(2000, remoteCdpTimeoutMs * 2), ); + const actionTimeoutMs = normalizeTimeoutMs( + cfg?.actionTimeoutMs, + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, + ); const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort); const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start; @@ -343,6 +349,7 @@ export function resolveBrowserConfig( cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), remoteCdpTimeoutMs, remoteCdpHandshakeTimeoutMs, + actionTimeoutMs, color: defaultColor, executablePath, headless, diff --git a/extensions/browser/src/browser/constants.ts b/extensions/browser/src/browser/constants.ts index fb229b6ec3d..9aac58a140b 100644 --- a/extensions/browser/src/browser/constants.ts +++ b/extensions/browser/src/browser/constants.ts @@ -3,6 +3,7 @@ export const DEFAULT_BROWSER_EVALUATE_ENABLED = true; 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_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.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index 619ec502909..f1e1eaa0d44 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,7 @@ function makeState(): BrowserServerState { cdpIsLoopback: true, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: 60_000, color: "#FF4500", headless: false, noSandbox: 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 3ba02cf8065..776e85ac1ad 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,7 @@ export function makeState( cdpIsLoopback: profile !== "remote", remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: 60_000, evaluateEnabled: false, extraArgs: [], color: "#FF4500", diff --git a/extensions/browser/src/browser/server-context.test-harness.ts b/extensions/browser/src/browser/server-context.test-harness.ts index e91ba2dcfc9..1b95d4b42ac 100644 --- a/extensions/browser/src/browser/server-context.test-harness.ts +++ b/extensions/browser/src/browser/server-context.test-harness.ts @@ -37,6 +37,7 @@ export function makeBrowserServerState(params?: { evaluateEnabled: false, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: 60_000, extraArgs: [], color: profile.color, headless: true, diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 4d6b851acc8..10ce5ef7c64 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -6,6 +6,7 @@ import { stopBrowserBridgeServer, } from "../../plugin-sdk/browser-bridge.js"; import { + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, @@ -96,6 +97,7 @@ function buildSandboxBrowserResolvedConfig(params: { cdpPortRangeEnd: cdpPortRange.end, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, + actionTimeoutMs: DEFAULT_BROWSER_ACTION_TIMEOUT_MS, color: DEFAULT_OPENCLAW_BROWSER_COLOR, executablePath: undefined, headless: params.headless, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 2926220475c..723bfa2cd64 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -603,6 +603,14 @@ 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.", }, + actionTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + title: "Browser Action Timeout (ms)", + description: + "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.", + }, color: { type: "string", title: "Browser Accent Color", @@ -23933,6 +23941,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "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.", tags: ["advanced", "url-secret"], }, + "browser.actionTimeoutMs": { + label: "Browser Action Timeout (ms)", + 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.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 02d61ddeaa8..f144255a01f 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -258,6 +258,8 @@ export const FIELD_HELP: Record = { "Enables browser capability wiring in the gateway so browser tools and CDP-driven workflows can run. Disable when browser automation is not needed to reduce surface area and startup work.", "browser.cdpUrl": "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.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 b27b439e2ac..60148eed508 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -140,6 +140,7 @@ export const FIELD_LABELS: Record = { browser: "Browser", "browser.enabled": "Browser Enabled", "browser.cdpUrl": "Browser CDP URL", + "browser.actionTimeoutMs": "Browser Action 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 562ab7108c7..9d9c159317a 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -54,6 +54,8 @@ export type BrowserConfig = { remoteCdpTimeoutMs?: number; /** Remote CDP WebSocket handshake timeout (ms). Default: max(remoteCdpTimeoutMs * 2, 2000). */ remoteCdpHandshakeTimeoutMs?: number; + /** Default browser act timeout (ms). Default: 60000. */ + actionTimeoutMs?: number; /** Accent color for the openclaw browser profile (hex). Default: #FF4500 */ color?: string; /** Override the browser executable path (all platforms). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 013d919d122..456b4c00931 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -381,6 +381,7 @@ export const OpenClawSchema = z cdpUrl: z.string().optional(), remoteCdpTimeoutMs: z.number().int().nonnegative().optional(), remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(), + actionTimeoutMs: z.number().int().positive().optional(), color: z.string().optional(), executablePath: z.string().optional(), headless: z.boolean().optional(), diff --git a/src/plugin-sdk/browser-config.ts b/src/plugin-sdk/browser-config.ts index 6aa687729fd..bdc4a7b232a 100644 --- a/src/plugin-sdk/browser-config.ts +++ b/src/plugin-sdk/browser-config.ts @@ -1,5 +1,6 @@ export { DEFAULT_AI_SNAPSHOT_MAX_CHARS, + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_OPENCLAW_BROWSER_COLOR, diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index fc1d82c1c25..ec452668232 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -9,6 +9,7 @@ export const DEFAULT_BROWSER_EVALUATE_ENABLED = true; 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_AI_SNAPSHOT_MAX_CHARS = 80_000; export const DEFAULT_UPLOAD_DIR = path.join(resolvePreferredOpenClawTmpDir(), "uploads"); @@ -30,6 +31,7 @@ export type ResolvedBrowserConfig = { cdpIsLoopback: boolean; remoteCdpTimeoutMs: number; remoteCdpHandshakeTimeoutMs: number; + actionTimeoutMs: number; color: string; executablePath?: string; headless: boolean;