mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
fix(browser): fall back to headless on Linux without display
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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\""
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user