mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(browser): support per-profile executable paths
Co-authored-by: nobrainer-tech <nobrainer-tech@users.noreply.github.com>
This commit is contained in:
@@ -74,6 +74,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Subagents: stop stale unended runs from counting as active or pending forever, while preserving restart-aborted recovery for recoverable child sessions. Fixes #71252. Thanks @hclsys.
|
||||
- Gateway/tools: allow `POST /tools/invoke` to reach plugin-backed catalog tools such as `browser` when no core implementation exists, while still preferring built-in tools for real core names. Thanks @chat2way.
|
||||
- Browser/security: require `operator.admin` for the `browser.request` gateway method, matching the host/browser-node control authority exposed by that route. Thanks @RichardCao.
|
||||
- Browser/profiles: allow local managed profiles to override `browser.executablePath`, so different profiles can launch different Chromium-based browsers. Thanks @nobrainer-tech.
|
||||
- Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana.
|
||||
- CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319.
|
||||
- Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
b6d1e53947fcdfbff1b99f8ec79d3814d243385a1750b7fb40b40bb30f2e2975 config-baseline.json
|
||||
98c83ce8af9ec4703726d7d673add95279be008a801b1d298982cbd9c1785747 config-baseline.core.json
|
||||
d885c14dea2c361123a97a0f6c854f6dbae8592f39daa211173ef7f1fe7d554a config-baseline.json
|
||||
c991bb527d8efffb5c9a2c5e502113260a2873923d469289c82f7029257fddaf config-baseline.core.json
|
||||
d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json
|
||||
86f615b7d267b03888af0af7ccb3f8232a6b636f8a741d522ff425e46729ba81 config-baseline.plugin.json
|
||||
0d5ba81f0030bd39b7ae285096276cc18b150836c2252fd2217329fc6154e80e config-baseline.plugin.json
|
||||
|
||||
@@ -36,6 +36,7 @@ openclaw config --section gateway --section daemon
|
||||
openclaw config schema
|
||||
openclaw config get browser.executablePath
|
||||
openclaw config set browser.executablePath "/usr/bin/google-chrome"
|
||||
openclaw config set browser.profiles.work.executablePath "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
openclaw config set agents.defaults.heartbeat.every "2h"
|
||||
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json --merge
|
||||
|
||||
@@ -175,7 +175,11 @@ See [Plugins](/tools/plugin).
|
||||
},
|
||||
profiles: {
|
||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||
work: { cdpPort: 18801, color: "#0066CC" },
|
||||
work: {
|
||||
cdpPort: 18801,
|
||||
color: "#0066CC",
|
||||
executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
},
|
||||
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||
brave: {
|
||||
driver: "existing-session",
|
||||
@@ -218,6 +222,9 @@ See [Plugins](/tools/plugin).
|
||||
`responsebody`, PDF export, download interception, or batch actions.
|
||||
- Local managed `openclaw` profiles auto-assign `cdpPort` and `cdpUrl`; only
|
||||
set `cdpUrl` explicitly for remote CDP.
|
||||
- Local managed profiles can set `executablePath` to override the global
|
||||
`browser.executablePath` for that profile. Use this to run one profile in
|
||||
Chrome and another in Brave.
|
||||
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
- `browser.executablePath` accepts `~` for your OS home directory.
|
||||
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
|
||||
|
||||
@@ -143,7 +143,12 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
||||
executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
profiles: {
|
||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||
work: { cdpPort: 18801, color: "#0066CC", headless: true },
|
||||
work: {
|
||||
cdpPort: 18801,
|
||||
color: "#0066CC",
|
||||
headless: true,
|
||||
executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
},
|
||||
user: {
|
||||
driver: "existing-session",
|
||||
attachOnly: true,
|
||||
@@ -187,6 +192,7 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
||||
|
||||
- `attachOnly: true` means never launch a local browser; only attach if one is already running.
|
||||
- `headless` can be set globally or per local managed profile. Per-profile values override `browser.headless`, so one locally launched profile can stay headless while another remains visible.
|
||||
- `executablePath` can be set globally or per local managed profile. Per-profile values override `browser.executablePath`, so different managed profiles can launch different Chromium-based browsers.
|
||||
- `color` (top-level and per-profile) tints the browser UI so you can see which profile is active.
|
||||
- Default profile is `openclaw` (managed standalone). Use `defaultProfile: "user"` to opt into the signed-in user browser.
|
||||
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||
@@ -205,6 +211,7 @@ auto-detection. `~` expands to your OS home directory:
|
||||
|
||||
```bash
|
||||
openclaw config set browser.executablePath "/usr/bin/google-chrome"
|
||||
openclaw config set browser.profiles.work.executablePath "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
```
|
||||
|
||||
Or set it in config, per platform:
|
||||
@@ -239,6 +246,10 @@ Or set it in config, per platform:
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Per-profile `executablePath` only affects local managed profiles that OpenClaw
|
||||
launches. `existing-session` profiles attach to an already-running browser
|
||||
instead, and remote CDP profiles use the browser behind `cdpUrl`.
|
||||
|
||||
## Local vs remote control
|
||||
|
||||
- **Local control (default):** the Gateway starts the loopback control service and can launch a local browser.
|
||||
@@ -246,6 +257,9 @@ Or set it in config, per platform:
|
||||
- **Remote CDP:** set `browser.profiles.<name>.cdpUrl` (or `browser.cdpUrl`) to
|
||||
attach to a remote Chromium-based browser. In this case, OpenClaw will not launch a local browser.
|
||||
- `headless` only affects local managed profiles that OpenClaw launches. It does not restart or change existing-session or remote CDP browsers.
|
||||
- `executablePath` follows the same local managed profile rule. Changing it on a
|
||||
running local managed profile marks that profile for restart/reconcile so the
|
||||
next launch uses the new binary.
|
||||
|
||||
Stopping behavior differs by profile mode:
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -750,6 +750,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
description:
|
||||
"Per-profile headless override for locally launched browser instances. Use this when one profile should stay headless without forcing browser.headless for every other profile.",
|
||||
},
|
||||
executablePath: {
|
||||
type: "string",
|
||||
},
|
||||
attachOnly: {
|
||||
type: "boolean",
|
||||
title: "Browser Profile Attach-only Mode",
|
||||
|
||||
@@ -9,6 +9,8 @@ export type BrowserProfileConfig = {
|
||||
driver?: "openclaw" | "clawd" | "existing-session";
|
||||
/** If true, launch this profile in headless mode. Falls back to browser.headless. */
|
||||
headless?: boolean;
|
||||
/** Browser executable path for this profile. Falls back to browser.executablePath. */
|
||||
executablePath?: string;
|
||||
/** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */
|
||||
attachOnly?: boolean;
|
||||
/** Profile color (hex). Auto-assigned at creation. */
|
||||
|
||||
@@ -410,6 +410,7 @@ export const OpenClawSchema = z
|
||||
.union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")])
|
||||
.optional(),
|
||||
headless: z.boolean().optional(),
|
||||
executablePath: z.string().optional(),
|
||||
attachOnly: z.boolean().optional(),
|
||||
color: HexColorSchema,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user