Plugins: preserve lazy runtime provider resolution

This commit is contained in:
Gustavo Madeira Santana 2026-03-16 11:39:26 +00:00
parent 5e4851ae2b
commit 4c8853122a
No known key found for this signature in database
4 changed files with 79 additions and 15 deletions

View File

@ -2800,6 +2800,7 @@ module.exports = {
it("preserves runtime reflection semantics when runtime is lazily initialized", () => {
useNoBundledPlugins();
const stateDir = makeTempDir();
const plugin = writePlugin({
id: "runtime-introspection",
filename: "runtime-introspection.cjs",
@ -2818,12 +2819,17 @@ module.exports = {
} };`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["runtime-introspection"],
},
});
const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () =>
loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["runtime-introspection"],
},
options: {
onlyPluginIds: ["runtime-introspection"],
},
}),
);
const record = registry.plugins.find((entry) => entry.id === "runtime-introspection");
expect(record?.status).toBe("loaded");

View File

@ -60,6 +60,21 @@ export type PluginLoadOptions = {
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 128;
const registryCache = new Map<string, PluginRegistry>();
const openAllowlistWarningCache = new Set<string>();
const LAZY_RUNTIME_REFLECTION_KEYS = [
"version",
"config",
"subagent",
"system",
"media",
"tts",
"stt",
"tools",
"channel",
"events",
"logging",
"state",
"modelAuth",
] as const satisfies readonly (keyof PluginRuntime)[];
export function clearPluginLoaderCache(): void {
registryCache.clear();
@ -870,6 +885,22 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions);
return resolvedRuntime;
};
const lazyRuntimeReflectionKeySet = new Set<PropertyKey>(LAZY_RUNTIME_REFLECTION_KEYS);
const resolveLazyRuntimeDescriptor = (prop: PropertyKey): PropertyDescriptor | undefined => {
if (!lazyRuntimeReflectionKeySet.has(prop)) {
return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop);
}
return {
configurable: true,
enumerable: true,
get() {
return Reflect.get(resolveRuntime() as object, prop);
},
set(value: unknown) {
Reflect.set(resolveRuntime() as object, prop, value);
},
};
};
const runtime = new Proxy({} as PluginRuntime, {
get(_target, prop, receiver) {
return Reflect.get(resolveRuntime(), prop, receiver);
@ -878,13 +909,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
return Reflect.set(resolveRuntime(), prop, value, receiver);
},
has(_target, prop) {
return Reflect.has(resolveRuntime(), prop);
return lazyRuntimeReflectionKeySet.has(prop) || Reflect.has(resolveRuntime(), prop);
},
ownKeys() {
return Reflect.ownKeys(resolveRuntime() as object);
return [...LAZY_RUNTIME_REFLECTION_KEYS];
},
getOwnPropertyDescriptor(_target, prop) {
return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop);
return resolveLazyRuntimeDescriptor(prop);
},
defineProperty(_target, prop, attributes) {
return Reflect.defineProperty(resolveRuntime() as object, prop, attributes);

View File

@ -58,6 +58,7 @@ describe("provider-runtime", () => {
});
it("matches providers by alias for runtime hook lookup", () => {
resolveOwningPluginIdsForProviderMock.mockReturnValue(["openrouter"]);
resolvePluginProvidersMock.mockReturnValue([
{
id: "openrouter",
@ -77,13 +78,35 @@ describe("provider-runtime", () => {
);
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["openrouter"],
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
}),
);
});
it("skips plugin loading when the provider has no owning plugin", () => {
const plugin = resolveProviderRuntimePlugin({ provider: "anthropic" });
expect(plugin).toBeUndefined();
expect(resolveOwningPluginIdsForProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "anthropic",
}),
);
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
});
it("dispatches runtime hooks for the matched provider", async () => {
resolveOwningPluginIdsForProviderMock.mockImplementation((params: { provider?: string }) => {
if (params.provider === "demo") {
return ["demo"];
}
if (params.provider === "openai") {
return ["openai"];
}
return undefined;
});
const prepareDynamicModel = vi.fn(async () => undefined);
const prepareRuntimeAuth = vi.fn(async () => ({
apiKey: "runtime-token",

View File

@ -54,14 +54,18 @@ export function resolveProviderRuntimePlugin(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ProviderPlugin | undefined {
const owningPluginIds = resolveOwningPluginIdsForProvider({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
if (!owningPluginIds || owningPluginIds.length === 0) {
return undefined;
}
return resolveProviderPluginsForHooks({
...params,
onlyPluginIds: resolveOwningPluginIdsForProvider({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}),
onlyPluginIds: owningPluginIds,
}).find((plugin) => matchesProviderId(plugin, params.provider));
}