fix(plugins): scope install slot selection

This commit is contained in:
Vincent Koc
2026-05-01 05:26:22 -07:00
parent 73891eaca6
commit f0c7c430f5
4 changed files with 145 additions and 59 deletions

View File

@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- fix(infra): block ambient Homebrew env vars from brew resolution. (#74463) Thanks @pgondhi987.
- Thinking/providers: resolve bundled provider thinking profiles through lightweight provider policy artifacts when startup-lazy providers are not active, so OpenAI Codex GPT-5.x keeps xhigh available in Gateway session validation. Fixes #74796. Thanks @maxschachere.
- Security/Windows: ignore workspace `.env` system-path variables and resolve stale-process `taskkill.exe` from the validated Windows install root, preventing repository-local env files from redirecting cleanup helpers. Thanks @pgondhi987.
- CLI/plugins: scope install and enable slot selection to the selected plugin manifest/runtime fallback, so plugin installs no longer load every plugin runtime or broad status snapshot just to update memory/context slots. Thanks @vincentkoc.
- Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc.
- Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in `googlemeet doctor`, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf.
- Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps.

View File

@@ -57,7 +57,7 @@ export const writePersistedInstalledPluginIndexInstallRecords: AsyncUnknownMock
);
},
);
const loadPluginManifestRegistry: UnknownMock = vi.fn();
export const loadPluginManifestRegistry: UnknownMock = vi.fn();
export const buildPluginSnapshotReport: UnknownMock = vi.fn();
export const buildPluginRegistrySnapshotReport: UnknownMock = vi.fn();
export const buildPluginInspectReport: UnknownMock = vi.fn();
@@ -288,7 +288,10 @@ vi.mock("../plugins/status.js", () => ({
}));
vi.mock("../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: [] }),
loadPluginManifestRegistryForPluginRegistry: ((...args: unknown[]) =>
invokeMock<unknown[], unknown>(loadPluginManifestRegistry, ...args)) as (
...args: unknown[]
) => unknown,
inspectPluginRegistry: ((
...args: Parameters<(typeof import("../plugins/plugin-registry.js"))["inspectPluginRegistry"]>
) =>

View File

@@ -1,8 +1,10 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
import { applyExclusiveSlotSelection, slotKeysForPluginKind } from "../plugins/slots.js";
import { buildPluginDiagnosticsReport, buildPluginSnapshotReport } from "../plugins/status.js";
import type { PluginKind } from "../plugins/plugin-kind.types.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
import type { PluginLogger } from "../plugins/types.js";
import { defaultRuntime } from "../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
@@ -17,6 +19,59 @@ export const quietPluginJsonLogger: PluginLogger = {
error: () => undefined,
};
type SlotSelectionPlugin = {
id: string;
kind?: PluginKind | PluginKind[];
};
type SlotSelectionRegistry = {
plugins: readonly SlotSelectionPlugin[];
};
function mergeRuntimeKinds(
report: SlotSelectionRegistry,
runtimeReport: SlotSelectionRegistry,
): SlotSelectionRegistry {
const runtimeKinds = new Map(
runtimeReport.plugins
.filter((plugin) => plugin.kind)
.map((plugin) => [plugin.id, plugin.kind] as const),
);
return {
plugins: report.plugins.map((plugin) => {
if (plugin.kind) {
return plugin;
}
const runtimeKind = runtimeKinds.get(plugin.id);
return runtimeKind ? { ...plugin, kind: runtimeKind } : plugin;
}),
};
}
function loadRuntimeKindReportForPlugins(config: OpenClawConfig, pluginIds: readonly string[]) {
return buildPluginDiagnosticsReport({
config,
onlyPluginIds: [...pluginIds],
});
}
function buildSlotSelectionRegistry(
config: OpenClawConfig,
pluginId: string,
): SlotSelectionRegistry {
const registry = loadPluginManifestRegistryForPluginRegistry({
config,
includeDisabled: true,
pluginIds: [pluginId],
});
return {
plugins: registry.plugins.map((plugin) => ({
id: plugin.id,
kind: plugin.kind,
})),
};
}
export function resolveFileNpmSpecToLocalPath(
raw: string,
): { ok: true; path: string } | { ok: false; error: string } | null {
@@ -47,34 +102,20 @@ export function applySlotSelectionForPlugin(
config: OpenClawConfig,
pluginId: string,
): { config: OpenClawConfig; warnings: string[] } {
const report = buildPluginSnapshotReport({ config });
const report = buildSlotSelectionRegistry(config, pluginId);
const plugin = report.plugins.find((entry) => entry.id === pluginId);
if (!plugin) {
return { config, warnings: [] };
}
if (
plugin.kind &&
slotKeysForPluginKind(plugin.kind).length > 0 &&
report.plugins.some((entry) => entry.id !== plugin.id && !entry.kind)
) {
const runtimeReport = buildPluginDiagnosticsReport({ config });
const result = applyExclusiveSlotSelection({
config,
selectedId: plugin.id,
selectedKind: plugin.kind,
registry: runtimeReport,
});
return { config: result.config, warnings: result.warnings };
}
if (!plugin.kind) {
const runtimeReport = buildPluginDiagnosticsReport({ config });
const runtimeReport = loadRuntimeKindReportForPlugins(config, [plugin.id]);
const runtimePlugin = runtimeReport.plugins.find((entry) => entry.id === plugin.id);
if (runtimePlugin?.kind) {
const result = applyExclusiveSlotSelection({
config,
selectedId: runtimePlugin.id,
selectedKind: runtimePlugin.kind,
registry: runtimeReport,
registry: mergeRuntimeKinds(report, runtimeReport),
});
return { config: result.config, warnings: result.warnings };
}

View File

@@ -3,8 +3,8 @@ import type { OpenClawConfig } from "../config/config.js";
import {
applyExclusiveSlotSelection,
buildPluginDiagnosticsReport,
buildPluginSnapshotReport,
enablePluginInConfig,
loadPluginManifestRegistry,
refreshPluginRegistry,
resetPluginsCliTestState,
writeConfigFile,
@@ -111,7 +111,7 @@ describe("persistPluginInstall", () => {
expect(next).toEqual(enabledConfig);
});
it("falls back to runtime kind registry cleanup when metadata omits kind", async () => {
it("scopes runtime kind lookup to the selected plugin when metadata omits kind", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
@@ -129,15 +129,12 @@ describe("persistPluginInstall", () => {
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
buildPluginSnapshotReport.mockReturnValue({
plugins: [{ id: "legacy-memory-a" }, { id: "legacy-memory" }],
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "legacy-memory" }],
diagnostics: [],
});
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [
{ id: "legacy-memory-a", kind: "memory" },
{ id: "legacy-memory", kind: "memory" },
],
buildPluginDiagnosticsReport.mockReturnValueOnce({
plugins: [{ id: "legacy-memory", kind: "memory" }],
diagnostics: [],
});
applyExclusiveSlotSelection.mockImplementation(((params: {
@@ -148,19 +145,12 @@ describe("persistPluginInstall", () => {
}) => {
expect(params.selectedId).toBe("legacy-memory");
expect(params.selectedKind).toBe("memory");
expect(params.registry?.plugins).toEqual([
{ id: "legacy-memory-a", kind: "memory" },
{ id: "legacy-memory", kind: "memory" },
]);
expect(params.registry?.plugins).toEqual([{ id: "legacy-memory", kind: "memory" }]);
return {
config: {
...params.config,
plugins: {
...params.config.plugins,
entries: {
...params.config.plugins?.entries,
"legacy-memory-a": { enabled: false },
},
slots: {
...params.config.plugins?.slots,
memory: "legacy-memory",
@@ -185,14 +175,21 @@ describe("persistPluginInstall", () => {
},
});
expect(buildPluginDiagnosticsReport).toHaveBeenCalledTimes(1);
expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith({
config: enabledConfig,
onlyPluginIds: ["legacy-memory"],
});
expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(false);
expect(loadPluginManifestRegistry).toHaveBeenCalledWith({
config: enabledConfig,
includeDisabled: true,
pluginIds: ["legacy-memory"],
});
expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true);
expect(next.plugins?.slots?.memory).toBe("legacy-memory");
});
it("uses runtime registry cleanup when a manifest-kind plugin has runtime-kind siblings", async () => {
it("uses cold metadata for manifest-kind slot selection without loading runtime siblings", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
@@ -210,15 +207,8 @@ describe("persistPluginInstall", () => {
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
buildPluginSnapshotReport.mockReturnValue({
plugins: [{ id: "legacy-memory-a" }, { id: "memory-b", kind: "memory" }],
diagnostics: [],
});
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [
{ id: "legacy-memory-a", kind: "memory" },
{ id: "memory-b", kind: "memory" },
],
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "memory-b", kind: "memory" }],
diagnostics: [],
});
applyExclusiveSlotSelection.mockImplementation(((params: {
@@ -229,19 +219,12 @@ describe("persistPluginInstall", () => {
}) => {
expect(params.selectedId).toBe("memory-b");
expect(params.selectedKind).toBe("memory");
expect(params.registry?.plugins).toEqual([
{ id: "legacy-memory-a", kind: "memory" },
{ id: "memory-b", kind: "memory" },
]);
expect(params.registry?.plugins).toEqual([{ id: "memory-b", kind: "memory" }]);
return {
config: {
...params.config,
plugins: {
...params.config.plugins,
entries: {
...params.config.plugins?.entries,
"legacy-memory-a": { enabled: false },
},
slots: {
...params.config.plugins?.slots,
memory: "memory-b",
@@ -266,11 +249,69 @@ describe("persistPluginInstall", () => {
},
});
expect(buildPluginDiagnosticsReport).not.toHaveBeenCalled();
expect(loadPluginManifestRegistry).toHaveBeenCalledWith({
config: enabledConfig,
includeDisabled: true,
pluginIds: ["memory-b"],
});
expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(true);
expect(next.plugins?.slots?.memory).toBe("memory-b");
});
it("does not load every plugin runtime for non-slot installs without manifest kind", async () => {
const { persistPluginInstall } = await import("./plugins-install-persist.js");
const baseConfig = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
plain: { enabled: true },
},
},
} as OpenClawConfig;
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "plain" }],
diagnostics: [],
});
buildPluginDiagnosticsReport.mockReturnValue({
plugins: [{ id: "plain" }],
diagnostics: [],
});
applyExclusiveSlotSelection.mockReturnValue({
config: enabledConfig,
warnings: [],
changed: false,
});
const next = await persistPluginInstall({
snapshot: {
config: baseConfig,
baseHash: "config-1",
},
pluginId: "plain",
install: {
source: "path",
sourcePath: "/tmp/plain",
installPath: "/tmp/plain",
},
});
expect(buildPluginDiagnosticsReport).toHaveBeenCalledTimes(1);
expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith({
config: enabledConfig,
onlyPluginIds: ["plain"],
});
expect(next.plugins?.entries?.["legacy-memory-a"]?.enabled).toBe(false);
expect(next.plugins?.slots?.memory).toBe("memory-b");
expect(loadPluginManifestRegistry).toHaveBeenCalledWith({
config: enabledConfig,
includeDisabled: true,
pluginIds: ["plain"],
});
expect(next).toEqual(enabledConfig);
});
it("can persist an install record without enabling a plugin that needs config first", async () => {