diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts index f6e3d8f8d7f..74f2adcbe3f 100644 --- a/src/browser/server-context.remote-tab-ops.test.ts +++ b/src/browser/server-context.remote-tab-ops.test.ts @@ -354,6 +354,99 @@ describe("browser server-context tab selection state", () => { }); }); + it("closes excess managed tabs after opening a new tab", 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 { ok: true, json: async () => ({}) } as unknown as Response; + } + 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 openclaw.openTab("http://127.0.0.1:3009"); + expect(opened.targetId).toBe("NEW"); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/json/close/OLD1"), + expect.any(Object), + ); + }); + 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 1890f56efcc..244d2f196c6 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -56,6 +56,8 @@ export function listKnownProfileNames(state: BrowserServerState): string[] { return [...names]; } +const MAX_MANAGED_BROWSER_PAGE_TABS = 8; + /** * Normalize a CDP WebSocket URL to use the correct base URL. */ @@ -136,6 +138,25 @@ function createProfileContext( .filter((t) => Boolean(t.targetId)); }; + const enforceManagedTabLimit = async (keepTargetId: string): Promise => { + if (profile.driver !== "openclaw") { + return; + } + + const pageTabs = (await listTabs()).filter((tab) => (tab.type ?? "page") === "page"); + if (pageTabs.length <= MAX_MANAGED_BROWSER_PAGE_TABS) { + return; + } + + 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(() => { + // best-effort cleanup only + }); + } + }; + const openTab = async (url: string): Promise => { const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy); @@ -152,6 +173,7 @@ function createProfileContext( }); const profileState = getProfileState(); profileState.lastTargetId = page.targetId; + await enforceManagedTabLimit(page.targetId); return { targetId: page.targetId, title: page.title, @@ -178,10 +200,12 @@ function createProfileContext( const found = tabs.find((t) => t.targetId === createdViaCdp); if (found) { await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts }); + await enforceManagedTabLimit(found.targetId); return found; } await new Promise((r) => setTimeout(r, 100)); } + await enforceManagedTabLimit(createdViaCdp); return { targetId: createdViaCdp, title: "", url, type: "page" }; } @@ -218,6 +242,7 @@ function createProfileContext( profileState.lastTargetId = created.id; const resolvedUrl = created.url ?? url; await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts }); + await enforceManagedTabLimit(created.id); return { targetId: created.id, title: created.title ?? "",