refactor: harden browser runtime profile handling

This commit is contained in:
Peter Steinberger
2026-03-09 00:25:29 +00:00
parent 4ff4ed7ec9
commit 141738f717
53 changed files with 790 additions and 270 deletions

View File

@@ -1,5 +1,8 @@
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { z } from "zod";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";

View File

@@ -1,5 +1,5 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
type LegacyRuntimeLogShape = { log?: (message: string) => void };

View File

@@ -1,4 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =

View File

@@ -1,4 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
buildOpenGroupPolicyConfigureRouteAllowlistWarning,

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat";
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/irc";
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/line";
const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/msteams";
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =

View File

@@ -1,4 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat";
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =

View File

@@ -1,4 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
collectAllowlistProviderGroupPolicyWarnings,
buildAccountScopedDmSecurityPolicy,

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/twitch";
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =

View File

@@ -1,4 +1,7 @@
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
import { z } from "zod";
import { buildSecretInputSchema } from "./secret-input.js";

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =

View File

@@ -1,4 +1,7 @@
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
import { z } from "zod";

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =

View File

@@ -30,6 +30,8 @@ export type ProfileStatus = {
tabCount: number;
isDefault: boolean;
isRemote: boolean;
missingFromConfig?: boolean;
reconcileReason?: string | null;
};
export type BrowserResetProfileResult = {

View File

@@ -2,8 +2,8 @@ import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig } from "./config.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
@@ -39,14 +39,9 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
}
state = {
state = await createBrowserRuntimeState({
server: null,
port: resolved.controlPort,
resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved,
onWarn: (message) => logService.warn(message),
});
@@ -59,22 +54,12 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
export async function stopBrowserControlService(): Promise<void> {
const current = state;
if (!current) {
return;
}
await stopKnownBrowserProfiles({
await stopBrowserRuntime({
current,
getState: () => state,
clearState: () => {
state = null;
},
onWarn: (message) => logService.warn(message),
});
state = null;
// Optional: Playwright is not always available (e.g. embedded gateway builds).
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}

82
src/browser/errors.ts Normal file
View File

@@ -0,0 +1,82 @@
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
export class BrowserError extends Error {
status: number;
constructor(message: string, status = 500, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
this.status = status;
}
}
export class BrowserValidationError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
export class BrowserConfigurationError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
export class BrowserTargetAmbiguousError extends BrowserError {
constructor(message = "ambiguous target id prefix", options?: ErrorOptions) {
super(message, 409, options);
}
}
export class BrowserTabNotFoundError extends BrowserError {
constructor(message = "tab not found", options?: ErrorOptions) {
super(message, 404, options);
}
}
export class BrowserProfileNotFoundError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 404, options);
}
}
export class BrowserConflictError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 409, options);
}
}
export class BrowserResetUnsupportedError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
export class BrowserProfileUnavailableError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 409, options);
}
}
export class BrowserResourceExhaustedError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 507, options);
}
}
export function toBrowserErrorResponse(err: unknown): {
status: number;
message: string;
} | null {
if (err instanceof BrowserError) {
return { status: err.status, message: err.message };
}
if (err instanceof SsrFBlockedError) {
return { status: 400, message: err.message };
}
if (err instanceof InvalidBrowserNavigationUrlError) {
return { status: 400, message: err.message };
}
return null;
}

View File

@@ -0,0 +1,100 @@
import type { ResolvedBrowserProfile } from "./config.js";
export type BrowserProfileMode = "local-managed" | "local-extension-relay" | "remote-cdp";
export type BrowserProfileCapabilities = {
mode: BrowserProfileMode;
isRemote: boolean;
requiresRelay: boolean;
requiresAttachedTab: boolean;
usesPersistentPlaywright: boolean;
supportsPerTabWs: boolean;
supportsJsonTabEndpoints: boolean;
supportsReset: boolean;
supportsManagedTabLimit: boolean;
};
export function getBrowserProfileCapabilities(
profile: ResolvedBrowserProfile,
): BrowserProfileCapabilities {
if (profile.driver === "extension") {
return {
mode: "local-extension-relay",
isRemote: false,
requiresRelay: true,
requiresAttachedTab: true,
usesPersistentPlaywright: false,
supportsPerTabWs: false,
supportsJsonTabEndpoints: true,
supportsReset: true,
supportsManagedTabLimit: false,
};
}
if (!profile.cdpIsLoopback) {
return {
mode: "remote-cdp",
isRemote: true,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: true,
supportsPerTabWs: false,
supportsJsonTabEndpoints: false,
supportsReset: false,
supportsManagedTabLimit: false,
};
}
return {
mode: "local-managed",
isRemote: false,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: false,
supportsPerTabWs: true,
supportsJsonTabEndpoints: true,
supportsReset: true,
supportsManagedTabLimit: true,
};
}
export function resolveDefaultSnapshotFormat(params: {
profile: ResolvedBrowserProfile;
hasPlaywright: boolean;
explicitFormat?: "ai" | "aria";
mode?: "efficient";
}): "ai" | "aria" {
if (params.explicitFormat) {
return params.explicitFormat;
}
if (params.mode === "efficient") {
return "ai";
}
const capabilities = getBrowserProfileCapabilities(params.profile);
if (capabilities.mode === "local-extension-relay") {
return "aria";
}
return params.hasPlaywright ? "ai" : "aria";
}
export function shouldUsePlaywrightForScreenshot(params: {
profile: ResolvedBrowserProfile;
wsUrl?: string;
ref?: string;
element?: string;
}): boolean {
const capabilities = getBrowserProfileCapabilities(params.profile);
return (
capabilities.requiresRelay || !params.wsUrl || Boolean(params.ref) || Boolean(params.element)
);
}
export function shouldUsePlaywrightForAriaSnapshot(params: {
profile: ResolvedBrowserProfile;
wsUrl?: string;
}): boolean {
const capabilities = getBrowserProfileCapabilities(params.profile);
return capabilities.requiresRelay || !params.wsUrl;
}

View File

@@ -132,6 +132,37 @@ describe("BrowserProfilesService", () => {
);
});
it("rejects driver=extension with non-loopback cdpUrl", async () => {
const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
await expect(
service.createProfile({
name: "chrome-remote",
driver: "extension",
cdpUrl: "http://10.0.0.42:9222",
}),
).rejects.toThrow(/loopback cdpUrl host/i);
});
it("rejects driver=extension without an explicit cdpUrl", async () => {
const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
await expect(
service.createProfile({
name: "chrome-extension",
driver: "extension",
}),
).rejects.toThrow(/requires an explicit loopback cdpUrl/i);
});
it("deletes remote profiles without stopping or removing local data", async () => {
const resolved = resolveBrowserConfig({
profiles: {

View File

@@ -3,9 +3,16 @@ import path from "node:path";
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { isLoopbackHost } from "../gateway/net.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import { DEFAULT_BROWSER_DEFAULT_PROFILE_NAME } from "./constants.js";
import {
BrowserConflictError,
BrowserProfileNotFoundError,
BrowserResourceExhaustedError,
BrowserValidationError,
} from "./errors.js";
import {
allocateCdpPort,
allocateColor,
@@ -75,19 +82,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const driver = params.driver === "extension" ? "extension" : undefined;
if (!isValidProfileName(name)) {
throw new Error("invalid profile name: use lowercase letters, numbers, and hyphens only");
throw new BrowserValidationError(
"invalid profile name: use lowercase letters, numbers, and hyphens only",
);
}
const state = ctx.state();
const resolvedProfiles = state.resolved.profiles;
if (name in resolvedProfiles) {
throw new Error(`profile "${name}" already exists`);
throw new BrowserConflictError(`profile "${name}" already exists`);
}
const cfg = loadConfig();
const rawProfiles = cfg.browser?.profiles ?? {};
if (name in rawProfiles) {
throw new Error(`profile "${name}" already exists`);
throw new BrowserConflictError(`profile "${name}" already exists`);
}
const usedColors = getUsedColors(resolvedProfiles);
@@ -97,17 +106,32 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
let profileConfig: BrowserProfileConfig;
if (rawCdpUrl) {
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
if (driver === "extension") {
if (!isLoopbackHost(parsed.parsed.hostname)) {
throw new BrowserValidationError(
`driver=extension requires a loopback cdpUrl host, got: ${parsed.parsed.hostname}`,
);
}
if (parsed.parsed.protocol !== "http:" && parsed.parsed.protocol !== "https:") {
throw new BrowserValidationError(
`driver=extension requires an http(s) cdpUrl, got: ${parsed.parsed.protocol.replace(":", "")}`,
);
}
}
profileConfig = {
cdpUrl: parsed.normalized,
...(driver ? { driver } : {}),
color: profileColor,
};
} else {
if (driver === "extension") {
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
}
const usedPorts = getUsedPorts(resolvedProfiles);
const range = cdpPortRange(state.resolved);
const cdpPort = allocateCdpPort(usedPorts, range);
if (cdpPort === null) {
throw new Error("no available CDP ports in range");
throw new BrowserResourceExhaustedError("no available CDP ports in range");
}
profileConfig = {
cdpPort,
@@ -132,7 +156,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
state.resolved.profiles[name] = profileConfig;
const resolved = resolveProfile(state.resolved, name);
if (!resolved) {
throw new Error(`profile "${name}" not found after creation`);
throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`);
}
return {
@@ -148,21 +172,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
const name = nameRaw.trim();
if (!name) {
throw new Error("profile name is required");
throw new BrowserValidationError("profile name is required");
}
if (!isValidProfileName(name)) {
throw new Error("invalid profile name");
throw new BrowserValidationError("invalid profile name");
}
const cfg = loadConfig();
const profiles = cfg.browser?.profiles ?? {};
if (!(name in profiles)) {
throw new Error(`profile "${name}" not found`);
throw new BrowserProfileNotFoundError(`profile "${name}" not found`);
}
const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME;
if (name === defaultProfile) {
throw new Error(
throw new BrowserValidationError(
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
);
}

View File

@@ -19,6 +19,7 @@ import {
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { getChromeWebSocketUrl } from "./chrome.js";
import { BrowserTabNotFoundError } from "./errors.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
@@ -495,7 +496,7 @@ async function resolvePageByTargetIdOrThrow(opts: {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
throw new BrowserTabNotFoundError();
}
return page;
}
@@ -521,7 +522,7 @@ export async function getPageForTargetId(opts: {
if (pages.length === 1) {
return first;
}
throw new Error("tab not found");
throw new BrowserTabNotFoundError();
}
return found;
}

View File

@@ -2,6 +2,29 @@ import { createConfigIO, loadConfig } from "../config/config.js";
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js";
import type { BrowserServerState } from "./server-context.types.js";
function changedProfileInvariants(
current: ResolvedBrowserProfile,
next: ResolvedBrowserProfile,
): string[] {
const changed: string[] = [];
if (current.cdpUrl !== next.cdpUrl) {
changed.push("cdpUrl");
}
if (current.cdpPort !== next.cdpPort) {
changed.push("cdpPort");
}
if (current.driver !== next.driver) {
changed.push("driver");
}
if (current.attachOnly !== next.attachOnly) {
changed.push("attachOnly");
}
if (current.cdpIsLoopback !== next.cdpIsLoopback) {
changed.push("cdpIsLoopback");
}
return changed;
}
function applyResolvedConfig(
current: BrowserServerState,
freshResolved: BrowserServerState["resolved"],
@@ -10,9 +33,22 @@ function applyResolvedConfig(
for (const [name, runtime] of current.profiles) {
const nextProfile = resolveProfile(freshResolved, name);
if (nextProfile) {
const changed = changedProfileInvariants(runtime.profile, nextProfile);
if (changed.length > 0) {
runtime.reconcile = {
previousProfile: runtime.profile,
reason: `profile invariants changed: ${changed.join(", ")}`,
};
runtime.lastTargetId = null;
}
runtime.profile = nextProfile;
continue;
}
runtime.reconcile = {
previousProfile: runtime.profile,
reason: "profile removed from config",
};
runtime.lastTargetId = null;
if (!runtime.running) {
current.profiles.delete(name);
}

View File

@@ -1,3 +1,4 @@
import { toBrowserErrorResponse } from "../errors.js";
import type { PwAiModule } from "../pw-ai-module.js";
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
@@ -37,6 +38,10 @@ export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse,
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
const browserMapped = toBrowserErrorResponse(err);
if (browserMapped) {
return jsonError(res, browserMapped.status, browserMapped.message);
}
jsonError(res, 500, String(err));
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { resolveBrowserConfig, resolveProfile } from "../config.js";
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
describe("resolveSnapshotPlan", () => {
it("defaults chrome extension relay snapshots to aria when format is omitted", () => {
const resolved = resolveBrowserConfig({});
const profile = resolveProfile(resolved, "chrome");
expect(profile).toBeTruthy();
const plan = resolveSnapshotPlan({
profile: profile as NonNullable<typeof profile>,
query: {},
hasPlaywright: true,
});
expect(plan.format).toBe("aria");
});
it("keeps ai snapshots for managed browsers when Playwright is available", () => {
const resolved = resolveBrowserConfig({});
const profile = resolveProfile(resolved, "openclaw");
expect(profile).toBeTruthy();
const plan = resolveSnapshotPlan({
profile: profile as NonNullable<typeof profile>,
query: {},
hasPlaywright: true,
});
expect(plan.format).toBe("ai");
});
});

View File

@@ -0,0 +1,97 @@
import type { ResolvedBrowserProfile } from "../config.js";
import {
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
} from "../constants.js";
import {
resolveDefaultSnapshotFormat,
shouldUsePlaywrightForAriaSnapshot,
shouldUsePlaywrightForScreenshot,
} from "../profile-capabilities.js";
import { toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
export type BrowserSnapshotPlan = {
format: "ai" | "aria";
mode?: "efficient";
labels?: boolean;
limit?: number;
resolvedMaxChars?: number;
interactive?: boolean;
compact?: boolean;
depth?: number;
refsMode?: "aria" | "role";
selectorValue?: string;
frameSelectorValue?: string;
wantsRoleSnapshot: boolean;
};
export function resolveSnapshotPlan(params: {
profile: ResolvedBrowserProfile;
query: Record<string, unknown>;
hasPlaywright: boolean;
}): BrowserSnapshotPlan {
const mode = params.query.mode === "efficient" ? "efficient" : undefined;
const labels = toBoolean(params.query.labels) ?? undefined;
const explicitFormat =
params.query.format === "aria" ? "aria" : params.query.format === "ai" ? "ai" : undefined;
const format = resolveDefaultSnapshotFormat({
profile: params.profile,
hasPlaywright: params.hasPlaywright,
explicitFormat,
mode,
});
const limitRaw = typeof params.query.limit === "string" ? Number(params.query.limit) : undefined;
const hasMaxChars = Object.hasOwn(params.query, "maxChars");
const maxCharsRaw =
typeof params.query.maxChars === "string" ? Number(params.query.maxChars) : undefined;
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
const maxChars =
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
? Math.floor(maxCharsRaw)
: undefined;
const resolvedMaxChars =
format === "ai"
? hasMaxChars
? maxChars
: mode === "efficient"
? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: undefined;
const interactiveRaw = toBoolean(params.query.interactive);
const compactRaw = toBoolean(params.query.compact);
const depthRaw = toNumber(params.query.depth);
const refsModeRaw = toStringOrEmpty(params.query.refs).trim();
const refsMode: "aria" | "role" | undefined =
refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined;
const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined);
const compact = compactRaw ?? (mode === "efficient" ? true : undefined);
const depth =
depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined);
const selectorValue = toStringOrEmpty(params.query.selector).trim() || undefined;
const frameSelectorValue = toStringOrEmpty(params.query.frame).trim() || undefined;
return {
format,
mode,
labels,
limit,
resolvedMaxChars,
interactive,
compact,
depth,
refsMode,
selectorValue,
frameSelectorValue,
wantsRoleSnapshot:
labels === true ||
mode === "efficient" ||
interactive === true ||
compact === true ||
depth !== undefined ||
Boolean(selectorValue) ||
Boolean(frameSelectorValue),
};
}
export { shouldUsePlaywrightForAriaSnapshot, shouldUsePlaywrightForScreenshot };

View File

@@ -38,8 +38,8 @@ describe("resolveTargetIdAfterNavigate", () => {
{ targetId: "fresh-777", url: "https://example.com" },
]),
});
// Both differ from old targetId; the first non-stale match wins.
expect(result).toBe("preexisting-000");
// Ambiguous replacement; prefer staying on the old target rather than guessing wrong.
expect(result).toBe("old-123");
});
it("retries and resolves targetId when first listTabs has no URL match", async () => {
@@ -114,4 +114,24 @@ describe("resolveTargetIdAfterNavigate", () => {
});
expect(result).toBe("old-123");
});
it("keeps the old target when multiple replacement candidates still match after retry", async () => {
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
});

View File

@@ -1,11 +1,6 @@
import path from "node:path";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.js";
import {
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
} from "../constants.js";
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
import {
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
@@ -22,8 +17,13 @@ import {
withPlaywrightRouteContext,
withRouteTabContext,
} from "./agent.shared.js";
import {
resolveSnapshotPlan,
shouldUsePlaywrightForAriaSnapshot,
shouldUsePlaywrightForScreenshot,
} from "./agent.snapshot.plan.js";
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
async function saveBrowserMediaResponse(params: {
res: BrowserResponse;
@@ -56,26 +56,28 @@ export async function resolveTargetIdAfterNavigate(opts: {
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const refreshed = await opts.listTabs();
if (!refreshed.some((t) => t.targetId === opts.oldTargetId)) {
// Renderer swap: old target gone, resolve the replacement.
// Prefer a URL match whose targetId differs from the old one
// to avoid picking a pre-existing tab when multiple share the URL.
const byUrl = refreshed.filter((t) => t.url === opts.navigatedUrl);
const replaced = byUrl.find((t) => t.targetId !== opts.oldTargetId) ?? byUrl[0];
if (replaced) {
currentTargetId = replaced.targetId;
} else {
await new Promise((r) => setTimeout(r, 800));
const retried = await opts.listTabs();
const match =
retried.find((t) => t.url === opts.navigatedUrl && t.targetId !== opts.oldTargetId) ??
retried.find((t) => t.url === opts.navigatedUrl) ??
(retried.length === 1 ? retried[0] : null);
if (match) {
currentTargetId = match.targetId;
}
const pickReplacement = (tabs: Array<{ targetId: string; url: string }>) => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return opts.oldTargetId;
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return byUrl[0]?.targetId ?? opts.oldTargetId;
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
}
if (tabs.length === 1) {
return tabs[0]?.targetId ?? opts.oldTargetId;
}
return opts.oldTargetId;
};
currentTargetId = pickReplacement(await opts.listTabs());
if (currentTargetId === opts.oldTargetId) {
await new Promise((r) => setTimeout(r, 800));
currentTargetId = pickReplacement(await opts.listTabs());
}
} catch {
// Best-effort: fall back to pre-navigation targetId
@@ -162,11 +164,12 @@ export function registerBrowserAgentSnapshotRoutes(
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
let buffer: Buffer;
const shouldUsePlaywright =
profileCtx.profile.driver === "extension" ||
!tab.wsUrl ||
Boolean(ref) ||
Boolean(element);
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
ref,
element,
});
if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) {
@@ -212,81 +215,45 @@ export function registerBrowserAgentSnapshotRoutes(
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const mode = req.query.mode === "efficient" ? "efficient" : undefined;
const labels = toBoolean(req.query.labels) ?? undefined;
const explicitFormat =
req.query.format === "aria" ? "aria" : req.query.format === "ai" ? "ai" : undefined;
const format = explicitFormat ?? (mode ? "ai" : (await getPwAiModule()) ? "ai" : "aria");
const limitRaw = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
const hasMaxChars = Object.hasOwn(req.query, "maxChars");
const maxCharsRaw =
typeof req.query.maxChars === "string" ? Number(req.query.maxChars) : undefined;
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
const maxChars =
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
? Math.floor(maxCharsRaw)
: undefined;
const resolvedMaxChars =
format === "ai"
? hasMaxChars
? maxChars
: mode === "efficient"
? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: undefined;
const interactiveRaw = toBoolean(req.query.interactive);
const compactRaw = toBoolean(req.query.compact);
const depthRaw = toNumber(req.query.depth);
const refsModeRaw = toStringOrEmpty(req.query.refs).trim();
const refsMode: "aria" | "role" | undefined =
refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined;
const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined);
const compact = compactRaw ?? (mode === "efficient" ? true : undefined);
const depth =
depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined);
const selector = toStringOrEmpty(req.query.selector);
const frameSelector = toStringOrEmpty(req.query.frame);
const selectorValue = selector.trim() || undefined;
const frameSelectorValue = frameSelector.trim() || undefined;
const hasPlaywright = Boolean(await getPwAiModule());
const plan = resolveSnapshotPlan({
profile: profileCtx.profile,
query: req.query,
hasPlaywright,
});
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if ((labels || mode === "efficient") && format === "aria") {
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (format === "ai") {
if (plan.format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) {
return;
}
const wantsRoleSnapshot =
labels === true ||
mode === "efficient" ||
interactive === true ||
compact === true ||
depth !== undefined ||
Boolean(selectorValue) ||
Boolean(frameSelectorValue);
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
selector: selectorValue,
frameSelector: frameSelectorValue,
refsMode,
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
options: {
interactive: interactive ?? undefined,
compact: compact ?? undefined,
maxDepth: depth ?? undefined,
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
};
const snap = wantsRoleSnapshot
const snap = plan.wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
: await pw
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
})
.catch(async (err) => {
// Public-API fallback when Playwright's private _snapshotForAI is missing.
@@ -295,7 +262,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
throw err;
});
if (labels) {
if (plan.labels) {
const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -316,7 +283,7 @@ export function registerBrowserAgentSnapshotRoutes(
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
return res.json({
ok: true,
format,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
labels: true,
@@ -330,30 +297,32 @@ export function registerBrowserAgentSnapshotRoutes(
return res.json({
ok: true,
format,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...snap,
});
}
const snap =
profileCtx.profile.driver === "extension" || !tab.wsUrl
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit,
});
const snap = shouldUsePlaywrightForAriaSnapshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
})
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit });
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
const resolved = await Promise.resolve(snap);
if (!resolved) {
@@ -361,7 +330,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
return res.json({
ok: true,
format,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...resolved,

View File

@@ -1,4 +1,5 @@
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { toBrowserErrorResponse } from "../errors.js";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveProfileContext } from "./agent.shared.js";
@@ -18,6 +19,10 @@ async function withBasicProfileRoute(params: {
try {
await params.run(profileCtx);
} catch (err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(params.res, mapped.status, mapped.message);
}
jsonError(params.res, 500, String(err));
}
}
@@ -157,20 +162,11 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
});
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("already exists")) {
return jsonError(res, 409, msg);
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
}
if (msg.includes("no available CDP ports")) {
return jsonError(res, 507, msg);
}
if (msg.includes("cdpUrl")) {
return jsonError(res, 400, msg);
}
jsonError(res, 500, msg);
jsonError(res, 500, String(err));
}
});
@@ -186,17 +182,11 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
const result = await service.deleteProfile(name);
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
if (msg.includes("default profile")) {
return jsonError(res, 400, msg);
}
if (msg.includes("not found")) {
return jsonError(res, 404, msg);
}
jsonError(res, 500, msg);
jsonError(res, 500, String(err));
}
});
}

View File

@@ -1,3 +1,4 @@
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
@@ -50,7 +51,11 @@ async function withTabsProfileRoute(params: {
async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) {
if (!(await profileCtx.isReachable(300))) {
jsonError(res, 409, "browser not running");
jsonError(
res,
new BrowserProfileUnavailableError("browser not running").status,
"browser not running",
);
return false;
}
return true;
@@ -191,7 +196,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
const tabs = await profileCtx.listTabs();
const target = resolveIndexedTab(tabs, index);
if (!target) {
return jsonError(res, 404, "tab not found");
throw new BrowserTabNotFoundError();
}
await profileCtx.closeTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
@@ -204,7 +209,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
const tabs = await profileCtx.listTabs();
const target = tabs[index];
if (!target) {
return jsonError(res, 404, "tab not found");
throw new BrowserTabNotFoundError();
}
await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });

View File

@@ -0,0 +1,60 @@
import type { Server } from "node:http";
import { isPwAiLoaded } from "./pw-ai-state.js";
import type { BrowserServerState } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
export async function createBrowserRuntimeState(params: {
resolved: BrowserServerState["resolved"];
port: number;
server?: Server | null;
onWarn: (message: string) => void;
}): Promise<BrowserServerState> {
const state: BrowserServerState = {
server: params.server ?? null,
port: params.port,
resolved: params.resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved: params.resolved,
onWarn: params.onWarn,
});
return state;
}
export async function stopBrowserRuntime(params: {
current: BrowserServerState | null;
getState: () => BrowserServerState | null;
clearState: () => void;
closeServer?: boolean;
onWarn: (message: string) => void;
}): Promise<void> {
if (!params.current) {
return;
}
await stopKnownBrowserProfiles({
getState: params.getState,
onWarn: params.onWarn,
});
if (params.closeServer && params.current.server) {
await new Promise<void>((resolve) => {
params.current?.server?.close(() => resolve());
});
}
params.clearState();
if (!isPwAiLoaded()) {
return;
}
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}

View File

@@ -10,10 +10,12 @@ import {
stopOpenClawChrome,
} from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import {
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
@@ -48,6 +50,7 @@ export function createProfileAvailability({
getProfileState,
setProfileRunning,
}: AvailabilityDeps): AvailabilityOps {
const capabilities = getBrowserProfileCapabilities(profile);
const resolveTimeouts = (timeoutMs: number | undefined) =>
resolveCdpReachabilityTimeouts({
profileIsLoopback: profile.cdpIsLoopback,
@@ -80,6 +83,38 @@ export function createProfileAvailability({
});
};
const closePlaywrightBrowserConnectionForProfile = async (cdpUrl?: string): Promise<void> => {
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined);
} catch {
// ignore
}
};
const reconcileProfileRuntime = async (): Promise<void> => {
const profileState = getProfileState();
const reconcile = profileState.reconcile;
if (!reconcile) {
return;
}
profileState.reconcile = null;
profileState.lastTargetId = null;
const previousProfile = reconcile.previousProfile;
if (profileState.running) {
await stopOpenClawChrome(profileState.running).catch(() => {});
setProfileRunning(null);
}
if (previousProfile.driver === "extension") {
await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false);
}
await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl);
if (previousProfile.cdpUrl !== profile.cdpUrl) {
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
}
};
const waitForCdpReadyAfterLaunch = async (): Promise<void> => {
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS.
// If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port.
@@ -102,15 +137,16 @@ export function createProfileAvailability({
};
const ensureBrowserAvailable = async (): Promise<void> => {
await reconcileProfileRuntime();
const current = state();
const remoteCdp = !profile.cdpIsLoopback;
const remoteCdp = capabilities.isRemote;
const attachOnly = profile.attachOnly;
const isExtension = profile.driver === "extension";
const isExtension = capabilities.requiresRelay;
const profileState = getProfileState();
const httpReachable = await isHttpReachable();
if (isExtension && remoteCdp) {
throw new Error(
throw new BrowserConfigurationError(
`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`,
);
}
@@ -122,7 +158,7 @@ export function createProfileAvailability({
bindHost: current.resolved.relayBindHost,
});
if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) {
throw new Error(
throw new BrowserProfileUnavailableError(
`Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`,
);
}
@@ -140,7 +176,7 @@ export function createProfileAvailability({
}
}
if (attachOnly || remoteCdp) {
throw new Error(
throw new BrowserProfileUnavailableError(
remoteCdp
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
@@ -172,7 +208,7 @@ export function createProfileAvailability({
return;
}
}
throw new Error(
throw new BrowserProfileUnavailableError(
remoteCdp
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
@@ -181,7 +217,7 @@ export function createProfileAvailability({
// HTTP responds but WebSocket fails - port in use by something else.
if (!profileState.running) {
throw new Error(
throw new BrowserProfileUnavailableError(
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
`Run action=reset-profile profile=${profile.name} to kill the process.`,
);
@@ -201,7 +237,8 @@ export function createProfileAvailability({
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
if (profile.driver === "extension") {
await reconcileProfileRuntime();
if (capabilities.requiresRelay) {
const stopped = await stopChromeExtensionRelayServer({
cdpUrl: profile.cdpUrl,
});

View File

@@ -1,9 +1,10 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveBrowserConfig } from "./config.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import {
refreshResolvedBrowserConfigFromDisk,
resolveBrowserProfileWithHotReload,
} from "./resolved-config-refresh.js";
import type { BrowserServerState } from "./server-context.types.js";
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
@@ -166,4 +167,42 @@ describe("server-context hot-reload profiles", () => {
});
expect(Object.keys(state.resolved.profiles)).toContain("desktop");
});
it("marks existing runtime state for reconcile when profile invariants change", async () => {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const openclawProfile = resolveProfile(resolved, "openclaw");
expect(openclawProfile).toBeTruthy();
const state: BrowserServerState = {
server: null,
port: 18791,
resolved,
profiles: new Map([
[
"openclaw",
{
profile: openclawProfile!,
running: { pid: 123 } as never,
lastTargetId: "tab-1",
reconcile: null,
},
],
]),
};
cfgProfiles.openclaw = { cdpPort: 19999, color: "#FF4500" };
cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("openclaw");
expect(runtime).toBeTruthy();
expect(runtime?.profile.cdpPort).toBe(19999);
expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("cdpPort");
});
});

View File

@@ -1,6 +1,8 @@
import fs from "node:fs";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserResetUnsupportedError } from "./errors.js";
import { stopChromeExtensionRelayServer } from "./extension-relay.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { ProfileRuntimeState } from "./server-context.types.js";
import { movePathToTrash } from "./trash.js";
@@ -32,13 +34,14 @@ export function createProfileResetOps({
isHttpReachable,
resolveOpenClawUserDataDir,
}: ResetDeps): ResetOps {
const capabilities = getBrowserProfileCapabilities(profile);
const resetProfile = async () => {
if (profile.driver === "extension") {
if (capabilities.requiresRelay) {
await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {});
return { moved: false, from: profile.cdpUrl };
}
if (!profile.cdpIsLoopback) {
throw new Error(
if (!capabilities.supportsReset) {
throw new BrowserResetUnsupportedError(
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
);
}

View File

@@ -1,6 +1,8 @@
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath } from "./cdp.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { PwAiModule } from "./pw-ai-module.js";
import { getPwAiModule } from "./pw-ai-module.js";
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
@@ -28,13 +30,14 @@ export function createProfileSelectionOps({
openTab,
}: SelectionDeps): SelectionOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const capabilities = getBrowserProfileCapabilities(profile);
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
await ensureBrowserAvailable();
const profileState = getProfileState();
let tabs1 = await listTabs();
if (tabs1.length === 0) {
if (profile.driver === "extension") {
if (capabilities.requiresAttachedTab) {
// Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker
// lifecycle, relay restart). If we previously had a target selected, wait briefly for
// the extension to reconnect and re-announce its attached tabs before failing.
@@ -46,7 +49,7 @@ export function createProfileSelectionOps({
}
}
if (tabs1.length === 0) {
throw new Error(
throw new BrowserTabNotFoundError(
`tab not found (no attached Chrome tabs for profile "${profile.name}"). ` +
"Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).",
);
@@ -57,12 +60,7 @@ export function createProfileSelectionOps({
}
const tabs = await listTabs();
// For remote profiles using Playwright's persistent connection, we don't need wsUrl
// because we access pages directly through Playwright, not via individual WebSocket URLs.
const candidates =
profile.driver === "extension" || !profile.cdpIsLoopback
? tabs
: tabs.filter((t) => Boolean(t.wsUrl));
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
const resolveById = (raw: string) => {
const resolved = resolveTargetIdFromTabs(raw, candidates);
@@ -89,10 +87,10 @@ export function createProfileSelectionOps({
const chosen = targetId ? resolveById(targetId) : pickDefault();
if (chosen === "AMBIGUOUS") {
throw new Error("ambiguous target id prefix");
throw new BrowserTargetAmbiguousError();
}
if (!chosen) {
throw new Error("tab not found");
throw new BrowserTabNotFoundError();
}
profileState.lastTargetId = chosen.targetId;
return chosen;
@@ -103,9 +101,9 @@ export function createProfileSelectionOps({
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new Error("ambiguous target id prefix");
throw new BrowserTargetAmbiguousError();
}
throw new Error("tab not found");
throw new BrowserTabNotFoundError();
}
return resolved.targetId;
};
@@ -113,7 +111,7 @@ export function createProfileSelectionOps({
const focusTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (!profile.cdpIsLoopback) {
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.focusPageByTargetIdViaPlaywright;
@@ -137,7 +135,7 @@ export function createProfileSelectionOps({
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
// For remote profiles, use Playwright's persistent connection to close tabs
if (!profile.cdpIsLoopback) {
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.closePageByTargetIdViaPlaywright;

View File

@@ -7,6 +7,7 @@ import {
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { PwAiModule } from "./pw-ai-module.js";
import { getPwAiModule } from "./pw-ai-module.js";
import {
@@ -59,10 +60,10 @@ export function createProfileTabOps({
getProfileState,
}: TabOpsDeps): ProfileTabOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const capabilities = getBrowserProfileCapabilities(profile);
const listTabs = async (): Promise<BrowserTab[]> => {
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
if (!profile.cdpIsLoopback) {
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
if (typeof listPagesViaPlaywright === "function") {
@@ -99,8 +100,7 @@ export function createProfileTabOps({
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
const profileState = getProfileState();
if (
profile.driver !== "openclaw" ||
!profile.cdpIsLoopback ||
!capabilities.supportsManagedTabLimit ||
state().resolved.attachOnly ||
!profileState.running
) {
@@ -132,9 +132,7 @@ export function createProfileTabOps({
const openTab = async (url: string): Promise<BrowserTab> => {
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
// For remote profiles, use Playwright's persistent connection to create tabs
// This ensures the tab persists beyond a single request.
if (!profile.cdpIsLoopback) {
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
if (typeof createPageViaPlaywright === "function") {

View File

@@ -2,6 +2,7 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js";
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import {
refreshResolvedBrowserConfigFromDisk,
@@ -57,7 +58,7 @@ function createProfileContext(
const current = state();
let profileState = current.profiles.get(profile.name);
if (!profileState) {
profileState = { profile, running: null, lastTargetId: null };
profileState = { profile, running: null, lastTargetId: null, reconcile: null };
current.profiles.set(profile.name, profileState);
}
return profileState;
@@ -136,7 +137,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
if (!profile) {
const available = Object.keys(current.resolved.profiles).join(", ");
throw new Error(`Profile "${name}" not found. Available profiles: ${available || "(none)"}`);
throw new BrowserProfileNotFoundError(
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
);
}
return createProfileContext(opts, profile);
};
@@ -150,9 +153,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
});
const result: ProfileStatus[] = [];
for (const name of Object.keys(current.resolved.profiles)) {
for (const name of listKnownProfileNames(current)) {
const profileState = current.profiles.get(name);
const profile = resolveProfile(current.resolved, name);
const profile = resolveProfile(current.resolved, name) ?? profileState?.profile;
if (!profile) {
continue;
}
@@ -193,6 +196,8 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
tabCount,
isDefault: name === current.resolved.defaultProfile,
isRemote: !profile.cdpIsLoopback,
missingFromConfig: !(name in current.resolved.profiles) || undefined,
reconcileReason: profileState?.reconcile?.reason ?? null,
});
}
@@ -203,22 +208,16 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
const getDefaultContext = () => forProfile();
const mapTabError = (err: unknown) => {
const browserMapped = toBrowserErrorResponse(err);
if (browserMapped) {
return browserMapped;
}
if (err instanceof SsrFBlockedError) {
return { status: 400, message: err.message };
}
if (err instanceof InvalidBrowserNavigationUrlError) {
return { status: 400, message: err.message };
}
const msg = String(err);
if (msg.includes("ambiguous target id prefix")) {
return { status: 409, message: "ambiguous target id prefix" };
}
if (msg.includes("tab not found")) {
return { status: 404, message: msg };
}
if (msg.includes("not found")) {
return { status: 404, message: msg };
}
return null;
};

View File

@@ -13,6 +13,10 @@ export type ProfileRuntimeState = {
running: RunningChrome | null;
/** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */
lastTargetId?: string | null;
reconcile?: {
previousProfile: ResolvedBrowserProfile;
reason: string;
} | null;
};
export type BrowserServerState = {
@@ -56,6 +60,8 @@ export type ProfileStatus = {
tabCount: number;
isDefault: boolean;
isRemote: boolean;
missingFromConfig?: boolean;
reconcileReason?: string | null;
};
export type ContextOptions = {

View File

@@ -116,6 +116,19 @@ describe("profile CRUD endpoints", () => {
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
const createBadExtension = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "badextension",
driver: "extension",
cdpUrl: "http://10.0.0.42:9222",
}),
});
expect(createBadExtension.status).toBe(400);
const createBadExtensionBody = (await createBadExtension.json()) as { error: string };
expect(createBadExtensionBody.error).toContain("loopback cdpUrl host");
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});

View File

@@ -4,11 +4,10 @@ import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig } from "./config.js";
import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js";
import { isPwAiLoaded } from "./pw-ai-state.js";
import { registerBrowserRoutes } from "./routes/index.js";
import type { BrowserRouteRegistrar } from "./routes/types.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
import {
installBrowserAuthMiddleware,
installBrowserCommonMiddleware,
@@ -74,14 +73,9 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
return null;
}
state = {
state = await createBrowserRuntimeState({
server,
port,
resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved,
onWarn: (message) => logServer.warn(message),
});
@@ -93,29 +87,13 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
export async function stopBrowserControlServer(): Promise<void> {
const current = state;
if (!current) {
return;
}
await stopKnownBrowserProfiles({
await stopBrowserRuntime({
current,
getState: () => state,
clearState: () => {
state = null;
},
closeServer: true,
onWarn: (message) => logServer.warn(message),
});
if (current.server) {
await new Promise<void>((resolve) => {
current.server?.close(() => resolve());
});
}
state = null;
// Optional: avoid importing heavy Playwright bridge when this process never used it.
if (isPwAiLoaded()) {
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}
}