From 9b48e4c0b643a8afa51dc3adb659da757a473864 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 10:45:51 +0100 Subject: [PATCH] fix(browser): fall back to headless on Linux without display --- CHANGELOG.md | 1 + docs/cli/browser.md | 4 + docs/tools/browser-linux-troubleshooting.md | 27 +++-- docs/tools/browser.md | 8 ++ .../src/browser/chrome.internal.test.ts | 39 +++++- extensions/browser/src/browser/chrome.ts | 22 +++- .../browser/src/browser/client.types.ts | 7 ++ extensions/browser/src/browser/config.test.ts | 114 +++++++++++++++++- extensions/browser/src/browser/config.ts | 96 +++++++++++++++ extensions/browser/src/browser/doctor.test.ts | 40 ++++++ extensions/browser/src/browser/doctor.ts | 13 +- .../routes/basic.existing-session.test.ts | 66 ++++++++++ .../browser/src/browser/routes/basic.ts | 5 +- .../server-context.remote-tab-ops.harness.ts | 3 + .../src/cli/browser-cli-manage.test.ts | 2 + .../browser/src/cli/browser-cli-manage.ts | 3 + 16 files changed, 428 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb185648e8..d9609767ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing `every` values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys. - Control UI/chat: collapse assistant token/model context details behind an explicit Context disclosure and show full dates in message footers, making historical transcript timing clear without noisy default metadata. (#71337) Thanks @BunsDev. - OpenAI/Codex OAuth: explain `unsupported_country_region_territory` token-exchange failures with a proxy/region hint instead of surfacing a generic OAuth error. Fixes #51175. (#71501) Thanks @vincentkoc and @wulala-xjj. +- Browser/Linux: fall back to headless mode for local managed profiles on hosts without a display server, while preserving explicit per-profile headed overrides and reporting the headless source. (#60953) Thanks @rrpsantos. - Telegram: remove the startup persisted-offset `getUpdates` preflight so polling restarts do not self-conflict before the runner starts. Fixes #69304. (#69779) Thanks @chinar-amrutkar. - Telegram: keep the polling stall watchdog active even when grammY reports the runner as not running while its task is still pending, so a rebuilt transport cannot leave `getUpdates` silent until a manual gateway restart. Fixes #69064. Thanks @LDLoeb. - Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai. diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 790d74479b7..d0af509370e 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -67,6 +67,10 @@ Notes: OpenClaw did not launch the browser process itself. - For local managed profiles, `openclaw browser stop` stops the spawned browser process. +- On Linux hosts without `DISPLAY` or `WAYLAND_DISPLAY`, local managed profiles + run headless automatically unless `OPENCLAW_BROWSER_HEADLESS=0`, + `browser.headless=false`, or `browser.profiles..headless=false` + explicitly requests a visible browser. ## If the command is missing diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 9362d937bb7..dd00cc62414 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -31,9 +31,13 @@ Other common Linux launch failures: found stale `Singleton*` lock files in the managed profile directory. OpenClaw removes those locks and retries once when the lock points at a dead or different-host process. -- `Missing X server or $DISPLAY` means OpenClaw is trying to launch a visible - browser on a host without a desktop session. Use `browser.headless: true`, - start `Xvfb`, or run OpenClaw in a real desktop session. +- `Missing X server or $DISPLAY` means a visible browser was explicitly + requested on a host without a desktop session. By default, local managed + profiles now fall back to headless mode on Linux when `DISPLAY` and + `WAYLAND_DISPLAY` are both unset. If you set `OPENCLAW_BROWSER_HEADLESS=0`, + `browser.headless: false`, or `browser.profiles..headless: false`, + remove that headed override, set `OPENCLAW_BROWSER_HEADLESS=1`, start `Xvfb`, + or run OpenClaw in a real desktop session. ### Solution 1: Install Google Chrome (Recommended) @@ -120,14 +124,15 @@ curl -s http://127.0.0.1:18791/tabs ### Config Reference -| Option | Description | Default | -| ------------------------ | -------------------------------------------------------------------- | ----------------------------------------------------------- | -| `browser.enabled` | Enable browser control | `true` | -| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers default browser when Chromium-based) | -| `browser.headless` | Run without GUI | `false` | -| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` | -| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` | -| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` | +| Option | Description | Default | +| --------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------- | +| `browser.enabled` | Enable browser control | `true` | +| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers default browser when Chromium-based) | +| `browser.headless` | Run without GUI | `false` | +| `OPENCLAW_BROWSER_HEADLESS` | Per-process override for local managed browser headless mode | unset | +| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` | +| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` | +| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` | ### Problem: "No Chrome tabs found for profile=\"user\"" diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 0c3ceb0119b..720b582ab7a 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -194,6 +194,14 @@ 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. +- On Linux hosts without `DISPLAY` or `WAYLAND_DISPLAY`, local managed profiles + default to headless automatically when neither the environment nor profile/global + config explicitly chooses headed mode. `openclaw browser status --json` + reports `headlessSource` as `env`, `profile`, `config`, + `linux-display-fallback`, or `default`. +- `OPENCLAW_BROWSER_HEADLESS=1` forces local managed launches headless for the + current process. `OPENCLAW_BROWSER_HEADLESS=0` forces headed mode and returns + an actionable error on Linux hosts without a display server. - `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. diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index 1450c525d11..98a8b2bbe2e 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -171,6 +171,7 @@ describe("chrome.ts internal", () => { headless: false, noSandbox: false, extraArgs: [], + headlessSource: "default", ...overrides, }) as unknown as ResolvedBrowserConfig; @@ -180,13 +181,16 @@ describe("chrome.ts internal", () => { cdpPort: 19222, cdpUrl: "http://127.0.0.1:19222", cdpIsLoopback: true, + driver: "openclaw", headless: false, + headlessSource: "default", + attachOnly: false, } as unknown as ResolvedBrowserProfile; it("toggles headless args", () => { const args = buildOpenClawChromeLaunchArgs({ resolved: baseResolved({ headless: false }), - profile: { ...baseProfile, headless: true }, + profile: { ...baseProfile, headless: true, headlessSource: "profile" }, userDataDir: "/tmp/foo", }); expect(args).toContain("--headless=new"); @@ -195,14 +199,43 @@ describe("chrome.ts internal", () => { it("lets profile headless=false override global headless=true", () => { const args = buildOpenClawChromeLaunchArgs({ - resolved: baseResolved({ headless: true }), - profile: { ...baseProfile, headless: false }, + resolved: baseResolved({ headless: true, headlessSource: "config" }), + profile: { ...baseProfile, headless: false, headlessSource: "profile" }, userDataDir: "/tmp/foo", }); expect(args).not.toContain("--headless=new"); expect(args).not.toContain("--disable-gpu"); }); + it("adds headless args for Linux local managed profiles without a display", () => { + const args = buildOpenClawChromeLaunchArgs({ + resolved: baseResolved(), + profile: baseProfile, + userDataDir: "/tmp/foo", + platform: "linux", + env: { DISPLAY: undefined, WAYLAND_DISPLAY: undefined }, + }); + expect(args).toContain("--headless=new"); + expect(args).toContain("--disable-gpu"); + }); + + it("does not apply Linux no-display fallback to remote profiles", () => { + const args = buildOpenClawChromeLaunchArgs({ + resolved: baseResolved(), + profile: { + ...baseProfile, + cdpHost: "10.0.0.42", + cdpUrl: "http://10.0.0.42:9222", + cdpIsLoopback: false, + }, + userDataDir: "/tmp/foo", + platform: "linux", + env: { DISPLAY: undefined, WAYLAND_DISPLAY: undefined }, + }); + 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.ts b/extensions/browser/src/browser/chrome.ts index 49804210a4f..4301279f098 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -46,11 +46,17 @@ import { ensureProfileCleanExit, isProfileDecorated, } from "./chrome.profile-decoration.js"; -import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; +import { + getManagedBrowserMissingDisplayError, + resolveManagedBrowserHeadlessMode, + type ResolvedBrowserConfig, + type ResolvedBrowserProfile, +} from "./config.js"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, } from "./constants.js"; +import { BrowserProfileUnavailableError } from "./errors.js"; import { DEFAULT_DOWNLOAD_DIR } from "./paths.js"; const log = createSubsystemLogger("browser").child("chrome"); @@ -179,9 +185,10 @@ function chromeLaunchHints(params: { if (process.platform === "linux" && !params.resolved.noSandbox) { hints.push("If running in a container or as root, try setting browser.noSandbox: true."); } - if (CHROME_MISSING_DISPLAY_PATTERN.test(params.stderrOutput) && !params.profile.headless) { + const headlessMode = resolveManagedBrowserHeadlessMode(params.resolved, params.profile); + if (CHROME_MISSING_DISPLAY_PATTERN.test(params.stderrOutput) && !headlessMode.headless) { hints.push( - "No DISPLAY/X server was detected. Enable browser.headless: true, start Xvfb, or run the Gateway in a desktop session.", + "No DISPLAY/X server was detected. Set OPENCLAW_BROWSER_HEADLESS=1, remove the headed override, start Xvfb, or run the Gateway in a desktop session.", ); } if (CHROME_SINGLETON_IN_USE_PATTERN.test(params.stderrOutput)) { @@ -223,8 +230,11 @@ export function buildOpenClawChromeLaunchArgs(params: { resolved: ResolvedBrowserConfig; profile: ResolvedBrowserProfile; userDataDir: string; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; }): string[] { const { resolved, profile, userDataDir } = params; + const headlessMode = resolveManagedBrowserHeadlessMode(resolved, profile, params); const args: string[] = [ `--remote-debugging-port=${profile.cdpPort}`, `--user-data-dir=${userDataDir}`, @@ -239,7 +249,7 @@ export function buildOpenClawChromeLaunchArgs(params: { "--password-store=basic", ]; - if (profile.headless) { + if (headlessMode.headless) { args.push("--headless=new"); args.push("--disable-gpu"); } @@ -382,6 +392,10 @@ export async function launchOpenClawChrome( if (!profile.cdpIsLoopback) { throw new Error(`Profile "${profile.name}" is remote; cannot launch local Chrome.`); } + const missingDisplayError = getManagedBrowserMissingDisplayError(resolved, profile); + if (missingDisplayError) { + throw new BrowserProfileUnavailableError(missingDisplayError); + } await ensurePortAvailable(profile.cdpPort); const exe = resolveBrowserExecutable(resolved, profile); diff --git a/extensions/browser/src/browser/client.types.ts b/extensions/browser/src/browser/client.types.ts index f3b72f455ec..d144dce97f6 100644 --- a/extensions/browser/src/browser/client.types.ts +++ b/extensions/browser/src/browser/client.types.ts @@ -1,4 +1,10 @@ export type BrowserTransport = "cdp" | "chrome-mcp"; +export type BrowserHeadlessSource = + | "env" + | "profile" + | "config" + | "linux-display-fallback" + | "default"; export type BrowserStatus = { enabled: boolean; @@ -18,6 +24,7 @@ export type BrowserStatus = { userDataDir: string | null; color: string; headless: boolean; + headlessSource?: BrowserHeadlessSource; noSandbox?: boolean; executablePath?: string | null; attachOnly: boolean; diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index c0ab1cff1bf..cc8a4d3a794 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -3,7 +3,14 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import type { BrowserConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; -import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js"; +import { + getManagedBrowserMissingDisplayError, + OPENCLAW_BROWSER_HEADLESS_ENV, + resolveBrowserConfig, + resolveManagedBrowserHeadlessMode, + resolveProfile, + shouldStartLocalBrowserServer, +} from "./config.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; function withEnv(env: Record, fn: () => T): T { @@ -315,6 +322,111 @@ describe("browser config", () => { expect(remote?.headless).toBe(false); }); + describe("managed browser headless mode", () => { + const noDisplayEnv = { + DISPLAY: undefined, + WAYLAND_DISPLAY: undefined, + [OPENCLAW_BROWSER_HEADLESS_ENV]: undefined, + }; + + it("falls back to headless for local managed Linux profiles without display", () => { + const resolved = resolveBrowserConfig({}); + const profile = resolveProfile(resolved, "openclaw")!; + + expect( + resolveManagedBrowserHeadlessMode(resolved, profile, { + platform: "linux", + env: noDisplayEnv, + }), + ).toEqual({ headless: true, source: "linux-display-fallback" }); + }); + + it("does not apply the no-display fallback to remote CDP profiles", () => { + const resolved = resolveBrowserConfig({ + profiles: { + remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, + }, + }); + const profile = resolveProfile(resolved, "remote")!; + + expect( + resolveManagedBrowserHeadlessMode(resolved, profile, { + platform: "linux", + env: noDisplayEnv, + }), + ).toEqual({ headless: false, source: "default" }); + }); + + it("lets explicit profile headless=false beat the Linux no-display fallback", () => { + const resolved = resolveBrowserConfig({ + headless: true, + profiles: { + openclaw: { cdpPort: 18800, color: "#FF4500", headless: false }, + }, + }); + const profile = resolveProfile(resolved, "openclaw")!; + + expect( + resolveManagedBrowserHeadlessMode(resolved, profile, { + platform: "linux", + env: noDisplayEnv, + }), + ).toEqual({ headless: false, source: "profile" }); + }); + + it("lets explicit global headless=false beat the Linux no-display fallback", () => { + const resolved = resolveBrowserConfig({ headless: false }); + const profile = resolveProfile(resolved, "openclaw")!; + + expect( + resolveManagedBrowserHeadlessMode(resolved, profile, { + platform: "linux", + env: noDisplayEnv, + }), + ).toEqual({ headless: false, source: "config" }); + }); + + it("lets OPENCLAW_BROWSER_HEADLESS override profile/global config", () => { + const resolved = resolveBrowserConfig({ + profiles: { + openclaw: { cdpPort: 18800, color: "#FF4500", headless: false }, + }, + }); + const profile = resolveProfile(resolved, "openclaw")!; + + expect( + resolveManagedBrowserHeadlessMode(resolved, profile, { + platform: "linux", + env: { ...noDisplayEnv, [OPENCLAW_BROWSER_HEADLESS_ENV]: "1" }, + }), + ).toEqual({ headless: true, source: "env" }); + }); + + it("returns an actionable error only when headed mode is explicitly selected", () => { + const defaultResolved = resolveBrowserConfig({}); + const defaultProfile = resolveProfile(defaultResolved, "openclaw")!; + expect( + getManagedBrowserMissingDisplayError(defaultResolved, defaultProfile, { + platform: "linux", + env: noDisplayEnv, + }), + ).toBeNull(); + + const profileResolved = resolveBrowserConfig({ + profiles: { + openclaw: { cdpPort: 18800, color: "#FF4500", headless: false }, + }, + }); + const profile = resolveProfile(profileResolved, "openclaw")!; + expect( + getManagedBrowserMissingDisplayError(profileResolved, profile, { + platform: "linux", + env: noDisplayEnv, + }), + ).toContain("browser.profiles.openclaw.headless=false"); + }); + }); + it("inherits executablePath from global browser config when profile override is not set", () => { const resolved = resolveBrowserConfig({ executablePath: "~/bin/chrome-global", diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 2ce42664745..115508c4e7e 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -17,6 +17,7 @@ import { } from "../config/port-defaults.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { resolveUserPath } from "../utils.js"; +import { parseBooleanValue } from "../utils/boolean.js"; import { parseBrowserHttpUrl, redactCdpUrl, isLoopbackHost } from "./cdp.helpers.js"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS, @@ -72,6 +73,7 @@ export type ResolvedBrowserConfig = { color: string; executablePath?: string; headless: boolean; + headlessSource?: "config" | "default"; noSandbox: boolean; attachOnly: boolean; defaultProfile: string; @@ -99,10 +101,24 @@ export type ResolvedBrowserProfile = { driver: "openclaw" | "existing-session"; executablePath?: string; headless: boolean; + headlessSource?: "profile" | "config" | "default"; attachOnly: boolean; }; const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800; +export const OPENCLAW_BROWSER_HEADLESS_ENV = "OPENCLAW_BROWSER_HEADLESS"; + +export type ManagedBrowserHeadlessSource = + | "env" + | "profile" + | "config" + | "linux-display-fallback" + | "default"; + +export type ManagedBrowserHeadlessMode = { + headless: boolean; + source: ManagedBrowserHeadlessSource; +}; function normalizeHexColor(raw: string | undefined): string { const value = (raw ?? "").trim(); @@ -142,6 +158,14 @@ function normalizeExecutablePath(raw: string | undefined): string | undefined { return path.resolve(value.replace(/^~(?=$|[\\/])/, os.homedir())); } +function hasLinuxDisplay(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim()); +} + +function isLocalManagedProfile(profile: ResolvedBrowserProfile): boolean { + return profile.driver === "openclaw" && profile.cdpIsLoopback && !profile.attachOnly; +} + function resolveBrowserTabCleanupConfig( cfg: BrowserConfig | undefined, ): ResolvedBrowserTabCleanupConfig { @@ -306,6 +330,7 @@ export function resolveBrowserConfig( } const headless = cfg?.headless === true; + const headlessSource = typeof cfg?.headless === "boolean" ? "config" : "default"; const noSandbox = cfg?.noSandbox === true; const attachOnly = cfg?.attachOnly === true; const executablePath = normalizeExecutablePath(cfg?.executablePath); @@ -354,6 +379,7 @@ export function resolveBrowserConfig( color: defaultColor, executablePath, headless, + headlessSource, noSandbox, attachOnly, defaultProfile, @@ -379,6 +405,8 @@ export function resolveProfile( let cdpUrl = ""; const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw"; const headless = profile.headless ?? resolved.headless; + const headlessSource = + typeof profile.headless === "boolean" ? "profile" : resolved.headlessSource; const executablePath = normalizeExecutablePath(profile.executablePath) ?? resolved.executablePath; if (driver === "existing-session") { @@ -393,6 +421,7 @@ export function resolveProfile( driver, executablePath, headless, + headlessSource, attachOnly: true, }; } @@ -428,10 +457,77 @@ export function resolveProfile( driver, executablePath, headless, + headlessSource, attachOnly: profile.attachOnly ?? resolved.attachOnly, }; } +export function resolveManagedBrowserHeadlessMode( + resolved: ResolvedBrowserConfig, + profile: ResolvedBrowserProfile, + params: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + } = {}, +): ManagedBrowserHeadlessMode { + if (!isLocalManagedProfile(profile)) { + return { headless: profile.headless, source: profile.headlessSource ?? "default" }; + } + + const env = params.env ?? process.env; + const platform = params.platform ?? process.platform; + const envHeadless = parseBooleanValue(env[OPENCLAW_BROWSER_HEADLESS_ENV]); + if (envHeadless !== undefined) { + return { headless: envHeadless, source: "env" }; + } + + const profileHeadlessSource = profile.headlessSource ?? "default"; + if (profileHeadlessSource !== "default") { + return { headless: profile.headless, source: profileHeadlessSource }; + } + + if (platform === "linux" && !hasLinuxDisplay(env)) { + return { headless: true, source: "linux-display-fallback" }; + } + + return { headless: resolved.headless, source: "default" }; +} + +export function getManagedBrowserMissingDisplayError( + resolved: ResolvedBrowserConfig, + profile: ResolvedBrowserProfile, + params: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + } = {}, +): string | null { + if (!isLocalManagedProfile(profile)) { + return null; + } + const env = params.env ?? process.env; + const platform = params.platform ?? process.platform; + if (platform !== "linux" || hasLinuxDisplay(env)) { + return null; + } + + const mode = resolveManagedBrowserHeadlessMode(resolved, profile, { env, platform }); + if (mode.headless) { + return null; + } + + const sourceHint = + mode.source === "env" + ? `${OPENCLAW_BROWSER_HEADLESS_ENV}=0` + : mode.source === "profile" + ? `browser.profiles.${profile.name}.headless=false` + : "browser.headless=false"; + return ( + `Headed browser start requested for profile "${profile.name}" via ${sourceHint}, ` + + "but no Linux display server was detected ($DISPLAY/$WAYLAND_DISPLAY unset). " + + `Set ${OPENCLAW_BROWSER_HEADLESS_ENV}=1, remove the headed override, or launch under Xvfb.` + ); +} + export function shouldStartLocalBrowserServer(_resolved: unknown) { return true; } diff --git a/extensions/browser/src/browser/doctor.test.ts b/extensions/browser/src/browser/doctor.test.ts index ada9fc721bb..b82e0542118 100644 --- a/extensions/browser/src/browser/doctor.test.ts +++ b/extensions/browser/src/browser/doctor.test.ts @@ -93,6 +93,7 @@ describe("buildBrowserDoctorReport", () => { userDataDir: "/tmp/openclaw", color: "#FF4500", headless: false, + headlessSource: "config", noSandbox: false, executablePath: null, attachOnly: false, @@ -101,5 +102,44 @@ describe("buildBrowserDoctorReport", () => { expect(report.ok).toBe(true); expect(report.checks.some((check) => check.status === "warn")).toBe(true); + expect(report.checks.find((check) => check.id === "display")).toMatchObject({ + summary: "No DISPLAY or WAYLAND_DISPLAY is set while headed mode is selected (config)", + }); + }); + + it("reports Linux no-display fallback without a display warning", () => { + const report = buildBrowserDoctorReport({ + platform: "linux", + env: {}, + uid: 1000, + status: { + enabled: true, + profile: "openclaw", + driver: "openclaw", + transport: "cdp", + running: false, + cdpReady: false, + cdpHttp: false, + pid: null, + cdpPort: 18800, + cdpUrl: "http://127.0.0.1:18800", + chosenBrowser: null, + detectedBrowser: "chrome", + detectedExecutablePath: "/usr/bin/google-chrome-stable", + detectError: null, + userDataDir: "/tmp/openclaw", + color: "#FF4500", + headless: true, + headlessSource: "linux-display-fallback", + noSandbox: false, + executablePath: null, + attachOnly: false, + }, + }); + + expect(report.checks.find((check) => check.id === "headless-mode")).toMatchObject({ + status: "pass", + }); + expect(report.checks.find((check) => check.id === "display")).toBeUndefined(); }); }); diff --git a/extensions/browser/src/browser/doctor.ts b/extensions/browser/src/browser/doctor.ts index a840e11769b..1964303b14b 100644 --- a/extensions/browser/src/browser/doctor.ts +++ b/extensions/browser/src/browser/doctor.ts @@ -78,13 +78,22 @@ export function buildBrowserDoctorReport(params: { const uid = params.uid ?? process.getuid?.(); const missingDisplay = platform === "linux" && !status.headless && !env.DISPLAY && !env.WAYLAND_DISPLAY; + if (status.headlessSource === "linux-display-fallback") { + checks.push({ + id: "headless-mode", + label: "Headless mode", + status: "pass", + summary: "Linux no-display fallback selected headless mode", + }); + } if (missingDisplay) { checks.push({ id: "display", label: "Display", status: "warn", - summary: "No DISPLAY or WAYLAND_DISPLAY is set while browser.headless is false", - fixHint: "Use a desktop session, Xvfb, or set browser.headless: true.", + summary: `No DISPLAY or WAYLAND_DISPLAY is set while headed mode is selected (${status.headlessSource ?? "unknown"})`, + fixHint: + "Use a desktop session, Xvfb, set OPENCLAW_BROWSER_HEADLESS=1, or remove the headed override.", }); } if (platform === "linux" && uid === 0 && !status.noSandbox) { 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 345b9b49988..a5cc98aee1b 100644 --- a/extensions/browser/src/browser/routes/basic.existing-session.test.ts +++ b/extensions/browser/src/browser/routes/basic.existing-session.test.ts @@ -41,6 +41,38 @@ function createExistingSessionProfileState(params?: { }; } +function createManagedProfileState() { + return { + resolved: { + enabled: true, + headless: false, + headlessSource: "default", + noSandbox: false, + executablePath: undefined, + }, + profiles: new Map(), + forProfile: () => + ({ + profile: { + name: "openclaw", + driver: "openclaw", + cdpPort: 18800, + cdpUrl: "http://127.0.0.1:18800", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + userDataDir: "/tmp/openclaw-profile", + color: "#FF4500", + headless: false, + headlessSource: "default", + attachOnly: false, + }, + isHttpReachable: async () => false, + isTransportAvailable: async () => false, + isReachable: async () => false, + }) as never, + }; +} + async function callBasicRouteWithState(params: { query?: Record; state: ReturnType; @@ -60,6 +92,40 @@ async function callBasicRouteWithState(params: { } describe("basic browser routes", () => { + it("reports Linux no-display headless fallback for local managed profiles", async () => { + const originalPlatform = process.platform; + const originalDisplay = process.env.DISPLAY; + const originalWayland = process.env.WAYLAND_DISPLAY; + Object.defineProperty(process, "platform", { value: "linux" }); + delete process.env.DISPLAY; + delete process.env.WAYLAND_DISPLAY; + try { + const response = await callBasicRouteWithState({ + query: { profile: "openclaw" }, + state: createManagedProfileState(), + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ + profile: "openclaw", + headless: true, + headlessSource: "linux-display-fallback", + }); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + if (originalDisplay === undefined) { + delete process.env.DISPLAY; + } else { + process.env.DISPLAY = originalDisplay; + } + if (originalWayland === undefined) { + delete process.env.WAYLAND_DISPLAY; + } else { + process.env.WAYLAND_DISPLAY = originalWayland; + } + } + }); + it("maps existing-session status failures to JSON browser errors", async () => { const response = await callBasicRouteWithState({ state: createExistingSessionProfileState({ diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index aa42235e7e1..b7aeb4cee85 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -1,5 +1,6 @@ import { getChromeMcpPid } from "../chrome-mcp.js"; import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js"; +import { resolveManagedBrowserHeadlessMode } from "../config.js"; import { buildBrowserDoctorReport } from "../doctor.js"; import { BrowserError, toBrowserErrorResponse } from "../errors.js"; import { getBrowserProfileCapabilities } from "../profile-capabilities.js"; @@ -83,6 +84,7 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext) } catch (err) { detectError = String(err); } + const headlessMode = resolveManagedBrowserHeadlessMode(current.resolved, profileCtx.profile); return { enabled: current.resolved.enabled, @@ -103,7 +105,8 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext) detectError, userDataDir: profileState?.running?.userDataDir ?? profileCtx.profile.userDataDir ?? null, color: profileCtx.profile.color, - headless: profileCtx.profile.headless, + headless: headlessMode.headless, + headlessSource: headlessMode.source, noSandbox: current.resolved.noSandbox, executablePath: profileCtx.profile.executablePath ?? null, attachOnly: profileCtx.profile.attachOnly, 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 776e85ac1ad..9cddde0ca86 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 @@ -29,6 +29,7 @@ export function makeState( extraArgs: [], color: "#FF4500", headless: true, + headlessSource: "config", noSandbox: false, attachOnly: false, ssrfPolicy: { allowPrivateNetwork: true }, @@ -85,6 +86,8 @@ function resolveProfileForTest( color: rawProfile.color ?? state.resolved.color, driver: rawProfile.driver === "existing-session" ? "existing-session" : "openclaw", headless: rawProfile.headless ?? state.resolved.headless, + headlessSource: + typeof rawProfile.headless === "boolean" ? "profile" : state.resolved.headlessSource, attachOnly: rawProfile.attachOnly ?? state.resolved.attachOnly, userDataDir: rawProfile.userDataDir, }; diff --git a/extensions/browser/src/cli/browser-cli-manage.test.ts b/extensions/browser/src/cli/browser-cli-manage.test.ts index e0406fa9d17..8479ff07c01 100644 --- a/extensions/browser/src/cli/browser-cli-manage.test.ts +++ b/extensions/browser/src/cli/browser-cli-manage.test.ts @@ -29,6 +29,7 @@ describe("browser manage output", () => { userDataDir: null, color: "#00AA00", headless: false, + headlessSource: "default", noSandbox: false, executablePath: null, attachOnly: true, @@ -43,6 +44,7 @@ describe("browser manage output", () => { const output = getBrowserCliRuntime().log.mock.calls.at(-1)?.[0] as string; expect(output).toContain("transport: chrome-mcp"); + expect(output).toContain("headless: false (default)"); expect(output).not.toContain("cdpPort:"); expect(output).not.toContain("cdpUrl:"); }); diff --git a/extensions/browser/src/cli/browser-cli-manage.ts b/extensions/browser/src/cli/browser-cli-manage.ts index 8606d3eaf84..9ee27a603e9 100644 --- a/extensions/browser/src/cli/browser-cli-manage.ts +++ b/extensions/browser/src/cli/browser-cli-manage.ts @@ -268,6 +268,9 @@ export function registerBrowserManageCommands( `browser: ${status.chosenBrowser ?? "unknown"}`, `detectedBrowser: ${status.detectedBrowser ?? "unknown"}`, `detectedPath: ${detectedDisplay}`, + `headless: ${status.headless}${ + status.headlessSource ? ` (${status.headlessSource})` : "" + }`, `profileColor: ${status.color}`, ...(status.detectError ? [`detectError: ${status.detectError}`] : []), ].join("\n"),