From 2a0a76f8768c7c1e9c5d5fe16c253d21423a6722 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 02:45:14 +0100 Subject: [PATCH] fix(browser): extend existing-session manage timeouts --- CHANGELOG.md | 1 + .../browser/src/browser-tool.actions.ts | 6 +- extensions/browser/src/browser-tool.test.ts | 74 ++++++++++++- extensions/browser/src/browser-tool.ts | 100 ++++++++++++++++-- .../src/browser/client-actions-core.ts | 7 +- extensions/browser/src/browser/client.ts | 67 +++++++++--- 6 files changed, 220 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e951cfc8ea..8b2f8fc03fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Discord/subagents: preserve thread-bound completion delivery by keeping the requester-agent announce path primary and falling back to direct thread sends only when the announce produces no visible output. (#71064) Thanks @DolencLuka. +- Browser/tool: give Chrome MCP existing-session manage calls a longer default timeout, pass explicit tool timeouts through tab management, and recover stale selected-page MCP sessions instead of forcing a manual reset. Thanks @steipete. - Gateway/sessions: recover main-agent turns interrupted by a gateway restart from stale transcript-lock evidence, avoiding stuck `status: "running"` sessions without broad post-boot transcript scans. Fixes #70555. Thanks @bitloi. - Codex approvals: keep command approval responses within Codex app-server `availableDecisions`, including deny/cancel fallbacks for prompts that do not offer `decline`. (#71338) Thanks @Lucenx9. - Plugins/Google Meet: include live Chrome-node readiness in `googlemeet setup` and document the Parallels recovery checks, so stale node tokens or disconnected VM browsers are visible before an agent opens a meeting. Thanks @steipete. diff --git a/extensions/browser/src/browser-tool.actions.ts b/extensions/browser/src/browser-tool.actions.ts index c6c941a98eb..658bb553ddb 100644 --- a/extensions/browser/src/browser-tool.actions.ts +++ b/extensions/browser/src/browser-tool.actions.ts @@ -209,19 +209,21 @@ function withRoleRefsFallback( export async function executeTabsAction(params: { baseUrl?: string; profile?: string; + timeoutMs?: number; proxyRequest: BrowserProxyRequest | null; }): Promise> { - const { baseUrl, profile, proxyRequest } = params; + const { baseUrl, profile, timeoutMs, proxyRequest } = params; if (proxyRequest) { const result = await proxyRequest({ method: "GET", path: "/tabs", profile, + timeoutMs, }); const tabs = (result as { tabs?: unknown[] }).tabs ?? []; return formatTabsToolResult(tabs); } - const tabs = await browserToolActionDeps.browserTabs(baseUrl, { profile }); + const tabs = await browserToolActionDeps.browserTabs(baseUrl, { profile, timeoutMs }); return formatTabsToolResult(tabs); } diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index 83c07f12bdf..add8f94ad0c 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -379,7 +379,69 @@ describe("browser tool snapshot maxChars", () => { const tool = createBrowserTool(); await tool.execute?.("call-1", { action: "profiles" }); - expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith(undefined); + expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ timeoutMs: undefined }), + ); + }); + + it("uses a longer default timeout for existing-session profile status through node proxy", async () => { + mockSingleBrowserProxyNode(); + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + const tool = createBrowserTool(); + await tool.execute?.("call-1", { action: "status", profile: "user", target: "node" }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + { timeoutMs: 50_000 }, + expect.objectContaining({ + params: expect.objectContaining({ + method: "GET", + path: "/", + profile: "user", + timeoutMs: 45_000, + }), + }), + ); + }); + + it("passes top-level timeoutMs through to existing-session open", async () => { + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "open", + profile: "user", + url: "https://example.com", + timeoutMs: 60_000, + }); + + expect(browserClientMocks.browserOpenTab).toHaveBeenCalledWith( + undefined, + "https://example.com", + expect.objectContaining({ profile: "user", timeoutMs: 60_000 }), + ); + }); + + it("passes top-level timeoutMs through to close without targetId", async () => { + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "close", + profile: "user", + timeoutMs: 60_000, + }); + + expect(browserActionsMocks.browserAct).toHaveBeenCalledWith( + undefined, + { kind: "close" }, + expect.objectContaining({ profile: "user", timeoutMs: 60_000 }), + ); }); it("passes refs mode through to browser snapshot", async () => { @@ -750,7 +812,7 @@ describe("browser tool snapshot maxChars", () => { expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( "node.invoke", - { timeoutMs: 25000 }, + { timeoutMs: 50_000 }, expect.objectContaining({ nodeId: "node-1", command: "browser.proxy", @@ -758,7 +820,7 @@ describe("browser tool snapshot maxChars", () => { profile: "user", path: "/", method: "GET", - timeoutMs: 20000, + timeoutMs: 45_000, }), }), ); @@ -809,7 +871,7 @@ describe("browser tool snapshot maxChars", () => { expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( "node.invoke", - { timeoutMs: 25000 }, + { timeoutMs: 50_000 }, expect.objectContaining({ nodeId: "node-1", command: "browser.proxy", @@ -817,6 +879,7 @@ describe("browser tool snapshot maxChars", () => { profile: "user", path: "/", method: "GET", + timeoutMs: 45_000, }), }), ); @@ -833,7 +896,7 @@ describe("browser tool snapshot maxChars", () => { expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( "node.invoke", - { timeoutMs: 25000 }, + { timeoutMs: 50_000 }, expect.objectContaining({ nodeId: "node-1", command: "browser.proxy", @@ -841,6 +904,7 @@ describe("browser tool snapshot maxChars", () => { profile: "user", path: "/", method: "GET", + timeoutMs: 45_000, }), }), ); diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index 8a94bd2d591..f9450aa7352 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -372,6 +372,43 @@ function shouldPreferHostForProfile(profileName: string | undefined) { return capabilities.usesChromeMcp; } +const DEFAULT_EXISTING_SESSION_MANAGE_TIMEOUT_MS = 45_000; +const EXISTING_SESSION_MANAGE_ACTIONS = new Set([ + "status", + "start", + "stop", + "profiles", + "tabs", + "open", + "focus", + "close", +]); + +function usesExistingSessionManageFlow(params: { action: string; profileName?: string }) { + if (!EXISTING_SESSION_MANAGE_ACTIONS.has(params.action)) { + return false; + } + const cfg = browserToolDeps.loadConfig(); + const resolved = resolveBrowserConfig(cfg.browser, cfg); + const profile = resolveProfile(resolved, params.profileName ?? resolved.defaultProfile); + if (profile && getBrowserProfileCapabilities(profile).usesChromeMcp) { + return true; + } + if (params.action !== "profiles") { + return false; + } + return Object.keys(resolved.profiles).some((name) => { + const candidate = resolveProfile(resolved, name); + return candidate ? getBrowserProfileCapabilities(candidate).usesChromeMcp : false; + }); +} + +function readToolTimeoutMs(params: Record) { + return typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) + ? Math.max(1, Math.floor(params.timeoutMs)) + : undefined; +} + export function createBrowserTool(opts?: { sandboxBridgeUrl?: string; allowHostControl?: boolean; @@ -402,6 +439,7 @@ export function createBrowserTool(opts?: { const action = readStringParam(params, "action", { required: true }); const profile = readStringParam(params, "profile"); const requestedNode = readStringParam(params, "node"); + const requestedTimeoutMs = readToolTimeoutMs(params); let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined; const configuredNode = browserToolDeps.loadConfig().gateway?.nodes?.browser?.node?.trim(); @@ -469,6 +507,11 @@ export function createBrowserTool(opts?: { return proxy.result; } : null; + const toolTimeoutMs = + requestedTimeoutMs ?? + (usesExistingSessionManageFlow({ action, profileName: profile }) + ? DEFAULT_EXISTING_SESSION_MANAGE_TIMEOUT_MS + : undefined); switch (action) { case "doctor": @@ -489,55 +532,74 @@ export function createBrowserTool(opts?: { method: "GET", path: "/", profile, + timeoutMs: toolTimeoutMs, }), ); } - return jsonResult(await browserToolDeps.browserStatus(baseUrl, { profile })); + return jsonResult( + await browserToolDeps.browserStatus(baseUrl, { profile, timeoutMs: toolTimeoutMs }), + ); case "start": if (proxyRequest) { await proxyRequest({ method: "POST", path: "/start", profile, + timeoutMs: toolTimeoutMs, }); return jsonResult( await proxyRequest({ method: "GET", path: "/", profile, + timeoutMs: toolTimeoutMs, }), ); } - await browserToolDeps.browserStart(baseUrl, { profile }); - return jsonResult(await browserToolDeps.browserStatus(baseUrl, { profile })); + await browserToolDeps.browserStart(baseUrl, { profile, timeoutMs: toolTimeoutMs }); + return jsonResult( + await browserToolDeps.browserStatus(baseUrl, { profile, timeoutMs: toolTimeoutMs }), + ); case "stop": if (proxyRequest) { await proxyRequest({ method: "POST", path: "/stop", profile, + timeoutMs: toolTimeoutMs, }); return jsonResult( await proxyRequest({ method: "GET", path: "/", profile, + timeoutMs: toolTimeoutMs, }), ); } - await browserToolDeps.browserStop(baseUrl, { profile }); - return jsonResult(await browserToolDeps.browserStatus(baseUrl, { profile })); + await browserToolDeps.browserStop(baseUrl, { profile, timeoutMs: toolTimeoutMs }); + return jsonResult( + await browserToolDeps.browserStatus(baseUrl, { profile, timeoutMs: toolTimeoutMs }), + ); case "profiles": if (proxyRequest) { const result = await proxyRequest({ method: "GET", path: "/profiles", + timeoutMs: toolTimeoutMs, }); return jsonResult(result); } - return jsonResult({ profiles: await browserToolDeps.browserProfiles(baseUrl) }); + return jsonResult({ + profiles: await browserToolDeps.browserProfiles(baseUrl, { timeoutMs: toolTimeoutMs }), + }); case "tabs": - return await executeTabsAction({ baseUrl, profile, proxyRequest }); + return await executeTabsAction({ + baseUrl, + profile, + timeoutMs: toolTimeoutMs, + proxyRequest, + }); case "open": { const targetUrl = readTargetUrlParam(params); const label = normalizeOptionalString(params.label); @@ -547,12 +609,14 @@ export function createBrowserTool(opts?: { path: "/tabs/open", profile, body: { url: targetUrl, ...(label ? { label } : {}) }, + timeoutMs: toolTimeoutMs, }); return jsonResult(result); } const opened = await browserToolDeps.browserOpenTab(baseUrl, targetUrl, { profile, label, + timeoutMs: toolTimeoutMs, }); browserToolDeps.trackSessionBrowserTab({ sessionKey: opts?.agentSessionKey, @@ -572,10 +636,14 @@ export function createBrowserTool(opts?: { path: "/tabs/focus", profile, body: { targetId }, + timeoutMs: toolTimeoutMs, }); return jsonResult(result); } - await browserToolDeps.browserFocusTab(baseUrl, targetId, { profile }); + await browserToolDeps.browserFocusTab(baseUrl, targetId, { + profile, + timeoutMs: toolTimeoutMs, + }); return jsonResult({ ok: true }); } case "close": { @@ -586,17 +654,22 @@ export function createBrowserTool(opts?: { method: "DELETE", path: `/tabs/${encodeURIComponent(targetId)}`, profile, + timeoutMs: toolTimeoutMs, }) : await proxyRequest({ method: "POST", path: "/act", profile, body: { kind: "close" }, + timeoutMs: toolTimeoutMs, }); return jsonResult(result); } if (targetId) { - await browserToolDeps.browserCloseTab(baseUrl, targetId, { profile }); + await browserToolDeps.browserCloseTab(baseUrl, targetId, { + profile, + timeoutMs: toolTimeoutMs, + }); browserToolDeps.untrackSessionBrowserTab({ sessionKey: opts?.agentSessionKey, targetId, @@ -604,7 +677,14 @@ export function createBrowserTool(opts?: { profile, }); } else { - await browserToolDeps.browserAct(baseUrl, { kind: "close" }, { profile }); + await browserToolDeps.browserAct( + baseUrl, + { kind: "close" }, + { + profile, + timeoutMs: toolTimeoutMs, + }, + ); } return jsonResult({ ok: true }); } diff --git a/extensions/browser/src/browser/client-actions-core.ts b/extensions/browser/src/browser/client-actions-core.ts index 8a116e56c47..f4ebf0fee95 100644 --- a/extensions/browser/src/browser/client-actions-core.ts +++ b/extensions/browser/src/browser/client-actions-core.ts @@ -157,14 +157,17 @@ export async function browserDownload( export async function browserAct( baseUrl: string | undefined, req: BrowserActRequest, - opts?: { profile?: string }, + opts?: { profile?: string; timeoutMs?: number }, ): Promise { const q = buildProfileQuery(opts?.profile); return await fetchBrowserJson(withBaseUrl(baseUrl, `/act${q}`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), - timeoutMs: 20000, + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 20000, }); } diff --git a/extensions/browser/src/browser/client.ts b/extensions/browser/src/browser/client.ts index 44a10ac103d..12859be7792 100644 --- a/extensions/browser/src/browser/client.ts +++ b/extensions/browser/src/browser/client.ts @@ -69,11 +69,14 @@ export type SnapshotResult = export async function browserStatus( baseUrl?: string, - opts?: { profile?: string }, + opts?: { profile?: string; timeoutMs?: number }, ): Promise { const q = buildProfileQuery(opts?.profile); return await fetchBrowserJson(withBaseUrl(baseUrl, `/${q}`), { - timeoutMs: 1500, + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 1500, }); } @@ -87,29 +90,47 @@ export async function browserDoctor( }); } -export async function browserProfiles(baseUrl?: string): Promise { +export async function browserProfiles( + baseUrl?: string, + opts?: { timeoutMs?: number }, +): Promise { const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>( withBaseUrl(baseUrl, `/profiles`), { - timeoutMs: 3000, + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 3000, }, ); return res.profiles ?? []; } -export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise { +export async function browserStart( + baseUrl?: string, + opts?: { profile?: string; timeoutMs?: number }, +): Promise { const q = buildProfileQuery(opts?.profile); await fetchBrowserJson(withBaseUrl(baseUrl, `/start${q}`), { method: "POST", - timeoutMs: 15000, + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 15000, }); } -export async function browserStop(baseUrl?: string, opts?: { profile?: string }): Promise { +export async function browserStop( + baseUrl?: string, + opts?: { profile?: string; timeoutMs?: number }, +): Promise { const q = buildProfileQuery(opts?.profile); await fetchBrowserJson(withBaseUrl(baseUrl, `/stop${q}`), { method: "POST", - timeoutMs: 15000, + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 15000, }); } @@ -186,12 +207,17 @@ export async function browserDeleteProfile( export async function browserTabs( baseUrl?: string, - opts?: { profile?: string }, + opts?: { profile?: string; timeoutMs?: number }, ): Promise { const q = buildProfileQuery(opts?.profile); const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>( withBaseUrl(baseUrl, `/tabs${q}`), - { timeoutMs: 3000 }, + { + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 3000, + }, ); return res.tabs ?? []; } @@ -199,40 +225,49 @@ export async function browserTabs( export async function browserOpenTab( baseUrl: string | undefined, url: string, - opts?: { profile?: string; label?: string }, + opts?: { profile?: string; label?: string; timeoutMs?: number }, ): Promise { const q = buildProfileQuery(opts?.profile); return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/open${q}`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, ...(opts?.label ? { label: opts.label } : {}) }), - timeoutMs: 15000, + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 15000, }); } export async function browserFocusTab( baseUrl: string | undefined, targetId: string, - opts?: { profile?: string }, + opts?: { profile?: string; timeoutMs?: number }, ): Promise { const q = buildProfileQuery(opts?.profile); await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/focus${q}`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ targetId }), - timeoutMs: 5000, + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 5000, }); } export async function browserCloseTab( baseUrl: string | undefined, targetId: string, - opts?: { profile?: string }, + opts?: { profile?: string; timeoutMs?: number }, ): Promise { const q = buildProfileQuery(opts?.profile); await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/${encodeURIComponent(targetId)}${q}`), { method: "DELETE", - timeoutMs: 5000, + timeoutMs: + typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(1, Math.floor(opts.timeoutMs)) + : 5000, }); }