mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor: harden browser runtime profile handling
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
|
||||
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -30,6 +30,8 @@ export type ProfileStatus = {
|
||||
tabCount: number;
|
||||
isDefault: boolean;
|
||||
isRemote: boolean;
|
||||
missingFromConfig?: boolean;
|
||||
reconcileReason?: string | null;
|
||||
};
|
||||
|
||||
export type BrowserResetProfileResult = {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { loadConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveBrowserConfig } from "./config.js";
|
||||
import { ensureBrowserControlAuth } from "./control-auth.js";
|
||||
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
|
||||
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
||||
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
||||
|
||||
let state: BrowserServerState | null = null;
|
||||
const log = createSubsystemLogger("browser");
|
||||
@@ -39,14 +39,9 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
|
||||
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
|
||||
}
|
||||
|
||||
state = {
|
||||
state = await createBrowserRuntimeState({
|
||||
server: null,
|
||||
port: resolved.controlPort,
|
||||
resolved,
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
await ensureExtensionRelayForProfiles({
|
||||
resolved,
|
||||
onWarn: (message) => logService.warn(message),
|
||||
});
|
||||
@@ -59,22 +54,12 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
|
||||
|
||||
export async function stopBrowserControlService(): Promise<void> {
|
||||
const current = state;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stopKnownBrowserProfiles({
|
||||
await stopBrowserRuntime({
|
||||
current,
|
||||
getState: () => state,
|
||||
clearState: () => {
|
||||
state = null;
|
||||
},
|
||||
onWarn: (message) => logService.warn(message),
|
||||
});
|
||||
|
||||
state = null;
|
||||
|
||||
// Optional: Playwright is not always available (e.g. embedded gateway builds).
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
82
src/browser/errors.ts
Normal file
82
src/browser/errors.ts
Normal 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;
|
||||
}
|
||||
100
src/browser/profile-capabilities.ts
Normal file
100
src/browser/profile-capabilities.ts
Normal 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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -3,9 +3,16 @@ import path from "node:path";
|
||||
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import { resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import { parseHttpUrl, resolveProfile } from "./config.js";
|
||||
import { DEFAULT_BROWSER_DEFAULT_PROFILE_NAME } from "./constants.js";
|
||||
import {
|
||||
BrowserConflictError,
|
||||
BrowserProfileNotFoundError,
|
||||
BrowserResourceExhaustedError,
|
||||
BrowserValidationError,
|
||||
} from "./errors.js";
|
||||
import {
|
||||
allocateCdpPort,
|
||||
allocateColor,
|
||||
@@ -75,19 +82,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
const driver = params.driver === "extension" ? "extension" : undefined;
|
||||
|
||||
if (!isValidProfileName(name)) {
|
||||
throw new Error("invalid profile name: use lowercase letters, numbers, and hyphens only");
|
||||
throw new BrowserValidationError(
|
||||
"invalid profile name: use lowercase letters, numbers, and hyphens only",
|
||||
);
|
||||
}
|
||||
|
||||
const state = ctx.state();
|
||||
const resolvedProfiles = state.resolved.profiles;
|
||||
if (name in resolvedProfiles) {
|
||||
throw new Error(`profile "${name}" already exists`);
|
||||
throw new BrowserConflictError(`profile "${name}" already exists`);
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const rawProfiles = cfg.browser?.profiles ?? {};
|
||||
if (name in rawProfiles) {
|
||||
throw new Error(`profile "${name}" already exists`);
|
||||
throw new BrowserConflictError(`profile "${name}" already exists`);
|
||||
}
|
||||
|
||||
const usedColors = getUsedColors(resolvedProfiles);
|
||||
@@ -97,17 +106,32 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
let profileConfig: BrowserProfileConfig;
|
||||
if (rawCdpUrl) {
|
||||
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
|
||||
if (driver === "extension") {
|
||||
if (!isLoopbackHost(parsed.parsed.hostname)) {
|
||||
throw new BrowserValidationError(
|
||||
`driver=extension requires a loopback cdpUrl host, got: ${parsed.parsed.hostname}`,
|
||||
);
|
||||
}
|
||||
if (parsed.parsed.protocol !== "http:" && parsed.parsed.protocol !== "https:") {
|
||||
throw new BrowserValidationError(
|
||||
`driver=extension requires an http(s) cdpUrl, got: ${parsed.parsed.protocol.replace(":", "")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
profileConfig = {
|
||||
cdpUrl: parsed.normalized,
|
||||
...(driver ? { driver } : {}),
|
||||
color: profileColor,
|
||||
};
|
||||
} else {
|
||||
if (driver === "extension") {
|
||||
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
|
||||
}
|
||||
const usedPorts = getUsedPorts(resolvedProfiles);
|
||||
const range = cdpPortRange(state.resolved);
|
||||
const cdpPort = allocateCdpPort(usedPorts, range);
|
||||
if (cdpPort === null) {
|
||||
throw new Error("no available CDP ports in range");
|
||||
throw new BrowserResourceExhaustedError("no available CDP ports in range");
|
||||
}
|
||||
profileConfig = {
|
||||
cdpPort,
|
||||
@@ -132,7 +156,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
state.resolved.profiles[name] = profileConfig;
|
||||
const resolved = resolveProfile(state.resolved, name);
|
||||
if (!resolved) {
|
||||
throw new Error(`profile "${name}" not found after creation`);
|
||||
throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -148,21 +172,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
|
||||
const name = nameRaw.trim();
|
||||
if (!name) {
|
||||
throw new Error("profile name is required");
|
||||
throw new BrowserValidationError("profile name is required");
|
||||
}
|
||||
if (!isValidProfileName(name)) {
|
||||
throw new Error("invalid profile name");
|
||||
throw new BrowserValidationError("invalid profile name");
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const profiles = cfg.browser?.profiles ?? {};
|
||||
if (!(name in profiles)) {
|
||||
throw new Error(`profile "${name}" not found`);
|
||||
throw new BrowserProfileNotFoundError(`profile "${name}" not found`);
|
||||
}
|
||||
|
||||
const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME;
|
||||
if (name === defaultProfile) {
|
||||
throw new Error(
|
||||
throw new BrowserValidationError(
|
||||
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
33
src/browser/routes/agent.snapshot.plan.test.ts
Normal file
33
src/browser/routes/agent.snapshot.plan.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
97
src/browser/routes/agent.snapshot.plan.ts
Normal file
97
src/browser/routes/agent.snapshot.plan.ts
Normal 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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import { captureScreenshot, snapshotAria } from "../cdp.js";
|
||||
import {
|
||||
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
|
||||
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
|
||||
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
} from "../constants.js";
|
||||
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
@@ -22,8 +17,13 @@ import {
|
||||
withPlaywrightRouteContext,
|
||||
withRouteTabContext,
|
||||
} from "./agent.shared.js";
|
||||
import {
|
||||
resolveSnapshotPlan,
|
||||
shouldUsePlaywrightForAriaSnapshot,
|
||||
shouldUsePlaywrightForScreenshot,
|
||||
} from "./agent.snapshot.plan.js";
|
||||
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
async function saveBrowserMediaResponse(params: {
|
||||
res: BrowserResponse;
|
||||
@@ -56,26 +56,28 @@ export async function resolveTargetIdAfterNavigate(opts: {
|
||||
}): Promise<string> {
|
||||
let currentTargetId = opts.oldTargetId;
|
||||
try {
|
||||
const refreshed = await opts.listTabs();
|
||||
if (!refreshed.some((t) => t.targetId === opts.oldTargetId)) {
|
||||
// Renderer swap: old target gone, resolve the replacement.
|
||||
// Prefer a URL match whose targetId differs from the old one
|
||||
// to avoid picking a pre-existing tab when multiple share the URL.
|
||||
const byUrl = refreshed.filter((t) => t.url === opts.navigatedUrl);
|
||||
const replaced = byUrl.find((t) => t.targetId !== opts.oldTargetId) ?? byUrl[0];
|
||||
if (replaced) {
|
||||
currentTargetId = replaced.targetId;
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
const retried = await opts.listTabs();
|
||||
const match =
|
||||
retried.find((t) => t.url === opts.navigatedUrl && t.targetId !== opts.oldTargetId) ??
|
||||
retried.find((t) => t.url === opts.navigatedUrl) ??
|
||||
(retried.length === 1 ? retried[0] : null);
|
||||
if (match) {
|
||||
currentTargetId = match.targetId;
|
||||
}
|
||||
const pickReplacement = (tabs: Array<{ targetId: string; url: string }>) => {
|
||||
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
|
||||
return opts.oldTargetId;
|
||||
}
|
||||
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
|
||||
if (byUrl.length === 1) {
|
||||
return byUrl[0]?.targetId ?? opts.oldTargetId;
|
||||
}
|
||||
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
|
||||
if (uniqueReplacement.length === 1) {
|
||||
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
|
||||
}
|
||||
if (tabs.length === 1) {
|
||||
return tabs[0]?.targetId ?? opts.oldTargetId;
|
||||
}
|
||||
return opts.oldTargetId;
|
||||
};
|
||||
|
||||
currentTargetId = pickReplacement(await opts.listTabs());
|
||||
if (currentTargetId === opts.oldTargetId) {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
currentTargetId = pickReplacement(await opts.listTabs());
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: fall back to pre-navigation targetId
|
||||
@@ -162,11 +164,12 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
targetId,
|
||||
run: async ({ profileCtx, tab, cdpUrl }) => {
|
||||
let buffer: Buffer;
|
||||
const shouldUsePlaywright =
|
||||
profileCtx.profile.driver === "extension" ||
|
||||
!tab.wsUrl ||
|
||||
Boolean(ref) ||
|
||||
Boolean(element);
|
||||
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
|
||||
profile: profileCtx.profile,
|
||||
wsUrl: tab.wsUrl,
|
||||
ref,
|
||||
element,
|
||||
});
|
||||
if (shouldUsePlaywright) {
|
||||
const pw = await requirePwAi(res, "screenshot");
|
||||
if (!pw) {
|
||||
@@ -212,81 +215,45 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
return;
|
||||
}
|
||||
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const mode = req.query.mode === "efficient" ? "efficient" : undefined;
|
||||
const labels = toBoolean(req.query.labels) ?? undefined;
|
||||
const explicitFormat =
|
||||
req.query.format === "aria" ? "aria" : req.query.format === "ai" ? "ai" : undefined;
|
||||
const format = explicitFormat ?? (mode ? "ai" : (await getPwAiModule()) ? "ai" : "aria");
|
||||
const limitRaw = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
|
||||
const hasMaxChars = Object.hasOwn(req.query, "maxChars");
|
||||
const maxCharsRaw =
|
||||
typeof req.query.maxChars === "string" ? Number(req.query.maxChars) : undefined;
|
||||
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
|
||||
const maxChars =
|
||||
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
|
||||
? Math.floor(maxCharsRaw)
|
||||
: undefined;
|
||||
const resolvedMaxChars =
|
||||
format === "ai"
|
||||
? hasMaxChars
|
||||
? maxChars
|
||||
: mode === "efficient"
|
||||
? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS
|
||||
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
|
||||
: undefined;
|
||||
const interactiveRaw = toBoolean(req.query.interactive);
|
||||
const compactRaw = toBoolean(req.query.compact);
|
||||
const depthRaw = toNumber(req.query.depth);
|
||||
const refsModeRaw = toStringOrEmpty(req.query.refs).trim();
|
||||
const refsMode: "aria" | "role" | undefined =
|
||||
refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined;
|
||||
const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined);
|
||||
const compact = compactRaw ?? (mode === "efficient" ? true : undefined);
|
||||
const depth =
|
||||
depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined);
|
||||
const selector = toStringOrEmpty(req.query.selector);
|
||||
const frameSelector = toStringOrEmpty(req.query.frame);
|
||||
const selectorValue = selector.trim() || undefined;
|
||||
const frameSelectorValue = frameSelector.trim() || undefined;
|
||||
const hasPlaywright = Boolean(await getPwAiModule());
|
||||
const plan = resolveSnapshotPlan({
|
||||
profile: profileCtx.profile,
|
||||
query: req.query,
|
||||
hasPlaywright,
|
||||
});
|
||||
|
||||
try {
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||
if ((labels || mode === "efficient") && format === "aria") {
|
||||
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
|
||||
return jsonError(res, 400, "labels/mode=efficient require format=ai");
|
||||
}
|
||||
if (format === "ai") {
|
||||
if (plan.format === "ai") {
|
||||
const pw = await requirePwAi(res, "ai snapshot");
|
||||
if (!pw) {
|
||||
return;
|
||||
}
|
||||
const wantsRoleSnapshot =
|
||||
labels === true ||
|
||||
mode === "efficient" ||
|
||||
interactive === true ||
|
||||
compact === true ||
|
||||
depth !== undefined ||
|
||||
Boolean(selectorValue) ||
|
||||
Boolean(frameSelectorValue);
|
||||
const roleSnapshotArgs = {
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
selector: selectorValue,
|
||||
frameSelector: frameSelectorValue,
|
||||
refsMode,
|
||||
selector: plan.selectorValue,
|
||||
frameSelector: plan.frameSelectorValue,
|
||||
refsMode: plan.refsMode,
|
||||
options: {
|
||||
interactive: interactive ?? undefined,
|
||||
compact: compact ?? undefined,
|
||||
maxDepth: depth ?? undefined,
|
||||
interactive: plan.interactive ?? undefined,
|
||||
compact: plan.compact ?? undefined,
|
||||
maxDepth: plan.depth ?? undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const snap = wantsRoleSnapshot
|
||||
const snap = plan.wantsRoleSnapshot
|
||||
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
|
||||
: await pw
|
||||
.snapshotAiViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
...(typeof plan.resolvedMaxChars === "number"
|
||||
? { maxChars: plan.resolvedMaxChars }
|
||||
: {}),
|
||||
})
|
||||
.catch(async (err) => {
|
||||
// Public-API fallback when Playwright's private _snapshotForAI is missing.
|
||||
@@ -295,7 +262,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (labels) {
|
||||
if (plan.labels) {
|
||||
const labeled = await pw.screenshotWithLabelsViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
@@ -316,7 +283,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
format: plan.format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
labels: true,
|
||||
@@ -330,30 +297,32 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
format: plan.format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
}
|
||||
|
||||
const snap =
|
||||
profileCtx.profile.driver === "extension" || !tab.wsUrl
|
||||
? (() => {
|
||||
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
|
||||
// Also covers cases where wsUrl is missing/unusable.
|
||||
return requirePwAi(res, "aria snapshot").then(async (pw) => {
|
||||
if (!pw) {
|
||||
return null;
|
||||
}
|
||||
return await pw.snapshotAriaViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
limit,
|
||||
});
|
||||
const snap = shouldUsePlaywrightForAriaSnapshot({
|
||||
profile: profileCtx.profile,
|
||||
wsUrl: tab.wsUrl,
|
||||
})
|
||||
? (() => {
|
||||
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
|
||||
// Also covers cases where wsUrl is missing/unusable.
|
||||
return requirePwAi(res, "aria snapshot").then(async (pw) => {
|
||||
if (!pw) {
|
||||
return null;
|
||||
}
|
||||
return await pw.snapshotAriaViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
limit: plan.limit,
|
||||
});
|
||||
})()
|
||||
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit });
|
||||
});
|
||||
})()
|
||||
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
|
||||
|
||||
const resolved = await Promise.resolve(snap);
|
||||
if (!resolved) {
|
||||
@@ -361,7 +330,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
}
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
format: plan.format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...resolved,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
60
src/browser/runtime-lifecycle.ts
Normal file
60
src/browser/runtime-lifecycle.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
stopOpenClawChrome,
|
||||
} from "./chrome.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js";
|
||||
import {
|
||||
ensureChromeExtensionRelayServer,
|
||||
stopChromeExtensionRelayServer,
|
||||
} from "./extension-relay.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import {
|
||||
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
|
||||
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
|
||||
@@ -48,6 +50,7 @@ export function createProfileAvailability({
|
||||
getProfileState,
|
||||
setProfileRunning,
|
||||
}: AvailabilityDeps): AvailabilityOps {
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const resolveTimeouts = (timeoutMs: number | undefined) =>
|
||||
resolveCdpReachabilityTimeouts({
|
||||
profileIsLoopback: profile.cdpIsLoopback,
|
||||
@@ -80,6 +83,38 @@ export function createProfileAvailability({
|
||||
});
|
||||
};
|
||||
|
||||
const closePlaywrightBrowserConnectionForProfile = async (cdpUrl?: string): Promise<void> => {
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const reconcileProfileRuntime = async (): Promise<void> => {
|
||||
const profileState = getProfileState();
|
||||
const reconcile = profileState.reconcile;
|
||||
if (!reconcile) {
|
||||
return;
|
||||
}
|
||||
profileState.reconcile = null;
|
||||
profileState.lastTargetId = null;
|
||||
|
||||
const previousProfile = reconcile.previousProfile;
|
||||
if (profileState.running) {
|
||||
await stopOpenClawChrome(profileState.running).catch(() => {});
|
||||
setProfileRunning(null);
|
||||
}
|
||||
if (previousProfile.driver === "extension") {
|
||||
await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false);
|
||||
}
|
||||
await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl);
|
||||
if (previousProfile.cdpUrl !== profile.cdpUrl) {
|
||||
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const waitForCdpReadyAfterLaunch = async (): Promise<void> => {
|
||||
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS.
|
||||
// If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port.
|
||||
@@ -102,15 +137,16 @@ export function createProfileAvailability({
|
||||
};
|
||||
|
||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||
await reconcileProfileRuntime();
|
||||
const current = state();
|
||||
const remoteCdp = !profile.cdpIsLoopback;
|
||||
const remoteCdp = capabilities.isRemote;
|
||||
const attachOnly = profile.attachOnly;
|
||||
const isExtension = profile.driver === "extension";
|
||||
const isExtension = capabilities.requiresRelay;
|
||||
const profileState = getProfileState();
|
||||
const httpReachable = await isHttpReachable();
|
||||
|
||||
if (isExtension && remoteCdp) {
|
||||
throw new Error(
|
||||
throw new BrowserConfigurationError(
|
||||
`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`,
|
||||
);
|
||||
}
|
||||
@@ -122,7 +158,7 @@ export function createProfileAvailability({
|
||||
bindHost: current.resolved.relayBindHost,
|
||||
});
|
||||
if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) {
|
||||
throw new Error(
|
||||
throw new BrowserProfileUnavailableError(
|
||||
`Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`,
|
||||
);
|
||||
}
|
||||
@@ -140,7 +176,7 @@ export function createProfileAvailability({
|
||||
}
|
||||
}
|
||||
if (attachOnly || remoteCdp) {
|
||||
throw new Error(
|
||||
throw new BrowserProfileUnavailableError(
|
||||
remoteCdp
|
||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
|
||||
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
|
||||
@@ -172,7 +208,7 @@ export function createProfileAvailability({
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
throw new BrowserProfileUnavailableError(
|
||||
remoteCdp
|
||||
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
|
||||
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
|
||||
@@ -181,7 +217,7 @@ export function createProfileAvailability({
|
||||
|
||||
// HTTP responds but WebSocket fails - port in use by something else.
|
||||
if (!profileState.running) {
|
||||
throw new Error(
|
||||
throw new BrowserProfileUnavailableError(
|
||||
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
|
||||
`Run action=reset-profile profile=${profile.name} to kill the process.`,
|
||||
);
|
||||
@@ -201,7 +237,8 @@ export function createProfileAvailability({
|
||||
};
|
||||
|
||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||
if (profile.driver === "extension") {
|
||||
await reconcileProfileRuntime();
|
||||
if (capabilities.requiresRelay) {
|
||||
const stopped = await stopChromeExtensionRelayServer({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveBrowserConfig } from "./config.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||
import {
|
||||
refreshResolvedBrowserConfigFromDisk,
|
||||
resolveBrowserProfileWithHotReload,
|
||||
} from "./resolved-config-refresh.js";
|
||||
import type { BrowserServerState } from "./server-context.types.js";
|
||||
|
||||
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
|
||||
|
||||
@@ -166,4 +167,42 @@ describe("server-context hot-reload profiles", () => {
|
||||
});
|
||||
expect(Object.keys(state.resolved.profiles)).toContain("desktop");
|
||||
});
|
||||
|
||||
it("marks existing runtime state for reconcile when profile invariants change", async () => {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const openclawProfile = resolveProfile(resolved, "openclaw");
|
||||
expect(openclawProfile).toBeTruthy();
|
||||
const state: BrowserServerState = {
|
||||
server: null,
|
||||
port: 18791,
|
||||
resolved,
|
||||
profiles: new Map([
|
||||
[
|
||||
"openclaw",
|
||||
{
|
||||
profile: openclawProfile!,
|
||||
running: { pid: 123 } as never,
|
||||
lastTargetId: "tab-1",
|
||||
reconcile: null,
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
|
||||
cfgProfiles.openclaw = { cdpPort: 19999, color: "#FF4500" };
|
||||
cachedConfig = null;
|
||||
|
||||
refreshResolvedBrowserConfigFromDisk({
|
||||
current: state,
|
||||
refreshConfigFromDisk: true,
|
||||
mode: "cached",
|
||||
});
|
||||
|
||||
const runtime = state.profiles.get("openclaw");
|
||||
expect(runtime).toBeTruthy();
|
||||
expect(runtime?.profile.cdpPort).toBe(19999);
|
||||
expect(runtime?.lastTargetId).toBeNull();
|
||||
expect(runtime?.reconcile?.reason).toContain("cdpPort");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||
import { appendCdpPath } from "./cdp.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import type { PwAiModule } from "./pw-ai-module.js";
|
||||
import { getPwAiModule } from "./pw-ai-module.js";
|
||||
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
|
||||
@@ -28,13 +30,14 @@ export function createProfileSelectionOps({
|
||||
openTab,
|
||||
}: SelectionDeps): SelectionOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
|
||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||
await ensureBrowserAvailable();
|
||||
const profileState = getProfileState();
|
||||
let tabs1 = await listTabs();
|
||||
if (tabs1.length === 0) {
|
||||
if (profile.driver === "extension") {
|
||||
if (capabilities.requiresAttachedTab) {
|
||||
// Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker
|
||||
// lifecycle, relay restart). If we previously had a target selected, wait briefly for
|
||||
// the extension to reconnect and re-announce its attached tabs before failing.
|
||||
@@ -46,7 +49,7 @@ export function createProfileSelectionOps({
|
||||
}
|
||||
}
|
||||
if (tabs1.length === 0) {
|
||||
throw new Error(
|
||||
throw new BrowserTabNotFoundError(
|
||||
`tab not found (no attached Chrome tabs for profile "${profile.name}"). ` +
|
||||
"Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).",
|
||||
);
|
||||
@@ -57,12 +60,7 @@ export function createProfileSelectionOps({
|
||||
}
|
||||
|
||||
const tabs = await listTabs();
|
||||
// For remote profiles using Playwright's persistent connection, we don't need wsUrl
|
||||
// because we access pages directly through Playwright, not via individual WebSocket URLs.
|
||||
const candidates =
|
||||
profile.driver === "extension" || !profile.cdpIsLoopback
|
||||
? tabs
|
||||
: tabs.filter((t) => Boolean(t.wsUrl));
|
||||
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
|
||||
|
||||
const resolveById = (raw: string) => {
|
||||
const resolved = resolveTargetIdFromTabs(raw, candidates);
|
||||
@@ -89,10 +87,10 @@ export function createProfileSelectionOps({
|
||||
const chosen = targetId ? resolveById(targetId) : pickDefault();
|
||||
|
||||
if (chosen === "AMBIGUOUS") {
|
||||
throw new Error("ambiguous target id prefix");
|
||||
throw new BrowserTargetAmbiguousError();
|
||||
}
|
||||
if (!chosen) {
|
||||
throw new Error("tab not found");
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
profileState.lastTargetId = chosen.targetId;
|
||||
return chosen;
|
||||
@@ -103,9 +101,9 @@ export function createProfileSelectionOps({
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === "ambiguous") {
|
||||
throw new Error("ambiguous target id prefix");
|
||||
throw new BrowserTargetAmbiguousError();
|
||||
}
|
||||
throw new Error("tab not found");
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
return resolved.targetId;
|
||||
};
|
||||
@@ -113,7 +111,7 @@ export function createProfileSelectionOps({
|
||||
const focusTab = async (targetId: string): Promise<void> => {
|
||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||
|
||||
if (!profile.cdpIsLoopback) {
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||
?.focusPageByTargetIdViaPlaywright;
|
||||
@@ -137,7 +135,7 @@ export function createProfileSelectionOps({
|
||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||
|
||||
// For remote profiles, use Playwright's persistent connection to close tabs
|
||||
if (!profile.cdpIsLoopback) {
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||
?.closePageByTargetIdViaPlaywright;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
import type { PwAiModule } from "./pw-ai-module.js";
|
||||
import { getPwAiModule } from "./pw-ai-module.js";
|
||||
import {
|
||||
@@ -59,10 +60,10 @@ export function createProfileTabOps({
|
||||
getProfileState,
|
||||
}: TabOpsDeps): ProfileTabOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
|
||||
if (!profile.cdpIsLoopback) {
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
|
||||
if (typeof listPagesViaPlaywright === "function") {
|
||||
@@ -99,8 +100,7 @@ export function createProfileTabOps({
|
||||
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
|
||||
const profileState = getProfileState();
|
||||
if (
|
||||
profile.driver !== "openclaw" ||
|
||||
!profile.cdpIsLoopback ||
|
||||
!capabilities.supportsManagedTabLimit ||
|
||||
state().resolved.attachOnly ||
|
||||
!profileState.running
|
||||
) {
|
||||
@@ -132,9 +132,7 @@ export function createProfileTabOps({
|
||||
const openTab = async (url: string): Promise<BrowserTab> => {
|
||||
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
|
||||
|
||||
// For remote profiles, use Playwright's persistent connection to create tabs
|
||||
// This ensures the tab persists beyond a single request.
|
||||
if (!profile.cdpIsLoopback) {
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
|
||||
if (typeof createPageViaPlaywright === "function") {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -4,11 +4,10 @@ import { loadConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveBrowserConfig } from "./config.js";
|
||||
import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js";
|
||||
import { isPwAiLoaded } from "./pw-ai-state.js";
|
||||
import { registerBrowserRoutes } from "./routes/index.js";
|
||||
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
||||
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
|
||||
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
||||
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
||||
import {
|
||||
installBrowserAuthMiddleware,
|
||||
installBrowserCommonMiddleware,
|
||||
@@ -74,14 +73,9 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
return null;
|
||||
}
|
||||
|
||||
state = {
|
||||
state = await createBrowserRuntimeState({
|
||||
server,
|
||||
port,
|
||||
resolved,
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
await ensureExtensionRelayForProfiles({
|
||||
resolved,
|
||||
onWarn: (message) => logServer.warn(message),
|
||||
});
|
||||
@@ -93,29 +87,13 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
||||
|
||||
export async function stopBrowserControlServer(): Promise<void> {
|
||||
const current = state;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stopKnownBrowserProfiles({
|
||||
await stopBrowserRuntime({
|
||||
current,
|
||||
getState: () => state,
|
||||
clearState: () => {
|
||||
state = null;
|
||||
},
|
||||
closeServer: true,
|
||||
onWarn: (message) => logServer.warn(message),
|
||||
});
|
||||
|
||||
if (current.server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
current.server?.close(() => resolve());
|
||||
});
|
||||
}
|
||||
state = null;
|
||||
|
||||
// Optional: avoid importing heavy Playwright bridge when this process never used it.
|
||||
if (isPwAiLoaded()) {
|
||||
try {
|
||||
const mod = await import("./pw-ai.js");
|
||||
await mod.closePlaywrightBrowserConnection();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user