Skip to content

Commit 4074e0c

Browse files
FMLSsteipete
andauthored
fix(browser): close tracked tabs after gateway restart (#110797)
* fix(browser): preserve tab cleanup across restarts * fix(browser): disambiguate restart tab aliases * fix(browser): keep untrack selection type private --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 2f7da30 commit 4074e0c

39 files changed

Lines changed: 5181 additions & 502 deletions

docs/docs_map.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9664,6 +9664,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
96649664
- H2: Missing browser command or tool
96659665
- H2: Profiles: openclaw, user, chrome
96669666
- H2: Configuration
9667+
- H3: Tab cleanup ownership
96679668
- H3: Screenshot vision (text-only model support)
96689669
- H2: Use Brave or another Chromium-based browser
96699670
- H2: Local vs remote control

docs/gateway/configuration-reference.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -450,9 +450,24 @@ See [Inferred commitments](/concepts/commitments).
450450
```
451451

452452
- `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`.
453-
- `tabCleanup` reclaims tracked primary-agent tabs after idle time or when a
454-
session exceeds its cap. Set `idleMinutes: 0` or `maxTabsPerSession: 0` to
455-
disable those individual cleanup modes.
453+
- `tabCleanup` controls best-effort periodic cleanup for tracked primary-agent
454+
tabs after idle time or when a session exceeds its cap. Tracking applies only
455+
to tabs created by browser tool `action: "open"`; tabs opened by the user or
456+
with unknown ownership are never adopted. Set `idleMinutes: 0` or
457+
`maxTabsPerSession: 0` to disable those individual cleanup modes. Disabling
458+
`tabCleanup` does not disable explicit session lifecycle cleanup.
459+
- Host-local opens with a stable native CDP target and browser identity are
460+
stored in shared SQLite state and remain eligible across Gateway restarts for
461+
`/new` and session lifecycle cleanup. Native tool-facing CDP targets also
462+
remain eligible for idle and cap cleanup after restart. Chrome MCP uses
463+
process-local target handles, so cold existing-session records wait for
464+
lifecycle cleanup rather than risking an idle sweep against unattributable
465+
post-restart activity. OpenClaw verifies the profile and browser instance
466+
before closing. Chrome MCP auto-connect, missing `/json/version` browser
467+
identity, and unresolved native targets remain fully process-local, so they
468+
are not automatically closed after a restart. Older untracked tabs require
469+
manual closure. Transient failures stay pending for a later retry. See
470+
[Tab cleanup ownership](/tools/browser#tab-cleanup-ownership).
456471
- `ssrfPolicy.dangerouslyAllowPrivateNetwork` is disabled when unset, so browser navigation stays strict by default.
457472
- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: true` only when you intentionally trust private-network browser navigation.
458473
- In strict mode, remote CDP profile endpoints (`profiles.*.cdpUrl`) are subject to the same private-network blocking during reachability/discovery checks.

docs/tools/browser.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,39 @@ extraction mode when a caller does not pass an explicit `snapshotFormat` or
205205
`mode`; see [Browser control API](/tools/browser-control) for per-call
206206
snapshot options.
207207

208+
### Tab cleanup ownership
209+
210+
Session tab cleanup applies only to tabs created by the OpenClaw browser tool
211+
with `action: "open"`. OpenClaw does not adopt tabs that were already open,
212+
opened by the user, or otherwise have unknown ownership. The
213+
`browser.tabCleanup` block controls periodic idle and cap sweeps for primary
214+
sessions; disabling it does not disable explicit session lifecycle cleanup.
215+
216+
For host-local opens, ownership with a stable native CDP target and browser
217+
identity is stored in the shared SQLite state. Those records survive a Gateway
218+
restart and remain eligible for `/new` and other session lifecycle cleanup;
219+
session lifecycle cleanup includes subagent, cron, and ACP session endings.
220+
Records whose tool-facing target is the native CDP target also remain eligible
221+
for idle and per-session cap sweeps after restart. Chrome MCP target handles are
222+
process-local, so cold existing-session records wait for lifecycle cleanup
223+
rather than risking an idle sweep against activity that cannot be attributed
224+
safely after restart. This durable path can cover OpenClaw-managed profiles,
225+
regular remote CDP profiles, and existing-session profiles with an explicit
226+
`cdpUrl`, provided OpenClaw can resolve both the native target and a stable
227+
browser identity. Before closing a durable record, OpenClaw verifies that the
228+
configured profile and browser instance still match.
229+
230+
Chrome MCP `--autoConnect`, CDP endpoints whose `/json/version` response lacks
231+
a stable browser identity, and opens whose native target cannot be resolved
232+
remain process-local best-effort tracking. They can be cleaned up while that
233+
Gateway process is running, but they are not automatically closed after a
234+
Gateway restart. Tabs left open before durable tracking was available are not
235+
retroactively adopted; close those tabs manually.
236+
237+
Cleanup is best-effort, not a guarantee that every eligible tab closes
238+
immediately. A transient ownership check or close failure leaves durable
239+
cleanup pending for a later retry.
240+
208241
### Screenshot vision (text-only model support)
209242

210243
When the main model is text-only (no vision/multimodal support), browser
@@ -284,7 +317,6 @@ main model can read the screenshot directly.
284317
the startup problem, disable the browser if it is not needed, or restart the
285318
Gateway after repair.
286319
- `actionTimeoutMs` is the default budget for browser `act` requests when the caller does not pass `timeoutMs`. The client transport adds a small slack window so long waits can finish instead of timing out at the HTTP boundary.
287-
- `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.
288320

289321
</Accordion>
290322

extensions/browser/index.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,38 @@ function createApi() {
7676
entries: vi.fn(async () => []),
7777
clear: vi.fn(async () => undefined),
7878
}));
79+
const openSyncKeyedStore = vi.fn(() => ({
80+
register: vi.fn(),
81+
registerIfAbsent: vi.fn(() => true),
82+
lookup: vi.fn(() => undefined),
83+
consume: vi.fn(() => undefined),
84+
delete: vi.fn(() => false),
85+
entries: vi.fn(() => []),
86+
clear: vi.fn(),
87+
}));
7988
const api = createTestPluginApi({
8089
id: "browser",
8190
name: "Browser",
8291
source: "test",
8392
rootDir: "/plugins/browser",
8493
config: {},
85-
runtime: { state: { openKeyedStore } } as unknown as OpenClawPluginApi["runtime"],
94+
runtime: {
95+
state: { openKeyedStore, openSyncKeyedStore },
96+
} as unknown as OpenClawPluginApi["runtime"],
8697
registerCli,
8798
registerGatewayMethod,
8899
registerService,
89100
registerTool,
90101
});
91-
return { api, openKeyedStore, registerCli, registerGatewayMethod, registerService, registerTool };
102+
return {
103+
api,
104+
openKeyedStore,
105+
openSyncKeyedStore,
106+
registerCli,
107+
registerGatewayMethod,
108+
registerService,
109+
registerTool,
110+
};
92111
}
93112

94113
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0): unknown {
@@ -126,6 +145,18 @@ describe("browser plugin", () => {
126145
});
127146
});
128147

148+
it("initializes the shared durable session-tab registry without loading browser control", () => {
149+
const { api, openSyncKeyedStore } = createApi();
150+
registerBrowserPlugin(api);
151+
152+
expect(openSyncKeyedStore).toHaveBeenCalledWith({
153+
namespace: "browser.session-tabs",
154+
maxEntries: 5_000,
155+
overflowPolicy: "reject-new",
156+
});
157+
expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled();
158+
});
159+
129160
it("exposes static browser metadata on the plugin definition", () => {
130161
expect(browserPluginReload).toEqual({
131162
restartPrefixes: ["browser"],

extensions/browser/plugin-registration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { parseBrowserTabToolBinding } from "./src/browser-tool-binding.js";
2222
import { describeBrowserTool } from "./src/browser-tool-description.js";
2323
import { BrowserToolSchema } from "./src/browser-tool.schema.js";
24+
import { initializeBrowserSessionTabStore } from "./src/browser/session-tab-store.js";
2425
import {
2526
configureSystemProfileImportStateStore,
2627
type SystemProfileImportState,
@@ -209,6 +210,7 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
209210

210211
/** Register Browser tool factories, CLI, gateway methods, services, and audits. */
211212
export function registerBrowserPlugin(api: OpenClawPluginApi) {
213+
initializeBrowserSessionTabStore(api.runtime);
212214
configureSystemProfileImportStateStore(
213215
api.runtime.state.openKeyedStore<SystemProfileImportState>({
214216
namespace: "browser.system-profile-import",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
2+
// Browser plugin runtime state shared across lazy bundles and duplicate SDK module instances.
3+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
4+
5+
type BrowserStateRuntime = {
6+
sessionTabs: PluginStateSyncKeyedStore<unknown>;
7+
};
8+
9+
const {
10+
setRuntime: setBrowserStateRuntime,
11+
getRuntime: getBrowserStateRuntime,
12+
tryGetRuntime: getOptionalBrowserStateRuntime,
13+
} = createPluginRuntimeStore<BrowserStateRuntime>({
14+
pluginId: "browser",
15+
errorMessage: "Browser state runtime not initialized",
16+
});
17+
18+
export { getBrowserStateRuntime, getOptionalBrowserStateRuntime, setBrowserStateRuntime };
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Session tracking for tabs created through the browser tool.
3+
*/
4+
import type { BrowserTabOwnership } from "./browser/client.types.js";
5+
6+
type SessionTabParams = {
7+
sessionKey?: string;
8+
targetId?: string;
9+
baseUrl?: string;
10+
profile?: string;
11+
profileAliases?: Array<string | undefined>;
12+
ownership?: BrowserTabOwnership;
13+
aliases?: Array<string | undefined>;
14+
};
15+
16+
type SessionTabRegistry = {
17+
trackSessionBrowserTab: (params: SessionTabParams) => void;
18+
touchSessionBrowserTab: (params: SessionTabParams) => void;
19+
untrackSessionBrowserTab: (params: SessionTabParams) => void;
20+
};
21+
22+
function readString(value: unknown): string | undefined {
23+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
24+
}
25+
26+
function readOpenedTab(result: unknown): {
27+
targetId?: string;
28+
aliases: string[];
29+
profile?: string;
30+
ownership?: BrowserTabOwnership;
31+
} {
32+
if (!result || typeof result !== "object" || Array.isArray(result)) {
33+
return { aliases: [] };
34+
}
35+
const opened = result as Record<string, unknown>;
36+
const targetId = readString(opened.targetId);
37+
const aliases = [
38+
targetId,
39+
readString(opened.tabId),
40+
readString(opened.label),
41+
readString(opened.suggestedTargetId),
42+
].filter((alias): alias is string => Boolean(alias));
43+
const profile = readString(opened.resolvedProfile);
44+
const rawOwnership =
45+
opened.ownership && typeof opened.ownership === "object"
46+
? (opened.ownership as BrowserTabOwnership)
47+
: undefined;
48+
// Older browser hosts do not return resolvedProfile. Their durable fingerprint
49+
// cannot prove which configured profile owns the tab, so keep that tab volatile.
50+
const ownership = rawOwnership?.status === "durable" && !profile ? undefined : rawOwnership;
51+
return { targetId, aliases: [...new Set(aliases)], profile, ownership };
52+
}
53+
54+
export function stripBrowserOpenInternalMetadata(value: unknown): unknown {
55+
if (!value || typeof value !== "object" || Array.isArray(value)) {
56+
return value;
57+
}
58+
const {
59+
ownership: _ownership,
60+
resolvedProfile: _resolvedProfile,
61+
...agentVisible
62+
} = value as Record<string, unknown>;
63+
return agentVisible;
64+
}
65+
66+
async function trackOpenedBrowserTab(params: {
67+
result: unknown;
68+
sessionKey?: string;
69+
fallbackProfile?: string;
70+
baseUrl?: string;
71+
track: SessionTabRegistry["trackSessionBrowserTab"];
72+
closeTab: (targetId: string, profile?: string) => Promise<void>;
73+
}): Promise<void> {
74+
const opened = readOpenedTab(params.result);
75+
const profile = opened.profile ?? params.fallbackProfile;
76+
try {
77+
params.track({
78+
sessionKey: params.sessionKey,
79+
targetId: opened.targetId,
80+
baseUrl: params.baseUrl,
81+
profile,
82+
...(params.fallbackProfile && opened.profile && opened.profile !== params.fallbackProfile
83+
? { profileAliases: [params.fallbackProfile] }
84+
: {}),
85+
// Sandbox/browser-bridge tabs belong to a different browser process.
86+
// Keep them process-local even if that server returned durable metadata.
87+
ownership: params.baseUrl ? undefined : opened.ownership,
88+
aliases: opened.aliases,
89+
});
90+
} catch (trackingError) {
91+
if (!opened.targetId) {
92+
throw trackingError;
93+
}
94+
try {
95+
await params.closeTab(opened.targetId, profile);
96+
} catch (closeError) {
97+
throw Object.assign(
98+
new Error("Failed to register browser tab cleanup and close the newly opened tab", {
99+
cause: closeError,
100+
}),
101+
{
102+
name: "BrowserTabTrackingCompensationError",
103+
errors: [trackingError, closeError],
104+
},
105+
);
106+
}
107+
throw trackingError;
108+
}
109+
}
110+
111+
export function createBrowserToolSessionTabs(params: {
112+
sessionKey?: string;
113+
requestedProfile?: string;
114+
defaultProfile: string;
115+
baseUrl?: string;
116+
isHostFallbackActive?: () => boolean;
117+
registry: SessionTabRegistry;
118+
}) {
119+
const profile = params.requestedProfile ?? params.defaultProfile;
120+
const isTrackedRoute = () => !params.isHostFallbackActive || params.isHostFallbackActive();
121+
const trackedBaseUrl = () => (params.isHostFallbackActive ? undefined : params.baseUrl);
122+
const trackedProfile = () => (trackedBaseUrl() && !params.requestedProfile ? undefined : profile);
123+
const identity = (targetId: string) => ({
124+
sessionKey: params.sessionKey,
125+
targetId,
126+
baseUrl: trackedBaseUrl(),
127+
profile: trackedProfile(),
128+
});
129+
return {
130+
touch: (targetId: string | undefined): void => {
131+
if (targetId && isTrackedRoute()) {
132+
params.registry.touchSessionBrowserTab(identity(targetId));
133+
}
134+
},
135+
untrack: (targetId: string | undefined): void => {
136+
if (targetId && isTrackedRoute()) {
137+
params.registry.untrackSessionBrowserTab(identity(targetId));
138+
}
139+
},
140+
trackOpened: async (
141+
result: unknown,
142+
closeTab: (targetId: string, openedProfile?: string) => Promise<void>,
143+
): Promise<void> => {
144+
if (!isTrackedRoute()) {
145+
return;
146+
}
147+
const baseUrl = trackedBaseUrl();
148+
await trackOpenedBrowserTab({
149+
result,
150+
sessionKey: params.sessionKey,
151+
fallbackProfile: baseUrl && !params.requestedProfile ? undefined : profile,
152+
baseUrl,
153+
track: params.registry.trackSessionBrowserTab,
154+
closeTab,
155+
});
156+
},
157+
};
158+
}

0 commit comments

Comments
 (0)