refactor(plugins): root-owned bundle installation with scoped registry handles (#117587)

* refactor(plugins): root-owned bundle installation with scoped registry handles

* refactor(plugins): root-owned bundle installation with scoped registry handles

* test(gateway): expect gateway-binding fact in reload rewarm

* fix(agents): reuse gateway prepared runtime generations

* test(agents): isolate runtime handle fixtures

* test(agents): align runtime owner fixtures

* test(cron): mock run-owned registry loading
This commit is contained in:
Peter Steinberger
2026-08-01 16:17:30 -07:00
committed by GitHub
parent 2fc32204b4
commit 6c9b38862d
131 changed files with 3538 additions and 2949 deletions
+83 -55
View File
@@ -1,8 +1,12 @@
/** Plugin node-host bridge for loading plugin registry commands and dispatching node capabilities. */
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginNodeHostCommandRegistration } from "../plugins/registry-types.js";
import type {
PluginNodeHostCommandRegistration,
PluginRegistry,
} from "../plugins/registry-types.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js";
import type {
OpenClawPluginNodeHostCommandAvailabilityContext,
OpenClawPluginNodeHostCommandIo,
@@ -18,16 +22,20 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
*/
const loadPluginRegistryLoaderModule = createLazyRuntimeModule(
() => import("../plugins/runtime/runtime-registry-loader.js"),
() => import("../plugins/loader.js"),
);
let nodeHostPluginRegistry: PluginRegistry | undefined;
function resolveNodeHostPluginRegistry() {
return nodeHostPluginRegistry ?? getActivePluginRegistry() ?? undefined;
}
/** Ensure plugin registry data is loaded before node-host command dispatch. */
export async function ensureNodeHostPluginRegistry(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
(await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({
scope: "all",
nodeHostPluginRegistry = (await loadPluginRegistryLoaderModule()).loadPluginRegistryHandle({
config: params.config,
activationSourceConfig: params.config,
env: params.env,
@@ -43,36 +51,38 @@ export function listRegisteredNodeHostCapsAndCommands(
commands: string[];
nodePluginTools: NodePluginToolDescriptor[];
} {
const registry = getActivePluginRegistry();
const caps = new Set<string>();
const commands = new Set<string>();
const nodePluginTools = new Map<string, NodePluginToolDescriptor>();
for (const entry of registry?.nodeHostCommands ?? []) {
if (entry.command.duplex === true && options.includeDuplex === false) {
continue;
const registry = resolveNodeHostPluginRegistry();
return withPluginRuntimeRegistryScope(registry, () => {
const caps = new Set<string>();
const commands = new Set<string>();
const nodePluginTools = new Map<string, NodePluginToolDescriptor>();
for (const entry of registry?.nodeHostCommands ?? []) {
if (entry.command.duplex === true && options.includeDuplex === false) {
continue;
}
// Availability belongs to the node-local plugin. Gateway policy still keeps
// the command registered so a differently configured remote node can expose it.
if (entry.command.isAvailable?.(context) === false) {
continue;
}
if (entry.command.cap) {
caps.add(entry.command.cap);
}
commands.add(entry.command.command);
const agentTool = buildNodePluginToolDescriptor(entry);
if (agentTool) {
nodePluginTools.set(`${agentTool.pluginId}\0${agentTool.name}`, agentTool);
}
}
// Availability belongs to the node-local plugin. Gateway policy still keeps
// the command registered so a differently configured remote node can expose it.
if (entry.command.isAvailable?.(context) === false) {
continue;
}
if (entry.command.cap) {
caps.add(entry.command.cap);
}
commands.add(entry.command.command);
const agentTool = buildNodePluginToolDescriptor(entry);
if (agentTool) {
nodePluginTools.set(`${agentTool.pluginId}\0${agentTool.name}`, agentTool);
}
}
return {
caps: [...caps].toSorted((left, right) => left.localeCompare(right)),
commands: [...commands].toSorted((left, right) => left.localeCompare(right)),
nodePluginTools: [...nodePluginTools.values()].toSorted(
(left, right) =>
left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name),
),
};
return {
caps: [...caps].toSorted((left, right) => left.localeCompare(right)),
commands: [...commands].toSorted((left, right) => left.localeCompare(right)),
nodePluginTools: [...nodePluginTools.values()].toSorted(
(left, right) =>
left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name),
),
};
});
}
/** Watch plugin-owned availability inputs that can change during this process. */
@@ -80,19 +90,24 @@ export function watchRegisteredNodeHostCommandAvailability(
context: OpenClawPluginNodeHostCommandAvailabilityContext,
onChange: () => void,
): () => void {
const registry = getActivePluginRegistry();
const registry = resolveNodeHostPluginRegistry();
const cleanups: Array<() => void> = [];
for (const entry of registry?.nodeHostCommands ?? []) {
const cleanup = entry.command.watchAvailability?.(context, onChange);
if (cleanup) {
cleanups.push(cleanup);
withPluginRuntimeRegistryScope(registry, () => {
for (const entry of registry?.nodeHostCommands ?? []) {
const cleanup = entry.command.watchAvailability?.(context, () =>
withPluginRuntimeRegistryScope(registry, onChange),
);
if (cleanup) {
cleanups.push(cleanup);
}
}
}
return () => {
for (const cleanup of cleanups.splice(0)) {
cleanup();
}
};
});
return () =>
withPluginRuntimeRegistryScope(registry, () => {
for (const cleanup of cleanups.splice(0)) {
cleanup();
}
});
}
function normalizeString(value: unknown): string {
@@ -144,30 +159,43 @@ export async function invokeRegisteredNodeHostCommand(
io?: OpenClawPluginNodeHostCommandIo,
context?: OpenClawPluginNodeHostCommandContext,
): Promise<string | null> {
const registry = getActivePluginRegistry();
const registry = resolveNodeHostPluginRegistry();
const match = (registry?.nodeHostCommands ?? []).find(
(entry) => entry.command.command === command,
);
if (!match) {
return null;
}
if (match.command.duplex === true) {
if (!io) {
throw new Error(`node command requires duplex transport: ${command}`);
return await withPluginRuntimeRegistryScope(registry, async () => {
if (match.command.duplex === true) {
if (!io) {
throw new Error(`node command requires duplex transport: ${command}`);
}
return context
? await match.command.handle(paramsJSON, io, context)
: await match.command.handle(paramsJSON, io);
}
return context
? await match.command.handle(paramsJSON, io, context)
: await match.command.handle(paramsJSON, io);
}
return context
? await match.command.handle(paramsJSON, undefined, context)
: await match.command.handle(paramsJSON);
? await match.command.handle(paramsJSON, undefined, context)
: await match.command.handle(paramsJSON);
});
}
export function isRegisteredNodeHostCommandDuplex(command: string): boolean {
const registry = getActivePluginRegistry();
const registry = resolveNodeHostPluginRegistry();
return (
(registry?.nodeHostCommands ?? []).find((entry) => entry.command.command === command)?.command
.duplex === true
);
}
function resetNodeHostPluginRegistry(): void {
nodeHostPluginRegistry = undefined;
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.nodeHostPluginTestApi")] = {
getNodeHostPluginRegistry: () => nodeHostPluginRegistry,
resetNodeHostPluginRegistry,
};
}