feat(browser): add one-shot headless start override

This commit is contained in:
Peter Steinberger
2026-04-25 11:34:44 +01:00
parent 51e6f9c27e
commit c52ec520c7
18 changed files with 412 additions and 43 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Browser/CLI: add `openclaw browser start --headless` as a one-shot local managed browser launch override without rewriting persisted browser config. Thanks @BenediktSchackenberg.
- CLI/Crestodian: open interactive Crestodian in the full OpenClaw TUI shell instead of a basic readline prompt.
- CLI/Crestodian: shorten the startup greeting to the active planner/model, config state, Gateway probe result, and next debug action instead of dumping every discovered backend.
- Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna.

View File

@@ -56,6 +56,7 @@ Detailed guidance: [Browser troubleshooting](/tools/browser#cdp-startup-failure-
openclaw browser status
openclaw browser doctor
openclaw browser start
openclaw browser start --headless
openclaw browser stop
openclaw browser --browser-profile openclaw reset-profile
```
@@ -67,6 +68,10 @@ Notes:
OpenClaw did not launch the browser process itself.
- For local managed profiles, `openclaw browser stop` stops the spawned browser
process.
- `openclaw browser start --headless` applies only to that start request and
only when OpenClaw launches a local managed browser. It does not rewrite
`browser.headless` or profile config, and it is a no-op for an already-running
browser.
- 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`

View File

@@ -194,14 +194,20 @@ 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.
- `POST /start?headless=true` and `openclaw browser start --headless` request a
one-shot headless launch for local managed profiles without rewriting
`browser.headless` or profile config. Existing-session, attach-only, and
remote CDP profiles reject the override because OpenClaw does not launch those
browser processes.
- 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`.
`request`, `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.
current process. `OPENCLAW_BROWSER_HEADLESS=0` forces headed mode for ordinary
starts and returns an actionable error on Linux hosts without a display server;
an explicit `start --headless` request still wins for that one launch.
- `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

@@ -207,6 +207,18 @@ describe("chrome.ts internal", () => {
expect(args).not.toContain("--disable-gpu");
});
it("lets a request headless override beat env and profile headed settings", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: baseResolved({ headless: false, headlessSource: "config" }),
profile: { ...baseProfile, headless: false, headlessSource: "profile" },
userDataDir: "/tmp/foo",
headlessOverride: true,
env: { OPENCLAW_BROWSER_HEADLESS: "0" },
});
expect(args).toContain("--headless=new");
expect(args).toContain("--disable-gpu");
});
it("adds headless args for Linux local managed profiles without a display", () => {
const args = buildOpenClawChromeLaunchArgs({
resolved: baseResolved(),

View File

@@ -49,6 +49,8 @@ import {
import {
getManagedBrowserMissingDisplayError,
resolveManagedBrowserHeadlessMode,
type ManagedBrowserHeadlessOptions,
type ManagedBrowserHeadlessSource,
type ResolvedBrowserConfig,
type ResolvedBrowserProfile,
} from "./config.js";
@@ -180,12 +182,17 @@ function chromeLaunchHints(params: {
stderrOutput: string;
resolved: ResolvedBrowserConfig;
profile: ResolvedBrowserProfile;
launchOptions?: ManagedBrowserHeadlessOptions;
}): string {
const hints: string[] = [];
if (process.platform === "linux" && !params.resolved.noSandbox) {
hints.push("If running in a container or as root, try setting browser.noSandbox: true.");
}
const headlessMode = resolveManagedBrowserHeadlessMode(params.resolved, params.profile);
const headlessMode = resolveManagedBrowserHeadlessMode(
params.resolved,
params.profile,
params.launchOptions,
);
if (CHROME_MISSING_DISPLAY_PATTERN.test(params.stderrOutput) && !headlessMode.headless) {
hints.push(
"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.",
@@ -206,6 +213,8 @@ export type RunningChrome = {
cdpPort: number;
startedAt: number;
proc: ChildProcess;
headless?: boolean;
headlessSource?: ManagedBrowserHeadlessSource;
};
function resolveBrowserExecutable(
@@ -230,6 +239,7 @@ export function buildOpenClawChromeLaunchArgs(params: {
resolved: ResolvedBrowserConfig;
profile: ResolvedBrowserProfile;
userDataDir: string;
headlessOverride?: boolean;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
}): string[] {
@@ -388,11 +398,17 @@ export async function isChromeCdpReady(
export async function launchOpenClawChrome(
resolved: ResolvedBrowserConfig,
profile: ResolvedBrowserProfile,
launchOptions: ManagedBrowserHeadlessOptions = {},
): Promise<RunningChrome> {
if (!profile.cdpIsLoopback) {
throw new Error(`Profile "${profile.name}" is remote; cannot launch local Chrome.`);
}
const missingDisplayError = getManagedBrowserMissingDisplayError(resolved, profile);
const headlessMode = resolveManagedBrowserHeadlessMode(resolved, profile, launchOptions);
const missingDisplayError = getManagedBrowserMissingDisplayError(
resolved,
profile,
launchOptions,
);
if (missingDisplayError) {
throw new BrowserProfileUnavailableError(missingDisplayError);
}
@@ -422,6 +438,7 @@ export async function launchOpenClawChrome(
resolved,
profile,
userDataDir,
...launchOptions,
});
// stdio tuple: discard stdout to prevent buffer saturation in constrained
// environments (e.g. Docker), while keeping stderr piped for diagnostics.
@@ -531,7 +548,7 @@ export async function launchOpenClawChrome(
const stderrHint = stderrOutput
? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}`
: "";
const launchHints = chromeLaunchHints({ stderrOutput, resolved, profile });
const launchHints = chromeLaunchHints({ stderrOutput, resolved, profile, launchOptions });
try {
proc.kill("SIGKILL");
} catch {
@@ -554,6 +571,8 @@ export async function launchOpenClawChrome(
cdpPort: profile.cdpPort,
startedAt,
proc,
headless: headlessMode.headless,
headlessSource: headlessMode.source,
};
} finally {
// Chrome started successfully or launch failed — detach the stderr listener

View File

@@ -1,5 +1,6 @@
export type BrowserTransport = "cdp" | "chrome-mcp";
export type BrowserHeadlessSource =
| "request"
| "env"
| "profile"
| "config"

View File

@@ -402,6 +402,24 @@ describe("browser config", () => {
).toEqual({ headless: true, source: "env" });
});
it("lets request-local headless override beat env and profile/global config", () => {
const resolved = resolveBrowserConfig({
headless: false,
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500", headless: false },
},
});
const profile = resolveProfile(resolved, "openclaw")!;
expect(
resolveManagedBrowserHeadlessMode(resolved, profile, {
headlessOverride: true,
platform: "linux",
env: { ...noDisplayEnv, [OPENCLAW_BROWSER_HEADLESS_ENV]: "0" },
}),
).toEqual({ headless: true, source: "request" });
});
it("returns an actionable error only when headed mode is explicitly selected", () => {
const defaultResolved = resolveBrowserConfig({});
const defaultProfile = resolveProfile(defaultResolved, "openclaw")!;

View File

@@ -109,6 +109,7 @@ const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800;
export const OPENCLAW_BROWSER_HEADLESS_ENV = "OPENCLAW_BROWSER_HEADLESS";
export type ManagedBrowserHeadlessSource =
| "request"
| "env"
| "profile"
| "config"
@@ -120,6 +121,12 @@ export type ManagedBrowserHeadlessMode = {
source: ManagedBrowserHeadlessSource;
};
export type ManagedBrowserHeadlessOptions = {
headlessOverride?: boolean;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
};
function normalizeHexColor(raw: string | undefined): string {
const value = (raw ?? "").trim();
if (!value) {
@@ -465,15 +472,16 @@ export function resolveProfile(
export function resolveManagedBrowserHeadlessMode(
resolved: ResolvedBrowserConfig,
profile: ResolvedBrowserProfile,
params: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
} = {},
params: ManagedBrowserHeadlessOptions = {},
): ManagedBrowserHeadlessMode {
if (!isLocalManagedProfile(profile)) {
return { headless: profile.headless, source: profile.headlessSource ?? "default" };
}
if (typeof params.headlessOverride === "boolean") {
return { headless: params.headlessOverride, source: "request" };
}
const env = params.env ?? process.env;
const platform = params.platform ?? process.platform;
const envHeadless = parseBooleanValue(env[OPENCLAW_BROWSER_HEADLESS_ENV]);
@@ -496,10 +504,7 @@ export function resolveManagedBrowserHeadlessMode(
export function getManagedBrowserMissingDisplayError(
resolved: ResolvedBrowserConfig,
profile: ResolvedBrowserProfile,
params: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
} = {},
params: ManagedBrowserHeadlessOptions = {},
): string | null {
if (!isLocalManagedProfile(profile)) {
return null;
@@ -516,11 +521,13 @@ export function getManagedBrowserMissingDisplayError(
}
const sourceHint =
mode.source === "env"
? `${OPENCLAW_BROWSER_HEADLESS_ENV}=0`
: mode.source === "profile"
? `browser.profiles.${profile.name}.headless=false`
: "browser.headless=false";
mode.source === "request"
? "request override"
: 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). " +

View File

@@ -91,6 +91,43 @@ async function callBasicRouteWithState(params: {
return response;
}
async function callStartRoute(params: {
profile?: Record<string, unknown>;
query?: Record<string, unknown>;
}) {
const ensureBrowserAvailable = vi.fn(async () => {});
const 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,
...params.profile,
};
const { app, postHandlers } = createBrowserRouteApp();
registerBrowserBasicRoutes(app, {
state: () => ({ resolved: { enabled: true, headless: false }, profiles: new Map() }),
forProfile: () =>
({
profile,
ensureBrowserAvailable,
}) as never,
} as never);
const handler = postHandlers.get("/start");
expect(handler).toBeTypeOf("function");
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: params.query ?? {} }, response.res);
return { response, ensureBrowserAvailable };
}
describe("basic browser routes", () => {
it("reports Linux no-display headless fallback for local managed profiles", async () => {
const originalPlatform = process.platform;
@@ -126,6 +163,38 @@ describe("basic browser routes", () => {
}
});
it("reports request-local headless source for tracked local launches", async () => {
const state = createManagedProfileState();
const profile = (state.forProfile() as { profile: unknown }).profile as never;
state.profiles.set("openclaw", {
profile,
running: {
pid: 222,
exe: { kind: "chromium", path: "/usr/bin/chromium" },
userDataDir: "/tmp/openclaw-profile",
cdpPort: 18800,
startedAt: Date.now(),
proc: {} as never,
headless: true,
headlessSource: "request",
},
});
const response = await callBasicRouteWithState({
query: { profile: "openclaw" },
state,
});
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({
profile: "openclaw",
pid: 222,
chosenBrowser: "chromium",
headless: true,
headlessSource: "request",
});
});
it("maps existing-session status failures to JSON browser errors", async () => {
const response = await callBasicRouteWithState({
state: createExistingSessionProfileState({
@@ -158,6 +227,50 @@ describe("basic browser routes", () => {
});
});
it("passes valid start headless override to local managed profiles", async () => {
const { response, ensureBrowserAvailable } = await callStartRoute({
query: { headless: "true" },
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ ok: true, profile: "openclaw" });
expect(ensureBrowserAvailable).toHaveBeenCalledWith({ headless: true });
});
it("rejects invalid start headless values", async () => {
const { response, ensureBrowserAvailable } = await callStartRoute({
query: { headless: "maybe" },
});
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
error: 'Invalid headless value. Use "true" or "false".',
});
expect(ensureBrowserAvailable).not.toHaveBeenCalled();
});
it("rejects start headless override for existing-session profiles", async () => {
const { response, ensureBrowserAvailable } = await callStartRoute({
profile: {
name: "chrome-live",
driver: "existing-session",
cdpPort: 0,
cdpUrl: "",
cdpHost: "",
cdpIsLoopback: true,
attachOnly: true,
},
query: { headless: "true" },
});
expect(response.statusCode).toBe(400);
expect(response.body).toMatchObject({
error:
'Headless start override is only supported for locally launched openclaw profiles. Profile "chrome-live" is attach-only, remote, or existing-session.',
});
expect(ensureBrowserAvailable).not.toHaveBeenCalled();
});
it("treats attach-only profiles as running when transport is available even if page reachability is false", async () => {
const response = await callBasicRouteWithState({
state: createExistingSessionProfileState({

View File

@@ -8,7 +8,13 @@ import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveProfileContext } from "./agent.shared.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { asyncBrowserRoute, getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
import {
asyncBrowserRoute,
getProfileContext,
jsonError,
toBoolean,
toStringOrEmpty,
} from "./utils.js";
function handleBrowserRouteError(res: BrowserResponse, err: unknown) {
const mapped = toBrowserErrorResponse(err);
@@ -84,7 +90,17 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext)
} catch (err) {
detectError = String(err);
}
const headlessMode = resolveManagedBrowserHeadlessMode(current.resolved, profileCtx.profile);
const configuredHeadlessMode = resolveManagedBrowserHeadlessMode(
current.resolved,
profileCtx.profile,
);
const headlessMode =
typeof profileState?.running?.headless === "boolean"
? {
headless: profileState.running.headless,
source: profileState.running.headlessSource ?? configuredHeadlessMode.source,
}
: configuredHeadlessMode;
return {
enabled: current.resolved.enabled,
@@ -113,6 +129,42 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext)
};
}
function hasQueryKey(query: BrowserRequest["query"], key: string): boolean {
return Object.prototype.hasOwnProperty.call(query ?? {}, key);
}
function parseHeadlessStartOverride(params: {
req: BrowserRequest;
res: BrowserResponse;
profileCtx: ProfileContext;
}): { ok: true; headless?: boolean } | { ok: false } {
if (!hasQueryKey(params.req.query, "headless")) {
return { ok: true };
}
const headless = toBoolean(params.req.query.headless);
if (typeof headless !== "boolean") {
jsonError(params.res, 400, 'Invalid headless value. Use "true" or "false".');
return { ok: false };
}
const capabilities = getBrowserProfileCapabilities(params.profileCtx.profile);
if (
params.profileCtx.profile.driver !== "openclaw" ||
params.profileCtx.profile.attachOnly ||
capabilities.isRemote
) {
jsonError(
params.res,
400,
`Headless start override is only supported for locally launched openclaw profiles. Profile "${params.profileCtx.profile.name}" is attach-only, remote, or existing-session.`,
);
return { ok: false };
}
return { ok: true, headless };
}
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
// List all profiles with their status
app.get(
@@ -169,7 +221,11 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
res,
ctx,
run: async (profileCtx) => {
await profileCtx.ensureBrowserAvailable();
const headlessOverride = parseHeadlessStartOverride({ req, res, profileCtx });
if (!headlessOverride.ok) {
return;
}
await profileCtx.ensureBrowserAvailable({ headless: headlessOverride.headless });
res.json({ ok: true, profile: profileCtx.profile.name });
},
});

View File

@@ -48,10 +48,24 @@ type AvailabilityOps = {
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isTransportAvailable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
ensureBrowserAvailable: () => Promise<void>;
ensureBrowserAvailable: (opts?: { headless?: boolean }) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
};
type BrowserEnsureOptions = {
headless?: boolean;
};
function launchOptionsForEnsure(options?: BrowserEnsureOptions) {
return typeof options?.headless === "boolean"
? { headlessOverride: options.headless }
: undefined;
}
function ensureOptionsKey(options?: BrowserEnsureOptions): string {
return typeof options?.headless === "boolean" ? `headless:${options.headless}` : "default";
}
export function createProfileAvailability({
opts,
profile,
@@ -213,9 +227,9 @@ export function createProfileAvailability({
throw new BrowserProfileUnavailableError(formatChromeMcpAttachFailure(lastError));
};
let inflightEnsureBrowserAvailable: Promise<void> | null = null;
let inflightEnsureBrowserAvailable: { key: string; promise: Promise<void> } | null = null;
const ensureBrowserAvailableOnce = async (): Promise<void> => {
const ensureBrowserAvailableOnce = async (options?: BrowserEnsureOptions): Promise<void> => {
await reconcileProfileRuntime();
if (capabilities.usesChromeMcp) {
if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) {
@@ -233,6 +247,7 @@ export function createProfileAvailability({
const attachOnly = profile.attachOnly;
const profileState = getProfileState();
const httpReachable = await isHttpReachable();
const launchOptions = launchOptionsForEnsure(options);
if (!httpReachable) {
if ((attachOnly || remoteCdp) && opts.onEnsureAttachTarget) {
@@ -259,7 +274,7 @@ export function createProfileAvailability({
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
);
}
const launched = await launchOpenClawChrome(current.resolved, profile);
const launched = await launchOpenClawChrome(current.resolved, profile, launchOptions);
attachRunning(launched);
try {
await waitForCdpReadyAfterLaunch();
@@ -308,7 +323,7 @@ export function createProfileAvailability({
await stopOpenClawChrome(profileState.running);
setProfileRunning(null);
const relaunched = await launchOpenClawChrome(current.resolved, profile);
const relaunched = await launchOpenClawChrome(current.resolved, profile, launchOptions);
attachRunning(relaunched);
if (!(await isReachable(PROFILE_POST_RESTART_WS_TIMEOUT_MS))) {
@@ -320,14 +335,25 @@ export function createProfileAvailability({
}
};
const ensureBrowserAvailable = async (): Promise<void> => {
if (inflightEnsureBrowserAvailable) {
return inflightEnsureBrowserAvailable;
const ensureBrowserAvailable = async (options?: BrowserEnsureOptions): Promise<void> => {
const key = ensureOptionsKey(options);
for (;;) {
const current = inflightEnsureBrowserAvailable;
if (!current) {
break;
}
if (current.key === key) {
return current.promise;
}
await current.promise.catch(() => {});
}
inflightEnsureBrowserAvailable = ensureBrowserAvailableOnce().finally(() => {
inflightEnsureBrowserAvailable = null;
const promise = ensureBrowserAvailableOnce(options).finally(() => {
if (inflightEnsureBrowserAvailable?.promise === promise) {
inflightEnsureBrowserAvailable = null;
}
});
return inflightEnsureBrowserAvailable;
inflightEnsureBrowserAvailable = { key, promise };
return promise;
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {

View File

@@ -1,3 +1,5 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import "./server-context.chrome-test-harness.js";
import {
@@ -103,6 +105,67 @@ describe("browser server-context ensureBrowserAvailable", () => {
expect(stopOpenClawChrome).not.toHaveBeenCalled();
});
it("passes request-local headless override to initial launch", async () => {
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
setupEnsureBrowserAvailableHarness();
isChromeCdpReady.mockResolvedValue(true);
mockLaunchedChrome(launchOpenClawChrome, 654);
const promise = profile.ensureBrowserAvailable({ headless: true });
await vi.advanceTimersByTimeAsync(100);
await expect(promise).resolves.toBeUndefined();
expect(launchOpenClawChrome).toHaveBeenCalledTimes(1);
expect(launchOpenClawChrome.mock.calls[0]?.[2]).toEqual({ headlessOverride: true });
expect(stopOpenClawChrome).not.toHaveBeenCalled();
});
it("passes request-local headless override to the owned restart path", async () => {
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } =
setupEnsureBrowserAvailableHarness();
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
const existingProc = new EventEmitter() as unknown as ChildProcessWithoutNullStreams;
state.profiles.set("openclaw", {
profile: profile.profile,
running: {
pid: 111,
exe: { kind: "chromium", path: "/usr/bin/chromium" },
userDataDir: "/tmp/openclaw-test",
cdpPort: 18800,
startedAt: Date.now(),
proc: existingProc,
},
lastTargetId: null,
reconcile: null,
});
isChromeReachable.mockResolvedValue(true);
isChromeCdpReady.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
mockLaunchedChrome(launchOpenClawChrome, 987);
await expect(profile.ensureBrowserAvailable({ headless: true })).resolves.toBeUndefined();
expect(stopOpenClawChrome).toHaveBeenCalledTimes(1);
expect(launchOpenClawChrome).toHaveBeenCalledTimes(1);
expect(launchOpenClawChrome.mock.calls[0]?.[2]).toEqual({ headlessOverride: true });
});
it("does not share inflight lazy-start promises across different headless overrides", async () => {
const { launchOpenClawChrome, isChromeCdpReady, profile } =
setupEnsureBrowserAvailableHarness();
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
isChromeReachable.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
isChromeCdpReady.mockResolvedValue(true);
mockLaunchedChrome(launchOpenClawChrome, 456);
const first = profile.ensureBrowserAvailable();
const second = profile.ensureBrowserAvailable({ headless: true });
await vi.advanceTimersByTimeAsync(100);
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
expect(launchOpenClawChrome).toHaveBeenCalledTimes(1);
expect(isChromeReachable.mock.calls.length).toBeGreaterThan(1);
});
it("clears the concurrent lazy-start guard after launch failure", async () => {
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
setupEnsureBrowserAvailableHarness();

View File

@@ -15,7 +15,7 @@ type SelectionDeps = {
profile: ResolvedBrowserProfile;
getProfileState: () => ProfileRuntimeState;
getCdpControlPolicy: () => SsrFPolicy | undefined;
ensureBrowserAvailable: () => Promise<void>;
ensureBrowserAvailable: (opts?: { headless?: boolean }) => Promise<void>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
};

View File

@@ -33,7 +33,7 @@ export type BrowserServerState = {
};
type BrowserProfileActions = {
ensureBrowserAvailable: () => Promise<void>;
ensureBrowserAvailable: (opts?: { headless?: boolean }) => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isTransportAvailable: (timeoutMs?: number) => Promise<boolean>;

View File

@@ -1,6 +1,7 @@
export const browserCoreExamples = [
"openclaw browser status",
"openclaw browser start",
"openclaw browser start --headless",
"openclaw browser stop",
"openclaw browser tabs",
"openclaw browser open https://example.com",

View File

@@ -4,7 +4,10 @@ import * as parentCoreApiModule from "../core-api.js";
import * as browserCliSharedModule from "./browser-cli-shared.js";
import * as cliCoreApiModule from "./core-api.js";
type BrowserRequest = { path?: string };
type BrowserRequest = {
path?: string;
query?: Record<string, string | number | boolean | undefined>;
};
type BrowserRuntimeOptions = { timeoutMs?: number };
export type BrowserManageCall = [unknown, BrowserRequest, BrowserRuntimeOptions | undefined];

View File

@@ -22,6 +22,24 @@ describe("browser manage start timeout option", () => {
expect(startCall?.[2]).toBeUndefined();
});
it("passes headless=true for browser start --headless", async () => {
const program = createBrowserManageProgram({ withParentTimeout: true });
await program.parseAsync(["browser", "start", "--headless"], { from: "user" });
const startCall = findBrowserManageCall("/start");
expect(startCall?.[1]?.query).toEqual({ headless: true });
});
it("combines browser profile with browser start --headless", async () => {
const program = createBrowserManageProgram({ withParentTimeout: true });
await program.parseAsync(["browser", "--browser-profile", "work", "start", "--headless"], {
from: "user",
});
const startCall = findBrowserManageCall("/start");
expect(startCall?.[1]?.query).toEqual({ profile: "work", headless: true });
});
it("uses a longer built-in timeout for browser status", async () => {
const program = createBrowserManageProgram({ withParentTimeout: true });
await program.parseAsync(["browser", "status"], { from: "user" });

View File

@@ -24,8 +24,18 @@ type BrowserDoctorCheck = {
detail?: string;
};
function resolveProfileQuery(profile?: string) {
return profile ? { profile } : undefined;
function resolveProfileQuery(
profile?: string,
extra?: Record<string, string | number | boolean | undefined>,
) {
const query: Record<string, string | number | boolean | undefined> = {};
if (profile) {
query.profile = profile;
}
if (extra) {
Object.assign(query, extra);
}
return Object.keys(query).length > 0 ? query : undefined;
}
function printJsonResult(parent: BrowserParentOpts, payload: unknown): boolean {
@@ -75,19 +85,24 @@ async function fetchBrowserStatus(
async function runBrowserToggle(
parent: BrowserParentOpts,
params: { profile?: string; path: string },
params: {
profile?: string;
path: string;
query?: Record<string, string | number | boolean | undefined>;
},
) {
await callBrowserRequest(parent, {
method: "POST",
path: params.path,
query: resolveProfileQuery(params.profile),
query: resolveProfileQuery(params.profile, params.query),
});
const status = await fetchBrowserStatus(parent, params.profile);
if (printJsonResult(parent, status)) {
return;
}
const name = status.profile ?? "openclaw";
defaultRuntime.log(info(`🦞 browser [${name}] running: ${status.running}`));
const headlessLabel = params.path === "/start" && status.headless ? " (headless)" : "";
defaultRuntime.log(info(`🦞 browser [${name}] running: ${status.running}${headlessLabel}`));
}
function runBrowserCommand(action: () => Promise<void>) {
@@ -299,11 +314,16 @@ export function registerBrowserManageCommands(
browser
.command("start")
.description("Start the browser (no-op if already running)")
.action(async (_opts, cmd) => {
.option("--headless", "Launch a local managed browser headless for this start")
.action(async (opts: { headless?: boolean }, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
await runBrowserCommand(async () => {
await runBrowserToggle(parent, { profile, path: "/start" });
await runBrowserToggle(parent, {
profile,
path: "/start",
query: opts.headless ? { headless: true } : undefined,
});
});
});