From d610e2cc6cfe4fdc88ba47600809ad927f8b8ee8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 01:49:07 +0100 Subject: [PATCH] feat(browser): support per-profile headless Co-authored-by: nakamotoliu Co-authored-by: Nakamoto --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 6 +- docs/tools/browser.md | 4 +- .../browser/src/browser/cdp.helpers.test.ts | 1 + .../src/browser/cdp.screenshot-params.test.ts | 1 + .../src/browser/chrome.internal.test.ts | 15 +- extensions/browser/src/browser/chrome.test.ts | 1 + extensions/browser/src/browser/chrome.ts | 2 +- extensions/browser/src/browser/config.test.ts | 36 ++++ extensions/browser/src/browser/config.ts | 4 + .../src/browser/resolved-config-refresh.ts | 11 ++ .../routes/agent.snapshot.plan.test.ts | 1 + .../browser/src/browser/routes/basic.ts | 2 +- .../browser/routes/tabs.attach-only.test.ts | 1 + ...wser-available.waits-for-cdp-ready.test.ts | 2 + ...server-context.hot-reload-profiles.test.ts | 161 +++++++++++++++++- .../server-context.list-profiles.test.ts | 1 + .../server-context.remote-tab-ops.harness.ts | 1 + .../src/browser/server-context.reset.test.ts | 1 + .../browser/server-context.test-harness.ts | 1 + src/config/schema.base.generated.ts | 11 ++ src/config/schema.help.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.browser.ts | 2 + src/config/zod-schema.ts | 1 + src/plugin-sdk/browser-profiles.ts | 1 + 26 files changed, 262 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7053b99b9..6a7b44de2ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Browser/config: support per-profile `browser.profiles..headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu. - Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc. - Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 68354495938..661d9ede57d 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -52af51e35e05d0cbaa1a79fb415f2c2fe56ad5d52a62efa9cbb9c32489d517f5 config-baseline.json -642b4e2c9891e710790313df097b4e0db75a197ec0908e9c03bdc76f5bbdf9b0 config-baseline.core.json +8f23e853ccde6cd021b84b32fe205f456f8516667683d16c9b56d6598f608989 config-baseline.json +037bf4a873587adb8349f531c0ad79cd4f90e01712f5aa5d8b4387be73538a7f config-baseline.core.json 22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json -d47a574045a47356e513ab308d7dcad9fa0b389f50e93c5cf0f820fab858e70e config-baseline.plugin.json +86f615b7d267b03888af0af7ccb3f8232a6b636f8a741d522ff425e46729ba81 config-baseline.plugin.json diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 043ac6dbb5f..d2870604208 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -137,7 +137,7 @@ 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" }, + work: { cdpPort: 18801, color: "#0066CC", headless: true }, user: { driver: "existing-session", attachOnly: true, @@ -177,6 +177,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. - `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. @@ -235,6 +236,7 @@ Or set it in config, per platform: - **Remote control (node host):** run a node host on the machine that has the browser; the Gateway proxies browser actions to it. - **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. Stopping behavior differs by profile mode: diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index 7f9518975c3..006241a9a40 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -159,6 +159,7 @@ function createProfile(overrides: Partial): ResolvedBrow driver: "openclaw", attachOnly: false, ...overrides, + headless: overrides.headless ?? false, }; } diff --git a/extensions/browser/src/browser/cdp.screenshot-params.test.ts b/extensions/browser/src/browser/cdp.screenshot-params.test.ts index 74c6e890aa8..36411a2b01f 100644 --- a/extensions/browser/src/browser/cdp.screenshot-params.test.ts +++ b/extensions/browser/src/browser/cdp.screenshot-params.test.ts @@ -75,6 +75,7 @@ const localProfile: ResolvedBrowserProfile = { cdpIsLoopback: true, color: "#FF4500", driver: "openclaw", + headless: false, attachOnly: false, }; diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index aa801064792..a8be83257f0 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -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 }), diff --git a/extensions/browser/src/browser/chrome.test.ts b/extensions/browser/src/browser/chrome.test.ts index 9a5909b5bbf..115d55c8941 100644 --- a/extensions/browser/src/browser/chrome.test.ts +++ b/extensions/browser/src/browser/chrome.test.ts @@ -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", diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 67c638afb50..183549cb180 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -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"); } diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index fa17210846e..57a9bf97f76 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -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", diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 503146dcf4a..e84ed8a6f88 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -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, }; } diff --git a/extensions/browser/src/browser/resolved-config-refresh.ts b/extensions/browser/src/browser/resolved-config-refresh.ts index 60d45a0d1de..1378ca04399 100644 --- a/extensions/browser/src/browser/resolved-config-refresh.ts +++ b/extensions/browser/src/browser/resolved-config-refresh.ts @@ -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"); } diff --git a/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts b/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts index a9582e48072..b5d5cc0d168 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts @@ -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", }; } diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index 3e40a73e653..f0a823f9b38 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -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, diff --git a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts index 7989a430eb1..b00e329e33e 100644 --- a/extensions/browser/src/browser/routes/tabs.attach-only.test.ts +++ b/extensions/browser/src/browser/routes/tabs.attach-only.test.ts @@ -23,6 +23,7 @@ describe("browser tab routes attachOnly loopback profiles", () => { cdpPort: 9222, color: "#00AA00", driver: "openclaw", + headless: false, attachOnly: true, }, resolvedOverrides: { diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index ce14981668e..b6d64a9d511 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -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: { 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 10c6fb892a0..f19f50c2d68 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 @@ -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(); + }); }); diff --git a/extensions/browser/src/browser/server-context.list-profiles.test.ts b/extensions/browser/src/browser/server-context.list-profiles.test.ts index 9ed03a4b6de..be14a2d7366 100644 --- a/extensions/browser/src/browser/server-context.list-profiles.test.ts +++ b/extensions/browser/src/browser/server-context.list-profiles.test.ts @@ -41,6 +41,7 @@ describe("browser server-context listProfiles", () => { cdpPort: 9222, color: "#00AA00", driver: "openclaw", + headless: false, attachOnly: true, }, resolvedOverrides: { diff --git a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts index 43098865116..965b25a438c 100644 --- a/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts +++ b/extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts @@ -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, }; diff --git a/extensions/browser/src/browser/server-context.reset.test.ts b/extensions/browser/src/browser/server-context.reset.test.ts index dad080e6500..b427de09ea0 100644 --- a/extensions/browser/src/browser/server-context.reset.test.ts +++ b/extensions/browser/src/browser/server-context.reset.test.ts @@ -32,6 +32,7 @@ function localOpenClawProfile(): Parameters[0]["pr cdpPort: 18800, color: "#f60", driver: "openclaw", + headless: false, attachOnly: false, }; } diff --git a/extensions/browser/src/browser/server-context.test-harness.ts b/extensions/browser/src/browser/server-context.test-harness.ts index a1dd18cba74..fbec0483402 100644 --- a/extensions/browser/src/browser/server-context.test-harness.ts +++ b/extensions/browser/src/browser/server-context.test-harness.ts @@ -15,6 +15,7 @@ export function makeBrowserProfile( cdpPort: 18800, color: "#FF4500", driver: "openclaw", + headless: false, attachOnly: false, ...overrides, }; diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 4b175b42221..c433621c3eb 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -744,6 +744,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.', }, + headless: { + type: "boolean", + title: "Browser Profile Headless Mode", + 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.", + }, attachOnly: { type: "boolean", title: "Browser Profile Attach-only Mode", @@ -23950,6 +23956,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.', tags: ["storage"], }, + "browser.profiles.*.headless": { + label: "Browser Profile Headless Mode", + help: "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.", + tags: ["storage"], + }, "browser.profiles.*.attachOnly": { label: "Browser Profile Attach-only Mode", help: "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 2e5aeb9c2dd..115a66e7d1a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -282,6 +282,8 @@ export const FIELD_HELP: Record = { "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory on the selected host or browser node.", "browser.profiles.*.driver": 'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.', + "browser.profiles.*.headless": + "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.", "browser.profiles.*.attachOnly": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", "browser.profiles.*.color": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 6cc72b40d65..fe3a289bbef 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -152,6 +152,7 @@ export const FIELD_LABELS: Record = { "browser.profiles.*.cdpUrl": "Browser Profile CDP URL", "browser.profiles.*.userDataDir": "Browser Profile User Data Dir", "browser.profiles.*.driver": "Browser Profile Driver", + "browser.profiles.*.headless": "Browser Profile Headless Mode", "browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode", "browser.profiles.*.color": "Browser Profile Accent Color", tools: "Tools", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index a1b9b707439..ce1e18708f4 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -7,6 +7,8 @@ export type BrowserProfileConfig = { userDataDir?: string; /** Profile driver (default: openclaw). */ driver?: "openclaw" | "clawd" | "existing-session"; + /** If true, launch this profile in headless mode. Falls back to browser.headless. */ + headless?: boolean; /** 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 6ab57eed84a..66dd10dd346 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -409,6 +409,7 @@ export const OpenClawSchema = z driver: z .union([z.literal("openclaw"), z.literal("clawd"), z.literal("existing-session")]) .optional(), + headless: z.boolean().optional(), attachOnly: z.boolean().optional(), color: HexColorSchema, }) diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index 61791c47b75..bc2283e9152 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -43,6 +43,7 @@ export type ResolvedBrowserProfile = { userDataDir?: string; color: string; driver: "openclaw" | "existing-session"; + headless?: boolean; attachOnly: boolean; };