refactor: move browser runtime seams behind plugin metadata

This commit is contained in:
Peter Steinberger
2026-04-05 23:13:03 +01:00
parent 1351bacaa4
commit 471d056e2f
44 changed files with 1441 additions and 1026 deletions

View File

@@ -24,6 +24,9 @@ export type BuildPluginApiParams = {
| "registerChannel"
| "registerGatewayMethod"
| "registerCli"
| "registerReload"
| "registerNodeHostCommand"
| "registerSecurityAuditCollector"
| "registerService"
| "registerConfigMigration"
| "registerAutoEnableProbe"
@@ -55,6 +58,10 @@ const noopRegisterHttpRoute: OpenClawPluginApi["registerHttpRoute"] = () => {};
const noopRegisterChannel: OpenClawPluginApi["registerChannel"] = () => {};
const noopRegisterGatewayMethod: OpenClawPluginApi["registerGatewayMethod"] = () => {};
const noopRegisterCli: OpenClawPluginApi["registerCli"] = () => {};
const noopRegisterReload: OpenClawPluginApi["registerReload"] = () => {};
const noopRegisterNodeHostCommand: OpenClawPluginApi["registerNodeHostCommand"] = () => {};
const noopRegisterSecurityAuditCollector: OpenClawPluginApi["registerSecurityAuditCollector"] =
() => {};
const noopRegisterService: OpenClawPluginApi["registerService"] = () => {};
const noopRegisterConfigMigration: OpenClawPluginApi["registerConfigMigration"] = () => {};
const noopRegisterAutoEnableProbe: OpenClawPluginApi["registerAutoEnableProbe"] = () => {};
@@ -104,6 +111,10 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerChannel: handlers.registerChannel ?? noopRegisterChannel,
registerGatewayMethod: handlers.registerGatewayMethod ?? noopRegisterGatewayMethod,
registerCli: handlers.registerCli ?? noopRegisterCli,
registerReload: handlers.registerReload ?? noopRegisterReload,
registerNodeHostCommand: handlers.registerNodeHostCommand ?? noopRegisterNodeHostCommand,
registerSecurityAuditCollector:
handlers.registerSecurityAuditCollector ?? noopRegisterSecurityAuditCollector,
registerService: handlers.registerService ?? noopRegisterService,
registerConfigMigration: handlers.registerConfigMigration ?? noopRegisterConfigMigration,
registerAutoEnableProbe: handlers.registerAutoEnableProbe ?? noopRegisterAutoEnableProbe,

View File

@@ -1093,7 +1093,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
},
});
const { registry, createApi } = createPluginRegistry({
const {
registry,
createApi,
registerReload,
registerNodeHostCommand,
registerSecurityAuditCollector,
} = createPluginRegistry({
logger,
runtime,
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
@@ -1536,6 +1542,18 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
if (registrationMode === "full") {
if (definition?.reload) {
registerReload(record, definition.reload);
}
for (const nodeHostCommand of definition?.nodeHostCommands ?? []) {
registerNodeHostCommand(record, nodeHostCommand);
}
for (const collector of definition?.securityAuditCollectors ?? []) {
registerSecurityAuditCollector(record, collector);
}
}
if (validateOnly) {
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);

View File

@@ -22,6 +22,9 @@ export function createEmptyPluginRegistry(): PluginRegistry {
gatewayMethodScopes: {},
httpRoutes: [],
cliRegistrars: [],
reloads: [],
nodeHostCommands: [],
securityAuditCollectors: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],

View File

@@ -9,6 +9,11 @@ import type {
} from "../gateway/server-methods/types.js";
import { registerInternalHook } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import {
NODE_EXEC_APPROVALS_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
NODE_SYSTEM_RUN_COMMANDS,
} from "../infra/node-commands.js";
import { normalizePluginGatewayMethodScope } from "../shared/gateway-method-policy.js";
import { resolveUserPath } from "../utils.js";
import { buildPluginApi } from "./api-builder.js";
@@ -52,6 +57,9 @@ import type {
OpenClawPluginHttpRouteHandler,
OpenClawPluginHttpRouteParams,
OpenClawPluginHookOptions,
OpenClawPluginNodeHostCommand,
OpenClawPluginReloadRegistration,
OpenClawPluginSecurityAuditCollector,
MediaUnderstandingProviderPlugin,
ProviderPlugin,
RealtimeVoiceProviderPlugin,
@@ -172,6 +180,30 @@ export type PluginServiceRegistration = {
rootDir?: string;
};
export type PluginReloadRegistration = {
pluginId: string;
pluginName?: string;
registration: OpenClawPluginReloadRegistration;
source: string;
rootDir?: string;
};
export type PluginNodeHostCommandRegistration = {
pluginId: string;
pluginName?: string;
command: OpenClawPluginNodeHostCommand;
source: string;
rootDir?: string;
};
export type PluginSecurityAuditCollectorRegistration = {
pluginId: string;
pluginName?: string;
collector: OpenClawPluginSecurityAuditCollector;
source: string;
rootDir?: string;
};
export type PluginCommandRegistration = {
pluginId: string;
pluginName?: string;
@@ -259,6 +291,9 @@ export type PluginRegistry = {
gatewayMethodScopes?: Partial<Record<string, OperatorScope>>;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
reloads?: PluginReloadRegistration[];
nodeHostCommands?: PluginNodeHostCommandRegistration[];
securityAuditCollectors?: PluginSecurityAuditCollectorRegistration[];
services: PluginServiceRegistration[];
commands: PluginCommandRegistration[];
conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
@@ -824,6 +859,104 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const reservedNodeHostCommands = new Set<string>([
...NODE_SYSTEM_RUN_COMMANDS,
...NODE_EXEC_APPROVALS_COMMANDS,
NODE_SYSTEM_NOTIFY_COMMAND,
]);
const registerReload = (record: PluginRecord, registration: OpenClawPluginReloadRegistration) => {
const normalize = (values?: string[]) =>
(values ?? []).map((value) => value.trim()).filter(Boolean);
const normalized: OpenClawPluginReloadRegistration = {
restartPrefixes: normalize(registration.restartPrefixes),
hotPrefixes: normalize(registration.hotPrefixes),
noopPrefixes: normalize(registration.noopPrefixes),
};
if (
(normalized.restartPrefixes?.length ?? 0) === 0 &&
(normalized.hotPrefixes?.length ?? 0) === 0 &&
(normalized.noopPrefixes?.length ?? 0) === 0
) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: "reload registration missing prefixes",
});
return;
}
registry.reloads ??= [];
registry.reloads.push({
pluginId: record.id,
pluginName: record.name,
registration: normalized,
source: record.source,
rootDir: record.rootDir,
});
};
const registerNodeHostCommand = (
record: PluginRecord,
nodeCommand: OpenClawPluginNodeHostCommand,
) => {
const command = nodeCommand.command.trim();
if (!command) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "node host command registration missing command",
});
return;
}
if (reservedNodeHostCommands.has(command)) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `node host command reserved by core: ${command}`,
});
return;
}
registry.nodeHostCommands ??= [];
const existing = registry.nodeHostCommands.find((entry) => entry.command.command === command);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `node host command already registered: ${command} (${existing.pluginId})`,
});
return;
}
registry.nodeHostCommands.push({
pluginId: record.id,
pluginName: record.name,
command: {
...nodeCommand,
command,
cap: nodeCommand.cap?.trim() || undefined,
},
source: record.source,
rootDir: record.rootDir,
});
};
const registerSecurityAuditCollector = (
record: PluginRecord,
collector: OpenClawPluginSecurityAuditCollector,
) => {
registry.securityAuditCollectors ??= [];
registry.securityAuditCollectors.push({
pluginId: record.id,
pluginName: record.name,
collector,
source: record.source,
rootDir: record.rootDir,
});
};
const registerService = (record: PluginRecord, service: OpenClawPluginService) => {
const id = service.id.trim();
if (!id) {
@@ -1051,6 +1184,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod: (method, handler, opts) =>
registerGatewayMethod(record, method, handler, opts),
registerService: (service) => registerService(record, service),
registerReload: (registration) => registerReload(record, registration),
registerNodeHostCommand: (command) => registerNodeHostCommand(record, command),
registerSecurityAuditCollector: (collector) =>
registerSecurityAuditCollector(record, collector),
registerInteractiveHandler: (registration) => {
const result = registerPluginInteractiveHandler(record.id, registration, {
pluginName: record.name,
@@ -1247,6 +1384,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerWebSearchProvider,
registerGatewayMethod,
registerCli,
registerReload,
registerNodeHostCommand,
registerSecurityAuditCollector,
registerService,
registerCommand,
registerHook,

View File

@@ -33,10 +33,17 @@ function activeRegistrySatisfiesScope(
scope: PluginRegistryScope,
active: ReturnType<typeof getActivePluginRegistry>,
expectedChannelPluginIds: readonly string[],
requestedPluginIds: readonly string[],
): boolean {
if (!active) {
return false;
}
if (requestedPluginIds.length > 0) {
const activePluginIds = new Set(
active.plugins.filter((plugin) => plugin.status === "loaded").map((plugin) => plugin.id),
);
return requestedPluginIds.every((pluginId) => activePluginIds.has(pluginId));
}
const activeChannelPluginIds = new Set(active.channels.map((entry) => entry.plugin.id));
switch (scope) {
case "configured-channels":
@@ -55,9 +62,13 @@ export function ensurePluginRegistryLoaded(options?: {
config?: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
}): void {
const scope = options?.scope ?? "all";
if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) {
const requestedPluginIds =
options?.onlyPluginIds?.map((pluginId) => pluginId.trim()).filter(Boolean) ?? [];
const scopedLoad = requestedPluginIds.length > 0;
if (!scopedLoad && scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) {
return;
}
const env = options?.env ?? process.env;
@@ -68,8 +79,9 @@ export function ensurePluginRegistryLoaded(options?: {
resolvedConfig,
resolveDefaultAgentId(resolvedConfig),
);
const expectedChannelPluginIds =
scope === "configured-channels"
const expectedChannelPluginIds = scopedLoad
? requestedPluginIds
: scope === "configured-channels"
? resolveConfiguredChannelPluginIds({
config: resolvedConfig,
workspaceDir,
@@ -84,10 +96,12 @@ export function ensurePluginRegistryLoaded(options?: {
: [];
const active = getActivePluginRegistry();
if (
pluginRegistryLoaded === "none" &&
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds)
(pluginRegistryLoaded === "none" || scopedLoad) &&
activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds, expectedChannelPluginIds)
) {
pluginRegistryLoaded = scope;
if (!scopedLoad) {
pluginRegistryLoaded = scope;
}
return;
}
const logger: PluginLogger = {
@@ -103,11 +117,11 @@ export function ensurePluginRegistryLoaded(options?: {
workspaceDir,
logger,
throwOnLoadError: true,
...(scope === "configured-channels" || scope === "channels"
? { onlyPluginIds: expectedChannelPluginIds }
: {}),
...(expectedChannelPluginIds.length > 0 ? { onlyPluginIds: expectedChannelPluginIds } : {}),
});
pluginRegistryLoaded = scope;
if (!scopedLoad) {
pluginRegistryLoaded = scope;
}
}
export const __testing = {

View File

@@ -53,6 +53,7 @@ import type {
RuntimeWebFetchMetadata,
RuntimeWebSearchMetadata,
} from "../secrets/runtime-web-tools.types.js";
import type { SecurityAuditFinding } from "../security/audit.js";
import type {
SpeechDirectiveTokenParseContext,
SpeechDirectiveTokenParseResult,
@@ -1928,6 +1929,30 @@ export type OpenClawPluginCliCommandDescriptor = {
hasSubcommands: boolean;
};
export type OpenClawPluginReloadRegistration = {
restartPrefixes?: string[];
hotPrefixes?: string[];
noopPrefixes?: string[];
};
export type OpenClawPluginNodeHostCommand = {
command: string;
cap?: string;
handle: (paramsJSON?: string | null) => Promise<string>;
};
export type OpenClawPluginSecurityAuditContext = {
config: OpenClawConfig;
sourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
stateDir: string;
configPath: string;
};
export type OpenClawPluginSecurityAuditCollector = (
ctx: OpenClawPluginSecurityAuditContext,
) => SecurityAuditFinding[] | Promise<SecurityAuditFinding[]>;
/** Context passed to long-lived plugin services. */
export type OpenClawPluginServiceContext = {
config: OpenClawConfig;
@@ -1955,6 +1980,9 @@ export type OpenClawPluginDefinition = {
version?: string;
kind?: PluginKind | PluginKind[];
configSchema?: OpenClawPluginConfigSchema;
reload?: OpenClawPluginReloadRegistration;
nodeHostCommands?: OpenClawPluginNodeHostCommand[];
securityAuditCollectors?: OpenClawPluginSecurityAuditCollector[];
register?: (api: OpenClawPluginApi) => void | Promise<void>;
activate?: (api: OpenClawPluginApi) => void | Promise<void>;
};
@@ -2040,6 +2068,9 @@ export type OpenClawPluginApi = {
descriptors?: OpenClawPluginCliCommandDescriptor[];
},
) => void;
registerReload: (registration: OpenClawPluginReloadRegistration) => void;
registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => void;
registerSecurityAuditCollector: (collector: OpenClawPluginSecurityAuditCollector) => void;
registerService: (service: OpenClawPluginService) => void;
/** Register a lightweight config migration that can run before plugin runtime loads. */
registerConfigMigration: (migrate: PluginConfigMigration) => void;