fix(browser): extend existing-session manage timeouts

This commit is contained in:
Peter Steinberger
2026-04-25 02:45:14 +01:00
parent 2ec70e6770
commit 2a0a76f876
6 changed files with 220 additions and 35 deletions

View File

@@ -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.

View File

@@ -209,19 +209,21 @@ function withRoleRefsFallback<T extends { refs?: "aria" | "role" }>(
export async function executeTabsAction(params: {
baseUrl?: string;
profile?: string;
timeoutMs?: number;
proxyRequest: BrowserProxyRequest | null;
}): Promise<AgentToolResult<unknown>> {
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);
}

View File

@@ -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,
}),
}),
);

View File

@@ -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<string, unknown>) {
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 });
}

View File

@@ -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<BrowserActResponse> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserActResponse>(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,
});
}

View File

@@ -69,11 +69,14 @@ export type SnapshotResult =
export async function browserStatus(
baseUrl?: string,
opts?: { profile?: string },
opts?: { profile?: string; timeoutMs?: number },
): Promise<BrowserStatus> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserStatus>(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<ProfileStatus[]> {
export async function browserProfiles(
baseUrl?: string,
opts?: { timeoutMs?: number },
): Promise<ProfileStatus[]> {
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<void> {
export async function browserStart(
baseUrl?: string,
opts?: { profile?: string; timeoutMs?: number },
): Promise<void> {
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<void> {
export async function browserStop(
baseUrl?: string,
opts?: { profile?: string; timeoutMs?: number },
): Promise<void> {
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<BrowserTab[]> {
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<BrowserTab> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserTab>(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<void> {
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<void> {
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,
});
}