From c7bf54b9142dbe94d3a92ea0cecc52e374bd6c7c Mon Sep 17 00:00:00 2001 From: pandego <7780875+pandego@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:41:49 +0100 Subject: [PATCH] fix(browser): scope tab cap to local profile and detach cleanup closes --- .../server-context.remote-tab-ops.test.ts | 135 ++++++++++++++++++ src/browser/server-context.ts | 4 +- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts index 3e4a074f4bd..89a982ce01e 100644 --- a/src/browser/server-context.remote-tab-ops.test.ts +++ b/src/browser/server-context.remote-tab-ops.test.ts @@ -320,6 +320,46 @@ describe("browser server-context remote profile tab operations", () => { expect(tabs.map((t) => t.targetId)).toEqual(["T1"]); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it("does not enforce managed tab cap for remote openclaw profiles", async () => { + const listPagesViaPlaywright = vi + .fn() + .mockResolvedValueOnce([ + { targetId: "T1", title: "1", url: "https://1.example", type: "page" }, + ]) + .mockResolvedValueOnce([ + { targetId: "T1", title: "1", url: "https://1.example", type: "page" }, + { targetId: "T2", title: "2", url: "https://2.example", type: "page" }, + { targetId: "T3", title: "3", url: "https://3.example", type: "page" }, + { targetId: "T4", title: "4", url: "https://4.example", type: "page" }, + { targetId: "T5", title: "5", url: "https://5.example", type: "page" }, + { targetId: "T6", title: "6", url: "https://6.example", type: "page" }, + { targetId: "T7", title: "7", url: "https://7.example", type: "page" }, + { targetId: "T8", title: "8", url: "https://8.example", type: "page" }, + { targetId: "T9", title: "9", url: "https://9.example", type: "page" }, + ]); + + const createPageViaPlaywright = vi.fn(async () => ({ + targetId: "T1", + title: "Tab 1", + url: "https://1.example", + type: "page", + })); + + vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue({ + listPagesViaPlaywright, + createPageViaPlaywright, + } as unknown as Awaited>); + + const fetchMock = vi.fn(async (url: unknown) => { + throw new Error(`unexpected fetch: ${String(url)}`); + }); + + const { remote } = createRemoteRouteHarness(fetchMock); + const opened = await remote.openTab("https://1.example"); + expect(opened.targetId).toBe("T1"); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); describe("browser server-context tab selection state", () => { @@ -483,6 +523,101 @@ describe("browser server-context tab selection state", () => { expect(opened.targetId).toBe("NEW"); }); + it("does not block openTab on slow best-effort cleanup closes", async () => { + vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" }); + + const existingTabs = [ + { + id: "OLD1", + title: "1", + url: "http://127.0.0.1:3001", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD1", + type: "page", + }, + { + id: "OLD2", + title: "2", + url: "http://127.0.0.1:3002", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD2", + type: "page", + }, + { + id: "OLD3", + title: "3", + url: "http://127.0.0.1:3003", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD3", + type: "page", + }, + { + id: "OLD4", + title: "4", + url: "http://127.0.0.1:3004", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD4", + type: "page", + }, + { + id: "OLD5", + title: "5", + url: "http://127.0.0.1:3005", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD5", + type: "page", + }, + { + id: "OLD6", + title: "6", + url: "http://127.0.0.1:3006", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD6", + type: "page", + }, + { + id: "OLD7", + title: "7", + url: "http://127.0.0.1:3007", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD7", + type: "page", + }, + { + id: "OLD8", + title: "8", + url: "http://127.0.0.1:3008", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD8", + type: "page", + }, + { + id: "NEW", + title: "9", + url: "http://127.0.0.1:3009", + webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW", + type: "page", + }, + ]; + + const fetchMock = vi.fn(async (url: unknown) => { + const value = String(url); + if (value.includes("/json/list")) { + return { ok: true, json: async () => existingTabs } as unknown as Response; + } + if (value.includes("/json/close/OLD1")) { + return new Promise(() => {}); + } + throw new Error(`unexpected fetch: ${value}`); + }); + + global.fetch = withFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + const opened = await Promise.race([ + openclaw.openTab("http://127.0.0.1:3009"), + new Promise((_, reject) => + setTimeout(() => reject(new Error("openTab timed out waiting for cleanup")), 300), + ), + ]); + + expect(opened.targetId).toBe("NEW"); + }); + it("blocks unsupported non-network URLs before any HTTP tab-open fallback", async () => { const fetchMock = vi.fn(async () => { throw new Error("unexpected fetch"); diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index b5676d56396..cc9bc53ce08 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -139,7 +139,7 @@ function createProfileContext( }; const enforceManagedTabLimit = async (keepTargetId: string): Promise => { - if (profile.driver !== "openclaw") { + if (profile.driver !== "openclaw" || !profile.cdpIsLoopback) { return; } @@ -153,7 +153,7 @@ function createProfileContext( const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId); const excessCount = pageTabs.length - MAX_MANAGED_BROWSER_PAGE_TABS; for (const tab of candidates.slice(0, excessCount)) { - await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => { + void fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => { // best-effort cleanup only }); }