fix(browser): share control runtime state

This commit is contained in:
Peter Steinberger
2026-04-30 15:35:16 +01:00
parent 44ad65f02b
commit cf772079c6
8 changed files with 262 additions and 45 deletions

View File

@@ -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.

View File

@@ -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<BrowserServerState> {
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<void> {
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,
});
}

View File

@@ -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();
}

View File

@@ -1,6 +1,7 @@
export {
getRuntimeConfig,
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
replaceConfigFile,
type BrowserConfig,
type BrowserProfileConfig,

View File

@@ -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<BrowserServerState | null> {
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<BrowserSer
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
}
state = await createBrowserRuntimeState({
const state = await ensureBrowserControlRuntime({
server: null,
port: resolved.controlPort,
resolved,
owner: "service",
onWarn: (message) => logService.warn(message),
});
@@ -57,13 +54,10 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
}
export async function stopBrowserControlService(): Promise<void> {
const current = state;
await stopBrowserRuntime({
current,
getState: () => state,
clearState: () => {
state = null;
},
await stopBrowserControlRuntime({
requestedBy: "service",
onWarn: (message) => logService.warn(message),
});
}
export { createBrowserControlContext, getBrowserControlState };

View File

@@ -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<typeof import("../config/config.js")>("../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<unknown> {
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,
});
});
});

View File

@@ -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 {

View File

@@ -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<BrowserServerState | null> {
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<BrowserServ
installBrowserCommonMiddleware(app);
installBrowserAuthMiddleware(app, browserAuth);
const ctx = createBrowserRouteContext({
getState: () => 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<BrowserServ
return null;
}
state = await createBrowserRuntimeState({
const state = await ensureBrowserControlRuntime({
server,
port,
resolved,
owner: "server",
onWarn: (message) => logServer.warn(message),
});
setBridgeAuthForPort(port, browserAuth);
@@ -103,16 +108,12 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
}
export async function stopBrowserControlServer(): Promise<void> {
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),
});