From c97764346081b45f8beca458aab92ad15e7ec8de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 13:07:01 +0100 Subject: [PATCH] perf(browser): precompute browser help --- CHANGELOG.md | 1 + openclaw.mjs | 31 +++++-- scripts/write-cli-startup-metadata.ts | 82 ++++++++++++++++++- src/cli/root-help-metadata.ts | 46 +++++++++-- src/cli/run-main.exit.test.ts | 27 ++++++ src/cli/run-main.test.ts | 15 ++++ src/cli/run-main.ts | 24 +++++- .../write-cli-startup-metadata.test.ts | 3 + 8 files changed, 212 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0b0309d741..1daf1b5e34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Subagents: fall back to direct completion delivery when the parent announce turn finishes without a visible payload, so child results still reach channel-backed requester sessions. - Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai. - Browser/CLI: lazy-load browser command groups and plugin runtime services so `openclaw browser --help` can render without loading the full browser automation stack. Fixes #65400. (#65460, #66640) Thanks @pandego and @Tianworld. +- Browser/CLI: serve precomputed `openclaw browser --help` text from CLI startup metadata, avoiding the full plugin/config startup path for the common help invocation. - Browser/downloads: seed managed Chrome profiles with OpenClaw download prefs and capture unmanaged click-triggered downloads under the guarded downloads directory, while explicit download waiters still own their target file. (#64558) Thanks @Pearcekieser. - Browser/Chrome: stop passing redundant `--disable-setuid-sandbox` when `browser.noSandbox` is enabled; `--no-sandbox` remains the effective sandbox opt-out. (#67939) Thanks @sebykrueger. - Browser/client: stop telling agents to permanently avoid the browser after transient timeout or cancellation failures; keep the no-retry hint for persistent unavailable/rate-limit cases. (#46505) Thanks @jriff. diff --git a/openclaw.mjs b/openclaw.mjs index 01a74302112..62bea9e9487 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -128,13 +128,18 @@ const buildMissingEntryErrorMessage = async () => { const isBareRootHelpInvocation = (argv) => argv.length === 3 && (argv[2] === "--help" || argv[2] === "-h"); -const loadPrecomputedRootHelpText = () => { +const isBrowserHelpInvocation = (argv) => + argv.length === 4 && argv[2] === "browser" && (argv[3] === "--help" || argv[3] === "-h"); + +const isHelpFastPathDisabled = () => + process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH === "1"; + +const loadPrecomputedHelpText = (key) => { try { const raw = readFileSync(new URL("./dist/cli-startup-metadata.json", import.meta.url), "utf8"); const parsed = JSON.parse(raw); - return typeof parsed?.rootHelpText === "string" && parsed.rootHelpText.length > 0 - ? parsed.rootHelpText - : null; + const value = parsed?.[key]; + return typeof value === "string" && value.length > 0 ? value : null; } catch { return null; } @@ -144,7 +149,7 @@ const tryOutputBareRootHelp = async () => { if (!isBareRootHelpInvocation(process.argv)) { return false; } - const precomputed = loadPrecomputedRootHelpText(); + const precomputed = loadPrecomputedHelpText("rootHelpText"); if (precomputed) { process.stdout.write(precomputed); return true; @@ -166,7 +171,21 @@ const tryOutputBareRootHelp = async () => { return false; }; -if (await tryOutputBareRootHelp()) { +const tryOutputBrowserHelp = () => { + if (!isBrowserHelpInvocation(process.argv)) { + return false; + } + const precomputed = loadPrecomputedHelpText("browserHelpText"); + if (!precomputed) { + return false; + } + process.stdout.write(precomputed); + return true; +}; + +if (!isHelpFastPathDisabled() && (await tryOutputBareRootHelp())) { + // OK +} else if (!isHelpFastPathDisabled() && tryOutputBrowserHelp()) { // OK } else { await installProcessWarningFilter(); diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts index 646306de234..e0927d4f1fc 100644 --- a/scripts/write-cli-startup-metadata.ts +++ b/scripts/write-cli-startup-metadata.ts @@ -26,6 +26,7 @@ const distDir = path.join(rootDir, "dist"); const outputPath = path.join(distDir, "cli-startup-metadata.json"); const extensionsDir = path.join(rootDir, "extensions"); const ROOT_HELP_RENDER_TIMEOUT_MS = 120_000; +const BROWSER_HELP_RENDER_TIMEOUT_MS = 120_000; const CORE_CHANNEL_ORDER = [ "telegram", "whatsapp", @@ -70,6 +71,29 @@ function resolveRootHelpBundleIdentity( }; } +function updateHashFromFiles(hash: ReturnType, files: string[]) { + for (const file of files.toSorted()) { + hash.update(`${path.relative(rootDir, file)}\0`); + hash.update(readFileSync(file)); + hash.update("\0"); + } +} + +function resolveBrowserHelpSourceSignature(): string { + const hash = createHash("sha1"); + const browserCliDir = path.join(rootDir, "extensions/browser/src/cli"); + const browserCliFiles = readdirSync(browserCliDir) + .filter((entry) => entry.endsWith(".ts")) + .map((entry) => path.join(browserCliDir, entry)); + updateHashFromFiles(hash, browserCliFiles); + updateHashFromFiles(hash, [ + path.join(rootDir, "src/cli/program/help.ts"), + path.join(rootDir, "src/cli/program/context.ts"), + path.join(rootDir, "src/cli/banner.ts"), + ]); + return hash.digest("hex"); +} + export function readBundledChannelCatalog( extensionsDirOverride: string = extensionsDir, ): BundledChannelCatalog { @@ -245,6 +269,53 @@ function renderSourceRootHelpText( return result.stdout ?? ""; } +function renderSourceBrowserHelpText( + renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(), +): string { + const browserCliUrl = pathToFileURL( + path.join(rootDir, "extensions/browser/src/cli/browser-cli.ts"), + ).href; + const helpUrl = pathToFileURL(path.join(rootDir, "src/cli/program/help.ts")).href; + const contextUrl = pathToFileURL(path.join(rootDir, "src/cli/program/context.ts")).href; + const inlineModule = [ + `const { Command } = await import("commander");`, + `const { registerBrowserCli } = await import(${JSON.stringify(browserCliUrl)});`, + `const { configureProgramHelp } = await import(${JSON.stringify(helpUrl)});`, + `const { createProgramContext } = await import(${JSON.stringify(contextUrl)});`, + `const program = new Command();`, + `configureProgramHelp(program, createProgramContext());`, + `registerBrowserCli(program, ["node", "openclaw", "browser", "--help"]);`, + `const browser = program.commands.find((cmd) => cmd.name() === "browser");`, + `if (!browser) throw new Error("Browser command was not registered.");`, + `browser.outputHelp();`, + "process.exit(0);", + ].join("\n"); + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", inlineModule], + { + cwd: rootDir, + encoding: "utf8", + env: { + ...renderContext.env, + OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH: "1", + }, + timeout: BROWSER_HELP_RENDER_TIMEOUT_MS, + }, + ); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + throw new Error( + "Failed to render source browser help" + + (stderr ? `: ${stderr}` : result.signal ? `: terminated by ${result.signal}` : ""), + ); + } + return result.stdout ?? ""; +} + export async function writeCliStartupMetadata(options?: { distDir?: string; outputPath?: string; @@ -255,6 +326,7 @@ export async function writeCliStartupMetadata(options?: { const resolvedExtensionsDir = options?.extensionsDir ?? extensionsDir; const channelCatalog = readBundledChannelCatalog(resolvedExtensionsDir); const bundleIdentity = resolveRootHelpBundleIdentity(resolvedDistDir); + const browserHelpSourceSignature = resolveBrowserHelpSourceSignature(); const bundledPluginsDir = path.join(resolvedDistDir, "extensions"); const renderContext = createIsolatedRootHelpRenderContext( existsSync(bundledPluginsDir) ? bundledPluginsDir : resolvedExtensionsDir, @@ -264,12 +336,17 @@ export async function writeCliStartupMetadata(options?: { try { const existing = JSON.parse(readFileSync(resolvedOutputPath, "utf8")) as { rootHelpBundleSignature?: unknown; + browserHelpSourceSignature?: unknown; channelCatalogSignature?: unknown; + browserHelpText?: unknown; }; if ( bundleIdentity && existing.rootHelpBundleSignature === bundleIdentity.signature && - existing.channelCatalogSignature === channelCatalog.signature + existing.browserHelpSourceSignature === browserHelpSourceSignature && + existing.channelCatalogSignature === channelCatalog.signature && + typeof existing.browserHelpText === "string" && + existing.browserHelpText.length > 0 ) { return; } @@ -283,6 +360,7 @@ export async function writeCliStartupMetadata(options?: { } catch { rootHelpText = renderSourceRootHelpText(renderContext); } + const browserHelpText = renderSourceBrowserHelpText(renderContext); mkdirSync(resolvedDistDir, { recursive: true }); writeFileSync( @@ -293,6 +371,8 @@ export async function writeCliStartupMetadata(options?: { channelOptions, channelCatalogSignature: channelCatalog.signature, rootHelpBundleSignature: bundleIdentity?.signature ?? null, + browserHelpSourceSignature, + browserHelpText, rootHelpText, }, null, diff --git a/src/cli/root-help-metadata.ts b/src/cli/root-help-metadata.ts index 5089790a924..2bd4431d663 100644 --- a/src/cli/root-help-metadata.ts +++ b/src/cli/root-help-metadata.ts @@ -3,10 +3,15 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; let precomputedRootHelpText: string | null | undefined; +let precomputedBrowserHelpText: string | null | undefined; -export function loadPrecomputedRootHelpText(): string | null { - if (precomputedRootHelpText !== undefined) { - return precomputedRootHelpText; +function loadPrecomputedHelpText( + key: "rootHelpText" | "browserHelpText", + cache: string | null | undefined, + setCache: (value: string | null) => void, +): string | null { + if (cache !== undefined) { + return cache; } try { const metadataPath = path.resolve( @@ -15,18 +20,31 @@ export function loadPrecomputedRootHelpText(): string | null { "cli-startup-metadata.json", ); const raw = fs.readFileSync(metadataPath, "utf8"); - const parsed = JSON.parse(raw) as { rootHelpText?: unknown }; - if (typeof parsed.rootHelpText === "string" && parsed.rootHelpText.length > 0) { - precomputedRootHelpText = parsed.rootHelpText; - return precomputedRootHelpText; + const parsed = JSON.parse(raw) as Record; + const value = parsed[key]; + if (typeof value === "string" && value.length > 0) { + setCache(value); + return value; } } catch { - // Fall back to live root-help rendering. + // Fall back to live help rendering. } - precomputedRootHelpText = null; + setCache(null); return null; } +export function loadPrecomputedRootHelpText(): string | null { + return loadPrecomputedHelpText("rootHelpText", precomputedRootHelpText, (value) => { + precomputedRootHelpText = value; + }); +} + +export function loadPrecomputedBrowserHelpText(): string | null { + return loadPrecomputedHelpText("browserHelpText", precomputedBrowserHelpText, (value) => { + precomputedBrowserHelpText = value; + }); +} + export function outputPrecomputedRootHelpText(): boolean { const rootHelpText = loadPrecomputedRootHelpText(); if (!rootHelpText) { @@ -36,8 +54,18 @@ export function outputPrecomputedRootHelpText(): boolean { return true; } +export function outputPrecomputedBrowserHelpText(): boolean { + const browserHelpText = loadPrecomputedBrowserHelpText(); + if (!browserHelpText) { + return false; + } + process.stdout.write(browserHelpText); + return true; +} + export const __testing = { resetPrecomputedRootHelpTextForTests(): void { precomputedRootHelpText = undefined; + precomputedBrowserHelpText = undefined; }, }; diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 803d2e0f9ee..d34fa63dbf3 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -14,6 +14,7 @@ const ensureTaskRegistryReadyMock = vi.hoisted(() => vi.fn()); const startTaskRegistryMaintenanceMock = vi.hoisted(() => vi.fn()); const outputRootHelpMock = vi.hoisted(() => vi.fn()); const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false)); +const outputPrecomputedBrowserHelpTextMock = vi.hoisted(() => vi.fn(() => false)); const buildProgramMock = vi.hoisted(() => vi.fn()); const getProgramContextMock = vi.hoisted(() => vi.fn(() => null)); const registerCoreCliByNameMock = vi.hoisted(() => vi.fn()); @@ -71,6 +72,7 @@ vi.mock("./program/root-help.js", () => ({ })); vi.mock("./root-help-metadata.js", () => ({ + outputPrecomputedBrowserHelpText: outputPrecomputedBrowserHelpTextMock, outputPrecomputedRootHelpText: outputPrecomputedRootHelpTextMock, })); @@ -98,8 +100,10 @@ describe("runCli exit behavior", () => { beforeEach(() => { vi.clearAllMocks(); hasMemoryRuntimeMock.mockReturnValue(false); + outputPrecomputedBrowserHelpTextMock.mockReturnValue(false); outputPrecomputedRootHelpTextMock.mockReturnValue(false); getProgramContextMock.mockReturnValue(null); + delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH; }); it("does not force process.exit after successful routed command", async () => { @@ -119,6 +123,29 @@ describe("runCli exit behavior", () => { exitSpy.mockRestore(); }); + it("renders browser help from startup metadata without building the full program", async () => { + outputPrecomputedBrowserHelpTextMock.mockReturnValueOnce(true); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`unexpected process.exit(${String(code)})`); + }) as typeof process.exit); + + await runCli(["node", "openclaw", "browser", "--help"]); + + expect(maybeRunCliInContainerMock).toHaveBeenCalledWith([ + "node", + "openclaw", + "browser", + "--help", + ]); + expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(outputPrecomputedBrowserHelpTextMock).toHaveBeenCalledTimes(1); + expect(outputRootHelpMock).not.toHaveBeenCalled(); + expect(buildProgramMock).not.toHaveBeenCalled(); + expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + it("renders root help without building the full program", async () => { const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`unexpected process.exit(${String(code)})`); diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 70f4914f157..6265c53b65b 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -6,6 +6,7 @@ import { shouldEnsureCliPath, shouldStartCrestodianForBareRoot, shouldStartCrestodianForModernOnboard, + shouldUseBrowserHelpFastPath, shouldUseRootHelpFastPath, } from "./run-main.js"; @@ -131,6 +132,20 @@ describe("shouldUseRootHelpFastPath", () => { }); }); +describe("shouldUseBrowserHelpFastPath", () => { + it("uses the fast path for browser command help only", () => { + expect(shouldUseBrowserHelpFastPath(["node", "openclaw", "browser", "--help"])).toBe(true); + expect(shouldUseBrowserHelpFastPath(["node", "openclaw", "browser", "-h"])).toBe(true); + expect( + shouldUseBrowserHelpFastPath(["node", "openclaw", "--profile", "work", "browser", "-h"]), + ).toBe(true); + expect(shouldUseBrowserHelpFastPath(["node", "openclaw", "browser", "status", "--help"])).toBe( + false, + ); + expect(shouldUseBrowserHelpFastPath(["node", "openclaw", "status", "--help"])).toBe(false); + }); +}); + describe("resolveMissingPluginCommandMessage", () => { it("explains plugins.allow misses for a bundled plugin command", () => { expect( diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 8dbff0aeaae..e0d5a5a43ec 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -68,7 +68,22 @@ export function shouldEnsureCliPath(argv: string[]): boolean { } export function shouldUseRootHelpFastPath(argv: string[]): boolean { - return resolveCliArgvInvocation(argv).isRootHelpInvocation; + return ( + process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH !== "1" && + resolveCliArgvInvocation(argv).isRootHelpInvocation + ); +} + +export function shouldUseBrowserHelpFastPath(argv: string[]): boolean { + if (process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH === "1") { + return false; + } + const invocation = resolveCliArgvInvocation(argv); + return ( + invocation.commandPath.length === 1 && + invocation.commandPath[0] === "browser" && + invocation.hasHelpOrVersion + ); } export function shouldStartCrestodianForBareRoot(argv: string[]): boolean { @@ -217,6 +232,13 @@ export async function runCli(argv: string[] = process.argv) { return; } + if (shouldUseBrowserHelpFastPath(normalizedArgv)) { + const { outputPrecomputedBrowserHelpText } = await import("./root-help-metadata.js"); + if (outputPrecomputedBrowserHelpText()) { + return; + } + } + if (shouldStartCrestodianForBareRoot(normalizedArgv)) { if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error( diff --git a/test/scripts/write-cli-startup-metadata.test.ts b/test/scripts/write-cli-startup-metadata.test.ts index 106c889a85a..4043b4d4432 100644 --- a/test/scripts/write-cli-startup-metadata.test.ts +++ b/test/scripts/write-cli-startup-metadata.test.ts @@ -32,10 +32,13 @@ describe("write-cli-startup-metadata", () => { await writeCliStartupMetadata({ distDir, outputPath, extensionsDir }); const written = JSON.parse(readFileSync(outputPath, "utf8")) as { + browserHelpText: string; channelOptions: string[]; rootHelpText: string; }; expect(written.channelOptions).toContain("matrix"); + expect(written.browserHelpText).toContain("Usage:"); + expect(written.browserHelpText).toContain("openclaw browser"); expect(written.rootHelpText).toContain("Usage:"); expect(written.rootHelpText).toContain("openclaw"); });