fix(browser): fall back to headless on Linux without display

This commit is contained in:
Peter Steinberger
2026-04-25 10:45:51 +01:00
parent b5a1b7d44d
commit 9b48e4c0b6
16 changed files with 428 additions and 22 deletions

View File

@@ -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.

View File

@@ -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.<name>.headless=false`
explicitly requests a visible browser.
## If the command is missing

View File

@@ -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.<name>.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\""

View File

@@ -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.

View File

@@ -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 }),

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<T>(env: Record<string, string | undefined>, 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",

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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) {

View File

@@ -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<string, string>;
state: ReturnType<typeof createExistingSessionProfileState>;
@@ -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({

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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:");
});

View File

@@ -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"),