refactor: harden browser runtime profile handling

This commit is contained in:
Peter Steinberger
2026-03-09 00:25:29 +00:00
committed by Vincent Koc
parent 7875fb6c27
commit 4b694d565d
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 { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { z } from "zod"; import { z } from "zod";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js"; 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 type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized"); const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
type LegacyRuntimeLogShape = { log?: (message: string) => void }; 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 { import {
buildAccountScopedDmSecurityPolicy, buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings, 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } = 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 { import {
buildAccountScopedDmSecurityPolicy, buildAccountScopedDmSecurityPolicy,
buildOpenGroupPolicyConfigureRouteAllowlistWarning, 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat";
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/irc";
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/line";
const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/msteams";
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = 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 { import {
buildAccountScopedDmSecurityPolicy, buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings, 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat";
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = 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 { import {
collectAllowlistProviderGroupPolicyWarnings, collectAllowlistProviderGroupPolicyWarnings,
buildAccountScopedDmSecurityPolicy, 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/twitch";
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } = 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = 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 { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
import { z } from "zod"; import { z } from "zod";
import { buildSecretInputSchema } from "./secret-input.js"; 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } = 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 { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
import { z } from "zod"; 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"; import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } = const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =

View File

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

View File

@@ -2,8 +2,8 @@ import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig } from "./config.js"; import { resolveBrowserConfig } from "./config.js";
import { ensureBrowserControlAuth } from "./control-auth.js"; import { ensureBrowserControlAuth } from "./control-auth.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
let state: BrowserServerState | null = null; let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser"); const log = createSubsystemLogger("browser");
@@ -39,14 +39,9 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
logService.warn(`failed to auto-configure browser auth: ${String(err)}`); logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
} }
state = { state = await createBrowserRuntimeState({
server: null, server: null,
port: resolved.controlPort, port: resolved.controlPort,
resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved, resolved,
onWarn: (message) => logService.warn(message), onWarn: (message) => logService.warn(message),
}); });
@@ -59,22 +54,12 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
export async function stopBrowserControlService(): Promise<void> { export async function stopBrowserControlService(): Promise<void> {
const current = state; const current = state;
if (!current) { await stopBrowserRuntime({
return; current,
}
await stopKnownBrowserProfiles({
getState: () => state, getState: () => state,
clearState: () => {
state = null;
},
onWarn: (message) => logService.warn(message), 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 () => { it("deletes remote profiles without stopping or removing local data", async () => {
const resolved = resolveBrowserConfig({ const resolved = resolveBrowserConfig({
profiles: { profiles: {

View File

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

View File

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

View File

@@ -2,6 +2,29 @@ import { createConfigIO, loadConfig } from "../config/config.js";
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js"; import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js";
import type { BrowserServerState } from "./server-context.types.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( function applyResolvedConfig(
current: BrowserServerState, current: BrowserServerState,
freshResolved: BrowserServerState["resolved"], freshResolved: BrowserServerState["resolved"],
@@ -10,9 +33,22 @@ function applyResolvedConfig(
for (const [name, runtime] of current.profiles) { for (const [name, runtime] of current.profiles) {
const nextProfile = resolveProfile(freshResolved, name); const nextProfile = resolveProfile(freshResolved, name);
if (nextProfile) { 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; runtime.profile = nextProfile;
continue; continue;
} }
runtime.reconcile = {
previousProfile: runtime.profile,
reason: "profile removed from config",
};
runtime.lastTargetId = null;
if (!runtime.running) { if (!runtime.running) {
current.profiles.delete(name); current.profiles.delete(name);
} }

View File

@@ -1,3 +1,4 @@
import { toBrowserErrorResponse } from "../errors.js";
import type { PwAiModule } from "../pw-ai-module.js"; import type { PwAiModule } from "../pw-ai-module.js";
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js"; import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
@@ -37,6 +38,10 @@ export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse,
if (mapped) { if (mapped) {
return jsonError(res, mapped.status, mapped.message); 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)); 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" }, { targetId: "fresh-777", url: "https://example.com" },
]), ]),
}); });
// Both differ from old targetId; the first non-stale match wins. // Ambiguous replacement; prefer staying on the old target rather than guessing wrong.
expect(result).toBe("preexisting-000"); expect(result).toBe("old-123");
}); });
it("retries and resolves targetId when first listTabs has no URL match", async () => { it("retries and resolves targetId when first listTabs has no URL match", async () => {
@@ -114,4 +114,24 @@ describe("resolveTargetIdAfterNavigate", () => {
}); });
expect(result).toBe("old-123"); 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 path from "node:path";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.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 { withBrowserNavigationPolicy } from "../navigation-guard.js";
import { import {
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
@@ -22,8 +17,13 @@ import {
withPlaywrightRouteContext, withPlaywrightRouteContext,
withRouteTabContext, withRouteTabContext,
} from "./agent.shared.js"; } from "./agent.shared.js";
import {
resolveSnapshotPlan,
shouldUsePlaywrightForAriaSnapshot,
shouldUsePlaywrightForScreenshot,
} from "./agent.snapshot.plan.js";
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.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: { async function saveBrowserMediaResponse(params: {
res: BrowserResponse; res: BrowserResponse;
@@ -56,26 +56,28 @@ export async function resolveTargetIdAfterNavigate(opts: {
}): Promise<string> { }): Promise<string> {
let currentTargetId = opts.oldTargetId; let currentTargetId = opts.oldTargetId;
try { try {
const refreshed = await opts.listTabs(); const pickReplacement = (tabs: Array<{ targetId: string; url: string }>) => {
if (!refreshed.some((t) => t.targetId === opts.oldTargetId)) { if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
// Renderer swap: old target gone, resolve the replacement. return opts.oldTargetId;
// 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 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 { } catch {
// Best-effort: fall back to pre-navigation targetId // Best-effort: fall back to pre-navigation targetId
@@ -162,11 +164,12 @@ export function registerBrowserAgentSnapshotRoutes(
targetId, targetId,
run: async ({ profileCtx, tab, cdpUrl }) => { run: async ({ profileCtx, tab, cdpUrl }) => {
let buffer: Buffer; let buffer: Buffer;
const shouldUsePlaywright = const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
profileCtx.profile.driver === "extension" || profile: profileCtx.profile,
!tab.wsUrl || wsUrl: tab.wsUrl,
Boolean(ref) || ref,
Boolean(element); element,
});
if (shouldUsePlaywright) { if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot"); const pw = await requirePwAi(res, "screenshot");
if (!pw) { if (!pw) {
@@ -212,81 +215,45 @@ export function registerBrowserAgentSnapshotRoutes(
return; return;
} }
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const mode = req.query.mode === "efficient" ? "efficient" : undefined; const hasPlaywright = Boolean(await getPwAiModule());
const labels = toBoolean(req.query.labels) ?? undefined; const plan = resolveSnapshotPlan({
const explicitFormat = profile: profileCtx.profile,
req.query.format === "aria" ? "aria" : req.query.format === "ai" ? "ai" : undefined; query: req.query,
const format = explicitFormat ?? (mode ? "ai" : (await getPwAiModule()) ? "ai" : "aria"); hasPlaywright,
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;
try { try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined); 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"); return jsonError(res, 400, "labels/mode=efficient require format=ai");
} }
if (format === "ai") { if (plan.format === "ai") {
const pw = await requirePwAi(res, "ai snapshot"); const pw = await requirePwAi(res, "ai snapshot");
if (!pw) { if (!pw) {
return; return;
} }
const wantsRoleSnapshot =
labels === true ||
mode === "efficient" ||
interactive === true ||
compact === true ||
depth !== undefined ||
Boolean(selectorValue) ||
Boolean(frameSelectorValue);
const roleSnapshotArgs = { const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl, cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
selector: selectorValue, selector: plan.selectorValue,
frameSelector: frameSelectorValue, frameSelector: plan.frameSelectorValue,
refsMode, refsMode: plan.refsMode,
options: { options: {
interactive: interactive ?? undefined, interactive: plan.interactive ?? undefined,
compact: compact ?? undefined, compact: plan.compact ?? undefined,
maxDepth: depth ?? undefined, maxDepth: plan.depth ?? undefined,
}, },
}; };
const snap = wantsRoleSnapshot const snap = plan.wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs) ? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
: await pw : await pw
.snapshotAiViaPlaywright({ .snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl, cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}), ...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
}) })
.catch(async (err) => { .catch(async (err) => {
// Public-API fallback when Playwright's private _snapshotForAI is missing. // Public-API fallback when Playwright's private _snapshotForAI is missing.
@@ -295,7 +262,7 @@ export function registerBrowserAgentSnapshotRoutes(
} }
throw err; throw err;
}); });
if (labels) { if (plan.labels) {
const labeled = await pw.screenshotWithLabelsViaPlaywright({ const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl, cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
@@ -316,7 +283,7 @@ export function registerBrowserAgentSnapshotRoutes(
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png"; const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
return res.json({ return res.json({
ok: true, ok: true,
format, format: plan.format,
targetId: tab.targetId, targetId: tab.targetId,
url: tab.url, url: tab.url,
labels: true, labels: true,
@@ -330,30 +297,32 @@ export function registerBrowserAgentSnapshotRoutes(
return res.json({ return res.json({
ok: true, ok: true,
format, format: plan.format,
targetId: tab.targetId, targetId: tab.targetId,
url: tab.url, url: tab.url,
...snap, ...snap,
}); });
} }
const snap = const snap = shouldUsePlaywrightForAriaSnapshot({
profileCtx.profile.driver === "extension" || !tab.wsUrl 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) => { // Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
if (!pw) { // Also covers cases where wsUrl is missing/unusable.
return null; return requirePwAi(res, "aria snapshot").then(async (pw) => {
} if (!pw) {
return await pw.snapshotAriaViaPlaywright({ return null;
cdpUrl: profileCtx.profile.cdpUrl, }
targetId: tab.targetId, return await pw.snapshotAriaViaPlaywright({
limit, 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); const resolved = await Promise.resolve(snap);
if (!resolved) { if (!resolved) {
@@ -361,7 +330,7 @@ export function registerBrowserAgentSnapshotRoutes(
} }
return res.json({ return res.json({
ok: true, ok: true,
format, format: plan.format,
targetId: tab.targetId, targetId: tab.targetId,
url: tab.url, url: tab.url,
...resolved, ...resolved,

View File

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

View File

@@ -1,3 +1,4 @@
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js"; import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js"; import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
@@ -50,7 +51,11 @@ async function withTabsProfileRoute(params: {
async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) { async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) {
if (!(await profileCtx.isReachable(300))) { 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 false;
} }
return true; return true;
@@ -191,7 +196,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
const tabs = await profileCtx.listTabs(); const tabs = await profileCtx.listTabs();
const target = resolveIndexedTab(tabs, index); const target = resolveIndexedTab(tabs, index);
if (!target) { if (!target) {
return jsonError(res, 404, "tab not found"); throw new BrowserTabNotFoundError();
} }
await profileCtx.closeTab(target.targetId); await profileCtx.closeTab(target.targetId);
return res.json({ ok: true, targetId: 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 tabs = await profileCtx.listTabs();
const target = tabs[index]; const target = tabs[index];
if (!target) { if (!target) {
return jsonError(res, 404, "tab not found"); throw new BrowserTabNotFoundError();
} }
await profileCtx.focusTab(target.targetId); await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: 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, stopOpenClawChrome,
} from "./chrome.js"; } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js"; import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js";
import { import {
ensureChromeExtensionRelayServer, ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer, stopChromeExtensionRelayServer,
} from "./extension-relay.js"; } from "./extension-relay.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import { import {
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS, CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS, CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
@@ -48,6 +50,7 @@ export function createProfileAvailability({
getProfileState, getProfileState,
setProfileRunning, setProfileRunning,
}: AvailabilityDeps): AvailabilityOps { }: AvailabilityDeps): AvailabilityOps {
const capabilities = getBrowserProfileCapabilities(profile);
const resolveTimeouts = (timeoutMs: number | undefined) => const resolveTimeouts = (timeoutMs: number | undefined) =>
resolveCdpReachabilityTimeouts({ resolveCdpReachabilityTimeouts({
profileIsLoopback: profile.cdpIsLoopback, 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> => { const waitForCdpReadyAfterLaunch = async (): Promise<void> => {
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS. // 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. // 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> => { const ensureBrowserAvailable = async (): Promise<void> => {
await reconcileProfileRuntime();
const current = state(); const current = state();
const remoteCdp = !profile.cdpIsLoopback; const remoteCdp = capabilities.isRemote;
const attachOnly = profile.attachOnly; const attachOnly = profile.attachOnly;
const isExtension = profile.driver === "extension"; const isExtension = capabilities.requiresRelay;
const profileState = getProfileState(); const profileState = getProfileState();
const httpReachable = await isHttpReachable(); const httpReachable = await isHttpReachable();
if (isExtension && remoteCdp) { if (isExtension && remoteCdp) {
throw new Error( throw new BrowserConfigurationError(
`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`, `Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`,
); );
} }
@@ -122,7 +158,7 @@ export function createProfileAvailability({
bindHost: current.resolved.relayBindHost, bindHost: current.resolved.relayBindHost,
}); });
if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) { 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}.`, `Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`,
); );
} }
@@ -140,7 +176,7 @@ export function createProfileAvailability({
} }
} }
if (attachOnly || remoteCdp) { if (attachOnly || remoteCdp) {
throw new Error( throw new BrowserProfileUnavailableError(
remoteCdp remoteCdp
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.` ? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`, : `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
@@ -172,7 +208,7 @@ export function createProfileAvailability({
return; return;
} }
} }
throw new Error( throw new BrowserProfileUnavailableError(
remoteCdp remoteCdp
? `Remote CDP websocket for profile "${profile.name}" is not reachable.` ? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
: `Browser attachOnly is enabled and 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. // HTTP responds but WebSocket fails - port in use by something else.
if (!profileState.running) { if (!profileState.running) {
throw new Error( throw new BrowserProfileUnavailableError(
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` + `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.`, `Run action=reset-profile profile=${profile.name} to kill the process.`,
); );
@@ -201,7 +237,8 @@ export function createProfileAvailability({
}; };
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
if (profile.driver === "extension") { await reconcileProfileRuntime();
if (capabilities.requiresRelay) {
const stopped = await stopChromeExtensionRelayServer({ const stopped = await stopChromeExtensionRelayServer({
cdpUrl: profile.cdpUrl, cdpUrl: profile.cdpUrl,
}); });

View File

@@ -1,9 +1,10 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveBrowserConfig } from "./config.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { import {
refreshResolvedBrowserConfigFromDisk, refreshResolvedBrowserConfigFromDisk,
resolveBrowserProfileWithHotReload, resolveBrowserProfileWithHotReload,
} from "./resolved-config-refresh.js"; } from "./resolved-config-refresh.js";
import type { BrowserServerState } from "./server-context.types.js";
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {}; 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"); 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 fs from "node:fs";
import type { ResolvedBrowserProfile } from "./config.js"; import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserResetUnsupportedError } from "./errors.js";
import { stopChromeExtensionRelayServer } from "./extension-relay.js"; import { stopChromeExtensionRelayServer } from "./extension-relay.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { ProfileRuntimeState } from "./server-context.types.js"; import type { ProfileRuntimeState } from "./server-context.types.js";
import { movePathToTrash } from "./trash.js"; import { movePathToTrash } from "./trash.js";
@@ -32,13 +34,14 @@ export function createProfileResetOps({
isHttpReachable, isHttpReachable,
resolveOpenClawUserDataDir, resolveOpenClawUserDataDir,
}: ResetDeps): ResetOps { }: ResetDeps): ResetOps {
const capabilities = getBrowserProfileCapabilities(profile);
const resetProfile = async () => { const resetProfile = async () => {
if (profile.driver === "extension") { if (capabilities.requiresRelay) {
await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {}); await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {});
return { moved: false, from: profile.cdpUrl }; return { moved: false, from: profile.cdpUrl };
} }
if (!profile.cdpIsLoopback) { if (!capabilities.supportsReset) {
throw new Error( throw new BrowserResetUnsupportedError(
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`, `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 { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath } from "./cdp.js"; import { appendCdpPath } from "./cdp.js";
import type { ResolvedBrowserProfile } from "./config.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 type { PwAiModule } from "./pw-ai-module.js";
import { getPwAiModule } from "./pw-ai-module.js"; import { getPwAiModule } from "./pw-ai-module.js";
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js"; import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
@@ -28,13 +30,14 @@ export function createProfileSelectionOps({
openTab, openTab,
}: SelectionDeps): SelectionOps { }: SelectionDeps): SelectionOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const capabilities = getBrowserProfileCapabilities(profile);
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => { const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
await ensureBrowserAvailable(); await ensureBrowserAvailable();
const profileState = getProfileState(); const profileState = getProfileState();
let tabs1 = await listTabs(); let tabs1 = await listTabs();
if (tabs1.length === 0) { if (tabs1.length === 0) {
if (profile.driver === "extension") { if (capabilities.requiresAttachedTab) {
// Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker // 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 // 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. // the extension to reconnect and re-announce its attached tabs before failing.
@@ -46,7 +49,7 @@ export function createProfileSelectionOps({
} }
} }
if (tabs1.length === 0) { if (tabs1.length === 0) {
throw new Error( throw new BrowserTabNotFoundError(
`tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + `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).", "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(); const tabs = await listTabs();
// For remote profiles using Playwright's persistent connection, we don't need wsUrl const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
// 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 resolveById = (raw: string) => { const resolveById = (raw: string) => {
const resolved = resolveTargetIdFromTabs(raw, candidates); const resolved = resolveTargetIdFromTabs(raw, candidates);
@@ -89,10 +87,10 @@ export function createProfileSelectionOps({
const chosen = targetId ? resolveById(targetId) : pickDefault(); const chosen = targetId ? resolveById(targetId) : pickDefault();
if (chosen === "AMBIGUOUS") { if (chosen === "AMBIGUOUS") {
throw new Error("ambiguous target id prefix"); throw new BrowserTargetAmbiguousError();
} }
if (!chosen) { if (!chosen) {
throw new Error("tab not found"); throw new BrowserTabNotFoundError();
} }
profileState.lastTargetId = chosen.targetId; profileState.lastTargetId = chosen.targetId;
return chosen; return chosen;
@@ -103,9 +101,9 @@ export function createProfileSelectionOps({
const resolved = resolveTargetIdFromTabs(targetId, tabs); const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) { if (!resolved.ok) {
if (resolved.reason === "ambiguous") { 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; return resolved.targetId;
}; };
@@ -113,7 +111,7 @@ export function createProfileSelectionOps({
const focusTab = async (targetId: string): Promise<void> => { const focusTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId); const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (!profile.cdpIsLoopback) { if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" }); const mod = await getPwAiModule({ mode: "strict" });
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null) const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.focusPageByTargetIdViaPlaywright; ?.focusPageByTargetIdViaPlaywright;
@@ -137,7 +135,7 @@ export function createProfileSelectionOps({
const resolvedTargetId = await resolveTargetIdOrThrow(targetId); const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
// For remote profiles, use Playwright's persistent connection to close tabs // For remote profiles, use Playwright's persistent connection to close tabs
if (!profile.cdpIsLoopback) { if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" }); const mod = await getPwAiModule({ mode: "strict" });
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null) const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.closePageByTargetIdViaPlaywright; ?.closePageByTargetIdViaPlaywright;

View File

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

View File

@@ -2,6 +2,7 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js"; import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js"; import { resolveProfile } from "./config.js";
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import { import {
refreshResolvedBrowserConfigFromDisk, refreshResolvedBrowserConfigFromDisk,
@@ -57,7 +58,7 @@ function createProfileContext(
const current = state(); const current = state();
let profileState = current.profiles.get(profile.name); let profileState = current.profiles.get(profile.name);
if (!profileState) { if (!profileState) {
profileState = { profile, running: null, lastTargetId: null }; profileState = { profile, running: null, lastTargetId: null, reconcile: null };
current.profiles.set(profile.name, profileState); current.profiles.set(profile.name, profileState);
} }
return profileState; return profileState;
@@ -136,7 +137,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
if (!profile) { if (!profile) {
const available = Object.keys(current.resolved.profiles).join(", "); 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); return createProfileContext(opts, profile);
}; };
@@ -150,9 +153,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
}); });
const result: ProfileStatus[] = []; 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 profileState = current.profiles.get(name);
const profile = resolveProfile(current.resolved, name); const profile = resolveProfile(current.resolved, name) ?? profileState?.profile;
if (!profile) { if (!profile) {
continue; continue;
} }
@@ -193,6 +196,8 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
tabCount, tabCount,
isDefault: name === current.resolved.defaultProfile, isDefault: name === current.resolved.defaultProfile,
isRemote: !profile.cdpIsLoopback, 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 getDefaultContext = () => forProfile();
const mapTabError = (err: unknown) => { const mapTabError = (err: unknown) => {
const browserMapped = toBrowserErrorResponse(err);
if (browserMapped) {
return browserMapped;
}
if (err instanceof SsrFBlockedError) { if (err instanceof SsrFBlockedError) {
return { status: 400, message: err.message }; return { status: 400, message: err.message };
} }
if (err instanceof InvalidBrowserNavigationUrlError) { if (err instanceof InvalidBrowserNavigationUrlError) {
return { status: 400, message: err.message }; 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; return null;
}; };

View File

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

View File

@@ -116,6 +116,19 @@ describe("profile CRUD endpoints", () => {
const createBadRemoteBody = (await createBadRemote.json()) as { error: string }; const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl"); 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`, { const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE", method: "DELETE",
}); });

View File

@@ -4,11 +4,10 @@ import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig } from "./config.js"; import { resolveBrowserConfig } from "./config.js";
import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js"; import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js";
import { isPwAiLoaded } from "./pw-ai-state.js";
import { registerBrowserRoutes } from "./routes/index.js"; import { registerBrowserRoutes } from "./routes/index.js";
import type { BrowserRouteRegistrar } from "./routes/types.js"; import type { BrowserRouteRegistrar } from "./routes/types.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
import { import {
installBrowserAuthMiddleware, installBrowserAuthMiddleware,
installBrowserCommonMiddleware, installBrowserCommonMiddleware,
@@ -74,14 +73,9 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
return null; return null;
} }
state = { state = await createBrowserRuntimeState({
server, server,
port, port,
resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved, resolved,
onWarn: (message) => logServer.warn(message), onWarn: (message) => logServer.warn(message),
}); });
@@ -93,29 +87,13 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
export async function stopBrowserControlServer(): Promise<void> { export async function stopBrowserControlServer(): Promise<void> {
const current = state; const current = state;
if (!current) { await stopBrowserRuntime({
return; current,
}
await stopKnownBrowserProfiles({
getState: () => state, getState: () => state,
clearState: () => {
state = null;
},
closeServer: true,
onWarn: (message) => logServer.warn(message), 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
}
}
} }