From 141738f717d19cc800508c39a2e73675b2bc8ec5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Mar 2026 00:25:29 +0000 Subject: [PATCH] refactor: harden browser runtime profile handling --- extensions/bluebubbles/src/config-schema.ts | 5 +- extensions/bluebubbles/src/runtime.ts | 2 +- extensions/discord/src/channel.ts | 2 +- extensions/discord/src/runtime.ts | 2 +- extensions/feishu/src/runtime.ts | 2 +- extensions/googlechat/src/channel.ts | 2 +- extensions/googlechat/src/runtime.ts | 2 +- extensions/imessage/src/runtime.ts | 2 +- extensions/irc/src/runtime.ts | 2 +- extensions/line/src/runtime.ts | 2 +- extensions/matrix/src/runtime.ts | 2 +- extensions/mattermost/src/runtime.ts | 2 +- extensions/msteams/src/runtime.ts | 2 +- extensions/nextcloud-talk/src/runtime.ts | 2 +- extensions/nostr/src/runtime.ts | 2 +- extensions/signal/src/runtime.ts | 2 +- extensions/slack/src/channel.ts | 2 +- extensions/slack/src/runtime.ts | 2 +- extensions/synology-chat/src/runtime.ts | 2 +- extensions/telegram/src/channel.ts | 2 +- extensions/telegram/src/runtime.ts | 2 +- extensions/tlon/src/runtime.ts | 2 +- extensions/twitch/src/runtime.ts | 2 +- extensions/whatsapp/src/runtime.ts | 2 +- extensions/zalo/src/config-schema.ts | 5 +- extensions/zalo/src/runtime.ts | 2 +- extensions/zalouser/src/config-schema.ts | 5 +- extensions/zalouser/src/runtime.ts | 2 +- src/browser/client.ts | 2 + src/browser/control-service.ts | 29 +-- src/browser/errors.ts | 82 ++++++++ src/browser/profile-capabilities.ts | 100 ++++++++++ src/browser/profiles-service.test.ts | 31 +++ src/browser/profiles-service.ts | 42 ++++- src/browser/pw-session.ts | 5 +- src/browser/resolved-config-refresh.ts | 36 ++++ src/browser/routes/agent.shared.ts | 5 + .../routes/agent.snapshot.plan.test.ts | 33 ++++ src/browser/routes/agent.snapshot.plan.ts | 97 ++++++++++ src/browser/routes/agent.snapshot.test.ts | 24 ++- src/browser/routes/agent.snapshot.ts | 177 ++++++++---------- src/browser/routes/basic.ts | 36 ++-- src/browser/routes/tabs.ts | 11 +- src/browser/runtime-lifecycle.ts | 60 ++++++ src/browser/server-context.availability.ts | 53 +++++- ...server-context.hot-reload-profiles.test.ts | 41 +++- src/browser/server-context.reset.ts | 9 +- src/browser/server-context.selection.ts | 26 ++- src/browser/server-context.tab-ops.ts | 12 +- src/browser/server-context.ts | 27 ++- src/browser/server-context.types.ts | 6 + ...s-open-profile-unknown-returns-404.test.ts | 13 ++ src/browser/server.ts | 38 +--- 53 files changed, 790 insertions(+), 270 deletions(-) create mode 100644 src/browser/errors.ts create mode 100644 src/browser/profile-capabilities.ts create mode 100644 src/browser/routes/agent.snapshot.plan.test.ts create mode 100644 src/browser/routes/agent.snapshot.plan.ts create mode 100644 src/browser/runtime-lifecycle.ts diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 94a0661afb7..32e239d3f45 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -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"; diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts index e1c0254e1c0..ee91445d69b 100644 --- a/extensions/bluebubbles/src/runtime.ts +++ b/extensions/bluebubbles/src/runtime.ts @@ -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("BlueBubbles runtime not initialized"); type LegacyRuntimeLogShape = { log?: (message: string) => void }; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 23a4a2ffae8..c6852a63469 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,4 +1,4 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 9a23266edda..2cc0074f457 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -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 } = diff --git a/extensions/feishu/src/runtime.ts b/extensions/feishu/src/runtime.ts index c1a4b65c50a..2e174a59320 100644 --- a/extensions/feishu/src/runtime.ts +++ b/extensions/feishu/src/runtime.ts @@ -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 } = diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index f0c5dace9f0..2be9ae3335b 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -1,4 +1,4 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedDmSecurityPolicy, buildOpenGroupPolicyConfigureRouteAllowlistWarning, diff --git a/extensions/googlechat/src/runtime.ts b/extensions/googlechat/src/runtime.ts index 2276eb7dcfa..44731cba8ea 100644 --- a/extensions/googlechat/src/runtime.ts +++ b/extensions/googlechat/src/runtime.ts @@ -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 } = diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index a4b2f1a98de..7bc726cb089 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -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 } = diff --git a/extensions/irc/src/runtime.ts b/extensions/irc/src/runtime.ts index b5597236b7a..e1d60a14652 100644 --- a/extensions/irc/src/runtime.ts +++ b/extensions/irc/src/runtime.ts @@ -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 } = diff --git a/extensions/line/src/runtime.ts b/extensions/line/src/runtime.ts index 38ed57e7875..57307cbe64e 100644 --- a/extensions/line/src/runtime.ts +++ b/extensions/line/src/runtime.ts @@ -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 } = diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts index 90fe7d1f8e9..eefce7b910a 100644 --- a/extensions/matrix/src/runtime.ts +++ b/extensions/matrix/src/runtime.ts @@ -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 } = diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts index 8fe131f2335..1f112c8361f 100644 --- a/extensions/mattermost/src/runtime.ts +++ b/extensions/mattermost/src/runtime.ts @@ -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 } = diff --git a/extensions/msteams/src/runtime.ts b/extensions/msteams/src/runtime.ts index 04444a29fc1..f9d1dec5714 100644 --- a/extensions/msteams/src/runtime.ts +++ b/extensions/msteams/src/runtime.ts @@ -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 } = diff --git a/extensions/nextcloud-talk/src/runtime.ts b/extensions/nextcloud-talk/src/runtime.ts index d4870a74839..4e539eb3687 100644 --- a/extensions/nextcloud-talk/src/runtime.ts +++ b/extensions/nextcloud-talk/src/runtime.ts @@ -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 } = diff --git a/extensions/nostr/src/runtime.ts b/extensions/nostr/src/runtime.ts index 1063bd8d6d3..347079d9750 100644 --- a/extensions/nostr/src/runtime.ts +++ b/extensions/nostr/src/runtime.ts @@ -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 } = diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index fd6c5fbdae6..480c174ab26 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -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 } = diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 1fdf4018f28..570ef20ffa1 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,4 +1,4 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index 9ba83fcb4c8..7961547004c 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -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 } = diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index 6abb71d8188..2f9b401192c 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -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 } = diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index d8879ab5858..0f4721a4d62 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,4 +1,4 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk"; +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { collectAllowlistProviderGroupPolicyWarnings, buildAccountScopedDmSecurityPolicy, diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index 4effcb7b5bf..8923cdd3e8d 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -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 } = diff --git a/extensions/tlon/src/runtime.ts b/extensions/tlon/src/runtime.ts index 1551ea38f3f..8df35088912 100644 --- a/extensions/tlon/src/runtime.ts +++ b/extensions/tlon/src/runtime.ts @@ -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 } = diff --git a/extensions/twitch/src/runtime.ts b/extensions/twitch/src/runtime.ts index f82e4313f81..18deeb40c07 100644 --- a/extensions/twitch/src/runtime.ts +++ b/extensions/twitch/src/runtime.ts @@ -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 } = diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index c5044db6a29..13ace8243db 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -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 } = diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index f2e5c5803e7..5f4886cdaf9 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -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"; diff --git a/extensions/zalo/src/runtime.ts b/extensions/zalo/src/runtime.ts index 74542043913..10f417b3c7f 100644 --- a/extensions/zalo/src/runtime.ts +++ b/extensions/zalo/src/runtime.ts @@ -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 } = diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index dd0f9c51fbe..e5cb64d012e 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -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"; diff --git a/extensions/zalouser/src/runtime.ts b/extensions/zalouser/src/runtime.ts index 473df2b8fbe..44cf09edbc7 100644 --- a/extensions/zalouser/src/runtime.ts +++ b/extensions/zalouser/src/runtime.ts @@ -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 } = diff --git a/src/browser/client.ts b/src/browser/client.ts index 76b799bde64..953c9efcd11 100644 --- a/src/browser/client.ts +++ b/src/browser/client.ts @@ -30,6 +30,8 @@ export type ProfileStatus = { tabCount: number; isDefault: boolean; isRemote: boolean; + missingFromConfig?: boolean; + reconcileReason?: string | null; }; export type BrowserResetProfileResult = { diff --git a/src/browser/control-service.ts b/src/browser/control-service.ts index 031bc5e00cd..48dc08beb30 100644 --- a/src/browser/control-service.ts +++ b/src/browser/control-service.ts @@ -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 logService.warn(message), }); @@ -59,22 +54,12 @@ export async function startBrowserControlServiceFromConfig(): Promise { 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 - } } diff --git a/src/browser/errors.ts b/src/browser/errors.ts new file mode 100644 index 00000000000..11a9bcec646 --- /dev/null +++ b/src/browser/errors.ts @@ -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; +} diff --git a/src/browser/profile-capabilities.ts b/src/browser/profile-capabilities.ts new file mode 100644 index 00000000000..07a70ba00c4 --- /dev/null +++ b/src/browser/profile-capabilities.ts @@ -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; +} diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index 38ed6e3c03c..3dc714d33f3 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -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: { diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index 5625cc924db..962c6408522 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -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 => { 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`, ); } diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 53f9c241142..a058ba07791 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -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; } diff --git a/src/browser/resolved-config-refresh.ts b/src/browser/resolved-config-refresh.ts index 721049036d4..fe934069a80 100644 --- a/src/browser/resolved-config-refresh.ts +++ b/src/browser/resolved-config-refresh.ts @@ -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); } diff --git a/src/browser/routes/agent.shared.ts b/src/browser/routes/agent.shared.ts index aee56696525..cc82e00d004 100644 --- a/src/browser/routes/agent.shared.ts +++ b/src/browser/routes/agent.shared.ts @@ -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)); } diff --git a/src/browser/routes/agent.snapshot.plan.test.ts b/src/browser/routes/agent.snapshot.plan.test.ts new file mode 100644 index 00000000000..493fbcdfbad --- /dev/null +++ b/src/browser/routes/agent.snapshot.plan.test.ts @@ -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, + 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, + query: {}, + hasPlaywright: true, + }); + + expect(plan.format).toBe("ai"); + }); +}); diff --git a/src/browser/routes/agent.snapshot.plan.ts b/src/browser/routes/agent.snapshot.plan.ts new file mode 100644 index 00000000000..6c913400d90 --- /dev/null +++ b/src/browser/routes/agent.snapshot.plan.ts @@ -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; + 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 }; diff --git a/src/browser/routes/agent.snapshot.test.ts b/src/browser/routes/agent.snapshot.test.ts index 77b802bdf7d..b31ea1c3e7d 100644 --- a/src/browser/routes/agent.snapshot.test.ts +++ b/src/browser/routes/agent.snapshot.test.ts @@ -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(); + }); }); diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts index 7739caa051e..c750cafe723 100644 --- a/src/browser/routes/agent.snapshot.ts +++ b/src/browser/routes/agent.snapshot.ts @@ -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 { 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, diff --git a/src/browser/routes/basic.ts b/src/browser/routes/basic.ts index 074e7ea285d..5f32c86729b 100644 --- a/src/browser/routes/basic.ts +++ b/src/browser/routes/basic.ts @@ -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)); } }); } diff --git a/src/browser/routes/tabs.ts b/src/browser/routes/tabs.ts index 89531b22f95..87cb36c562c 100644 --- a/src/browser/routes/tabs.ts +++ b/src/browser/routes/tabs.ts @@ -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 }); diff --git a/src/browser/runtime-lifecycle.ts b/src/browser/runtime-lifecycle.ts new file mode 100644 index 00000000000..7b181faea6e --- /dev/null +++ b/src/browser/runtime-lifecycle.ts @@ -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 { + 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 { + if (!params.current) { + return; + } + + await stopKnownBrowserProfiles({ + getState: params.getState, + onWarn: params.onWarn, + }); + + if (params.closeServer && params.current.server) { + await new Promise((resolve) => { + params.current?.server?.close(() => resolve()); + }); + } + + params.clearState(); + + if (!isPwAiLoaded()) { + return; + } + try { + const mod = await import("./pw-ai.js"); + await mod.closePlaywrightBrowserConnection(); + } catch { + // ignore + } +} diff --git a/src/browser/server-context.availability.ts b/src/browser/server-context.availability.ts index 07772c6b598..3b00ff99dff 100644 --- a/src/browser/server-context.availability.ts +++ b/src/browser/server-context.availability.ts @@ -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 => { + try { + const mod = await import("./pw-ai.js"); + await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined); + } catch { + // ignore + } + }; + + const reconcileProfileRuntime = async (): Promise => { + 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 => { // 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 => { + 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, }); diff --git a/src/browser/server-context.hot-reload-profiles.test.ts b/src/browser/server-context.hot-reload-profiles.test.ts index 7145dff5173..ec0c7e072aa 100644 --- a/src/browser/server-context.hot-reload-profiles.test.ts +++ b/src/browser/server-context.hot-reload-profiles.test.ts @@ -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 = {}; @@ -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"); + }); }); diff --git a/src/browser/server-context.reset.ts b/src/browser/server-context.reset.ts index 7f890a2184c..09bc31cbf38 100644 --- a/src/browser/server-context.reset.ts +++ b/src/browser/server-context.reset.ts @@ -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).`, ); } diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 7afeca36c5c..8a9cfa19c42 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -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 => { 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 => { const resolvedTargetId = await resolveTargetIdOrThrow(targetId); - if (!profile.cdpIsLoopback) { + if (capabilities.usesPersistentPlaywright) { const mod = await getPwAiModule({ mode: "strict" }); const focusPageByTargetIdViaPlaywright = (mod as Partial | 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 | null) ?.closePageByTargetIdViaPlaywright; diff --git a/src/browser/server-context.tab-ops.ts b/src/browser/server-context.tab-ops.ts index fcf0d66ebce..5adbd45923e 100644 --- a/src/browser/server-context.tab-ops.ts +++ b/src/browser/server-context.tab-ops.ts @@ -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 => { - // 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 | null)?.listPagesViaPlaywright; if (typeof listPagesViaPlaywright === "function") { @@ -99,8 +100,7 @@ export function createProfileTabOps({ const enforceManagedTabLimit = async (keepTargetId: string): Promise => { 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 => { 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 | null)?.createPageViaPlaywright; if (typeof createPageViaPlaywright === "function") { diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 29632c7b8a4..d75b14c2471 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -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; }; diff --git a/src/browser/server-context.types.ts b/src/browser/server-context.types.ts index b9dc634fe93..f05e90e9e77 100644 --- a/src/browser/server-context.types.ts +++ b/src/browser/server-context.types.ts @@ -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 = { diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index b65c74319a3..8d84ef3c7a8 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -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", }); diff --git a/src/browser/server.ts b/src/browser/server.ts index f6a269aee1e..ce4a59419a4 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -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 logServer.warn(message), }); @@ -93,29 +87,13 @@ export async function startBrowserControlServerFromConfig(): Promise { 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((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 - } - } }