mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
feat(browser): add one-shot headless start override
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type BrowserTransport = "cdp" | "chrome-mcp";
|
||||
export type BrowserHeadlessSource =
|
||||
| "request"
|
||||
| "env"
|
||||
| "profile"
|
||||
| "config"
|
||||
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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). " +
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 }> => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user