mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
parent
2f7da3057c
commit
4074e0cae1
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
>;
|
||||
|
||||
+2
-1
@@ -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 () => {
|
||||
|
||||
+85
-2
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user