diff --git a/CHANGELOG.md b/CHANGELOG.md index ee74f5839f4..f52f8cb2b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - 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. +- Browser/sandbox: clean up idle tracked tabs opened by primary-agent browser sessions, while preserving active tab reuse and lifecycle cleanup for subagents, cron, and ACP sessions. Fixes #71165. Thanks @dwbutler. - Plugins/Voice Call: pin voice response sessions to `responseModel` before embedded agent runs, avoiding live-session model switch failures when the global default model differs. Fixes #60118. Thanks @xinbenlv. - Media tools: honor the configured web-fetch SSRF policy for media understanding, image/music/video generation references, and PDF inputs, so explicit RFC2544 opt-ins cover WebChat OSS uploads without weakening defaults. Fixes #71300. (#71321) Thanks @neeravmakwana. - 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. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 7801e1ad6b9..6097d23d53f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -167,6 +167,12 @@ See [Plugins](/tools/plugin). // hostnameAllowlist: ["*.example.com", "example.com"], // allowedHostnames: ["localhost"], }, + tabCleanup: { + enabled: true, + idleMinutes: 120, + maxTabsPerSession: 8, + sweepMinutes: 5, + }, profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, @@ -190,6 +196,9 @@ See [Plugins](/tools/plugin). ``` - `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`. +- `tabCleanup` reclaims tracked primary-agent tabs after idle time or when a + session exceeds its cap. Set `idleMinutes: 0` or `maxTabsPerSession: 0` to + disable those individual cleanup modes. - `ssrfPolicy.dangerouslyAllowPrivateNetwork` is disabled when unset, so browser navigation stays strict by default. - Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: true` only when you intentionally trust private-network browser navigation. - In strict mode, remote CDP profile endpoints (`profiles.*.cdpUrl`) are subject to the same private-network blocking during reachability/discovery checks. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index d2870604208..da2846cd249 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -129,6 +129,12 @@ Browser settings live in `~/.openclaw/openclaw.json`. // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms) remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms) + tabCleanup: { + enabled: true, // default: true + idleMinutes: 120, // set 0 to disable idle cleanup + maxTabsPerSession: 8, // set 0 to disable the per-session cap + sweepMinutes: 5, + }, defaultProfile: "openclaw", color: "#FF4500", headless: false, @@ -162,6 +168,7 @@ Browser settings live in `~/.openclaw/openclaw.json`. - Control service binds to loopback on a port derived from `gateway.port` (default `18791` = gateway + 2). Overriding `gateway.port` or `OPENCLAW_GATEWAY_PORT` shifts the derived ports in the same family. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; set those only for remote CDP. `cdpUrl` defaults to the managed local CDP port when unset. - `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP HTTP reachability checks; `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket handshakes. +- `tabCleanup` is best-effort cleanup for tabs opened by primary-agent browser sessions. Subagent, cron, and ACP lifecycle cleanup still closes their explicit tracked tabs at session end; primary sessions keep active tabs reusable, then close idle or excess tracked tabs in the background. diff --git a/extensions/browser/src/browser-tool.actions.ts b/extensions/browser/src/browser-tool.actions.ts index 658bb553ddb..c3227beb7b0 100644 --- a/extensions/browser/src/browser-tool.actions.ts +++ b/extensions/browser/src/browser-tool.actions.ts @@ -232,6 +232,7 @@ export async function executeSnapshotAction(params: { baseUrl?: string; profile?: string; proxyRequest: BrowserProxyRequest | null; + onTabActivity?: (targetId: string | undefined) => void; }): Promise> { const { input, baseUrl, profile, proxyRequest } = params; const snapshotDefaults = browserToolActionDeps.loadConfig().browser?.snapshotDefaults; @@ -310,6 +311,7 @@ export async function executeSnapshotAction(params: { refsFallback = "role"; snapshot = await readSnapshot(withRoleRefsFallback(snapshotQuery)); } + params.onTabActivity?.(readStringValue(snapshot.targetId) ?? targetId); if (snapshot.format === "ai") { const extractedText = snapshot.snapshot ?? ""; const wrappedSnapshot = wrapExternalContent(extractedText, { @@ -410,6 +412,7 @@ export async function executeActAction(params: { baseUrl?: string; profile?: string; proxyRequest: BrowserProxyRequest | null; + onTabActivity?: (targetId: string | undefined) => void; }): Promise> { const { request, baseUrl, profile, proxyRequest } = params; try { @@ -423,6 +426,10 @@ export async function executeActAction(params: { : await browserToolActionDeps.browserAct(baseUrl, request, { profile, }); + params.onTabActivity?.( + readStringValue((result as { targetId?: unknown }).targetId) ?? + readStringValue(request.targetId), + ); return jsonResult(result); } catch (err) { if (isChromeStaleTargetError(profile, err)) { @@ -450,6 +457,10 @@ export async function executeActAction(params: { : await browserToolActionDeps.browserAct(baseUrl, retryRequest, { profile, }); + params.onTabActivity?.( + readStringValue((retryResult as { targetId?: unknown }).targetId) ?? + readStringValue(retryRequest.targetId), + ); return jsonResult(retryResult); } catch { // Fall through to explicit stale-target guidance. diff --git a/extensions/browser/src/browser-tool.runtime.ts b/extensions/browser/src/browser-tool.runtime.ts index a75a71a48a3..1ecf4c30982 100644 --- a/extensions/browser/src/browser-tool.runtime.ts +++ b/extensions/browser/src/browser-tool.runtime.ts @@ -39,6 +39,7 @@ export { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./browser/pa export { getBrowserProfileCapabilities } from "./browser/profile-capabilities.js"; export { applyBrowserProxyPaths, persistBrowserProxyFiles } from "./browser/proxy-files.js"; export { + touchSessionBrowserTab, trackSessionBrowserTab, untrackSessionBrowserTab, } from "./browser/session-tab-registry.js"; diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index add8f94ad0c..08cdf66fc7f 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -146,6 +146,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async () => { }); const sessionTabRegistryMocks = vi.hoisted(() => ({ + touchSessionBrowserTab: vi.fn(), trackSessionBrowserTab: vi.fn(), untrackSessionBrowserTab: vi.fn(), })); @@ -958,6 +959,28 @@ describe("browser tool url alias support", () => { }); }); + it("touches tracked tabs for direct tab activity", async () => { + browserClientMocks.browserSnapshot.mockResolvedValueOnce({ + ok: true, + format: "ai", + targetId: "tab-live", + url: "https://example.com", + snapshot: "ok", + }); + const tool = createBrowserTool({ agentSessionKey: "agent:main:main" }); + await tool.execute?.("call-1", { + action: "snapshot", + targetId: "tab-live", + }); + + expect(sessionTabRegistryMocks.touchSessionBrowserTab).toHaveBeenCalledWith({ + sessionKey: "agent:main:main", + targetId: "tab-live", + baseUrl: undefined, + profile: undefined, + }); + }); + it("accepts url alias for navigate", async () => { const tool = createBrowserTool(); await tool.execute?.("call-1", { diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index f9450aa7352..f40cee6dc29 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -40,6 +40,7 @@ import { resolveNodeIdFromList, resolveProfile, selectDefaultNodeFromList, + touchSessionBrowserTab, trackSessionBrowserTab, untrackSessionBrowserTab, } from "./browser-tool.runtime.js"; @@ -64,6 +65,7 @@ const browserToolDeps = { loadConfig, listNodes, callGatewayTool, + touchSessionBrowserTab, trackSessionBrowserTab, untrackSessionBrowserTab, }; @@ -89,6 +91,7 @@ export const __testing = { loadConfig: typeof loadConfig; listNodes: typeof listNodes; callGatewayTool: typeof callGatewayTool; + touchSessionBrowserTab: typeof touchSessionBrowserTab; trackSessionBrowserTab: typeof trackSessionBrowserTab; untrackSessionBrowserTab: typeof untrackSessionBrowserTab; }> | null, @@ -113,6 +116,8 @@ export const __testing = { browserToolDeps.loadConfig = overrides?.loadConfig ?? loadConfig; browserToolDeps.listNodes = overrides?.listNodes ?? listNodes; browserToolDeps.callGatewayTool = overrides?.callGatewayTool ?? callGatewayTool; + browserToolDeps.touchSessionBrowserTab = + overrides?.touchSessionBrowserTab ?? touchSessionBrowserTab; browserToolDeps.trackSessionBrowserTab = overrides?.trackSessionBrowserTab ?? trackSessionBrowserTab; browserToolDeps.untrackSessionBrowserTab = @@ -512,6 +517,17 @@ export function createBrowserTool(opts?: { (usesExistingSessionManageFlow({ action, profileName: profile }) ? DEFAULT_EXISTING_SESSION_MANAGE_TIMEOUT_MS : undefined); + const touchTrackedTab = (targetId: string | undefined) => { + if (proxyRequest || !targetId) { + return; + } + browserToolDeps.touchSessionBrowserTab({ + sessionKey: opts?.agentSessionKey, + targetId, + baseUrl, + profile, + }); + }; switch (action) { case "doctor": @@ -644,6 +660,7 @@ export function createBrowserTool(opts?: { profile, timeoutMs: toolTimeoutMs, }); + touchTrackedTab(targetId); return jsonResult({ ok: true }); } case "close": { @@ -694,6 +711,7 @@ export function createBrowserTool(opts?: { baseUrl, profile, proxyRequest, + onTabActivity: touchTrackedTab, }); case "screenshot": { const targetId = readStringParam(params, "targetId"); @@ -733,6 +751,7 @@ export function createBrowserTool(opts?: { timeoutMs: effectiveTimeoutMs, profile, }); + touchTrackedTab(readStringValue(result.targetId) ?? targetId); return await browserToolDeps.imageResultFromFile({ label: "browser:screenshot", path: result.path, @@ -754,13 +773,13 @@ export function createBrowserTool(opts?: { }); return jsonResult(result); } - return jsonResult( - await browserToolDeps.browserNavigate(baseUrl, { - url: targetUrl, - targetId, - profile, - }), - ); + const result = await browserToolDeps.browserNavigate(baseUrl, { + url: targetUrl, + targetId, + profile, + }); + touchTrackedTab(readStringValue(result.targetId) ?? targetId); + return jsonResult(result); } case "console": return await executeConsoleAction({ @@ -779,6 +798,7 @@ export function createBrowserTool(opts?: { body: { targetId }, })) as Awaited>) : await browserToolDeps.browserPdfSave(baseUrl, { targetId, profile }); + touchTrackedTab(readStringValue(result.targetId) ?? targetId); return { content: [{ type: "text" as const, text: `FILE:${result.path}` }], details: result, @@ -818,17 +838,17 @@ export function createBrowserTool(opts?: { }); return jsonResult(result); } - return jsonResult( - await browserToolDeps.browserArmFileChooser(baseUrl, { - paths: normalizedPaths, - ref, - inputRef, - element, - targetId, - timeoutMs, - profile, - }), - ); + const result = await browserToolDeps.browserArmFileChooser(baseUrl, { + paths: normalizedPaths, + ref, + inputRef, + element, + targetId, + timeoutMs, + profile, + }); + touchTrackedTab(readStringValue((result as { targetId?: unknown }).targetId) ?? targetId); + return jsonResult(result); } case "dialog": { const accept = Boolean(params.accept); @@ -848,15 +868,15 @@ export function createBrowserTool(opts?: { }); return jsonResult(result); } - return jsonResult( - await browserToolDeps.browserArmDialog(baseUrl, { - accept, - promptText, - targetId, - timeoutMs, - profile, - }), - ); + const result = await browserToolDeps.browserArmDialog(baseUrl, { + accept, + promptText, + targetId, + timeoutMs, + profile, + }); + touchTrackedTab(readStringValue((result as { targetId?: unknown }).targetId) ?? targetId); + return jsonResult(result); } case "act": { const request = readActRequestParam(params); @@ -868,6 +888,7 @@ export function createBrowserTool(opts?: { baseUrl, profile, proxyRequest, + onTabActivity: touchTrackedTab, }); } default: diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index 115d55c8941..4d650e21064 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -637,6 +637,12 @@ describe("browser chrome launch args", () => { noSandbox: false, attachOnly: false, ssrfPolicy: { allowPrivateNetwork: true }, + tabCleanup: { + enabled: true, + idleMinutes: 120, + maxTabsPerSession: 8, + sweepMinutes: 5, + }, defaultProfile: "openclaw", profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index 57a9bf97f76..167330045a9 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -58,6 +58,12 @@ describe("browser config", () => { expect(resolveProfile(resolved, "chrome-relay")).toBe(null); expect(resolved.remoteCdpTimeoutMs).toBe(1500); expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000); + expect(resolved.tabCleanup).toEqual({ + enabled: true, + idleMinutes: 120, + maxTabsPerSession: 8, + sweepMinutes: 5, + }); }); it("derives default ports from OPENCLAW_GATEWAY_PORT when unset", () => { @@ -116,6 +122,39 @@ describe("browser config", () => { expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000); }); + it("supports custom browser tab cleanup policy", () => { + const resolved = resolveBrowserConfig({ + tabCleanup: { + enabled: false, + idleMinutes: 0, + maxTabsPerSession: 0, + sweepMinutes: 15, + }, + }); + expect(resolved.tabCleanup).toEqual({ + enabled: false, + idleMinutes: 0, + maxTabsPerSession: 0, + sweepMinutes: 15, + }); + }); + + it("normalizes invalid browser tab cleanup numbers to defaults", () => { + const resolved = resolveBrowserConfig({ + tabCleanup: { + idleMinutes: -1, + maxTabsPerSession: -2, + sweepMinutes: 0, + }, + }); + expect(resolved.tabCleanup).toEqual({ + enabled: true, + idleMinutes: 120, + maxTabsPerSession: 8, + sweepMinutes: 5, + }); + }); + it("falls back to default color for invalid hex", () => { const resolved = resolveBrowserConfig({ color: "#GGGGGG", diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index e84ed8a6f88..eafe31ebebc 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -20,6 +20,9 @@ import { DEFAULT_AI_SNAPSHOT_MAX_CHARS, DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, DEFAULT_BROWSER_EVALUATE_ENABLED, + DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES, + DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION, + DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES, DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_ENABLED, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, @@ -68,10 +71,18 @@ export type ResolvedBrowserConfig = { attachOnly: boolean; defaultProfile: string; profiles: Record; + tabCleanup: ResolvedBrowserTabCleanupConfig; ssrfPolicy?: SsrFPolicy; extraArgs: string[]; }; +export type ResolvedBrowserTabCleanupConfig = { + enabled: boolean; + idleMinutes: number; + maxTabsPerSession: number; + sweepMinutes: number; +}; + export type ResolvedBrowserProfile = { name: string; cdpPort: number; @@ -104,6 +115,37 @@ function normalizeTimeoutMs(raw: number | undefined, fallback: number): number { return value < 0 ? fallback : value; } +function normalizeNonNegativeInteger(raw: number | undefined, fallback: number): number { + const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback; + return value < 0 ? fallback : value; +} + +function normalizePositiveInteger(raw: number | undefined, fallback: number): number { + const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback; + return value <= 0 ? fallback : value; +} + +function resolveBrowserTabCleanupConfig( + cfg: BrowserConfig | undefined, +): ResolvedBrowserTabCleanupConfig { + const raw = cfg?.tabCleanup; + return { + enabled: raw?.enabled ?? true, + idleMinutes: normalizeNonNegativeInteger( + raw?.idleMinutes, + DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES, + ), + maxTabsPerSession: normalizeNonNegativeInteger( + raw?.maxTabsPerSession, + DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION, + ), + sweepMinutes: normalizePositiveInteger( + raw?.sweepMinutes, + DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES, + ), + }; +} + function resolveCdpPortRangeStart( rawStart: number | undefined, fallbackStart: number, @@ -294,6 +336,7 @@ export function resolveBrowserConfig( attachOnly, defaultProfile, profiles, + tabCleanup: resolveBrowserTabCleanupConfig(cfg), ssrfPolicy: resolveBrowserSsrFPolicy(cfg), extraArgs, }; diff --git a/extensions/browser/src/browser/constants.ts b/extensions/browser/src/browser/constants.ts index f1c58a130f7..fb229b6ec3d 100644 --- a/extensions/browser/src/browser/constants.ts +++ b/extensions/browser/src/browser/constants.ts @@ -4,6 +4,9 @@ export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500"; export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw"; export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw"; export const DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS = 20_000; +export const DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES = 120; +export const DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION = 8; +export const DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES = 5; export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 40_000; export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 8_000; export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6; diff --git a/extensions/browser/src/browser/runtime-lifecycle.ts b/extensions/browser/src/browser/runtime-lifecycle.ts index 87602ff281e..ae63ebb7d55 100644 --- a/extensions/browser/src/browser/runtime-lifecycle.ts +++ b/extensions/browser/src/browser/runtime-lifecycle.ts @@ -3,6 +3,7 @@ import { getPwAiModule } from "./pw-ai-module.js"; import { isPwAiLoaded } from "./pw-ai-state.js"; import type { BrowserServerState } from "./server-context.js"; import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js"; +import { startTrackedBrowserTabCleanupTimer } from "./session-tab-cleanup.js"; export async function createBrowserRuntimeState(params: { resolved: BrowserServerState["resolved"]; @@ -16,6 +17,9 @@ export async function createBrowserRuntimeState(params: { resolved: params.resolved, profiles: new Map(), }; + state.stopTrackedTabCleanup = startTrackedBrowserTabCleanupTimer({ + onWarn: params.onWarn, + }); await ensureExtensionRelayForProfiles({ resolved: params.resolved, @@ -35,6 +39,7 @@ export async function stopBrowserRuntime(params: { if (!params.current) { return; } + params.current.stopTrackedTabCleanup?.(); await stopKnownBrowserProfiles({ getState: params.getState, diff --git a/extensions/browser/src/browser/server-context.existing-session.test.ts b/extensions/browser/src/browser/server-context.existing-session.test.ts index fc089ca1a5e..7c412261fdc 100644 --- a/extensions/browser/src/browser/server-context.existing-session.test.ts +++ b/extensions/browser/src/browser/server-context.existing-session.test.ts @@ -43,6 +43,12 @@ function makeState(): BrowserServerState { noSandbox: false, attachOnly: false, defaultProfile: "chrome-live", + tabCleanup: { + enabled: true, + idleMinutes: 120, + maxTabsPerSession: 8, + sweepMinutes: 5, + }, profiles: { "chrome-live": { cdpPort: 18801, diff --git a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts index 965b25a438c..3ba02cf8065 100644 --- a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts +++ b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts @@ -31,6 +31,12 @@ export function makeState( noSandbox: false, attachOnly: false, ssrfPolicy: { allowPrivateNetwork: true }, + tabCleanup: { + enabled: true, + idleMinutes: 120, + maxTabsPerSession: 8, + sweepMinutes: 5, + }, defaultProfile: profile, profiles: { remote: { diff --git a/extensions/browser/src/browser/server-context.test-harness.ts b/extensions/browser/src/browser/server-context.test-harness.ts index fbec0483402..e91ba2dcfc9 100644 --- a/extensions/browser/src/browser/server-context.test-harness.ts +++ b/extensions/browser/src/browser/server-context.test-harness.ts @@ -26,31 +26,41 @@ export function makeBrowserServerState(params?: { resolvedOverrides?: Partial; }): BrowserServerState { const profile = params?.profile ?? makeBrowserProfile(); + const resolvedBase: BrowserServerState["resolved"] = { + enabled: true, + controlPort: 18791, + cdpProtocol: "http", + cdpHost: profile.cdpHost, + cdpIsLoopback: profile.cdpIsLoopback, + cdpPortRangeStart: 18800, + cdpPortRangeEnd: 18810, + evaluateEnabled: false, + remoteCdpTimeoutMs: 1500, + remoteCdpHandshakeTimeoutMs: 3000, + extraArgs: [], + color: profile.color, + headless: true, + noSandbox: false, + attachOnly: false, + ssrfPolicy: { allowPrivateNetwork: true }, + tabCleanup: { + enabled: true, + idleMinutes: 120, + maxTabsPerSession: 8, + sweepMinutes: 5, + }, + defaultProfile: profile.name, + profiles: { + [profile.name]: profile, + }, + }; return { server: null as any, port: 0, resolved: { - enabled: true, - controlPort: 18791, - cdpProtocol: "http", - cdpHost: profile.cdpHost, - cdpIsLoopback: profile.cdpIsLoopback, - cdpPortRangeStart: 18800, - cdpPortRangeEnd: 18810, - evaluateEnabled: false, - remoteCdpTimeoutMs: 1500, - remoteCdpHandshakeTimeoutMs: 3000, - extraArgs: [], - color: profile.color, - headless: true, - noSandbox: false, - attachOnly: false, - ssrfPolicy: { allowPrivateNetwork: true }, - defaultProfile: profile.name, - profiles: { - [profile.name]: profile, - }, + ...resolvedBase, ...params?.resolvedOverrides, + tabCleanup: params?.resolvedOverrides?.tabCleanup ?? resolvedBase.tabCleanup, }, profiles: new Map(), }; diff --git a/extensions/browser/src/browser/server-context.types.ts b/extensions/browser/src/browser/server-context.types.ts index 3137218db74..88a4ee4bcc4 100644 --- a/extensions/browser/src/browser/server-context.types.ts +++ b/extensions/browser/src/browser/server-context.types.ts @@ -29,6 +29,7 @@ export type BrowserServerState = { port: number; resolved: ResolvedBrowserConfig; profiles: Map; + stopTrackedTabCleanup?: () => void; }; type BrowserProfileActions = { diff --git a/extensions/browser/src/browser/session-tab-cleanup.test.ts b/extensions/browser/src/browser/session-tab-cleanup.test.ts new file mode 100644 index 00000000000..4f7ef5b468a --- /dev/null +++ b/extensions/browser/src/browser/session-tab-cleanup.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + isPrimaryTrackedBrowserSessionKey, + runTrackedBrowserTabCleanupOnce, +} from "./session-tab-cleanup.js"; +import { + __countTrackedSessionBrowserTabsForTests, + __resetTrackedSessionBrowserTabsForTests, + trackSessionBrowserTab, +} from "./session-tab-registry.js"; + +describe("session tab cleanup", () => { + beforeEach(() => { + vi.useFakeTimers(); + __resetTrackedSessionBrowserTabsForTests(); + }); + + afterEach(() => { + __resetTrackedSessionBrowserTabsForTests(); + vi.useRealTimers(); + }); + + it("classifies primary sessions without matching subagent, cron, or acp sessions", () => { + expect(isPrimaryTrackedBrowserSessionKey("agent:main:main")).toBe(true); + expect(isPrimaryTrackedBrowserSessionKey("agent:main:subagent:child")).toBe(false); + expect(isPrimaryTrackedBrowserSessionKey("agent:main:cron:nightly")).toBe(false); + expect(isPrimaryTrackedBrowserSessionKey("agent:main:acp:child")).toBe(false); + }); + + it("only cleans up tracked tabs for primary-agent sessions", async () => { + vi.setSystemTime(1_000); + trackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "primary-tab" }); + trackSessionBrowserTab({ sessionKey: "agent:main:subagent:child", targetId: "child-tab" }); + trackSessionBrowserTab({ sessionKey: "agent:main:cron:nightly", targetId: "cron-tab" }); + + const closed = await runTrackedBrowserTabCleanupOnce({ + now: 10_000, + closeTab: vi.fn(async () => {}), + cleanup: { + enabled: true, + idleMinutes: 0.001, + maxTabsPerSession: 8, + sweepMinutes: 5, + }, + }); + + expect(closed).toBe(1); + expect(__countTrackedSessionBrowserTabsForTests("agent:main:main")).toBe(0); + expect(__countTrackedSessionBrowserTabsForTests("agent:main:subagent:child")).toBe(1); + expect(__countTrackedSessionBrowserTabsForTests("agent:main:cron:nightly")).toBe(1); + }); +}); diff --git a/extensions/browser/src/browser/session-tab-cleanup.ts b/extensions/browser/src/browser/session-tab-cleanup.ts new file mode 100644 index 00000000000..3912ba2a018 --- /dev/null +++ b/extensions/browser/src/browser/session-tab-cleanup.ts @@ -0,0 +1,92 @@ +import { + isAcpSessionKey, + isCronSessionKey, + isSubagentSessionKey, +} from "openclaw/plugin-sdk/routing"; +import { loadConfig } from "../config/config.js"; +import { resolveBrowserConfig, type ResolvedBrowserTabCleanupConfig } from "./config.js"; +import { sweepTrackedBrowserTabs } from "./session-tab-registry.js"; + +const MIN_SWEEP_INTERVAL_MS = 60_000; + +function minutesToMs(minutes: number): number { + return Math.max(0, Math.floor(minutes * 60_000)); +} + +export function isPrimaryTrackedBrowserSessionKey(sessionKey: string): boolean { + return ( + !isSubagentSessionKey(sessionKey) && + !isCronSessionKey(sessionKey) && + !isAcpSessionKey(sessionKey) + ); +} + +export function resolveBrowserTabCleanupRuntimeConfig(): ResolvedBrowserTabCleanupConfig { + const cfg = loadConfig(); + return resolveBrowserConfig(cfg.browser, cfg).tabCleanup; +} + +export async function runTrackedBrowserTabCleanupOnce(params?: { + now?: number; + cleanup?: ResolvedBrowserTabCleanupConfig; + closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise; + onWarn?: (message: string) => void; +}): Promise { + const cleanup = params?.cleanup ?? resolveBrowserTabCleanupRuntimeConfig(); + if (!cleanup.enabled) { + return 0; + } + return await sweepTrackedBrowserTabs({ + now: params?.now, + idleMs: minutesToMs(cleanup.idleMinutes), + maxTabsPerSession: cleanup.maxTabsPerSession, + sessionFilter: isPrimaryTrackedBrowserSessionKey, + closeTab: params?.closeTab, + onWarn: params?.onWarn, + }); +} + +export function startTrackedBrowserTabCleanupTimer(params: { + onWarn: (message: string) => void; +}): () => void { + let stopped = false; + let timer: NodeJS.Timeout | null = null; + let running: Promise | null = null; + + const schedule = () => { + if (stopped) { + return; + } + let sweepMinutes = 5; + try { + sweepMinutes = resolveBrowserTabCleanupRuntimeConfig().sweepMinutes; + } catch (err) { + params.onWarn(`failed to resolve browser tab cleanup config: ${String(err)}`); + } + timer = setTimeout(run, Math.max(MIN_SWEEP_INTERVAL_MS, minutesToMs(sweepMinutes))); + timer.unref?.(); + }; + + const run = () => { + if (stopped) { + return; + } + if (!running) { + running = runTrackedBrowserTabCleanupOnce({ onWarn: params.onWarn }).finally(() => { + running = null; + schedule(); + }); + return; + } + schedule(); + }; + + schedule(); + return () => { + stopped = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + }; +} diff --git a/extensions/browser/src/browser/session-tab-registry.test.ts b/extensions/browser/src/browser/session-tab-registry.test.ts index 2abdcd34462..c64598feedf 100644 --- a/extensions/browser/src/browser/session-tab-registry.test.ts +++ b/extensions/browser/src/browser/session-tab-registry.test.ts @@ -3,17 +3,21 @@ import { __countTrackedSessionBrowserTabsForTests, __resetTrackedSessionBrowserTabsForTests, closeTrackedBrowserTabsForSessions, + sweepTrackedBrowserTabs, + touchSessionBrowserTab, trackSessionBrowserTab, untrackSessionBrowserTab, } from "./session-tab-registry.js"; describe("session tab registry", () => { beforeEach(() => { + vi.useFakeTimers(); __resetTrackedSessionBrowserTabsForTests(); }); afterEach(() => { __resetTrackedSessionBrowserTabsForTests(); + vi.useRealTimers(); }); it("tracks and closes tabs for normalized session keys", async () => { @@ -111,4 +115,82 @@ describe("session tab registry", () => { expect(warnings).toEqual([expect.stringContaining("network down")]); expect(__countTrackedSessionBrowserTabsForTests()).toBe(0); }); + + it("sweeps idle tracked tabs and keeps recently touched tabs", async () => { + vi.setSystemTime(1_000); + trackSessionBrowserTab({ + sessionKey: "agent:main:main", + targetId: "old-tab", + }); + trackSessionBrowserTab({ + sessionKey: "agent:main:main", + targetId: "active-tab", + }); + touchSessionBrowserTab({ + sessionKey: "agent:main:main", + targetId: "active-tab", + now: 11_000, + }); + + const closeTab = vi.fn(async () => {}); + const closed = await sweepTrackedBrowserTabs({ + now: 11_000, + idleMs: 5_000, + closeTab, + }); + + expect(closed).toBe(1); + expect(closeTab).toHaveBeenCalledWith({ + targetId: "old-tab", + baseUrl: undefined, + profile: undefined, + }); + expect(__countTrackedSessionBrowserTabsForTests("agent:main:main")).toBe(1); + }); + + it("caps tracked tabs per session by closing least recently used tabs first", async () => { + vi.setSystemTime(1_000); + trackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "tab-a" }); + vi.setSystemTime(2_000); + trackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "tab-b" }); + vi.setSystemTime(3_000); + trackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "tab-c" }); + + const closeTab = vi.fn(async () => {}); + const closed = await sweepTrackedBrowserTabs({ + now: 4_000, + maxTabsPerSession: 2, + closeTab, + }); + + expect(closed).toBe(1); + expect(closeTab).toHaveBeenCalledWith({ + targetId: "tab-a", + baseUrl: undefined, + profile: undefined, + }); + expect(__countTrackedSessionBrowserTabsForTests("agent:main:main")).toBe(2); + }); + + it("honors session filters during sweeps", async () => { + vi.setSystemTime(1_000); + trackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "primary-tab" }); + trackSessionBrowserTab({ sessionKey: "agent:main:subagent:child", targetId: "child-tab" }); + + const closeTab = vi.fn(async () => {}); + const closed = await sweepTrackedBrowserTabs({ + now: 10_000, + idleMs: 1, + sessionFilter: (sessionKey) => !sessionKey.includes(":subagent:"), + closeTab, + }); + + expect(closed).toBe(1); + expect(closeTab).toHaveBeenCalledWith({ + targetId: "primary-tab", + baseUrl: undefined, + profile: undefined, + }); + expect(__countTrackedSessionBrowserTabsForTests()).toBe(1); + }); }); diff --git a/extensions/browser/src/browser/session-tab-registry.ts b/extensions/browser/src/browser/session-tab-registry.ts index 6905de727f3..472cb50eb80 100644 --- a/extensions/browser/src/browser/session-tab-registry.ts +++ b/extensions/browser/src/browser/session-tab-registry.ts @@ -10,6 +10,7 @@ export type TrackedSessionBrowserTab = { baseUrl?: string; profile?: string; trackedAt: number; + lastUsedAt: number; }; const trackedTabsBySession = new Map>(); @@ -43,7 +44,7 @@ function resolveTrackedTabIdentity(params: { targetId?: string; baseUrl?: string; profile?: string; -}): Omit | undefined { +}): Omit | undefined { const sessionKeyRaw = params.sessionKey?.trim(); const targetIdRaw = params.targetId?.trim(); if (!sessionKeyRaw || !targetIdRaw) { @@ -77,9 +78,11 @@ export function trackSessionBrowserTab(params: { if (!identity) { return; } + const now = Date.now(); const tracked: TrackedSessionBrowserTab = { ...identity, - trackedAt: Date.now(), + trackedAt: now, + lastUsedAt: now, }; const trackedId = toTrackedTabId(tracked); let trackedForSession = trackedTabsBySession.get(identity.sessionKey); @@ -87,7 +90,37 @@ export function trackSessionBrowserTab(params: { trackedForSession = new Map(); trackedTabsBySession.set(identity.sessionKey, trackedForSession); } - trackedForSession.set(trackedId, tracked); + const existing = trackedForSession.get(trackedId); + trackedForSession.set(trackedId, { + ...tracked, + trackedAt: existing?.trackedAt ?? tracked.trackedAt, + }); +} + +export function touchSessionBrowserTab(params: { + sessionKey?: string; + targetId?: string; + baseUrl?: string; + profile?: string; + now?: number; +}): void { + const identity = resolveTrackedTabIdentity(params); + if (!identity) { + return; + } + const trackedForSession = trackedTabsBySession.get(identity.sessionKey); + if (!trackedForSession) { + return; + } + const trackedId = toTrackedTabId(identity); + const tracked = trackedForSession.get(trackedId); + if (!tracked) { + return; + } + trackedForSession.set(trackedId, { + ...tracked, + lastUsedAt: params.now ?? Date.now(), + }); } export function untrackSessionBrowserTab(params: { @@ -144,13 +177,12 @@ function takeTrackedTabsForSessionKeys( return tabs; } -export async function closeTrackedBrowserTabsForSessions(params: { - sessionKeys: Array; +async function closeTrackedTabs(params: { + tabs: TrackedSessionBrowserTab[]; closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise; onWarn?: (message: string) => void; }): Promise { - const tabs = takeTrackedTabsForSessionKeys(params.sessionKeys); - if (tabs.length === 0) { + if (params.tabs.length === 0) { return 0; } const closeTab = @@ -161,7 +193,7 @@ export async function closeTrackedBrowserTabsForSessions(params: { }); }); let closed = 0; - for (const tab of tabs) { + for (const tab of params.tabs) { try { await closeTab({ targetId: tab.targetId, @@ -178,6 +210,104 @@ export async function closeTrackedBrowserTabsForSessions(params: { return closed; } +export async function closeTrackedBrowserTabsForSessions(params: { + sessionKeys: Array; + closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise; + onWarn?: (message: string) => void; +}): Promise { + return await closeTrackedTabs({ + tabs: takeTrackedTabsForSessionKeys(params.sessionKeys), + closeTab: params.closeTab, + onWarn: params.onWarn, + }); +} + +function takeStaleTrackedTabs(params: { + now: number; + idleMs?: number; + maxTabsPerSession?: number; + sessionFilter?: (sessionKey: string) => boolean; +}): TrackedSessionBrowserTab[] { + const tabsToClose: TrackedSessionBrowserTab[] = []; + const takenIdsBySession = new Map>(); + const mark = (sessionKey: string, trackedId: string, tracked: TrackedSessionBrowserTab): void => { + let takenForSession = takenIdsBySession.get(sessionKey); + if (!takenForSession) { + takenForSession = new Set(); + takenIdsBySession.set(sessionKey, takenForSession); + } + if (takenForSession.has(trackedId)) { + return; + } + takenForSession.add(trackedId); + tabsToClose.push(tracked); + }; + + for (const [sessionKey, trackedForSession] of trackedTabsBySession) { + if (params.sessionFilter && !params.sessionFilter(sessionKey)) { + continue; + } + const entries = [...trackedForSession.entries()].toSorted( + (a, b) => a[1].lastUsedAt - b[1].lastUsedAt || a[1].trackedAt - b[1].trackedAt, + ); + if (params.idleMs && params.idleMs > 0) { + for (const [trackedId, tracked] of entries) { + if (params.now - tracked.lastUsedAt >= params.idleMs) { + mark(sessionKey, trackedId, tracked); + } + } + } + + const remainingEntries = entries.filter( + ([trackedId]) => !takenIdsBySession.get(sessionKey)?.has(trackedId), + ); + if ( + params.maxTabsPerSession && + params.maxTabsPerSession > 0 && + remainingEntries.length > params.maxTabsPerSession + ) { + const excess = remainingEntries.length - params.maxTabsPerSession; + for (const [trackedId, tracked] of remainingEntries.slice(0, excess)) { + mark(sessionKey, trackedId, tracked); + } + } + } + + for (const [sessionKey, trackedIds] of takenIdsBySession) { + const trackedForSession = trackedTabsBySession.get(sessionKey); + if (!trackedForSession) { + continue; + } + for (const trackedId of trackedIds) { + trackedForSession.delete(trackedId); + } + if (trackedForSession.size === 0) { + trackedTabsBySession.delete(sessionKey); + } + } + return tabsToClose; +} + +export async function sweepTrackedBrowserTabs(params: { + now?: number; + idleMs?: number; + maxTabsPerSession?: number; + sessionFilter?: (sessionKey: string) => boolean; + closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise; + onWarn?: (message: string) => void; +}): Promise { + return await closeTrackedTabs({ + tabs: takeStaleTrackedTabs({ + now: params.now ?? Date.now(), + idleMs: params.idleMs, + maxTabsPerSession: params.maxTabsPerSession, + sessionFilter: params.sessionFilter, + }), + closeTab: params.closeTab, + onWarn: params.onWarn, + }); +} + export function __resetTrackedSessionBrowserTabsForTests(): void { trackedTabsBySession.clear(); } diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 7ec821c2391..2c21c28c799 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -101,6 +101,12 @@ function buildSandboxBrowserResolvedConfig(params: { attachOnly: true, defaultProfile: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, extraArgs: [], + tabCleanup: { + enabled: true, + idleMinutes: 120, + maxTabsPerSession: 8, + sweepMinutes: 5, + }, profiles: { [DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]: { cdpPort: params.cdpPort, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index c433621c3eb..d387b2597cc 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -26338,6 +26338,31 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Default snapshot extraction mode controlling how page content is transformed for agent consumption. Choose the mode that balances readability, fidelity, and token footprint for your workflows.", tags: ["advanced"], }, + "browser.tabCleanup": { + label: "Browser Tab Cleanup", + help: "Best-effort cleanup policy for browser tabs opened by primary-agent sessions. Keep enabled to avoid stale sandbox or managed-browser tabs accumulating across long-lived gateways.", + tags: ["advanced"], + }, + "browser.tabCleanup.enabled": { + label: "Browser Tab Cleanup Enabled", + help: "Enables cleanup of idle tracked browser tabs for primary-agent sessions. Disable only when external tooling owns tab lifecycle completely.", + tags: ["advanced"], + }, + "browser.tabCleanup.idleMinutes": { + label: "Browser Tab Cleanup Idle Minutes", + help: "Minutes of inactivity before a tracked primary-agent browser tab is eligible for closure. Set 0 to disable idle-time cleanup while keeping the per-session tab cap.", + tags: ["advanced"], + }, + "browser.tabCleanup.maxTabsPerSession": { + label: "Browser Tab Cleanup Max Tabs Per Session", + help: "Maximum tracked browser tabs kept per primary-agent session. Oldest inactive tabs are closed first. Set 0 to disable the cap.", + tags: ["performance", "storage"], + }, + "browser.tabCleanup.sweepMinutes": { + label: "Browser Tab Cleanup Sweep Minutes", + help: "Minutes between browser tab cleanup sweeps. Keep this modest so idle tabs are reclaimed without adding frequent background work.", + tags: ["advanced"], + }, "browser.ssrfPolicy": { label: "Browser SSRF Policy", help: "Server-side request forgery guardrail settings for browser/network fetch paths that could reach internal hosts. Keep restrictive defaults in production and open only explicitly approved targets.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 115a66e7d1a..465f3400df4 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -294,6 +294,16 @@ export const FIELD_HELP: Record = { "Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.", "browser.snapshotDefaults.mode": "Default snapshot extraction mode controlling how page content is transformed for agent consumption. Choose the mode that balances readability, fidelity, and token footprint for your workflows.", + "browser.tabCleanup": + "Best-effort cleanup policy for browser tabs opened by primary-agent sessions. Keep enabled to avoid stale sandbox or managed-browser tabs accumulating across long-lived gateways.", + "browser.tabCleanup.enabled": + "Enables cleanup of idle tracked browser tabs for primary-agent sessions. Disable only when external tooling owns tab lifecycle completely.", + "browser.tabCleanup.idleMinutes": + "Minutes of inactivity before a tracked primary-agent browser tab is eligible for closure. Set 0 to disable idle-time cleanup while keeping the per-session tab cap.", + "browser.tabCleanup.maxTabsPerSession": + "Maximum tracked browser tabs kept per primary-agent session. Oldest inactive tabs are closed first. Set 0 to disable the cap.", + "browser.tabCleanup.sweepMinutes": + "Minutes between browser tab cleanup sweeps. Keep this modest so idle tabs are reclaimed without adding frequent background work.", "browser.ssrfPolicy": "Server-side request forgery guardrail settings for browser/network fetch paths that could reach internal hosts. Keep restrictive defaults in production and open only explicitly approved targets.", "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index fe3a289bbef..c16dcedc354 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -626,6 +626,11 @@ export const FIELD_LABELS: Record = { "browser.evaluateEnabled": "Browser Evaluate Enabled", "browser.snapshotDefaults": "Browser Snapshot Defaults", "browser.snapshotDefaults.mode": "Browser Snapshot Mode", + "browser.tabCleanup": "Browser Tab Cleanup", + "browser.tabCleanup.enabled": "Browser Tab Cleanup Enabled", + "browser.tabCleanup.idleMinutes": "Browser Tab Cleanup Idle Minutes", + "browser.tabCleanup.maxTabsPerSession": "Browser Tab Cleanup Max Tabs Per Session", + "browser.tabCleanup.sweepMinutes": "Browser Tab Cleanup Sweep Minutes", "browser.ssrfPolicy": "Browser SSRF Policy", "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork": "Browser Dangerously Allow Private Network", "browser.ssrfPolicy.allowedHostnames": "Browser Allowed Hostnames", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index ce1e18708f4..5834cce9b73 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -18,6 +18,16 @@ export type BrowserSnapshotDefaults = { /** Default snapshot mode (applies when mode is not provided). */ mode?: "efficient"; }; +export type BrowserTabCleanupConfig = { + /** Enable best-effort cleanup for tracked primary-agent browser tabs. Default: true */ + enabled?: boolean; + /** Close tracked tabs after this many idle minutes. Set 0 to disable idle cleanup. Default: 120 */ + idleMinutes?: number; + /** Keep at most this many tracked tabs per primary session. Set 0 to disable the cap. Default: 8 */ + maxTabsPerSession?: number; + /** Cleanup sweep interval in minutes. Default: 5 */ + sweepMinutes?: number; +}; export type BrowserSsrFPolicyConfig = { /** If true, permit browser navigation to private/internal networks. Default: true */ dangerouslyAllowPrivateNetwork?: boolean; @@ -60,6 +70,8 @@ export type BrowserConfig = { profiles?: Record; /** Default snapshot options (applied by the browser tool/CLI when unset). */ snapshotDefaults?: BrowserSnapshotDefaults; + /** Best-effort cleanup policy for tabs opened by primary-agent browser sessions. */ + tabCleanup?: BrowserTabCleanupConfig; /** SSRF policy for browser navigation/open-tab operations. */ ssrfPolicy?: BrowserSsrFPolicyConfig; /** diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index bc2283e9152..fc1d82c1c25 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -12,6 +12,13 @@ export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw"; export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000; export const DEFAULT_UPLOAD_DIR = path.join(resolvePreferredOpenClawTmpDir(), "uploads"); +export type ResolvedBrowserTabCleanupConfig = { + enabled: boolean; + idleMinutes: number; + maxTabsPerSession: number; + sweepMinutes: number; +}; + export type ResolvedBrowserConfig = { enabled: boolean; evaluateEnabled: boolean; @@ -30,6 +37,7 @@ export type ResolvedBrowserConfig = { attachOnly: boolean; defaultProfile: string; profiles: Record; + tabCleanup: ResolvedBrowserTabCleanupConfig; ssrfPolicy?: SsrFPolicy; extraArgs: string[]; };