diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cbd56f0ef..59612634859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 18beac9d4a0..89bd8e1b70c 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/cli/config.md b/docs/cli/config.md index ea9cc12831c..c35c07f59a7 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -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 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index dd64ae5d1df..b47885db151 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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`). diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 1fe77fe2053..51f5d514b62 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -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: +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..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: diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index 74fbdb34553..0a4e16a3dd2 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -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" }); diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 7e88859d79c..95c046ebe7e 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -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).", diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index abf1fbf0432..ec56f032766 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -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", diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 2eb87ed4c49..ae4b68c7c53 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -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, }; diff --git a/extensions/browser/src/browser/resolved-config-refresh.ts b/extensions/browser/src/browser/resolved-config-refresh.ts index 1378ca04399..07ce8bbc02d 100644 --- a/extensions/browser/src/browser/resolved-config-refresh.ts +++ b/extensions/browser/src/browser/resolved-config-refresh.ts @@ -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"); } diff --git a/extensions/browser/src/browser/routes/basic.existing-session.test.ts b/extensions/browser/src/browser/routes/basic.existing-session.test.ts index e61b0d6be8c..631463e3273 100644 --- a/extensions/browser/src/browser/routes/basic.existing-session.test.ts +++ b/extensions/browser/src/browser/routes/basic.existing-session.test.ts @@ -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, }); }); diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index f0a823f9b38..8a60899f47c 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -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, }; } diff --git a/extensions/browser/src/browser/server-context.hot-reload-profiles.test.ts b/extensions/browser/src/browser/server-context.hot-reload-profiles.test.ts index f19f50c2d68..c7f3f3b3886 100644 --- a/extensions/browser/src/browser/server-context.hot-reload-profiles.test.ts +++ b/extensions/browser/src/browser/server-context.hot-reload-profiles.test.ts @@ -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", diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index a843427a647..5c40b3fc3dd 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 5834cce9b73..562ab7108c7 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -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. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 66dd10dd346..d183d02cff3 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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, })