fix: clean up idle browser tabs

This commit is contained in:
Peter Steinberger
2026-04-25 03:07:04 +01:00
parent d99d9eda37
commit ae5c657367
26 changed files with 669 additions and 55 deletions

View File

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

View File

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

View File

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

View File

@@ -232,6 +232,7 @@ export async function executeSnapshotAction(params: {
baseUrl?: string;
profile?: string;
proxyRequest: BrowserProxyRequest | null;
onTabActivity?: (targetId: string | undefined) => void;
}): Promise<AgentToolResult<unknown>> {
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<AgentToolResult<unknown>> {
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.

View File

@@ -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";

View File

@@ -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", {

View File

@@ -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<ReturnType<typeof browserPdfSave>>)
: 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:

View File

@@ -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" },

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

@@ -26,31 +26,41 @@ export function makeBrowserServerState(params?: {
resolvedOverrides?: Partial<BrowserServerState["resolved"]>;
}): 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(),
};

View File

@@ -29,6 +29,7 @@ export type BrowserServerState = {
port: number;
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;
stopTrackedTabCleanup?: () => void;
};
type BrowserProfileActions = {

View File

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

View File

@@ -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<void>;
onWarn?: (message: string) => void;
}): Promise<number> {
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<unknown> | 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;
}
};
}

View File

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

View File

@@ -10,6 +10,7 @@ export type TrackedSessionBrowserTab = {
baseUrl?: string;
profile?: string;
trackedAt: number;
lastUsedAt: number;
};
const trackedTabsBySession = new Map<string, Map<string, TrackedSessionBrowserTab>>();
@@ -43,7 +44,7 @@ function resolveTrackedTabIdentity(params: {
targetId?: string;
baseUrl?: string;
profile?: string;
}): Omit<TrackedSessionBrowserTab, "trackedAt"> | undefined {
}): Omit<TrackedSessionBrowserTab, "trackedAt" | "lastUsedAt"> | 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<string | undefined>;
async function closeTrackedTabs(params: {
tabs: TrackedSessionBrowserTab[];
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
onWarn?: (message: string) => void;
}): Promise<number> {
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<string | undefined>;
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
onWarn?: (message: string) => void;
}): Promise<number> {
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<string, Set<string>>();
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<void>;
onWarn?: (message: string) => void;
}): Promise<number> {
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();
}

View File

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

View File

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

View File

@@ -294,6 +294,16 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -626,6 +626,11 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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<string, BrowserProfileConfig>;
/** 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;
/**

View File

@@ -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<string, BrowserProfileConfig>;
tabCleanup: ResolvedBrowserTabCleanupConfig;
ssrfPolicy?: SsrFPolicy;
extraArgs: string[];
};