diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b5175f9fc..c9164b42198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index c8dfc220b0f..cd91d9826f5 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -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 | undefined)?.baseUrl, + headerAuth: ( + (params.context.model as Record | undefined)?.headers as + | Record + | 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: { diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index ba0af047df7..48091785712 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -38,6 +38,8 @@ const providerRuntimeDeps = { ...defaultProviderRuntimeDeps, }; +let preparedExtraParamsCache = new WeakMap>>(); + export const __testing = { setProviderRuntimeDepsForTest( deps: Partial | 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; + 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; + thinkingLevel?: ThinkLevel; + agentId?: string; + resolvedExtraParams?: Record; + 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( diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index 5587f784ba7..9639b49ec27 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -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, + }, }, }); }); diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index 61258a1de6b..e6a1e39a89c 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -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, + }, + }); } diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts index 76819b447eb..c25d5a9c8b2 100644 --- a/src/cli/plugin-registry.test.ts +++ b/src/cli/plugin-registry.test.ts @@ -29,6 +29,8 @@ function withActivatedPluginIdsForTest>( const mocks = vi.hoisted(() => ({ loadOpenClawPlugins: vi.fn(), + resolveCompatibleRuntimePluginRegistry: + vi.fn(), resolveRuntimePluginRegistry: vi.fn(), getActivePluginRegistry: vi.fn(), @@ -50,6 +52,9 @@ let resetPluginRegistryLoadedForTests: typeof import("./plugin-registry.js").__t vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins: (...args: Parameters) => mocks.loadOpenClawPlugins(...args), + resolveCompatibleRuntimePluginRegistry: ( + ...args: Parameters + ) => mocks.resolveCompatibleRuntimePluginRegistry(...args), resolveRuntimePluginRegistry: (...args: Parameters) => 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) => { diff --git a/src/commands/migrate.test.ts b/src/commands/migrate.test.ts index aad729848ec..c2926d83140 100644 --- a/src/commands/migrate.test.ts +++ b/src/commands/migrate.test.ts @@ -38,6 +38,7 @@ vi.mock("@clack/prompts", () => ({ })); vi.mock("../plugins/migration-provider-runtime.js", () => ({ + ensureStandaloneMigrationProviderRegistryLoaded: vi.fn(), resolvePluginMigrationProvider: () => mocks.provider, resolvePluginMigrationProviders: () => [mocks.provider], })); diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index dcd61d32561..598ce57b023 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -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; diff --git a/src/commands/migrate/providers.ts b/src/commands/migrate/providers.ts index 70b0c8f98e2..2f007df0dfe 100644 --- a/src/commands/migrate/providers.ts +++ b/src/commands/migrate/providers.ts @@ -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); diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 317c298a779..405465641d8 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -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, diff --git a/src/infra/outbound/channel-bootstrap.runtime.ts b/src/infra/outbound/channel-bootstrap.runtime.ts index 6c0c5aee934..8eb0dab460e 100644 --- a/src/infra/outbound/channel-bootstrap.runtime.ts +++ b/src/infra/outbound/channel-bootstrap.runtime.ts @@ -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(); - 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; } diff --git a/src/infra/outbound/channel-resolution.test.ts b/src/infra/outbound/channel-resolution.test.ts index d363d589f31..e02c5c0e08b 100644 --- a/src/infra/outbound/channel-resolution.test.ts +++ b/src/infra/outbound/channel-resolution.test.ts @@ -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(); }); }); diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index 5566875be12..f7b4e29e4d5 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -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(); }); }); diff --git a/src/mcp/plugin-tools-serve.test.ts b/src/mcp/plugin-tools-serve.test.ts index f5bf216adcb..f89649c48eb 100644 --- a/src/mcp/plugin-tools-serve.test.ts +++ b/src/mcp/plugin-tools-serve.test.ts @@ -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(); 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, ); diff --git a/src/mcp/plugin-tools-serve.ts b/src/mcp/plugin-tools-serve.ts index 010f7983a0e..8aa64660780 100644 --- a/src/mcp/plugin-tools-serve.ts +++ b/src/mcp/plugin-tools-serve.ts @@ -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, diff --git a/src/plugins/active-runtime-registry.test.ts b/src/plugins/active-runtime-registry.test.ts new file mode 100644 index 00000000000..47952ca02b0 --- /dev/null +++ b/src/plugins/active-runtime-registry.test.ts @@ -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(); + }); +}); diff --git a/src/plugins/active-runtime-registry.ts b/src/plugins/active-runtime-registry.ts index 336248801a2..f3b17afddf1 100644 --- a/src/plugins/active-runtime-registry.ts +++ b/src/plugins/active-runtime-registry.ts @@ -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; +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(); + 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; } diff --git a/src/plugins/agent-tool-result-middleware-loader.ts b/src/plugins/agent-tool-result-middleware-loader.ts index f567f9c3697..647067327d8 100644 --- a/src/plugins/agent-tool-result-middleware-loader.ts +++ b/src/plugins/agent-tool-result-middleware-loader.ts @@ -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)) diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 1e99ab44df6..34cf7ff2cf1 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -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, "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"]); }); }); diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 204bf4ea6a9..236cc384ad0 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -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( loadOptions: PluginLoadOptions; requested?: Set; }): 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 0 diff --git a/src/plugins/memory-runtime.test.ts b/src/plugins/memory-runtime.test.ts index f02b6a19cd1..64d2647eb46 100644 --- a/src/plugins/memory-runtime.test.ts +++ b/src/plugins/memory-runtime.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const resolveRuntimePluginRegistryMock = vi.fn(); +const getLoadedRuntimePluginRegistryMock = + vi.fn(); const applyPluginAutoEnableMock = vi.fn(); const getMemoryRuntimeMock = vi.fn(); @@ -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([ diff --git a/src/plugins/memory-runtime.ts b/src/plugins/memory-runtime.ts index 23c428e4f96..1c6bd41a3d8 100644 --- a/src/plugins/memory-runtime.ts +++ b/src/plugins/memory-runtime.ts @@ -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(); } diff --git a/src/plugins/migration-provider-runtime.test.ts b/src/plugins/migration-provider-runtime.test.ts index 2495c56bc6e..af31c605c42 100644 --- a/src/plugins/migration-provider-runtime.test.ts +++ b/src/plugins/migration-provider-runtime.test.ts @@ -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, }); }); diff --git a/src/plugins/migration-provider-runtime.ts b/src/plugins/migration-provider-runtime.ts index 2601f975248..b4419d76f68 100644 --- a/src/plugins/migration-provider-runtime.ts +++ b/src/plugins/migration-provider-runtime.ts @@ -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 ?? []); } diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index 2a7586ea86f..a758bf6a1ad 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -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, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 19009833ea6..9c5568709a4 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -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, diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index fff916b5263..feed999068f 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -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 []; } diff --git a/src/plugins/runtime-registry-boundary.test.ts b/src/plugins/runtime-registry-boundary.test.ts new file mode 100644 index 00000000000..928581be420 --- /dev/null +++ b/src/plugins/runtime-registry-boundary.test.ts @@ -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([]); + }); +}); diff --git a/src/plugins/runtime/runtime-registry-loader.test.ts b/src/plugins/runtime/runtime-registry-loader.test.ts index dfeb7ad91ae..f28d67d9d4c 100644 --- a/src/plugins/runtime/runtime-registry-loader.test.ts +++ b/src/plugins/runtime/runtime-registry-loader.test.ts @@ -3,6 +3,8 @@ import { createEmptyPluginRegistry } from "../registry.js"; const mocks = vi.hoisted(() => ({ loadOpenClawPlugins: vi.fn(), + resolveCompatibleRuntimePluginRegistry: + vi.fn(), resolveRuntimePluginRegistry: vi.fn(), getActivePluginRegistry: vi.fn(), resolveConfiguredChannelPluginIds: @@ -27,14 +29,19 @@ let resetPluginRegistryLoadedForTests: typeof import("./runtime-registry-loader. vi.mock("../loader.js", () => ({ loadOpenClawPlugins: (...args: Parameters) => mocks.loadOpenClawPlugins(...args), + resolveCompatibleRuntimePluginRegistry: ( + ...args: Parameters + ) => mocks.resolveCompatibleRuntimePluginRegistry(...args), resolveRuntimePluginRegistry: (...args: Parameters) => mocks.resolveRuntimePluginRegistry(...args), })); vi.mock("../runtime.js", () => ({ getActivePluginChannelRegistry: () => null, + getActivePluginHttpRouteRegistry: () => null, getActivePluginRegistry: (...args: Parameters) => 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) => 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(); }); }); diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts index b0799e7bf92..4354d008762 100644 --- a/src/plugins/runtime/runtime-registry-loader.ts +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -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[0], + loadOptions: NonNullable[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); } } diff --git a/src/plugins/runtime/standalone-runtime-registry-loader.ts b/src/plugins/runtime/standalone-runtime-registry-loader.ts new file mode 100644 index 00000000000..3bb78ba8e72 --- /dev/null +++ b/src/plugins/runtime/standalone-runtime-registry-loader.ts @@ -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; +} diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 3e0183e7976..9e6d5ea112c 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -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) { - 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(); }); }); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 316e6f80c70..97492c69844 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -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, - 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; 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; + 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; + 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, diff --git a/src/plugins/web-provider-runtime-shared.test.ts b/src/plugins/web-provider-runtime-shared.test.ts index 178d8c78028..dd977d1dc47 100644 --- a/src/plugins/web-provider-runtime-shared.test.ts +++ b/src/plugins/web-provider-runtime-shared.test.ts @@ -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 | 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", () => { diff --git a/src/plugins/web-provider-runtime-shared.ts b/src/plugins/web-provider-runtime-shared.ts index 6ba2e1e746b..33129a9a1f8 100644 --- a/src/plugins/web-provider-runtime-shared.ts +++ b/src/plugins/web-provider-runtime-shared.ts @@ -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( 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( } 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( params: Omit, deps: ResolveWebProviderRuntimeDeps, ): 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, diff --git a/src/wizard/setup.migration-import.ts b/src/wizard/setup.migration-import.ts index f0ea06730ba..f955234c17e 100644 --- a/src/wizard/setup.migration-import.ts +++ b/src/wizard/setup.migration-import.ts @@ -93,12 +93,16 @@ export async function detectSetupMigrationSources(params: { config: OpenClawConfig; runtime: RuntimeEnv; }): Promise { - 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.");