mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 03:02:45 +00:00
refactor: harden browser runtime profile handling
This commit is contained in:
committed by
Vincent Koc
parent
7875fb6c27
commit
4b694d565d
@@ -1,5 +1,8 @@
|
|||||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
|
||||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
|
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
|
||||||
|
import {
|
||||||
|
AllowFromEntrySchema,
|
||||||
|
buildCatchallMultiAccountChannelSchema,
|
||||||
|
} from "openclaw/plugin-sdk/compat";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
|
||||||
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||||
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||||
|
|
||||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
|
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
|
||||||
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||||
import {
|
import {
|
||||||
buildAccountScopedDmSecurityPolicy,
|
buildAccountScopedDmSecurityPolicy,
|
||||||
collectOpenProviderGroupPolicyWarnings,
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
|
||||||
|
|
||||||
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
|
||||||
|
|
||||||
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
|
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||||
import {
|
import {
|
||||||
buildAccountScopedDmSecurityPolicy,
|
buildAccountScopedDmSecurityPolicy,
|
||||||
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat";
|
||||||
|
|
||||||
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
|
||||||
|
|
||||||
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/irc";
|
||||||
|
|
||||||
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/line";
|
||||||
|
|
||||||
const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
|
||||||
|
|
||||||
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
|
||||||
|
|
||||||
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/msteams";
|
||||||
|
|
||||||
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
|
||||||
|
|
||||||
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
|
||||||
|
|
||||||
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
|
||||||
|
|
||||||
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
|
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||||
import {
|
import {
|
||||||
buildAccountScopedDmSecurityPolicy,
|
buildAccountScopedDmSecurityPolicy,
|
||||||
collectOpenProviderGroupPolicyWarnings,
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
|
||||||
|
|
||||||
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat";
|
||||||
|
|
||||||
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
|
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||||
import {
|
import {
|
||||||
collectAllowlistProviderGroupPolicyWarnings,
|
collectAllowlistProviderGroupPolicyWarnings,
|
||||||
buildAccountScopedDmSecurityPolicy,
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
|
||||||
|
|
||||||
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
|
||||||
|
|
||||||
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/twitch";
|
||||||
|
|
||||||
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
|
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";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
|
||||||
|
|
||||||
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
|
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 { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { buildSecretInputSchema } from "./secret-input.js";
|
import { buildSecretInputSchema } from "./secret-input.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||||
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
|
||||||
|
|
||||||
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
|
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
import {
|
||||||
|
AllowFromEntrySchema,
|
||||||
|
buildCatchallMultiAccountChannelSchema,
|
||||||
|
} from "openclaw/plugin-sdk/compat";
|
||||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
|
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||||
import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
|
import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
|
||||||
|
|
||||||
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
|
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export type ProfileStatus = {
|
|||||||
tabCount: number;
|
tabCount: number;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
isRemote: boolean;
|
isRemote: boolean;
|
||||||
|
missingFromConfig?: boolean;
|
||||||
|
reconcileReason?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrowserResetProfileResult = {
|
export type BrowserResetProfileResult = {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { loadConfig } from "../config/config.js";
|
|||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { resolveBrowserConfig } from "./config.js";
|
import { resolveBrowserConfig } from "./config.js";
|
||||||
import { ensureBrowserControlAuth } from "./control-auth.js";
|
import { ensureBrowserControlAuth } from "./control-auth.js";
|
||||||
|
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
|
||||||
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
||||||
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
|
||||||
|
|
||||||
let state: BrowserServerState | null = null;
|
let state: BrowserServerState | null = null;
|
||||||
const log = createSubsystemLogger("browser");
|
const log = createSubsystemLogger("browser");
|
||||||
@@ -39,14 +39,9 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
|
|||||||
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
|
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
state = {
|
state = await createBrowserRuntimeState({
|
||||||
server: null,
|
server: null,
|
||||||
port: resolved.controlPort,
|
port: resolved.controlPort,
|
||||||
resolved,
|
|
||||||
profiles: new Map(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await ensureExtensionRelayForProfiles({
|
|
||||||
resolved,
|
resolved,
|
||||||
onWarn: (message) => logService.warn(message),
|
onWarn: (message) => logService.warn(message),
|
||||||
});
|
});
|
||||||
@@ -59,22 +54,12 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
|
|||||||
|
|
||||||
export async function stopBrowserControlService(): Promise<void> {
|
export async function stopBrowserControlService(): Promise<void> {
|
||||||
const current = state;
|
const current = state;
|
||||||
if (!current) {
|
await stopBrowserRuntime({
|
||||||
return;
|
current,
|
||||||
}
|
|
||||||
|
|
||||||
await stopKnownBrowserProfiles({
|
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
|
clearState: () => {
|
||||||
|
state = null;
|
||||||
|
},
|
||||||
onWarn: (message) => logService.warn(message),
|
onWarn: (message) => logService.warn(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
state = null;
|
|
||||||
|
|
||||||
// Optional: Playwright is not always available (e.g. embedded gateway builds).
|
|
||||||
try {
|
|
||||||
const mod = await import("./pw-ai.js");
|
|
||||||
await mod.closePlaywrightBrowserConnection();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/browser/errors.ts
Normal file
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 () => {
|
it("deletes remote profiles without stopping or removing local data", async () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
profiles: {
|
profiles: {
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import path from "node:path";
|
|||||||
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
|
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
|
||||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||||
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
|
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
|
||||||
|
import { isLoopbackHost } from "../gateway/net.js";
|
||||||
import { resolveOpenClawUserDataDir } from "./chrome.js";
|
import { resolveOpenClawUserDataDir } from "./chrome.js";
|
||||||
import { parseHttpUrl, resolveProfile } from "./config.js";
|
import { parseHttpUrl, resolveProfile } from "./config.js";
|
||||||
import { DEFAULT_BROWSER_DEFAULT_PROFILE_NAME } from "./constants.js";
|
import { DEFAULT_BROWSER_DEFAULT_PROFILE_NAME } from "./constants.js";
|
||||||
|
import {
|
||||||
|
BrowserConflictError,
|
||||||
|
BrowserProfileNotFoundError,
|
||||||
|
BrowserResourceExhaustedError,
|
||||||
|
BrowserValidationError,
|
||||||
|
} from "./errors.js";
|
||||||
import {
|
import {
|
||||||
allocateCdpPort,
|
allocateCdpPort,
|
||||||
allocateColor,
|
allocateColor,
|
||||||
@@ -75,19 +82,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
|||||||
const driver = params.driver === "extension" ? "extension" : undefined;
|
const driver = params.driver === "extension" ? "extension" : undefined;
|
||||||
|
|
||||||
if (!isValidProfileName(name)) {
|
if (!isValidProfileName(name)) {
|
||||||
throw new Error("invalid profile name: use lowercase letters, numbers, and hyphens only");
|
throw new BrowserValidationError(
|
||||||
|
"invalid profile name: use lowercase letters, numbers, and hyphens only",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = ctx.state();
|
const state = ctx.state();
|
||||||
const resolvedProfiles = state.resolved.profiles;
|
const resolvedProfiles = state.resolved.profiles;
|
||||||
if (name in resolvedProfiles) {
|
if (name in resolvedProfiles) {
|
||||||
throw new Error(`profile "${name}" already exists`);
|
throw new BrowserConflictError(`profile "${name}" already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const rawProfiles = cfg.browser?.profiles ?? {};
|
const rawProfiles = cfg.browser?.profiles ?? {};
|
||||||
if (name in rawProfiles) {
|
if (name in rawProfiles) {
|
||||||
throw new Error(`profile "${name}" already exists`);
|
throw new BrowserConflictError(`profile "${name}" already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usedColors = getUsedColors(resolvedProfiles);
|
const usedColors = getUsedColors(resolvedProfiles);
|
||||||
@@ -97,17 +106,32 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
|||||||
let profileConfig: BrowserProfileConfig;
|
let profileConfig: BrowserProfileConfig;
|
||||||
if (rawCdpUrl) {
|
if (rawCdpUrl) {
|
||||||
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
|
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
|
||||||
|
if (driver === "extension") {
|
||||||
|
if (!isLoopbackHost(parsed.parsed.hostname)) {
|
||||||
|
throw new BrowserValidationError(
|
||||||
|
`driver=extension requires a loopback cdpUrl host, got: ${parsed.parsed.hostname}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsed.parsed.protocol !== "http:" && parsed.parsed.protocol !== "https:") {
|
||||||
|
throw new BrowserValidationError(
|
||||||
|
`driver=extension requires an http(s) cdpUrl, got: ${parsed.parsed.protocol.replace(":", "")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
profileConfig = {
|
profileConfig = {
|
||||||
cdpUrl: parsed.normalized,
|
cdpUrl: parsed.normalized,
|
||||||
...(driver ? { driver } : {}),
|
...(driver ? { driver } : {}),
|
||||||
color: profileColor,
|
color: profileColor,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
if (driver === "extension") {
|
||||||
|
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
|
||||||
|
}
|
||||||
const usedPorts = getUsedPorts(resolvedProfiles);
|
const usedPorts = getUsedPorts(resolvedProfiles);
|
||||||
const range = cdpPortRange(state.resolved);
|
const range = cdpPortRange(state.resolved);
|
||||||
const cdpPort = allocateCdpPort(usedPorts, range);
|
const cdpPort = allocateCdpPort(usedPorts, range);
|
||||||
if (cdpPort === null) {
|
if (cdpPort === null) {
|
||||||
throw new Error("no available CDP ports in range");
|
throw new BrowserResourceExhaustedError("no available CDP ports in range");
|
||||||
}
|
}
|
||||||
profileConfig = {
|
profileConfig = {
|
||||||
cdpPort,
|
cdpPort,
|
||||||
@@ -132,7 +156,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
|||||||
state.resolved.profiles[name] = profileConfig;
|
state.resolved.profiles[name] = profileConfig;
|
||||||
const resolved = resolveProfile(state.resolved, name);
|
const resolved = resolveProfile(state.resolved, name);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(`profile "${name}" not found after creation`);
|
throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -148,21 +172,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
|||||||
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
|
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
|
||||||
const name = nameRaw.trim();
|
const name = nameRaw.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new Error("profile name is required");
|
throw new BrowserValidationError("profile name is required");
|
||||||
}
|
}
|
||||||
if (!isValidProfileName(name)) {
|
if (!isValidProfileName(name)) {
|
||||||
throw new Error("invalid profile name");
|
throw new BrowserValidationError("invalid profile name");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const profiles = cfg.browser?.profiles ?? {};
|
const profiles = cfg.browser?.profiles ?? {};
|
||||||
if (!(name in profiles)) {
|
if (!(name in profiles)) {
|
||||||
throw new Error(`profile "${name}" not found`);
|
throw new BrowserProfileNotFoundError(`profile "${name}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME;
|
const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME;
|
||||||
if (name === defaultProfile) {
|
if (name === defaultProfile) {
|
||||||
throw new Error(
|
throw new BrowserValidationError(
|
||||||
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
|
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "./cdp.helpers.js";
|
} from "./cdp.helpers.js";
|
||||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||||
|
import { BrowserTabNotFoundError } from "./errors.js";
|
||||||
import {
|
import {
|
||||||
assertBrowserNavigationAllowed,
|
assertBrowserNavigationAllowed,
|
||||||
assertBrowserNavigationResultAllowed,
|
assertBrowserNavigationResultAllowed,
|
||||||
@@ -495,7 +496,7 @@ async function resolvePageByTargetIdOrThrow(opts: {
|
|||||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||||
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||||
if (!page) {
|
if (!page) {
|
||||||
throw new Error("tab not found");
|
throw new BrowserTabNotFoundError();
|
||||||
}
|
}
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
@@ -521,7 +522,7 @@ export async function getPageForTargetId(opts: {
|
|||||||
if (pages.length === 1) {
|
if (pages.length === 1) {
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
throw new Error("tab not found");
|
throw new BrowserTabNotFoundError();
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,29 @@ import { createConfigIO, loadConfig } from "../config/config.js";
|
|||||||
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js";
|
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js";
|
||||||
import type { BrowserServerState } from "./server-context.types.js";
|
import type { BrowserServerState } from "./server-context.types.js";
|
||||||
|
|
||||||
|
function changedProfileInvariants(
|
||||||
|
current: ResolvedBrowserProfile,
|
||||||
|
next: ResolvedBrowserProfile,
|
||||||
|
): string[] {
|
||||||
|
const changed: string[] = [];
|
||||||
|
if (current.cdpUrl !== next.cdpUrl) {
|
||||||
|
changed.push("cdpUrl");
|
||||||
|
}
|
||||||
|
if (current.cdpPort !== next.cdpPort) {
|
||||||
|
changed.push("cdpPort");
|
||||||
|
}
|
||||||
|
if (current.driver !== next.driver) {
|
||||||
|
changed.push("driver");
|
||||||
|
}
|
||||||
|
if (current.attachOnly !== next.attachOnly) {
|
||||||
|
changed.push("attachOnly");
|
||||||
|
}
|
||||||
|
if (current.cdpIsLoopback !== next.cdpIsLoopback) {
|
||||||
|
changed.push("cdpIsLoopback");
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
function applyResolvedConfig(
|
function applyResolvedConfig(
|
||||||
current: BrowserServerState,
|
current: BrowserServerState,
|
||||||
freshResolved: BrowserServerState["resolved"],
|
freshResolved: BrowserServerState["resolved"],
|
||||||
@@ -10,9 +33,22 @@ function applyResolvedConfig(
|
|||||||
for (const [name, runtime] of current.profiles) {
|
for (const [name, runtime] of current.profiles) {
|
||||||
const nextProfile = resolveProfile(freshResolved, name);
|
const nextProfile = resolveProfile(freshResolved, name);
|
||||||
if (nextProfile) {
|
if (nextProfile) {
|
||||||
|
const changed = changedProfileInvariants(runtime.profile, nextProfile);
|
||||||
|
if (changed.length > 0) {
|
||||||
|
runtime.reconcile = {
|
||||||
|
previousProfile: runtime.profile,
|
||||||
|
reason: `profile invariants changed: ${changed.join(", ")}`,
|
||||||
|
};
|
||||||
|
runtime.lastTargetId = null;
|
||||||
|
}
|
||||||
runtime.profile = nextProfile;
|
runtime.profile = nextProfile;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
runtime.reconcile = {
|
||||||
|
previousProfile: runtime.profile,
|
||||||
|
reason: "profile removed from config",
|
||||||
|
};
|
||||||
|
runtime.lastTargetId = null;
|
||||||
if (!runtime.running) {
|
if (!runtime.running) {
|
||||||
current.profiles.delete(name);
|
current.profiles.delete(name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toBrowserErrorResponse } from "../errors.js";
|
||||||
import type { PwAiModule } from "../pw-ai-module.js";
|
import type { PwAiModule } from "../pw-ai-module.js";
|
||||||
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
|
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
|
||||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||||
@@ -37,6 +38,10 @@ export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse,
|
|||||||
if (mapped) {
|
if (mapped) {
|
||||||
return jsonError(res, mapped.status, mapped.message);
|
return jsonError(res, mapped.status, mapped.message);
|
||||||
}
|
}
|
||||||
|
const browserMapped = toBrowserErrorResponse(err);
|
||||||
|
if (browserMapped) {
|
||||||
|
return jsonError(res, browserMapped.status, browserMapped.message);
|
||||||
|
}
|
||||||
jsonError(res, 500, String(err));
|
jsonError(res, 500, String(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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" },
|
{ targetId: "fresh-777", url: "https://example.com" },
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
// Both differ from old targetId; the first non-stale match wins.
|
// Ambiguous replacement; prefer staying on the old target rather than guessing wrong.
|
||||||
expect(result).toBe("preexisting-000");
|
expect(result).toBe("old-123");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries and resolves targetId when first listTabs has no URL match", async () => {
|
it("retries and resolves targetId when first listTabs has no URL match", async () => {
|
||||||
@@ -114,4 +114,24 @@ describe("resolveTargetIdAfterNavigate", () => {
|
|||||||
});
|
});
|
||||||
expect(result).toBe("old-123");
|
expect(result).toBe("old-123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the old target when multiple replacement candidates still match after retry", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const result$ = resolveTargetIdAfterNavigate({
|
||||||
|
oldTargetId: "old-123",
|
||||||
|
navigatedUrl: "https://example.com",
|
||||||
|
listTabs: staticListTabs([
|
||||||
|
{ targetId: "preexisting-000", url: "https://example.com" },
|
||||||
|
{ targetId: "fresh-777", url: "https://example.com" },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
const result = await result$;
|
||||||
|
|
||||||
|
expect(result).toBe("old-123");
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||||
import { captureScreenshot, snapshotAria } from "../cdp.js";
|
import { captureScreenshot, snapshotAria } from "../cdp.js";
|
||||||
import {
|
|
||||||
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
|
|
||||||
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
|
|
||||||
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
|
||||||
} from "../constants.js";
|
|
||||||
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
|
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||||
@@ -22,8 +17,13 @@ import {
|
|||||||
withPlaywrightRouteContext,
|
withPlaywrightRouteContext,
|
||||||
withRouteTabContext,
|
withRouteTabContext,
|
||||||
} from "./agent.shared.js";
|
} from "./agent.shared.js";
|
||||||
|
import {
|
||||||
|
resolveSnapshotPlan,
|
||||||
|
shouldUsePlaywrightForAriaSnapshot,
|
||||||
|
shouldUsePlaywrightForScreenshot,
|
||||||
|
} from "./agent.snapshot.plan.js";
|
||||||
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
|
||||||
|
|
||||||
async function saveBrowserMediaResponse(params: {
|
async function saveBrowserMediaResponse(params: {
|
||||||
res: BrowserResponse;
|
res: BrowserResponse;
|
||||||
@@ -56,26 +56,28 @@ export async function resolveTargetIdAfterNavigate(opts: {
|
|||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
let currentTargetId = opts.oldTargetId;
|
let currentTargetId = opts.oldTargetId;
|
||||||
try {
|
try {
|
||||||
const refreshed = await opts.listTabs();
|
const pickReplacement = (tabs: Array<{ targetId: string; url: string }>) => {
|
||||||
if (!refreshed.some((t) => t.targetId === opts.oldTargetId)) {
|
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
|
||||||
// Renderer swap: old target gone, resolve the replacement.
|
return opts.oldTargetId;
|
||||||
// Prefer a URL match whose targetId differs from the old one
|
|
||||||
// to avoid picking a pre-existing tab when multiple share the URL.
|
|
||||||
const byUrl = refreshed.filter((t) => t.url === opts.navigatedUrl);
|
|
||||||
const replaced = byUrl.find((t) => t.targetId !== opts.oldTargetId) ?? byUrl[0];
|
|
||||||
if (replaced) {
|
|
||||||
currentTargetId = replaced.targetId;
|
|
||||||
} else {
|
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
|
||||||
const retried = await opts.listTabs();
|
|
||||||
const match =
|
|
||||||
retried.find((t) => t.url === opts.navigatedUrl && t.targetId !== opts.oldTargetId) ??
|
|
||||||
retried.find((t) => t.url === opts.navigatedUrl) ??
|
|
||||||
(retried.length === 1 ? retried[0] : null);
|
|
||||||
if (match) {
|
|
||||||
currentTargetId = match.targetId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
|
||||||
|
if (byUrl.length === 1) {
|
||||||
|
return byUrl[0]?.targetId ?? opts.oldTargetId;
|
||||||
|
}
|
||||||
|
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
|
||||||
|
if (uniqueReplacement.length === 1) {
|
||||||
|
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
|
||||||
|
}
|
||||||
|
if (tabs.length === 1) {
|
||||||
|
return tabs[0]?.targetId ?? opts.oldTargetId;
|
||||||
|
}
|
||||||
|
return opts.oldTargetId;
|
||||||
|
};
|
||||||
|
|
||||||
|
currentTargetId = pickReplacement(await opts.listTabs());
|
||||||
|
if (currentTargetId === opts.oldTargetId) {
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
currentTargetId = pickReplacement(await opts.listTabs());
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort: fall back to pre-navigation targetId
|
// Best-effort: fall back to pre-navigation targetId
|
||||||
@@ -162,11 +164,12 @@ export function registerBrowserAgentSnapshotRoutes(
|
|||||||
targetId,
|
targetId,
|
||||||
run: async ({ profileCtx, tab, cdpUrl }) => {
|
run: async ({ profileCtx, tab, cdpUrl }) => {
|
||||||
let buffer: Buffer;
|
let buffer: Buffer;
|
||||||
const shouldUsePlaywright =
|
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
|
||||||
profileCtx.profile.driver === "extension" ||
|
profile: profileCtx.profile,
|
||||||
!tab.wsUrl ||
|
wsUrl: tab.wsUrl,
|
||||||
Boolean(ref) ||
|
ref,
|
||||||
Boolean(element);
|
element,
|
||||||
|
});
|
||||||
if (shouldUsePlaywright) {
|
if (shouldUsePlaywright) {
|
||||||
const pw = await requirePwAi(res, "screenshot");
|
const pw = await requirePwAi(res, "screenshot");
|
||||||
if (!pw) {
|
if (!pw) {
|
||||||
@@ -212,81 +215,45 @@ export function registerBrowserAgentSnapshotRoutes(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||||
const mode = req.query.mode === "efficient" ? "efficient" : undefined;
|
const hasPlaywright = Boolean(await getPwAiModule());
|
||||||
const labels = toBoolean(req.query.labels) ?? undefined;
|
const plan = resolveSnapshotPlan({
|
||||||
const explicitFormat =
|
profile: profileCtx.profile,
|
||||||
req.query.format === "aria" ? "aria" : req.query.format === "ai" ? "ai" : undefined;
|
query: req.query,
|
||||||
const format = explicitFormat ?? (mode ? "ai" : (await getPwAiModule()) ? "ai" : "aria");
|
hasPlaywright,
|
||||||
const limitRaw = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
|
});
|
||||||
const hasMaxChars = Object.hasOwn(req.query, "maxChars");
|
|
||||||
const maxCharsRaw =
|
|
||||||
typeof req.query.maxChars === "string" ? Number(req.query.maxChars) : undefined;
|
|
||||||
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
|
|
||||||
const maxChars =
|
|
||||||
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
|
|
||||||
? Math.floor(maxCharsRaw)
|
|
||||||
: undefined;
|
|
||||||
const resolvedMaxChars =
|
|
||||||
format === "ai"
|
|
||||||
? hasMaxChars
|
|
||||||
? maxChars
|
|
||||||
: mode === "efficient"
|
|
||||||
? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS
|
|
||||||
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
|
|
||||||
: undefined;
|
|
||||||
const interactiveRaw = toBoolean(req.query.interactive);
|
|
||||||
const compactRaw = toBoolean(req.query.compact);
|
|
||||||
const depthRaw = toNumber(req.query.depth);
|
|
||||||
const refsModeRaw = toStringOrEmpty(req.query.refs).trim();
|
|
||||||
const refsMode: "aria" | "role" | undefined =
|
|
||||||
refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined;
|
|
||||||
const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined);
|
|
||||||
const compact = compactRaw ?? (mode === "efficient" ? true : undefined);
|
|
||||||
const depth =
|
|
||||||
depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined);
|
|
||||||
const selector = toStringOrEmpty(req.query.selector);
|
|
||||||
const frameSelector = toStringOrEmpty(req.query.frame);
|
|
||||||
const selectorValue = selector.trim() || undefined;
|
|
||||||
const frameSelectorValue = frameSelector.trim() || undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||||
if ((labels || mode === "efficient") && format === "aria") {
|
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
|
||||||
return jsonError(res, 400, "labels/mode=efficient require format=ai");
|
return jsonError(res, 400, "labels/mode=efficient require format=ai");
|
||||||
}
|
}
|
||||||
if (format === "ai") {
|
if (plan.format === "ai") {
|
||||||
const pw = await requirePwAi(res, "ai snapshot");
|
const pw = await requirePwAi(res, "ai snapshot");
|
||||||
if (!pw) {
|
if (!pw) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wantsRoleSnapshot =
|
|
||||||
labels === true ||
|
|
||||||
mode === "efficient" ||
|
|
||||||
interactive === true ||
|
|
||||||
compact === true ||
|
|
||||||
depth !== undefined ||
|
|
||||||
Boolean(selectorValue) ||
|
|
||||||
Boolean(frameSelectorValue);
|
|
||||||
const roleSnapshotArgs = {
|
const roleSnapshotArgs = {
|
||||||
cdpUrl: profileCtx.profile.cdpUrl,
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
selector: selectorValue,
|
selector: plan.selectorValue,
|
||||||
frameSelector: frameSelectorValue,
|
frameSelector: plan.frameSelectorValue,
|
||||||
refsMode,
|
refsMode: plan.refsMode,
|
||||||
options: {
|
options: {
|
||||||
interactive: interactive ?? undefined,
|
interactive: plan.interactive ?? undefined,
|
||||||
compact: compact ?? undefined,
|
compact: plan.compact ?? undefined,
|
||||||
maxDepth: depth ?? undefined,
|
maxDepth: plan.depth ?? undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const snap = wantsRoleSnapshot
|
const snap = plan.wantsRoleSnapshot
|
||||||
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
|
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
|
||||||
: await pw
|
: await pw
|
||||||
.snapshotAiViaPlaywright({
|
.snapshotAiViaPlaywright({
|
||||||
cdpUrl: profileCtx.profile.cdpUrl,
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
...(typeof plan.resolvedMaxChars === "number"
|
||||||
|
? { maxChars: plan.resolvedMaxChars }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
.catch(async (err) => {
|
.catch(async (err) => {
|
||||||
// Public-API fallback when Playwright's private _snapshotForAI is missing.
|
// Public-API fallback when Playwright's private _snapshotForAI is missing.
|
||||||
@@ -295,7 +262,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
if (labels) {
|
if (plan.labels) {
|
||||||
const labeled = await pw.screenshotWithLabelsViaPlaywright({
|
const labeled = await pw.screenshotWithLabelsViaPlaywright({
|
||||||
cdpUrl: profileCtx.profile.cdpUrl,
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
@@ -316,7 +283,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
|||||||
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
|
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
format,
|
format: plan.format,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
url: tab.url,
|
url: tab.url,
|
||||||
labels: true,
|
labels: true,
|
||||||
@@ -330,30 +297,32 @@ export function registerBrowserAgentSnapshotRoutes(
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
format,
|
format: plan.format,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
url: tab.url,
|
url: tab.url,
|
||||||
...snap,
|
...snap,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const snap =
|
const snap = shouldUsePlaywrightForAriaSnapshot({
|
||||||
profileCtx.profile.driver === "extension" || !tab.wsUrl
|
profile: profileCtx.profile,
|
||||||
? (() => {
|
wsUrl: tab.wsUrl,
|
||||||
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
|
})
|
||||||
// Also covers cases where wsUrl is missing/unusable.
|
? (() => {
|
||||||
return requirePwAi(res, "aria snapshot").then(async (pw) => {
|
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
|
||||||
if (!pw) {
|
// Also covers cases where wsUrl is missing/unusable.
|
||||||
return null;
|
return requirePwAi(res, "aria snapshot").then(async (pw) => {
|
||||||
}
|
if (!pw) {
|
||||||
return await pw.snapshotAriaViaPlaywright({
|
return null;
|
||||||
cdpUrl: profileCtx.profile.cdpUrl,
|
}
|
||||||
targetId: tab.targetId,
|
return await pw.snapshotAriaViaPlaywright({
|
||||||
limit,
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
});
|
targetId: tab.targetId,
|
||||||
|
limit: plan.limit,
|
||||||
});
|
});
|
||||||
})()
|
});
|
||||||
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit });
|
})()
|
||||||
|
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
|
||||||
|
|
||||||
const resolved = await Promise.resolve(snap);
|
const resolved = await Promise.resolve(snap);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
@@ -361,7 +330,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
|||||||
}
|
}
|
||||||
return res.json({
|
return res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
format,
|
format: plan.format,
|
||||||
targetId: tab.targetId,
|
targetId: tab.targetId,
|
||||||
url: tab.url,
|
url: tab.url,
|
||||||
...resolved,
|
...resolved,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
||||||
|
import { toBrowserErrorResponse } from "../errors.js";
|
||||||
import { createBrowserProfilesService } from "../profiles-service.js";
|
import { createBrowserProfilesService } from "../profiles-service.js";
|
||||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||||
import { resolveProfileContext } from "./agent.shared.js";
|
import { resolveProfileContext } from "./agent.shared.js";
|
||||||
@@ -18,6 +19,10 @@ async function withBasicProfileRoute(params: {
|
|||||||
try {
|
try {
|
||||||
await params.run(profileCtx);
|
await params.run(profileCtx);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const mapped = toBrowserErrorResponse(err);
|
||||||
|
if (mapped) {
|
||||||
|
return jsonError(params.res, mapped.status, mapped.message);
|
||||||
|
}
|
||||||
jsonError(params.res, 500, String(err));
|
jsonError(params.res, 500, String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,20 +162,11 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
|||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = String(err);
|
const mapped = toBrowserErrorResponse(err);
|
||||||
if (msg.includes("already exists")) {
|
if (mapped) {
|
||||||
return jsonError(res, 409, msg);
|
return jsonError(res, mapped.status, mapped.message);
|
||||||
}
|
}
|
||||||
if (msg.includes("invalid profile name")) {
|
jsonError(res, 500, String(err));
|
||||||
return jsonError(res, 400, msg);
|
|
||||||
}
|
|
||||||
if (msg.includes("no available CDP ports")) {
|
|
||||||
return jsonError(res, 507, msg);
|
|
||||||
}
|
|
||||||
if (msg.includes("cdpUrl")) {
|
|
||||||
return jsonError(res, 400, msg);
|
|
||||||
}
|
|
||||||
jsonError(res, 500, msg);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,17 +182,11 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
|||||||
const result = await service.deleteProfile(name);
|
const result = await service.deleteProfile(name);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = String(err);
|
const mapped = toBrowserErrorResponse(err);
|
||||||
if (msg.includes("invalid profile name")) {
|
if (mapped) {
|
||||||
return jsonError(res, 400, msg);
|
return jsonError(res, mapped.status, mapped.message);
|
||||||
}
|
}
|
||||||
if (msg.includes("default profile")) {
|
jsonError(res, 500, String(err));
|
||||||
return jsonError(res, 400, msg);
|
|
||||||
}
|
|
||||||
if (msg.includes("not found")) {
|
|
||||||
return jsonError(res, 404, msg);
|
|
||||||
}
|
|
||||||
jsonError(res, 500, msg);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js";
|
||||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||||
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
||||||
@@ -50,7 +51,11 @@ async function withTabsProfileRoute(params: {
|
|||||||
|
|
||||||
async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) {
|
async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) {
|
||||||
if (!(await profileCtx.isReachable(300))) {
|
if (!(await profileCtx.isReachable(300))) {
|
||||||
jsonError(res, 409, "browser not running");
|
jsonError(
|
||||||
|
res,
|
||||||
|
new BrowserProfileUnavailableError("browser not running").status,
|
||||||
|
"browser not running",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -191,7 +196,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
|||||||
const tabs = await profileCtx.listTabs();
|
const tabs = await profileCtx.listTabs();
|
||||||
const target = resolveIndexedTab(tabs, index);
|
const target = resolveIndexedTab(tabs, index);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return jsonError(res, 404, "tab not found");
|
throw new BrowserTabNotFoundError();
|
||||||
}
|
}
|
||||||
await profileCtx.closeTab(target.targetId);
|
await profileCtx.closeTab(target.targetId);
|
||||||
return res.json({ ok: true, targetId: target.targetId });
|
return res.json({ ok: true, targetId: target.targetId });
|
||||||
@@ -204,7 +209,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
|||||||
const tabs = await profileCtx.listTabs();
|
const tabs = await profileCtx.listTabs();
|
||||||
const target = tabs[index];
|
const target = tabs[index];
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return jsonError(res, 404, "tab not found");
|
throw new BrowserTabNotFoundError();
|
||||||
}
|
}
|
||||||
await profileCtx.focusTab(target.targetId);
|
await profileCtx.focusTab(target.targetId);
|
||||||
return res.json({ ok: true, targetId: target.targetId });
|
return res.json({ ok: true, targetId: target.targetId });
|
||||||
|
|||||||
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,
|
stopOpenClawChrome,
|
||||||
} from "./chrome.js";
|
} from "./chrome.js";
|
||||||
import type { ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
|
import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js";
|
||||||
import {
|
import {
|
||||||
ensureChromeExtensionRelayServer,
|
ensureChromeExtensionRelayServer,
|
||||||
stopChromeExtensionRelayServer,
|
stopChromeExtensionRelayServer,
|
||||||
} from "./extension-relay.js";
|
} from "./extension-relay.js";
|
||||||
|
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||||
import {
|
import {
|
||||||
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
|
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
|
||||||
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
|
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
|
||||||
@@ -48,6 +50,7 @@ export function createProfileAvailability({
|
|||||||
getProfileState,
|
getProfileState,
|
||||||
setProfileRunning,
|
setProfileRunning,
|
||||||
}: AvailabilityDeps): AvailabilityOps {
|
}: AvailabilityDeps): AvailabilityOps {
|
||||||
|
const capabilities = getBrowserProfileCapabilities(profile);
|
||||||
const resolveTimeouts = (timeoutMs: number | undefined) =>
|
const resolveTimeouts = (timeoutMs: number | undefined) =>
|
||||||
resolveCdpReachabilityTimeouts({
|
resolveCdpReachabilityTimeouts({
|
||||||
profileIsLoopback: profile.cdpIsLoopback,
|
profileIsLoopback: profile.cdpIsLoopback,
|
||||||
@@ -80,6 +83,38 @@ export function createProfileAvailability({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closePlaywrightBrowserConnectionForProfile = async (cdpUrl?: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const mod = await import("./pw-ai.js");
|
||||||
|
await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconcileProfileRuntime = async (): Promise<void> => {
|
||||||
|
const profileState = getProfileState();
|
||||||
|
const reconcile = profileState.reconcile;
|
||||||
|
if (!reconcile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
profileState.reconcile = null;
|
||||||
|
profileState.lastTargetId = null;
|
||||||
|
|
||||||
|
const previousProfile = reconcile.previousProfile;
|
||||||
|
if (profileState.running) {
|
||||||
|
await stopOpenClawChrome(profileState.running).catch(() => {});
|
||||||
|
setProfileRunning(null);
|
||||||
|
}
|
||||||
|
if (previousProfile.driver === "extension") {
|
||||||
|
await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false);
|
||||||
|
}
|
||||||
|
await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl);
|
||||||
|
if (previousProfile.cdpUrl !== profile.cdpUrl) {
|
||||||
|
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const waitForCdpReadyAfterLaunch = async (): Promise<void> => {
|
const waitForCdpReadyAfterLaunch = async (): Promise<void> => {
|
||||||
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS.
|
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS.
|
||||||
// If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port.
|
// If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port.
|
||||||
@@ -102,15 +137,16 @@ export function createProfileAvailability({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||||
|
await reconcileProfileRuntime();
|
||||||
const current = state();
|
const current = state();
|
||||||
const remoteCdp = !profile.cdpIsLoopback;
|
const remoteCdp = capabilities.isRemote;
|
||||||
const attachOnly = profile.attachOnly;
|
const attachOnly = profile.attachOnly;
|
||||||
const isExtension = profile.driver === "extension";
|
const isExtension = capabilities.requiresRelay;
|
||||||
const profileState = getProfileState();
|
const profileState = getProfileState();
|
||||||
const httpReachable = await isHttpReachable();
|
const httpReachable = await isHttpReachable();
|
||||||
|
|
||||||
if (isExtension && remoteCdp) {
|
if (isExtension && remoteCdp) {
|
||||||
throw new Error(
|
throw new BrowserConfigurationError(
|
||||||
`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`,
|
`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,7 +158,7 @@ export function createProfileAvailability({
|
|||||||
bindHost: current.resolved.relayBindHost,
|
bindHost: current.resolved.relayBindHost,
|
||||||
});
|
});
|
||||||
if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) {
|
if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) {
|
||||||
throw new Error(
|
throw new BrowserProfileUnavailableError(
|
||||||
`Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`,
|
`Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -140,7 +176,7 @@ export function createProfileAvailability({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (attachOnly || remoteCdp) {
|
if (attachOnly || remoteCdp) {
|
||||||
throw new Error(
|
throw new BrowserProfileUnavailableError(
|
||||||
remoteCdp
|
remoteCdp
|
||||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
|
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
|
||||||
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
|
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
|
||||||
@@ -172,7 +208,7 @@ export function createProfileAvailability({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new BrowserProfileUnavailableError(
|
||||||
remoteCdp
|
remoteCdp
|
||||||
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
|
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
|
||||||
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
|
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
|
||||||
@@ -181,7 +217,7 @@ export function createProfileAvailability({
|
|||||||
|
|
||||||
// HTTP responds but WebSocket fails - port in use by something else.
|
// HTTP responds but WebSocket fails - port in use by something else.
|
||||||
if (!profileState.running) {
|
if (!profileState.running) {
|
||||||
throw new Error(
|
throw new BrowserProfileUnavailableError(
|
||||||
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
|
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
|
||||||
`Run action=reset-profile profile=${profile.name} to kill the process.`,
|
`Run action=reset-profile profile=${profile.name} to kill the process.`,
|
||||||
);
|
);
|
||||||
@@ -201,7 +237,8 @@ export function createProfileAvailability({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||||
if (profile.driver === "extension") {
|
await reconcileProfileRuntime();
|
||||||
|
if (capabilities.requiresRelay) {
|
||||||
const stopped = await stopChromeExtensionRelayServer({
|
const stopped = await stopChromeExtensionRelayServer({
|
||||||
cdpUrl: profile.cdpUrl,
|
cdpUrl: profile.cdpUrl,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resolveBrowserConfig } from "./config.js";
|
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||||
import {
|
import {
|
||||||
refreshResolvedBrowserConfigFromDisk,
|
refreshResolvedBrowserConfigFromDisk,
|
||||||
resolveBrowserProfileWithHotReload,
|
resolveBrowserProfileWithHotReload,
|
||||||
} from "./resolved-config-refresh.js";
|
} from "./resolved-config-refresh.js";
|
||||||
|
import type { BrowserServerState } from "./server-context.types.js";
|
||||||
|
|
||||||
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
|
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
|
||||||
|
|
||||||
@@ -166,4 +167,42 @@ describe("server-context hot-reload profiles", () => {
|
|||||||
});
|
});
|
||||||
expect(Object.keys(state.resolved.profiles)).toContain("desktop");
|
expect(Object.keys(state.resolved.profiles)).toContain("desktop");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks existing runtime state for reconcile when profile invariants change", async () => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||||
|
const openclawProfile = resolveProfile(resolved, "openclaw");
|
||||||
|
expect(openclawProfile).toBeTruthy();
|
||||||
|
const state: BrowserServerState = {
|
||||||
|
server: null,
|
||||||
|
port: 18791,
|
||||||
|
resolved,
|
||||||
|
profiles: new Map([
|
||||||
|
[
|
||||||
|
"openclaw",
|
||||||
|
{
|
||||||
|
profile: openclawProfile!,
|
||||||
|
running: { pid: 123 } as never,
|
||||||
|
lastTargetId: "tab-1",
|
||||||
|
reconcile: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
cfgProfiles.openclaw = { cdpPort: 19999, color: "#FF4500" };
|
||||||
|
cachedConfig = null;
|
||||||
|
|
||||||
|
refreshResolvedBrowserConfigFromDisk({
|
||||||
|
current: state,
|
||||||
|
refreshConfigFromDisk: true,
|
||||||
|
mode: "cached",
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = state.profiles.get("openclaw");
|
||||||
|
expect(runtime).toBeTruthy();
|
||||||
|
expect(runtime?.profile.cdpPort).toBe(19999);
|
||||||
|
expect(runtime?.lastTargetId).toBeNull();
|
||||||
|
expect(runtime?.reconcile?.reason).toContain("cdpPort");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import type { ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
|
import { BrowserResetUnsupportedError } from "./errors.js";
|
||||||
import { stopChromeExtensionRelayServer } from "./extension-relay.js";
|
import { stopChromeExtensionRelayServer } from "./extension-relay.js";
|
||||||
|
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||||
import type { ProfileRuntimeState } from "./server-context.types.js";
|
import type { ProfileRuntimeState } from "./server-context.types.js";
|
||||||
import { movePathToTrash } from "./trash.js";
|
import { movePathToTrash } from "./trash.js";
|
||||||
|
|
||||||
@@ -32,13 +34,14 @@ export function createProfileResetOps({
|
|||||||
isHttpReachable,
|
isHttpReachable,
|
||||||
resolveOpenClawUserDataDir,
|
resolveOpenClawUserDataDir,
|
||||||
}: ResetDeps): ResetOps {
|
}: ResetDeps): ResetOps {
|
||||||
|
const capabilities = getBrowserProfileCapabilities(profile);
|
||||||
const resetProfile = async () => {
|
const resetProfile = async () => {
|
||||||
if (profile.driver === "extension") {
|
if (capabilities.requiresRelay) {
|
||||||
await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {});
|
await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {});
|
||||||
return { moved: false, from: profile.cdpUrl };
|
return { moved: false, from: profile.cdpUrl };
|
||||||
}
|
}
|
||||||
if (!profile.cdpIsLoopback) {
|
if (!capabilities.supportsReset) {
|
||||||
throw new Error(
|
throw new BrowserResetUnsupportedError(
|
||||||
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
|
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
|
||||||
import { appendCdpPath } from "./cdp.js";
|
import { appendCdpPath } from "./cdp.js";
|
||||||
import type { ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
|
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
|
||||||
|
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||||
import type { PwAiModule } from "./pw-ai-module.js";
|
import type { PwAiModule } from "./pw-ai-module.js";
|
||||||
import { getPwAiModule } from "./pw-ai-module.js";
|
import { getPwAiModule } from "./pw-ai-module.js";
|
||||||
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
|
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
|
||||||
@@ -28,13 +30,14 @@ export function createProfileSelectionOps({
|
|||||||
openTab,
|
openTab,
|
||||||
}: SelectionDeps): SelectionOps {
|
}: SelectionDeps): SelectionOps {
|
||||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||||
|
const capabilities = getBrowserProfileCapabilities(profile);
|
||||||
|
|
||||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||||
await ensureBrowserAvailable();
|
await ensureBrowserAvailable();
|
||||||
const profileState = getProfileState();
|
const profileState = getProfileState();
|
||||||
let tabs1 = await listTabs();
|
let tabs1 = await listTabs();
|
||||||
if (tabs1.length === 0) {
|
if (tabs1.length === 0) {
|
||||||
if (profile.driver === "extension") {
|
if (capabilities.requiresAttachedTab) {
|
||||||
// Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker
|
// Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker
|
||||||
// lifecycle, relay restart). If we previously had a target selected, wait briefly for
|
// lifecycle, relay restart). If we previously had a target selected, wait briefly for
|
||||||
// the extension to reconnect and re-announce its attached tabs before failing.
|
// the extension to reconnect and re-announce its attached tabs before failing.
|
||||||
@@ -46,7 +49,7 @@ export function createProfileSelectionOps({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tabs1.length === 0) {
|
if (tabs1.length === 0) {
|
||||||
throw new Error(
|
throw new BrowserTabNotFoundError(
|
||||||
`tab not found (no attached Chrome tabs for profile "${profile.name}"). ` +
|
`tab not found (no attached Chrome tabs for profile "${profile.name}"). ` +
|
||||||
"Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).",
|
"Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).",
|
||||||
);
|
);
|
||||||
@@ -57,12 +60,7 @@ export function createProfileSelectionOps({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs = await listTabs();
|
const tabs = await listTabs();
|
||||||
// For remote profiles using Playwright's persistent connection, we don't need wsUrl
|
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
|
||||||
// because we access pages directly through Playwright, not via individual WebSocket URLs.
|
|
||||||
const candidates =
|
|
||||||
profile.driver === "extension" || !profile.cdpIsLoopback
|
|
||||||
? tabs
|
|
||||||
: tabs.filter((t) => Boolean(t.wsUrl));
|
|
||||||
|
|
||||||
const resolveById = (raw: string) => {
|
const resolveById = (raw: string) => {
|
||||||
const resolved = resolveTargetIdFromTabs(raw, candidates);
|
const resolved = resolveTargetIdFromTabs(raw, candidates);
|
||||||
@@ -89,10 +87,10 @@ export function createProfileSelectionOps({
|
|||||||
const chosen = targetId ? resolveById(targetId) : pickDefault();
|
const chosen = targetId ? resolveById(targetId) : pickDefault();
|
||||||
|
|
||||||
if (chosen === "AMBIGUOUS") {
|
if (chosen === "AMBIGUOUS") {
|
||||||
throw new Error("ambiguous target id prefix");
|
throw new BrowserTargetAmbiguousError();
|
||||||
}
|
}
|
||||||
if (!chosen) {
|
if (!chosen) {
|
||||||
throw new Error("tab not found");
|
throw new BrowserTabNotFoundError();
|
||||||
}
|
}
|
||||||
profileState.lastTargetId = chosen.targetId;
|
profileState.lastTargetId = chosen.targetId;
|
||||||
return chosen;
|
return chosen;
|
||||||
@@ -103,9 +101,9 @@ export function createProfileSelectionOps({
|
|||||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
if (resolved.reason === "ambiguous") {
|
if (resolved.reason === "ambiguous") {
|
||||||
throw new Error("ambiguous target id prefix");
|
throw new BrowserTargetAmbiguousError();
|
||||||
}
|
}
|
||||||
throw new Error("tab not found");
|
throw new BrowserTabNotFoundError();
|
||||||
}
|
}
|
||||||
return resolved.targetId;
|
return resolved.targetId;
|
||||||
};
|
};
|
||||||
@@ -113,7 +111,7 @@ export function createProfileSelectionOps({
|
|||||||
const focusTab = async (targetId: string): Promise<void> => {
|
const focusTab = async (targetId: string): Promise<void> => {
|
||||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||||
|
|
||||||
if (!profile.cdpIsLoopback) {
|
if (capabilities.usesPersistentPlaywright) {
|
||||||
const mod = await getPwAiModule({ mode: "strict" });
|
const mod = await getPwAiModule({ mode: "strict" });
|
||||||
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||||
?.focusPageByTargetIdViaPlaywright;
|
?.focusPageByTargetIdViaPlaywright;
|
||||||
@@ -137,7 +135,7 @@ export function createProfileSelectionOps({
|
|||||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||||
|
|
||||||
// For remote profiles, use Playwright's persistent connection to close tabs
|
// For remote profiles, use Playwright's persistent connection to close tabs
|
||||||
if (!profile.cdpIsLoopback) {
|
if (capabilities.usesPersistentPlaywright) {
|
||||||
const mod = await getPwAiModule({ mode: "strict" });
|
const mod = await getPwAiModule({ mode: "strict" });
|
||||||
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||||
?.closePageByTargetIdViaPlaywright;
|
?.closePageByTargetIdViaPlaywright;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
assertBrowserNavigationResultAllowed,
|
assertBrowserNavigationResultAllowed,
|
||||||
withBrowserNavigationPolicy,
|
withBrowserNavigationPolicy,
|
||||||
} from "./navigation-guard.js";
|
} from "./navigation-guard.js";
|
||||||
|
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||||
import type { PwAiModule } from "./pw-ai-module.js";
|
import type { PwAiModule } from "./pw-ai-module.js";
|
||||||
import { getPwAiModule } from "./pw-ai-module.js";
|
import { getPwAiModule } from "./pw-ai-module.js";
|
||||||
import {
|
import {
|
||||||
@@ -59,10 +60,10 @@ export function createProfileTabOps({
|
|||||||
getProfileState,
|
getProfileState,
|
||||||
}: TabOpsDeps): ProfileTabOps {
|
}: TabOpsDeps): ProfileTabOps {
|
||||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||||
|
const capabilities = getBrowserProfileCapabilities(profile);
|
||||||
|
|
||||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||||
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
|
if (capabilities.usesPersistentPlaywright) {
|
||||||
if (!profile.cdpIsLoopback) {
|
|
||||||
const mod = await getPwAiModule({ mode: "strict" });
|
const mod = await getPwAiModule({ mode: "strict" });
|
||||||
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
|
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
|
||||||
if (typeof listPagesViaPlaywright === "function") {
|
if (typeof listPagesViaPlaywright === "function") {
|
||||||
@@ -99,8 +100,7 @@ export function createProfileTabOps({
|
|||||||
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
|
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
|
||||||
const profileState = getProfileState();
|
const profileState = getProfileState();
|
||||||
if (
|
if (
|
||||||
profile.driver !== "openclaw" ||
|
!capabilities.supportsManagedTabLimit ||
|
||||||
!profile.cdpIsLoopback ||
|
|
||||||
state().resolved.attachOnly ||
|
state().resolved.attachOnly ||
|
||||||
!profileState.running
|
!profileState.running
|
||||||
) {
|
) {
|
||||||
@@ -132,9 +132,7 @@ export function createProfileTabOps({
|
|||||||
const openTab = async (url: string): Promise<BrowserTab> => {
|
const openTab = async (url: string): Promise<BrowserTab> => {
|
||||||
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
|
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
|
||||||
|
|
||||||
// For remote profiles, use Playwright's persistent connection to create tabs
|
if (capabilities.usesPersistentPlaywright) {
|
||||||
// This ensures the tab persists beyond a single request.
|
|
||||||
if (!profile.cdpIsLoopback) {
|
|
||||||
const mod = await getPwAiModule({ mode: "strict" });
|
const mod = await getPwAiModule({ mode: "strict" });
|
||||||
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
|
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
|
||||||
if (typeof createPageViaPlaywright === "function") {
|
if (typeof createPageViaPlaywright === "function") {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
|||||||
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
|
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
|
||||||
import type { ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserProfile } from "./config.js";
|
||||||
import { resolveProfile } from "./config.js";
|
import { resolveProfile } from "./config.js";
|
||||||
|
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
|
||||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||||
import {
|
import {
|
||||||
refreshResolvedBrowserConfigFromDisk,
|
refreshResolvedBrowserConfigFromDisk,
|
||||||
@@ -57,7 +58,7 @@ function createProfileContext(
|
|||||||
const current = state();
|
const current = state();
|
||||||
let profileState = current.profiles.get(profile.name);
|
let profileState = current.profiles.get(profile.name);
|
||||||
if (!profileState) {
|
if (!profileState) {
|
||||||
profileState = { profile, running: null, lastTargetId: null };
|
profileState = { profile, running: null, lastTargetId: null, reconcile: null };
|
||||||
current.profiles.set(profile.name, profileState);
|
current.profiles.set(profile.name, profileState);
|
||||||
}
|
}
|
||||||
return profileState;
|
return profileState;
|
||||||
@@ -136,7 +137,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
|||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
const available = Object.keys(current.resolved.profiles).join(", ");
|
const available = Object.keys(current.resolved.profiles).join(", ");
|
||||||
throw new Error(`Profile "${name}" not found. Available profiles: ${available || "(none)"}`);
|
throw new BrowserProfileNotFoundError(
|
||||||
|
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return createProfileContext(opts, profile);
|
return createProfileContext(opts, profile);
|
||||||
};
|
};
|
||||||
@@ -150,9 +153,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
|||||||
});
|
});
|
||||||
const result: ProfileStatus[] = [];
|
const result: ProfileStatus[] = [];
|
||||||
|
|
||||||
for (const name of Object.keys(current.resolved.profiles)) {
|
for (const name of listKnownProfileNames(current)) {
|
||||||
const profileState = current.profiles.get(name);
|
const profileState = current.profiles.get(name);
|
||||||
const profile = resolveProfile(current.resolved, name);
|
const profile = resolveProfile(current.resolved, name) ?? profileState?.profile;
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -193,6 +196,8 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
|||||||
tabCount,
|
tabCount,
|
||||||
isDefault: name === current.resolved.defaultProfile,
|
isDefault: name === current.resolved.defaultProfile,
|
||||||
isRemote: !profile.cdpIsLoopback,
|
isRemote: !profile.cdpIsLoopback,
|
||||||
|
missingFromConfig: !(name in current.resolved.profiles) || undefined,
|
||||||
|
reconcileReason: profileState?.reconcile?.reason ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,22 +208,16 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
|||||||
const getDefaultContext = () => forProfile();
|
const getDefaultContext = () => forProfile();
|
||||||
|
|
||||||
const mapTabError = (err: unknown) => {
|
const mapTabError = (err: unknown) => {
|
||||||
|
const browserMapped = toBrowserErrorResponse(err);
|
||||||
|
if (browserMapped) {
|
||||||
|
return browserMapped;
|
||||||
|
}
|
||||||
if (err instanceof SsrFBlockedError) {
|
if (err instanceof SsrFBlockedError) {
|
||||||
return { status: 400, message: err.message };
|
return { status: 400, message: err.message };
|
||||||
}
|
}
|
||||||
if (err instanceof InvalidBrowserNavigationUrlError) {
|
if (err instanceof InvalidBrowserNavigationUrlError) {
|
||||||
return { status: 400, message: err.message };
|
return { status: 400, message: err.message };
|
||||||
}
|
}
|
||||||
const msg = String(err);
|
|
||||||
if (msg.includes("ambiguous target id prefix")) {
|
|
||||||
return { status: 409, message: "ambiguous target id prefix" };
|
|
||||||
}
|
|
||||||
if (msg.includes("tab not found")) {
|
|
||||||
return { status: 404, message: msg };
|
|
||||||
}
|
|
||||||
if (msg.includes("not found")) {
|
|
||||||
return { status: 404, message: msg };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export type ProfileRuntimeState = {
|
|||||||
running: RunningChrome | null;
|
running: RunningChrome | null;
|
||||||
/** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */
|
/** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */
|
||||||
lastTargetId?: string | null;
|
lastTargetId?: string | null;
|
||||||
|
reconcile?: {
|
||||||
|
previousProfile: ResolvedBrowserProfile;
|
||||||
|
reason: string;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrowserServerState = {
|
export type BrowserServerState = {
|
||||||
@@ -56,6 +60,8 @@ export type ProfileStatus = {
|
|||||||
tabCount: number;
|
tabCount: number;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
isRemote: boolean;
|
isRemote: boolean;
|
||||||
|
missingFromConfig?: boolean;
|
||||||
|
reconcileReason?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ContextOptions = {
|
export type ContextOptions = {
|
||||||
|
|||||||
@@ -116,6 +116,19 @@ describe("profile CRUD endpoints", () => {
|
|||||||
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
|
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
|
||||||
expect(createBadRemoteBody.error).toContain("cdpUrl");
|
expect(createBadRemoteBody.error).toContain("cdpUrl");
|
||||||
|
|
||||||
|
const createBadExtension = await realFetch(`${base}/profiles/create`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "badextension",
|
||||||
|
driver: "extension",
|
||||||
|
cdpUrl: "http://10.0.0.42:9222",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createBadExtension.status).toBe(400);
|
||||||
|
const createBadExtensionBody = (await createBadExtension.json()) as { error: string };
|
||||||
|
expect(createBadExtensionBody.error).toContain("loopback cdpUrl host");
|
||||||
|
|
||||||
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
|
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import { loadConfig } from "../config/config.js";
|
|||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { resolveBrowserConfig } from "./config.js";
|
import { resolveBrowserConfig } from "./config.js";
|
||||||
import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js";
|
import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js";
|
||||||
import { isPwAiLoaded } from "./pw-ai-state.js";
|
|
||||||
import { registerBrowserRoutes } from "./routes/index.js";
|
import { registerBrowserRoutes } from "./routes/index.js";
|
||||||
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
||||||
|
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
|
||||||
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
||||||
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
|
|
||||||
import {
|
import {
|
||||||
installBrowserAuthMiddleware,
|
installBrowserAuthMiddleware,
|
||||||
installBrowserCommonMiddleware,
|
installBrowserCommonMiddleware,
|
||||||
@@ -74,14 +73,9 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
state = {
|
state = await createBrowserRuntimeState({
|
||||||
server,
|
server,
|
||||||
port,
|
port,
|
||||||
resolved,
|
|
||||||
profiles: new Map(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await ensureExtensionRelayForProfiles({
|
|
||||||
resolved,
|
resolved,
|
||||||
onWarn: (message) => logServer.warn(message),
|
onWarn: (message) => logServer.warn(message),
|
||||||
});
|
});
|
||||||
@@ -93,29 +87,13 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
|||||||
|
|
||||||
export async function stopBrowserControlServer(): Promise<void> {
|
export async function stopBrowserControlServer(): Promise<void> {
|
||||||
const current = state;
|
const current = state;
|
||||||
if (!current) {
|
await stopBrowserRuntime({
|
||||||
return;
|
current,
|
||||||
}
|
|
||||||
|
|
||||||
await stopKnownBrowserProfiles({
|
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
|
clearState: () => {
|
||||||
|
state = null;
|
||||||
|
},
|
||||||
|
closeServer: true,
|
||||||
onWarn: (message) => logServer.warn(message),
|
onWarn: (message) => logServer.warn(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (current.server) {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
current.server?.close(() => resolve());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
state = null;
|
|
||||||
|
|
||||||
// Optional: avoid importing heavy Playwright bridge when this process never used it.
|
|
||||||
if (isPwAiLoaded()) {
|
|
||||||
try {
|
|
||||||
const mod = await import("./pw-ai.js");
|
|
||||||
await mod.closePlaywrightBrowserConnection();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user