diff --git a/extensions/browser/src/browser-node-proxy.test.ts b/extensions/browser/src/browser-node-proxy.test.ts new file mode 100644 index 000000000000..64a3b044a2fc --- /dev/null +++ b/extensions/browser/src/browser-node-proxy.test.ts @@ -0,0 +1,307 @@ +// Browser tests cover independently bounded delegated node-proxy requests. +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type BrowserNodeRequest = { + nodeId: string; + command: string; + timeoutMs: number; + idempotencyKey: string; + params: { + method: string; + path: string; + timeoutMs: number; + profile?: string; + errorEnvelope: string; + }; +}; + +type BrowserNodeResponse = { + payload: { result: { ok: true; profile?: string } }; +}; + +type BrowserGatewayCall = ( + method: string, + options: { timeoutMs: number }, + request: BrowserNodeRequest, + extra: { scopes: string[]; signal?: AbortSignal }, +) => Promise; + +const runtimeMocks = vi.hoisted(() => ({ + callGatewayTool: vi.fn(), + persistBrowserProxyFiles: vi.fn<(_files?: unknown) => Promise>>(), + applyBrowserProxyPaths: vi.fn<(result: unknown, mapping: Map) => void>(), + fetchBrowserJson: vi.fn<(...args: unknown[]) => Promise>(), +})); + +vi.mock("./browser-tool.runtime.js", () => runtimeMocks); + +import { createBrowserNodeProxyRequest } from "./browser-node-proxy.js"; + +function createSessionProxy() { + return createBrowserNodeProxyRequest({ + nodeTarget: { nodeId: "node-1" }, + allowAutomaticHostFallback: false, + }); +} + +function readGatewayCall(index = 0) { + const call = runtimeMocks.callGatewayTool.mock.calls[index]; + if (!call) { + throw new Error(`Expected Browser node invocation ${index}`); + } + const [method, gateway, node, extra] = call; + return { method, gateway, node, extra }; +} + +beforeEach(() => { + runtimeMocks.callGatewayTool.mockReset(); + runtimeMocks.callGatewayTool.mockResolvedValue({ payload: { result: { ok: true } } }); + runtimeMocks.persistBrowserProxyFiles.mockReset(); + runtimeMocks.persistBrowserProxyFiles.mockResolvedValue(new Map()); + runtimeMocks.applyBrowserProxyPaths.mockReset(); + runtimeMocks.fetchBrowserJson.mockReset(); +}); + +describe("Browser node proxy nested watchdogs", () => { + it("keeps a requested action inside separate node and Gateway watchdogs", async () => { + const signal = new AbortController().signal; + + await createSessionProxy()({ + method: "GET", + path: "/snapshot", + profile: "work", + timeoutMs: 7_777, + signal, + }); + + const { method, gateway, node, extra } = readGatewayCall(); + expect(method).toBe("node.invoke"); + expect(node.nodeId).toBe("node-1"); + expect(node.command).toBe("browser.proxy"); + expect([node.params.timeoutMs, node.timeoutMs, gateway.timeoutMs]).toEqual([ + 7_777, 12_777, 17_777, + ]); + expect(node.params.errorEnvelope).toBe("browser-v1"); + expect(extra).toEqual({ scopes: ["operator.admin"], signal }); + expect(runtimeMocks.fetchBrowserJson).not.toHaveBeenCalled(); + }); + + it("binds the tool execution signal to node requests without their own signal", async () => { + const signal = new AbortController().signal; + const proxy = createBrowserNodeProxyRequest({ + nodeTarget: { nodeId: "node-1" }, + allowAutomaticHostFallback: false, + signal, + }); + + await proxy({ method: "GET", path: "/snapshot" }); + + expect(readGatewayCall().extra).toEqual({ scopes: ["operator.admin"], signal }); + }); + + it("keeps an explicit request signal ahead of the bound tool execution signal", async () => { + const toolSignal = new AbortController().signal; + const requestSignal = new AbortController().signal; + const proxy = createBrowserNodeProxyRequest({ + nodeTarget: { nodeId: "node-1" }, + allowAutomaticHostFallback: false, + signal: toolSignal, + }); + + await proxy({ method: "GET", path: "/snapshot", signal: requestSignal }); + + expect(readGatewayCall().extra).toEqual({ + scopes: ["operator.admin"], + signal: requestSignal, + }); + }); + + it("keeps the bound tool execution signal when a node falls back to the host", async () => { + const signal = new AbortController().signal; + runtimeMocks.callGatewayTool.mockRejectedValueOnce( + new Error("Browser control host is not reachable on 127.0.0.1:18791."), + ); + runtimeMocks.fetchBrowserJson.mockResolvedValueOnce({ ok: true, source: "gateway-host" }); + const proxy = createBrowserNodeProxyRequest({ + nodeTarget: { nodeId: "node-1" }, + allowAutomaticHostFallback: true, + signal, + }); + + await expect(proxy({ method: "GET", path: "/snapshot" })).resolves.toMatchObject({ + ok: true, + source: "gateway-host", + }); + + expect(runtimeMocks.fetchBrowserJson).toHaveBeenCalledWith( + "/snapshot", + expect.objectContaining({ method: "GET", signal }), + ); + }); + + it("keeps default action, node, and Gateway watchdogs strictly nested", async () => { + await createSessionProxy()({ method: "GET", path: "/snapshot" }); + + const { gateway, node } = readGatewayCall(); + expect([node.params.timeoutMs, node.timeoutMs, gateway.timeoutMs]).toEqual([ + 20_000, 25_000, 30_000, + ]); + }); + + it.each([ + Number.MAX_SAFE_INTEGER, + MAX_TIMER_TIMEOUT_MS, + MAX_TIMER_TIMEOUT_MS - 1, + MAX_TIMER_TIMEOUT_MS - 4_999, + MAX_TIMER_TIMEOUT_MS - 5_000, + MAX_TIMER_TIMEOUT_MS - 9_999, + MAX_TIMER_TIMEOUT_MS - 10_000, + MAX_TIMER_TIMEOUT_MS - 10_001, + ])("reserves both Node-safe watchdog windows for %i ms", async (timeoutMs) => { + await createSessionProxy()({ method: "GET", path: "/snapshot", timeoutMs }); + + const actionTimeoutMs = Math.min(timeoutMs, MAX_TIMER_TIMEOUT_MS - 10_000); + const { gateway, node } = readGatewayCall(); + expect([node.params.timeoutMs, node.timeoutMs, gateway.timeoutMs]).toEqual([ + actionTimeoutMs, + actionTimeoutMs + 5_000, + actionTimeoutMs + 10_000, + ]); + expect(gateway.timeoutMs).toBeLessThanOrEqual(MAX_TIMER_TIMEOUT_MS); + }); + + it("finishes ten independently owned, concurrently blocked Browser sessions", async () => { + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + runtimeMocks.callGatewayTool.mockImplementation(async (_method, _gateway, node) => { + await barrier; + return { payload: { result: { ok: true, profile: node.params.profile } } }; + }); + + const sessions = Array.from({ length: 10 }, (_, index) => ({ + profile: `session-${index}`, + timeoutMs: 7_777 + index, + signal: new AbortController().signal, + proxy: createSessionProxy(), + })); + const completed = new Set(); + const pending = sessions.map(async ({ profile, timeoutMs, signal, proxy }) => { + const result = await proxy({ method: "GET", path: "/snapshot", profile, timeoutMs, signal }); + completed.add(profile); + return result; + }); + + try { + expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10); + expect(completed.size).toBe(0); + expect(runtimeMocks.persistBrowserProxyFiles).not.toHaveBeenCalled(); + const invocationIds = new Set(); + + sessions.forEach(({ profile, timeoutMs, signal }, index) => { + const { method, gateway, node, extra } = readGatewayCall(index); + expect(method).toBe("node.invoke"); + expect(node.nodeId).toBe("node-1"); + expect(node.command).toBe("browser.proxy"); + expect(node.params.profile).toBe(profile); + expect(node.params.errorEnvelope).toBe("browser-v1"); + expect([node.params.timeoutMs, node.timeoutMs, gateway.timeoutMs]).toEqual([ + timeoutMs, + timeoutMs + 5_000, + timeoutMs + 10_000, + ]); + expect(extra).toEqual({ scopes: ["operator.admin"], signal }); + invocationIds.add(node.idempotencyKey); + }); + + expect(invocationIds.size).toBe(10); + expect(runtimeMocks.fetchBrowserJson).not.toHaveBeenCalled(); + } finally { + release(); + } + + await expect(Promise.all(pending)).resolves.toEqual( + sessions.map(({ profile }) => ({ ok: true, profile })), + ); + expect(completed.size).toBe(10); + expect(runtimeMocks.persistBrowserProxyFiles).toHaveBeenCalledTimes(10); + expect(runtimeMocks.applyBrowserProxyPaths).toHaveBeenCalledTimes(10); + expect(runtimeMocks.fetchBrowserJson).not.toHaveBeenCalled(); + expect(sessions.every(({ proxy }) => !proxy.isHostFallbackActive())).toBe(true); + }); + + it("keeps one session cancellation isolated from nine concurrent sessions", async () => { + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + runtimeMocks.callGatewayTool.mockImplementation( + (_method, _gateway, node, extra) => + new Promise((resolve, reject) => { + const onAbort = () => { + const reason = extra.signal?.reason; + reject(reason instanceof Error ? reason : new Error("Browser session cancelled")); + }; + if (extra.signal?.aborted) { + onAbort(); + return; + } + extra.signal?.addEventListener("abort", onAbort, { once: true }); + void barrier.then(() => { + extra.signal?.removeEventListener("abort", onAbort); + if (!extra.signal?.aborted) { + resolve({ payload: { result: { ok: true, profile: node.params.profile } } }); + } + }); + }), + ); + + const sessions = Array.from({ length: 10 }, (_, index) => ({ + profile: `session-${index}`, + timeoutMs: 7_777 + index, + controller: new AbortController(), + proxy: createSessionProxy(), + })); + const pending = sessions.map(({ profile, timeoutMs, controller, proxy }) => + proxy({ + method: "GET", + path: "/snapshot", + profile, + timeoutMs, + signal: controller.signal, + }), + ); + const completion = Promise.allSettled(pending); + const cancelledSession = sessions.at(3); + const cancelledRun = pending.at(3); + if (!cancelledSession || !cancelledRun) { + release(); + throw new Error("Expected a dedicated cancellation session"); + } + const abortError = new Error("session-3 cancelled"); + + try { + expect(runtimeMocks.callGatewayTool).toHaveBeenCalledTimes(10); + cancelledSession.controller.abort(abortError); + await expect(cancelledRun).rejects.toBe(abortError); + expect(runtimeMocks.persistBrowserProxyFiles).not.toHaveBeenCalled(); + expect(runtimeMocks.fetchBrowserJson).not.toHaveBeenCalled(); + } finally { + release(); + } + + await expect(completion).resolves.toEqual( + sessions.map(({ profile }, index) => + index === 3 + ? { status: "rejected", reason: abortError } + : { status: "fulfilled", value: { ok: true, profile } }, + ), + ); + expect(runtimeMocks.persistBrowserProxyFiles).toHaveBeenCalledTimes(9); + expect(runtimeMocks.applyBrowserProxyPaths).toHaveBeenCalledTimes(9); + expect(runtimeMocks.fetchBrowserJson).not.toHaveBeenCalled(); + expect(sessions.every(({ proxy }) => !proxy.isHostFallbackActive())).toBe(true); + }); +}); diff --git a/extensions/browser/src/browser-node-proxy.ts b/extensions/browser/src/browser-node-proxy.ts index 49dd1d844cce..7e1f90d46098 100644 --- a/extensions/browser/src/browser-node-proxy.ts +++ b/extensions/browser/src/browser-node-proxy.ts @@ -1,4 +1,9 @@ import crypto from "node:crypto"; +import { + addTimerTimeoutGraceMs, + MAX_TIMER_TIMEOUT_MS, + resolveTimerTimeoutMs, +} from "openclaw/plugin-sdk/number-runtime"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { isBrowserControlHostUnavailableError } from "./browser-node-fallback.js"; import { @@ -65,11 +70,18 @@ async function callBrowserProxy(params: { profile?: string; signal?: AbortSignal; }): Promise { - const proxyTimeoutMs = - typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) - ? Math.max(1, Math.floor(params.timeoutMs)) - : DEFAULT_BROWSER_PROXY_TIMEOUT_MS; - const gatewayTimeoutMs = proxyTimeoutMs + BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS; + // Reserve both watchdog windows before clamping so timer saturation cannot + // make an outer watchdog expire alongside the browser action. + const proxyTimeoutMs = Math.min( + resolveTimerTimeoutMs(params.timeoutMs, DEFAULT_BROWSER_PROXY_TIMEOUT_MS), + MAX_TIMER_TIMEOUT_MS - 2 * BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS, + ); + const nodeInvokeTimeoutMs = + addTimerTimeoutGraceMs(proxyTimeoutMs, BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS) ?? + proxyTimeoutMs; + const gatewayTimeoutMs = + addTimerTimeoutGraceMs(nodeInvokeTimeoutMs, BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS) ?? + nodeInvokeTimeoutMs; let payload: { payload?: unknown; payloadJSON?: unknown } | null; try { payload = await callGatewayTool<{ payload?: unknown; payloadJSON?: unknown }>( @@ -78,9 +90,9 @@ async function callBrowserProxy(params: { { nodeId: params.nodeId, command: "browser.proxy", - // node.invoke owns a separate watchdog from browser.proxy. Keep both - // bounded, with enough outer slack for the proxy result to cross back. - timeoutMs: gatewayTimeoutMs, + // Keep the browser action, node watchdog, and Gateway RPC on distinct + // budgets so a detailed node timeout can cross both outer boundaries. + timeoutMs: nodeInvokeTimeoutMs, params: { method: params.method, path: params.path, @@ -136,17 +148,24 @@ async function callLocalBrowserControl(params: Parameters[0 export function createBrowserNodeProxyRequest(params: { nodeTarget: { nodeId: string; label?: string }; allowAutomaticHostFallback: boolean; + signal?: AbortSignal; }): BrowserProxyRequest { let hostFallbackActive = false; const dispatch = async (request: Parameters[0]) => { + // Bind cancellation once so every node action and its safe host fallback + // inherit their execution signal without overriding an explicit request. + const requestWithSignal = + request.signal || params.signal + ? { ...request, signal: request.signal ?? params.signal } + : request; if (hostFallbackActive) { - return await callLocalBrowserControl(request); + return await callLocalBrowserControl(requestWithSignal); } try { const proxy = await callBrowserProxy({ nodeId: params.nodeTarget.nodeId, markControlHostUnavailable: params.allowAutomaticHostFallback, - ...request, + ...requestWithSignal, }); const mapping = await persistBrowserProxyFiles(proxy.files); applyBrowserProxyPaths(proxy.result, mapping); @@ -164,7 +183,7 @@ export function createBrowserNodeProxyRequest(params: { logger.warn( `browser node ${params.nodeTarget.label ?? params.nodeTarget.nodeId} control host unavailable; falling back to Gateway host`, ); - return await callLocalBrowserControl(request); + return await callLocalBrowserControl(requestWithSignal); } }; return Object.assign(dispatch, { diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index 64b1a91bddad..2cbd9e50cc79 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -532,6 +532,7 @@ function nodeInvokeCall(callIndex: number): { nodeId?: string; command?: string; timeoutMs?: number; + idempotencyKey?: string; params?: { method?: string; path?: string; @@ -542,7 +543,7 @@ function nodeInvokeCall(callIndex: number): { body?: Record; }; }; - extra?: { scopes?: string[] }; + extra?: { scopes?: string[]; signal?: AbortSignal }; } { const toolName = mockCallArg(gatewayMocks.callGatewayTool, callIndex, 0); const options = mockCallArg<{ timeoutMs?: number }>(gatewayMocks.callGatewayTool, callIndex, 1); @@ -550,6 +551,7 @@ function nodeInvokeCall(callIndex: number): { nodeId?: string; command?: string; timeoutMs?: number; + idempotencyKey?: string; params?: { method?: string; path?: string; @@ -559,7 +561,7 @@ function nodeInvokeCall(callIndex: number): { body?: Record; }; }>(gatewayMocks.callGatewayTool, callIndex, 2); - const extra = mockCallArg<{ scopes?: string[] } | undefined>( + const extra = mockCallArg<{ scopes?: string[]; signal?: AbortSignal } | undefined>( gatewayMocks.callGatewayTool, callIndex, 3, @@ -572,6 +574,50 @@ function lastNodeInvokeCall(): ReturnType { return nodeInvokeCall(-1); } +function blockBrowserNodeGateway(count = 1): () => void { + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + + for (let index = 0; index < count; index += 1) { + gatewayMocks.callGatewayTool.mockImplementationOnce( + () => + new Promise>((resolve, reject) => { + const { request, extra } = lastNodeInvokeCall(); + const signal = extra?.signal; + const onAbort = () => { + const reason = signal?.reason; + reject(reason instanceof Error ? reason : new Error("Browser tool cancelled")); + }; + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + void barrier.then(() => { + signal?.removeEventListener("abort", onAbort); + if (!signal?.aborted) { + resolve({ + ok: true, + payload: { + result: { + ok: true, + running: true, + profile: request.params?.profile, + path: "/tmp/test.png", + }, + }, + }); + } + }); + }), + ); + } + + return release; +} + describe("browser tool output schema", () => { it("accepts snapshot details", async () => { const tool = createBrowserTool(); @@ -673,7 +719,7 @@ describe("browser tool download actions", () => { }); const { options, request } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(40_000); + expect(options.timeoutMs).toBe(45_000); expect(request.params?.path).toBe("/wait/download"); expect(request.params?.timeoutMs).toBe(35_000); expect(request.params?.body).toEqual({ @@ -705,7 +751,7 @@ describe("browser tool download actions", () => { }); const { options, request } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(130_000); + expect(options.timeoutMs).toBe(135_000); expect(request.params?.timeoutMs).toBe(125_000); expect(request.params?.path).toBe("/download"); expect(request.params?.body).toMatchObject({ ref: "e12", path: "report.pdf" }); @@ -819,7 +865,7 @@ describe("browser tool snapshot maxChars", () => { await tool.execute?.("call-1", { action: "status", profile: "user", target: "node" }); const { options, request } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(50_000); + expect(options.timeoutMs).toBe(55_000); expect(request.params?.method).toBe("GET"); expect(request.params?.path).toBe("/"); expect(request.params?.profile).toBe("user"); @@ -980,8 +1026,8 @@ describe("browser tool snapshot maxChars", () => { expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( "node.invoke", - // proxy adds a 5_000 ms slack on top of the per-request timeout. - expect.objectContaining({ timeoutMs: 7777 + 5_000 }), + // The Gateway watchdog must also outlive the separate node watchdog. + expect.objectContaining({ timeoutMs: 7777 + 10_000 }), expect.objectContaining({ command: "browser.proxy", params: expect.objectContaining({ @@ -1102,7 +1148,7 @@ describe("browser tool snapshot maxChars", () => { await tool.execute?.("call-1", { action: "status", target: "node" }); const { options, request, extra } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(25_000); + expect(options.timeoutMs).toBe(30_000); expect(extra?.scopes).toEqual(["operator.admin"]); expect(request.nodeId).toBe("node-1"); expect(request.command).toBe("browser.proxy"); @@ -1111,6 +1157,99 @@ describe("browser tool snapshot maxChars", () => { expect(browserClientMocks.browserStatus).not.toHaveBeenCalled(); }); + it.each([ + { action: "status", path: "/" }, + { action: "screenshot", path: "/screenshot" }, + ])("cancels an actual blocked node-backed $action tool execution", async ({ action, path }) => { + mockSingleBrowserProxyNode(); + const release = blockBrowserNodeGateway(); + const controller = new AbortController(); + const abortError = new Error(`${action} tool execution cancelled`); + const pending = createBrowserTool().execute!( + `cancel-node-${action}`, + { action, target: "node" }, + controller.signal, + ); + + try { + await vi.waitFor(() => expect(gatewayMocks.callGatewayTool).toHaveBeenCalledTimes(1)); + const { request, extra } = lastNodeInvokeCall(); + expect(request.params?.path).toBe(path); + expect(extra?.signal).toBe(controller.signal); + controller.abort(abortError); + await expect(pending).rejects.toBe(abortError); + expect(toolCommonMocks.fetchBrowserJson).not.toHaveBeenCalled(); + } finally { + release(); + } + }); + + it("isolates one cancelled real Browser tool execution from nine blocked node sessions", async () => { + mockSingleBrowserProxyNode(); + const release = blockBrowserNodeGateway(10); + const sessions = Array.from({ length: 10 }, (_, index) => ({ + profile: `session-${index}`, + controller: new AbortController(), + tool: createBrowserTool(), + })); + const completed = new Set(); + const pending = sessions.map(({ profile, controller, tool }) => + tool.execute!( + `browser-tool-${profile}`, + { action: "status", target: "node", profile }, + controller.signal, + ).then((result) => { + completed.add(profile); + return result; + }), + ); + const completion = Promise.allSettled(pending); + const cancelledSession = sessions.at(3); + const cancelledRun = pending.at(3); + if (!cancelledSession || !cancelledRun) { + release(); + throw new Error("Expected a dedicated Browser tool cancellation session"); + } + const abortError = new Error("Browser tool session-3 cancelled"); + + try { + await vi.waitFor(() => expect(gatewayMocks.callGatewayTool).toHaveBeenCalledTimes(10)); + expect(completed.size).toBe(0); + const invocationIds = new Set(); + sessions.forEach(({ profile, controller }, index) => { + const { request, extra } = nodeInvokeCall(index); + expect(request.params?.path).toBe("/"); + expect(request.params?.profile).toBe(profile); + expect(extra?.signal).toBe(controller.signal); + if (request.idempotencyKey) { + invocationIds.add(request.idempotencyKey); + } + }); + expect(invocationIds.size).toBe(10); + cancelledSession.controller.abort(abortError); + await expect(cancelledRun).rejects.toBe(abortError); + expect(completed.size).toBe(0); + expect(toolCommonMocks.fetchBrowserJson).not.toHaveBeenCalled(); + } finally { + release(); + } + + await expect(completion).resolves.toEqual( + sessions.map(({ profile }, index) => + index === 3 + ? { status: "rejected", reason: abortError } + : { + status: "fulfilled", + value: expect.objectContaining({ + details: expect.objectContaining({ ok: true, profile }), + }), + }, + ), + ); + expect(completed.size).toBe(9); + expect(toolCommonMocks.fetchBrowserJson).not.toHaveBeenCalled(); + }); + it("falls back to the Gateway host when an auto-selected node has no browser host", async () => { mockSingleBrowserProxyNode(); gatewayMocks.callGatewayTool.mockRejectedValueOnce( @@ -1420,7 +1559,7 @@ describe("browser tool snapshot maxChars", () => { await tool.execute?.("call-1", { action: "doctor", target: "node" }); const { options, request } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(25_000); + expect(options.timeoutMs).toBe(30_000); expect(request.nodeId).toBe("node-1"); expect(request.command).toBe("browser.proxy"); expect(request.params?.method).toBe("GET"); @@ -1667,7 +1806,7 @@ describe("browser tool snapshot maxChars", () => { const { options, request } = lastNodeInvokeCall(); const body = request.params?.body as { targetId?: string; timeoutMs?: number } | undefined; - expect(options.timeoutMs).toBe(17_345); + expect(options.timeoutMs).toBe(22_345); expect(request.params?.method).toBe("POST"); expect(request.params?.path).toBe("/screenshot"); expect(request.params?.timeoutMs).toBe(12_345); @@ -1692,7 +1831,7 @@ describe("browser tool snapshot maxChars", () => { const { options, request } = lastNodeInvokeCall(); const body = request.params?.body as { timeoutMs?: number } | undefined; - expect(options.timeoutMs).toBe(25_000); + expect(options.timeoutMs).toBe(30_000); expect(request.params?.timeoutMs).toBe(20_000); expect(body?.timeoutMs).toBe(20_000); }); @@ -1728,11 +1867,11 @@ describe("browser tool snapshot maxChars", () => { expect((result?.details as { refsFallback?: string } | undefined)?.refsFallback).toBe("role"); const firstCall = nodeInvokeCall(0); - expect(firstCall.options.timeoutMs).toBe(25_000); + expect(firstCall.options.timeoutMs).toBe(30_000); expect(firstCall.request.params?.path).toBe("/snapshot"); expect(firstCall.request.params?.query?.refs).toBe("aria"); const secondCall = nodeInvokeCall(1); - expect(secondCall.options.timeoutMs).toBe(25_000); + expect(secondCall.options.timeoutMs).toBe(30_000); expect(secondCall.request.params?.path).toBe("/snapshot"); expect(secondCall.request.params?.query?.refs).toBe("role"); }); @@ -1753,7 +1892,7 @@ describe("browser tool snapshot maxChars", () => { }); const { options, request } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(25_000); + expect(options.timeoutMs).toBe(30_000); expect(request.params?.timeoutMs).toBe(20_000); }); @@ -1778,7 +1917,7 @@ describe("browser tool snapshot maxChars", () => { await tool.execute?.("call-1", { action: "status", profile: "user" }); const { options, request } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(50_000); + expect(options.timeoutMs).toBe(55_000); expect(request.nodeId).toBe("node-1"); expect(request.command).toBe("browser.proxy"); expect(request.params?.profile).toBe("user"); @@ -1829,7 +1968,7 @@ describe("browser tool snapshot maxChars", () => { await tool.execute?.("call-1", { action: "status", profile: "user", target: "node" }); const { options, request } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(50_000); + expect(options.timeoutMs).toBe(55_000); expect(request.nodeId).toBe("node-1"); expect(request.command).toBe("browser.proxy"); expect(request.params?.profile).toBe("user"); @@ -1848,7 +1987,7 @@ describe("browser tool snapshot maxChars", () => { await tool.execute?.("call-1", { action: "status", profile: "user", node: "node-1" }); const { options, request } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(50_000); + expect(options.timeoutMs).toBe(55_000); expect(request.nodeId).toBe("node-1"); expect(request.command).toBe("browser.proxy"); expect(request.params?.profile).toBe("user"); @@ -2515,7 +2654,7 @@ describe("browser tool act compatibility", () => { }); const { options, request } = lastNodeInvokeCall(); - expect(options.timeoutMs).toBe(75_000); + expect(options.timeoutMs).toBe(80_000); expect(request.params?.path).toBe("/act"); expect(request.params?.body).toEqual({ kind: "wait", @@ -2551,7 +2690,7 @@ describe("browser tool act compatibility", () => { const { options, request } = lastNodeInvokeCall(); expect(request.params?.timeoutMs).toBe(95_000); expect(request.timeoutMs).toBe(100_000); - expect(options.timeoutMs).toBe(100_000); + expect(options.timeoutMs).toBe(105_000); }); it("rejects fractional act request timeouts before node proxy calls", async () => { diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index c3491eed05aa..525607fe5710 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -520,7 +520,7 @@ export function createBrowserTool(opts?: { opts?.allowHostControl !== false, ); const proxyRequest = nodeTarget - ? createBrowserNodeProxyRequest({ nodeTarget, allowAutomaticHostFallback }) + ? createBrowserNodeProxyRequest({ nodeTarget, allowAutomaticHostFallback, signal }) : null; const toolTimeoutMs = requestedTimeoutMs ??