From cf772079c6bca0b54447a8fb2ff5826587e6be83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Apr 2026 15:35:16 +0100 Subject: [PATCH] fix(browser): share control runtime state --- CHANGELOG.md | 1 + .../browser/src/browser-control-state.ts | 70 +++++++++ .../src/browser/config-refresh-source.ts | 8 +- extensions/browser/src/config/config.ts | 1 + extensions/browser/src/control-service.ts | 44 +++--- ...owser-request.shared-control-state.test.ts | 145 ++++++++++++++++++ extensions/browser/src/sdk-config.ts | 1 + extensions/browser/src/server.ts | 37 ++--- 8 files changed, 262 insertions(+), 45 deletions(-) create mode 100644 extensions/browser/src/browser-control-state.ts create mode 100644 extensions/browser/src/gateway/browser-request.shared-control-state.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a855e961439..d030ba11345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Auto-reply/group chats: fall back to automatic source delivery when a channel precomputes message-tool-only replies but the `message` tool is unavailable, so Discord/Slack-style group turns do not silently complete without a visible reply. Fixes #74868. Thanks @kagura-agent. +- Browser/gateway: share one browser control runtime across the HTTP control server and `browser.request`, and refresh browser profile config from the source snapshot, so CLI status/start honors configured `browser.executablePath`, `headless`, and `noSandbox` instead of falling back to stale auto-detection. Fixes #75087; repairs #73617. Thanks @civiltox and @martingarramon. - Agents/subagents: bound automatic orphan recovery with persisted recovery attempts and a wedged-session tombstone, and teach task maintenance/doctor to reconcile those sessions so restart loops no longer require manual `sessions.json` surgery. Fixes #74864. Thanks @solosage1. - Plugins/runtime-deps: keep bundled provider policy config loading from staging plugin runtime dependencies, so config reads no longer fail on locked-down `/var/lib/openclaw/plugin-runtime-deps` directories. Fixes #74971. Thanks @eurojojo. - Memory/runtime-deps: retain the native `node-llama-cpp` runtime only when local memory search is configured, so packaged installs can repair local embeddings without relying on unreachable global npm installs. Fixes #74777. Thanks @LLagoon3. diff --git a/extensions/browser/src/browser-control-state.ts b/extensions/browser/src/browser-control-state.ts new file mode 100644 index 00000000000..f0d632ee10c --- /dev/null +++ b/extensions/browser/src/browser-control-state.ts @@ -0,0 +1,70 @@ +import type { Server } from "node:http"; +import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js"; +import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js"; + +type BrowserControlOwner = "server" | "service"; + +let state: BrowserServerState | null = null; +let owner: BrowserControlOwner | null = null; + +export function getBrowserControlState(): BrowserServerState | null { + return state; +} + +export function createBrowserControlContext() { + return createBrowserRouteContext({ + getState: () => state, + refreshConfigFromDisk: true, + }); +} + +export async function ensureBrowserControlRuntime(params: { + server?: Server | null; + port: number; + resolved: BrowserServerState["resolved"]; + owner: BrowserControlOwner; + onWarn: (message: string) => void; +}): Promise { + if (state) { + if (params.server) { + state.server = params.server; + state.port = params.port; + state.resolved = { ...params.resolved, controlPort: params.port }; + owner = "server"; + } + return state; + } + + state = await createBrowserRuntimeState({ + server: params.server ?? null, + port: params.port, + resolved: params.resolved, + onWarn: params.onWarn, + }); + owner = params.owner; + return state; +} + +export async function stopBrowserControlRuntime(params: { + requestedBy: BrowserControlOwner; + closeServer?: boolean; + onWarn: (message: string) => void; +}): Promise { + const current = state; + if (!current) { + return; + } + if (params.requestedBy === "service" && current.server && owner === "server") { + return; + } + await stopBrowserRuntime({ + current, + getState: () => state, + clearState: () => { + state = null; + owner = null; + }, + closeServer: params.closeServer, + onWarn: params.onWarn, + }); +} diff --git a/extensions/browser/src/browser/config-refresh-source.ts b/extensions/browser/src/browser/config-refresh-source.ts index a9a3c0150b3..3538d6011f0 100644 --- a/extensions/browser/src/browser/config-refresh-source.ts +++ b/extensions/browser/src/browser/config-refresh-source.ts @@ -1,5 +1,9 @@ -import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; +import { + getRuntimeConfig, + getRuntimeConfigSourceSnapshot, + type OpenClawConfig, +} from "../config/config.js"; export function loadBrowserConfigForRuntimeRefresh(): OpenClawConfig { - return getRuntimeConfig(); + return getRuntimeConfigSourceSnapshot() ?? getRuntimeConfig(); } diff --git a/extensions/browser/src/config/config.ts b/extensions/browser/src/config/config.ts index 30f8194af71..839417b31b0 100644 --- a/extensions/browser/src/config/config.ts +++ b/extensions/browser/src/config/config.ts @@ -1,6 +1,7 @@ export { getRuntimeConfig, getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, replaceConfigFile, type BrowserConfig, type BrowserProfileConfig, diff --git a/extensions/browser/src/control-service.ts b/extensions/browser/src/control-service.ts index 0b42621415a..f7e81721557 100644 --- a/extensions/browser/src/control-service.ts +++ b/extensions/browser/src/control-service.ts @@ -1,36 +1,32 @@ +import { + createBrowserControlContext, + ensureBrowserControlRuntime, + getBrowserControlState, + stopBrowserControlRuntime, +} from "./browser-control-state.js"; +import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js"; import { resolveBrowserConfig } from "./browser/config.js"; import { ensureBrowserControlAuth } from "./browser/control-auth.js"; -import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js"; -import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js"; +import type { BrowserServerState } from "./browser/server-context.js"; import { getRuntimeConfig } from "./config/config.js"; import { createSubsystemLogger } from "./logging/subsystem.js"; import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js"; -let state: BrowserServerState | null = null; const log = createSubsystemLogger("browser"); const logService = log.child("service"); -export function getBrowserControlState(): BrowserServerState | null { - return state; -} - -export function createBrowserControlContext() { - return createBrowserRouteContext({ - getState: () => state, - refreshConfigFromDisk: true, - }); -} - export async function startBrowserControlServiceFromConfig(): Promise { - if (state) { - return state; + const current = getBrowserControlState(); + if (current) { + return current; } const cfg = getRuntimeConfig(); if (!isDefaultBrowserPluginEnabled(cfg)) { return null; } - const resolved = resolveBrowserConfig(cfg.browser, cfg); + const browserCfg = loadBrowserConfigForRuntimeRefresh(); + const resolved = resolveBrowserConfig(browserCfg.browser, browserCfg); if (!resolved.enabled) { return null; } @@ -43,10 +39,11 @@ export async function startBrowserControlServiceFromConfig(): Promise logService.warn(message), }); @@ -57,13 +54,10 @@ export async function startBrowserControlServiceFromConfig(): Promise { - const current = state; - await stopBrowserRuntime({ - current, - getState: () => state, - clearState: () => { - state = null; - }, + await stopBrowserControlRuntime({ + requestedBy: "service", onWarn: (message) => logService.warn(message), }); } + +export { createBrowserControlContext, getBrowserControlState }; diff --git a/extensions/browser/src/gateway/browser-request.shared-control-state.test.ts b/extensions/browser/src/gateway/browser-request.shared-control-state.test.ts new file mode 100644 index 00000000000..efe676ca612 --- /dev/null +++ b/extensions/browser/src/gateway/browser-request.shared-control-state.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getFreePort } from "../browser/test-port.js"; +import type { OpenClawConfig } from "../config/config.js"; + +const mocks = vi.hoisted(() => ({ + runtimeConfig: {} as OpenClawConfig, + runtimeSourceConfig: null as OpenClawConfig | null, + ensureBrowserControlAuth: vi.fn(async () => ({ auth: {} })), + resolveBrowserControlAuth: vi.fn(() => ({})), + shouldAutoGenerateBrowserAuth: vi.fn(() => false), + ensureExtensionRelayForProfiles: vi.fn(async () => {}), + stopKnownBrowserProfiles: vi.fn(async () => {}), + isChromeReachable: vi.fn(async () => false), + isChromeCdpReady: vi.fn(async () => false), +})); + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + getRuntimeConfig: () => mocks.runtimeConfig, + getRuntimeConfigSourceSnapshot: () => mocks.runtimeSourceConfig, + loadConfig: () => mocks.runtimeConfig, + }; +}); + +vi.mock("../browser/control-auth.js", () => ({ + ensureBrowserControlAuth: mocks.ensureBrowserControlAuth, + resolveBrowserControlAuth: mocks.resolveBrowserControlAuth, + shouldAutoGenerateBrowserAuth: mocks.shouldAutoGenerateBrowserAuth, +})); + +vi.mock("../browser/server-lifecycle.js", () => ({ + ensureExtensionRelayForProfiles: mocks.ensureExtensionRelayForProfiles, + stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles, +})); + +vi.mock("../browser/chrome.js", () => ({ + diagnoseChromeCdp: vi.fn(async () => ({ ok: false })), + formatChromeCdpDiagnostic: vi.fn(() => "not reachable"), + isChromeCdpReady: mocks.isChromeCdpReady, + isChromeReachable: mocks.isChromeReachable, + launchOpenClawChrome: vi.fn(async () => { + throw new Error("launch should not be needed for status"); + }), + resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw-browser"), + stopOpenClawChrome: vi.fn(async () => {}), +})); + +vi.mock("../browser/pw-ai-state.js", () => ({ + isPwAiLoaded: vi.fn(() => false), +})); + +const { startBrowserControlServerFromConfig, stopBrowserControlServer } = + await import("../server.js"); +const { stopBrowserControlService } = await import("../control-service.js"); +const { browserHandlers } = await import("./browser-request.js"); + +function browserConfig(params: { + gatewayPort: number; + executablePath?: string; + headless?: boolean; + noSandbox?: boolean; +}): OpenClawConfig { + return { + gateway: { + port: params.gatewayPort, + }, + browser: { + enabled: true, + defaultProfile: "openclaw", + ...(params.executablePath ? { executablePath: params.executablePath } : {}), + ...(typeof params.headless === "boolean" ? { headless: params.headless } : {}), + ...(typeof params.noSandbox === "boolean" ? { noSandbox: params.noSandbox } : {}), + profiles: { + openclaw: { + cdpPort: params.gatewayPort + 11, + color: "#FF4500", + }, + }, + }, + }; +} + +async function browserRequestStatus(): Promise { + const respond = vi.fn(); + await browserHandlers["browser.request"]({ + params: { + method: "GET", + path: "/", + query: { profile: "openclaw" }, + }, + respond: respond as never, + context: { + nodeRegistry: { + listConnected: () => [], + }, + } as never, + client: null, + req: { type: "req", id: "req-1", method: "browser.request" }, + isWebchatConnect: () => false, + }); + const call = respond.mock.calls[0]; + expect(call?.[0]).toBe(true); + return call?.[1]; +} + +describe("browser.request local control state", () => { + afterEach(async () => { + await stopBrowserControlService(); + await stopBrowserControlServer(); + mocks.runtimeSourceConfig = null; + vi.clearAllMocks(); + }); + + it("uses the same resolved browser config as the HTTP control service", async () => { + const controlPort = await getFreePort(); + const gatewayPort = controlPort - 2; + + mocks.runtimeConfig = browserConfig({ + gatewayPort, + executablePath: "/usr/bin/google-chrome", + headless: true, + noSandbox: true, + }); + mocks.runtimeSourceConfig = mocks.runtimeConfig; + const httpState = await startBrowserControlServerFromConfig(); + expect(httpState?.resolved.executablePath).toBe("/usr/bin/google-chrome"); + expect(httpState?.resolved.noSandbox).toBe(true); + + // The runtime snapshot can lag behind source config after gateway startup; + // browser.request must not fork a second stale control state from it. + mocks.runtimeConfig = browserConfig({ + gatewayPort, + headless: false, + noSandbox: false, + }); + + await expect(browserRequestStatus()).resolves.toMatchObject({ + executablePath: "/usr/bin/google-chrome", + headless: true, + noSandbox: true, + }); + }); +}); diff --git a/extensions/browser/src/sdk-config.ts b/extensions/browser/src/sdk-config.ts index f41d11d6f6b..29792b9ccee 100644 --- a/extensions/browser/src/sdk-config.ts +++ b/extensions/browser/src/sdk-config.ts @@ -3,6 +3,7 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runti export { getRuntimeConfig, getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, } from "openclaw/plugin-sdk/runtime-config-snapshot"; export { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation"; export { diff --git a/extensions/browser/src/server.ts b/extensions/browser/src/server.ts index c4752f7edc0..688c3b37eb2 100644 --- a/extensions/browser/src/server.ts +++ b/extensions/browser/src/server.ts @@ -1,6 +1,13 @@ import type { Server } from "node:http"; import express from "express"; +import { + createBrowserControlContext, + ensureBrowserControlRuntime, + getBrowserControlState, + stopBrowserControlRuntime, +} from "./browser-control-state.js"; import { deleteBridgeAuthForPort, setBridgeAuthForPort } from "./browser/bridge-auth-registry.js"; +import { loadBrowserConfigForRuntimeRefresh } from "./browser/config-refresh-source.js"; import { resolveBrowserConfig } from "./browser/config.js"; import { ensureBrowserControlAuth, @@ -9,8 +16,7 @@ import { } from "./browser/control-auth.js"; import { registerBrowserRoutes } from "./browser/routes/index.js"; import type { BrowserRouteRegistrar } from "./browser/routes/types.js"; -import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js"; -import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js"; +import type { BrowserServerState } from "./browser/server-context.js"; import { installBrowserAuthMiddleware, installBrowserCommonMiddleware, @@ -19,20 +25,21 @@ import { getRuntimeConfig } from "./config/config.js"; import { createSubsystemLogger } from "./logging/subsystem.js"; import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js"; -let state: BrowserServerState | null = null; const log = createSubsystemLogger("browser"); const logServer = log.child("server"); export async function startBrowserControlServerFromConfig(): Promise { - if (state) { - return state; + const current = getBrowserControlState(); + if (current?.server) { + return current; } const cfg = getRuntimeConfig(); if (!isDefaultBrowserPluginEnabled(cfg)) { return null; } - const resolved = resolveBrowserConfig(cfg.browser, cfg); + const browserCfg = loadBrowserConfigForRuntimeRefresh(); + const resolved = resolveBrowserConfig(browserCfg.browser, browserCfg); if (!resolved.enabled) { return null; } @@ -70,10 +77,7 @@ export async function startBrowserControlServerFromConfig(): Promise state, - refreshConfigFromDisk: true, - }); + const ctx = createBrowserControlContext(); registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx); const port = resolved.controlPort; @@ -89,10 +93,11 @@ export async function startBrowserControlServerFromConfig(): Promise logServer.warn(message), }); setBridgeAuthForPort(port, browserAuth); @@ -103,16 +108,12 @@ export async function startBrowserControlServerFromConfig(): Promise { - const current = state; + const current = getBrowserControlState(); if (current?.port) { deleteBridgeAuthForPort(current.port); } - await stopBrowserRuntime({ - current, - getState: () => state, - clearState: () => { - state = null; - }, + await stopBrowserControlRuntime({ + requestedBy: "server", closeServer: true, onWarn: (message) => logServer.warn(message), });