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 <steipete@gmail.com>
This commit is contained in:
FMLS
2026-07-18 22:24:20 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 2f7da3057c
commit 4074e0cae1
39 changed files with 5221 additions and 542 deletions
+1
View File
@@ -9664,6 +9664,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Missing browser command or tool
- H2: Profiles: openclaw, user, chrome
- H2: Configuration
- H3: Tab cleanup ownership
- H3: Screenshot vision (text-only model support)
- H2: Use Brave or another Chromium-based browser
- H2: Local vs remote control
+18 -3
View File
@@ -450,9 +450,24 @@ See [Inferred commitments](/concepts/commitments).
```
- `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.
- `tabCleanup` controls best-effort periodic cleanup for tracked primary-agent
tabs after idle time or when a session exceeds its cap. Tracking applies only
to tabs created by browser tool `action: "open"`; tabs opened by the user or
with unknown ownership are never adopted. Set `idleMinutes: 0` or
`maxTabsPerSession: 0` to disable those individual cleanup modes. Disabling
`tabCleanup` does not disable explicit session lifecycle cleanup.
- Host-local opens with a stable native CDP target and browser identity are
stored in shared SQLite state and remain eligible across Gateway restarts for
`/new` and session lifecycle cleanup. Native tool-facing CDP targets also
remain eligible for idle and cap cleanup after restart. Chrome MCP uses
process-local target handles, so cold existing-session records wait for
lifecycle cleanup rather than risking an idle sweep against unattributable
post-restart activity. OpenClaw verifies the profile and browser instance
before closing. Chrome MCP auto-connect, missing `/json/version` browser
identity, and unresolved native targets remain fully process-local, so they
are not automatically closed after a restart. Older untracked tabs require
manual closure. Transient failures stay pending for a later retry. See
[Tab cleanup ownership](/tools/browser#tab-cleanup-ownership).
- `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.
+33 -1
View File
@@ -205,6 +205,39 @@ extraction mode when a caller does not pass an explicit `snapshotFormat` or
`mode`; see [Browser control API](/tools/browser-control) for per-call
snapshot options.
### Tab cleanup ownership
Session tab cleanup applies only to tabs created by the OpenClaw browser tool
with `action: "open"`. OpenClaw does not adopt tabs that were already open,
opened by the user, or otherwise have unknown ownership. The
`browser.tabCleanup` block controls periodic idle and cap sweeps for primary
sessions; disabling it does not disable explicit session lifecycle cleanup.
For host-local opens, ownership with a stable native CDP target and browser
identity is stored in the shared SQLite state. Those records survive a Gateway
restart and remain eligible for `/new` and other session lifecycle cleanup;
session lifecycle cleanup includes subagent, cron, and ACP session endings.
Records whose tool-facing target is the native CDP target also remain eligible
for idle and per-session cap sweeps after restart. Chrome MCP target handles are
process-local, so cold existing-session records wait for lifecycle cleanup
rather than risking an idle sweep against activity that cannot be attributed
safely after restart. This durable path can cover OpenClaw-managed profiles,
regular remote CDP profiles, and existing-session profiles with an explicit
`cdpUrl`, provided OpenClaw can resolve both the native target and a stable
browser identity. Before closing a durable record, OpenClaw verifies that the
configured profile and browser instance still match.
Chrome MCP `--autoConnect`, CDP endpoints whose `/json/version` response lacks
a stable browser identity, and opens whose native target cannot be resolved
remain process-local best-effort tracking. They can be cleaned up while that
Gateway process is running, but they are not automatically closed after a
Gateway restart. Tabs left open before durable tracking was available are not
retroactively adopted; close those tabs manually.
Cleanup is best-effort, not a guarantee that every eligible tab closes
immediately. A transient ownership check or close failure leaves durable
cleanup pending for a later retry.
### Screenshot vision (text-only model support)
When the main model is text-only (no vision/multimodal support), browser
@@ -284,7 +317,6 @@ main model can read the screenshot directly.
the startup problem, disable the browser if it is not needed, or restart the
Gateway after repair.
- `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.
- `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>
+33 -2
View File
@@ -76,19 +76,38 @@ function createApi() {
entries: vi.fn(async () => []),
clear: vi.fn(async () => undefined),
}));
const openSyncKeyedStore = vi.fn(() => ({
register: vi.fn(),
registerIfAbsent: vi.fn(() => true),
lookup: vi.fn(() => undefined),
consume: vi.fn(() => undefined),
delete: vi.fn(() => false),
entries: vi.fn(() => []),
clear: vi.fn(),
}));
const api = createTestPluginApi({
id: "browser",
name: "Browser",
source: "test",
rootDir: "/plugins/browser",
config: {},
runtime: { state: { openKeyedStore } } as unknown as OpenClawPluginApi["runtime"],
runtime: {
state: { openKeyedStore, openSyncKeyedStore },
} as unknown as OpenClawPluginApi["runtime"],
registerCli,
registerGatewayMethod,
registerService,
registerTool,
});
return { api, openKeyedStore, registerCli, registerGatewayMethod, registerService, registerTool };
return {
api,
openKeyedStore,
openSyncKeyedStore,
registerCli,
registerGatewayMethod,
registerService,
registerTool,
};
}
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0): unknown {
@@ -126,6 +145,18 @@ describe("browser plugin", () => {
});
});
it("initializes the shared durable session-tab registry without loading browser control", () => {
const { api, openSyncKeyedStore } = createApi();
registerBrowserPlugin(api);
expect(openSyncKeyedStore).toHaveBeenCalledWith({
namespace: "browser.session-tabs",
maxEntries: 5_000,
overflowPolicy: "reject-new",
});
expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled();
});
it("exposes static browser metadata on the plugin definition", () => {
expect(browserPluginReload).toEqual({
restartPrefixes: ["browser"],
@@ -21,6 +21,7 @@ import {
import { parseBrowserTabToolBinding } from "./src/browser-tool-binding.js";
import { describeBrowserTool } from "./src/browser-tool-description.js";
import { BrowserToolSchema } from "./src/browser-tool.schema.js";
import { initializeBrowserSessionTabStore } from "./src/browser/session-tab-store.js";
import {
configureSystemProfileImportStateStore,
type SystemProfileImportState,
@@ -209,6 +210,7 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
/** Register Browser tool factories, CLI, gateway methods, services, and audits. */
export function registerBrowserPlugin(api: OpenClawPluginApi) {
initializeBrowserSessionTabStore(api.runtime);
configureSystemProfileImportStateStore(
api.runtime.state.openKeyedStore<SystemProfileImportState>({
namespace: "browser.system-profile-import",
@@ -0,0 +1,18 @@
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
// Browser plugin runtime state shared across lazy bundles and duplicate SDK module instances.
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
type BrowserStateRuntime = {
sessionTabs: PluginStateSyncKeyedStore<unknown>;
};
const {
setRuntime: setBrowserStateRuntime,
getRuntime: getBrowserStateRuntime,
tryGetRuntime: getOptionalBrowserStateRuntime,
} = createPluginRuntimeStore<BrowserStateRuntime>({
pluginId: "browser",
errorMessage: "Browser state runtime not initialized",
});
export { getBrowserStateRuntime, getOptionalBrowserStateRuntime, setBrowserStateRuntime };
@@ -0,0 +1,158 @@
/**
* Session tracking for tabs created through the browser tool.
*/
import type { BrowserTabOwnership } from "./browser/client.types.js";
type SessionTabParams = {
sessionKey?: string;
targetId?: string;
baseUrl?: string;
profile?: string;
profileAliases?: Array<string | undefined>;
ownership?: BrowserTabOwnership;
aliases?: Array<string | undefined>;
};
type SessionTabRegistry = {
trackSessionBrowserTab: (params: SessionTabParams) => void;
touchSessionBrowserTab: (params: SessionTabParams) => void;
untrackSessionBrowserTab: (params: SessionTabParams) => void;
};
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readOpenedTab(result: unknown): {
targetId?: string;
aliases: string[];
profile?: string;
ownership?: BrowserTabOwnership;
} {
if (!result || typeof result !== "object" || Array.isArray(result)) {
return { aliases: [] };
}
const opened = result as Record<string, unknown>;
const targetId = readString(opened.targetId);
const aliases = [
targetId,
readString(opened.tabId),
readString(opened.label),
readString(opened.suggestedTargetId),
].filter((alias): alias is string => Boolean(alias));
const profile = readString(opened.resolvedProfile);
const rawOwnership =
opened.ownership && typeof opened.ownership === "object"
? (opened.ownership as BrowserTabOwnership)
: undefined;
// Older browser hosts do not return resolvedProfile. Their durable fingerprint
// cannot prove which configured profile owns the tab, so keep that tab volatile.
const ownership = rawOwnership?.status === "durable" && !profile ? undefined : rawOwnership;
return { targetId, aliases: [...new Set(aliases)], profile, ownership };
}
export function stripBrowserOpenInternalMetadata(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const {
ownership: _ownership,
resolvedProfile: _resolvedProfile,
...agentVisible
} = value as Record<string, unknown>;
return agentVisible;
}
async function trackOpenedBrowserTab(params: {
result: unknown;
sessionKey?: string;
fallbackProfile?: string;
baseUrl?: string;
track: SessionTabRegistry["trackSessionBrowserTab"];
closeTab: (targetId: string, profile?: string) => Promise<void>;
}): Promise<void> {
const opened = readOpenedTab(params.result);
const profile = opened.profile ?? params.fallbackProfile;
try {
params.track({
sessionKey: params.sessionKey,
targetId: opened.targetId,
baseUrl: params.baseUrl,
profile,
...(params.fallbackProfile && opened.profile && opened.profile !== params.fallbackProfile
? { profileAliases: [params.fallbackProfile] }
: {}),
// Sandbox/browser-bridge tabs belong to a different browser process.
// Keep them process-local even if that server returned durable metadata.
ownership: params.baseUrl ? undefined : opened.ownership,
aliases: opened.aliases,
});
} catch (trackingError) {
if (!opened.targetId) {
throw trackingError;
}
try {
await params.closeTab(opened.targetId, profile);
} catch (closeError) {
throw Object.assign(
new Error("Failed to register browser tab cleanup and close the newly opened tab", {
cause: closeError,
}),
{
name: "BrowserTabTrackingCompensationError",
errors: [trackingError, closeError],
},
);
}
throw trackingError;
}
}
export function createBrowserToolSessionTabs(params: {
sessionKey?: string;
requestedProfile?: string;
defaultProfile: string;
baseUrl?: string;
isHostFallbackActive?: () => boolean;
registry: SessionTabRegistry;
}) {
const profile = params.requestedProfile ?? params.defaultProfile;
const isTrackedRoute = () => !params.isHostFallbackActive || params.isHostFallbackActive();
const trackedBaseUrl = () => (params.isHostFallbackActive ? undefined : params.baseUrl);
const trackedProfile = () => (trackedBaseUrl() && !params.requestedProfile ? undefined : profile);
const identity = (targetId: string) => ({
sessionKey: params.sessionKey,
targetId,
baseUrl: trackedBaseUrl(),
profile: trackedProfile(),
});
return {
touch: (targetId: string | undefined): void => {
if (targetId && isTrackedRoute()) {
params.registry.touchSessionBrowserTab(identity(targetId));
}
},
untrack: (targetId: string | undefined): void => {
if (targetId && isTrackedRoute()) {
params.registry.untrackSessionBrowserTab(identity(targetId));
}
},
trackOpened: async (
result: unknown,
closeTab: (targetId: string, openedProfile?: string) => Promise<void>,
): Promise<void> => {
if (!isTrackedRoute()) {
return;
}
const baseUrl = trackedBaseUrl();
await trackOpenedBrowserTab({
result,
sessionKey: params.sessionKey,
fallbackProfile: baseUrl && !params.requestedProfile ? undefined : profile,
baseUrl,
track: params.registry.trackSessionBrowserTab,
closeTab,
});
},
};
}
+421 -13
View File
@@ -1038,18 +1038,80 @@ describe("browser tool snapshot maxChars", () => {
);
toolCommonMocks.fetchBrowserJson.mockResolvedValueOnce({
targetId: "host-tab-opened",
tabId: "t7",
label: "docs",
suggestedTargetId: "docs",
resolvedProfile: "host-actual",
url: "https://example.com",
ownership: {
status: "durable",
nativeTargetId: "HOST-NATIVE-7",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await tool.execute?.("call-1", { action: "open", url: "https://example.com" });
const result = await tool.execute?.("call-1", {
action: "open",
url: "https://example.com",
});
expect(sessionTabRegistryMocks.trackSessionBrowserTab).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
targetId: "host-tab-opened",
baseUrl: undefined,
profile: undefined,
profile: "host-actual",
profileAliases: ["openclaw"],
ownership: {
status: "durable",
nativeTargetId: "HOST-NATIVE-7",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
aliases: ["host-tab-opened", "t7", "docs"],
});
expect(result?.details).not.toHaveProperty("ownership");
expect(result?.details).not.toHaveProperty("resolvedProfile");
});
it("compensates durable tracking failure on the automatic host fallback", async () => {
const trackingError = new Error("sqlite unavailable");
mockSingleBrowserProxyNode();
gatewayMocks.callGatewayTool.mockRejectedValueOnce(
new Error("Browser control host is not reachable on 127.0.0.1:18791."),
);
toolCommonMocks.fetchBrowserJson.mockResolvedValueOnce({
targetId: "host-tab-compensate",
resolvedProfile: "work-actual",
url: "https://example.com",
ownership: {
status: "durable",
nativeTargetId: "HOST-NATIVE-COMPENSATE",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
});
sessionTabRegistryMocks.trackSessionBrowserTab.mockImplementationOnce(() => {
throw trackingError;
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await expect(
tool.execute?.("call-1", {
action: "open",
profile: "work",
url: "https://example.com",
}),
).rejects.toBe(trackingError);
expect(toolCommonMocks.fetchBrowserJson).toHaveBeenLastCalledWith(
"/tabs/host-tab-compensate?profile=work-actual",
{
method: "DELETE",
body: undefined,
timeoutMs: undefined,
},
);
});
it("touches tabs used after automatic host fallback", async () => {
@@ -1073,7 +1135,7 @@ describe("browser tool snapshot maxChars", () => {
sessionKey: "agent:main:main",
targetId: "host-tab-used",
baseUrl: undefined,
profile: undefined,
profile: "openclaw",
});
});
@@ -1091,7 +1153,7 @@ describe("browser tool snapshot maxChars", () => {
sessionKey: "agent:main:main",
targetId: "host-tab-closed",
baseUrl: undefined,
profile: undefined,
profile: "openclaw",
});
});
@@ -1733,8 +1795,18 @@ describe("browser tool url alias support", () => {
it("tracks opened tabs when session context is available", async () => {
browserClientMocks.browserOpenTab.mockResolvedValueOnce({
targetId: "tab-123",
tabId: "t1",
label: "example",
suggestedTargetId: "example",
resolvedProfile: "hot-profile",
title: "Example",
url: "https://example.com",
ownership: {
status: "durable",
nativeTargetId: "NATIVE-123",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await tool.execute?.("call-1", { action: "open", url: "https://example.com" });
@@ -1743,29 +1815,348 @@ describe("browser tool url alias support", () => {
sessionKey: "agent:main:main",
targetId: "tab-123",
baseUrl: undefined,
profile: undefined,
profile: "hot-profile",
profileAliases: ["openclaw"],
ownership: {
status: "durable",
nativeTargetId: "NATIVE-123",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
aliases: ["tab-123", "t1", "example"],
});
});
it("keeps non-durable host opens on best-effort process tracking", async () => {
browserClientMocks.browserOpenTab.mockResolvedValueOnce({
targetId: "tab-volatile",
title: "Example",
url: "https://example.com",
ownership: {
status: "non-durable",
reason: "browser-identity-lookup-failed",
},
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await expect(
tool.execute?.("call-1", { action: "open", url: "https://example.com" }),
).resolves.toBeDefined();
expect(sessionTabRegistryMocks.trackSessionBrowserTab).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
targetId: "tab-volatile",
profile: "openclaw",
ownership: {
status: "non-durable",
reason: "browser-identity-lookup-failed",
},
}),
);
expect(browserClientMocks.browserCloseTab).not.toHaveBeenCalled();
});
it("closes a newly opened non-durable tab when process tracking fails", async () => {
const trackingError = new Error("tracking unavailable");
browserClientMocks.browserOpenTab.mockResolvedValueOnce({
targetId: "tab-volatile-compensate",
resolvedProfile: "work-actual",
title: "Example",
url: "https://example.com",
ownership: {
status: "non-durable",
reason: "browser-identity-lookup-failed",
},
});
sessionTabRegistryMocks.trackSessionBrowserTab.mockImplementationOnce(() => {
throw trackingError;
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await expect(
tool.execute?.("call-1", {
action: "open",
profile: "work",
url: "https://example.com",
}),
).rejects.toBe(trackingError);
expect(browserClientMocks.browserCloseTab).toHaveBeenCalledWith(
undefined,
"tab-volatile-compensate",
{
profile: "work-actual",
timeoutMs: undefined,
},
);
});
it("does not persist durable ownership from a legacy open result without resolved profile", async () => {
browserClientMocks.browserOpenTab.mockResolvedValueOnce({
targetId: "legacy-tab",
title: "Legacy",
url: "https://example.com",
ownership: {
status: "durable",
nativeTargetId: "LEGACY-NATIVE",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await tool.execute?.("call-1", { action: "open", url: "https://example.com" });
expect(sessionTabRegistryMocks.trackSessionBrowserTab).toHaveBeenCalledWith(
expect.objectContaining({
targetId: "legacy-tab",
profile: "openclaw",
ownership: undefined,
}),
);
});
it("closes a newly opened durable tab when synchronous tracking fails", async () => {
const trackingError = new Error("sqlite unavailable");
browserClientMocks.browserOpenTab.mockResolvedValueOnce({
targetId: "tab-compensate",
resolvedProfile: "work-actual",
title: "Example",
url: "https://example.com",
ownership: {
status: "durable",
nativeTargetId: "NATIVE-COMPENSATE",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
});
sessionTabRegistryMocks.trackSessionBrowserTab.mockImplementationOnce(() => {
throw trackingError;
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await expect(
tool.execute?.("call-1", {
action: "open",
profile: "work",
url: "https://example.com",
}),
).rejects.toBe(trackingError);
expect(browserClientMocks.browserCloseTab).toHaveBeenCalledWith(undefined, "tab-compensate", {
profile: "work-actual",
timeoutMs: undefined,
});
});
it("preserves tracking and compensation failures when durable rollback fails", async () => {
const trackingError = new Error("sqlite unavailable");
const closeError = new Error("close failed");
browserClientMocks.browserOpenTab.mockResolvedValueOnce({
targetId: "tab-leaked",
resolvedProfile: "openclaw",
title: "Example",
url: "https://example.com",
ownership: {
status: "durable",
nativeTargetId: "NATIVE-LEAKED",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
});
sessionTabRegistryMocks.trackSessionBrowserTab.mockImplementationOnce(() => {
throw trackingError;
});
browserClientMocks.browserCloseTab.mockRejectedValueOnce(closeError);
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
try {
const error = await tool
.execute?.("call-1", { action: "open", url: "https://example.com" })
.then(
() => new Error("open unexpectedly succeeded"),
(cause: unknown) => cause,
);
expect(error).toMatchObject({
name: "BrowserTabTrackingCompensationError",
message: "Failed to register browser tab cleanup and close the newly opened tab",
});
const errors = (error as Error & { errors: unknown[] }).errors;
expect(errors[0]).toBe(trackingError);
expect(errors[1]).toBe(closeError);
expect((error as Error & { cause?: unknown }).cause).toBe(closeError);
} finally {
browserClientMocks.browserCloseTab.mockReset().mockResolvedValue({});
}
});
it("keeps legacy sandbox opens process-local without inventing a host profile", async () => {
browserClientMocks.browserOpenTab.mockResolvedValueOnce({
targetId: "sandbox-tab",
title: "Sandbox",
url: "https://example.com",
ownership: {
status: "durable",
nativeTargetId: "SANDBOX-NATIVE",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
});
const tool = createBrowserTool({
agentSessionKey: "agent:main:main",
sandboxBridgeUrl: "http://127.0.0.1:9999",
});
const result = await tool.execute?.("call-1", {
action: "open",
target: "sandbox",
url: "https://example.com",
});
expect(sessionTabRegistryMocks.trackSessionBrowserTab).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
targetId: "sandbox-tab",
baseUrl: "http://127.0.0.1:9999",
profile: undefined,
ownership: undefined,
}),
);
expect(browserClientMocks.browserCloseTab).not.toHaveBeenCalled();
expect(result?.details).not.toHaveProperty("ownership");
});
it("keeps internal ownership metadata out of the agent-visible open result", async () => {
browserClientMocks.browserOpenTab.mockResolvedValueOnce({
targetId: "tab-123",
title: "Example",
url: "https://example.com",
type: "page",
resolvedProfile: "actual-profile",
ownership: {
status: "durable",
nativeTargetId: "NATIVE-123",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute?.("call-1", {
action: "open",
url: "https://example.com",
});
expect(result?.details).toEqual({
targetId: "tab-123",
title: "Example",
url: "https://example.com",
type: "page",
});
});
it("keeps node-proxy ownership metadata out of the agent-visible open result", async () => {
mockSingleBrowserProxyNode();
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
ok: true,
payload: {
result: {
targetId: "node-tab-123",
title: "Node Example",
url: "https://example.com",
type: "page",
resolvedProfile: "node-actual",
ownership: {
status: "durable",
nativeTargetId: "NODE-NATIVE-123",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
},
},
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute?.("call-1", {
action: "open",
target: "node",
url: "https://example.com",
});
expect(result?.details).toEqual({
targetId: "node-tab-123",
title: "Node Example",
url: "https://example.com",
type: "page",
});
expect(sessionTabRegistryMocks.trackSessionBrowserTab).not.toHaveBeenCalled();
});
it("touches tracked tabs for direct tab activity", async () => {
browserClientMocks.browserSnapshot.mockResolvedValueOnce({
ok: true,
format: "ai",
targetId: "tab-live",
targetId: "RAW-LIVE",
url: "https://example.com",
snapshot: "ok",
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await tool.execute?.("call-1", {
action: "snapshot",
targetId: "tab-live",
targetId: "docs",
});
expect(sessionTabRegistryMocks.touchSessionBrowserTab).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
targetId: "tab-live",
targetId: "RAW-LIVE",
baseUrl: undefined,
profile: undefined,
profile: "openclaw",
});
});
it("prefers the canonical console result target when touching an input alias", async () => {
browserActionsMocks.browserConsoleMessages.mockResolvedValueOnce({
ok: true,
targetId: "RAW-CONSOLE",
messages: [],
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await tool.execute?.("call-1", {
action: "console",
targetId: "docs",
});
expect(sessionTabRegistryMocks.touchSessionBrowserTab).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
targetId: "RAW-CONSOLE",
baseUrl: undefined,
profile: "openclaw",
});
});
it("touches the canonical dialog target after automatic host fallback", async () => {
mockSingleBrowserProxyNode();
gatewayMocks.callGatewayTool.mockRejectedValueOnce(
new Error("Browser control host is not reachable on 127.0.0.1:18791."),
);
toolCommonMocks.fetchBrowserJson.mockResolvedValueOnce({
ok: true,
targetId: "RAW-DIALOG",
});
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await tool.execute?.("call-1", {
action: "dialog",
accept: true,
targetId: "docs",
});
expect(sessionTabRegistryMocks.touchSessionBrowserTab).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
targetId: "RAW-DIALOG",
baseUrl: undefined,
profile: "openclaw",
});
});
@@ -1818,20 +2209,37 @@ describe("browser tool url alias support", () => {
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await tool.execute?.("call-1", {
action: "close",
targetId: "tab-xyz",
targetId: "docs",
});
const targetId = lastMockCallArg<string>(browserClientMocks.browserCloseTab, 1);
const opts = lastMockCallArg<{ profile?: string }>(browserClientMocks.browserCloseTab, 2);
expect(targetId).toBe("tab-xyz");
expect(targetId).toBe("docs");
expect(opts.profile).toBeUndefined();
expect(sessionTabRegistryMocks.untrackSessionBrowserTab).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
targetId: "tab-xyz",
targetId: "docs",
baseUrl: undefined,
profile: undefined,
profile: "openclaw",
});
});
it("never creates tracking records from tab listing or focus", async () => {
browserClientMocks.browserTabs.mockResolvedValueOnce([
{
targetId: "USER-TAB",
tabId: "t1",
title: "User tab",
url: "https://example.com",
},
]);
const tool = createBrowserTool({ agentSessionKey: "agent:main:main" });
await tool.execute?.("call-1", { action: "tabs", target: "host" });
await tool.execute?.("call-2", { action: "focus", target: "host", targetId: "t1" });
expect(sessionTabRegistryMocks.trackSessionBrowserTab).not.toHaveBeenCalled();
});
});
describe("browser tool act compatibility", () => {
+65 -57
View File
@@ -7,6 +7,10 @@
import { createBrowserNodeProxyRequest } from "./browser-node-proxy.js";
import { applyBrowserTabToolBinding, parseBrowserTabToolBinding } from "./browser-tool-binding.js";
import { describeBrowserTool } from "./browser-tool-description.js";
import {
createBrowserToolSessionTabs,
stripBrowserOpenInternalMetadata,
} from "./browser-tool-session-tabs.js";
import {
executeActAction,
executeConsoleAction,
@@ -430,9 +434,9 @@ export function createBrowserTool(opts?: {
const requestedNode = readStringParam(params, "node");
const requestedTimeoutMs = readToolTimeoutMs(params);
let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined;
const configuredNode = browserToolDeps
.getRuntimeConfig()
.gateway?.nodes?.browser?.node?.trim();
const runtimeConfig = browserToolDeps.getRuntimeConfig();
const resolvedBrowser = resolveBrowserConfig(runtimeConfig.browser, runtimeConfig);
const configuredNode = runtimeConfig.gateway?.nodes?.browser?.node?.trim();
if (requestedNode && target && target !== "node") {
throw new Error('node is only supported with target="node".');
@@ -503,17 +507,14 @@ export function createBrowserTool(opts?: {
(usesExistingSessionManageFlow({ action, profileName: profile })
? DEFAULT_EXISTING_SESSION_MANAGE_TIMEOUT_MS
: undefined);
const touchTrackedTab = (targetId: string | undefined) => {
if (!targetId || (proxyRequest && !proxyRequest.isHostFallbackActive())) {
return;
}
browserToolDeps.touchSessionBrowserTab({
sessionKey: opts?.agentSessionKey,
targetId,
baseUrl,
profile,
});
};
const sessionTabs = createBrowserToolSessionTabs({
sessionKey: opts?.agentSessionKey,
requestedProfile: profile,
defaultProfile: resolvedBrowser.defaultProfile,
baseUrl,
isHostFallbackActive: proxyRequest?.isHostFallbackActive,
registry: browserToolDeps,
});
switch (action) {
case "doctor":
@@ -641,28 +642,30 @@ export function createBrowserTool(opts?: {
body: { url: targetUrl, ...(label ? { label } : {}) },
timeoutMs: toolTimeoutMs,
});
if (proxyRequest.isHostFallbackActive()) {
browserToolDeps.trackSessionBrowserTab({
sessionKey: opts?.agentSessionKey,
targetId: readStringValue((result as { targetId?: unknown }).targetId),
baseUrl,
profile,
const closeOpenedTab = async (targetId: string, openedProfile?: string) => {
await proxyRequest({
method: "DELETE",
path: `/tabs/${encodeURIComponent(targetId)}`,
profile: openedProfile,
timeoutMs: toolTimeoutMs,
});
}
return jsonResult(result);
};
await sessionTabs.trackOpened(result, closeOpenedTab);
return jsonResult(stripBrowserOpenInternalMetadata(result));
}
const opened = await browserToolDeps.browserOpenTab(baseUrl, targetUrl, {
profile,
label,
timeoutMs: toolTimeoutMs,
});
browserToolDeps.trackSessionBrowserTab({
sessionKey: opts?.agentSessionKey,
targetId: opened.targetId,
baseUrl,
profile,
});
return jsonResult(opened);
const closeOpenedTab = async (targetId: string, openedProfile?: string) => {
await browserToolDeps.browserCloseTab(baseUrl, targetId, {
profile: openedProfile,
timeoutMs: toolTimeoutMs,
});
};
await sessionTabs.trackOpened(opened, closeOpenedTab);
return jsonResult(stripBrowserOpenInternalMetadata(opened));
}
case "focus": {
const targetId = readStringParam(params, "targetId", {
@@ -676,14 +679,14 @@ export function createBrowserTool(opts?: {
body: { targetId },
timeoutMs: toolTimeoutMs,
});
touchTrackedTab(targetId);
sessionTabs.touch(targetId);
return jsonResult(result);
}
await browserToolDeps.browserFocusTab(baseUrl, targetId, {
const result = await browserToolDeps.browserFocusTab(baseUrl, targetId, {
profile,
timeoutMs: toolTimeoutMs,
});
touchTrackedTab(targetId);
sessionTabs.touch(readStringValue(result.targetId) ?? targetId);
return jsonResult({ ok: true });
}
case "close": {
@@ -703,14 +706,7 @@ export function createBrowserTool(opts?: {
body: { kind: "close" },
timeoutMs: toolTimeoutMs,
});
if (targetId && proxyRequest.isHostFallbackActive()) {
browserToolDeps.untrackSessionBrowserTab({
sessionKey: opts?.agentSessionKey,
targetId,
baseUrl,
profile,
});
}
sessionTabs.untrack(targetId);
return jsonResult(result);
}
if (targetId) {
@@ -718,12 +714,7 @@ export function createBrowserTool(opts?: {
profile,
timeoutMs: toolTimeoutMs,
});
browserToolDeps.untrackSessionBrowserTab({
sessionKey: opts?.agentSessionKey,
targetId,
baseUrl,
profile,
});
sessionTabs.untrack(targetId);
} else {
await browserToolDeps.browserAct(
baseUrl,
@@ -742,7 +733,7 @@ export function createBrowserTool(opts?: {
baseUrl,
profile,
proxyRequest,
onTabActivity: touchTrackedTab,
onTabActivity: sessionTabs.touch,
});
case "screenshot": {
const targetId = readStringParam(params, "targetId");
@@ -778,7 +769,7 @@ export function createBrowserTool(opts?: {
timeoutMs: effectiveTimeoutMs,
profile,
});
touchTrackedTab(readStringValue(result.targetId) ?? targetId);
sessionTabs.touch(readStringValue(result.targetId) ?? targetId);
const screenshotPath = result.path;
const screenshotCfg = browserToolDeps.getRuntimeConfig();
const imageSanitization = resolveRuntimeImageSanitization();
@@ -887,7 +878,7 @@ export function createBrowserTool(opts?: {
targetId,
},
});
touchTrackedTab(
sessionTabs.touch(
readStringValue((result as { targetId?: unknown }).targetId) ?? targetId,
);
return jsonResult(result);
@@ -897,16 +888,23 @@ export function createBrowserTool(opts?: {
targetId,
profile,
});
touchTrackedTab(readStringValue(result.targetId) ?? targetId);
sessionTabs.touch(readStringValue(result.targetId) ?? targetId);
return jsonResult(result);
}
case "console":
return await executeConsoleAction({
case "console": {
const result = await executeConsoleAction({
input: params,
baseUrl,
profile,
proxyRequest,
});
const targetId = readStringParam(params, "targetId");
const canonicalTargetId = readStringValue(
(result.details as { targetId?: unknown } | undefined)?.targetId,
);
sessionTabs.touch(canonicalTargetId ?? targetId);
return result;
}
case "pdf": {
const targetId = normalizeOptionalString(params.targetId);
const result = proxyRequest
@@ -917,7 +915,7 @@ export function createBrowserTool(opts?: {
body: { targetId },
})) as Awaited<ReturnType<typeof browserPdfSave>>)
: await browserToolDeps.browserPdfSave(baseUrl, { targetId, profile });
touchTrackedTab(readStringValue(result.targetId) ?? targetId);
sessionTabs.touch(readStringValue(result.targetId) ?? targetId);
return {
content: [{ type: "text" as const, text: `FILE:${result.path}` }],
details: result,
@@ -931,7 +929,7 @@ export function createBrowserTool(opts?: {
baseUrl,
profile,
proxyRequest,
onTabActivity: touchTrackedTab,
onTabActivity: sessionTabs.touch,
});
case "upload": {
const paths = Array.isArray(params.paths) ? params.paths.map((p) => String(p)) : [];
@@ -961,6 +959,9 @@ export function createBrowserTool(opts?: {
timeoutMs,
},
});
sessionTabs.touch(
readStringValue((result as { targetId?: unknown }).targetId) ?? targetId,
);
return jsonResult(result);
}
const result = await browserToolDeps.browserArmFileChooser(baseUrl, {
@@ -972,7 +973,9 @@ export function createBrowserTool(opts?: {
timeoutMs,
profile,
});
touchTrackedTab(readStringValue((result as { targetId?: unknown }).targetId) ?? targetId);
sessionTabs.touch(
readStringValue((result as { targetId?: unknown }).targetId) ?? targetId,
);
return jsonResult(result);
}
case "dialog": {
@@ -993,6 +996,9 @@ export function createBrowserTool(opts?: {
timeoutMs,
},
});
sessionTabs.touch(
readStringValue((result as { targetId?: unknown }).targetId) ?? targetId,
);
return jsonResult(result);
}
const result = await browserToolDeps.browserArmDialog(baseUrl, {
@@ -1003,7 +1009,9 @@ export function createBrowserTool(opts?: {
timeoutMs,
profile,
});
touchTrackedTab(readStringValue((result as { targetId?: unknown }).targetId) ?? targetId);
sessionTabs.touch(
readStringValue((result as { targetId?: unknown }).targetId) ?? targetId,
);
return jsonResult(result);
}
case "act": {
@@ -1016,7 +1024,7 @@ export function createBrowserTool(opts?: {
baseUrl,
profile,
proxyRequest,
onTabActivity: touchTrackedTab,
onTabActivity: sessionTabs.touch,
});
}
default:
@@ -0,0 +1,81 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import "../test-support/browser-security.mock.js";
import { closeTrackedCdpTarget, resolveCdpTabOwnership } from "./cdp.helpers.js";
const servers: Array<{ close: (callback: () => void) => void }> = [];
async function listen(server: {
once: (event: string, callback: () => void) => void;
}): Promise<void> {
await new Promise<void>((resolve) => {
server.once("listening", resolve);
});
}
function replyToCloseMessages(socket: WebSocket): void {
socket.on("message", (data) => {
const message = JSON.parse(rawDataToString(data)) as { id?: number; method?: string };
if (message.method === "Target.getTargets") {
socket.send(
JSON.stringify({
id: message.id,
result: { targetInfos: [{ targetId: "OWNED", type: "page" }] },
}),
);
} else if (message.method === "Target.closeTarget") {
socket.send(JSON.stringify({ id: message.id, result: { success: false } }));
}
});
}
afterEach(async () => {
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.close(resolve);
}),
),
);
});
describe("closeTrackedCdpTarget", () => {
it("keeps ownership retryable when CDP declines the close", async () => {
const wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
servers.push(wsServer);
await listen(wsServer);
wsServer.on("connection", replyToCloseMessages);
const browserWebSocketUrl = `ws://127.0.0.1:${(wsServer.address() as AddressInfo).port}/devtools/browser/TEST`;
const httpServer = createServer((_, response) => {
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ webSocketDebuggerUrl: browserWebSocketUrl }));
});
servers.push(httpServer);
httpServer.listen(0, "127.0.0.1");
await listen(httpServer);
const cdpUrl = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}`;
const ownership = await resolveCdpTabOwnership({
profileName: "remote",
cdpUrl,
nativeTargetId: "OWNED",
});
if (ownership.status !== "durable") {
throw new Error("expected durable ownership");
}
await expect(
closeTrackedCdpTarget({
profileName: "remote",
cdpUrl,
nativeTargetId: "OWNED",
expectedProfileFingerprint: ownership.profileFingerprint,
expectedBrowserInstanceFingerprint: ownership.browserInstanceFingerprint,
}),
).resolves.toEqual({ status: "unavailable", reason: "target-close-failed" });
});
});
@@ -24,6 +24,7 @@ import {
assertCdpEndpointAllowed,
fetchJson,
fetchOk,
resolveCdpTabOwnership,
scopeCdpPolicyToConfiguredEndpoint,
} from "./cdp.helpers.js";
@@ -221,6 +222,97 @@ describe("cdp helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("threads caller abort and strict CDP policy through browser identity lookup", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
webSocketDebuggerUrl: "wss://1.1.1.1/devtools/browser/BROWSER-1",
}),
{ headers: { "content-type": "application/json" } },
),
release,
});
const controller = new AbortController();
const policy = {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["1.1.1.1"],
};
const resolveOwnership = resolveCdpTabOwnership as unknown as (params: {
profileName: string;
cdpUrl: string;
nativeTargetId: string;
timeoutMs: number;
signal: AbortSignal;
ssrfPolicy: typeof policy;
}) => ReturnType<typeof resolveCdpTabOwnership>;
await expect(
resolveOwnership({
profileName: "remote",
cdpUrl: "https://1.1.1.1",
nativeTargetId: "TARGET-1",
timeoutMs: 4321,
signal: controller.signal,
ssrfPolicy: policy,
}),
).resolves.toMatchObject({ status: "durable", nativeTargetId: "TARGET-1" });
const request = requireGuardedFetchRequest();
expect(request.policy).toBe(policy);
expect(request.signal).not.toBe(controller.signal);
controller.abort(new Error("caller stopped ownership lookup"));
expect(request.signal.aborted).toBe(true);
expect(release).toHaveBeenCalledOnce();
});
it("classifies browser identity network failures without hiding caller aborts", async () => {
fetchWithSsrFGuardMock.mockRejectedValueOnce(new Error("version lookup timed out"));
await expect(
resolveCdpTabOwnership({
profileName: "remote",
cdpUrl: "https://browser.example",
nativeTargetId: "TARGET-1",
}),
).resolves.toEqual({
status: "non-durable",
reason: "browser-identity-lookup-failed",
});
const controller = new AbortController();
const abortError = new Error("caller stopped ownership lookup");
fetchWithSsrFGuardMock.mockImplementationOnce(
async ({ signal }: { signal: AbortSignal }) =>
await new Promise<never>((_resolve, reject) => {
signal.addEventListener(
"abort",
() =>
reject(
signal.reason instanceof Error
? signal.reason
: new Error("ownership lookup aborted"),
),
{ once: true },
);
}),
);
const resolveOwnership = resolveCdpTabOwnership as unknown as (params: {
profileName: string;
cdpUrl: string;
nativeTargetId: string;
signal: AbortSignal;
}) => ReturnType<typeof resolveCdpTabOwnership>;
const pending = resolveOwnership({
profileName: "remote",
cdpUrl: "https://browser.example",
nativeTargetId: "TARGET-1",
signal: controller.signal,
});
controller.abort(abortError);
await expect(pending).rejects.toBe(abortError);
});
it("decodes URL credentials before sending guarded CDP auth headers", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
+234 -1
View File
@@ -4,6 +4,7 @@
* Handles CDP URL normalization, SSRF-guarded HTTP discovery, credential
* redaction/headers, and request/response correlation over WebSocket.
*/
import { createHash } from "node:crypto";
import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
@@ -24,6 +25,7 @@ import {
withNoProxyForCdpUrl,
} from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
import type { BrowserTabOwnership } from "./client.types.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js";
import { withExactHostnamePolicy } from "./ssrf-policy-helpers.js";
@@ -251,6 +253,236 @@ export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
}
}
function fingerprintCdpIdentity(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
function canonicalCdpAuthority(url: URL, protocol: "http:" | "https:" | "ws:" | "wss:"): string {
const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
const port = url.port || (protocol === "https:" || protocol === "wss:" ? "443" : "80");
return `${protocol}//${hostname}:${port}`;
}
function canonicalCdpProfileIdentity(url: string): string {
const parsed = new URL(url);
const protocol =
parsed.protocol === "ws:" ? "http:" : parsed.protocol === "wss:" ? "https:" : parsed.protocol;
if (protocol !== "http:" && protocol !== "https:") {
throw new Error("CDP profile identity requires an HTTP(S) or WebSocket endpoint");
}
const standardBrowserPath = /^\/devtools\/browser\/[A-Za-z0-9._-]+$/.test(parsed.pathname);
const hasTokenShapedSegment =
!standardBrowserPath && parsed.pathname.split("/").some((segment) => segment.length >= 24);
if (hasTokenShapedSegment) {
throw new Error("CDP profile endpoint path may contain credentials");
}
return canonicalCdpAuthority(parsed, protocol);
}
function canonicalBrowserWebSocketIdentity(url: string): string {
const parsed = new URL(url);
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
throw new Error("Browser websocket identity requires a WebSocket endpoint");
}
const pathMatch = parsed.pathname.match(/^\/devtools\/browser\/([A-Za-z0-9._-]+)$/);
if (!pathMatch?.[1]) {
// Provider path prefixes can contain bearer material. Only Chrome's
// standard browser path is safe to persist as an opaque fingerprint input.
throw new Error("Browser websocket identity path is not credential-free");
}
return `${canonicalCdpAuthority(parsed, parsed.protocol)}/devtools/browser/${pathMatch[1]}`;
}
/** Build restart-stable hashes without retaining endpoint credentials. */
function createCdpOwnershipFingerprints(params: {
profileName: string;
cdpUrl: string;
browserWebSocketUrl: string;
}): {
profileFingerprint: string;
browserInstanceFingerprint: string;
} {
return {
profileFingerprint: fingerprintCdpIdentity(
JSON.stringify([params.profileName, canonicalCdpProfileIdentity(params.cdpUrl)]),
),
browserInstanceFingerprint: fingerprintCdpIdentity(
canonicalBrowserWebSocketIdentity(params.browserWebSocketUrl),
),
};
}
type CdpTabOwnershipParams = {
profileName: string;
cdpUrl: string;
nativeTargetId: string;
timeoutMs?: number;
signal?: AbortSignal;
ssrfPolicy?: SsrFPolicy;
};
async function resolveCdpTabOwnershipContext(
params: CdpTabOwnershipParams,
): Promise<{ ownership: BrowserTabOwnership; browserWebSocketUrl?: string }> {
params.signal?.throwIfAborted();
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(params.cdpUrl);
let version: { webSocketDebuggerUrl?: unknown };
try {
version = await fetchJson<{ webSocketDebuggerUrl?: unknown }>(
appendCdpPath(cdpHttpBase, "/json/version"),
params.timeoutMs,
{ signal: params.signal },
params.ssrfPolicy,
);
} catch (error) {
if (params.signal?.aborted) {
throw params.signal.reason ?? error;
}
if (error instanceof BrowserCdpEndpointBlockedError) {
throw error;
}
return {
ownership: { status: "non-durable", reason: "browser-identity-lookup-failed" },
};
}
params.signal?.throwIfAborted();
const browserWebSocketUrl =
typeof version.webSocketDebuggerUrl === "string" ? version.webSocketDebuggerUrl.trim() : "";
if (!browserWebSocketUrl) {
return { ownership: { status: "non-durable", reason: "browser-identity-unavailable" } };
}
try {
await assertCdpEndpointAllowed(browserWebSocketUrl, params.ssrfPolicy, {
source: "discovered",
configuredUrl: params.cdpUrl,
});
return {
ownership: {
status: "durable",
nativeTargetId: params.nativeTargetId,
...createCdpOwnershipFingerprints({
profileName: params.profileName,
cdpUrl: params.cdpUrl,
browserWebSocketUrl,
}),
},
browserWebSocketUrl,
};
} catch (error) {
if (error instanceof BrowserCdpEndpointBlockedError) {
throw error;
}
return { ownership: { status: "non-durable", reason: "browser-identity-unavailable" } };
}
}
/** Resolve durable ownership for a native target from the browser-level CDP identity. */
export async function resolveCdpTabOwnership(
params: CdpTabOwnershipParams,
): Promise<BrowserTabOwnership> {
return (await resolveCdpTabOwnershipContext(params)).ownership;
}
export type CloseTrackedCdpTargetResult =
| { status: "cancelled" | "closed" | "missing" | "ownership-mismatch" }
| {
status: "unavailable";
reason:
| Extract<BrowserTabOwnership, { status: "non-durable" }>["reason"]
| "target-close-failed";
};
/** Verify ownership and close a tracked target on the same browser-level CDP connection. */
export async function closeTrackedCdpTarget(
params: CdpTabOwnershipParams & {
expectedProfileFingerprint: string;
expectedBrowserInstanceFingerprint: string;
shouldClose?: () => boolean;
},
): Promise<CloseTrackedCdpTargetResult> {
const resolved = await resolveCdpTabOwnershipContext(params);
if (resolved.ownership.status !== "durable" || !resolved.browserWebSocketUrl) {
return {
status: "unavailable",
reason:
resolved.ownership.status === "non-durable"
? resolved.ownership.reason
: "browser-identity-unavailable",
};
}
if (
resolved.ownership.profileFingerprint !== params.expectedProfileFingerprint ||
resolved.ownership.browserInstanceFingerprint !== params.expectedBrowserInstanceFingerprint
) {
return { status: "ownership-mismatch" };
}
params.signal?.throwIfAborted();
try {
return await withCdpSocket(
resolved.browserWebSocketUrl,
async (send) => {
params.signal?.throwIfAborted();
const response = await send("Target.getTargets");
params.signal?.throwIfAborted();
const targetInfos =
response && typeof response === "object"
? (response as { targetInfos?: unknown }).targetInfos
: undefined;
if (!Array.isArray(targetInfos)) {
return { status: "unavailable", reason: "target-lookup-failed" } as const;
}
const exists = targetInfos.some(
(target) =>
target &&
typeof target === "object" &&
(target as { targetId?: unknown }).targetId === params.nativeTargetId,
);
if (!exists) {
return { status: "missing" } as const;
}
// The SQLite cleanup generation can be revoked while browser identity
// is being resolved. Recheck on this same socket immediately before
// the irreversible close so fresh activity cancels an idle sweep.
if (params.shouldClose && !params.shouldClose()) {
return { status: "cancelled" } as const;
}
try {
params.signal?.throwIfAborted();
const closeResponse = await send("Target.closeTarget", {
targetId: params.nativeTargetId,
});
params.signal?.throwIfAborted();
return closeResponse &&
typeof closeResponse === "object" &&
(closeResponse as { success?: unknown }).success === true
? ({ status: "closed" } as const)
: ({ status: "unavailable", reason: "target-close-failed" } as const);
} catch (error) {
// Chromium can destroy the page between getTargets and closeTarget.
// Its protocol implementation uses this exact InvalidParams message.
if (String(error).includes("No target with given id found")) {
return { status: "missing" } as const;
}
throw error;
}
},
{
commandTimeoutMs: params.timeoutMs,
handshakeTimeoutMs: params.timeoutMs,
handshakeRetries: 0,
},
);
} catch (error) {
if (params.signal?.aborted) {
throw params.signal.reason ?? error;
}
if (error instanceof BrowserCdpEndpointBlockedError) {
throw error;
}
return { status: "unavailable", reason: "target-lookup-failed" };
}
}
type CdpFetchResult = {
response: Response;
release: () => Promise<void>;
@@ -372,6 +604,7 @@ export async function fetchCdpChecked(
): Promise<CdpFetchResult> {
const ctrl = new AbortController();
const t = setTimeout(ctrl.abort.bind(ctrl), normalizeBrowserTimerDelayMs(timeoutMs));
const signal = init?.signal ? AbortSignal.any([ctrl.signal, init.signal]) : ctrl.signal;
let guardedRelease: (() => Promise<void>) | undefined;
let released = false;
const release = async () => {
@@ -396,7 +629,7 @@ export async function fetchCdpChecked(
const guarded = await fetchWithSsrFGuard({
url: fetchUrl,
init: { ...init, headers },
signal: ctrl.signal,
signal,
policy,
auditContext: "browser-cdp",
});
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
};
});
import { resolveCdpTabOwnership } from "./cdp.helpers.js";
function endpointWithFixtureAuth(protocol: "https:" | "wss:", path: string, value: string): string {
const endpoint = new URL(`${protocol}//browser.example${path}`);
endpoint.username = "fixture-user";
endpoint.password = value;
endpoint.searchParams.set("auth", value);
return endpoint.toString();
}
function mockVersion(browserWebSocketUrl: string): void {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(JSON.stringify({ webSocketDebuggerUrl: browserWebSocketUrl })),
release: vi.fn(async () => {}),
});
}
describe("CDP ownership fingerprints", () => {
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
it("ignores rotated endpoint credentials", async () => {
const firstFixture = "fixture-value-a-with-more-than-eighteen-characters";
const secondFixture = "fixture-value-b-with-more-than-eighteen-characters";
mockVersion(endpointWithFixtureAuth("wss:", "/devtools/browser/BROWSER-1", firstFixture));
const first = await resolveCdpTabOwnership({
profileName: "remote",
cdpUrl: endpointWithFixtureAuth("https:", "", firstFixture),
nativeTargetId: "TARGET-1",
});
mockVersion(endpointWithFixtureAuth("wss:", "/devtools/browser/BROWSER-1", secondFixture));
const rotated = await resolveCdpTabOwnership({
profileName: "remote",
cdpUrl: endpointWithFixtureAuth("https:", "", secondFixture),
nativeTargetId: "TARGET-1",
});
expect(first).toEqual(rotated);
});
it("refuses provider paths that may embed credentials", async () => {
mockVersion("wss://browser.example/session/fixture-value/devtools/browser/BROWSER-1");
await expect(
resolveCdpTabOwnership({
profileName: "remote",
cdpUrl: "https://browser.example",
nativeTargetId: "TARGET-1",
}),
).resolves.toEqual({ status: "non-durable", reason: "browser-identity-unavailable" });
const fixturePath = "fixture-path-segment-".repeat(4);
mockVersion("wss://browser.example/devtools/browser/BROWSER-1");
await expect(
resolveCdpTabOwnership({
profileName: "remote",
cdpUrl: `https://browser.example/session/${fixturePath}`,
nativeTargetId: "TARGET-1",
}),
).resolves.toEqual({ status: "non-durable", reason: "browser-identity-unavailable" });
});
});
+123
View File
@@ -8,9 +8,11 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import "../test-support/browser-security.mock.js";
import {
closeTrackedCdpTarget,
isDirectCdpWebSocketEndpoint,
isWebSocketUrl,
parseBrowserHttpUrl as parseHttpUrl,
resolveCdpTabOwnership,
} from "./cdp.helpers.js";
import {
createTargetViaCdp,
@@ -153,6 +155,127 @@ describe("cdp", () => {
]);
});
it("verifies ownership and closes a tracked target on one browser connection", async () => {
const methods: string[] = [];
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method) {
methods.push(msg.method);
}
if (msg.method === "Target.getTargets") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
targetInfos: [
{ targetId: "OWNED", type: "page" },
{ targetId: "USER", type: "page" },
],
},
}),
);
} else if (msg.method === "Target.closeTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { success: true } }));
}
});
const browserWebSocketUrl = `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`;
const httpPort = await startVersionHttpServer({ webSocketDebuggerUrl: browserWebSocketUrl });
const cdpUrl = `http://127.0.0.1:${httpPort}`;
const resolvedOwnership = await resolveCdpTabOwnership({
profileName: "remote",
cdpUrl,
nativeTargetId: "OWNED",
});
expect(resolvedOwnership.status).toBe("durable");
if (resolvedOwnership.status !== "durable") {
throw new Error("expected durable ownership");
}
await expect(
closeTrackedCdpTarget({
profileName: "remote",
cdpUrl,
nativeTargetId: "OWNED",
expectedProfileFingerprint: resolvedOwnership.profileFingerprint,
expectedBrowserInstanceFingerprint: resolvedOwnership.browserInstanceFingerprint,
}),
).resolves.toEqual({ status: "closed" });
expect(methods).toEqual(["Target.getTargets", "Target.closeTarget"]);
methods.length = 0;
await expect(
closeTrackedCdpTarget({
profileName: "remote",
cdpUrl,
nativeTargetId: "OWNED",
expectedProfileFingerprint: resolvedOwnership.profileFingerprint,
expectedBrowserInstanceFingerprint: resolvedOwnership.browserInstanceFingerprint,
shouldClose: () => false,
}),
).resolves.toEqual({ status: "cancelled" });
expect(methods).toEqual(["Target.getTargets"]);
});
it("retires an absent target without issuing a close command", async () => {
const methods: string[] = [];
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method) {
methods.push(msg.method);
}
if (msg.method === "Target.getTargets") {
socket.send(
JSON.stringify({
id: msg.id,
result: { targetInfos: [{ targetId: "USER", type: "page" }] },
}),
);
}
});
const browserWebSocketUrl = `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`;
const httpPort = await startVersionHttpServer({ webSocketDebuggerUrl: browserWebSocketUrl });
const cdpUrl = `http://127.0.0.1:${httpPort}`;
const resolvedOwnership = await resolveCdpTabOwnership({
profileName: "remote",
cdpUrl,
nativeTargetId: "MISSING",
});
expect(resolvedOwnership.status).toBe("durable");
if (resolvedOwnership.status !== "durable") {
throw new Error("expected durable ownership");
}
await expect(
closeTrackedCdpTarget({
profileName: "remote",
cdpUrl,
nativeTargetId: "MISSING",
expectedProfileFingerprint: resolvedOwnership.profileFingerprint,
expectedBrowserInstanceFingerprint: resolvedOwnership.browserInstanceFingerprint,
}),
).resolves.toEqual({ status: "missing" });
expect(methods).toEqual(["Target.getTargets"]);
});
it("does not inspect or close targets after browser ownership changes", async () => {
const methods: string[] = [];
const wsPort = await startWsServerWithMessages((msg) => {
if (msg.method) {
methods.push(msg.method);
}
});
const browserWebSocketUrl = `ws://127.0.0.1:${wsPort}/devtools/browser/NEW`;
const httpPort = await startVersionHttpServer({ webSocketDebuggerUrl: browserWebSocketUrl });
await expect(
closeTrackedCdpTarget({
profileName: "remote",
cdpUrl: `http://127.0.0.1:${httpPort}`,
nativeTargetId: "REUSED",
expectedProfileFingerprint: "sha256:old-profile",
expectedBrowserInstanceFingerprint: "sha256:old-browser",
}),
).resolves.toEqual({ status: "ownership-mismatch" });
expect(methods).toEqual([]);
});
it("returns the stable browser-owned frame URL when requested", async () => {
let frameReadCount = 0;
const methods: string[] = [];
@@ -0,0 +1,556 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { fetchJsonMock, fetchOkMock } = vi.hoisted(() => ({
fetchJsonMock: vi.fn(),
fetchOkMock: vi.fn(),
}));
vi.mock("./cdp.helpers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./cdp.helpers.js")>();
return {
...actual,
fetchJson: (...args: unknown[]) => fetchJsonMock(...args),
fetchOk: (...args: unknown[]) => fetchOkMock(...args),
resolveCdpTabOwnership: async (params: {
profileName: string;
cdpUrl: string;
nativeTargetId: string;
signal?: AbortSignal;
timeoutMs?: number;
ssrfPolicy?: unknown;
}) => {
const version = (await fetchJsonMock(
`${actual.normalizeCdpHttpBaseForJsonEndpoints(params.cdpUrl)}/json/version`,
params.timeoutMs,
{ signal: params.signal },
params.ssrfPolicy,
)) as { webSocketDebuggerUrl?: unknown };
if (
typeof version.webSocketDebuggerUrl !== "string" ||
!/\/devtools\/browser\/[^/?#]+/i.test(new URL(version.webSocketDebuggerUrl).pathname)
) {
return { status: "non-durable", reason: "browser-identity-unavailable" };
}
return {
status: "durable",
nativeTargetId: params.nativeTargetId,
profileFingerprint: "sha256:fixture-profile",
browserInstanceFingerprint: "sha256:fixture-browser",
};
},
};
});
import {
openChromeMcpTab,
resetChromeMcpSessionsForTest,
setChromeMcpSessionFactoryForTest,
} from "./chrome-mcp.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
type ToolCall = {
name: string;
arguments?: Record<string, unknown>;
};
type FakePage = {
id: number;
nativeTargetId: string;
url: string;
};
function createSerialLock() {
let tail = Promise.resolve();
return async <T>(operation: () => Promise<T>): Promise<T> => {
const current = tail.then(operation);
tail = current.then(
() => undefined,
() => undefined,
);
return await current;
};
}
function createMarkerSession(options: { existingPage?: boolean; navigateError?: Error } = {}) {
const pages: FakePage[] =
options.existingPage === false
? []
: [{ id: 1, nativeTargetId: "NATIVE-EXISTING", url: "about:blank" }];
const events: string[] = [];
const readUrl = (value: unknown) => (typeof value === "string" ? value : "");
const callTool = vi.fn(async (call: ToolCall) => {
if (call.name === "new_page") {
const url = readUrl(call.arguments?.url);
const page = {
id: pages.length + 1,
nativeTargetId: `NATIVE-${pages.length + 1}`,
url,
};
pages.push(page);
events.push(`new:${url}`);
return {
structuredContent: {
pages: [{ id: page.id, url: page.url, selected: true }],
},
};
}
if (call.name === "navigate_page") {
if (options.navigateError) {
throw options.navigateError;
}
const page = pages.find((candidate) => candidate.id === call.arguments?.pageId);
if (!page) {
throw new Error("unknown page");
}
events.push(`navigate:${page.url}`);
page.url = readUrl(call.arguments?.url);
return { content: [{ type: "text", text: "navigated" }] };
}
if (call.name === "list_pages") {
return {
structuredContent: {
pages: pages.map((page) => ({ id: page.id, url: page.url })),
},
};
}
if (call.name === "close_page") {
const pageIndex = pages.findIndex((candidate) => candidate.id === call.arguments?.pageId);
if (pageIndex < 0) {
throw new Error("unknown page");
}
const [closed] = pages.splice(pageIndex, 1);
events.push(`close:${closed?.url ?? ""}`);
return { content: [{ type: "text", text: "closed" }] };
}
throw new Error(`unexpected tool ${call.name}`);
});
const session = {
client: {
callTool,
listTools: vi.fn(),
close: vi.fn(async () => {}),
connect: vi.fn(),
},
transport: { pid: 123 },
ready: Promise.resolve(),
routing: {
sessionNonce: "000000000001",
withOperationLock: createSerialLock(),
targetIdByPageId: new Map<number, string>(),
nextTargetHandleId: 1,
snapshotRefById: new Map(),
nextSnapshotRefId: 1,
},
};
return { session, pages, events };
}
function ownershipOf(value: unknown): Record<string, unknown> | undefined {
return (value as { ownership?: Record<string, unknown> }).ownership;
}
function fixtureCdpEndpoint(protocol: "http:" | "ws:", path = ""): string {
const endpoint = new URL(`${protocol}//127.0.0.1:9222${path}`);
endpoint.searchParams.set("auth", "fixture-value");
return endpoint.toString();
}
describe("Chrome MCP durable tab ownership", () => {
beforeEach(async () => {
await resetChromeMcpSessionsForTest();
fetchJsonMock.mockReset();
fetchOkMock.mockReset().mockResolvedValue(undefined);
});
afterEach(async () => {
await resetChromeMcpSessionsForTest();
});
it("captures the uniquely marked native target before final navigation", async () => {
const { session, pages, events } = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockImplementation(async (url: string) => {
if (url.includes("/json/list")) {
events.push("json-list");
return pages.map((page) => ({
id: page.nativeTargetId,
url: page.url,
type: "page",
}));
}
if (url.includes("/json/version")) {
events.push("json-version");
return {
webSocketDebuggerUrl: fixtureCdpEndpoint("ws:", "/devtools/browser/BROWSER-ONE"),
};
}
throw new Error(`unexpected fetch ${url}`);
});
const opened = await openChromeMcpTab("chrome-live", "https://example.com/final", {
cdpUrl: fixtureCdpEndpoint("http:"),
});
expect(events[0]).toMatch(/^new:about:blank#openclaw-/);
expect(events.indexOf("json-list")).toBeLessThan(
events.findIndex((event) => event.startsWith("navigate:")),
);
expect(ownershipOf(opened)).toMatchObject({
status: "durable",
nativeTargetId: "NATIVE-2",
profileFingerprint: expect.stringMatching(/^sha256:/),
browserInstanceFingerprint: expect.stringMatching(/^sha256:/),
});
});
it("serializes duplicate-destination opens while preserving unique marker mapping", async () => {
const { session, pages } = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockImplementation(async (url: string) => {
if (url.includes("/json/list")) {
return pages.map((page) => ({
id: page.nativeTargetId,
url: page.url,
type: "page",
}));
}
return {
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/browser/BROWSER-ONE",
};
});
const profile = { cdpUrl: "http://127.0.0.1:9222" };
const [first, second] = await Promise.all([
openChromeMcpTab("chrome-live", "https://example.com/same", profile),
openChromeMcpTab("chrome-live", "https://example.com/same", profile),
]);
expect(ownershipOf(first)?.nativeTargetId).toBe("NATIVE-2");
expect(ownershipOf(second)?.nativeTargetId).toBe("NATIVE-3");
const calls = (session.client.callTool as ReturnType<typeof vi.fn>).mock.calls as Array<
[ToolCall, ...unknown[]]
>;
const markerUrls = calls
.filter(([call]) => call.name === "new_page")
.map(([call]) => call.arguments?.url);
expect(new Set(markerUrls).size).toBe(2);
});
it("threads CDP policy, signal, and remote HTTP timeout through marker capture", async () => {
const { session, pages } = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockImplementation(async (url: string) => {
if (url.includes("/json/list")) {
return pages.map((page) => ({
id: page.nativeTargetId,
url: page.url,
type: "page",
}));
}
return {
webSocketDebuggerUrl: "wss://browser.example/devtools/browser/BROWSER-ONE",
};
});
const controller = new AbortController();
const cdpPolicy = {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["browser.example"],
};
const operationOptions = {
signal: controller.signal,
cdpPolicy,
cdpTimeouts: { httpTimeoutMs: 4321, handshakeTimeoutMs: 8765 },
} as unknown as Parameters<typeof openChromeMcpTab>[3];
await openChromeMcpTab(
"chrome-live",
"https://example.com",
{ cdpUrl: "https://browser.example" },
operationOptions,
);
const ownershipFetches = fetchJsonMock.mock.calls.filter(([url]) =>
/\/json\/(?:list|version)/.test(String(url)),
);
expect(ownershipFetches).toEqual([
["https://browser.example/json/list", 4321, { signal: controller.signal }, cdpPolicy],
["https://browser.example/json/version", 4321, { signal: controller.signal }, cdpPolicy],
]);
});
it("propagates aborts and strict CDP policy failures from marker lookup", async () => {
const abortedSession = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => abortedSession.session as never);
const controller = new AbortController();
const abortError = new Error("marker lookup aborted");
fetchJsonMock.mockImplementationOnce(async () => {
controller.abort(abortError);
throw abortError;
});
await expect(
openChromeMcpTab(
"chrome-live",
"about:blank",
{ cdpUrl: "http://127.0.0.1:9222" },
{ signal: controller.signal },
),
).rejects.toThrow("marker lookup aborted");
expect(abortedSession.pages).toEqual([
{ id: 1, nativeTargetId: "NATIVE-EXISTING", url: "about:blank" },
]);
expect(abortedSession.events.at(-1)).toMatch(/^close:about:blank#openclaw-/);
await resetChromeMcpSessionsForTest();
const blockedSession = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => blockedSession.session as never);
const blocked = new BrowserCdpEndpointBlockedError({
cause: new Error("strict SSRF policy rejected endpoint"),
});
fetchJsonMock.mockRejectedValueOnce(blocked);
await expect(
openChromeMcpTab("chrome-live", "about:blank", {
cdpUrl: "http://10.0.0.1:9222",
}),
).rejects.toBe(blocked);
expect(blockedSession.pages).toEqual([
{ id: 1, nativeTargetId: "NATIVE-EXISTING", url: "about:blank" },
]);
});
it("opens the first page in an empty explicit-CDP browser", async () => {
const { session, pages } = createMarkerSession({ existingPage: false });
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockImplementation(async (url: string) => {
if (url.includes("/json/list")) {
return pages.map((page) => ({
id: page.nativeTargetId,
url: page.url,
type: "page",
}));
}
return {
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/browser/BROWSER-ONE",
};
});
const opened = await openChromeMcpTab("chrome-live", "about:blank", {
cdpUrl: "http://127.0.0.1:9222",
});
expect(ownershipOf(opened)).toMatchObject({
status: "durable",
nativeTargetId: "NATIVE-1",
});
expect(pages).toEqual([{ id: 1, nativeTargetId: "NATIVE-1", url: "about:blank" }]);
});
it("rejects an empty auto-connected browser before creating a page", async () => {
const { session, pages } = createMarkerSession({ existingPage: false });
setChromeMcpSessionFactoryForTest(async () => session as never);
await expect(openChromeMcpTab("chrome-live", "about:blank")).rejects.toThrow(
"without an explicit CDP endpoint",
);
expect(pages).toEqual([]);
const calls = (session.client.callTool as ReturnType<typeof vi.fn>).mock.calls as Array<
[ToolCall, ...unknown[]]
>;
expect(calls.map(([call]) => call.name)).toEqual(["list_pages"]);
});
it("rejects and closes a first page without durable browser ownership", async () => {
const { session, pages } = createMarkerSession({ existingPage: false });
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockImplementation(async (url: string) => {
if (url.includes("/json/list")) {
return pages.map((page) => ({
id: page.nativeTargetId,
url: page.url,
type: "page",
}));
}
return { webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/NATIVE-1" };
});
fetchOkMock.mockImplementationOnce(async (url: string) => {
const nativeTargetId = decodeURIComponent(url.split("/").at(-1) ?? "");
const index = pages.findIndex((page) => page.nativeTargetId === nativeTargetId);
if (index >= 0) {
pages.splice(index, 1);
}
});
await expect(
openChromeMcpTab("chrome-live", "about:blank", {
cdpUrl: "http://127.0.0.1:9222",
}),
).rejects.toThrow("without durable CDP ownership");
expect(pages).toEqual([]);
expect(fetchOkMock).toHaveBeenCalledWith(
"http://127.0.0.1:9222/json/close/NATIVE-1",
undefined,
undefined,
undefined,
);
});
it("closes a failed first-page marker through its captured native target", async () => {
const navigateError = new Error("navigation failed");
const { session, pages } = createMarkerSession({ existingPage: false, navigateError });
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockImplementation(async (url: string) => {
if (url.includes("/json/list")) {
return pages.map((page) => ({
id: page.nativeTargetId,
url: page.url,
type: "page",
}));
}
return {
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/browser/BROWSER-ONE",
};
});
fetchOkMock.mockImplementationOnce(async (url: string) => {
const nativeTargetId = decodeURIComponent(url.split("/").at(-1) ?? "");
const index = pages.findIndex((page) => page.nativeTargetId === nativeTargetId);
if (index >= 0) {
pages.splice(index, 1);
}
});
await expect(
openChromeMcpTab("chrome-live", "https://example.com", {
cdpUrl: "http://127.0.0.1:9222",
}),
).rejects.toBe(navigateError);
expect(pages).toEqual([]);
expect(fetchOkMock).toHaveBeenCalledWith(
"http://127.0.0.1:9222/json/close/NATIVE-1",
undefined,
undefined,
undefined,
);
const calls = (session.client.callTool as ReturnType<typeof vi.fn>).mock.calls as Array<
[ToolCall, ...unknown[]]
>;
expect(calls.map(([call]) => call.name)).not.toContain("close_page");
});
it("classifies marker lookup network failures separately from ambiguous matches", async () => {
const { session } = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockRejectedValueOnce(new Error("marker lookup timed out"));
const opened = await openChromeMcpTab("chrome-live", "about:blank", {
cdpUrl: "https://browser.example",
});
expect(ownershipOf(opened)).toEqual({
status: "non-durable",
reason: "target-marker-lookup-failed",
});
});
it("classifies malformed marker lookup payloads as lookup failures", async () => {
const { session } = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockResolvedValueOnce({ targets: "not-an-array" });
const opened = await openChromeMcpTab("chrome-live", "about:blank", {
cdpUrl: "https://browser.example",
});
expect(ownershipOf(opened)).toEqual({
status: "non-durable",
reason: "target-marker-lookup-failed",
});
});
it("classifies malformed marker list entries as lookup failures", async () => {
const { session } = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockResolvedValueOnce([null, undefined, "not-a-target"]);
const opened = await openChromeMcpTab("chrome-live", "about:blank", {
cdpUrl: "https://browser.example",
});
expect(ownershipOf(opened)).toEqual({
status: "non-durable",
reason: "target-marker-lookup-failed",
});
});
it.each([
{ label: "no marker match", matches: [] },
{
label: "multiple marker matches",
matches: [
{ id: "NATIVE-A", type: "page" },
{ id: "NATIVE-B", type: "page" },
],
},
])("returns non-durable ownership for $label", async ({ matches }) => {
const { session } = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => session as never);
fetchJsonMock.mockImplementation(async (url: string) => {
if (url.includes("/json/list")) {
const calls = (session.client.callTool as ReturnType<typeof vi.fn>).mock.calls as Array<
[ToolCall, ...unknown[]]
>;
const markerValue = calls.find(([call]) => call.name === "new_page")?.[0].arguments?.url;
const marker = typeof markerValue === "string" ? markerValue : "";
return matches.map((entry) => ({ ...entry, url: marker }));
}
return {
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/browser/BROWSER-ONE",
};
});
const opened = await openChromeMcpTab("chrome-live", "https://example.com/final", {
cdpUrl: "http://127.0.0.1:9222",
});
expect(ownershipOf(opened)).toEqual({
status: "non-durable",
reason: "target-marker-not-unique",
});
});
it("does not claim durable ownership for auto-connect or missing browser identity", async () => {
const autoConnectSession = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => autoConnectSession.session as never);
const autoConnected = await openChromeMcpTab("chrome-live", "about:blank");
expect(ownershipOf(autoConnected)).toEqual({
status: "non-durable",
reason: "explicit-cdp-url-required",
});
expect(fetchJsonMock).not.toHaveBeenCalled();
await resetChromeMcpSessionsForTest();
const endpointSession = createMarkerSession();
setChromeMcpSessionFactoryForTest(async () => endpointSession.session as never);
fetchJsonMock.mockImplementation(async (url: string) => {
if (url.includes("/json/list")) {
return endpointSession.pages.map((page) => ({
id: page.nativeTargetId,
url: page.url,
type: "page",
}));
}
return { Browser: "Chrome/138" };
});
const withoutVersionIdentity = await openChromeMcpTab("chrome-live", "about:blank", {
cdpUrl: "http://127.0.0.1:9222",
});
expect(ownershipOf(withoutVersionIdentity)).toEqual({
status: "non-durable",
reason: "browser-identity-unavailable",
});
});
});
@@ -1594,10 +1594,19 @@ describe("chrome MCP page parsing", () => {
title: "",
url: "https://example.com/",
type: "page",
ownership: {
status: "non-durable",
reason: "explicit-cdp-url-required",
},
});
const calls = (session.client.callTool as unknown as ToolCallMock).mock.calls;
expect(calls.map(([call]) => call.name)).toEqual(["new_page", "navigate_page", "list_pages"]);
expect(calls[2]?.[2]?.timeout).toBe(25_000);
expect(calls.map(([call]) => call.name)).toEqual([
"list_pages",
"new_page",
"navigate_page",
"list_pages",
]);
expect(calls[3]?.[2]?.timeout).toBe(25_000);
});
it("opens about:blank directly without an extra navigate", async () => {
@@ -1612,6 +1621,10 @@ describe("chrome MCP page parsing", () => {
title: "",
url: "about:blank",
type: "page",
ownership: {
status: "non-durable",
reason: "explicit-cdp-url-required",
},
});
expect(session.client["callTool"]).toHaveBeenCalledWith({
name: "new_page",
@@ -1619,7 +1632,7 @@ describe("chrome MCP page parsing", () => {
});
const callToolMock = session.client["callTool"] as unknown as ToolCallMock;
const callNames = callToolMock.mock.calls.map(([call]) => call.name);
expect(callNames).toEqual(["new_page"]);
expect(callNames).toEqual(["list_pages", "new_page"]);
});
it("preserves unrelated targets and refs when new_page returns only the created page", async () => {
@@ -1667,7 +1680,7 @@ describe("chrome MCP page parsing", () => {
const calls = (session.client.callTool as unknown as ToolCallMock).mock.calls.map(
([call]) => call.name,
);
expect(calls).toEqual(["list_pages", "take_snapshot", "new_page", "click"]);
expect(calls).toEqual(["list_pages", "take_snapshot", "list_pages", "new_page", "click"]);
});
it("parses evaluate_script text responses when structuredContent is missing", async () => {
+227 -40
View File
@@ -23,15 +23,29 @@ import {
readStringValue,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { redactToolPayloadText } from "../logging/redact.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { asRecord } from "../record-shared.js";
import { createBoundedUtf8Tail, decodeBoundedUtf8Tail } from "./bounded-utf8-tail.js";
import { redactCdpErrorText, redactCdpUrl } from "./cdp.helpers.js";
import {
appendCdpPath,
fetchJson,
fetchOk,
normalizeCdpHttpBaseForJsonEndpoints,
redactCdpErrorText,
redactCdpUrl,
resolveCdpTabOwnership,
} from "./cdp.helpers.js";
import type { CdpActionTimeouts } from "./cdp.js";
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
import type { BrowserTab } from "./client.types.js";
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "./errors.js";
import type { BrowserOpenResult, BrowserTab, BrowserTabOwnership } from "./client.types.js";
import {
BrowserCdpEndpointBlockedError,
BrowserProfileUnavailableError,
BrowserTabNotFoundError,
} from "./errors.js";
const log = createSubsystemLogger("browser").child("chrome-mcp");
@@ -70,6 +84,11 @@ export type ChromeMcpOperationOptions = {
signal?: AbortSignal;
};
type ChromeMcpOpenOptions = ChromeMcpOperationOptions & {
cdpPolicy?: SsrFPolicy;
cdpTimeouts?: CdpActionTimeouts;
};
type ChromeMcpTargetOperation = ChromeMcpOperationOptions & {
profileName: string;
profile?: ChromeMcpProfileOptions;
@@ -2031,24 +2050,112 @@ export async function countChromeMcpTabs(
return (await readChromeMcpTabs(profileName, profileOptions, options)).length;
}
async function lookupChromeMcpMarkerNativeTarget(params: {
browserUrl: string;
markerUrl: string;
options: ChromeMcpOpenOptions;
}): Promise<string | undefined> {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(params.browserUrl);
const rawTargets = await fetchJson<unknown>(
appendCdpPath(cdpHttpBase, "/json/list"),
params.options.cdpTimeouts?.httpTimeoutMs,
{ signal: params.options.signal },
params.options.cdpPolicy,
);
if (!Array.isArray(rawTargets)) {
throw new Error("CDP target list response was not an array");
}
if (rawTargets.some((target) => !target || typeof target !== "object")) {
throw new Error("CDP target list response contained a malformed entry");
}
const targets = rawTargets as Array<{ id?: unknown; url?: unknown; type?: unknown }>;
const matches = targets.filter(
(target) =>
target.url === params.markerUrl &&
typeof target.id === "string" &&
target.id.trim() &&
(target.type === undefined || target.type === "page"),
);
if (matches.length !== 1) {
return undefined;
}
const nativeTargetId = matches[0]?.id;
return typeof nativeTargetId === "string" ? nativeTargetId.trim() || undefined : undefined;
}
async function captureChromeMcpTabOwnership(params: {
profileName: string;
browserUrl: string | undefined;
markerUrl: string | undefined;
options: ChromeMcpOpenOptions;
}): Promise<{ ownership: BrowserTabOwnership; nativeTargetId?: string }> {
if (!params.browserUrl || !params.markerUrl) {
return { ownership: { status: "non-durable", reason: "explicit-cdp-url-required" } };
}
let nativeTargetId: string | undefined;
try {
nativeTargetId = await lookupChromeMcpMarkerNativeTarget({
browserUrl: params.browserUrl,
markerUrl: params.markerUrl,
options: params.options,
});
} catch (error) {
if (params.options.signal?.aborted) {
throw params.options.signal.reason ?? error;
}
if (error instanceof BrowserCdpEndpointBlockedError) {
throw error;
}
return { ownership: { status: "non-durable", reason: "target-marker-lookup-failed" } };
}
if (!nativeTargetId) {
return { ownership: { status: "non-durable", reason: "target-marker-not-unique" } };
}
const ownership = await resolveCdpTabOwnership({
profileName: params.profileName,
cdpUrl: params.browserUrl,
nativeTargetId,
timeoutMs: params.options.cdpTimeouts?.httpTimeoutMs,
signal: params.options.signal,
ssrfPolicy: params.options.cdpPolicy,
});
return { ownership, nativeTargetId };
}
/** Open a new Chrome MCP tab and navigate it to the requested URL. */
export async function openChromeMcpTab(
profileName: string,
url: string,
profileOptions?: string | ChromeMcpProfileOptions,
options: ChromeMcpOperationOptions = {},
): Promise<BrowserTab> {
options: ChromeMcpOpenOptions = {},
): Promise<BrowserOpenResult> {
const targetUrl = url.trim() || "about:blank";
return await withChromeMcpLease(
profileName,
profileOptions,
options,
async (lease, normalizedProfileOptions) => {
const existingPages = await listChromeMcpTargetsWithLease({
profileName,
profileOptions: normalizedProfileOptions,
lease,
options: { timeoutMs: CHROME_MCP_NEW_PAGE_TIMEOUT_MS, signal: options.signal },
});
const canUseMcpCompensation = existingPages.length > 0;
if (!canUseMcpCompensation && !normalizedProfileOptions.browserUrl) {
throw new Error(
"Chrome MCP cannot safely open the first page without an explicit CDP endpoint.",
);
}
const markerUrl = normalizedProfileOptions.browserUrl
? `about:blank#openclaw-${randomUUID()}`
: undefined;
const initialUrl = markerUrl ?? "about:blank";
const result = await callTool(
profileName,
normalizedProfileOptions,
"new_page",
{ url: "about:blank", timeout: CHROME_MCP_NEW_PAGE_TIMEOUT_MS },
{ url: initialUrl, timeout: CHROME_MCP_NEW_PAGE_TIMEOUT_MS },
options,
lease,
);
@@ -2061,46 +2168,126 @@ export async function openChromeMcpTab(
if (!created) {
throw new Error("Chrome MCP did not return the created page.");
}
if (targetUrl === "about:blank") {
let capturedNativeTargetId: string | undefined;
const closeUntrackedPage = async () => {
// Page creation already succeeded, so cleanup must not reuse an aborted
// caller signal that would leave the marker page untracked.
let directCloseError: unknown;
if (normalizedProfileOptions.browserUrl && markerUrl) {
try {
const nativeTargetId =
capturedNativeTargetId ??
(await lookupChromeMcpMarkerNativeTarget({
browserUrl: normalizedProfileOptions.browserUrl,
markerUrl,
options: { ...options, signal: undefined },
}));
if (nativeTargetId) {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(
normalizedProfileOptions.browserUrl,
);
await fetchOk(
appendCdpPath(cdpHttpBase, `/json/close/${encodeURIComponent(nativeTargetId)}`),
options.cdpTimeouts?.httpTimeoutMs,
undefined,
options.cdpPolicy,
);
const routing = getChromeMcpRoutingState(lease.session);
routing.targetIdByPageId.delete(created.page.id);
clearChromeMcpSnapshotRefsForTarget(routing, created.targetId);
return;
}
} catch (error) {
directCloseError = error;
}
}
if (!canUseMcpCompensation) {
throw directCloseError instanceof Error
? directCloseError
: new Error("Could not resolve the created Chrome MCP target", {
cause: directCloseError,
});
}
await callTool(
profileName,
normalizedProfileOptions,
"close_page",
{ pageId: created.page.id },
{ timeoutMs: CHROME_MCP_NEW_PAGE_TIMEOUT_MS },
lease,
);
const routing = getChromeMcpRoutingState(lease.session);
routing.targetIdByPageId.delete(created.page.id);
clearChromeMcpSnapshotRefsForTarget(routing, created.targetId);
};
try {
const captured = await captureChromeMcpTabOwnership({
profileName,
browserUrl: normalizedProfileOptions.browserUrl,
markerUrl,
options,
});
capturedNativeTargetId = captured.nativeTargetId;
if (!canUseMcpCompensation && captured.ownership.status !== "durable") {
throw new Error(
"Chrome MCP cannot safely track the first page without durable CDP ownership.",
);
}
if (targetUrl === initialUrl) {
return {
targetId: created.targetId,
title: "",
url: created.page.url ?? targetUrl,
type: "page",
ownership: captured.ownership,
};
}
const navigateCallTimeoutMs = resolveChromeMcpNavigateCallTimeoutMs(
CHROME_MCP_NAVIGATE_TIMEOUT_MS,
);
await callTool(
profileName,
normalizedProfileOptions,
"navigate_page",
{
pageId: created.page.id,
type: "url",
url: targetUrl,
timeout: CHROME_MCP_NAVIGATE_TIMEOUT_MS,
},
{ timeoutMs: navigateCallTimeoutMs, signal: options.signal },
lease,
);
const verified = await listChromeMcpTargetsWithLease({
profileName,
profileOptions: normalizedProfileOptions,
lease,
options: { timeoutMs: navigateCallTimeoutMs, signal: options.signal },
});
const finalPage = verified.find((entry) => entry.targetId === created.targetId);
if (!finalPage) {
throw new Error("Chrome MCP created page identity changed before navigation completed.");
}
return {
targetId: created.targetId,
title: "",
url: created.page.url ?? targetUrl,
url: finalPage.page.url ?? targetUrl,
type: "page",
ownership: captured.ownership,
};
} catch (openError) {
try {
await closeUntrackedPage();
} catch (closeError) {
throw Object.assign(
new Error("Failed to open a tracked Chrome MCP page and close its marker", {
cause: openError,
}),
{ errors: [openError, closeError] },
);
}
throw openError;
}
const navigateCallTimeoutMs = resolveChromeMcpNavigateCallTimeoutMs(
CHROME_MCP_NAVIGATE_TIMEOUT_MS,
);
await callTool(
profileName,
normalizedProfileOptions,
"navigate_page",
{
pageId: created.page.id,
type: "url",
url: targetUrl,
timeout: CHROME_MCP_NAVIGATE_TIMEOUT_MS,
},
{ timeoutMs: navigateCallTimeoutMs, signal: options.signal },
lease,
);
const verified = await listChromeMcpTargetsWithLease({
profileName,
profileOptions: normalizedProfileOptions,
lease,
options: { timeoutMs: navigateCallTimeoutMs, signal: options.signal },
});
const finalPage = verified.find((entry) => entry.targetId === created.targetId);
if (!finalPage) {
throw new Error("Chrome MCP created page identity changed before navigation completed.");
}
return {
targetId: created.targetId,
title: "",
url: finalPage.page.url ?? targetUrl,
type: "page",
};
},
);
}
+22 -15
View File
@@ -11,6 +11,7 @@ import {
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
import type {
BrowserOpenResult,
BrowserStatus,
BrowserTab,
BrowserTransport,
@@ -70,12 +71,15 @@ async function sendTabTargetRequest(params: {
method: "POST" | "DELETE";
opts: BrowserClientProfileOptions | undefined;
body?: object;
}): Promise<void> {
await fetchBrowserJson(withProfilePath(params.baseUrl, params.path, params.opts?.profile), {
method: params.method,
...(params.body ? { headers: JSON_HEADERS, body: JSON.stringify(params.body) } : {}),
timeoutMs: resolveBrowserClientTimeoutMs(params.opts, 5000),
});
}): Promise<{ ok: true; targetId?: string }> {
return await fetchBrowserJson(
withProfilePath(params.baseUrl, params.path, params.opts?.profile),
{
method: params.method,
...(params.body ? { headers: JSON_HEADERS, body: JSON.stringify(params.body) } : {}),
timeoutMs: resolveBrowserClientTimeoutMs(params.opts, 5000),
},
);
}
/** Profile status record returned by browser profile listing. */
@@ -341,13 +345,16 @@ export async function browserOpenTab(
baseUrl: string | undefined,
url: string,
opts?: { profile?: string; label?: string; timeoutMs?: number },
): Promise<BrowserTab> {
return await fetchBrowserJson<BrowserTab>(withProfilePath(baseUrl, "/tabs/open", opts?.profile), {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ url, ...(opts?.label ? { label: opts.label } : {}) }),
timeoutMs: resolveBrowserClientTimeoutMs(opts, 15000),
});
): Promise<BrowserOpenResult> {
return await fetchBrowserJson<BrowserOpenResult>(
withProfilePath(baseUrl, "/tabs/open", opts?.profile),
{
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify({ url, ...(opts?.label ? { label: opts.label } : {}) }),
timeoutMs: resolveBrowserClientTimeoutMs(opts, 15000),
},
);
}
/** Focus an existing browser tab. */
@@ -355,9 +362,9 @@ export async function browserFocusTab(
baseUrl: string | undefined,
targetId: string,
opts?: { profile?: string; timeoutMs?: number },
): Promise<void> {
): Promise<{ ok: true; targetId?: string }> {
const body = { targetId };
await sendTabTargetRequest({ baseUrl, path: "/tabs/focus", method: "POST", opts, body });
return await sendTabTargetRequest({ baseUrl, path: "/tabs/focus", method: "POST", opts, body });
}
/** Close an existing browser tab. */
@@ -59,6 +59,24 @@ export type BrowserGraphicsDiagnostics =
reason: string;
};
export type BrowserTabOwnership =
| {
status: "durable";
nativeTargetId: string;
profileFingerprint: string;
browserInstanceFingerprint: string;
}
| {
status: "non-durable";
reason:
| "explicit-cdp-url-required"
| "target-marker-not-unique"
| "target-marker-lookup-failed"
| "target-lookup-failed"
| "browser-identity-unavailable"
| "browser-identity-lookup-failed";
};
/** Browser status response returned by the control server. */
export type BrowserStatus = {
enabled: boolean;
@@ -111,6 +129,12 @@ export type BrowserTab = {
type?: string;
};
/** Internal tab-open result. Browser tools must remove internal metadata before model output. */
export type BrowserOpenResult = BrowserTab & {
ownership?: BrowserTabOwnership;
resolvedProfile?: string;
};
/** ARIA snapshot node exposed in structured snapshot responses. */
export type SnapshotAriaNode = {
ref: string;
@@ -157,7 +157,7 @@ function createRouteContext(
async function callTabsRoute(params: {
method: "get" | "post";
path: "/tabs" | "/tabs/action" | "/tabs/focus";
path: "/tabs" | "/tabs/action" | "/tabs/focus" | "/tabs/open";
body?: Record<string, unknown>;
profileCtx: ProfileContext;
actionTimeoutMs?: number;
@@ -189,6 +189,45 @@ async function callTabsRoute(params: {
return response;
}
it("returns the profile that actually handled tab open", async () => {
const profileCtx = createProfileContext({
profile: { name: "hot-profile" },
openTab: vi.fn(async () => ({
targetId: "HOT-TAB",
title: "Hot",
url: "https://example.com",
type: "page" as const,
ownership: {
status: "durable" as const,
nativeTargetId: "HOT-NATIVE",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
})),
});
const response = await callTabsRoute({
method: "post",
path: "/tabs/open",
body: { url: "https://example.com" },
profileCtx,
});
expect(response.body).toEqual({
targetId: "HOT-TAB",
title: "Hot",
url: "https://example.com",
type: "page",
ownership: {
status: "durable",
nativeTargetId: "HOT-NATIVE",
profileFingerprint: "sha256:profile",
browserInstanceFingerprint: "sha256:browser",
},
resolvedProfile: "hot-profile",
});
});
async function callTabsAction(params: {
body: Record<string, unknown>;
profileCtx: ProfileContext;
@@ -591,7 +630,7 @@ describe("browser tab routes", () => {
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ ok: true });
expect(response.body).toEqual({ ok: true, targetId: "T2" });
expect(profileCtx.focusTab).toHaveBeenCalledWith("T2", { exactTargetId: true });
expect(profileCtx.ensureTabAvailable).not.toHaveBeenCalled();
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
+13 -4
View File
@@ -192,7 +192,11 @@ async function runTabTargetMutation(params: {
res: BrowserResponse;
ctx: BrowserRouteContext;
targetId: string;
mutate: (profileCtx: ProfileContext, targetId: string, signal: AbortSignal) => Promise<void>;
mutate: (
profileCtx: ProfileContext,
targetId: string,
signal: AbortSignal,
) => Promise<string | void>;
}) {
const result = await runTabsProfileRoute({
req: params.req,
@@ -201,8 +205,11 @@ async function runTabTargetMutation(params: {
mapTabError: true,
run: async (profileCtx, signal) => {
await ensureBrowserRunning(params.ctx, profileCtx, signal);
await params.mutate(profileCtx, params.targetId, signal);
return { ok: true } as const;
const canonicalTargetId = await params.mutate(profileCtx, params.targetId, signal);
return {
ok: true,
...(canonicalTargetId ? { targetId: canonicalTargetId } : {}),
} as const;
},
});
if (result) {
@@ -253,7 +260,8 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
...browserNavigationPolicyForProfile(ctx, profileCtx),
});
await profileCtx.ensureBrowserAvailable({ signal });
return await profileCtx.openTab(url, { label });
const opened = await profileCtx.openTab(url, { label });
return { ...opened, resolvedProfile: profileCtx.profile.name };
},
});
if (result) {
@@ -292,6 +300,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
});
}
await profileCtx.focusTab(resolved.targetId, { exactTargetId: true });
return resolved.targetId;
},
});
});
@@ -286,12 +286,27 @@ describe("browser server-context existing-session profile", () => {
expect(listCall?.[1]?.name).toBe("chrome-live");
expect(listCall?.[1]?.driver).toBe("existing-session");
const [openCall] = vi.mocked(chromeMcp.openChromeMcpTab).mock.calls as unknown as Array<
[string, string, ChromeLiveProfile]
[
string,
string,
ChromeLiveProfile,
{
signal?: AbortSignal;
cdpTimeouts?: { httpTimeoutMs?: number; handshakeTimeoutMs?: number };
},
]
>;
expect(openCall?.[0]).toBe("chrome-live");
expect(openCall?.[1]).toBe("about:blank");
expect(openCall?.[2]?.name).toBe("chrome-live");
expect(openCall?.[2]?.driver).toBe("existing-session");
expect(openCall?.[3]).toMatchObject({
signal: expect.any(AbortSignal),
cdpTimeouts: {
httpTimeoutMs: state.resolved.remoteCdpTimeoutMs,
handshakeTimeoutMs: state.resolved.remoteCdpHandshakeTimeoutMs,
},
});
const [focusCall] = vi.mocked(chromeMcp.focusChromeMcpTab).mock.calls as unknown as Array<
[string, string, ChromeLiveProfile]
>;
@@ -270,7 +270,8 @@ describe("browser remote profile fallback and attachOnly behavior", () => {
const { remote } = deps.createRemoteRouteHarness(fetchMock);
const opened = await remote.openTab("https://1.example");
expect(opened.targetId).toBe("T1");
expect(fetchMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledOnce();
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/json/version");
});
it("passes configured remote CDP timeouts when opening tabs through raw CDP", async () => {
@@ -55,7 +55,17 @@ describe("browser remote profile tab ops via Playwright", () => {
closePageByTargetIdViaPlaywright,
} as unknown as Awaited<ReturnType<typeof deps.pwAiModule.getPwAiModule>>);
const { state, remote, fetchMock } = deps.createRemoteRouteHarness();
const fetchMock = vi.fn(async (url: unknown) => {
expect(String(url)).toContain("/json/version");
return {
ok: true,
json: async () => ({
webSocketDebuggerUrl:
"wss://1.1.1.1:9222/devtools/browser/REMOTE-BROWSER?auth=fixture-value",
}),
} as unknown as Response;
});
const { state, remote } = deps.createRemoteRouteHarness(fetchMock);
const tabs = await remote.listTabs();
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
@@ -67,6 +77,12 @@ describe("browser remote profile tab ops via Playwright", () => {
const opened = await remote.openTab("http://127.0.0.1:3000");
expect(opened.targetId).toBe("T2");
expect((opened as { ownership?: unknown }).ownership).toMatchObject({
status: "durable",
nativeTargetId: "T2",
profileFingerprint: expect.stringMatching(/^sha256:/),
browserInstanceFingerprint: expect.stringMatching(/^sha256:/),
});
expect(state.profiles.get("remote")?.lastTargetId).toBe("T2");
expect(createPageViaPlaywright).toHaveBeenCalledWith({
cdpUrl: "https://1.1.1.1:9222/chrome?token=abc",
@@ -81,7 +97,74 @@ describe("browser remote profile tab ops via Playwright", () => {
targetId: "T1",
ssrfPolicy: permissiveRemoteCdpPolicy,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledOnce();
});
it("uses the remote HTTP timeout for the ownership version probe", async () => {
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue({
createPageViaPlaywright: vi.fn(async () => page("T2")),
} as unknown as Awaited<ReturnType<typeof deps.pwAiModule.getPwAiModule>>);
const fetchMock = vi.fn(
async (_url: unknown, init?: RequestInit) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() => reject(new Error("ownership version probe timed out")),
{ once: true },
);
}),
);
const { state, remote } = deps.createRemoteRouteHarness(fetchMock);
state.resolved.remoteCdpTimeoutMs = 25;
const startedAt = Date.now();
const opened = await remote.openTab("https://t2.example");
expect(Date.now() - startedAt).toBeLessThan(700);
expect((opened as { ownership?: unknown }).ownership).toEqual({
status: "non-durable",
reason: "browser-identity-lookup-failed",
});
});
it("propagates caller abort through the ownership version probe", async () => {
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue({
createPageViaPlaywright: vi.fn(async () => page("T2")),
} as unknown as Awaited<ReturnType<typeof deps.pwAiModule.getPwAiModule>>);
let markProbeStarted!: () => void;
const probeStarted = new Promise<void>((resolve) => {
markProbeStarted = resolve;
});
const cleanupUrls: string[] = [];
const fetchMock = vi.fn(async (url: unknown, init?: RequestInit) => {
if (String(url).includes("/json/close/T2")) {
cleanupUrls.push(String(url));
return new Response(null, { status: 200 });
}
return await new Promise<Response>((_resolve, reject) => {
markProbeStarted();
init?.signal?.addEventListener(
"abort",
() =>
reject(
init.signal?.reason instanceof Error
? init.signal.reason
: new Error("ownership version probe aborted"),
),
{ once: true },
);
});
});
const { remote } = deps.createRemoteRouteHarness(fetchMock);
const controller = new AbortController();
const abortError = new Error("caller aborted ownership probe");
const opening = remote.openTab("https://t2.example", { signal: controller.signal });
await probeStarted;
controller.abort(abortError);
await expect(opening).rejects.toBe(abortError);
expect(cleanupUrls).toEqual([expect.stringContaining("/json/close/T2")]);
});
it("rejects invalid labels before Playwright creates a page", async () => {
@@ -11,6 +11,7 @@ import {
fetchJson,
fetchOk,
normalizeCdpHttpBaseForJsonEndpoints,
resolveCdpTabOwnership,
} from "./cdp.helpers.js";
import {
appendCdpPath,
@@ -20,6 +21,7 @@ import {
} from "./cdp.js";
import type { CdpActionTimeouts } from "./cdp.js";
import { getChromeMcpModule } from "./chrome-mcp.runtime.js";
import type { BrowserOpenResult } from "./client.types.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
import {
@@ -61,7 +63,7 @@ type ProfileTabOps = {
openTab: (
url: string,
opts?: { label?: string; signal?: AbortSignal; timeoutMs?: number },
) => Promise<BrowserTab>;
) => Promise<BrowserOpenResult>;
labelTab: (
targetId: string,
label: string,
@@ -235,10 +237,51 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
return adopted;
};
const withTabOwnership = async (
tab: BrowserTab,
options?: BrowserOperationOptions,
): Promise<BrowserOpenResult> => {
const cdpTimeouts = getRemoteCdpActionTimeouts();
let ownership: BrowserOpenResult["ownership"];
try {
ownership = await resolveCdpTabOwnership({
profileName: profile.name,
cdpUrl: profile.cdpUrl,
nativeTargetId: tab.targetId,
signal: options?.signal,
timeoutMs: cdpTimeouts?.httpTimeoutMs,
ssrfPolicy: getCdpControlPolicy(),
});
} catch (ownershipError) {
try {
// Ownership probing happens after target creation. Cleanup must not
// inherit a caller abort that would strand the new untracked page.
await fetchOk(
appendCdpPath(cdpHttpBase, `/json/close/${encodeURIComponent(tab.targetId)}`),
state().resolved.remoteCdpTimeoutMs,
undefined,
getCdpControlPolicy(),
);
} catch (closeError) {
throw Object.assign(
new Error("Failed to resolve browser tab ownership and close the new target", {
cause: ownershipError,
}),
{ errors: [ownershipError, closeError] },
);
}
throw ownershipError;
}
return {
...tab,
ownership,
};
};
const openTab = async (
url: string,
opts?: { label?: string; signal?: AbortSignal; timeoutMs?: number },
): Promise<BrowserTab> => {
): Promise<BrowserOpenResult> => {
opts?.signal?.throwIfAborted();
const normalizedLabel = opts?.label === undefined ? undefined : normalizeTabLabel(opts.label);
const ssrfPolicyOpts = getNavigationPolicy();
@@ -246,7 +289,13 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
if (capabilities.usesChromeMcp) {
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const { openChromeMcpTab } = await getChromeMcpModule();
const page = await openChromeMcpTab(profile.name, url, profile, opts);
const cdpTimeouts = getRemoteCdpActionTimeouts();
const page = await openChromeMcpTab(profile.name, url, profile, {
signal: opts?.signal,
timeoutMs: opts?.timeoutMs,
cdpPolicy: getCdpControlPolicy(),
...(cdpTimeouts ? { cdpTimeouts } : {}),
});
await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });
return adoptValidatedTab(page, { ...opts, label: normalizedLabel });
}
@@ -262,12 +311,15 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
...ssrfPolicyOpts,
});
return adoptValidatedTab(
{
targetId: page.targetId,
title: page.title,
url: page.url,
type: page.type,
},
await withTabOwnership(
{
targetId: page.targetId,
title: page.title,
url: page.url,
type: page.type,
},
opts,
),
{ ...opts, label: normalizedLabel },
);
}
@@ -300,7 +352,15 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
if (!createdViaCdp.finalUrl) {
// The target exists, but its committed document is not authoritative.
// Preserve the explicit result without sticky, alias, or cleanup adoption.
return { targetId: createdViaCdp.targetId, title: "", url, type: "page" };
return await withTabOwnership(
{
targetId: createdViaCdp.targetId,
title: "",
url,
type: "page",
},
opts,
);
}
await assertBrowserNavigationResultAllowed({
url: createdViaCdp.finalUrl,
@@ -316,7 +376,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
// The attached target owns the committed URL; /json/list supplies the
// remaining metadata and may briefly lag that exact document snapshot.
return adoptValidatedTab(
{ ...found, url: createdViaCdp.finalUrl },
await withTabOwnership({ ...found, url: createdViaCdp.finalUrl }, opts),
{ ...opts, label: normalizedLabel },
);
}
@@ -325,12 +385,15 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
opts?.signal?.throwIfAborted();
// Preserve the explicit target-id result for callers, but do not adopt an
// undiscovered target into sticky, alias, or managed-cleanup state.
return {
targetId: createdViaCdp.targetId,
title: "",
url: createdViaCdp.finalUrl,
type: "page",
};
return await withTabOwnership(
{
targetId: createdViaCdp.targetId,
title: "",
url: createdViaCdp.finalUrl,
type: "page",
},
opts,
);
}
const encoded = encodeURIComponent(url);
@@ -383,23 +446,29 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
: undefined;
opts?.signal?.throwIfAborted();
if (!committedUrl) {
return {
targetId: created.id,
title: created.title ?? "",
url: resolvedUrl,
wsUrl,
type: created.type,
};
return await withTabOwnership(
{
targetId: created.id,
title: created.title ?? "",
url: resolvedUrl,
wsUrl,
type: created.type,
},
opts,
);
}
await assertBrowserNavigationResultAllowed({ url: committedUrl, ...ssrfPolicyOpts });
return adoptValidatedTab(
{
targetId: created.id,
title: created.title ?? "",
url: committedUrl,
wsUrl,
type: created.type,
},
await withTabOwnership(
{
targetId: created.id,
title: created.title ?? "",
url: committedUrl,
wsUrl,
type: created.type,
},
opts,
),
{ ...opts, label: normalizedLabel },
);
};
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withBrowserFetchPreconnect } from "../../test-fetch.js";
import "../test-support/browser-security.mock.js";
import "./server-context.chrome-test-harness.js";
import * as cdpModule from "./cdp.js";
import {
createTestBrowserRouteContext,
makeState,
originalFetch,
} from "./server-context.remote-tab-ops.harness.js";
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("browser tab ownership probes", () => {
it("propagates caller abort through the managed ownership version probe", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({
targetId: "CREATED",
finalUrl: "http://127.0.0.1:8080",
});
let versionSignal: AbortSignal | undefined;
let closedCreatedTarget = false;
let markProbeStarted!: () => void;
const probeStarted = new Promise<void>((resolve) => {
markProbeStarted = resolve;
});
const fetchMock = vi.fn(async (url: unknown, init?: RequestInit) => {
const value = String(url);
if (value.includes("/json/list")) {
return {
ok: true,
json: async () => [
{
id: "CREATED",
title: "New Tab",
url: "http://127.0.0.1:8080",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
type: "page",
},
],
} as unknown as Response;
}
if (value.includes("/json/version")) {
versionSignal = init?.signal ?? undefined;
markProbeStarted();
return await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() =>
reject(
init.signal?.reason instanceof Error
? init.signal.reason
: new Error("managed ownership probe aborted"),
),
{ once: true },
);
});
}
if (value.includes("/json/close/CREATED")) {
closedCreatedTarget = true;
return { ok: true } as Response;
}
throw new Error(`unexpected fetch: ${value}`);
});
global.fetch = withBrowserFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const openclaw = createTestBrowserRouteContext({ getState: () => state }).forProfile(
"openclaw",
);
const controller = new AbortController();
const abortError = new Error("caller aborted managed ownership probe");
const opening = openclaw.openTab("http://127.0.0.1:8080", {
signal: controller.signal,
});
await probeStarted;
controller.abort(abortError);
const propagatedImmediately = versionSignal?.aborted;
await expect(opening).rejects.toBe(abortError);
expect(propagatedImmediately).toBe(true);
expect(closedCreatedTarget).toBe(true);
});
});
@@ -108,6 +108,15 @@ describe("browser server-context tab selection state", () => {
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (u.includes("/json/version")) {
return {
ok: true,
json: async () => ({
webSocketDebuggerUrl:
"ws://127.0.0.1:18800/devtools/browser/MANAGED-BROWSER?auth=fixture-value",
}),
} as unknown as Response;
}
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
@@ -132,6 +141,12 @@ describe("browser server-context tab selection state", () => {
const opened = await openclaw.openTab("http://127.0.0.1:8080");
expect(opened.targetId).toBe("CREATED");
expect((opened as { ownership?: unknown }).ownership).toMatchObject({
status: "durable",
nativeTargetId: "CREATED",
profileFingerprint: expect.stringMatching(/^sha256:/),
browserInstanceFingerprint: expect.stringMatching(/^sha256:/),
});
expect(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED");
expect(createTargetViaCdp).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18800",
@@ -305,6 +320,10 @@ describe("browser server-context tab selection state", () => {
title: "",
url: "https://example.com/final",
type: "page",
ownership: {
status: "non-durable",
reason: "browser-identity-lookup-failed",
},
});
expect(profileState.lastTargetId).toBe("GOOD");
@@ -344,6 +363,10 @@ describe("browser server-context tab selection state", () => {
title: "",
url: "https://example.com",
type: "page",
ownership: {
status: "non-durable",
reason: "browser-identity-lookup-failed",
},
});
expect(profileState.lastTargetId).toBe("GOOD");
@@ -351,7 +374,7 @@ describe("browser server-context tab selection state", () => {
nextTabNumber: 2,
byTargetId: { GOOD: { tabId: "t1", label: "good", url: "about:blank" } },
});
expect(fetchMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledOnce();
});
it("rejects invalid labels before direct CDP target creation", async () => {
@@ -695,6 +718,10 @@ describe("browser server-context tab selection state", () => {
url: "https://example.com",
wsUrl: "ws://127.0.0.1:18800/devtools/page/RAW_UNSETTLED",
type: "page",
ownership: {
status: "non-durable",
reason: "browser-identity-lookup-failed",
},
});
expect(profileState.lastTargetId).toBe("GOOD");
expect(profileState.tabAliases?.byTargetId.RAW_UNSETTLED).toBeUndefined();
@@ -4,7 +4,7 @@
*/
import type { Server } from "node:http";
import type { RunningChrome } from "./chrome.js";
import type { BrowserTab, BrowserTransport } from "./client.types.js";
import type { BrowserOpenResult, BrowserTab, BrowserTransport } from "./client.types.js";
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
import type { BrowserErrorResponse } from "./errors.js";
import type { ExtensionRelayHandle } from "./extension-relay/relay-server.js";
@@ -80,7 +80,7 @@ type BrowserProfileActions = {
openTab: (
url: string,
opts?: { label?: string; signal?: AbortSignal; timeoutMs?: number },
) => Promise<BrowserTab>;
) => Promise<BrowserOpenResult>;
labelTab: (targetId: string, label: string) => Promise<BrowserTab>;
focusTab: (targetId: string, options?: BrowserTabTargetOptions) => Promise<void>;
closeTab: (targetId: string, options?: BrowserTabTargetOptions) => Promise<void>;
@@ -0,0 +1,57 @@
// Browser tests cover periodic session-tab cleanup failure handling.
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const registryMocks = vi.hoisted(() => ({
sweepTrackedBrowserTabs: vi.fn(),
}));
vi.mock("./session-tab-registry.js", () => registryMocks);
import { startTrackedBrowserTabCleanupTimer } from "./session-tab-cleanup.js";
describe("session tab cleanup timer", () => {
beforeEach(() => {
vi.useFakeTimers();
registryMocks.sweepTrackedBrowserTabs.mockReset();
clearRuntimeConfigSnapshot();
const config = {
browser: {
tabCleanup: {
enabled: true,
idleMinutes: 30,
maxTabsPerSession: 10,
sweepMinutes: 1,
},
},
};
setRuntimeConfigSnapshot(config, config);
});
afterEach(() => {
clearRuntimeConfigSnapshot();
vi.useRealTimers();
});
it("warns on sweep store failures and continues scheduling", async () => {
registryMocks.sweepTrackedBrowserTabs
.mockRejectedValueOnce(new Error("sqlite read failed"))
.mockResolvedValueOnce(0);
const onWarn = vi.fn();
const stop = startTrackedBrowserTabCleanupTimer({ onWarn });
await vi.advanceTimersByTimeAsync(60_000);
await vi.waitFor(() =>
expect(onWarn).toHaveBeenCalledWith(
"failed to sweep tracked browser tabs: Error: sqlite read failed",
),
);
await vi.advanceTimersByTimeAsync(60_000);
await vi.waitFor(() => expect(registryMocks.sweepTrackedBrowserTabs).toHaveBeenCalledTimes(2));
await stop();
});
});
@@ -78,10 +78,14 @@ export function startTrackedBrowserTabCleanupTimer(params: {
return;
}
if (!running) {
running = runTrackedBrowserTabCleanupOnce({ onWarn: params.onWarn }).finally(() => {
running = null;
schedule();
});
running = runTrackedBrowserTabCleanupOnce({ onWarn: params.onWarn })
.catch((error: unknown) => {
params.onWarn(`failed to sweep tracked browser tabs: ${String(error)}`);
})
.finally(() => {
running = null;
schedule();
});
return;
}
schedule();
@@ -0,0 +1,215 @@
/**
* Process-local aliases for durable storage keys and non-durable tab rows.
*/
type AliasIdentity = {
sessionKey: string;
targetId: string;
baseUrl?: string;
profile?: string;
};
type VolatileAliasTarget = {
sessionKey: string;
tabKey: string;
};
const durableAliasStateSymbol = Symbol.for(
"openclaw.browser.session-tabs.interaction-storage-keys",
);
const durableExactStateSymbol = Symbol.for(
"openclaw.browser.session-tabs.exact-interaction-storage-keys",
);
const volatileAliasStateSymbol = Symbol.for("openclaw.browser.session-tabs.volatile-aliases");
const volatileExactStateSymbol = Symbol.for("openclaw.browser.session-tabs.exact-volatile-aliases");
function interactionKey(identity: AliasIdentity): string {
return `${identity.sessionKey}\u0000${identity.baseUrl ?? ""}\u0000${identity.profile ?? ""}\u0000${identity.targetId}`;
}
function normalizedTargetIds(
identity: AliasIdentity,
aliases: Array<string | undefined>,
): Set<string> {
return new Set([
identity.targetId,
...aliases.flatMap((alias) => {
const targetId = alias?.trim();
return targetId ? [targetId] : [];
}),
]);
}
function normalizedProfiles(
identity: AliasIdentity,
aliases: Array<string | undefined>,
): Set<string | undefined> {
const profiles = new Set<string | undefined>([identity.profile]);
for (const alias of aliases) {
const profile = alias?.trim();
if (profile) {
profiles.add(profile);
}
}
return profiles;
}
function durableKeysByInteraction(): Map<string, Set<string>> {
const state = globalThis as typeof globalThis & {
[durableAliasStateSymbol]?: Map<string, Set<string>>;
};
state[durableAliasStateSymbol] ??= new Map();
return state[durableAliasStateSymbol];
}
function durableExactKeysByInteraction(): Map<string, Set<string>> {
const state = globalThis as typeof globalThis & {
[durableExactStateSymbol]?: Map<string, Set<string>>;
};
state[durableExactStateSymbol] ??= new Map();
return state[durableExactStateSymbol];
}
function removeStorageKey(mappings: Map<string, Set<string>>, storageKey: string): void {
for (const [key, storageKeys] of mappings) {
storageKeys.delete(storageKey);
if (storageKeys.size === 0) {
mappings.delete(key);
}
}
}
export function resetDurableTabAliases(): void {
durableKeysByInteraction().clear();
durableExactKeysByInteraction().clear();
}
export function clearDurableTabAliases(storageKey: string): void {
removeStorageKey(durableKeysByInteraction(), storageKey);
removeStorageKey(durableExactKeysByInteraction(), storageKey);
}
export function rememberDurableTabAliases(
identity: AliasIdentity,
aliases: Array<string | undefined>,
storageKey: string,
profileAliases: Array<string | undefined> = [],
): void {
clearDurableTabAliases(storageKey);
const mappings = durableKeysByInteraction();
const exactMappings = durableExactKeysByInteraction();
for (const profile of normalizedProfiles(identity, profileAliases)) {
const exactKey = interactionKey({ ...identity, profile });
const exactStorageKeys = exactMappings.get(exactKey) ?? new Set<string>();
exactStorageKeys.add(storageKey);
exactMappings.set(exactKey, exactStorageKeys);
for (const targetId of normalizedTargetIds(identity, aliases)) {
const key = interactionKey({ ...identity, profile, targetId });
const storageKeys = mappings.get(key) ?? new Set<string>();
storageKeys.add(storageKey);
mappings.set(key, storageKeys);
}
}
}
export function resolveDurableTabAlias(identity: AliasIdentity): string | undefined {
const storageKeys = durableKeysByInteraction().get(interactionKey(identity));
return storageKeys?.size === 1 ? storageKeys.values().next().value : undefined;
}
export function hasDurableTabAlias(identity: AliasIdentity): boolean {
return (durableKeysByInteraction().get(interactionKey(identity))?.size ?? 0) > 0;
}
export function resolveDurableTabExact(identity: AliasIdentity): string | undefined {
const storageKeys = durableExactKeysByInteraction().get(interactionKey(identity));
return storageKeys?.size === 1 ? storageKeys.values().next().value : undefined;
}
export function hasDurableTabExact(identity: AliasIdentity): boolean {
return (durableExactKeysByInteraction().get(interactionKey(identity))?.size ?? 0) > 0;
}
function volatileAliasTargetKey(target: VolatileAliasTarget): string {
return JSON.stringify([target.sessionKey, target.tabKey]);
}
function volatileAliasesByInteraction(): Map<string, Map<string, VolatileAliasTarget>> {
const state = globalThis as typeof globalThis & {
[volatileAliasStateSymbol]?: Map<string, Map<string, VolatileAliasTarget>>;
};
state[volatileAliasStateSymbol] ??= new Map();
return state[volatileAliasStateSymbol];
}
function volatileExactTargetsByInteraction(): Map<string, Map<string, VolatileAliasTarget>> {
const state = globalThis as typeof globalThis & {
[volatileExactStateSymbol]?: Map<string, Map<string, VolatileAliasTarget>>;
};
state[volatileExactStateSymbol] ??= new Map();
return state[volatileExactStateSymbol];
}
function removeVolatileTarget(
mappings: Map<string, Map<string, VolatileAliasTarget>>,
targetKey: string,
): void {
for (const [key, targets] of mappings) {
targets.delete(targetKey);
if (targets.size === 0) {
mappings.delete(key);
}
}
}
export function clearVolatileTabAliases(sessionKey: string, tabKey: string): void {
const targetKey = volatileAliasTargetKey({ sessionKey, tabKey });
removeVolatileTarget(volatileAliasesByInteraction(), targetKey);
removeVolatileTarget(volatileExactTargetsByInteraction(), targetKey);
}
export function rememberVolatileTabAliases(
identity: AliasIdentity,
aliases: Array<string | undefined>,
tabKey: string,
profileAliases: Array<string | undefined> = [],
): void {
clearVolatileTabAliases(identity.sessionKey, tabKey);
const target = { sessionKey: identity.sessionKey, tabKey };
const mappings = volatileAliasesByInteraction();
const exactMappings = volatileExactTargetsByInteraction();
for (const profile of normalizedProfiles(identity, profileAliases)) {
const exactKey = interactionKey({ ...identity, profile });
const exactTargets = exactMappings.get(exactKey) ?? new Map<string, VolatileAliasTarget>();
exactTargets.set(volatileAliasTargetKey(target), target);
exactMappings.set(exactKey, exactTargets);
for (const targetId of normalizedTargetIds(identity, aliases)) {
const key = interactionKey({ ...identity, profile, targetId });
const targets = mappings.get(key) ?? new Map<string, VolatileAliasTarget>();
targets.set(volatileAliasTargetKey(target), target);
mappings.set(key, targets);
}
}
}
export function resolveVolatileTabAlias(identity: AliasIdentity): VolatileAliasTarget | undefined {
const targets = volatileAliasesByInteraction().get(interactionKey(identity));
return targets?.size === 1 ? targets.values().next().value : undefined;
}
export function hasVolatileTabAlias(identity: AliasIdentity): boolean {
return (volatileAliasesByInteraction().get(interactionKey(identity))?.size ?? 0) > 0;
}
export function resolveVolatileTabExact(identity: AliasIdentity): VolatileAliasTarget | undefined {
const targets = volatileExactTargetsByInteraction().get(interactionKey(identity));
return targets?.size === 1 ? targets.values().next().value : undefined;
}
export function hasVolatileTabExact(identity: AliasIdentity): boolean {
return (volatileExactTargetsByInteraction().get(interactionKey(identity))?.size ?? 0) > 0;
}
export function forgetVolatileTabAlias(identity: AliasIdentity): void {
volatileAliasesByInteraction().delete(interactionKey(identity));
volatileExactTargetsByInteraction().delete(interactionKey(identity));
}
@@ -0,0 +1,65 @@
export type SessionTabInteractionIdentity = {
sessionKey: string;
targetId: string;
baseUrl?: string;
profile?: string;
};
export type VolatileSessionTab = SessionTabInteractionIdentity & {
kind: "volatile";
trackedAt: number;
lastUsedAt: number;
};
const volatileStateSymbol = Symbol.for("openclaw.browser.session-tabs.volatile");
const activeDurableStateSymbol = Symbol.for("openclaw.browser.session-tabs.active-durable-keys");
const coldNativeActivityStateSymbol = Symbol.for(
"openclaw.browser.session-tabs.cold-native-activity",
);
export function activeDurableStorageKeys(): Set<string> {
const state = globalThis as typeof globalThis & {
[activeDurableStateSymbol]?: Set<string>;
};
state[activeDurableStateSymbol] ??= new Set();
return state[activeDurableStateSymbol];
}
export function volatileTabsBySession(): Map<string, Map<string, VolatileSessionTab>> {
const state = globalThis as typeof globalThis & {
[volatileStateSymbol]?: Map<string, Map<string, VolatileSessionTab>>;
};
state[volatileStateSymbol] ??= new Map();
return state[volatileStateSymbol];
}
export function deleteVolatileSessionTab(sessionKey: string, tabKey: string): void {
const state = volatileTabsBySession();
const tabs = state.get(sessionKey);
tabs?.delete(tabKey);
clearVolatileTabAliases(sessionKey, tabKey);
if (tabs?.size === 0) {
state.delete(sessionKey);
}
}
function coldNativeActivity(): Map<string, number> {
const state = globalThis as typeof globalThis & {
[coldNativeActivityStateSymbol]?: Map<string, number>;
};
state[coldNativeActivityStateSymbol] ??= new Map();
return state[coldNativeActivityStateSymbol];
}
export function rememberColdNativeActivity(identity: string, now: number): void {
coldNativeActivity().set(identity, now);
}
export function forgetColdNativeActivity(identity: string): void {
coldNativeActivity().delete(identity);
}
export function readColdNativeActivity(identity: string): number | undefined {
return coldNativeActivity().get(identity);
}
import { clearVolatileTabAliases } from "./session-tab-ephemeral-aliases.js";
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
// Browser tests cover session tab registry plugin behavior.
// Browser tests cover process-local session tab cleanup behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const clientMocks = vi.hoisted(() => ({
@@ -53,13 +53,13 @@ describe("session tab registry", () => {
profile: "OpenClaw",
});
const closeTab = vi.fn(async () => {});
const closed = await closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab,
});
expect(closed).toBe(2);
expect(closeTab).toHaveBeenCalledTimes(2);
await expect(
closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab,
}),
).resolves.toBe(2);
expect(closeTab).toHaveBeenNthCalledWith(1, {
targetId: "tab-a",
baseUrl: "http://127.0.0.1:9222",
@@ -83,7 +83,6 @@ describe("session tab registry", () => {
await expect(
closeTrackedBrowserTabsForSessions({ sessionKeys: ["agent:main:main"] }),
).resolves.toBe(1);
expect(clientMocks.browserCloseTabByRawTargetId).toHaveBeenCalledWith(
"http://127.0.0.1:9222",
"RAW_TARGET",
@@ -91,28 +90,24 @@ describe("session tab registry", () => {
);
});
it("untracks specific tabs", async () => {
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "tab-a",
});
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "tab-b",
});
untrackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "tab-a",
});
it("untracks a specific tab and never adopts unknown user tabs", async () => {
trackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "tab-a" });
trackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "tab-b" });
untrackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "tab-a" });
const closeTab = vi.fn(async () => {});
const closed = await closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab,
});
expect(closed).toBe(1);
expect(closeTab).toHaveBeenCalledTimes(1);
await expect(
closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:unknown"],
closeTab,
}),
).resolves.toBe(0);
await expect(
closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab,
}),
).resolves.toBe(1);
expect(closeTab).toHaveBeenCalledWith({
targetId: "tab-b",
baseUrl: undefined,
@@ -120,60 +115,126 @@ describe("session tab registry", () => {
});
});
it("deduplicates tabs and ignores expected close errors", async () => {
it("touches and untracks a volatile tab through same-process aliases", async () => {
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "tab-a",
targetId: "RAW-A",
profile: "openclaw",
ownership: { status: "non-durable", reason: "browser-identity-lookup-failed" },
aliases: ["RAW-A", "t1", "docs"],
now: 1_000,
});
trackSessionBrowserTab({
sessionKey: "main",
targetId: "tab-a",
touchSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "docs",
profile: "openclaw",
now: 9_000,
});
trackSessionBrowserTab({
sessionKey: "main",
targetId: "tab-b",
});
const warnings: string[] = [];
const closeTab = vi
.fn()
.mockRejectedValueOnce(new Error("target not found"))
.mockRejectedValueOnce(new Error("network down"));
const closeTab = vi.fn(async () => {});
const closed = await closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main", "main"],
closeTab,
onWarn: (message) => warnings.push(message),
await expect(sweepTrackedBrowserTabs({ now: 10_000, idleMs: 5_000, closeTab })).resolves.toBe(
0,
);
untrackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "t1",
profile: "openclaw",
});
expect(closed).toBe(0);
expect(closeTab).toHaveBeenCalledTimes(2);
expect(warnings).toEqual(["failed to close tracked browser tab tab-b: Error: network down"]);
await expect(
closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab,
}),
).resolves.toBe(0);
expect(closeTab).not.toHaveBeenCalled();
});
it("sweeps idle tracked tabs and keeps recently touched tabs", async () => {
it("isolates volatile aliases by browser surface", async () => {
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "RAW-A",
baseUrl: "http://127.0.0.1:9001",
profile: "openclaw",
aliases: ["shared"],
now: 1_000,
});
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "RAW-B",
baseUrl: "http://127.0.0.1:9002",
profile: "openclaw",
aliases: ["shared"],
now: 1_000,
});
touchSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "shared",
baseUrl: "http://127.0.0.1:9001",
profile: "openclaw",
now: 9_000,
});
const closeTab = vi.fn(async () => {});
await expect(sweepTrackedBrowserTabs({ now: 10_000, idleMs: 5_000, closeTab })).resolves.toBe(
1,
);
expect(closeTab).toHaveBeenCalledWith({
targetId: "RAW-B",
baseUrl: "http://127.0.0.1:9002",
profile: "openclaw",
});
});
it("retries transient close failures and retires missing targets", async () => {
trackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "missing" });
trackSessionBrowserTab({ sessionKey: "agent:main:main", targetId: "transient" });
const warnings: string[] = [];
const firstClose = vi.fn(async ({ targetId }: { targetId: string }) => {
if (targetId === "missing") {
throw new Error("No target with given id found");
}
throw new Error("network down");
});
await expect(
closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab: firstClose,
onWarn: (message) => warnings.push(message),
}),
).resolves.toBe(0);
expect(warnings).toEqual([
"failed to close tracked browser tab transient: Error: network down",
]);
const retryClose = vi.fn(async () => {});
await expect(
closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab: retryClose,
}),
).resolves.toBe(1);
expect(retryClose).toHaveBeenCalledWith({
targetId: "transient",
baseUrl: undefined,
profile: undefined,
});
});
it("sweeps idle tabs while preserving recently touched tabs", async () => {
vi.setSystemTime(1_000);
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "old-tab",
});
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "active-tab",
});
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);
await expect(sweepTrackedBrowserTabs({ now: 11_000, idleMs: 5_000, closeTab })).resolves.toBe(
1,
);
expect(closeTab).toHaveBeenCalledWith({
targetId: "old-tab",
baseUrl: undefined,
@@ -187,54 +248,32 @@ describe("session tab registry", () => {
).resolves.toBe(1);
});
it("caps tracked tabs per session by closing least recently used tabs first", async () => {
it("caps each session by least-recently-used order and honors session filters", 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,
trackSessionBrowserTab({
sessionKey: "agent:main:subagent:child",
targetId: "child-tab",
});
const closeTab = vi.fn(async () => {});
expect(closed).toBe(1);
await expect(
sweepTrackedBrowserTabs({
now: 4_000,
maxTabsPerSession: 2,
sessionFilter: (sessionKey) => !sessionKey.includes(":subagent:"),
closeTab,
}),
).resolves.toBe(1);
expect(closeTab).toHaveBeenCalledWith({
targetId: "tab-a",
baseUrl: undefined,
profile: undefined,
});
await expect(
closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab: async () => {},
}),
).resolves.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,
});
await expect(
closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:subagent:child"],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,216 @@
import { createHash } from "node:crypto";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
import {
getBrowserStateRuntime,
getOptionalBrowserStateRuntime,
setBrowserStateRuntime,
} from "../browser-runtime-state.js";
import {
rememberDurableTabAliases,
resetDurableTabAliases,
} from "./session-tab-ephemeral-aliases.js";
const BROWSER_SESSION_TABS_NAMESPACE = "browser.session-tabs";
const BROWSER_SESSION_TABS_MAX_ENTRIES = 5_000;
export type BrowserSessionTabRecord = {
version: 1;
sessionKey: string;
nativeTargetId: string;
profile: string;
profileAliases?: string[];
profileFingerprint: string;
browserInstanceFingerprint: string;
interactionTargetKind: "native" | "opaque";
trackedAt: number;
lastUsedAt: number;
cleanupRequestedAt?: number;
cleanupAttemptToken?: string;
cleanupKind?: "lifecycle" | "sweep";
};
type BrowserSessionTabStoreRuntime = {
state: Pick<PluginRuntime["state"], "openSyncKeyedStore">;
};
/** Opens and publishes Browser's canonical durable tab store during plugin registration. */
export function initializeBrowserSessionTabStore(runtime: BrowserSessionTabStoreRuntime): void {
const sessionTabs = runtime.state.openSyncKeyedStore<unknown>({
namespace: BROWSER_SESSION_TABS_NAMESPACE,
maxEntries: BROWSER_SESSION_TABS_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
setBrowserStateRuntime({ sessionTabs });
resetDurableTabAliases();
for (const entry of sessionTabs.entries()) {
const record = parseBrowserSessionTabRecord(entry.value);
if (!record || browserSessionTabStorageKey(record) !== entry.key) {
continue;
}
rememberDurableTabAliases(
{
sessionKey: record.sessionKey,
targetId: record.nativeTargetId,
profile: record.profile,
},
[],
entry.key,
record.profileAliases,
);
}
}
export function getBrowserSessionTabStore() {
return getBrowserStateRuntime().sessionTabs;
}
export function getOptionalBrowserSessionTabStore() {
return getOptionalBrowserStateRuntime()?.sessionTabs;
}
export function browserSessionTabStorageKey(record: {
sessionKey: string;
nativeTargetId: string;
profileFingerprint: string;
browserInstanceFingerprint: string;
}): string {
return `sha256:${createHash("sha256")
.update(
JSON.stringify([
record.sessionKey,
record.nativeTargetId,
record.profileFingerprint,
record.browserInstanceFingerprint,
]),
)
.digest("hex")}`;
}
export function browserSessionTabNativeIdentity(
record: Pick<BrowserSessionTabRecord, "sessionKey" | "profile" | "nativeTargetId">,
): string {
return `${record.sessionKey}\u0000${record.profile}\u0000${record.nativeTargetId}`;
}
function isTimestamp(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
export function compareBrowserSessionTabProfileAliases(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function isCanonicalProfileAliases(
value: unknown,
profile: unknown,
): value is string[] | undefined {
if (value === undefined) {
return true;
}
if (
!Array.isArray(value) ||
value.length === 0 ||
value.some(
(entry) => typeof entry !== "string" || !entry || entry !== entry.trim().toLowerCase(),
)
) {
return false;
}
const canonical = [...new Set(value)].toSorted(compareBrowserSessionTabProfileAliases);
return (
!canonical.includes(String(profile)) &&
canonical.every((entry, index) => entry === value[index])
);
}
export function parseBrowserSessionTabRecord(value: unknown): BrowserSessionTabRecord | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const cleanupFieldsValid =
(record.cleanupRequestedAt === undefined &&
record.cleanupAttemptToken === undefined &&
record.cleanupKind === undefined) ||
(isTimestamp(record.cleanupRequestedAt) &&
typeof record.cleanupAttemptToken === "string" &&
record.cleanupAttemptToken.length > 0 &&
(record.cleanupKind === "lifecycle" || record.cleanupKind === "sweep"));
if (
record.version !== 1 ||
typeof record.sessionKey !== "string" ||
!record.sessionKey ||
typeof record.nativeTargetId !== "string" ||
!record.nativeTargetId ||
typeof record.profile !== "string" ||
!record.profile ||
!isCanonicalProfileAliases(record.profileAliases, record.profile) ||
typeof record.profileFingerprint !== "string" ||
!record.profileFingerprint ||
typeof record.browserInstanceFingerprint !== "string" ||
!record.browserInstanceFingerprint ||
(record.interactionTargetKind !== "native" && record.interactionTargetKind !== "opaque") ||
!isTimestamp(record.trackedAt) ||
!isTimestamp(record.lastUsedAt) ||
!cleanupFieldsValid ||
Object.hasOwn(record, "baseUrl") ||
Object.hasOwn(record, "interactionTargetId")
) {
return undefined;
}
return record as BrowserSessionTabRecord;
}
export function sameBrowserSessionTabRecord(
left: BrowserSessionTabRecord,
right: BrowserSessionTabRecord,
): boolean {
return (
left.version === right.version &&
left.sessionKey === right.sessionKey &&
left.nativeTargetId === right.nativeTargetId &&
left.profile === right.profile &&
(left.profileAliases?.length ?? 0) === (right.profileAliases?.length ?? 0) &&
(left.profileAliases ?? []).every((alias, index) => alias === right.profileAliases?.[index]) &&
left.profileFingerprint === right.profileFingerprint &&
left.browserInstanceFingerprint === right.browserInstanceFingerprint &&
left.interactionTargetKind === right.interactionTargetKind &&
left.trackedAt === right.trackedAt &&
left.lastUsedAt === right.lastUsedAt &&
left.cleanupRequestedAt === right.cleanupRequestedAt &&
left.cleanupAttemptToken === right.cleanupAttemptToken &&
left.cleanupKind === right.cleanupKind
);
}
export function withoutBrowserSessionTabCleanup(
record: BrowserSessionTabRecord,
): BrowserSessionTabRecord {
const active = { ...record };
delete active.cleanupRequestedAt;
delete active.cleanupAttemptToken;
delete active.cleanupKind;
return active;
}
export function updateBrowserSessionTab(
key: string,
update: (current: unknown) => BrowserSessionTabRecord | undefined,
): boolean {
const updateStore = getBrowserSessionTabStore().update;
if (!updateStore) {
throw new Error("Browser session tab store requires atomic update support");
}
return updateStore(key, update);
}
export function deleteBrowserSessionTabIf(
key: string,
predicate: (current: unknown) => boolean,
): boolean {
const deleteIf = getBrowserSessionTabStore().deleteIf;
if (!deleteIf) {
throw new Error("Browser session tab store requires atomic deleteIf support");
}
return deleteIf(key, predicate);
}
@@ -0,0 +1,114 @@
import {
activeDurableStorageKeys,
readColdNativeActivity,
type VolatileSessionTab,
} from "./session-tab-process-state.js";
import {
browserSessionTabNativeIdentity,
type BrowserSessionTabRecord,
} from "./session-tab-store.js";
type DurableTab = BrowserSessionTabRecord & {
kind: "durable";
storageKey: string;
};
type TrackedTab = VolatileSessionTab | DurableTab;
function trackedTabIdentity(tab: TrackedTab): string {
return tab.kind === "durable"
? `durable:${tab.storageKey}`
: `volatile:${tab.sessionKey}:${tab.targetId}\u0000${tab.baseUrl ?? ""}\u0000${tab.profile ?? ""}`;
}
export function selectStaleTrackedTabs(params: {
tabs: TrackedTab[];
now: number;
idleMs?: number;
maxTabsPerSession?: number;
sessionFilter?: (sessionKey: string) => boolean;
}): TrackedTab[] {
const selected = new Map<string, TrackedTab>();
const activeBySession = new Map<string, TrackedTab[]>();
const nativeIdentityCounts = new Map<string, number>();
const observedNativeActivity = new Map<string, number>();
for (const tab of params.tabs) {
if (tab.kind !== "durable" || tab.interactionTargetKind !== "native") {
continue;
}
const identity = browserSessionTabNativeIdentity(tab);
nativeIdentityCounts.set(identity, (nativeIdentityCounts.get(identity) ?? 0) + 1);
const observedAt = readColdNativeActivity(identity);
if (observedAt !== undefined) {
observedNativeActivity.set(tab.storageKey, observedAt);
}
}
const effectiveLastUsedAt = (tab: TrackedTab): number =>
tab.kind === "durable"
? Math.max(tab.lastUsedAt, observedNativeActivity.get(tab.storageKey) ?? 0)
: tab.lastUsedAt;
for (const tab of params.tabs) {
const observedAt =
tab.kind === "durable" ? observedNativeActivity.get(tab.storageKey) : undefined;
const isActiveDurable =
tab.kind === "durable" && activeDurableStorageKeys().has(tab.storageKey);
const isUnambiguousNative =
tab.kind === "durable" &&
tab.interactionTargetKind === "native" &&
nativeIdentityCounts.get(browserSessionTabNativeIdentity(tab)) === 1;
const activitySupersedesSweep =
tab.kind === "durable" &&
tab.cleanupKind === "sweep" &&
observedAt !== undefined &&
observedAt >= (tab.cleanupRequestedAt ?? 0);
// A prior lifecycle failure remains mandatory even when normal periodic
// sweeps exclude that terminated session.
if (tab.kind === "durable" && tab.cleanupAttemptToken && !activitySupersedesSweep) {
if (tab.cleanupKind === "lifecycle" || isActiveDurable || isUnambiguousNative) {
selected.set(trackedTabIdentity(tab), tab);
}
continue;
}
if (params.sessionFilter && !params.sessionFilter(tab.sessionKey)) {
continue;
}
// Process-scoped handles and ambiguous native ids cannot prove which durable
// row was used after restart. Lifecycle cleanup can still verify ownership,
// but an idle sweep must wait for safe process-local activity evidence.
if (
tab.kind === "durable" &&
!isActiveDurable &&
(tab.interactionTargetKind === "opaque" || !isUnambiguousNative)
) {
continue;
}
const active = activeBySession.get(tab.sessionKey) ?? [];
active.push(tab);
activeBySession.set(tab.sessionKey, active);
}
for (const tabs of activeBySession.values()) {
tabs.sort(
(left, right) =>
effectiveLastUsedAt(left) - effectiveLastUsedAt(right) || left.trackedAt - right.trackedAt,
);
if (params.idleMs && params.idleMs > 0) {
for (const tab of tabs) {
if (params.now - effectiveLastUsedAt(tab) >= params.idleMs) {
selected.set(trackedTabIdentity(tab), tab);
}
}
}
const remaining = tabs.filter((tab) => !selected.has(trackedTabIdentity(tab)));
if (
params.maxTabsPerSession &&
params.maxTabsPerSession > 0 &&
remaining.length > params.maxTabsPerSession
) {
for (const tab of remaining.slice(0, remaining.length - params.maxTabsPerSession)) {
selected.set(trackedTabIdentity(tab), tab);
}
}
}
return [...selected.values()];
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { selectSessionTabToUntrack } from "./session-tab-untrack-selection.js";
const missing = {
volatileAvailable: false,
durableAvailable: false,
hasVolatileCandidate: false,
hasDurableCandidate: false,
volatileIsExact: false,
durableIsExact: false,
hasVolatileExactCandidate: false,
hasDurableExactCandidate: false,
};
describe("session tab untrack selection", () => {
it.each([
["missing candidates", {}, "missing"],
["two aliases", { hasVolatileCandidate: true, hasDurableCandidate: true }, "ambiguous"],
[
"an exact volatile target over a durable alias",
{
volatileAvailable: true,
durableAvailable: true,
hasVolatileCandidate: true,
hasDurableCandidate: true,
volatileIsExact: true,
},
"volatile",
],
[
"an exact durable target over a volatile alias",
{
volatileAvailable: true,
durableAvailable: true,
hasVolatileCandidate: true,
hasDurableCandidate: true,
durableIsExact: true,
hasDurableExactCandidate: true,
},
"durable",
],
[
"exact targets in both ownership kinds",
{
durableAvailable: true,
hasVolatileCandidate: true,
hasDurableCandidate: true,
durableIsExact: true,
hasVolatileExactCandidate: true,
hasDurableExactCandidate: true,
},
"ambiguous",
],
] as const)("selects %s", (_label, state, expected) => {
expect(selectSessionTabToUntrack({ ...missing, ...state })).toBe(expected);
});
});
@@ -0,0 +1,30 @@
export function selectSessionTabToUntrack(params: {
volatileAvailable: boolean;
durableAvailable: boolean;
hasVolatileCandidate: boolean;
hasDurableCandidate: boolean;
volatileIsExact: boolean;
durableIsExact: boolean;
hasVolatileExactCandidate: boolean;
hasDurableExactCandidate: boolean;
}): "volatile" | "durable" | "ambiguous" | "missing" {
if (params.volatileIsExact && !params.hasDurableExactCandidate) {
return "volatile";
}
if (params.durableIsExact && !params.hasVolatileExactCandidate) {
return "durable";
}
if (params.hasVolatileCandidate && params.hasDurableCandidate) {
return "ambiguous";
}
if (params.volatileAvailable) {
return "volatile";
}
if (params.durableAvailable) {
return "durable";
}
if (params.hasVolatileCandidate || params.hasDurableCandidate) {
return "ambiguous";
}
return "missing";
}