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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user