diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index 2eee4b980ed..2bd79a43a4a 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -55,6 +55,7 @@ function createSinglePluginRegistry(params: { pluginRoot: string; skills: string[]; format?: "openclaw" | "bundle"; + legacyPluginIds?: string[]; }): PluginManifestRegistry { return { diagnostics: [], @@ -65,6 +66,7 @@ function createSinglePluginRegistry(params: { format: params.format, channels: [], providers: [], + legacyPluginIds: params.legacyPluginIds, cliBackends: [], skills: params.skills, hooks: [], @@ -232,4 +234,31 @@ describe("resolvePluginSkillDirs", () => { path.resolve(pluginRoot, "commands"), ]); }); + + it("resolves enabled plugin skills through legacy manifest aliases", async () => { + const workspaceDir = await tempDirs.make("openclaw-"); + const pluginRoot = await tempDirs.make("openclaw-legacy-plugin-"); + await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true }); + + hoisted.loadPluginManifestRegistry.mockReturnValue( + createSinglePluginRegistry({ + pluginRoot, + skills: ["./skills"], + legacyPluginIds: ["helper-legacy"], + }), + ); + + const dirs = resolvePluginSkillDirs({ + workspaceDir, + config: { + plugins: { + entries: { + "helper-legacy": { enabled: true }, + }, + }, + } as OpenClawConfig, + }); + + expect(dirs).toEqual([path.resolve(pluginRoot, "skills")]); + }); }); diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index e3604345c20..c773ab3cb63 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -3,16 +3,46 @@ import path from "node:path"; import type { OpenClawConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { - normalizePluginsConfig, + normalizePluginsConfigWithResolver, resolveEffectivePluginActivationState, resolveMemorySlotDecision, -} from "../../plugins/config-state.js"; -import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; +} from "../../plugins/config-policy.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRegistry, +} from "../../plugins/manifest-registry.js"; import { hasKind } from "../../plugins/slots.js"; import { isPathInsideWithRealpath } from "../../security/scan-paths.js"; const log = createSubsystemLogger("skills"); +function buildRegistryPluginIdAliases( + registry: PluginManifestRegistry, +): Readonly> { + return Object.fromEntries( + registry.plugins + .flatMap((record) => [ + ...record.providers + .filter((providerId) => providerId !== record.id) + .map((providerId) => [providerId, record.id] as const), + ...(record.legacyPluginIds ?? []).map( + (legacyPluginId) => [legacyPluginId, record.id] as const, + ), + ]) + .toSorted(([left], [right]) => left.localeCompare(right)), + ); +} + +function createRegistryPluginIdNormalizer( + registry: PluginManifestRegistry, +): (id: string) => string { + const aliases = buildRegistryPluginIdAliases(registry); + return (id: string) => { + const trimmed = id.trim(); + return aliases[trimmed] ?? trimmed; + }; +} + export function resolvePluginSkillDirs(params: { workspaceDir: string | undefined; config?: OpenClawConfig; @@ -28,7 +58,10 @@ export function resolvePluginSkillDirs(params: { if (registry.plugins.length === 0) { return []; } - const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); + const normalizedPlugins = normalizePluginsConfigWithResolver( + params.config?.plugins, + createRegistryPluginIdNormalizer(registry), + ); const acpEnabled = params.config?.acp?.enabled !== false; const memorySlot = normalizedPlugins.slots.memory; let selectedMemoryPluginId: string | null = null; diff --git a/src/agents/skills/refresh.test.ts b/src/agents/skills/refresh.test.ts index 227b3412634..087f61221e3 100644 --- a/src/agents/skills/refresh.test.ts +++ b/src/agents/skills/refresh.test.ts @@ -13,6 +13,10 @@ vi.mock("chokidar", () => ({ default: { watch: watchMock }, })); +vi.mock("./plugin-skills.js", () => ({ + resolvePluginSkillDirs: vi.fn(() => []), +})); + describe("ensureSkillsWatcher", () => { beforeAll(async () => { refreshModule = await import("./refresh.js"); diff --git a/src/plugins/config-policy.ts b/src/plugins/config-policy.ts new file mode 100644 index 00000000000..87513bcb446 --- /dev/null +++ b/src/plugins/config-policy.ts @@ -0,0 +1,409 @@ +import { normalizeChatChannelId } from "../channels/registry.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { defaultSlotIdForKey, hasKind } from "./slots.js"; +import type { PluginKind, PluginOrigin } from "./types.js"; + +export type PluginActivationSource = "disabled" | "explicit" | "auto" | "default"; + +export type PluginActivationState = { + enabled: boolean; + activated: boolean; + explicitlyEnabled: boolean; + source: PluginActivationSource; + reason?: string; +}; + +export type NormalizedPluginsConfig = { + enabled: boolean; + allow: string[]; + deny: string[]; + loadPaths: string[]; + slots: { + memory?: string | null; + }; + entries: Record< + string, + { + enabled?: boolean; + hooks?: { + allowPromptInjection?: boolean; + }; + subagent?: { + allowModelOverride?: boolean; + allowedModels?: string[]; + hasAllowedModelsConfig?: boolean; + }; + config?: unknown; + } + >; +}; + +type NormalizePluginId = (id: string) => string; + +const identityNormalizePluginId: NormalizePluginId = (id) => id.trim(); + +const normalizeList = (value: unknown, normalizePluginId: NormalizePluginId): string[] => { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : "")) + .filter(Boolean); +}; + +const normalizeSlotValue = (value: unknown): string | null | undefined => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.toLowerCase() === "none") { + return null; + } + return trimmed; +}; + +const normalizePluginEntries = ( + entries: unknown, + normalizePluginId: NormalizePluginId, +): NormalizedPluginsConfig["entries"] => { + if (!entries || typeof entries !== "object" || Array.isArray(entries)) { + return {}; + } + const normalized: NormalizedPluginsConfig["entries"] = {}; + for (const [key, value] of Object.entries(entries)) { + const normalizedKey = normalizePluginId(key); + if (!normalizedKey) { + continue; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + normalized[normalizedKey] = {}; + continue; + } + const entry = value as Record; + const hooksRaw = entry.hooks; + const hooks = + hooksRaw && typeof hooksRaw === "object" && !Array.isArray(hooksRaw) + ? { + allowPromptInjection: (hooksRaw as { allowPromptInjection?: unknown }) + .allowPromptInjection, + } + : undefined; + const normalizedHooks = + hooks && typeof hooks.allowPromptInjection === "boolean" + ? { + allowPromptInjection: hooks.allowPromptInjection, + } + : undefined; + const subagentRaw = entry.subagent; + const subagent = + subagentRaw && typeof subagentRaw === "object" && !Array.isArray(subagentRaw) + ? { + allowModelOverride: (subagentRaw as { allowModelOverride?: unknown }) + .allowModelOverride, + hasAllowedModelsConfig: Array.isArray( + (subagentRaw as { allowedModels?: unknown }).allowedModels, + ), + allowedModels: Array.isArray((subagentRaw as { allowedModels?: unknown }).allowedModels) + ? ((subagentRaw as { allowedModels?: unknown }).allowedModels as unknown[]) + .map((model) => (typeof model === "string" ? model.trim() : "")) + .filter(Boolean) + : undefined, + } + : undefined; + const normalizedSubagent = + subagent && + (typeof subagent.allowModelOverride === "boolean" || + subagent.hasAllowedModelsConfig || + (Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0)) + ? { + ...(typeof subagent.allowModelOverride === "boolean" + ? { allowModelOverride: subagent.allowModelOverride } + : {}), + ...(subagent.hasAllowedModelsConfig ? { hasAllowedModelsConfig: true } : {}), + ...(Array.isArray(subagent.allowedModels) && subagent.allowedModels.length > 0 + ? { allowedModels: subagent.allowedModels } + : {}), + } + : undefined; + normalized[normalizedKey] = { + ...normalized[normalizedKey], + enabled: + typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled, + hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks, + subagent: normalizedSubagent ?? normalized[normalizedKey]?.subagent, + config: "config" in entry ? entry.config : normalized[normalizedKey]?.config, + }; + } + return normalized; +}; + +export function normalizePluginsConfigWithResolver( + config?: OpenClawConfig["plugins"], + normalizePluginId: NormalizePluginId = identityNormalizePluginId, +): NormalizedPluginsConfig { + const memorySlot = normalizeSlotValue(config?.slots?.memory); + return { + enabled: config?.enabled !== false, + allow: normalizeList(config?.allow, normalizePluginId), + deny: normalizeList(config?.deny, normalizePluginId), + loadPaths: normalizeList(config?.load?.paths, identityNormalizePluginId), + slots: { + memory: memorySlot === undefined ? defaultSlotIdForKey("memory") : memorySlot, + }, + entries: normalizePluginEntries(config?.entries, normalizePluginId), + }; +} + +function resolveExplicitPluginSelection(params: { + id: string; + origin: PluginOrigin; + config: NormalizedPluginsConfig; + rootConfig?: OpenClawConfig; +}): { explicitlyEnabled: boolean; reason?: string } { + if (params.config.entries[params.id]?.enabled === true) { + return { explicitlyEnabled: true, reason: "enabled in config" }; + } + if ( + params.origin === "bundled" && + isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id) + ) { + return { explicitlyEnabled: true, reason: "channel enabled in config" }; + } + if (params.config.slots.memory === params.id) { + return { explicitlyEnabled: true, reason: "selected memory slot" }; + } + if (params.origin !== "bundled" && params.config.allow.includes(params.id)) { + return { explicitlyEnabled: true, reason: "selected in allowlist" }; + } + return { explicitlyEnabled: false }; +} + +export function resolvePluginActivationState(params: { + id: string; + origin: PluginOrigin; + config: NormalizedPluginsConfig; + rootConfig?: OpenClawConfig; + enabledByDefault?: boolean; + sourceConfig?: NormalizedPluginsConfig; + sourceRootConfig?: OpenClawConfig; + autoEnabledReason?: string; +}): PluginActivationState { + const explicitSelection = resolveExplicitPluginSelection({ + id: params.id, + origin: params.origin, + config: params.sourceConfig ?? params.config, + rootConfig: params.sourceRootConfig ?? params.rootConfig, + }); + + if (!params.config.enabled) { + return { + enabled: false, + activated: false, + explicitlyEnabled: explicitSelection.explicitlyEnabled, + source: "disabled", + reason: "plugins disabled", + }; + } + if (params.config.deny.includes(params.id)) { + return { + enabled: false, + activated: false, + explicitlyEnabled: explicitSelection.explicitlyEnabled, + source: "disabled", + reason: "blocked by denylist", + }; + } + const entry = params.config.entries[params.id]; + if (entry?.enabled === false) { + return { + enabled: false, + activated: false, + explicitlyEnabled: explicitSelection.explicitlyEnabled, + source: "disabled", + reason: "disabled in config", + }; + } + const explicitlyAllowed = params.config.allow.includes(params.id); + if (params.origin === "workspace" && !explicitlyAllowed && entry?.enabled !== true) { + return { + enabled: false, + activated: false, + explicitlyEnabled: explicitSelection.explicitlyEnabled, + source: "disabled", + reason: "workspace plugin (disabled by default)", + }; + } + if (params.config.slots.memory === params.id) { + return { + enabled: true, + activated: true, + explicitlyEnabled: true, + source: "explicit", + reason: "selected memory slot", + }; + } + if (params.config.allow.length > 0 && !explicitlyAllowed) { + return { + enabled: false, + activated: false, + explicitlyEnabled: explicitSelection.explicitlyEnabled, + source: "disabled", + reason: "not in allowlist", + }; + } + if (explicitSelection.explicitlyEnabled) { + return { + enabled: true, + activated: true, + explicitlyEnabled: true, + source: "explicit", + reason: explicitSelection.reason, + }; + } + if (params.autoEnabledReason) { + return { + enabled: true, + activated: true, + explicitlyEnabled: false, + source: "auto", + reason: params.autoEnabledReason, + }; + } + if (entry?.enabled === true) { + return { + enabled: true, + activated: true, + explicitlyEnabled: false, + source: "auto", + reason: "enabled by effective config", + }; + } + if ( + params.origin === "bundled" && + isBundledChannelEnabledByChannelConfig(params.rootConfig, params.id) + ) { + return { + enabled: true, + activated: true, + explicitlyEnabled: false, + source: "auto", + reason: "channel configured", + }; + } + if (params.origin === "bundled" && params.enabledByDefault === true) { + return { + enabled: true, + activated: true, + explicitlyEnabled: false, + source: "default", + reason: "bundled default enablement", + }; + } + if (params.origin === "bundled") { + return { + enabled: false, + activated: false, + explicitlyEnabled: false, + source: "disabled", + reason: "bundled (disabled by default)", + }; + } + return { + enabled: true, + activated: true, + explicitlyEnabled: explicitSelection.explicitlyEnabled, + source: "default", + }; +} + +export function resolveEnableState( + id: string, + origin: PluginOrigin, + config: NormalizedPluginsConfig, + enabledByDefault?: boolean, +): { enabled: boolean; reason?: string } { + const state = resolvePluginActivationState({ + id, + origin, + config, + enabledByDefault, + }); + return state.enabled ? { enabled: true } : { enabled: false, reason: state.reason }; +} + +export function isBundledChannelEnabledByChannelConfig( + cfg: OpenClawConfig | undefined, + pluginId: string, +): boolean { + if (!cfg) { + return false; + } + const channelId = normalizeChatChannelId(pluginId); + if (!channelId) { + return false; + } + const channels = cfg.channels as Record | undefined; + const entry = channels?.[channelId]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + return (entry as Record).enabled === true; +} + +export function resolveEffectiveEnableState(params: { + id: string; + origin: PluginOrigin; + config: NormalizedPluginsConfig; + rootConfig?: OpenClawConfig; + enabledByDefault?: boolean; +}): { enabled: boolean; reason?: string } { + const state = resolveEffectivePluginActivationState(params); + return state.enabled ? { enabled: true } : { enabled: false, reason: state.reason }; +} + +export function resolveEffectivePluginActivationState(params: { + id: string; + origin: PluginOrigin; + config: NormalizedPluginsConfig; + rootConfig?: OpenClawConfig; + enabledByDefault?: boolean; + sourceConfig?: NormalizedPluginsConfig; + sourceRootConfig?: OpenClawConfig; + autoEnabledReason?: string; +}): PluginActivationState { + return resolvePluginActivationState(params); +} + +export function resolveMemorySlotDecision(params: { + id: string; + kind?: PluginKind | PluginKind[]; + slot: string | null | undefined; + selectedId: string | null; +}): { enabled: boolean; reason?: string; selected?: boolean } { + if (!hasKind(params.kind, "memory")) { + return { enabled: true }; + } + // A dual-kind plugin (e.g. ["memory", "context-engine"]) that lost the + // memory slot must stay enabled so its other slot role can still load. + const isMultiKind = Array.isArray(params.kind) && params.kind.length > 1; + if (params.slot === null) { + return isMultiKind ? { enabled: true } : { enabled: false, reason: "memory slot disabled" }; + } + if (typeof params.slot === "string") { + if (params.slot === params.id) { + return { enabled: true, selected: true }; + } + return isMultiKind + ? { enabled: true } + : { enabled: false, reason: `memory slot set to "${params.slot}"` }; + } + if (params.selectedId && params.selectedId !== params.id) { + return isMultiKind + ? { enabled: true } + : { enabled: false, reason: `memory slot already filled by "${params.selectedId}"` }; + } + return { enabled: true, selected: true }; +} diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 1bc1e90c805..ec4e5e6cdf1 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -4,7 +4,10 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { loadBundleManifest } from "./bundle-manifest.js"; -import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; +import { + normalizePluginsConfigWithResolver, + type NormalizedPluginsConfig, +} from "./config-policy.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { loadPluginManifest, @@ -46,6 +49,7 @@ export type PluginManifestRecord = { version?: string; enabledByDefault?: boolean; autoEnableWhenConfiguredProviders?: string[]; + legacyPluginIds?: string[]; format?: PluginFormat; bundleFormat?: PluginBundleFormat; bundleCapabilities?: string[]; @@ -206,6 +210,7 @@ function buildRecord(params: { version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion, enabledByDefault: params.manifest.enabledByDefault === true ? true : undefined, autoEnableWhenConfiguredProviders: params.manifest.autoEnableWhenConfiguredProviders, + legacyPluginIds: params.manifest.legacyPluginIds, format: params.candidate.format ?? "openclaw", bundleFormat: params.candidate.bundleFormat, kind: params.manifest.kind, @@ -356,7 +361,7 @@ export function loadPluginManifestRegistry( } = {}, ): PluginManifestRegistry { const config = params.config ?? {}; - const normalized = normalizePluginsConfig(config.plugins); + const normalized = normalizePluginsConfigWithResolver(config.plugins); const env = params.env ?? process.env; const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized, env }); const cacheEnabled = params.cache !== false && shouldUseManifestCache(env);