mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(browser): share control runtime state
This commit is contained in:
@@ -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.
|
||||
|
||||
70
extensions/browser/src/browser-control-state.ts
Normal file
70
extensions/browser/src/browser-control-state.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
getRuntimeConfig,
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
replaceConfigFile,
|
||||
type BrowserConfig,
|
||||
type BrowserProfileConfig,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user