fix(browser): support per-profile executable paths

Co-authored-by: nobrainer-tech <nobrainer-tech@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-04-25 05:50:10 +01:00
parent 759fe0bf95
commit b0e834b2d9
16 changed files with 178 additions and 9 deletions

View File

@@ -387,6 +387,32 @@ describe("chrome.ts internal", () => {
});
});
it("uses profile executablePath over global executablePath when launching", async () => {
vi.spyOn(fs, "existsSync").mockImplementation((p) => {
const s = String(p);
if (s === "/tmp/profile-chrome" || s.endsWith("Local State") || s.endsWith("Preferences")) {
return true;
}
return false;
});
spawnMock.mockImplementation(() => makeFakeProc());
await withMockChromeCdpServer({
wsPath: "/devtools/browser/PROFILE_EXE",
run: async (baseUrl) => {
const port = new URL(baseUrl).port;
const profile = { ...makeProfile(Number(port)), executablePath: "/tmp/profile-chrome" };
const resolved = {
...makeResolved(),
executablePath: "/tmp/global-chrome",
} as ResolvedBrowserConfig;
const running = await launchOpenClawChrome(resolved, profile);
expect(spawnMock.mock.calls[0]?.[0]).toBe("/tmp/profile-chrome");
running.proc.kill?.("SIGTERM");
},
});
});
it("throws with stderr hint + sandbox hint when CDP never becomes reachable", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "linux" });

View File

@@ -90,8 +90,14 @@ export type RunningChrome = {
proc: ChildProcess;
};
function resolveBrowserExecutable(resolved: ResolvedBrowserConfig): BrowserExecutable | null {
return resolveBrowserExecutableForPlatform(resolved, process.platform);
function resolveBrowserExecutable(
resolved: ResolvedBrowserConfig,
profile: ResolvedBrowserProfile,
): BrowserExecutable | null {
return resolveBrowserExecutableForPlatform(
{ ...resolved, executablePath: profile.executablePath ?? resolved.executablePath },
process.platform,
);
}
export function resolveOpenClawUserDataDir(profileName = DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME) {
@@ -268,7 +274,7 @@ export async function launchOpenClawChrome(
}
await ensurePortAvailable(profile.cdpPort);
const exe = resolveBrowserExecutable(resolved);
const exe = resolveBrowserExecutable(resolved, profile);
if (!exe) {
throw new Error(
"No supported browser found (Chrome/Brave/Edge/Chromium on macOS, Linux, or Windows).",

View File

@@ -279,6 +279,50 @@ describe("browser config", () => {
expect(remote?.headless).toBe(false);
});
it("inherits executablePath from global browser config when profile override is not set", () => {
const resolved = resolveBrowserConfig({
executablePath: "~/bin/chrome-global",
profiles: {
remote: { cdpUrl: "http://127.0.0.1:9222", color: "#0066CC" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.executablePath).toBe(path.resolve(os.homedir(), "bin/chrome-global"));
});
it("allows profile executablePath to override global browser executablePath", () => {
const resolved = resolveBrowserConfig({
executablePath: "/usr/bin/chrome-global",
profiles: {
remote: {
cdpUrl: "http://127.0.0.1:9222",
executablePath: " ~/bin/chrome-profile ",
color: "#0066CC",
},
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.executablePath).toBe(path.resolve(os.homedir(), "bin/chrome-profile"));
});
it("falls back to global executablePath when profile executablePath is blank", () => {
const resolved = resolveBrowserConfig({
executablePath: "/usr/bin/chrome-global",
profiles: {
remote: {
cdpUrl: "http://127.0.0.1:9222",
executablePath: " ",
color: "#0066CC",
},
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.executablePath).toBe("/usr/bin/chrome-global");
});
it("uses base protocol for profiles with only cdpPort", () => {
const resolved = resolveBrowserConfig({
cdpUrl: "https://example.com:9443",

View File

@@ -94,6 +94,7 @@ export type ResolvedBrowserProfile = {
userDataDir?: string;
color: string;
driver: "openclaw" | "existing-session";
executablePath?: string;
headless: boolean;
attachOnly: boolean;
};
@@ -370,6 +371,7 @@ export function resolveProfile(
let cdpUrl = "";
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
const headless = profile.headless ?? resolved.headless;
const executablePath = normalizeExecutablePath(profile.executablePath) ?? resolved.executablePath;
if (driver === "existing-session") {
return {
@@ -381,6 +383,7 @@ export function resolveProfile(
userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined,
color: profile.color,
driver,
executablePath,
headless,
attachOnly: true,
};
@@ -415,6 +418,7 @@ export function resolveProfile(
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
driver,
executablePath,
headless,
attachOnly: profile.attachOnly ?? resolved.attachOnly,
};

View File

@@ -27,6 +27,13 @@ function changedProfileInvariants(
) {
changed.push("headless");
}
if (
currentUsesLocalManagedLaunch &&
nextUsesLocalManagedLaunch &&
current.executablePath !== next.executablePath
) {
changed.push("executablePath");
}
if (current.attachOnly !== next.attachOnly) {
changed.push("attachOnly");
}

View File

@@ -26,6 +26,8 @@ function createExistingSessionProfileState(params?: { isHttpReachable?: () => Pr
cdpUrl: "",
userDataDir: "/tmp/brave-profile",
color: "#00AA00",
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
headless: false,
attachOnly: true,
},
isHttpReachable: params?.isHttpReachable ?? (async () => true),
@@ -80,6 +82,7 @@ describe("basic browser routes", () => {
cdpPort: null,
cdpUrl: null,
userDataDir: "/tmp/brave-profile",
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
pid: 4321,
});
});

View File

@@ -103,7 +103,7 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext)
color: profileCtx.profile.color,
headless: profileCtx.profile.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
executablePath: profileCtx.profile.executablePath ?? null,
attachOnly: profileCtx.profile.attachOnly,
};
}

View File

@@ -6,6 +6,7 @@ type TestProfileConfig = {
cdpUrl?: string;
color?: string;
headless?: boolean;
executablePath?: string;
driver?: "openclaw" | "existing-session";
};
type TestConfig = {
@@ -275,6 +276,55 @@ describe("server-context hot-reload profiles", () => {
expect(runtime?.reconcile?.reason).toContain("headless");
});
it("marks local managed runtime state for reconcile when profile executablePath changes", async () => {
mockState.cfgProfiles.openclaw = {
cdpPort: 18800,
color: "#FF4500",
executablePath: "/usr/bin/chrome-old",
};
mockState.cachedConfig = null;
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const openclawProfile = resolveProfile(resolved, "openclaw");
expect(openclawProfile).toBeTruthy();
expect(openclawProfile?.executablePath).toBe("/usr/bin/chrome-old");
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",
executablePath: "/usr/bin/chrome-new",
};
mockState.cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("openclaw");
expect(runtime).toBeTruthy();
expect(runtime?.profile.executablePath).toBe("/usr/bin/chrome-new");
expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("executablePath");
});
it("does not reconcile existing-session runtime when only headless changes", async () => {
mockState.cfgProfiles.remote = {
cdpUrl: "http://127.0.0.1:9222",