mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
fix: clean up idle browser tabs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ export type BrowserServerState = {
|
||||
port: number;
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profiles: Map<string, ProfileRuntimeState>;
|
||||
stopTrackedTabCleanup?: () => void;
|
||||
};
|
||||
|
||||
type BrowserProfileActions = {
|
||||
|
||||
52
extensions/browser/src/browser/session-tab-cleanup.test.ts
Normal file
52
extensions/browser/src/browser/session-tab-cleanup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
92
extensions/browser/src/browser/session-tab-cleanup.ts
Normal file
92
extensions/browser/src/browser/session-tab-cleanup.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user