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
This commit is contained in:
Vincent Koc
2026-03-01 23:50:50 -08:00
committed by GitHub
parent c6e5026edf
commit 22be0c5801
12 changed files with 130 additions and 2 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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),

View File

@@ -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);

View File

@@ -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<ProfileStatus[]> => {
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");

View File

@@ -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,

View File

@@ -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",

View File

@@ -220,6 +220,8 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -105,6 +105,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@@ -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. */

View File

@@ -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