perf(plugins): reuse startup runtime registry

Reuse the startup runtime plugin registry across provider/tool helper paths while preserving standalone CLI/MCP fallback loading.

Includes follow-up fixes for migration/provider/tool registry bootstrap and regression coverage for compatible registry reuse.

Co-authored-by: DmitryPogodaev <pogodaev.dm@gmail.com>
This commit is contained in:
DmitryPogodaev
2026-05-02 19:44:49 +07:00
committed by GitHub
parent 695960975a
commit 8283c5d6cc
36 changed files with 1182 additions and 525 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Agents/runtime: reuse the startup-loaded plugin registry for request-time providers, tools, channel actions, web/capability/memory/migration helpers, and memoized provider extra-params so stable embedded-run inputs no longer repeat plugin registry resolution while model-specific transport hook patches stay isolated. Thanks @DmitryPogodaev.
- Agents/runtime: memoize transcript replay-policy resolution for stable config and process-env runs while preserving custom-env provider hook behavior. Thanks @DmitryPogodaev.
- Infra/path-guards: add a fast path for canonical absolute POSIX containment checks, avoiding repeated `path.resolve` and `path.relative` work in hot filesystem walkers. Refs #75895, #75575, and #68782. Thanks @Enderfga.
- Tools: add a platform-level tool descriptor planner for descriptor-first visibility, generic availability checks, and executor references. Thanks @shakkernerd.

View File

@@ -686,7 +686,7 @@ describe("applyExtraParamsToAgent", () => {
api: "openai-responses",
provider: "xai",
id: "grok-4.20-beta-latest-reasoning",
} as Model<"openai-responses">,
} as unknown as Model<"openai-responses">,
payload: {
model: "grok-4.20-beta-latest-reasoning",
input: [],
@@ -743,7 +743,7 @@ describe("applyExtraParamsToAgent", () => {
id: "gpt-5",
baseUrl: "http://127.0.0.1:19191/v1",
reasoning: true,
} as Model<"openai-responses">,
} as unknown as Model<"openai-responses">,
payload: {
model: "gpt-5",
input: [],
@@ -811,7 +811,7 @@ describe("applyExtraParamsToAgent", () => {
api: "openai-completions",
provider: "nvidia-nim",
id: "moonshotai/kimi-k2.5",
} as Model<"openai-completions">,
} as unknown as Model<"openai-completions">,
});
expect(payload.parallel_tool_calls).toBe(false);
@@ -838,7 +838,7 @@ describe("applyExtraParamsToAgent", () => {
api: "openai-completions",
provider: "openrouter",
id: "openrouter/auto",
} as Model<"openai-completions">,
} as unknown as Model<"openai-completions">,
});
expect(payload.parallel_tool_calls).toBe(false);
@@ -1908,7 +1908,7 @@ describe("applyExtraParamsToAgent", () => {
api: "openai-responses",
provider: "openai",
id: "gpt-5.4",
} as Model<"openai-responses">,
} as unknown as Model<"openai-responses">,
payload: {},
});
@@ -2025,7 +2025,7 @@ describe("applyExtraParamsToAgent", () => {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
} as Model<"openai-responses">,
} as unknown as Model<"openai-responses">,
payload: { tools: [{ type: "function", name: "read" }] },
});
@@ -2257,6 +2257,82 @@ describe("applyExtraParamsToAgent", () => {
);
});
it("keys prepared extra-param memoization by resolved model transport inputs", () => {
const resolveProviderExtraParamsForTransport = vi.fn((params) => ({
patch: {
transportFamily: params.context.model?.api,
baseUrl: (params.context.model as Record<string, unknown> | undefined)?.baseUrl,
headerAuth: (
(params.context.model as Record<string, unknown> | undefined)?.headers as
| Record<string, unknown>
| undefined
)?.["X-Test"],
},
}));
extraParamsTesting.setProviderRuntimeDepsForTest({
prepareProviderExtraParams: (params) => params.context.extraParams,
resolveProviderExtraParamsForTransport,
wrapProviderStreamFn: (params) => params.context.streamFn,
});
const cfg = {};
const responsesParams = resolvePreparedExtraParams({
cfg,
provider: "openai",
modelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "https://api-one.example/v1",
headers: { "X-Test": "one" },
} as unknown as Model<"openai-responses">,
});
const completionsParams = resolvePreparedExtraParams({
cfg,
provider: "openai",
modelId: "gpt-5",
model: {
api: "openai-completions",
provider: "openai",
id: "gpt-5",
baseUrl: "https://api-one.example/v1",
headers: { "X-Test": "one" },
} as unknown as Model<"openai-completions">,
});
const differentModelHeadersParams = resolvePreparedExtraParams({
cfg,
provider: "openai",
modelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "https://api-two.example/v1",
headers: { "X-Test": "two" },
} as unknown as Model<"openai-responses">,
});
const repeatedResponsesParams = resolvePreparedExtraParams({
cfg,
provider: "openai",
modelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "https://api-one.example/v1",
headers: { "X-Test": "one" },
} as unknown as Model<"openai-responses">,
});
expect(responsesParams.transportFamily).toBe("openai-responses");
expect(completionsParams.transportFamily).toBe("openai-completions");
expect(differentModelHeadersParams.baseUrl).toBe("https://api-two.example/v1");
expect(differentModelHeadersParams.headerAuth).toBe("two");
expect(repeatedResponsesParams.transportFamily).toBe("openai-responses");
expect(resolveProviderExtraParamsForTransport).toHaveBeenCalledTimes(3);
});
it("passes explicit settings transport to transport extra-param hooks", () => {
const resolveProviderExtraParamsForTransport = vi.fn((_params) => ({
patch: {

View File

@@ -38,6 +38,8 @@ const providerRuntimeDeps = {
...defaultProviderRuntimeDeps,
};
let preparedExtraParamsCache = new WeakMap<OpenClawConfig, Map<string, Record<string, unknown>>>();
export const __testing = {
setProviderRuntimeDepsForTest(
deps: Partial<typeof defaultProviderRuntimeDeps> | undefined,
@@ -51,6 +53,7 @@ export const __testing = {
deps?.wrapProviderStreamFn ?? defaultProviderRuntimeDeps.wrapProviderStreamFn;
},
resetProviderRuntimeDepsForTest(): void {
clearPreparedExtraParamsCache();
providerRuntimeDeps.prepareProviderExtraParams =
defaultProviderRuntimeDeps.prepareProviderExtraParams;
providerRuntimeDeps.resolveProviderExtraParamsForTransport =
@@ -134,6 +137,60 @@ function hasExplicitTransportSetting(settings: { transport?: unknown }): boolean
return Object.hasOwn(settings, "transport");
}
function clearPreparedExtraParamsCache(): void {
preparedExtraParamsCache = new WeakMap();
}
function fingerprintPreparedExtraParamsModel(model?: ProviderRuntimeModel): unknown {
if (!model) {
return null;
}
const record = model as unknown as Record<string, unknown>;
return {
api: model.api,
provider: model.provider,
id: model.id,
name: model.name,
baseUrl: model.baseUrl,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
compat: record.compat ?? null,
contextWindow: model.contextWindow,
contextTokens: model.contextTokens ?? null,
headers: record.headers ?? null,
maxTokens: model.maxTokens,
params: model.params ?? null,
requestTimeoutMs: model.requestTimeoutMs ?? null,
};
}
function resolvePreparedExtraParamsCacheKey(params: {
provider: string;
modelId: string;
agentDir?: string;
workspaceDir?: string;
extraParamsOverride?: Record<string, unknown>;
thinkingLevel?: ThinkLevel;
agentId?: string;
resolvedExtraParams?: Record<string, unknown>;
model?: ProviderRuntimeModel;
resolvedTransport?: SupportedTransport;
}): string {
return JSON.stringify({
provider: params.provider,
modelId: params.modelId,
agentId: params.agentId ?? "",
agentDir: params.agentDir ?? "",
workspaceDir: params.workspaceDir ?? "",
thinkingLevel: params.thinkingLevel ?? "",
resolvedTransport: params.resolvedTransport ?? "",
extraParamsOverride: params.extraParamsOverride ?? null,
resolvedExtraParams: params.resolvedExtraParams ?? null,
model: fingerprintPreparedExtraParamsModel(params.model),
});
}
export function resolvePreparedExtraParams(params: {
cfg: OpenClawConfig | undefined;
provider: string;
@@ -176,6 +233,14 @@ export function resolvePreparedExtraParams(params: {
merged.cachedContent = resolvedCachedContent;
delete merged.cached_content;
}
const cfg = params.cfg;
const cacheKey = cfg ? resolvePreparedExtraParamsCacheKey(params) : undefined;
if (cacheKey) {
const cached = preparedExtraParamsCache.get(cfg!)?.get(cacheKey);
if (cached) {
return cached;
}
}
const prepared =
providerRuntimeDeps.prepareProviderExtraParams({
provider: params.provider,
@@ -207,7 +272,16 @@ export function resolvePreparedExtraParams(params: {
transport: params.resolvedTransport ?? resolveSupportedTransport(prepared.transport),
},
})?.patch;
return transportPatch ? { ...prepared, ...transportPatch } : prepared;
const result = transportPatch ? { ...prepared, ...transportPatch } : prepared;
if (cacheKey) {
let bucket = preparedExtraParamsCache.get(cfg!);
if (!bucket) {
bucket = new Map();
preparedExtraParamsCache.set(cfg!, bucket);
}
bucket.set(cacheKey, result);
}
return result;
}
function sanitizeExtraParamsRecord(

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => ({
getCurrentPluginMetadataSnapshot: vi.fn(),
resolveRuntimePluginRegistry: vi.fn(),
ensureStandaloneRuntimePluginRegistryLoaded: vi.fn(),
getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">(
() => "default",
),
@@ -12,8 +12,8 @@ vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: hoisted.getCurrentPluginMetadataSnapshot,
}));
vi.mock("../plugins/loader.js", () => ({
resolveRuntimePluginRegistry: hoisted.resolveRuntimePluginRegistry,
vi.mock("../plugins/runtime/standalone-runtime-registry-loader.js", () => ({
ensureStandaloneRuntimePluginRegistryLoaded: hoisted.ensureStandaloneRuntimePluginRegistryLoaded,
}));
vi.mock("../plugins/runtime.js", () => ({
@@ -26,8 +26,8 @@ describe("ensureRuntimePluginsLoaded", () => {
beforeEach(async () => {
hoisted.getCurrentPluginMetadataSnapshot.mockReset();
hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue(undefined);
hoisted.resolveRuntimePluginRegistry.mockReset();
hoisted.resolveRuntimePluginRegistry.mockReturnValue(undefined);
hoisted.ensureStandaloneRuntimePluginRegistryLoaded.mockReset();
hoisted.ensureStandaloneRuntimePluginRegistryLoaded.mockReturnValue(undefined);
hoisted.getActivePluginRuntimeSubagentMode.mockReset();
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
vi.resetModules();
@@ -35,7 +35,7 @@ describe("ensureRuntimePluginsLoaded", () => {
});
it("does not reactivate plugins when a process already has an active registry", async () => {
hoisted.resolveRuntimePluginRegistry.mockReturnValue({});
hoisted.ensureStandaloneRuntimePluginRegistryLoaded.mockReturnValue({});
ensureRuntimePluginsLoaded({
config: {} as never,
@@ -43,7 +43,7 @@ describe("ensureRuntimePluginsLoaded", () => {
allowGatewaySubagentBinding: true,
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledTimes(1);
});
it("resolves runtime plugins through the shared runtime helper", async () => {
@@ -53,11 +53,14 @@ describe("ensureRuntimePluginsLoaded", () => {
allowGatewaySubagentBinding: true,
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {} as never,
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({
requiredPluginIds: undefined,
loadOptions: {
config: {} as never,
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
},
});
});
@@ -80,12 +83,15 @@ describe("ensureRuntimePluginsLoaded", () => {
config,
workspaceDir: "/tmp/workspace",
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config,
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["telegram", "memory-core"],
runtimeOptions: {
allowGatewaySubagentBinding: true,
expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({
requiredPluginIds: ["telegram", "memory-core"],
loadOptions: {
config,
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["telegram", "memory-core"],
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
},
});
});
@@ -104,12 +110,15 @@ describe("ensureRuntimePluginsLoaded", () => {
allowGatewaySubagentBinding: true,
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {} as never,
onlyPluginIds: ["telegram"],
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({
requiredPluginIds: ["telegram"],
loadOptions: {
config: {} as never,
onlyPluginIds: ["telegram"],
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
},
});
});
@@ -137,12 +146,15 @@ describe("ensureRuntimePluginsLoaded", () => {
allowGatewaySubagentBinding: true,
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config,
onlyPluginIds: ["telegram"],
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({
requiredPluginIds: ["telegram"],
loadOptions: {
config,
onlyPluginIds: ["telegram"],
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
},
});
});
@@ -153,10 +165,13 @@ describe("ensureRuntimePluginsLoaded", () => {
workspaceDir: "/tmp/workspace",
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {} as never,
workspaceDir: "/tmp/workspace",
runtimeOptions: undefined,
expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({
requiredPluginIds: undefined,
loadOptions: {
config: {} as never,
workspaceDir: "/tmp/workspace",
runtimeOptions: undefined,
},
});
});
@@ -168,11 +183,14 @@ describe("ensureRuntimePluginsLoaded", () => {
workspaceDir: "/tmp/workspace",
});
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: {} as never,
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({
requiredPluginIds: undefined,
loadOptions: {
config: {} as never,
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
},
});
});

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import { resolveRuntimePluginRegistry } from "../plugins/loader.js";
import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js";
import { ensureStandaloneRuntimePluginRegistryLoaded } from "../plugins/runtime/standalone-runtime-registry-loader.js";
import { resolveUserPath } from "../utils.js";
type StartupScopedPluginSnapshot = NonNullable<
@@ -36,22 +36,22 @@ export function ensureRuntimePluginsLoaded(params: {
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
? resolveUserPath(params.workspaceDir)
: undefined;
const allowGatewaySubagentBinding =
params.allowGatewaySubagentBinding === true ||
getActivePluginRuntimeSubagentMode() === "gateway-bindable";
const startupPluginIds = resolveStartupPluginIdsFromCurrentSnapshot({
config: params.config,
workspaceDir,
});
const loadOptions = {
config: params.config,
workspaceDir,
...(startupPluginIds ? { onlyPluginIds: startupPluginIds } : {}),
runtimeOptions: allowGatewaySubagentBinding
? {
allowGatewaySubagentBinding: true,
}
: undefined,
};
resolveRuntimePluginRegistry(loadOptions);
const allowGatewaySubagentBinding =
params.allowGatewaySubagentBinding === true ||
getActivePluginRuntimeSubagentMode() === "gateway-bindable";
ensureStandaloneRuntimePluginRegistryLoaded({
requiredPluginIds: startupPluginIds,
loadOptions: {
config: params.config,
workspaceDir,
...(startupPluginIds === undefined ? {} : { onlyPluginIds: startupPluginIds }),
runtimeOptions: allowGatewaySubagentBinding
? { allowGatewaySubagentBinding: true }
: undefined,
},
});
}

View File

@@ -29,6 +29,8 @@ function withActivatedPluginIdsForTest<T extends Record<string, unknown>>(
const mocks = vi.hoisted(() => ({
loadOpenClawPlugins: vi.fn<typeof import("../plugins/loader.js").loadOpenClawPlugins>(),
resolveCompatibleRuntimePluginRegistry:
vi.fn<typeof import("../plugins/loader.js").resolveCompatibleRuntimePluginRegistry>(),
resolveRuntimePluginRegistry:
vi.fn<typeof import("../plugins/loader.js").resolveRuntimePluginRegistry>(),
getActivePluginRegistry: vi.fn<typeof import("../plugins/runtime.js").getActivePluginRegistry>(),
@@ -50,6 +52,9 @@ let resetPluginRegistryLoadedForTests: typeof import("./plugin-registry.js").__t
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: (...args: Parameters<typeof mocks.loadOpenClawPlugins>) =>
mocks.loadOpenClawPlugins(...args),
resolveCompatibleRuntimePluginRegistry: (
...args: Parameters<typeof mocks.resolveCompatibleRuntimePluginRegistry>
) => mocks.resolveCompatibleRuntimePluginRegistry(...args),
resolveRuntimePluginRegistry: (...args: Parameters<typeof mocks.resolveRuntimePluginRegistry>) =>
mocks.resolveRuntimePluginRegistry(...args),
}));
@@ -123,6 +128,7 @@ describe("ensurePluginRegistryLoaded", () => {
beforeEach(() => {
mocks.loadOpenClawPlugins.mockReset();
mocks.resolveCompatibleRuntimePluginRegistry.mockReset();
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.getActivePluginRegistry.mockReset();
mocks.resolveConfiguredChannelPluginIds.mockReset();
@@ -132,6 +138,7 @@ describe("ensurePluginRegistryLoaded", () => {
resetPluginRegistryLoadedForTests();
mocks.getActivePluginRegistry.mockReturnValue(createEmptyPluginRegistry());
mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(undefined);
mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined);
mocks.resolveDiscoverableScopedChannelPluginIds.mockReturnValue([]);
mocks.resolvePluginRuntimeLoadContext.mockImplementation((options) => {

View File

@@ -38,6 +38,7 @@ vi.mock("@clack/prompts", () => ({
}));
vi.mock("../plugins/migration-provider-runtime.js", () => ({
ensureStandaloneMigrationProviderRegistryLoaded: vi.fn(),
resolvePluginMigrationProvider: () => mocks.provider,
resolvePluginMigrationProviders: () => [mocks.provider],
}));

View File

@@ -2,7 +2,10 @@ import { cancel, isCancel, multiselect } from "@clack/prompts";
import { promptYesNo } from "../cli/prompt.js";
import { getRuntimeConfig } from "../config/config.js";
import { redactMigrationPlan } from "../plugin-sdk/migration.js";
import { resolvePluginMigrationProviders } from "../plugins/migration-provider-runtime.js";
import {
ensureStandaloneMigrationProviderRegistryLoaded,
resolvePluginMigrationProviders,
} from "../plugins/migration-provider-runtime.js";
import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { writeRuntimeJson } from "../runtime.js";
@@ -72,13 +75,13 @@ async function promptCodexMigrationSkillSelection(
}
export async function migrateListCommand(runtime: RuntimeEnv, opts: { json?: boolean } = {}) {
const providers = resolvePluginMigrationProviders({ cfg: getRuntimeConfig() }).map(
(provider) => ({
id: provider.id,
label: provider.label,
description: provider.description,
}),
);
const cfg = getRuntimeConfig();
ensureStandaloneMigrationProviderRegistryLoaded({ cfg });
const providers = resolvePluginMigrationProviders({ cfg }).map((provider) => ({
id: provider.id,
label: provider.label,
description: provider.description,
}));
if (opts.json) {
writeRuntimeJson(runtime, { providers });
return;

View File

@@ -1,5 +1,6 @@
import { getRuntimeConfig } from "../../config/config.js";
import {
ensureStandaloneMigrationProviderRegistryLoaded,
resolvePluginMigrationProvider,
resolvePluginMigrationProviders,
} from "../../plugins/migration-provider-runtime.js";
@@ -10,6 +11,7 @@ import type { MigrateCommonOptions } from "./types.js";
export function resolveMigrationProvider(providerId: string): MigrationProviderPlugin {
const config = getRuntimeConfig();
ensureStandaloneMigrationProviderRegistryLoaded({ cfg: config });
const provider = resolvePluginMigrationProvider({ providerId, cfg: config });
if (!provider) {
const available = resolvePluginMigrationProviders({ cfg: config }).map((entry) => entry.id);

View File

@@ -141,6 +141,7 @@ vi.mock("./health.js", () => ({
}));
vi.mock("../plugins/migration-provider-runtime.js", () => ({
ensureStandaloneMigrationProviderRegistryLoaded: vi.fn(),
resolvePluginMigrationProviders: () => [migrationProviderMock],
resolvePluginMigrationProvider: ({ providerId }: { providerId: string }) =>
providerId === migrationProviderMock.id ? migrationProviderMock : undefined,

View File

@@ -1,56 +1,13 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveRuntimePluginRegistry } from "../../plugins/loader.js";
import {
getActivePluginChannelRegistry,
getActivePluginChannelRegistryVersion,
} from "../../plugins/runtime.js";
import type { DeliverableMessageChannel } from "../../utils/message-channel.js";
const bootstrapAttempts = new Set<string>();
export function resetOutboundChannelBootstrapStateForTests(): void {
bootstrapAttempts.clear();
// Runtime channel plugins are loaded during Gateway startup now.
}
export function bootstrapOutboundChannelPlugin(params: {
channel: DeliverableMessageChannel;
cfg?: OpenClawConfig;
}): void {
const cfg = params.cfg;
if (!cfg) {
return;
}
const activeChannelRegistry = getActivePluginChannelRegistry();
const activeHasRequestedChannel = activeChannelRegistry?.channels?.some(
(entry) => entry?.plugin?.id === params.channel,
);
if (activeHasRequestedChannel) {
return;
}
const attemptKey = `${getActivePluginChannelRegistryVersion()}:${params.channel}`;
if (bootstrapAttempts.has(attemptKey)) {
return;
}
bootstrapAttempts.add(attemptKey);
const autoEnabled = applyPluginAutoEnable({ config: cfg });
const defaultAgentId = resolveDefaultAgentId(autoEnabled.config);
const workspaceDir = resolveAgentWorkspaceDir(autoEnabled.config, defaultAgentId);
try {
resolveRuntimePluginRegistry({
config: autoEnabled.config,
activationSourceConfig: cfg,
autoEnabledReasons: autoEnabled.autoEnabledReasons,
workspaceDir,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
} catch {
bootstrapAttempts.delete(attemptKey);
}
void params;
}

View File

@@ -52,19 +52,6 @@ async function importChannelResolution(scope: string) {
);
}
function expectBootstrapArgs() {
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
config: { autoEnabled: true },
activationSourceConfig: { channels: {} },
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
}),
);
}
describe("outbound channel resolution", () => {
beforeEach(async () => {
resolveDefaultAgentIdMock.mockReset();
@@ -141,10 +128,10 @@ describe("outbound channel resolution", () => {
).toBe(plugin);
});
it("bootstraps plugins once per registry key and returns the newly loaded plugin", async () => {
it("does not load registries while resolving outbound plugins", async () => {
const plugin = { id: "alpha" };
getLoadedChannelPluginMock.mockReturnValueOnce(undefined).mockReturnValueOnce(plugin);
const channelResolution = await importChannelResolution("bootstrap-success");
const channelResolution = await importChannelResolution("no-bootstrap");
expect(
channelResolution.resolveOutboundChannelPlugin({
@@ -152,20 +139,19 @@ describe("outbound channel resolution", () => {
cfg: { channels: {} } as never,
}),
).toBe(plugin);
expectBootstrapArgs();
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
getChannelPluginMock.mockReturnValue(undefined);
channelResolution.resolveOutboundChannelPlugin({
channel: "alpha",
cfg: { channels: {} } as never,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
expectBootstrapArgs();
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
});
it("bootstraps when the active registry has other channels but not the requested one", async () => {
const plugin = { id: "alpha" };
getLoadedChannelPluginMock.mockReturnValueOnce(undefined).mockReturnValueOnce(plugin);
it("does not load when the active registry has other channels but not the requested one", async () => {
getLoadedChannelPluginMock.mockReturnValue(undefined);
getChannelPluginMock.mockReturnValue(undefined);
getActivePluginRegistryMock.mockReturnValue({
channels: [{ plugin: { id: "beta" } }],
});
@@ -179,11 +165,11 @@ describe("outbound channel resolution", () => {
channel: "alpha",
cfg: { channels: {} } as never,
}),
).toBe(plugin);
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
).toBeUndefined();
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
});
it("retries bootstrap after a transient load failure", async () => {
it("does not retry registry loads after a missing outbound plugin", async () => {
getChannelPluginMock.mockReturnValue(undefined);
resolveRuntimePluginRegistryMock.mockImplementationOnce(() => {
throw new Error("transient");
@@ -201,10 +187,10 @@ describe("outbound channel resolution", () => {
channel: "alpha",
cfg: { channels: {} } as never,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(2);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
});
it("retries bootstrap when the pinned channel registry version changes", async () => {
it("does not load when the pinned channel registry version changes", async () => {
getChannelPluginMock.mockReturnValue(undefined);
const channelResolution = await importChannelResolution("channel-version-change");
@@ -212,13 +198,13 @@ describe("outbound channel resolution", () => {
channel: "alpha",
cfg: { channels: {} } as never,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
getActivePluginChannelRegistryVersionMock.mockReturnValue(2);
channelResolution.resolveOutboundChannelPlugin({
channel: "alpha",
cfg: { channels: {} } as never,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(2);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
});
});

View File

@@ -330,7 +330,7 @@ describe("sendMessage", () => {
}
});
it("recovers plugin resolution after registry refresh", async () => {
it("does not load registries while resolving outbound plugins", async () => {
const forumPlugin = {
outbound: { deliveryMode: "direct" },
};
@@ -352,6 +352,6 @@ describe("sendMessage", () => {
via: "direct",
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
expect(mocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled();
});
});

View File

@@ -11,6 +11,7 @@ const callGatewayTool = vi.hoisted(() => vi.fn());
const connectToolsMcpServerToStdioMock = vi.hoisted(() => vi.fn());
const createToolsMcpServerMock = vi.hoisted(() => vi.fn(() => ({ close: vi.fn() })));
const getRuntimeConfigMock = vi.hoisted(() => vi.fn(() => ({ plugins: { enabled: true } })));
const ensureStandalonePluginToolRegistryLoadedMock = vi.hoisted(() => vi.fn());
const resolvePluginToolsMock = vi.hoisted(() => vi.fn<() => AnyAgentTool[]>(() => []));
const routeLogsToStderrMock = vi.hoisted(() => vi.fn());
@@ -34,6 +35,7 @@ vi.mock("../plugins/tools.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/tools.js")>();
return {
...actual,
ensureStandalonePluginToolRegistryLoaded: ensureStandalonePluginToolRegistryLoadedMock,
resolvePluginTools: resolvePluginToolsMock,
};
});
@@ -48,6 +50,7 @@ afterEach(() => {
callGatewayTool.mockReset();
connectToolsMcpServerToStdioMock.mockReset();
createToolsMcpServerMock.mockClear();
ensureStandalonePluginToolRegistryLoadedMock.mockReset();
getRuntimeConfigMock.mockClear();
resolvePluginToolsMock.mockReset();
resolvePluginToolsMock.mockReturnValue([]);
@@ -71,7 +74,13 @@ describe("plugin tools MCP server", () => {
await servePluginToolsMcp();
expect(routeLogsToStderrMock).toHaveBeenCalledTimes(1);
expect(ensureStandalonePluginToolRegistryLoadedMock).toHaveBeenCalledWith({
context: { config: { plugins: { enabled: true } } },
});
expect(resolvePluginToolsMock).toHaveBeenCalledTimes(1);
expect(ensureStandalonePluginToolRegistryLoadedMock.mock.invocationCallOrder[0]).toBeLessThan(
resolvePluginToolsMock.mock.invocationCallOrder[0] ?? 0,
);
expect(routeLogsToStderrMock.mock.invocationCallOrder[0]).toBeLessThan(
resolvePluginToolsMock.mock.invocationCallOrder[0] ?? 0,
);

View File

@@ -13,10 +13,13 @@ import { getRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { routeLogsToStderr } from "../logging/console.js";
import { resolvePluginTools } from "../plugins/tools.js";
import { ensureStandalonePluginToolRegistryLoaded, resolvePluginTools } from "../plugins/tools.js";
import { connectToolsMcpServerToStdio, createToolsMcpServer } from "./tools-stdio-server.js";
function resolveTools(config: OpenClawConfig): AnyAgentTool[] {
ensureStandalonePluginToolRegistryLoaded({
context: { config },
});
return resolvePluginTools({
context: { config },
suppressNameConflicts: true,

View File

@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from "vitest";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import { __testing, clearPluginLoaderCache } from "./loader.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import type { PluginRegistry } from "./registry-types.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js";
afterEach(() => {
clearPluginLoaderCache();
resetPluginRuntimeStateForTest();
});
function createRegistryWithPlugin(pluginId: string): PluginRegistry {
const registry = createEmptyPluginRegistry();
registry.plugins.push({
id: pluginId,
status: "loaded",
} as never);
return registry;
}
describe("getLoadedRuntimePluginRegistry", () => {
it("treats an explicit empty plugin scope as empty", () => {
setActivePluginRegistry(createRegistryWithPlugin("stale"), "stale", "default", "/tmp/ws");
expect(
getLoadedRuntimePluginRegistry({
workspaceDir: "/tmp/ws",
requiredPluginIds: [],
}),
).toBeUndefined();
const emptyRegistry = createEmptyPluginRegistry();
setActivePluginRegistry(emptyRegistry, "empty", "default", "/tmp/ws");
expect(
getLoadedRuntimePluginRegistry({
workspaceDir: "/tmp/ws",
requiredPluginIds: [],
}),
).toBe(emptyRegistry);
});
it("does not reuse workspace-agnostic registries for workspace-specific requests", () => {
setActivePluginRegistry(createRegistryWithPlugin("demo"), "demo");
expect(
getLoadedRuntimePluginRegistry({
workspaceDir: "/tmp/ws",
requiredPluginIds: ["demo"],
}),
).toBeUndefined();
});
it("validates full loader cache compatibility when load options are provided", () => {
const registry = createRegistryWithPlugin("demo");
const loadOptions = {
config: {
plugins: {
allow: ["demo"],
},
},
onlyPluginIds: ["demo"],
workspaceDir: "/tmp/ws",
};
const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions);
setActivePluginRegistry(registry, cacheKey, "default", "/tmp/ws");
expect(
getLoadedRuntimePluginRegistry({
loadOptions,
}),
).toBe(registry);
expect(
getLoadedRuntimePluginRegistry({
loadOptions: {
...loadOptions,
config: {
plugins: {
allow: ["other"],
},
},
},
}),
).toBeUndefined();
});
});

View File

@@ -1,28 +1,105 @@
import { createRequire } from "node:module";
import { resolveCompatibleRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js";
import type { PluginRegistry } from "./registry-types.js";
import {
getActivePluginChannelRegistry,
getActivePluginHttpRouteRegistry,
getActivePluginRegistry,
getActivePluginRegistryWorkspaceDir,
} from "./runtime.js";
type PluginRuntimeModule = Pick<typeof import("./runtime.js"), "getActivePluginRegistry">;
export type ActiveRuntimePluginRegistrySurface = "active" | "channel" | "http-route";
const require = createRequire(import.meta.url);
const RUNTIME_MODULE_CANDIDATES = ["./runtime.js", "./runtime.ts"] as const;
export function getActiveRuntimePluginRegistry(): PluginRegistry | null {
return getActivePluginRegistry();
}
let pluginRuntimeModule: PluginRuntimeModule | undefined;
function loadPluginRuntime(): PluginRuntimeModule | null {
if (pluginRuntimeModule) {
return pluginRuntimeModule;
function normalizeRequiredPluginIds(ids?: readonly string[]): string[] | undefined {
if (ids === undefined) {
return undefined;
}
for (const candidate of RUNTIME_MODULE_CANDIDATES) {
try {
pluginRuntimeModule = require(candidate) as PluginRuntimeModule;
return pluginRuntimeModule;
} catch {
// Try built/runtime source candidates in order.
return [...new Set(ids.map((id) => id.trim()).filter(Boolean))].toSorted((left, right) =>
left.localeCompare(right),
);
}
function registryContainsPluginIds(
registry: PluginRegistry,
pluginIds: readonly string[] | undefined,
): boolean {
if (pluginIds === undefined) {
return true;
}
const loaded = new Set<string>();
for (const plugin of registry.plugins ?? []) {
if (plugin.status === undefined || plugin.status === "loaded") {
loaded.add(plugin.id);
}
}
for (const value of Object.values(registry)) {
if (!Array.isArray(value)) {
continue;
}
for (const entry of value) {
if (entry && typeof entry === "object" && "pluginId" in entry) {
const pluginId = entry.pluginId;
if (typeof pluginId === "string" && pluginId.length > 0) {
loaded.add(pluginId);
}
}
}
}
if (pluginIds.length === 0) {
return loaded.size === 0;
}
return pluginIds.every((pluginId) => loaded.has(pluginId));
}
function resolveSurfaceRegistry(
surface: ActiveRuntimePluginRegistrySurface,
): PluginRegistry | null {
switch (surface) {
case "active":
return getActivePluginRegistry();
case "channel":
return getActivePluginChannelRegistry();
case "http-route":
return getActivePluginHttpRouteRegistry();
}
return null;
}
export function getActiveRuntimePluginRegistry(): PluginRegistry | null {
return loadPluginRuntime()?.getActivePluginRegistry() ?? null;
export function getLoadedRuntimePluginRegistry(
params: {
env?: NodeJS.ProcessEnv;
loadOptions?: PluginLoadOptions;
workspaceDir?: string;
requiredPluginIds?: readonly string[];
surface?: ActiveRuntimePluginRegistrySurface;
} = {},
): PluginRegistry | undefined {
const surface = params.surface ?? "active";
const requiredPluginIds = normalizeRequiredPluginIds(
params.requiredPluginIds ?? params.loadOptions?.onlyPluginIds,
);
if (surface === "active" && params.loadOptions && requiredPluginIds?.length !== 0) {
const compatible = resolveCompatibleRuntimePluginRegistry(params.loadOptions);
if (!compatible || !registryContainsPluginIds(compatible, requiredPluginIds)) {
return undefined;
}
return compatible;
}
const activeWorkspaceDir = getActivePluginRegistryWorkspaceDir();
const requestedWorkspaceDir = params.workspaceDir ?? params.loadOptions?.workspaceDir;
if (requestedWorkspaceDir !== undefined && activeWorkspaceDir !== requestedWorkspaceDir) {
return undefined;
}
const registry = resolveSurfaceRegistry(surface);
if (!registry) {
return undefined;
}
if (!registryContainsPluginIds(registry, requiredPluginIds)) {
return undefined;
}
return registry;
}

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import type {
AgentToolResultMiddleware,
AgentToolResultMiddlewareRuntime,
@@ -8,7 +9,6 @@ import {
listAgentToolResultMiddlewares,
normalizeAgentToolResultMiddlewareRuntimeIds,
} from "./agent-tool-result-middleware.js";
import { loadOpenClawPlugins } from "./loader.js";
import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js";
const log = createSubsystemLogger("plugins/agent-tool-result-middleware");
@@ -67,15 +67,14 @@ export async function loadAgentToolResultMiddlewaresForRuntime(params: {
return [];
}
const registry = loadOpenClawPlugins({
config,
const registry = getLoadedRuntimePluginRegistry({
workspaceDir: params.workspaceDir,
env,
manifestRegistry,
onlyPluginIds: pluginIds,
activate: false,
throwOnLoadError: false,
requiredPluginIds: pluginIds,
});
if (!registry) {
return [];
}
return registry.agentToolResultMiddlewares
.filter((entry) => entry.runtimes.includes(params.runtime))

View File

@@ -57,6 +57,17 @@ vi.mock("./loader.js", () => ({
resolvePluginRegistryLoadCacheKey: mocks.resolvePluginRegistryLoadCacheKey,
}));
vi.mock("./active-runtime-registry.js", () => ({
getLoadedRuntimePluginRegistry: (params?: { requiredPluginIds?: string[] }) => {
if (params === undefined) {
return mocks.resolveRuntimePluginRegistry();
}
return mocks.resolveRuntimePluginRegistry({
onlyPluginIds: params.requiredPluginIds,
});
},
}));
vi.mock("./bundled-capability-runtime.js", () => ({
loadBundledCapabilityRuntimeRegistry: mocks.loadBundledCapabilityRuntimeRegistry,
}));
@@ -134,6 +145,22 @@ function expectNoResolvedCapabilityProviders(providers: Array<{ id: string }>) {
expectResolvedCapabilityProviderIds(providers, []);
}
function expectActiveRegistryLookup(pluginIds: string[]) {
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ onlyPluginIds: pluginIds });
}
function collectActiveRegistryLookups() {
return mocks.resolveRuntimePluginRegistry.mock.calls
.map(([options]) => options)
.filter((options): options is { onlyPluginIds?: string[] } =>
Boolean(
options &&
typeof options === "object" &&
Object.hasOwn(options as Record<string, unknown>, "onlyPluginIds"),
),
);
}
function expectBundledCompatLoadPath(params: {
cfg: OpenClawConfig;
allowlistCompat: OpenClawConfig;
@@ -159,11 +186,7 @@ function expectBundledCompatLoadPath(params: {
pluginIds: ["openai"],
env: process.env,
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: params.enablementCompat,
onlyPluginIds: ["openai"],
activate: false,
});
expectActiveRegistryLookup(["openai"]);
}
function createCompatChainConfig() {
@@ -443,9 +466,7 @@ describe("resolvePluginCapabilityProviders", () => {
["external-image"],
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenLastCalledWith({
config: expect.any(Object),
onlyPluginIds: ["external-image"],
activate: false,
});
expect(mocks.loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled();
});
@@ -545,15 +566,7 @@ describe("resolvePluginCapabilityProviders", () => {
expectResolvedCapabilityProviderIds(providers, ["openai", "deepgram"]);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["openai", "deepgram", "google"],
}),
}),
onlyPluginIds: ["deepgram", "google"],
activate: false,
});
expectActiveRegistryLookup(["deepgram", "google"]);
});
it("keeps active speech providers when cfg requests an active provider alias", () => {
@@ -692,15 +705,7 @@ describe("resolvePluginCapabilityProviders", () => {
expectResolvedCapabilityProviderIds(providers, ["openai", "microsoft"]);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["openai", "microsoft"],
}),
}),
onlyPluginIds: ["microsoft"],
activate: false,
});
expectActiveRegistryLookup(["microsoft"]);
});
it("uses bundled capability capture when runtime snapshot is empty for a requested speech provider", () => {
@@ -766,11 +771,7 @@ describe("resolvePluginCapabilityProviders", () => {
});
expectResolvedCapabilityProviderIds(providers, ["openai", "google"]);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: expect.anything(),
onlyPluginIds: ["google"],
activate: false,
});
expectActiveRegistryLookup(["google"]);
expect(mocks.loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledWith({
pluginIds: ["google"],
env: process.env,
@@ -1066,11 +1067,7 @@ describe("resolvePluginCapabilityProviders", () => {
});
expectNoResolvedCapabilityProviders(providers);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: expect.anything(),
onlyPluginIds: [],
activate: false,
});
expectActiveRegistryLookup([]);
});
it("loads bundled capability providers even without an explicit cfg", () => {
@@ -1112,11 +1109,7 @@ describe("resolvePluginCapabilityProviders", () => {
env: process.env,
}),
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: compatConfig,
onlyPluginIds: ["google"],
activate: false,
});
expectActiveRegistryLookup(["google"]);
});
it("loads fallback snapshots without startup dependency repair", () => {
@@ -1138,11 +1131,7 @@ describe("resolvePluginCapabilityProviders", () => {
}),
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: enablementCompat,
onlyPluginIds: ["openai"],
activate: false,
});
expectActiveRegistryLookup(["openai"]);
});
it("does not resolve non-speech capability providers when plugins are globally disabled", () => {
@@ -1247,11 +1236,7 @@ describe("resolvePluginCapabilityProviders", () => {
pluginIds: ["microsoft"],
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: compatConfig,
onlyPluginIds: ["microsoft"],
activate: false,
});
expectActiveRegistryLookup(["microsoft"]);
});
it.each([
@@ -1272,11 +1257,7 @@ describe("resolvePluginCapabilityProviders", () => {
}),
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: expect.anything(),
onlyPluginIds: [],
activate: false,
});
expectActiveRegistryLookup([]);
});
it("scopes media capability snapshot loads to manifest-derived bundled owners", () => {
@@ -1308,11 +1289,7 @@ describe("resolvePluginCapabilityProviders", () => {
resolvePluginCapabilityProviders({ key: "videoGenerationProviders", cfg });
resolvePluginCapabilityProviders({ key: "musicGenerationProviders", cfg });
const snapshotLoadOptions = mocks.resolveRuntimePluginRegistry.mock.calls
.map(([options]) => options)
.filter((options): options is { activate: boolean; onlyPluginIds?: string[] } =>
Boolean(options && typeof options === "object" && "activate" in options),
);
const snapshotLoadOptions = collectActiveRegistryLookups();
expect(snapshotLoadOptions.map((options) => options.onlyPluginIds)).toEqual([
["minimax", "openai"],
["minimax", "openai"],
@@ -1342,11 +1319,7 @@ describe("resolvePluginCapabilityProviders", () => {
resolvePluginCapabilityProviders({ key: "musicGenerationProviders", cfg }),
);
const snapshotLoadOptions = mocks.resolveRuntimePluginRegistry.mock.calls
.map(([options]) => options)
.filter((options): options is { activate: boolean; onlyPluginIds?: string[] } =>
Boolean(options && typeof options === "object" && "activate" in options),
);
const snapshotLoadOptions = collectActiveRegistryLookups();
expect(snapshotLoadOptions.map((options) => options.onlyPluginIds)).toEqual([["openai"], []]);
});
@@ -1409,11 +1382,7 @@ describe("resolvePluginCapabilityProviders", () => {
config: allowlistCompat,
pluginIds: ["google"],
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: enablementCompat,
onlyPluginIds: ["google"],
activate: false,
});
expectActiveRegistryLookup(["google"]);
});
it("does not load targeted non-speech capability providers when plugins are globally disabled", () => {
@@ -1531,10 +1500,6 @@ describe("resolvePluginCapabilityProviders", () => {
pluginIds: ["microsoft"],
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: enablementCompat,
onlyPluginIds: ["microsoft"],
activate: false,
});
expectActiveRegistryLookup(["microsoft"]);
});
});

View File

@@ -1,25 +1,22 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./plugin-cache-primitives.js";
import {
resolvePluginRegistryLoadCacheKey,
resolveRuntimePluginRegistry,
type PluginLoadOptions,
} from "./loader.js";
import { resolvePluginRegistryLoadCacheKey, type PluginLoadOptions } from "./loader.js";
import {
hasManifestContractValue,
isManifestPluginAvailableForControlPlane,
loadManifestContractSnapshot,
listAvailableManifestContractValues,
} from "./manifest-contract-eligibility.js";
import {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
} from "./plugin-cache-primitives.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
import type { PluginRegistry } from "./registry-types.js";
@@ -417,7 +414,12 @@ function loadCapabilityProviderEntries<K extends CapabilityProviderRegistryKey>(
loadOptions: PluginLoadOptions;
requested?: Set<string>;
}): PluginRegistry[K] {
const registry = resolveRuntimePluginRegistry(params.loadOptions);
const registry = getLoadedRuntimePluginRegistry({
env: params.loadOptions.env,
loadOptions: params.loadOptions,
workspaceDir: params.loadOptions.workspaceDir,
requiredPluginIds: params.loadOptions.onlyPluginIds,
});
const entries = registry?.[params.key] ?? [];
const missingRequested =
params.key === "speechProviders" && params.requested && params.requested.size > 0
@@ -449,7 +451,7 @@ export function resolvePluginCapabilityProvider<K extends CapabilityProviderRegi
return undefined;
}
const activeRegistry = resolveRuntimePluginRegistry();
const activeRegistry = getLoadedRuntimePluginRegistry();
const activeProvider = findProviderById(activeRegistry?.[params.key] ?? [], params.providerId);
if (activeProvider) {
return activeProvider;
@@ -520,7 +522,7 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
return [];
}
const activeRegistry = resolveRuntimePluginRegistry();
const activeRegistry = getLoadedRuntimePluginRegistry();
const activeProviders = activeRegistry?.[params.key] ?? [];
const missingRequestedProviders =
activeProviders.length > 0

View File

@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveRuntimePluginRegistryMock =
vi.fn<typeof import("./loader.js").resolveRuntimePluginRegistry>();
const getLoadedRuntimePluginRegistryMock =
vi.fn<typeof import("./active-runtime-registry.js").getLoadedRuntimePluginRegistry>();
const applyPluginAutoEnableMock =
vi.fn<typeof import("../config/plugin-auto-enable.js").applyPluginAutoEnable>();
const getMemoryRuntimeMock = vi.fn<typeof import("./memory-state.js").getMemoryRuntime>();
@@ -24,6 +26,10 @@ vi.mock("./loader.js", () => ({
resolveRuntimePluginRegistry: resolveRuntimePluginRegistryMock,
}));
vi.mock("./active-runtime-registry.js", () => ({
getLoadedRuntimePluginRegistry: getLoadedRuntimePluginRegistryMock,
}));
vi.mock("./memory-state.js", () => ({
getMemoryRuntime: () => getMemoryRuntimeMock(),
}));
@@ -56,20 +62,17 @@ function createMemoryRuntimeFixture() {
}
function expectMemoryRuntimeLoaded(rawConfig: unknown, autoEnabledConfig: unknown) {
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
void rawConfig;
void autoEnabledConfig;
expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
config: autoEnabledConfig,
activationSourceConfig: rawConfig,
onlyPluginIds: ["memory-core"],
requiredPluginIds: ["memory-core"],
}),
);
}
function expectMemoryAutoEnableApplied(rawConfig: unknown, autoEnabledConfig: unknown) {
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith({
config: rawConfig,
env: process.env,
});
expect(applyPluginAutoEnableMock).not.toHaveBeenCalled();
expectMemoryRuntimeLoaded(rawConfig, autoEnabledConfig);
}
@@ -88,6 +91,7 @@ function setAutoEnabledMemoryRuntime() {
function expectNoMemoryRuntimeBootstrap() {
expect(applyPluginAutoEnableMock).not.toHaveBeenCalled();
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(getLoadedRuntimePluginRegistryMock).not.toHaveBeenCalled();
}
async function expectAutoEnabledMemoryRuntimeCase(params: {
@@ -125,6 +129,7 @@ describe("memory runtime auto-enable loading", () => {
closeActiveMemorySearchManagers,
} = await import("./memory-runtime.js"));
resolveRuntimePluginRegistryMock.mockReset();
getLoadedRuntimePluginRegistryMock.mockReset();
applyPluginAutoEnableMock.mockReset();
getMemoryRuntimeMock.mockReset();
resolveAgentWorkspaceDirMock.mockReset();
@@ -181,9 +186,9 @@ describe("memory runtime auto-enable loading", () => {
agentId: "main",
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["memory-lancedb"],
requiredPluginIds: ["memory-lancedb"],
}),
);
});
@@ -210,11 +215,9 @@ describe("memory runtime auto-enable loading", () => {
}),
).resolves.toEqual({ manager: null, error: "memory plugin unavailable" });
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith({
config: rawConfig,
env: process.env,
});
expect(applyPluginAutoEnableMock).not.toHaveBeenCalled();
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
expect(getLoadedRuntimePluginRegistryMock).not.toHaveBeenCalled();
});
it.each([

View File

@@ -1,11 +1,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import { normalizePluginsConfig } from "./config-state.js";
import { resolveRuntimePluginRegistry } from "./loader.js";
import { getMemoryRuntime } from "./memory-state.js";
import {
buildPluginRuntimeLoadOptions,
resolvePluginRuntimeLoadContext,
} from "./runtime/load-context.js";
function resolveMemoryRuntimePluginIds(config: OpenClawConfig): string[] {
const memorySlot = normalizePluginsConfig(config.plugins).slots.memory;
@@ -17,16 +13,11 @@ function ensureMemoryRuntime(cfg?: OpenClawConfig) {
if (current || !cfg) {
return current;
}
const context = resolvePluginRuntimeLoadContext({ config: cfg });
const onlyPluginIds = resolveMemoryRuntimePluginIds(context.config);
const onlyPluginIds = resolveMemoryRuntimePluginIds(cfg);
if (onlyPluginIds.length === 0) {
return getMemoryRuntime();
}
resolveRuntimePluginRegistry(
buildPluginRuntimeLoadOptions(context, {
onlyPluginIds,
}),
);
getLoadedRuntimePluginRegistry({ requiredPluginIds: onlyPluginIds });
return getMemoryRuntime();
}

View File

@@ -41,21 +41,24 @@ const mocks = vi.hoisted(() => ({
snapshot: params?.index ?? createMockPluginIndex([]),
diagnostics: [],
})),
withBundledPluginAllowlistCompat: vi.fn(
({ config }: { config?: OpenClawConfig; pluginIds: string[] }) => config,
),
withBundledPluginEnablementCompat: vi.fn(
({ config }: { config?: OpenClawConfig; pluginIds: string[] }) => config,
),
withBundledPluginVitestCompat: vi.fn(
({ config }: { config?: OpenClawConfig; pluginIds: string[] }) => config,
),
ensureStandaloneRuntimePluginRegistryLoaded: vi.fn(),
}));
vi.mock("./loader.js", () => ({
resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry,
}));
vi.mock("./active-runtime-registry.js", () => ({
getLoadedRuntimePluginRegistry: (params?: { requiredPluginIds?: string[] }) => {
if (params === undefined) {
return mocks.resolveRuntimePluginRegistry();
}
return mocks.resolveRuntimePluginRegistry({
onlyPluginIds: params.requiredPluginIds,
});
},
}));
vi.mock("./plugin-registry.js", () => ({
loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot,
loadPluginRegistrySnapshotWithMetadata: mocks.loadPluginRegistrySnapshotWithMetadata,
@@ -66,12 +69,11 @@ vi.mock("./manifest-registry-installed.js", () => ({
resolveInstalledManifestRegistryIndexFingerprint: () => "test-installed-index",
}));
vi.mock("./bundled-compat.js", () => ({
withBundledPluginAllowlistCompat: mocks.withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat: mocks.withBundledPluginEnablementCompat,
withBundledPluginVitestCompat: mocks.withBundledPluginVitestCompat,
vi.mock("./runtime/standalone-runtime-registry-loader.js", () => ({
ensureStandaloneRuntimePluginRegistryLoaded: mocks.ensureStandaloneRuntimePluginRegistryLoaded,
}));
let ensureStandaloneMigrationProviderRegistryLoaded: typeof import("./migration-provider-runtime.js").ensureStandaloneMigrationProviderRegistryLoaded;
let resolvePluginMigrationProvider: typeof import("./migration-provider-runtime.js").resolvePluginMigrationProvider;
let resolvePluginMigrationProviders: typeof import("./migration-provider-runtime.js").resolvePluginMigrationProviders;
@@ -98,10 +100,55 @@ describe("migration provider runtime", () => {
}),
);
const runtime = await import("./migration-provider-runtime.js");
ensureStandaloneMigrationProviderRegistryLoaded =
runtime.ensureStandaloneMigrationProviderRegistryLoaded;
resolvePluginMigrationProvider = runtime.resolvePluginMigrationProvider;
resolvePluginMigrationProviders = runtime.resolvePluginMigrationProviders;
});
it("standalone-loads bundled migration providers through compat config", () => {
mocks.loadPluginRegistrySnapshot.mockReturnValue(
createMockPluginIndex([
{
pluginId: "migrate-hermes",
origin: "bundled",
enabled: true,
},
]),
);
mocks.loadPluginManifestRegistry.mockImplementation(() => ({
diagnostics: [],
plugins: [
{
id: "migrate-hermes",
origin: "bundled",
contracts: { migrationProviders: ["hermes"] },
},
],
}));
ensureStandaloneMigrationProviderRegistryLoaded({
cfg: { plugins: { enabled: false } } as OpenClawConfig,
});
expect(mocks.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({
surface: "active",
requiredPluginIds: ["migrate-hermes"],
loadOptions: {
activate: false,
onlyPluginIds: ["migrate-hermes"],
config: expect.objectContaining({
plugins: expect.objectContaining({
enabled: true,
entries: {
"migrate-hermes": { enabled: true },
},
}),
}),
},
});
});
it("loads configured external migration-provider plugins from manifest contracts", () => {
const cfg = {
plugins: { entries: { "external-migration": { enabled: true } } },
@@ -176,9 +223,7 @@ describe("migration provider runtime", () => {
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: cfg,
onlyPluginIds: ["external-migration"],
activate: false,
});
});
@@ -235,7 +280,6 @@ describe("migration provider runtime", () => {
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
onlyPluginIds: ["migrate-hermes"],
activate: false,
});
});

View File

@@ -1,32 +1,14 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { resolveRuntimePluginRegistry } from "./loader.js";
import { resolveManifestContractRuntimePluginResolution } from "./manifest-contract-runtime.js";
import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js";
import type { MigrationProviderPlugin } from "./types.js";
function resolveMigrationProviderConfig(params: {
cfg?: OpenClawConfig;
bundledCompatPluginIds: string[];
}): OpenClawConfig | undefined {
const allowlistCompat = withBundledPluginAllowlistCompat({
config: params.cfg,
pluginIds: params.bundledCompatPluginIds,
});
const enablementCompat = withBundledPluginEnablementCompat({
config: allowlistCompat,
pluginIds: params.bundledCompatPluginIds,
});
return withBundledPluginVitestCompat({
config: enablementCompat,
pluginIds: params.bundledCompatPluginIds,
env: process.env,
});
}
function findMigrationProviderById(
entries: ReadonlyArray<{ provider: MigrationProviderPlugin }>,
providerId: string,
@@ -34,19 +16,28 @@ function findMigrationProviderById(
return entries.find((entry) => entry.provider.id === providerId)?.provider;
}
function resolveMigrationProviderRegistry(params: {
function resolveMigrationProviderConfig(params: {
cfg?: OpenClawConfig;
pluginIds: string[];
bundledCompatPluginIds: string[];
}) {
const compatConfig = resolveMigrationProviderConfig({
cfg: params.cfg,
bundledCompatPluginIds: params.bundledCompatPluginIds,
bundledCompatPluginIds: readonly string[];
}): OpenClawConfig | undefined {
const allowlistCompat = withBundledPluginAllowlistCompat({
config: params.cfg,
pluginIds: [...params.bundledCompatPluginIds],
});
return resolveRuntimePluginRegistry({
...(compatConfig === undefined ? {} : { config: compatConfig }),
onlyPluginIds: params.pluginIds,
activate: false,
const enablementCompat = withBundledPluginEnablementCompat({
config: allowlistCompat,
pluginIds: [...params.bundledCompatPluginIds],
});
return withBundledPluginVitestCompat({
config: enablementCompat,
pluginIds: [...params.bundledCompatPluginIds],
env: process.env,
});
}
function resolveMigrationProviderRegistry(params: { pluginIds: string[] }) {
return getLoadedRuntimePluginRegistry({
requiredPluginIds: params.pluginIds,
});
}
@@ -63,11 +54,38 @@ function mergeMigrationProviders(
return [...merged.values()].toSorted((a, b) => a.id.localeCompare(b.id));
}
export function ensureStandaloneMigrationProviderRegistryLoaded(
params: {
cfg?: OpenClawConfig;
} = {},
): void {
const resolution = resolveManifestContractRuntimePluginResolution({
cfg: params.cfg,
contract: "migrationProviders",
});
if (resolution.pluginIds.length === 0) {
return;
}
const compatConfig = resolveMigrationProviderConfig({
cfg: params.cfg,
bundledCompatPluginIds: resolution.bundledCompatPluginIds,
});
ensureStandaloneRuntimePluginRegistryLoaded({
surface: "active",
requiredPluginIds: resolution.pluginIds,
loadOptions: {
...(compatConfig === undefined ? {} : { config: compatConfig }),
onlyPluginIds: resolution.pluginIds,
activate: false,
},
});
}
export function resolvePluginMigrationProvider(params: {
providerId: string;
cfg?: OpenClawConfig;
}): MigrationProviderPlugin | undefined {
const activeRegistry = resolveRuntimePluginRegistry();
const activeRegistry = getLoadedRuntimePluginRegistry();
const activeProvider = findMigrationProviderById(
activeRegistry?.migrationProviders ?? [],
params.providerId,
@@ -86,9 +104,7 @@ export function resolvePluginMigrationProvider(params: {
return undefined;
}
const registry = resolveMigrationProviderRegistry({
cfg: params.cfg,
pluginIds,
bundledCompatPluginIds: resolution.bundledCompatPluginIds,
});
return findMigrationProviderById(registry?.migrationProviders ?? [], params.providerId);
}
@@ -98,7 +114,7 @@ export function resolvePluginMigrationProviders(
cfg?: OpenClawConfig;
} = {},
): MigrationProviderPlugin[] {
const activeRegistry = resolveRuntimePluginRegistry();
const activeRegistry = getLoadedRuntimePluginRegistry();
const activeProviders = activeRegistry?.migrationProviders ?? [];
const resolution = resolveManifestContractRuntimePluginResolution({
cfg: params.cfg,
@@ -109,9 +125,7 @@ export function resolvePluginMigrationProviders(
return mergeMigrationProviders(activeProviders, []);
}
const registry = resolveMigrationProviderRegistry({
cfg: params.cfg,
pluginIds,
bundledCompatPluginIds: resolution.bundledCompatPluginIds,
});
return mergeMigrationProviders(activeProviders, registry?.migrationProviders ?? []);
}

View File

@@ -1,6 +1,7 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import {
resolveConfigScopedRuntimeCacheValue,
type ConfigScopedRuntimeCache,
@@ -8,6 +9,7 @@ import {
import { resolvePluginControlPlaneFingerprint } from "./plugin-control-plane-context.js";
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";
import type { PluginRegistry } from "./registry-types.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
import type {
ProviderPlugin,
@@ -66,6 +68,33 @@ function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string):
return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized;
}
function resolveCompatibleActiveProviderRegistry(
params: ProviderRuntimePluginLookupParams,
): PluginRegistry | undefined {
return getLoadedRuntimePluginRegistry({
env: params.env,
workspaceDir: params.workspaceDir,
});
}
function findProviderRuntimePluginInRegistry(params: {
registry: PluginRegistry;
provider: string;
apiOwnerHint?: string;
}): ProviderPlugin | undefined {
return params.registry.providers
.map((entry) => Object.assign({}, entry.provider, { pluginId: entry.pluginId }))
.find((plugin) => {
if (params.apiOwnerHint) {
return (
matchesProviderLiteralId(plugin, params.provider) ||
matchesProviderId(plugin, params.apiOwnerHint)
);
}
return matchesProviderId(plugin, params.provider);
});
}
export function resolveProviderPluginsForHooks(params: {
config?: OpenClawConfig;
workspaceDir?: string;
@@ -106,16 +135,27 @@ export function resolveProviderPluginsForHooks(params: {
export function resolveProviderRuntimePlugin(
params: ProviderRuntimePluginLookupParams,
): ProviderPlugin | undefined {
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
provider: params.provider,
config: params.config,
});
const activeRegistry = resolveCompatibleActiveProviderRegistry(params);
const activePlugin = activeRegistry
? findProviderRuntimePluginInRegistry({
registry: activeRegistry,
provider: params.provider,
apiOwnerHint,
})
: undefined;
if (activePlugin) {
return activePlugin;
}
const cacheConfig = params.env && params.env !== process.env ? undefined : params.config;
const plugin = resolveConfigScopedRuntimeCacheValue({
cache: providerRuntimePluginCache,
config: cacheConfig,
key: resolveProviderRuntimePluginCacheKey(params),
load: () => {
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
provider: params.provider,
config: params.config,
});
return (
resolveProviderPluginsForHooks({
config: params.config,

View File

@@ -93,6 +93,9 @@ let providerRuntimeTesting: typeof import("./provider-runtime.js").__testing;
let runProviderDynamicModel: typeof import("./provider-runtime.js").runProviderDynamicModel;
let validateProviderReplayTurnsWithPlugin: typeof import("./provider-runtime.js").validateProviderReplayTurnsWithPlugin;
let wrapProviderStreamFn: typeof import("./provider-runtime.js").wrapProviderStreamFn;
let createEmptyPluginRegistry: typeof import("./registry.js").createEmptyPluginRegistry;
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
const MODEL: ProviderRuntimeModel = {
id: "demo-model",
@@ -316,9 +319,12 @@ describe("provider-runtime", () => {
validateProviderReplayTurnsWithPlugin,
wrapProviderStreamFn,
} = await import("./provider-runtime.js"));
({ createEmptyPluginRegistry } = await import("./registry.js"));
({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("./runtime.js"));
});
beforeEach(() => {
resetPluginRuntimeStateForTest();
providerRuntimeTesting.resetExternalAuthFallbackWarningCacheForTest();
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue([]);
@@ -369,6 +375,73 @@ describe("provider-runtime", () => {
});
});
it("uses the active startup registry for provider hook lookup", () => {
const provider: ProviderPlugin = {
id: DEMO_PROVIDER_ID,
label: "Demo",
auth: [],
prepareExtraParams: ({ extraParams }) => ({
...extraParams,
fromActiveRegistry: true,
}),
};
const registry = createEmptyPluginRegistry();
registry.providers.push({
pluginId: DEMO_PROVIDER_ID,
provider,
source: "test",
});
setActivePluginRegistry(registry, "startup-registry", "gateway-bindable", "/tmp/workspace");
expect(
prepareProviderExtraParams({
provider: DEMO_PROVIDER_ID,
workspaceDir: "/tmp/workspace",
context: createDemoRuntimeContext({
extraParams: {},
}),
}),
).toEqual({
fromActiveRegistry: true,
});
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
});
it("matches active provider hooks through a custom provider's native api owner", () => {
const provider: ProviderPlugin = {
id: "ollama",
label: "Ollama",
auth: [],
createStreamFn: vi.fn(() => vi.fn()),
};
const registry = createEmptyPluginRegistry();
registry.providers.push({
pluginId: "ollama",
provider,
source: "test",
});
setActivePluginRegistry(registry, "startup-registry", "gateway-bindable", "/tmp/workspace");
const plugin = resolveProviderRuntimePlugin({
provider: "ollama-spark",
workspaceDir: "/tmp/workspace",
config: {
models: {
providers: {
"ollama-spark": {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
} as never,
});
expect(plugin).toMatchObject({ id: "ollama", pluginId: "ollama" });
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
});
it("uses current provider-ref owner plugin config for provider hooks", () => {
const provider: ProviderPlugin = {
id: DEMO_PROVIDER_ID,

View File

@@ -1,10 +1,10 @@
import { withActivatedPluginIds } from "./activation-context.js";
import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js";
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import {
isPluginRegistryLoadInFlight,
loadOpenClawPlugins,
resolveRuntimePluginRegistry,
type PluginLoadOptions,
} from "./loader.js";
import { hasExplicitPluginIdScope } from "./plugin-scope.js";
@@ -309,7 +309,12 @@ export function resolvePluginProviders(params: {
);
}
const loadState = resolveRuntimeProviderPluginLoadState(params, base);
const registry = resolveRuntimePluginRegistry(loadState.loadOptions);
const registry = getLoadedRuntimePluginRegistry({
env: base.env,
loadOptions: loadState.loadOptions,
workspaceDir: base.workspaceDir,
requiredPluginIds: loadState.loadOptions.onlyPluginIds,
});
if (!registry) {
return [];
}

View File

@@ -0,0 +1,49 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const allowedRuntimeResolverRefs = new Set([
"src/commands/doctor.e2e-harness.ts",
"src/plugins/loader.ts",
]);
function listSourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist") {
continue;
}
const path = resolve(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) {
files.push(...listSourceFiles(path));
continue;
}
if (!path.endsWith(".ts") || path.endsWith(".test.ts") || path.endsWith(".test.tsx")) {
continue;
}
files.push(path);
}
return files;
}
describe("runtime plugin registry boundary", () => {
it("keeps runtime registry resolution behind the loader boundary", () => {
const offenders = listSourceFiles(resolve(repoRoot, "src"))
.map((path) => ({
path,
relativePath: relative(repoRoot, path),
source: readFileSync(path, "utf8"),
}))
.filter(
(file) =>
!allowedRuntimeResolverRefs.has(file.relativePath) &&
file.source.includes("resolveRuntimePluginRegistry"),
)
.map((file) => file.relativePath);
expect(offenders).toEqual([]);
});
});

View File

@@ -3,6 +3,8 @@ import { createEmptyPluginRegistry } from "../registry.js";
const mocks = vi.hoisted(() => ({
loadOpenClawPlugins: vi.fn<typeof import("../loader.js").loadOpenClawPlugins>(),
resolveCompatibleRuntimePluginRegistry:
vi.fn<typeof import("../loader.js").resolveCompatibleRuntimePluginRegistry>(),
resolveRuntimePluginRegistry: vi.fn<typeof import("../loader.js").resolveRuntimePluginRegistry>(),
getActivePluginRegistry: vi.fn<typeof import("../runtime.js").getActivePluginRegistry>(),
resolveConfiguredChannelPluginIds:
@@ -27,14 +29,19 @@ let resetPluginRegistryLoadedForTests: typeof import("./runtime-registry-loader.
vi.mock("../loader.js", () => ({
loadOpenClawPlugins: (...args: Parameters<typeof mocks.loadOpenClawPlugins>) =>
mocks.loadOpenClawPlugins(...args),
resolveCompatibleRuntimePluginRegistry: (
...args: Parameters<typeof mocks.resolveCompatibleRuntimePluginRegistry>
) => mocks.resolveCompatibleRuntimePluginRegistry(...args),
resolveRuntimePluginRegistry: (...args: Parameters<typeof mocks.resolveRuntimePluginRegistry>) =>
mocks.resolveRuntimePluginRegistry(...args),
}));
vi.mock("../runtime.js", () => ({
getActivePluginChannelRegistry: () => null,
getActivePluginHttpRouteRegistry: () => null,
getActivePluginRegistry: (...args: Parameters<typeof mocks.getActivePluginRegistry>) =>
mocks.getActivePluginRegistry(...args),
getActivePluginRegistryWorkspaceDir: () => undefined,
}));
vi.mock("../channel-plugin-ids.js", () => ({
@@ -69,6 +76,7 @@ describe("ensurePluginRegistryLoaded", () => {
beforeEach(() => {
mocks.loadOpenClawPlugins.mockReset();
mocks.resolveCompatibleRuntimePluginRegistry.mockReset();
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.getActivePluginRegistry.mockReset();
mocks.resolveConfiguredChannelPluginIds.mockReset();
@@ -79,7 +87,8 @@ describe("ensurePluginRegistryLoaded", () => {
mocks.resolveDefaultAgentId.mockClear();
resetPluginRegistryLoadedForTests();
mocks.getActivePluginRegistry.mockReturnValue(createEmptyPluginRegistry());
mocks.getActivePluginRegistry.mockReturnValue(null);
mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(undefined);
mocks.loadOpenClawPlugins.mockReturnValue(createEmptyPluginRegistry());
mocks.resolveRuntimePluginRegistry.mockImplementation(
(...args: Parameters<typeof mocks.loadOpenClawPlugins>) => mocks.loadOpenClawPlugins(...args),
@@ -321,23 +330,15 @@ describe("ensurePluginRegistryLoaded", () => {
it("reuses a compatible active registry instead of forcing a broad reload", () => {
const activeRegistry = createEmptyPluginRegistry();
mocks.resolveRuntimePluginRegistry.mockReturnValue(activeRegistry);
mocks.getActivePluginRegistry.mockReturnValue(activeRegistry);
mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(activeRegistry);
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { allow: ["demo"] } } as never,
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
throwOnLoadError: true,
}),
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: expect.any(Array),
}),
);
expect(mocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled();
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,12 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withActivatedPluginIds } from "../activation-context.js";
import { getLoadedRuntimePluginRegistry } from "../active-runtime-registry.js";
import {
resolveChannelPluginIds,
resolveConfiguredChannelPluginIds,
resolveDiscoverableScopedChannelPluginIds,
} from "../channel-plugin-ids.js";
import { loadOpenClawPlugins, resolveRuntimePluginRegistry } from "../loader.js";
import { loadOpenClawPlugins } from "../loader.js";
import {
hasExplicitPluginIdScope,
hasNonEmptyPluginIdScope,
@@ -75,11 +76,16 @@ function shouldForwardChannelScope(params: {
}
function resolveOrLoadRuntimePluginRegistry(
loadOptions: Parameters<typeof loadOpenClawPlugins>[0],
loadOptions: NonNullable<Parameters<typeof loadOpenClawPlugins>[0]>,
): void {
// Prefer the runtime resolver so broad ensures can reuse compatible active
// registries, including gateway-bindable startup registries.
if (!resolveRuntimePluginRegistry(loadOptions)) {
if (
!getLoadedRuntimePluginRegistry({
env: loadOptions.env,
loadOptions,
workspaceDir: loadOptions.workspaceDir,
requiredPluginIds: loadOptions.onlyPluginIds,
})
) {
loadOpenClawPlugins(loadOptions);
}
}

View File

@@ -0,0 +1,89 @@
import {
type ActiveRuntimePluginRegistrySurface,
getLoadedRuntimePluginRegistry,
} from "../active-runtime-registry.js";
import {
loadOpenClawPlugins,
resolvePluginRegistryLoadCacheKey,
type PluginLoadOptions,
} from "../loader.js";
import type { PluginRegistry } from "../registry-types.js";
import {
pinActivePluginChannelRegistry,
pinActivePluginHttpRouteRegistry,
setActivePluginRegistry,
} from "../runtime.js";
function resolveRuntimeSubagentMode(
loadOptions: PluginLoadOptions,
): "default" | "explicit" | "gateway-bindable" {
if (loadOptions.runtimeOptions?.allowGatewaySubagentBinding === true) {
return "gateway-bindable";
}
if (loadOptions.runtimeOptions?.subagent) {
return "explicit";
}
return "default";
}
function installStandaloneRegistry(
registry: PluginRegistry,
params: {
loadOptions: PluginLoadOptions;
surface: ActiveRuntimePluginRegistrySurface;
},
): void {
const cacheKey = resolvePluginRegistryLoadCacheKey(params.loadOptions);
const mode = resolveRuntimeSubagentMode(params.loadOptions);
setActivePluginRegistry(registry, cacheKey, mode, params.loadOptions.workspaceDir);
switch (params.surface) {
case "active":
break;
case "channel":
pinActivePluginChannelRegistry(registry);
break;
case "http-route":
pinActivePluginHttpRouteRegistry(registry);
break;
}
}
export function ensureStandaloneRuntimePluginRegistryLoaded(params: {
loadOptions: PluginLoadOptions;
requiredPluginIds?: readonly string[];
surface?: ActiveRuntimePluginRegistrySurface;
}): PluginRegistry | undefined {
const requiredPluginIds = params.requiredPluginIds ?? params.loadOptions.onlyPluginIds;
const surface = params.surface ?? "active";
const existing = getLoadedRuntimePluginRegistry({
env: params.loadOptions.env,
loadOptions: params.loadOptions,
workspaceDir: params.loadOptions.workspaceDir,
requiredPluginIds,
surface,
});
if (existing) {
return existing;
}
const registry = loadOpenClawPlugins(params.loadOptions);
if (params.loadOptions.activate !== false) {
switch (surface) {
case "active":
break;
case "channel":
pinActivePluginChannelRegistry(registry);
break;
case "http-route":
pinActivePluginHttpRouteRegistry(registry);
break;
}
return registry;
}
installStandaloneRegistry(registry, {
loadOptions: params.loadOptions,
surface,
});
return registry;
}

View File

@@ -17,6 +17,10 @@ const resolveRuntimePluginRegistryMock = vi.fn();
const applyPluginAutoEnableMock = vi.fn();
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (params: unknown) => loadOpenClawPluginsMock(params),
resolveCompatibleRuntimePluginRegistry: (params: unknown) =>
resolveRuntimePluginRegistryMock(params),
resolvePluginRegistryLoadCacheKey: (params: unknown) => JSON.stringify(params),
resolveRuntimePluginRegistry: (params: unknown) => resolveRuntimePluginRegistryMock(params),
}));
@@ -25,6 +29,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
}));
let resolvePluginTools: typeof import("./tools.js").resolvePluginTools;
let ensureStandalonePluginToolRegistryLoaded: typeof import("./tools.js").ensureStandalonePluginToolRegistryLoaded;
let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey;
let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache;
let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry;
@@ -76,8 +81,9 @@ function createResolveToolsParams(params?: {
};
}
function setRegistry(entries: MockRegistryToolEntry[]) {
const registry = {
function createToolRegistry(entries: MockRegistryToolEntry[]) {
return {
plugins: entries.map((entry) => ({ id: entry.pluginId, status: "loaded" })),
tools: entries,
diagnostics: [] as Array<{
level: string;
@@ -86,7 +92,12 @@ function setRegistry(entries: MockRegistryToolEntry[]) {
message: string;
}>,
};
}
function setRegistry(entries: MockRegistryToolEntry[]) {
const registry = createToolRegistry(entries);
loadOpenClawPluginsMock.mockReturnValue(registry);
setActivePluginRegistry?.(registry as never, "test-tool-registry", "gateway-bindable", "/tmp");
installToolManifestSnapshots({
config: createContext().config,
plugins: entries
@@ -227,11 +238,13 @@ function createOptionalDemoActiveRegistry() {
},
},
});
return {
const registry = {
plugins: [{ id: "optional-demo", status: "loaded" }],
tools: [createOptionalDemoEntry()],
diagnostics: [],
};
setActivePluginRegistry?.(registry as never, "test-tool-registry", "gateway-bindable", "/tmp");
return registry;
}
function installToolManifestSnapshot(params: {
@@ -332,7 +345,8 @@ function expectResolvedToolNames(
}
function expectLoaderCall(overrides: Record<string, unknown>) {
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(expect.objectContaining(overrides));
void overrides;
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
}
function expectSingleDiagnosticMessage(
@@ -362,8 +376,12 @@ function expectConflictingCoreNameResolution(params: {
describe("resolvePluginTools optional tools", () => {
beforeAll(async () => {
({ buildPluginToolMetadataKey, resetPluginToolFactoryCache, resolvePluginTools } =
await import("./tools.js"));
({
buildPluginToolMetadataKey,
ensureStandalonePluginToolRegistryLoaded,
resetPluginToolFactoryCache,
resolvePluginTools,
} = await import("./tools.js"));
({ pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } =
await import("./runtime.js"));
({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } =
@@ -432,9 +450,43 @@ describe("resolvePluginTools optional tools", () => {
expect(tools).toEqual([]);
expect(factory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("standalone bootstrap loads configured plugin tools before resolution", () => {
const config = createContext().config;
const registry = createToolRegistry([createOptionalDemoEntry()]);
loadOpenClawPluginsMock.mockReturnValue(registry);
installToolManifestSnapshot({
config,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
ensureStandalonePluginToolRegistryLoaded({
context: createContext() as never,
toolAllowlist: ["optional_tool"],
});
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
activate: false,
onlyPluginIds: ["optional-demo"],
toolDiscovery: true,
}),
);
});
@@ -473,11 +525,7 @@ describe("resolvePluginTools optional tools", () => {
expect(tools).toEqual([]);
expect(factory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
}),
);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("loads plugin-owned tools when manifest tool metadata has env auth evidence", () => {
@@ -488,18 +536,24 @@ describe("resolvePluginTools optional tools", () => {
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
loadOpenClawPluginsMock.mockReturnValue({
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
});
setActivePluginRegistry(
{
plugins: [{ id: "xai", status: "loaded" }],
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
} as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const tools = resolvePluginTools({
context: {
@@ -513,11 +567,7 @@ describe("resolvePluginTools optional tools", () => {
expectResolvedToolNames(tools, ["x_search"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["xai"],
}),
);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("loads plugin-owned tools when manifest config signals point at configured non-env SecretRefs", () => {
@@ -556,18 +606,24 @@ describe("resolvePluginTools optional tools", () => {
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
loadOpenClawPluginsMock.mockReturnValue({
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
});
setActivePluginRegistry(
{
plugins: [{ id: "xai", status: "loaded" }],
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
} as never,
"test-tool-registry",
"gateway-bindable",
"/tmp",
);
const tools = resolvePluginTools({
context: {
@@ -579,11 +635,7 @@ describe("resolvePluginTools optional tools", () => {
expectResolvedToolNames(tools, ["x_search"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["xai"],
}),
);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("skips optional tools without explicit allowlist", () => {
@@ -688,7 +740,7 @@ describe("resolvePluginTools optional tools", () => {
it.each([
{
name: "forwards an explicit env to plugin loading",
name: "uses loaded plugin tools with an explicit env",
params: {
env: { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv,
toolAllowlist: ["optional_tool"],
@@ -698,7 +750,7 @@ describe("resolvePluginTools optional tools", () => {
},
},
{
name: "forwards gateway subagent binding to plugin runtime options",
name: "uses loaded plugin tools with gateway subagent binding",
params: {
allowGatewaySubagentBinding: true,
toolAllowlist: ["optional_tool"],
@@ -1057,7 +1109,7 @@ describe("resolvePluginTools optional tools", () => {
it("reuses the gateway-bindable registry when it covers the tool runtime scope", () => {
const activeRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable");
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
const tools = resolvePluginTools(
@@ -1104,7 +1156,7 @@ describe("resolvePluginTools optional tools", () => {
],
diagnostics: [],
};
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable");
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
const tools = resolvePluginTools(
@@ -1122,7 +1174,7 @@ describe("resolvePluginTools optional tools", () => {
it("adds enabled non-startup tool plugins to the active tool runtime scope", () => {
const activeRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable");
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
resolvePluginTools({
@@ -1142,23 +1194,27 @@ describe("resolvePluginTools optional tools", () => {
allowGatewaySubagentBinding: true,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["tavily"],
}),
);
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
});
it("reuses the pinned gateway channel registry after provider runtime loads replace active registry", () => {
const gatewayRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(
gatewayRegistry as never,
"gateway-startup",
"gateway-bindable",
"/tmp",
);
pinActivePluginChannelRegistry(gatewayRegistry as never);
setActivePluginRegistry(
{
plugins: [],
tools: [],
diagnostics: [],
} as never,
"provider-runtime",
"default",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
@@ -1176,14 +1232,22 @@ describe("resolvePluginTools optional tools", () => {
it("reuses the pinned gateway channel registry even when the caller omits gateway binding", () => {
const gatewayRegistry = createOptionalDemoActiveRegistry();
setActivePluginRegistry(
gatewayRegistry as never,
"gateway-startup",
"gateway-bindable",
"/tmp",
);
pinActivePluginChannelRegistry(gatewayRegistry as never);
setActivePluginRegistry(
{
plugins: [],
tools: [],
diagnostics: [],
} as never,
"provider-runtime",
"default",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
@@ -1219,6 +1283,7 @@ describe("resolvePluginTools optional tools", () => {
it("reloads when gateway binding would otherwise reuse a default-mode active registry", () => {
setActivePluginRegistry(
{
plugins: [],
tools: [],
diagnostics: [],
} as never,
@@ -1233,13 +1298,7 @@ describe("resolvePluginTools optional tools", () => {
toolAllowlist: ["optional_tool"],
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
}),
);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,24 +1,20 @@
import { normalizeToolName } from "../agents/tool-policy.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js";
import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js";
import type { PluginLoadOptions } from "./loader.js";
import {
isManifestPluginAvailableForControlPlane,
loadManifestContractSnapshot,
} from "./manifest-contract-eligibility.js";
import { hasManifestToolAvailability } from "./manifest-tool-availability.js";
import type { PluginToolRegistration } from "./registry-types.js";
import {
getActivePluginChannelRegistry,
getActivePluginRegistry,
getActivePluginRegistryKey,
getActivePluginRuntimeSubagentMode,
} from "./runtime.js";
import {
buildPluginRuntimeLoadOptions,
resolvePluginRuntimeLoadContext,
} from "./runtime/load-context.js";
import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js";
import { findUndeclaredPluginToolNames } from "./tool-contracts.js";
import {
buildPluginToolFactoryCacheKey,
@@ -406,51 +402,32 @@ function resolvePluginToolRuntimePluginIds(params: {
return [...pluginIds].toSorted((left, right) => left.localeCompare(right));
}
function registryContainsPluginIds(
registry: ReturnType<typeof getActivePluginRegistry>,
pluginIds?: readonly string[],
): boolean {
if (!registry || pluginIds === undefined || pluginIds.length === 0) {
return false;
}
const loadedPluginIds = new Set(
(registry.plugins ?? [])
.filter((plugin) => plugin.status === undefined || plugin.status === "loaded")
.map((plugin) => plugin.id),
);
return pluginIds.every((pluginId) => loadedPluginIds.has(pluginId));
}
function resolvePluginToolRegistry(params: {
loadOptions: PluginLoadOptions;
onlyPluginIds?: readonly string[];
}) {
const activeRegistry = getActivePluginRegistry();
const channelRegistry = getActivePluginChannelRegistry();
const activeRegistryIsGatewayBindable =
getActivePluginRegistryKey() && getActivePluginRuntimeSubagentMode() === "gateway-bindable";
const hasPinnedGatewayRegistry = Boolean(channelRegistry && channelRegistry !== activeRegistry);
if (
channelRegistry &&
(activeRegistryIsGatewayBindable || hasPinnedGatewayRegistry) &&
registryContainsPluginIds(channelRegistry, params.onlyPluginIds)
) {
return channelRegistry;
}
return resolveRuntimePluginRegistry(params.loadOptions);
return getLoadedRuntimePluginRegistry({
env: params.loadOptions.env,
loadOptions: params.loadOptions,
workspaceDir: params.loadOptions.workspaceDir,
requiredPluginIds: params.onlyPluginIds,
surface: "channel",
});
}
export function resolvePluginTools(params: {
function resolvePluginToolLoadState(params: {
context: OpenClawPluginToolContext;
existingToolNames?: Set<string>;
toolAllowlist?: string[];
suppressNameConflicts?: boolean;
allowGatewaySubagentBinding?: boolean;
hasAuthForProvider?: (providerId: string) => boolean;
env?: NodeJS.ProcessEnv;
}): AnyAgentTool[] {
// Fast path: when plugins are effectively disabled, avoid discovery/jiti entirely.
// This matters a lot for unit tests and for tool construction hot paths.
}):
| {
context: ReturnType<typeof resolvePluginRuntimeLoadContext>;
loadOptions: PluginLoadOptions;
onlyPluginIds: string[];
}
| undefined {
const env = params.env ?? process.env;
const baseConfig = applyTestPluginDefaults(params.context.config ?? {}, env);
const context = resolvePluginRuntimeLoadContext({
@@ -460,7 +437,7 @@ export function resolvePluginTools(params: {
});
const normalized = normalizePluginsConfig(context.config.plugins);
if (!normalized.enabled) {
return [];
return undefined;
}
const runtimeOptions = params.allowGatewaySubagentBinding
@@ -480,6 +457,43 @@ export function resolvePluginTools(params: {
...(onlyPluginIds !== undefined ? { onlyPluginIds } : {}),
runtimeOptions,
});
return { context, loadOptions, onlyPluginIds };
}
export function ensureStandalonePluginToolRegistryLoaded(params: {
context: OpenClawPluginToolContext;
toolAllowlist?: string[];
allowGatewaySubagentBinding?: boolean;
hasAuthForProvider?: (providerId: string) => boolean;
env?: NodeJS.ProcessEnv;
}): void {
const loadState = resolvePluginToolLoadState(params);
if (!loadState) {
return;
}
ensureStandaloneRuntimePluginRegistryLoaded({
surface: "channel",
requiredPluginIds: loadState.onlyPluginIds,
loadOptions: loadState.loadOptions,
});
}
export function resolvePluginTools(params: {
context: OpenClawPluginToolContext;
existingToolNames?: Set<string>;
toolAllowlist?: string[];
suppressNameConflicts?: boolean;
allowGatewaySubagentBinding?: boolean;
hasAuthForProvider?: (providerId: string) => boolean;
env?: NodeJS.ProcessEnv;
}): AnyAgentTool[] {
// Fast path: when plugins are effectively disabled, avoid discovery/jiti entirely.
// This matters a lot for unit tests and for tool construction hot paths.
const loadState = resolvePluginToolLoadState(params);
if (!loadState) {
return [];
}
const { context, loadOptions, onlyPluginIds } = loadState;
const registry = resolvePluginToolRegistry({
loadOptions,
onlyPluginIds,

View File

@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
isPluginRegistryLoadInFlight: vi.fn(() => false),
loadOpenClawPlugins: vi.fn(),
resolveCompatibleRuntimePluginRegistry: vi.fn(),
getLoadedRuntimePluginRegistry: vi.fn(),
resolvePluginRegistryLoadCacheKey: vi.fn((options: unknown) => JSON.stringify(options)),
resolveRuntimePluginRegistry: vi.fn(),
getActivePluginRegistry: vi.fn<() => Record<string, unknown> | null>(() => null),
@@ -29,6 +30,10 @@ vi.mock("./loader.js", () => ({
resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry,
}));
vi.mock("./active-runtime-registry.js", () => ({
getLoadedRuntimePluginRegistry: mocks.getLoadedRuntimePluginRegistry,
}));
vi.mock("./runtime.js", () => ({
getActivePluginRegistry: mocks.getActivePluginRegistry,
getActivePluginRegistryWorkspaceDir: mocks.getActivePluginRegistryWorkspaceDir,
@@ -53,6 +58,8 @@ describe("web-provider-runtime-shared", () => {
mocks.isPluginRegistryLoadInFlight.mockReturnValue(false);
mocks.loadOpenClawPlugins.mockReset();
mocks.resolveCompatibleRuntimePluginRegistry.mockReset();
mocks.getLoadedRuntimePluginRegistry.mockReset();
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(undefined);
mocks.resolvePluginRegistryLoadCacheKey.mockReset();
mocks.resolvePluginRegistryLoadCacheKey.mockImplementation((options: unknown) =>
JSON.stringify(options),
@@ -72,7 +79,7 @@ describe("web-provider-runtime-shared", () => {
it("preserves explicit empty scopes in runtime-compatible web provider loads", () => {
const mapRegistryProviders = vi.fn(() => []);
mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue({} as never);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue({} as never);
resolvePluginWebProviders(
{
@@ -90,9 +97,9 @@ describe("web-provider-runtime-shared", () => {
},
);
expect(mocks.resolveCompatibleRuntimePluginRegistry).toHaveBeenCalledWith(
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
requiredPluginIds: [],
}),
);
expect(mapRegistryProviders).toHaveBeenCalledWith(
@@ -104,7 +111,7 @@ describe("web-provider-runtime-shared", () => {
it("preserves explicit empty scopes in direct runtime web provider resolution", () => {
const mapRegistryProviders = vi.fn(() => []);
mocks.resolveRuntimePluginRegistry.mockReturnValue({} as never);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue({} as never);
resolveRuntimeWebProviders(
{
@@ -122,9 +129,9 @@ describe("web-provider-runtime-shared", () => {
},
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
requiredPluginIds: [],
}),
);
expect(mapRegistryProviders).toHaveBeenCalledWith(
@@ -136,7 +143,7 @@ describe("web-provider-runtime-shared", () => {
it("preserves explicit scopes when config is omitted in direct runtime resolution", () => {
const mapRegistryProviders = vi.fn(() => []);
mocks.resolveRuntimePluginRegistry.mockReturnValue({} as never);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue({} as never);
resolveRuntimeWebProviders(
{
@@ -153,7 +160,11 @@ describe("web-provider-runtime-shared", () => {
},
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(undefined);
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
requiredPluginIds: ["alpha"],
}),
);
expect(mapRegistryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["alpha"],
@@ -166,8 +177,7 @@ describe("web-provider-runtime-shared", () => {
const resolvedConfig = { plugins: { entries: { brave: { enabled: true } } } };
const resolveCandidatePluginIds = vi.fn(() => ["brave"]);
const mapRegistryProviders = vi.fn(() => ["provider"]);
mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(null);
mocks.getActivePluginRegistry.mockReturnValue(activeRegistry);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry);
const providers = resolvePluginWebProviders(
{
@@ -206,8 +216,7 @@ describe("web-provider-runtime-shared", () => {
it("preserves explicit empty candidate scopes when reusing the active registry", () => {
const activeRegistry = { source: "active" };
const mapRegistryProviders = vi.fn(() => []);
mocks.resolveCompatibleRuntimePluginRegistry.mockReturnValue(null);
mocks.getActivePluginRegistry.mockReturnValue(activeRegistry);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry);
resolvePluginWebProviders(
{
@@ -232,10 +241,10 @@ describe("web-provider-runtime-shared", () => {
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("caches runtime web provider plugin loads by default", () => {
it("uses loaded runtime web providers without runtime plugin loads", () => {
const loadedRegistry = { source: "loaded" };
const mapRegistryProviders = vi.fn(() => ["provider"]);
mocks.loadOpenClawPlugins.mockReturnValue(loadedRegistry as never);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(loadedRegistry as never);
const providers = resolvePluginWebProviders(
{
@@ -254,24 +263,18 @@ describe("web-provider-runtime-shared", () => {
);
expect(providers).toEqual(["provider"]);
expect(mocks.resolveCompatibleRuntimePluginRegistry).toHaveBeenCalledWith(
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
cache: true,
onlyPluginIds: ["brave"],
}),
);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
cache: true,
onlyPluginIds: ["brave"],
requiredPluginIds: ["brave"],
}),
);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("keeps explicit runtime web provider cache opt-outs", () => {
it("ignores runtime web provider cache opt-outs after startup loading", () => {
const loadedRegistry = { source: "loaded" };
const mapRegistryProviders = vi.fn(() => ["provider"]);
mocks.loadOpenClawPlugins.mockReturnValue(loadedRegistry as never);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(loadedRegistry as never);
resolvePluginWebProviders(
{
@@ -290,12 +293,12 @@ describe("web-provider-runtime-shared", () => {
},
);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect(mocks.getLoadedRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
cache: false,
onlyPluginIds: ["brave"],
requiredPluginIds: ["brave"],
}),
);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("caches setup web provider plugin loads by default", () => {

View File

@@ -1,15 +1,11 @@
import { withActivatedPluginIds } from "./activation-context.js";
import {
isPluginRegistryLoadInFlight,
loadOpenClawPlugins,
resolveCompatibleRuntimePluginRegistry,
resolveRuntimePluginRegistry,
} from "./loader.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import { isPluginRegistryLoadInFlight, loadOpenClawPlugins } from "./loader.js";
import type { PluginLoadOptions } from "./loader.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import { hasExplicitPluginIdScope, normalizePluginIdScope } from "./plugin-scope.js";
import type { PluginRegistry } from "./registry.js";
import { getActivePluginRegistry, getActivePluginRegistryWorkspaceDir } from "./runtime.js";
import { getActivePluginRegistryWorkspaceDir } from "./runtime.js";
import {
buildPluginRuntimeLoadOptionsFromValues,
createPluginRuntimeLoaderLogger,
@@ -176,7 +172,12 @@ export function resolvePluginWebProviders<TEntry>(
const context = resolveWebProviderRuntimeContext(params, deps);
const loadOptions = resolveWebProviderLoadOptions(context, params);
const compatible = resolveCompatibleRuntimePluginRegistry(loadOptions);
const compatible = getLoadedRuntimePluginRegistry({
env: context.env,
loadOptions,
workspaceDir: context.workspaceDir,
requiredPluginIds: context.onlyPluginIds,
});
if (compatible) {
return deps.mapRegistryProviders({
registry: compatible,
@@ -188,33 +189,21 @@ export function resolvePluginWebProviders<TEntry>(
}
const scopedPluginIds = context.onlyPluginIds;
const hasExplicitEmptyScope = scopedPluginIds !== undefined && scopedPluginIds.length === 0;
const activeRegistry = getActivePluginRegistry();
if (activeRegistry) {
const activeProviders = deps.mapRegistryProviders({
registry: activeRegistry,
onlyPluginIds: context.onlyPluginIds,
});
if (activeProviders.length > 0 || hasExplicitEmptyScope) {
return activeProviders;
}
}
if (hasExplicitEmptyScope) {
return [];
}
return deps.mapRegistryProviders({
registry: loadOpenClawPlugins(loadOptions),
});
return [];
}
export function resolveRuntimeWebProviders<TEntry>(
params: Omit<ResolvePluginWebProvidersParams, "activate" | "cache" | "mode">,
deps: ResolveWebProviderRuntimeDeps<TEntry>,
): TEntry[] {
const loadOptions =
params.config === undefined
? undefined
: resolveWebProviderLoadOptions(resolveWebProviderRuntimeContext(params, deps), params);
const runtimeRegistry = resolveRuntimePluginRegistry(loadOptions);
const runtimeRegistry = getLoadedRuntimePluginRegistry({
env: params.env,
workspaceDir: params.workspaceDir,
requiredPluginIds: params.onlyPluginIds,
});
if (runtimeRegistry) {
return deps.mapRegistryProviders({
registry: runtimeRegistry,

View File

@@ -93,12 +93,16 @@ export async function detectSetupMigrationSources(params: {
config: OpenClawConfig;
runtime: RuntimeEnv;
}): Promise<SetupMigrationDetection[]> {
const [{ resolvePluginMigrationProviders }, { createMigrationLogger }, { resolveStateDir }] =
await Promise.all([
import("../plugins/migration-provider-runtime.js"),
import("../commands/migrate/context.js"),
import("../config/paths.js"),
]);
const [
{ ensureStandaloneMigrationProviderRegistryLoaded, resolvePluginMigrationProviders },
{ createMigrationLogger },
{ resolveStateDir },
] = await Promise.all([
import("../plugins/migration-provider-runtime.js"),
import("../commands/migrate/context.js"),
import("../config/paths.js"),
]);
ensureStandaloneMigrationProviderRegistryLoaded({ cfg: params.config });
const stateDir = resolveStateDir();
const logger = createMigrationLogger(params.runtime);
const detections: SetupMigrationDetection[] = [];
@@ -151,8 +155,12 @@ async function selectSetupMigrationProvider(params: {
provider: MigrationProviderPlugin;
providerId: string;
}> {
const { resolvePluginMigrationProvider, resolvePluginMigrationProviders } =
await import("../plugins/migration-provider-runtime.js");
const {
ensureStandaloneMigrationProviderRegistryLoaded,
resolvePluginMigrationProvider,
resolvePluginMigrationProviders,
} = await import("../plugins/migration-provider-runtime.js");
ensureStandaloneMigrationProviderRegistryLoaded({ cfg: params.baseConfig });
const providers = resolvePluginMigrationProviders({ cfg: params.baseConfig });
if (providers.length === 0) {
throw new Error("No migration providers found.");