From c52ec520c77cf0e0606bae7124019c8610d2dc6d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 11:34:44 +0100 Subject: [PATCH] feat(browser): add one-shot headless start override --- CHANGELOG.md | 1 + docs/cli/browser.md | 5 + docs/tools/browser.md | 12 +- .../src/browser/chrome.internal.test.ts | 12 ++ extensions/browser/src/browser/chrome.ts | 25 +++- .../browser/src/browser/client.types.ts | 1 + extensions/browser/src/browser/config.test.ts | 18 +++ extensions/browser/src/browser/config.ts | 33 +++-- .../routes/basic.existing-session.test.ts | 113 ++++++++++++++++++ .../browser/src/browser/routes/basic.ts | 62 +++++++++- .../browser/server-context.availability.ts | 48 ++++++-- ...wser-available.waits-for-cdp-ready.test.ts | 63 ++++++++++ .../src/browser/server-context.selection.ts | 2 +- .../src/browser/server-context.types.ts | 2 +- .../browser/src/cli/browser-cli-examples.ts | 1 + .../cli/browser-cli-manage.test-helpers.ts | 5 +- .../browser-cli-manage.timeout-option.test.ts | 18 +++ .../browser/src/cli/browser-cli-manage.ts | 34 ++++-- 18 files changed, 412 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f59a617a5..7f31d0f9bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/browser.md b/docs/cli/browser.md index d0af509370e..962d8c7fc6b 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -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..headless=false` diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 720b582ab7a..02e80c58e53 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -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. diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index 98a8b2bbe2e..3f95d2a8b1f 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -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(), diff --git a/extensions/browser/src/browser/chrome.ts b/extensions/browser/src/browser/chrome.ts index 4301279f098..12e33a5b040 100644 --- a/extensions/browser/src/browser/chrome.ts +++ b/extensions/browser/src/browser/chrome.ts @@ -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 { 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 diff --git a/extensions/browser/src/browser/client.types.ts b/extensions/browser/src/browser/client.types.ts index d144dce97f6..68894081c3a 100644 --- a/extensions/browser/src/browser/client.types.ts +++ b/extensions/browser/src/browser/client.types.ts @@ -1,5 +1,6 @@ export type BrowserTransport = "cdp" | "chrome-mcp"; export type BrowserHeadlessSource = + | "request" | "env" | "profile" | "config" diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index cc8a4d3a794..17550515163 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -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")!; diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index 115508c4e7e..4ba3c4e10a7 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -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). " + 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 a5cc98aee1b..7276696bcb8 100644 --- a/extensions/browser/src/browser/routes/basic.existing-session.test.ts +++ b/extensions/browser/src/browser/routes/basic.existing-session.test.ts @@ -91,6 +91,43 @@ async function callBasicRouteWithState(params: { return response; } +async function callStartRoute(params: { + profile?: Record; + query?: Record; +}) { + 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({ diff --git a/extensions/browser/src/browser/routes/basic.ts b/extensions/browser/src/browser/routes/basic.ts index b7aeb4cee85..8b852cfa0a6 100644 --- a/extensions/browser/src/browser/routes/basic.ts +++ b/extensions/browser/src/browser/routes/basic.ts @@ -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 }); }, }); diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 480bf048b5a..4a868a749dc 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -48,10 +48,24 @@ type AvailabilityOps = { isHttpReachable: (timeoutMs?: number) => Promise; isTransportAvailable: (timeoutMs?: number) => Promise; isReachable: (timeoutMs?: number) => Promise; - ensureBrowserAvailable: () => Promise; + ensureBrowserAvailable: (opts?: { headless?: boolean }) => Promise; 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 | null = null; + let inflightEnsureBrowserAvailable: { key: string; promise: Promise } | null = null; - const ensureBrowserAvailableOnce = async (): Promise => { + const ensureBrowserAvailableOnce = async (options?: BrowserEnsureOptions): Promise => { 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 => { - if (inflightEnsureBrowserAvailable) { - return inflightEnsureBrowserAvailable; + const ensureBrowserAvailable = async (options?: BrowserEnsureOptions): Promise => { + 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 }> => { diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index 9a044053834..4be27509a4e 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -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(); diff --git a/extensions/browser/src/browser/server-context.selection.ts b/extensions/browser/src/browser/server-context.selection.ts index 7b799adf66e..c15ea8cdeb4 100644 --- a/extensions/browser/src/browser/server-context.selection.ts +++ b/extensions/browser/src/browser/server-context.selection.ts @@ -15,7 +15,7 @@ type SelectionDeps = { profile: ResolvedBrowserProfile; getProfileState: () => ProfileRuntimeState; getCdpControlPolicy: () => SsrFPolicy | undefined; - ensureBrowserAvailable: () => Promise; + ensureBrowserAvailable: (opts?: { headless?: boolean }) => Promise; listTabs: () => Promise; openTab: (url: string) => Promise; }; diff --git a/extensions/browser/src/browser/server-context.types.ts b/extensions/browser/src/browser/server-context.types.ts index 8be63c52426..bb4cfd16dfe 100644 --- a/extensions/browser/src/browser/server-context.types.ts +++ b/extensions/browser/src/browser/server-context.types.ts @@ -33,7 +33,7 @@ export type BrowserServerState = { }; type BrowserProfileActions = { - ensureBrowserAvailable: () => Promise; + ensureBrowserAvailable: (opts?: { headless?: boolean }) => Promise; ensureTabAvailable: (targetId?: string) => Promise; isHttpReachable: (timeoutMs?: number) => Promise; isTransportAvailable: (timeoutMs?: number) => Promise; diff --git a/extensions/browser/src/cli/browser-cli-examples.ts b/extensions/browser/src/cli/browser-cli-examples.ts index de621a80f60..084f94041d7 100644 --- a/extensions/browser/src/cli/browser-cli-examples.ts +++ b/extensions/browser/src/cli/browser-cli-examples.ts @@ -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", diff --git a/extensions/browser/src/cli/browser-cli-manage.test-helpers.ts b/extensions/browser/src/cli/browser-cli-manage.test-helpers.ts index 5cad4190cb1..53d0dc24d04 100644 --- a/extensions/browser/src/cli/browser-cli-manage.test-helpers.ts +++ b/extensions/browser/src/cli/browser-cli-manage.test-helpers.ts @@ -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; +}; type BrowserRuntimeOptions = { timeoutMs?: number }; export type BrowserManageCall = [unknown, BrowserRequest, BrowserRuntimeOptions | undefined]; diff --git a/extensions/browser/src/cli/browser-cli-manage.timeout-option.test.ts b/extensions/browser/src/cli/browser-cli-manage.timeout-option.test.ts index c07c58f8d79..44c6c21f271 100644 --- a/extensions/browser/src/cli/browser-cli-manage.timeout-option.test.ts +++ b/extensions/browser/src/cli/browser-cli-manage.timeout-option.test.ts @@ -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" }); diff --git a/extensions/browser/src/cli/browser-cli-manage.ts b/extensions/browser/src/cli/browser-cli-manage.ts index 9ee27a603e9..67ae940bba0 100644 --- a/extensions/browser/src/cli/browser-cli-manage.ts +++ b/extensions/browser/src/cli/browser-cli-manage.ts @@ -24,8 +24,18 @@ type BrowserDoctorCheck = { detail?: string; }; -function resolveProfileQuery(profile?: string) { - return profile ? { profile } : undefined; +function resolveProfileQuery( + profile?: string, + extra?: Record, +) { + const query: Record = {}; + 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; + }, ) { 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) { @@ -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, + }); }); });