diff --git a/extensions/browser/src/browser/cdp.test.ts b/extensions/browser/src/browser/cdp.test.ts index aa0d7767c7e..a3e23623ff9 100644 --- a/extensions/browser/src/browser/cdp.test.ts +++ b/extensions/browser/src/browser/cdp.test.ts @@ -4,9 +4,12 @@ import { type WebSocket, WebSocketServer } from "ws"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { rawDataToString } from "../infra/ws.js"; import "../../test-support/browser-security-runtime.mock.js"; -import { isDirectCdpWebSocketEndpoint, isWebSocketUrl } from "./cdp.helpers.js"; +import { + isDirectCdpWebSocketEndpoint, + isWebSocketUrl, + parseBrowserHttpUrl as parseHttpUrl, +} from "./cdp.helpers.js"; import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js"; -import { parseHttpUrl } from "./config.js"; import { BROWSER_ENDPOINT_BLOCKED_MESSAGE, BROWSER_NAVIGATION_BLOCKED_MESSAGE, diff --git a/extensions/browser/src/browser/control-service.plugin-disabled.test.ts b/extensions/browser/src/browser/control-service.plugin-disabled.test.ts index d3b39143e3e..a3cf22f9acd 100644 --- a/extensions/browser/src/browser/control-service.plugin-disabled.test.ts +++ b/extensions/browser/src/browser/control-service.plugin-disabled.test.ts @@ -42,7 +42,12 @@ vi.mock("./runtime-lifecycle.js", () => ({ stopBrowserRuntime: vi.fn(async () => {}), })); +vi.mock("./server-context.js", () => ({ + createBrowserRouteContext: vi.fn(), +})); + const { startBrowserControlServiceFromConfig } = await import("../control-service.js"); +vi.doUnmock("./server-context.js"); describe("startBrowserControlServiceFromConfig", () => { beforeEach(() => { diff --git a/extensions/browser/src/browser/navigation-guard.ts b/extensions/browser/src/browser/navigation-guard.ts index 5367c37fb6f..29989eae66f 100644 --- a/extensions/browser/src/browser/navigation-guard.ts +++ b/extensions/browser/src/browser/navigation-guard.ts @@ -3,7 +3,6 @@ import { matchesHostnameAllowlist, normalizeHostname, } from "openclaw/plugin-sdk/browser-security-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; import { isPrivateNetworkAllowedByPolicy, @@ -20,6 +19,10 @@ function isAllowedNonNetworkNavigationUrl(parsed: URL): boolean { return SAFE_NON_NETWORK_URLS.has(parsed.href); } +function normalizeNavigationUrl(url: string): string { + return url.trim(); +} + export class InvalidBrowserNavigationUrlError extends Error { constructor(message: string) { super(message); @@ -85,7 +88,7 @@ export async function assertBrowserNavigationAllowed( lookupFn?: LookupFn; } & BrowserNavigationPolicyOptions, ): Promise { - const rawUrl = normalizeOptionalString(opts.url) ?? ""; + const rawUrl = normalizeNavigationUrl(opts.url); if (!rawUrl) { throw new InvalidBrowserNavigationUrlError("url is required"); } @@ -150,7 +153,7 @@ export async function assertBrowserNavigationResultAllowed( lookupFn?: LookupFn; } & BrowserNavigationPolicyOptions, ): Promise { - const rawUrl = normalizeOptionalString(opts.url) ?? ""; + const rawUrl = normalizeNavigationUrl(opts.url); if (!rawUrl) { return; } diff --git a/extensions/browser/src/browser/rate-limit-message.ts b/extensions/browser/src/browser/rate-limit-message.ts index 4ad9886797a..bcc4a9d37b0 100644 --- a/extensions/browser/src/browser/rate-limit-message.ts +++ b/extensions/browser/src/browser/rate-limit-message.ts @@ -1,5 +1,3 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; - const BROWSER_SERVICE_RATE_LIMIT_MESSAGE = "Browser service rate limit reached. " + "Wait for the current session to complete, or retry later."; @@ -17,7 +15,7 @@ function isBrowserbaseUrl(url: string): boolean { return false; } try { - const host = normalizeLowercaseStringOrEmpty(new URL(url).hostname); + const host = new URL(url).hostname.trim().toLowerCase(); return host === "browserbase.com" || host.endsWith(".browserbase.com"); } catch { return false; diff --git a/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts b/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts index 210e519da84..65f89bf6ca2 100644 --- a/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts +++ b/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts @@ -2,8 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { withBrowserFetchPreconnect } from "../../test-fetch.js"; import * as cdpModule from "./cdp.js"; import { BrowserCdpEndpointBlockedError } from "./errors.js"; -import { createBrowserRouteContext } from "./server-context.js"; -import { makeState, originalFetch } from "./server-context.remote-tab-ops.harness.js"; +import { + createTestBrowserRouteContext, + makeState, + originalFetch, +} from "./server-context.remote-tab-ops.harness.js"; afterEach(() => { globalThis.fetch = originalFetch; @@ -40,7 +43,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => { cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", color: "#FF4500", }; - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const opened = await openclaw.openTab("about:blank"); @@ -85,7 +88,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => { cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc", color: "#FF4500", }; - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); await openclaw.focusTab("T1"); @@ -133,7 +136,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => { cdpUrl: "wss://127.0.0.1:18800/cdp?token=abc", color: "#FF4500", }; - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const tabs = await openclaw.listTabs(); @@ -158,7 +161,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => { cdpUrl: "ws://10.0.0.42:18800/devtools/browser/SESSION?token=abc", color: "#FF4500", }; - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); await expect(openclaw.listTabs()).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError); diff --git a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts index 6d339a9db3f..43098865116 100644 --- a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts +++ b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts @@ -1,7 +1,10 @@ import { vi } from "vitest"; import { withBrowserFetchPreconnect } from "../../test-fetch.js"; -import type { BrowserServerState } from "./server-context.js"; -import { createBrowserRouteContext } from "./server-context.js"; +import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js"; +import type { ResolvedBrowserProfile } from "./config.js"; +import { createProfileSelectionOps } from "./server-context.selection.js"; +import { createProfileTabOps } from "./server-context.tab-ops.js"; +import type { BrowserServerState, ProfileRuntimeState } from "./server-context.types.js"; export const originalFetch = globalThis.fetch; @@ -48,11 +51,72 @@ export function makeUnexpectedFetchMock() { }); } +function resolveProfileForTest( + state: BrowserServerState, + profileName: string, +): ResolvedBrowserProfile { + const rawProfile = state.resolved.profiles[profileName] ?? {}; + const cdpPort = + typeof rawProfile.cdpPort === "number" + ? rawProfile.cdpPort + : profileName === "remote" + ? 9222 + : state.resolved.cdpPortRangeStart; + const cdpUrl = + typeof rawProfile.cdpUrl === "string" + ? rawProfile.cdpUrl + : `${state.resolved.cdpProtocol}://${state.resolved.cdpHost}:${cdpPort}`; + const parsed = new URL(cdpUrl.replace(/^ws/i, "http")); + const cdpHost = parsed.hostname; + const cdpIsLoopback = cdpHost === "localhost" || cdpHost === "127.0.0.1" || cdpHost === "::1"; + return { + name: profileName, + cdpPort, + cdpUrl, + cdpHost, + cdpIsLoopback, + color: rawProfile.color ?? state.resolved.color, + driver: rawProfile.driver === "existing-session" ? "existing-session" : "openclaw", + attachOnly: rawProfile.attachOnly ?? state.resolved.attachOnly, + userDataDir: rawProfile.userDataDir, + }; +} + +export function createTestBrowserRouteContext(opts: { getState: () => BrowserServerState }) { + const forProfile = (profileName?: string) => { + const state = opts.getState(); + const profile = resolveProfileForTest(state, profileName ?? state.resolved.defaultProfile); + const getProfileState = (): ProfileRuntimeState => { + let profileState = state.profiles.get(profile.name); + if (!profileState) { + profileState = { profile, running: null, lastTargetId: null, reconcile: null }; + state.profiles.set(profile.name, profileState); + } + return profileState; + }; + const tabOps = createProfileTabOps({ + profile, + state: () => state, + getProfileState, + }); + const selectionOps = createProfileSelectionOps({ + profile, + getProfileState, + getCdpControlPolicy: () => resolveCdpControlPolicy(profile, state.resolved.ssrfPolicy), + ensureBrowserAvailable: async () => {}, + listTabs: tabOps.listTabs, + openTab: tabOps.openTab, + }); + return { profile, ...tabOps, ...selectionOps }; + }; + return { forProfile }; +} + export function createRemoteRouteHarness(fetchMock?: (url: unknown) => Promise) { const activeFetchMock = fetchMock ?? makeUnexpectedFetchMock(); global.fetch = withBrowserFetchPreconnect(activeFetchMock); const state = makeState("remote"); - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); return { state, remote: ctx.forProfile("remote"), fetchMock: activeFetchMock }; } diff --git a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts index 953360c6733..be8edeec30e 100644 --- a/extensions/browser/src/browser/server-context.tab-selection-state.test.ts +++ b/extensions/browser/src/browser/server-context.tab-selection-state.test.ts @@ -9,8 +9,8 @@ import "./server-context.chrome-test-harness.js"; import * as cdpHelpersModule from "./cdp.helpers.js"; import * as cdpModule from "./cdp.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; -import { createBrowserRouteContext } from "./server-context.js"; import { + createTestBrowserRouteContext, makeManagedTabsWithNew, makeState, originalFetch, @@ -85,7 +85,7 @@ async function openManagedTabWithRunningProfile(params: { global.fetch = withBrowserFetchPreconnect(params.fetchMock); const state = makeState("openclaw"); seedRunningProfileState(state); - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); return await openclaw.openTab(params.url ?? "http://127.0.0.1:3009"); } @@ -117,7 +117,7 @@ describe("browser server-context tab selection state", () => { global.fetch = withBrowserFetchPreconnect(fetchMock); const state = makeState("openclaw"); - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const opened = await openclaw.openTab("http://127.0.0.1:8080"); @@ -162,7 +162,7 @@ describe("browser server-context tab selection state", () => { global.fetch = withBrowserFetchPreconnect(fetchMock); const state = makeState("openclaw"); state.resolved.ssrfPolicy = {}; - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const selected = await openclaw.ensureTabAvailable(); @@ -228,7 +228,7 @@ describe("browser server-context tab selection state", () => { global.fetch = withBrowserFetchPreconnect(fetchMock); const state = makeState("openclaw"); seedRunningProfileState(state); - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const opened = await openclaw.openTab("http://127.0.0.1:3009"); @@ -248,7 +248,7 @@ describe("browser server-context tab selection state", () => { global.fetch = withBrowserFetchPreconnect(fetchMock); const state = makeState("openclaw"); state.resolved.attachOnly = true; - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const opened = await openclaw.openTab("http://127.0.0.1:3009"); @@ -289,7 +289,7 @@ describe("browser server-context tab selection state", () => { global.fetch = withBrowserFetchPreconnect(fetchMock); const state = makeState("openclaw"); - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); await expect(openclaw.openTab("file:///etc/passwd")).rejects.toBeInstanceOf( @@ -311,7 +311,7 @@ describe("browser server-context tab selection state", () => { const state = makeState("openclaw"); state.resolved.ssrfPolicy = {}; - const ctx = createBrowserRouteContext({ getState: () => state }); + const ctx = createTestBrowserRouteContext({ getState: () => state }); const openclaw = ctx.forProfile("openclaw"); const opened = await openclaw.openTab("https://example.com");