openclaw/extensions/browser/plugin-registration.ts
Peter Steinberger 678cdf4246
fix(gateway): apply settings without unnecessary restarts (#154792)
* fix(gateway): apply settings without unnecessary restarts

Apply operation settings through their existing runtime owners, retire stale request authority, and retain accepted work through cleanup. Preserve current visitor grants and cancellation while consolidating desktop, secrets, transcript, and UI ownership.

* refactor(gateway): consolidate history and audit test wiring

* test(gateway): observe plugin approval response completion

Replace the plugin approval fixture's one-second polling deadline with
observation of the existing response callback. Keep acceptance, payload,
visibility, decision, and handler-settlement assertions intact. Worker-backed
registration can legitimately finish after the unrelated polling deadline.

The unchanged full file reproduced nine acceptance-wait failures out of 35
cases (341.72s). All nine affected cases passed with the repair. The repaired
full file passed 34/35 (462.81s); an untouched resolve case reported a SQLite
lock error without an operation stack. A bounded replay of that case and its
immediate predecessor passed 2/2 with 33 cases filtered (77.50s), so the lock
cause remains unestablished and is not claimed fixed.

The owning gateway-methods type graph (44.54s), typed lint (89.54s), formatting,
and diff checks passed. Independent review found no actionable P0-P2 findings.
No production code, retries, or timeout values changed.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-09-21 11:50:57 -07:00

427 lines
15 KiB
TypeScript

/**
* Browser plugin registration helpers. This file keeps registration lazy while
* advertising Browser tools, services, node-host commands, and audits.
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
import type {
AnyAgentTool,
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
OpenClawPluginSecurityAuditCollector,
OpenClawPluginService,
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/plugin-entry";
import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
import { isBrowserMachineOutput } from "./cli-output-mode.js";
import { bindBrowserDashboardEvents } from "./src/browser-dashboard-events.js";
import {
BROWSER_REQUEST_GATEWAY_METHOD,
BROWSER_REQUEST_GATEWAY_SCOPE,
} from "./src/browser-gateway-contract.js";
import {
BROWSER_PROXY_COMMAND,
BROWSER_PROXY_UPLOAD_COMMAND,
} from "./src/browser-node-commands.js";
import { getOptionalBrowserStateRuntime } from "./src/browser-runtime-state.js";
import { parseBrowserTabToolBinding } from "./src/browser-tool-binding.js";
import { describeBrowserTool } from "./src/browser-tool-description.js";
import {
BrowserToolOutputSchema,
createBrowserToolSchema,
resolveBrowserToolCapabilities,
} from "./src/browser-tool.schema.js";
import { resolveBrowserConfig, resolveProfile } from "./src/browser/config.js";
import { getBrowserProfileCapabilities } from "./src/browser/profile-capabilities.js";
import {
initializeBrowserSessionTabStore,
readBrowserDashboardSessionOwners,
} from "./src/browser/session-tab-store.js";
import {
configureSystemProfileImportStateStore,
type SystemProfileImportState,
} from "./src/browser/system-profile-import-state.js";
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
const logger = createSubsystemLogger("browser");
let hasBrowserNodeHostWork: (() => boolean) | undefined;
let hasBrowserProxyUploadWork: (() => boolean) | undefined;
const loadBrowserRegistrationRuntimeModule = createLazyRuntimeSurface(
() => import("./register.runtime.js"),
(runtime) => {
hasBrowserNodeHostWork = runtime.hasBrowserNodeHostWork;
return runtime;
},
);
const loadBrowserUploadCleanupRuntimeModule = createLazyRuntimeSurface(
() => import("./src/browser-proxy-upload-cleanup.runtime.js"),
(runtime) => {
hasBrowserProxyUploadWork = runtime.hasBrowserProxyUploadWork;
return runtime;
},
);
function deriveChatTypeFromSessionKey(
sessionKey: string | undefined,
): "direct" | "group" | "channel" | undefined {
const tokens = new Set(sessionKey?.toLowerCase().split(":").filter(Boolean) ?? []);
if (tokens.has("group")) {
return "group";
}
if (tokens.has("channel")) {
return "channel";
}
if (tokens.has("direct") || tokens.has("dm")) {
return "direct";
}
return undefined;
}
const BROWSER_CLI_DESCRIPTOR = {
name: "browser",
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
hasSubcommands: true,
machineOutput: isBrowserMachineOutput,
};
function createLazyBrowserTool(
opts?: {
sandboxBridgeUrl?: string;
allowHostControl?: boolean;
agentSessionKey?: string;
agentId?: string;
agentDir?: string;
workspaceDir?: string;
activeModel?: {
provider?: string;
model?: string;
};
mediaScope?: {
sessionKey?: string;
channel?: string;
chatType?: string;
};
runToolBinding?: unknown;
},
config?: OpenClawPluginToolContext["runtimeConfig"],
): AnyAgentTool {
const bindingResult =
opts?.runToolBinding === undefined
? undefined
: parseBrowserTabToolBinding(opts.runToolBinding);
if (bindingResult && !bindingResult.ok) {
throw new Error(`invalid browser run binding: ${bindingResult.error}`);
}
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
const hostHint =
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
const boundProfile =
bindingResult?.ok && bindingResult.binding.target === "host"
? resolveProfile(resolveBrowserConfig(config?.browser, config), bindingResult.binding.profile)
: undefined;
const capabilities = resolveBrowserToolCapabilities({
tabBound: bindingResult?.ok,
evaluateEnabled: config?.browser?.evaluateEnabled !== false,
...(boundProfile ? { profileCapabilities: getBrowserProfileCapabilities(boundProfile) } : {}),
});
return {
label: "Browser",
name: "browser",
resultContentSource: "network",
description: describeBrowserTool({ targetDefault, hostHint, capabilities }),
parameters: createBrowserToolSchema(capabilities),
outputSchema: BrowserToolOutputSchema,
execute: async (toolCallId, args, signal, onUpdate) => {
const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule();
const tool = createBrowserTool(
bindingResult?.ok
? {
...opts,
runToolBinding: bindingResult.binding,
toolCapabilities: capabilities,
}
: { ...opts, toolCapabilities: capabilities },
);
return await tool.execute(toolCallId, args, signal, onUpdate);
},
};
}
function createBrowserToolOptions(ctx: OpenClawPluginToolContext): {
sandboxBridgeUrl?: string;
allowHostControl?: boolean;
agentSessionKey?: string;
agentId?: string;
agentDir?: string;
workspaceDir?: string;
activeModel?: {
provider?: string;
model?: string;
};
mediaScope?: {
sessionKey?: string;
channel?: string;
chatType?: string;
};
runToolBinding?: unknown;
} {
const mediaChannel = ctx.deliveryContext?.channel ?? ctx.messageChannel;
const mediaChatType = deriveChatTypeFromSessionKey(ctx.sessionKey);
return {
...(ctx.browser?.sandboxBridgeUrl ? { sandboxBridgeUrl: ctx.browser.sandboxBridgeUrl } : {}),
...(ctx.browser?.allowHostControl !== undefined
? { allowHostControl: ctx.browser.allowHostControl }
: {}),
...(ctx.sessionKey ? { agentSessionKey: ctx.sessionKey } : {}),
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}),
...(ctx.activeModel?.provider || ctx.activeModel?.modelId
? {
activeModel: {
provider: ctx.activeModel.provider,
model: ctx.activeModel.modelId,
},
}
: {}),
...(ctx.sessionKey || mediaChannel
? {
mediaScope: {
...(ctx.sessionKey ? { sessionKey: ctx.sessionKey } : {}),
...(mediaChannel ? { channel: mediaChannel } : {}),
...(mediaChatType ? { chatType: mediaChatType } : {}),
},
}
: {}),
...(ctx.toolBindings && Object.hasOwn(ctx.toolBindings, "browser")
? { runToolBinding: ctx.toolBindings.browser }
: {}),
};
}
/** Browser plugin reload policy. */
export const browserPluginReload = {
restartPrefixes: ["browser"],
hotPrefixes: [
"browser.profiles",
"browser.defaultProfile",
"browser.headless",
"browser.executablePath",
"browser.attachOnly",
"browser.cdpUrl",
"browser.noSandbox",
"browser.extraArgs",
"browser.snapshotDefaults",
"browser.tabCleanup",
"browser.allowSystemProfileImport",
],
};
/** Node-host command descriptors exposed by the Browser plugin. */
function createBrowserProxyNodeHostCommand(command: string): OpenClawPluginNodeHostCommand {
return {
command,
cap: "browser",
hasActiveWork: () =>
(loadBrowserRegistrationRuntimeModule.peek() !== undefined &&
hasBrowserNodeHostWork?.() !== false) ||
(loadBrowserUploadCleanupRuntimeModule.peek() !== undefined &&
hasBrowserProxyUploadWork?.() !== false),
isAvailable: ({ config }) =>
config.browser?.enabled !== false && config.nodeHost?.browserProxy?.enabled !== false,
handle: async (paramsJSON, _io, context) => {
const { runBrowserProxyCommand } = await loadBrowserRegistrationRuntimeModule();
return await runBrowserProxyCommand(paramsJSON, command, context?.signal);
},
...(command === BROWSER_PROXY_UPLOAD_COMMAND
? {
watchAvailability: () => {
void loadBrowserUploadCleanupRuntimeModule()
.then(({ ensureBrowserProxyUploadCleanup }) => ensureBrowserProxyUploadCleanup())
.catch((error: unknown) => {
logger.warn(`browser proxy upload cleanup startup failed: ${String(error)}`);
});
},
}
: {}),
};
}
export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
createBrowserProxyNodeHostCommand(BROWSER_PROXY_COMMAND),
createBrowserProxyNodeHostCommand(BROWSER_PROXY_UPLOAD_COMMAND),
];
/** Security audit collectors contributed by the Browser plugin. */
export const browserSecurityAuditCollectors: OpenClawPluginSecurityAuditCollector[] = [
async (ctx) => {
const { collectBrowserSecurityAuditFindings } = await loadBrowserRegistrationRuntimeModule();
return collectBrowserSecurityAuditFindings(ctx);
},
];
function createLazyBrowserPluginService(): OpenClawPluginService {
let service: OpenClawPluginService | null = null;
let stopDashboardEvents: (() => Promise<void>) | undefined;
return {
id: "browser-control",
// Policy changes drain the service's generation before adopting new values.
// Profile-level refresh keeps the admitted policy until this owner stops.
reload: {
configPrefixes: [
"browser.enabled",
"browser.evaluateEnabled",
"browser.ssrfPolicy",
"browser.extensionRelay.allowLegacyAuth",
],
},
start: async (ctx) => {
await stopDashboardEvents?.();
stopDashboardEvents = ctx.gatewayEvents
? bindBrowserDashboardEvents(ctx.gatewayEvents, (message) => logger.warn(message))
: undefined;
if (!isTruthyEnvValue(process.env[EAGER_BROWSER_CONTROL_SERVICE_ENV])) {
return;
}
const { createBrowserPluginService, stopBrowserControlService } =
await loadBrowserRegistrationRuntimeModule();
service ??= createBrowserPluginService({ stopOnDemand: stopBrowserControlService });
await service.start(ctx);
},
stop: async (ctx) => {
await stopDashboardEvents?.();
stopDashboardEvents = undefined;
if (!service) {
const loadedRuntime = loadBrowserRegistrationRuntimeModule.peek();
if (!loadedRuntime) {
return;
}
const { stopBrowserControlService } = await loadedRuntime;
await stopBrowserControlService();
return;
}
await service.stop?.(ctx);
},
};
}
/** Register Browser tool factories, CLI, gateway methods, services, and audits. */
export function registerBrowserPlugin(api: OpenClawPluginApi) {
const runtime = initializeBrowserSessionTabStore(api.runtime);
api.session.controls.registerControlUiDescriptor({
id: "dashboard",
surface: "widget",
label: "Browser",
description:
"An HTTP(S) dashboard shared with the agent's managed browser. Author with dashboard widget_put, then use the browser tool's dashboard selector to interact with that same page.",
requiredScopes: ["operator.admin"],
schema: {
type: "object",
additionalProperties: false,
required: ["url"],
properties: {
url: {
type: "string",
maxLength: 4096,
description: "HTTP(S) website URL without embedded credentials",
},
profile: {
type: "string",
maxLength: 128,
description: "Local managed Browser profile; defaults to openclaw",
},
},
},
});
api.on("session_end", async (event) => {
if (
event.reason !== "deleted" ||
!event.sessionKey ||
getOptionalBrowserStateRuntime() !== runtime
) {
return;
}
const dashboards = await readBrowserDashboardSessionOwners();
if (
getOptionalBrowserStateRuntime() !== runtime ||
!dashboards.some((dashboard) => dashboard.sessionKey === event.sessionKey)
) {
return;
}
const { reconcileBrowserDashboards } = await import("./src/browser-dashboard.js");
if (getOptionalBrowserStateRuntime() !== runtime) {
return;
}
await reconcileBrowserDashboards({
sessionKeys: [event.sessionKey],
onWarn: (message) => logger.warn(message),
});
});
configureSystemProfileImportStateStore(
api.runtime.state.openKeyedStore<SystemProfileImportState>({
namespace: "browser.system-profile-import",
maxEntries: 1,
}),
);
api.registerTool(((ctx: OpenClawPluginToolContext) => {
const config = ctx.getRuntimeConfig?.() ?? ctx.runtimeConfig ?? ctx.config;
return createLazyBrowserTool(createBrowserToolOptions(ctx), config);
}) as OpenClawPluginToolFactory);
api.registerCli(
async ({ program }) => {
const { registerBrowserCli } = await import("./src/cli/browser-cli.js");
registerBrowserCli(program, process.argv, api.rootDir);
},
{ commands: ["browser"], descriptors: [BROWSER_CLI_DESCRIPTOR] },
);
api.registerGatewayMethod(
BROWSER_REQUEST_GATEWAY_METHOD,
async (opts) => {
const { handleBrowserGatewayRequest } = await loadBrowserRegistrationRuntimeModule();
return await handleBrowserGatewayRequest(opts);
},
{
scope: BROWSER_REQUEST_GATEWAY_SCOPE,
},
);
// Remote extension relay: lets the Chrome extension connect directly to this
// gateway over wss:// (no node host on the browser machine). auth:"plugin"
// with no nodeCapability means the gateway does not pre-enforce token auth;
// the handler self-validates the host-local relay secret. Path kept in sync
// with GATEWAY_EXTENSION_RELAY_PATH (hardcoded here to stay lazy).
api.registerHttpRoute({
path: "/browser/extension",
auth: "plugin",
match: "exact",
handler: (_req: IncomingMessage, res: ServerResponse) => {
res.writeHead(426, { "Content-Type": "text/plain" });
res.end("Upgrade Required: connect the OpenClaw Chrome extension over WebSocket.");
},
handleUpgrade: async (req: IncomingMessage, socket: Duplex, head: Buffer) => {
// Direct relay activity prepares the teardown module consumed by lazy service shutdown.
await loadBrowserRegistrationRuntimeModule();
const { handleGatewayExtensionUpgrade } =
await import("./src/browser/extension-relay/gateway-relay-route.js");
return await handleGatewayExtensionUpgrade(req, socket, head);
},
});
api.registerHttpRoute({
path: "/browser/screencast",
auth: "plugin",
match: "exact",
handler: (_req: IncomingMessage, res: ServerResponse) => {
res.writeHead(426, { "Content-Type": "text/plain" });
res.end("Upgrade Required: connect the browser screencast over WebSocket.");
},
handleUpgrade: async (req: IncomingMessage, socket: Duplex, head: Buffer) => {
await loadBrowserRegistrationRuntimeModule();
const { handleBrowserScreencastUpgrade } =
await import("./src/browser/screencast/upgrade.js");
return await handleBrowserScreencastUpgrade(req, socket, head);
},
});
api.registerService(createLazyBrowserPluginService());
}