feat(browser): support per-profile headless

Co-authored-by: nakamotoliu <nakamotoliu2026@gmail.com>
Co-authored-by: Nakamoto <nakamoto@claude.ai>
This commit is contained in:
Peter Steinberger
2026-04-25 01:49:07 +01:00
parent 5ea0ded5a5
commit d610e2cc6c
26 changed files with 262 additions and 9 deletions

View File

@@ -159,6 +159,7 @@ function createProfile(overrides: Partial<ResolvedBrowserProfile>): ResolvedBrow
driver: "openclaw",
attachOnly: false,
...overrides,
headless: overrides.headless ?? false,
};
}

View File

@@ -75,6 +75,7 @@ const localProfile: ResolvedBrowserProfile = {
cdpIsLoopback: true,
color: "#FF4500",
driver: "openclaw",
headless: false,
attachOnly: false,
};

View File

@@ -166,18 +166,29 @@ describe("chrome.ts internal", () => {
cdpPort: 19222,
cdpUrl: "http://127.0.0.1:19222",
cdpIsLoopback: true,
headless: false,
} as unknown as ResolvedBrowserProfile;
it("toggles headless args", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: baseResolved({ headless: true }),
profile: baseProfile,
resolved: baseResolved({ headless: false }),
profile: { ...baseProfile, headless: true },
userDataDir: "/tmp/foo",
});
expect(args).toContain("--headless=new");
expect(args).toContain("--disable-gpu");
});
it("lets profile headless=false override global headless=true", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: baseResolved({ headless: true }),
profile: { ...baseProfile, headless: false },
userDataDir: "/tmp/foo",
});
expect(args).not.toContain("--headless=new");
expect(args).not.toContain("--disable-gpu");
});
it("toggles no-sandbox args", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: baseResolved({ noSandbox: true }),

View File

@@ -650,6 +650,7 @@ describe("browser chrome launch args", () => {
cdpIsLoopback: true,
color: "#FF4500",
driver: "openclaw",
headless: false,
attachOnly: false,
},
userDataDir: "/tmp/openclaw-test-user-data",

View File

@@ -121,7 +121,7 @@ export function buildOpenClawChromeLaunchArgs(params: {
"--password-store=basic",
];
if (resolved.headless) {
if (profile.headless) {
args.push("--headless=new");
args.push("--disable-gpu");
}

View File

@@ -178,6 +178,42 @@ describe("browser config", () => {
expect(remote?.attachOnly).toBe(true);
});
it("inherits headless from global browser config when profile override is not set", () => {
const resolved = resolveBrowserConfig({
headless: true,
profiles: {
remote: { cdpUrl: "http://127.0.0.1:9222", color: "#0066CC" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.headless).toBe(true);
});
it("allows profile headless to override global browser headless", () => {
const resolved = resolveBrowserConfig({
headless: false,
profiles: {
remote: { cdpUrl: "http://127.0.0.1:9222", headless: true, color: "#0066CC" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.headless).toBe(true);
});
it("allows profile headless=false to override global browser headless=true", () => {
const resolved = resolveBrowserConfig({
headless: true,
profiles: {
remote: { cdpUrl: "http://127.0.0.1:9222", headless: false, color: "#0066CC" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.headless).toBe(false);
});
it("uses base protocol for profiles with only cdpPort", () => {
const resolved = resolveBrowserConfig({
cdpUrl: "https://example.com:9443",

View File

@@ -81,6 +81,7 @@ export type ResolvedBrowserProfile = {
userDataDir?: string;
color: string;
driver: "openclaw" | "existing-session";
headless: boolean;
attachOnly: boolean;
};
@@ -312,6 +313,7 @@ export function resolveProfile(
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl = "";
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
const headless = profile.headless ?? resolved.headless;
if (driver === "existing-session") {
return {
@@ -323,6 +325,7 @@ export function resolveProfile(
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
color: profile.color,
driver,
headless,
attachOnly: true,
};
}
@@ -356,6 +359,7 @@ export function resolveProfile(
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
driver,
headless,
attachOnly: profile.attachOnly ?? resolved.attachOnly,
};
}

View File

@@ -7,6 +7,10 @@ function changedProfileInvariants(
next: ResolvedBrowserProfile,
): string[] {
const changed: string[] = [];
const currentUsesLocalManagedLaunch =
current.driver === "openclaw" && !current.attachOnly && current.cdpIsLoopback;
const nextUsesLocalManagedLaunch =
next.driver === "openclaw" && !next.attachOnly && next.cdpIsLoopback;
if (current.cdpUrl !== next.cdpUrl) {
changed.push("cdpUrl");
}
@@ -16,6 +20,13 @@ function changedProfileInvariants(
if (current.driver !== next.driver) {
changed.push("driver");
}
if (
currentUsesLocalManagedLaunch &&
nextUsesLocalManagedLaunch &&
current.headless !== next.headless
) {
changed.push("headless");
}
if (current.attachOnly !== next.attachOnly) {
changed.push("attachOnly");
}

View File

@@ -11,6 +11,7 @@ function profile(driver: "existing-session" | "openclaw"): ResolvedBrowserProfil
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#00AA00",
headless: false,
attachOnly: driver === "existing-session",
};
}

View File

@@ -101,7 +101,7 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext)
detectError,
userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
headless: profileCtx.profile.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,

View File

@@ -23,6 +23,7 @@ describe("browser tab routes attachOnly loopback profiles", () => {
cdpPort: 9222,
color: "#00AA00",
driver: "openclaw",
headless: false,
attachOnly: true,
},
resolvedOverrides: {

View File

@@ -35,6 +35,7 @@ function createAttachOnlyLoopbackProfile(cdpUrl: string) {
cdpPort: 9222,
color: "#00AA00",
driver: "openclaw",
headless: false,
attachOnly: true,
},
resolvedOverrides: {
@@ -236,6 +237,7 @@ describe("browser server-context ensureBrowserAvailable", () => {
cdpPort: 443,
color: "#00AA00",
driver: "openclaw",
headless: false,
attachOnly: false,
},
resolvedOverrides: {

View File

@@ -1,7 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "./server-context.types.js";
type TestProfileConfig = { cdpPort?: number; cdpUrl?: string; color?: string };
type TestProfileConfig = {
cdpPort?: number;
cdpUrl?: string;
color?: string;
headless?: boolean;
driver?: "openclaw" | "existing-session";
};
type TestConfig = {
browser: {
enabled: true;
@@ -225,4 +231,157 @@ describe("server-context hot-reload profiles", () => {
expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("cdpPort");
});
it("marks local managed runtime state for reconcile when profile headless changes", async () => {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const openclawProfile = resolveProfile(resolved, "openclaw");
expect(openclawProfile).toBeTruthy();
expect(openclawProfile?.headless).toBe(true);
const state: BrowserServerState = {
server: null,
port: 18791,
resolved,
profiles: new Map([
[
"openclaw",
{
profile: openclawProfile!,
running: { pid: 123 } as never,
lastTargetId: "tab-1",
reconcile: null,
},
],
]),
};
mockState.cfgProfiles.openclaw = {
cdpPort: 18800,
color: "#FF4500",
headless: false,
};
mockState.cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("openclaw");
expect(runtime).toBeTruthy();
expect(runtime?.profile.headless).toBe(false);
expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("headless");
});
it("does not reconcile existing-session runtime when only headless changes", async () => {
mockState.cfgProfiles.remote = {
cdpUrl: "http://127.0.0.1:9222",
color: "#0066CC",
headless: true,
driver: "existing-session",
};
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const remoteProfile = resolveProfile(resolved, "remote");
expect(remoteProfile).toBeTruthy();
expect(remoteProfile?.driver).toBe("existing-session");
expect(remoteProfile?.attachOnly).toBe(true);
expect(remoteProfile?.headless).toBe(true);
const state: BrowserServerState = {
server: null,
port: 18791,
resolved,
profiles: new Map([
[
"remote",
{
profile: remoteProfile!,
running: { pid: 456 } as never,
lastTargetId: "tab-remote",
reconcile: null,
},
],
]),
};
mockState.cfgProfiles.remote = {
cdpUrl: "http://127.0.0.1:9222",
color: "#0066CC",
headless: false,
driver: "existing-session",
};
mockState.cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("remote");
expect(runtime).toBeTruthy();
expect(runtime?.profile.driver).toBe("existing-session");
expect(runtime?.profile.headless).toBe(false);
expect(runtime?.lastTargetId).toBe("tab-remote");
expect(runtime?.reconcile).toBeNull();
});
it("does not reconcile remote cdp runtime when only headless changes", async () => {
mockState.cfgProfiles.remote = {
cdpUrl: "http://10.0.0.42:9222",
color: "#0066CC",
headless: true,
};
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const remoteProfile = resolveProfile(resolved, "remote");
expect(remoteProfile).toBeTruthy();
expect(remoteProfile?.driver).toBe("openclaw");
expect(remoteProfile?.attachOnly).toBe(false);
expect(remoteProfile?.cdpIsLoopback).toBe(false);
expect(remoteProfile?.headless).toBe(true);
const state: BrowserServerState = {
server: null,
port: 18791,
resolved,
profiles: new Map([
[
"remote",
{
profile: remoteProfile!,
running: { pid: 789 } as never,
lastTargetId: "tab-remote-cdp",
reconcile: null,
},
],
]),
};
mockState.cfgProfiles.remote = {
cdpUrl: "http://10.0.0.42:9222",
color: "#0066CC",
headless: false,
};
mockState.cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("remote");
expect(runtime).toBeTruthy();
expect(runtime?.profile.driver).toBe("openclaw");
expect(runtime?.profile.cdpIsLoopback).toBe(false);
expect(runtime?.profile.headless).toBe(false);
expect(runtime?.lastTargetId).toBe("tab-remote-cdp");
expect(runtime?.reconcile).toBeNull();
});
});

View File

@@ -41,6 +41,7 @@ describe("browser server-context listProfiles", () => {
cdpPort: 9222,
color: "#00AA00",
driver: "openclaw",
headless: false,
attachOnly: true,
},
resolvedOverrides: {

View File

@@ -77,6 +77,7 @@ function resolveProfileForTest(
cdpIsLoopback,
color: rawProfile.color ?? state.resolved.color,
driver: rawProfile.driver === "existing-session" ? "existing-session" : "openclaw",
headless: rawProfile.headless ?? state.resolved.headless,
attachOnly: rawProfile.attachOnly ?? state.resolved.attachOnly,
userDataDir: rawProfile.userDataDir,
};

View File

@@ -32,6 +32,7 @@ function localOpenClawProfile(): Parameters<typeof createProfileResetOps>[0]["pr
cdpPort: 18800,
color: "#f60",
driver: "openclaw",
headless: false,
attachOnly: false,
};
}

View File

@@ -15,6 +15,7 @@ export function makeBrowserProfile(
cdpPort: 18800,
color: "#FF4500",
driver: "openclaw",
headless: false,
attachOnly: false,
...overrides,
};