From 22be0c5801bf3bd2ceeb16141303165614b1bb75 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 1 Mar 2026 23:50:50 -0800 Subject: [PATCH] fix(browser): support configurable CDP auto-port range start (#31352) * config(browser): add cdpPortRangeStart type * config(schema): validate browser.cdpPortRangeStart * config(labels): add browser.cdpPortRangeStart label * config(help): document browser.cdpPortRangeStart * browser(config): resolve custom cdp port range start * browser(profiles): allocate ports from resolved CDP range * test(browser): cover cdpPortRangeStart config behavior * test(browser): cover cdpPortRangeStart profile allocation * test(browser): include CDP range fields in remote tab harness * test(browser): include CDP range fields in ensure-tab harness * test(browser): include CDP range fields in bridge auth config * build(browser): add resolved CDP range metadata * fix(browser): fallback CDP port allocation to derived range * test(browser): cover missing resolved CDP range fallback * fix(browser): remove duplicate resolved CDP range fields * fix(agents): provide resolved CDP range in sandbox browser config * chore(browser): format sandbox bridge resolved config * chore(browser): reformat sandbox imports to satisfy oxfmt --- src/agents/sandbox/browser.ts | 4 ++ src/browser/bridge-server.auth.test.ts | 2 + src/browser/config.test.ts | 16 ++++++++ src/browser/config.ts | 34 +++++++++++++++- src/browser/profiles-service.test.ts | 40 +++++++++++++++++++ src/browser/profiles-service.ts | 26 +++++++++++- ...-tab-available.prefers-last-target.test.ts | 2 + .../server-context.remote-tab-ops.test.ts | 2 + src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.browser.ts | 2 + src/config/zod-schema.ts | 1 + 12 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index a58348fcb..624230db7 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -6,6 +6,7 @@ import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, } from "../../browser/constants.js"; +import { deriveDefaultBrowserCdpPortRange } from "../../config/port-defaults.js"; import { defaultRuntime } from "../../runtime.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; import { computeSandboxBrowserConfigHash } from "./config-hash.js"; @@ -70,6 +71,7 @@ function buildSandboxBrowserResolvedConfig(params: { evaluateEnabled: boolean; }): ResolvedBrowserConfig { const cdpHost = "127.0.0.1"; + const cdpPortRange = deriveDefaultBrowserCdpPortRange(params.controlPort); return { enabled: true, evaluateEnabled: params.evaluateEnabled, @@ -77,6 +79,8 @@ function buildSandboxBrowserResolvedConfig(params: { cdpProtocol: "http", cdpHost, cdpIsLoopback: true, + cdpPortRangeStart: cdpPortRange.start, + cdpPortRangeEnd: cdpPortRange.end, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, color: DEFAULT_OPENCLAW_BROWSER_COLOR, diff --git a/src/browser/bridge-server.auth.test.ts b/src/browser/bridge-server.auth.test.ts index eb72c340a..1f7717506 100644 --- a/src/browser/bridge-server.auth.test.ts +++ b/src/browser/bridge-server.auth.test.ts @@ -11,6 +11,8 @@ function buildResolvedConfig(): ResolvedBrowserConfig { enabled: true, evaluateEnabled: false, controlPort: 0, + cdpPortRangeStart: 18800, + cdpPortRangeEnd: 18899, cdpProtocol: "http", cdpHost: "127.0.0.1", cdpIsLoopback: true, diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 6eeeb0f7b..c70cc3228 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -55,6 +55,22 @@ describe("browser config", () => { }); }); + it("supports overriding the local CDP auto-allocation range start", () => { + const resolved = resolveBrowserConfig({ + cdpPortRangeStart: 19000, + }); + const openclaw = resolveProfile(resolved, "openclaw"); + expect(resolved.cdpPortRangeStart).toBe(19000); + expect(openclaw?.cdpPort).toBe(19000); + expect(openclaw?.cdpUrl).toBe("http://127.0.0.1:19000"); + }); + + it("rejects cdpPortRangeStart values that overflow the CDP range window", () => { + expect(() => resolveBrowserConfig({ cdpPortRangeStart: 65535 })).toThrow( + /cdpPortRangeStart .* too high/i, + ); + }); + it("normalizes hex colors", () => { const resolved = resolveBrowserConfig({ color: "ff4500", diff --git a/src/browser/config.ts b/src/browser/config.ts index 3c066b4a6..231188a25 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -20,6 +20,8 @@ export type ResolvedBrowserConfig = { enabled: boolean; evaluateEnabled: boolean; controlPort: number; + cdpPortRangeStart: number; + cdpPortRangeEnd: number; cdpProtocol: "http" | "https"; cdpHost: string; cdpIsLoopback: boolean; @@ -63,6 +65,27 @@ function normalizeTimeoutMs(raw: number | undefined, fallback: number) { return value < 0 ? fallback : value; } +function resolveCdpPortRangeStart( + rawStart: number | undefined, + fallbackStart: number, + rangeSpan: number, +) { + const start = + typeof rawStart === "number" && Number.isFinite(rawStart) + ? Math.floor(rawStart) + : fallbackStart; + if (start < 1 || start > 65535) { + throw new Error(`browser.cdpPortRangeStart must be between 1 and 65535, got: ${start}`); + } + const maxStart = 65535 - rangeSpan; + if (start > maxStart) { + throw new Error( + `browser.cdpPortRangeStart (${start}) is too high for a ${rangeSpan + 1}-port range; max is ${maxStart}.`, + ); + } + return start; +} + function normalizeStringList(raw: string[] | undefined): string[] | undefined { if (!Array.isArray(raw) || raw.length === 0) { return undefined; @@ -193,6 +216,13 @@ export function resolveBrowserConfig( ); const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort); + const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start; + const cdpPortRangeStart = resolveCdpPortRangeStart( + cfg?.cdpPortRangeStart, + derivedCdpRange.start, + cdpRangeSpan, + ); + const cdpPortRangeEnd = cdpPortRangeStart + cdpRangeSpan; const rawCdpUrl = (cfg?.cdpUrl ?? "").trim(); let cdpInfo: @@ -228,7 +258,7 @@ export function resolveBrowserConfig( // Use legacy cdpUrl port for backward compatibility when no profiles configured const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined; const profiles = ensureDefaultChromeExtensionProfile( - ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, derivedCdpRange.start), + ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, cdpPortRangeStart), controlPort, ); const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; @@ -254,6 +284,8 @@ export function resolveBrowserConfig( enabled, evaluateEnabled, controlPort, + cdpPortRangeStart, + cdpPortRangeEnd, cdpProtocol, cdpHost: cdpInfo.parsed.hostname, cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname), diff --git a/src/browser/profiles-service.test.ts b/src/browser/profiles-service.test.ts index ef599fad8..3477d6e8c 100644 --- a/src/browser/profiles-service.test.ts +++ b/src/browser/profiles-service.test.ts @@ -61,6 +61,46 @@ describe("BrowserProfilesService", () => { expect(writeConfigFile).toHaveBeenCalled(); }); + it("falls back to derived CDP range when resolved CDP range is missing", async () => { + const base = resolveBrowserConfig({}); + const baseWithoutRange = { ...base } as { + [key: string]: unknown; + cdpPortRangeStart?: unknown; + cdpPortRangeEnd?: unknown; + }; + delete baseWithoutRange.cdpPortRangeStart; + delete baseWithoutRange.cdpPortRangeEnd; + const resolved = { + ...baseWithoutRange, + controlPort: 30000, + } as BrowserServerState["resolved"]; + const { ctx, state } = createCtx(resolved); + + vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + + const service = createBrowserProfilesService(ctx); + const result = await service.createProfile({ name: "work" }); + + expect(result.cdpPort).toBe(30009); + expect(state.resolved.profiles.work?.cdpPort).toBe(30009); + expect(writeConfigFile).toHaveBeenCalled(); + }); + + it("allocates from configured cdpPortRangeStart for new local profiles", async () => { + const resolved = resolveBrowserConfig({ cdpPortRangeStart: 19000 }); + const { ctx, state } = createCtx(resolved); + + vi.mocked(loadConfig).mockReturnValue({ browser: { cdpPortRangeStart: 19000, profiles: {} } }); + + const service = createBrowserProfilesService(ctx); + const result = await service.createProfile({ name: "work" }); + + expect(result.cdpPort).toBe(19001); + expect(result.isRemote).toBe(false); + expect(state.resolved.profiles.work?.cdpPort).toBe(19001); + expect(writeConfigFile).toHaveBeenCalled(); + }); + it("accepts per-profile cdpUrl for remote Chrome", async () => { const resolved = resolveBrowserConfig({}); const { ctx } = createCtx(resolved); diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index 149090d4a..5625cc924 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -40,6 +40,30 @@ export type DeleteProfileResult = { const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/; +const cdpPortRange = (resolved: { + controlPort: number; + cdpPortRangeStart?: number; + cdpPortRangeEnd?: number; +}): { start: number; end: number } => { + const start = resolved.cdpPortRangeStart; + const end = resolved.cdpPortRangeEnd; + if ( + typeof start === "number" && + Number.isFinite(start) && + Number.isInteger(start) && + typeof end === "number" && + Number.isFinite(end) && + Number.isInteger(end) && + start > 0 && + end >= start && + end <= 65535 + ) { + return { start, end }; + } + + return deriveDefaultBrowserCdpPortRange(resolved.controlPort); +}; + export function createBrowserProfilesService(ctx: BrowserRouteContext) { const listProfiles = async (): Promise => { return await ctx.listProfiles(); @@ -80,7 +104,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { }; } else { const usedPorts = getUsedPorts(resolvedProfiles); - const range = deriveDefaultBrowserCdpPortRange(state.resolved.controlPort); + const range = cdpPortRange(state.resolved); const cdpPort = allocateCdpPort(usedPorts, range); if (cdpPort === null) { throw new Error("no available CDP ports in range"); diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index b3f15680d..81f71cc21 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -12,6 +12,8 @@ function makeBrowserState(): BrowserServerState { resolved: { enabled: true, controlPort: 18791, + cdpPortRangeStart: 18800, + cdpPortRangeEnd: 18899, cdpProtocol: "http", cdpHost: "127.0.0.1", cdpIsLoopback: true, diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts index ebf261246..31fe92d82 100644 --- a/src/browser/server-context.remote-tab-ops.test.ts +++ b/src/browser/server-context.remote-tab-ops.test.ts @@ -24,6 +24,8 @@ function makeState( resolved: { enabled: true, controlPort: 18791, + cdpPortRangeStart: 18800, + cdpPortRangeEnd: 18899, cdpProtocol: profile === "remote" ? "https" : "http", cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1", cdpIsLoopback: profile !== "remote", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 9b940da0f..e5da6bbce 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -220,6 +220,8 @@ export const FIELD_HELP: Record = { "Disables Chromium sandbox isolation flags for environments where sandboxing fails at runtime. Keep this off whenever possible because process isolation protections are reduced.", "browser.attachOnly": "Restricts browser mode to attach-only behavior without starting local browser processes. Use this when all browser sessions are externally managed by a remote CDP provider.", + "browser.cdpPortRangeStart": + "Starting local CDP port used for auto-allocated browser profile ports. Increase this when host-level port defaults conflict with other local services.", "browser.defaultProfile": "Default browser profile name selected when callers do not explicitly choose a profile. Use a stable low-privilege profile as the default to reduce accidental cross-context state use.", "browser.profiles": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 83cbbe27b..40f1cf315 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -105,6 +105,7 @@ export const FIELD_LABELS: Record = { "browser.headless": "Browser Headless Mode", "browser.noSandbox": "Browser No-Sandbox Mode", "browser.attachOnly": "Browser Attach-only Mode", + "browser.cdpPortRangeStart": "Browser CDP Port Range Start", "browser.defaultProfile": "Browser Default Profile", "browser.profiles": "Browser Profiles", "browser.profiles.*.cdpPort": "Browser Profile CDP Port", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index b251ef59e..e8bc5e3cf 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -48,6 +48,8 @@ export type BrowserConfig = { noSandbox?: boolean; /** If true: never launch; only attach to an existing browser. Default: false */ attachOnly?: boolean; + /** Starting local CDP port for auto-assigned browser profiles. Default derives from gateway port. */ + cdpPortRangeStart?: number; /** Default profile to use when profile param is omitted. Default: "chrome" */ defaultProfile?: string; /** Named browser profiles with explicit CDP ports or URLs. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8034c5b5e..0034b9846 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -250,6 +250,7 @@ export const OpenClawSchema = z headless: z.boolean().optional(), noSandbox: z.boolean().optional(), attachOnly: z.boolean().optional(), + cdpPortRangeStart: z.number().int().min(1).max(65535).optional(), defaultProfile: z.string().optional(), snapshotDefaults: BrowserSnapshotDefaultsSchema, ssrfPolicy: z