docs: document browser server context

This commit is contained in:
Peter Steinberger
2026-06-04 07:53:45 -04:00
parent 29ddb9d926
commit 5ab430fa11
18 changed files with 122 additions and 3 deletions

View File

@@ -1,3 +1,7 @@
/**
* Browser profile availability operations: reachability probes, managed Chrome
* launch/restart, Chrome MCP attach, and profile stop handling.
*/
import fs from "node:fs";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import {
@@ -133,6 +137,7 @@ function assertManagedLaunchNotCoolingDown(profileName: string, profileState: Pr
);
}
/** Builds reachability, ensure, and stop operations for one resolved browser profile. */
export function createProfileAvailability({
opts,
profile,

View File

@@ -1,3 +1,6 @@
/**
* Shared Chrome module mocks for Browser server-context tests.
*/
import { vi } from "vitest";
import { installChromeUserDataDirHooks } from "./chrome-user-data-dir.test-harness.js";

View File

@@ -1,5 +1,9 @@
/**
* Timing and size constants for Browser profile/tab runtime operations.
*/
import { DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS } from "./constants.js";
/** Maximum managed page tabs kept open before best-effort cleanup starts. */
export const MANAGED_BROWSER_PAGE_TAB_LIMIT = 8;
export const OPEN_TAB_DISCOVERY_WINDOW_MS = 2000;

View File

@@ -1,7 +1,12 @@
/**
* Browser profile lifecycle helpers shared by availability, reset, and runtime
* teardown.
*/
import type { ResolvedBrowserProfile } from "./config.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import { getPwAiModule } from "./pw-ai-module.js";
/** Resolves how an idle stop should behave for local, remote, or attach-only profiles. */
export function resolveIdleProfileStopOutcome(profile: ResolvedBrowserProfile): {
stopped: boolean;
closePlaywright: boolean;
@@ -19,6 +24,7 @@ export function resolveIdleProfileStopOutcome(profile: ResolvedBrowserProfile):
};
}
/** Closes cached Playwright CDP connections for one profile without requiring the module. */
export async function closePlaywrightBrowserConnectionForProfile(cdpUrl?: string): Promise<void> {
try {
const mod = await getPwAiModule({ mode: "soft" });

View File

@@ -1,5 +1,9 @@
/**
* Lazy-loaded dependency bundle for remote-profile tab operation tests.
*/
import { afterEach, beforeEach, vi } from "vitest";
/** Modules and helpers shared by remote-profile tab operation tests. */
export type RemoteProfileTestDeps = {
cdpModule: typeof import("./cdp.js");
chromeModule: typeof import("./chrome.js");
@@ -16,6 +20,7 @@ export type RemoteProfileTestDeps = {
let remoteProfileTestDepsPromise: Promise<RemoteProfileTestDeps> | undefined;
/** Loads remote-profile tab operation dependencies after Chrome mocks are installed. */
export async function loadRemoteProfileTestDeps(): Promise<RemoteProfileTestDeps> {
remoteProfileTestDepsPromise ??= (async () => {
await import("./server-context.chrome-test-harness.js");
@@ -49,6 +54,7 @@ export async function loadRemoteProfileTestDeps(): Promise<RemoteProfileTestDeps
return await remoteProfileTestDepsPromise;
}
/** Installs per-test mock reset and Playwright connection cleanup. */
export function installRemoteProfileTestLifecycle(deps: RemoteProfileTestDeps): void {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1,3 +1,6 @@
/**
* Remote-tab operation harness for Browser server-context tests.
*/
import { vi } from "vitest";
import { withBrowserFetchPreconnect } from "../../test-fetch.js";
import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js";
@@ -6,8 +9,10 @@ import { createProfileSelectionOps } from "./server-context.selection.js";
import { createProfileTabOps } from "./server-context.tab-ops.js";
import type { BrowserServerState, ProfileRuntimeState } from "./server-context.types.js";
/** Original global fetch restored between remote-tab harness tests. */
export const originalFetch = globalThis.fetch;
/** Creates Browser server state for remote or local profile tab tests. */
export function makeState(
profile: "remote" | "openclaw",
): BrowserServerState & { profiles: Map<string, { lastTargetId?: string | null }> } {
@@ -95,6 +100,7 @@ function resolveProfileForTest(
};
}
/** Creates a minimal Browser route context for profile operation tests. */
export function createTestBrowserRouteContext(opts: { getState: () => BrowserServerState }) {
const forProfile = (profileName?: string) => {
const state = opts.getState();
@@ -125,6 +131,7 @@ export function createTestBrowserRouteContext(opts: { getState: () => BrowserSer
return { forProfile };
}
/** Creates a remote profile context with a preconnected fetch mock. */
export function createRemoteRouteHarness(fetchMock?: (url: unknown) => Promise<Response>) {
const activeFetchMock = fetchMock ?? makeUnexpectedFetchMock();
global.fetch = withBrowserFetchPreconnect(activeFetchMock);
@@ -133,6 +140,7 @@ export function createRemoteRouteHarness(fetchMock?: (url: unknown) => Promise<R
return { state, remote: ctx.forProfile("remote"), fetchMock: activeFetchMock };
}
/** Returns a page lister that yields prepared responses in order. */
export function createSequentialPageLister<T>(responses: T[]) {
return async () => {
const next = responses.shift();
@@ -151,6 +159,7 @@ type JsonListEntry = {
type: "page";
};
/** Creates a /json/list fetch mock with static entries. */
export function createJsonListFetchMock(entries: JsonListEntry[]) {
return async (url: unknown) => {
const u = String(url);
@@ -174,6 +183,7 @@ function makeManagedTab(id: string, ordinal: number): JsonListEntry {
};
}
/** Creates eight old managed tabs plus one new tab for cleanup-limit tests. */
export function makeManagedTabsWithNew(params?: { newFirst?: boolean }): JsonListEntry[] {
const oldTabs = Array.from({ length: 8 }, (_, index) =>
makeManagedTab(`OLD${index + 1}`, index + 1),

View File

@@ -1,3 +1,6 @@
/**
* Browser profile reset operations for local managed profiles.
*/
import fs from "node:fs";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserResetUnsupportedError } from "./errors.js";
@@ -18,6 +21,7 @@ type ResetOps = {
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
};
/** Builds the reset-profile operation for one resolved browser profile. */
export function createProfileResetOps({
profile,
getProfileState,

View File

@@ -1,3 +1,6 @@
/**
* Browser tab selection operations for default tab choice, focus, and close.
*/
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
@@ -26,6 +29,7 @@ type SelectionOps = {
closeTab: (targetId: string) => Promise<void>;
};
/** Builds tab selection/focus/close operations for one resolved browser profile. */
export function createProfileSelectionOps({
profile,
getProfileState,

View File

@@ -1,3 +1,6 @@
/**
* Browser tab listing, opening, labeling, and alias management for one profile.
*/
import { resolveBrowserNavigationProxyMode } from "./browser-proxy-mode.js";
import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js";
import { isSelectableCdpBrowserTarget } from "./cdp-target-filter.js";
@@ -170,6 +173,7 @@ function assignTabAliases(profileState: ProfileRuntimeState, tabs: BrowserTab[])
return tabs.map((tab) => assignTabAlias({ profileState, tab }));
}
/** Builds list/open/label tab operations for one resolved browser profile. */
export function createProfileTabOps({
profile,
state,

View File

@@ -1,9 +1,13 @@
/**
* Test factories for Browser profile/runtime state and launched Chrome mocks.
*/
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import type { RunningChrome } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import type { BrowserServerState } from "./server-context.js";
/** Creates a resolved Browser profile for unit tests. */
export function makeBrowserProfile(
overrides: Partial<ResolvedBrowserProfile> = {},
): ResolvedBrowserProfile {
@@ -21,6 +25,7 @@ export function makeBrowserProfile(
};
}
/** Creates Browser server state around a test profile. */
export function makeBrowserServerState(params?: {
profile?: ResolvedBrowserProfile;
resolvedOverrides?: Partial<BrowserServerState["resolved"]>;
@@ -69,6 +74,7 @@ export function makeBrowserServerState(params?: {
};
}
/** Mocks a launched OpenClaw Chrome process with the supplied pid. */
export function mockLaunchedChrome(
launchOpenClawChrome: { mockResolvedValue: (value: RunningChrome) => unknown },
pid: number,

View File

@@ -1,3 +1,7 @@
/**
* Browser route context factory that wires profile-scoped runtime operations for
* the Browser control server.
*/
import {
resolveCdpControlPolicy,
resolveCdpReachabilityPolicy,
@@ -37,6 +41,7 @@ export type {
ProfileStatus,
} from "./server-context.types.js";
/** Lists configured and runtime-known Browser profile names without duplicates. */
export function listKnownProfileNames(state: BrowserServerState): string[] {
const names = new Set(Object.keys(state.resolved.profiles));
for (const name of state.profiles.keys()) {
@@ -129,6 +134,7 @@ function createProfileContext(
};
}
/** Creates the Browser route context used by control-server route handlers. */
export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteContext {
const refreshConfigFromDisk = opts.refreshConfigFromDisk === true;

View File

@@ -1,3 +1,7 @@
/**
* Shared Browser server context types used by route handlers and profile
* operation factories.
*/
import type { Server } from "node:http";
import type { RunningChrome } from "./chrome.js";
import type { BrowserTab, BrowserTransport } from "./client.types.js";
@@ -5,9 +9,7 @@ import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"
export type { BrowserTab };
/**
* Runtime state for a single profile's Chrome instance.
*/
/** Runtime state for a single profile's Chrome instance. */
export type ProfileRuntimeState = {
profile: ResolvedBrowserProfile;
running: RunningChrome | null;
@@ -31,6 +33,7 @@ export type ProfileRuntimeState = {
} | null;
};
/** Runtime state for the Browser control server. */
export type BrowserServerState = {
server?: Server | null;
port: number;
@@ -58,6 +61,7 @@ type BrowserProfileActions = {
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
};
/** Profile-aware operations exposed to Browser route handlers. */
export type BrowserRouteContext = {
state: () => BrowserServerState;
forProfile: (profileName?: string) => ProfileContext;
@@ -66,10 +70,12 @@ export type BrowserRouteContext = {
mapTabError: (err: unknown) => { status: number; message: string } | null;
} & BrowserProfileActions;
/** Operations scoped to a single resolved Browser profile. */
export type ProfileContext = {
profile: ResolvedBrowserProfile;
} & BrowserProfileActions;
/** Status payload returned by Browser profile listing. */
export type ProfileStatus = {
name: string;
transport: BrowserTransport;
@@ -85,6 +91,7 @@ export type ProfileStatus = {
reconcileReason?: string | null;
};
/** Inputs for creating a Browser route context. */
export type ContextOptions = {
getState: () => BrowserServerState | null;
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;

View File

@@ -1,3 +1,6 @@
/**
* Browser server lifecycle helpers for relay setup and profile shutdown.
*/
import { stopOpenClawChrome } from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
import {
@@ -6,6 +9,7 @@ import {
listKnownProfileNames,
} from "./server-context.js";
/** Ensures extension relay compatibility hooks for configured profiles. */
export async function ensureExtensionRelayForProfiles(_params: {
resolved: ResolvedBrowserConfig;
onWarn: (message: string) => void;
@@ -15,6 +19,7 @@ export async function ensureExtensionRelayForProfiles(_params: {
// breaking cleanup rather than changing the call graph in a patch release.
}
/** Stops every known Browser profile during runtime shutdown. */
export async function stopKnownBrowserProfiles(params: {
getState: () => BrowserServerState | null;
onWarn: (message: string) => void;

View File

@@ -1,3 +1,7 @@
/**
* Shared Express middleware for Browser control routes, including auth marking,
* JSON parsing, abort signals, and mutation CSRF checks.
*/
import type { Express, Request } from "express";
import express from "express";
import { browserMutationGuardMiddleware } from "./csrf.js";
@@ -9,6 +13,7 @@ type BrowserAuthMarkedRequest = Request & {
[BROWSER_AUTH_VERIFIED_FLAG]?: boolean;
};
/** Returns whether Browser auth middleware already verified this request. */
export function hasVerifiedBrowserAuth(req: Request): boolean {
return (req as BrowserAuthMarkedRequest)[BROWSER_AUTH_VERIFIED_FLAG] === true;
}
@@ -17,6 +22,7 @@ function markVerifiedBrowserAuth(req: Request) {
(req as BrowserAuthMarkedRequest)[BROWSER_AUTH_VERIFIED_FLAG] = true;
}
/** Installs common Browser control-server middleware. */
export function installBrowserCommonMiddleware(app: Express) {
app.use((req, res, next) => {
const ctrl = new AbortController();
@@ -42,6 +48,7 @@ export function installBrowserCommonMiddleware(app: Express) {
app.use(browserMutationGuardMiddleware());
}
/** Installs optional token/password auth for Browser control-server requests. */
export function installBrowserAuthMiddleware(
app: Express,
auth: { token?: string; password?: string },

View File

@@ -1,3 +1,7 @@
/**
* Agent-contract test harness for starting the Browser control server and
* posting JSON through a real fetch implementation.
*/
import {
getBrowserControlServerBaseUrl,
installBrowserControlServerHooks,
@@ -5,6 +9,7 @@ import {
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-support/fetch.js";
/** Installs Browser control-server hooks for agent-contract tests. */
export function installAgentContractHooks() {
installBrowserControlServerHooks();
}
@@ -50,6 +55,7 @@ async function postStartWithRetry(params: {
throw lastError;
}
/** Starts the Browser control server and returns its base URL. */
export async function startServerAndBase(): Promise<string> {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
@@ -58,6 +64,7 @@ export async function startServerAndBase(): Promise<string> {
return base;
}
/** Posts JSON to a Browser control-server route and parses the JSON response. */
export async function postJson<T>(url: string, body?: unknown): Promise<T> {
const realFetch = getBrowserTestFetch();
const res = await realFetch(url, {

View File

@@ -1,3 +1,7 @@
/**
* Shared Browser control-server test harness with mocked Chrome, CDP,
* Playwright, Chrome MCP, config, and media dependencies.
*/
import { afterEach, beforeEach, vi } from "vitest";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
@@ -44,10 +48,12 @@ const state: HarnessState = {
prevGatewayPassword: undefined,
};
/** Returns mutable Browser control-server harness state. */
export function getBrowserControlServerTestState(): HarnessState {
return state;
}
/** Returns the loopback base URL for the current test server. */
export function getBrowserControlServerBaseUrl(): string {
return `http://127.0.0.1:${state.testPort}`;
}
@@ -60,22 +66,27 @@ function restoreGatewayPortEnv(prevGatewayPort: string | undefined): void {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
/** Sets the mocked browser.evaluateEnabled config flag. */
export function setBrowserControlServerEvaluateEnabled(enabled: boolean): void {
state.cfgEvaluateEnabled = enabled;
}
/** Sets the mocked Browser SSRF policy. */
export function setBrowserControlServerSsrFPolicy(policy: SsrFPolicy | undefined): void {
state.cfgSsrfPolicy = policy;
}
/** Sets whether mocked Chrome/CDP probes should report reachable. */
export function setBrowserControlServerReachable(reachable: boolean): void {
state.reachable = reachable;
}
/** Sets the URL returned by mocked /json/list tab responses. */
export function setBrowserControlServerTabUrl(url: string): void {
state.tabUrl = url;
}
/** Sets mocked Browser profiles and default profile for config reload tests. */
export function setBrowserControlServerProfiles(
profiles: HarnessState["cfgProfiles"],
defaultProfile = Object.keys(profiles)[0] ?? "openclaw",
@@ -98,6 +109,7 @@ const cdpMocks = vi.hoisted(() => ({
})),
}));
/** Returns mocked CDP functions used by Browser control-server tests. */
export function getCdpMocks(): {
createTargetViaCdp: MockFn;
snapshotAria: MockFn;
@@ -333,6 +345,7 @@ pwMocks.executeActViaPlaywright.mockImplementation(
},
);
/** Returns mocked Playwright-backed Browser tool functions. */
export function getPwMocks(): Record<string, MockFn> {
return pwMocks as unknown as Record<string, MockFn>;
}
@@ -528,6 +541,7 @@ async function loadBrowserServerModule() {
return await browserServerModulePromise;
}
/** Starts the Browser control server from the mocked config module. */
export async function startBrowserControlServerFromConfig() {
return await (await loadBrowserServerModule()).startBrowserControlServerFromConfig();
}
@@ -536,6 +550,7 @@ async function stopBrowserControlServer(): Promise<void> {
await (await loadBrowserServerModule()).stopBrowserControlServer();
}
/** Creates a minimal Response-like object for mocked fetch handlers. */
export function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
@@ -557,6 +572,7 @@ function mockClearAll(obj: Record<string, { mockClear: () => unknown }>) {
}
}
/** Resets harness state, env, and mocks before one Browser control-server test. */
export async function resetBrowserControlServerTestContext(): Promise<void> {
state.reachable = false;
state.cfgAttachOnly = false;
@@ -599,6 +615,7 @@ function restoreGatewayAuthEnv(
}
}
/** Restores globals/env and stops the Browser control server after one test. */
export async function cleanupBrowserControlServerTestContext(): Promise<void> {
vi.unstubAllGlobals();
vi.restoreAllMocks();
@@ -607,6 +624,7 @@ export async function cleanupBrowserControlServerTestContext(): Promise<void> {
await stopBrowserControlServer();
}
/** Installs beforeEach/afterEach hooks for Browser control-server tests. */
export function installBrowserControlServerHooks() {
const hookTimeoutMs = process.platform === "win32" ? 300_000 : 240_000;
beforeEach(async () => {

View File

@@ -1,3 +1,6 @@
/**
* Periodic cleanup for browser tabs tracked to primary OpenClaw sessions.
*/
import {
isAcpSessionKey,
isCronSessionKey,
@@ -13,6 +16,7 @@ function minutesToMs(minutes: number): number {
return Math.max(0, Math.floor(minutes * 60_000));
}
/** Returns true for user-facing sessions whose tabs should be tracked for cleanup. */
export function isPrimaryTrackedBrowserSessionKey(sessionKey: string): boolean {
return (
!isSubagentSessionKey(sessionKey) &&
@@ -26,6 +30,7 @@ function resolveBrowserTabCleanupRuntimeConfig(): ResolvedBrowserTabCleanupConfi
return resolveBrowserConfig(cfg.browser, cfg).tabCleanup;
}
/** Runs one Browser tab cleanup sweep from runtime config or injected test config. */
export async function runTrackedBrowserTabCleanupOnce(params?: {
now?: number;
cleanup?: ResolvedBrowserTabCleanupConfig;
@@ -46,6 +51,7 @@ export async function runTrackedBrowserTabCleanupOnce(params?: {
});
}
/** Starts the recurring Browser tab cleanup timer and returns its disposer. */
export function startTrackedBrowserTabCleanupTimer(params: {
onWarn: (message: string) => void;
}): () => void {

View File

@@ -1,3 +1,7 @@
/**
* In-memory registry that associates browser tabs with OpenClaw sessions for
* cleanup on session end or idle sweeps.
*/
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
@@ -91,6 +95,7 @@ function isIgnorableCloseError(err: unknown): boolean {
);
}
/** Starts tracking a browser tab for later session cleanup. */
export function trackSessionBrowserTab(params: SessionBrowserTabIdentityParams): void {
const identity = resolveTrackedTabIdentity(params);
if (!identity) {
@@ -115,6 +120,7 @@ export function trackSessionBrowserTab(params: SessionBrowserTabIdentityParams):
});
}
/** Updates last-used time for a tracked browser tab. */
export function touchSessionBrowserTab(
params: SessionBrowserTabIdentityParams & { now?: number },
): void {
@@ -137,6 +143,7 @@ export function touchSessionBrowserTab(
});
}
/** Removes a browser tab from session cleanup tracking. */
export function untrackSessionBrowserTab(params: SessionBrowserTabIdentityParams): void {
const identity = resolveTrackedTabIdentity(params);
if (!identity) {
@@ -211,6 +218,7 @@ async function closeTrackedTabs(params: {
return closed;
}
/** Closes and untracks tabs for the supplied session keys. */
export async function closeTrackedBrowserTabsForSessions(params: {
sessionKeys: Array<string | undefined>;
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
@@ -289,6 +297,7 @@ function takeStaleTrackedTabs(params: {
return tabsToClose;
}
/** Closes and untracks stale or excess browser tabs across tracked sessions. */
export async function sweepTrackedBrowserTabs(params: {
now?: number;
idleMs?: number;
@@ -309,10 +318,12 @@ export async function sweepTrackedBrowserTabs(params: {
});
}
/** Clears tracked tab state for tests. */
export function resetTrackedSessionBrowserTabsForTests(): void {
trackedTabsBySession.clear();
}
/** Counts tracked tabs for one session or all sessions in tests. */
export function countTrackedSessionBrowserTabsForTests(sessionKey?: string): number {
if (typeof sessionKey === "string" && sessionKey.trim()) {
return trackedTabsBySession.get(normalizeSessionKey(sessionKey))?.size ?? 0;