diff --git a/src/plugins/activation-planner.test.ts b/src/plugins/activation-planner.test.ts index d24ee919193f..c7b4b4599e5a 100644 --- a/src/plugins/activation-planner.test.ts +++ b/src/plugins/activation-planner.test.ts @@ -307,6 +307,93 @@ describe("activation planner", () => { ]); }); + it("skips unreadable manifest activation rows before healthy command owners", () => { + const poisonedPlugin = { + id: "poisoned-plugin", + origin: "workspace" as const, + providers: [], + channels: [], + cliBackends: [], + skills: [], + hooks: [], + commandAliases: [{ name: "poisoned-command" }], + get activation(): { onCommands: string[] } { + throw new Error("manifest activation metadata exploded"); + }, + }; + mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ + plugins: [ + poisonedPlugin, + { + id: "healthy-plugin", + providers: [], + channels: [], + cliBackends: [], + skills: [], + hooks: [], + activation: { + onCommands: ["healthy-command"], + }, + origin: "workspace", + }, + ], + diagnostics: [], + }); + + expect( + resolveManifestActivationPluginIds({ + trigger: { + kind: "command", + command: "healthy-command", + }, + }), + ).toEqual(["healthy-plugin"]); + }); + + it("skips unreadable manifest id rows before healthy tool owners", () => { + const poisonedPlugin = { + get id(): string { + throw new Error("manifest activation plugin id exploded"); + }, + origin: "workspace" as const, + providers: [], + channels: [], + cliBackends: [], + skills: [], + hooks: [], + contracts: { + tools: ["poisoned-tool"], + }, + }; + mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ + plugins: [ + poisonedPlugin, + { + id: "healthy-tool-owner", + providers: [], + channels: [], + cliBackends: [], + skills: [], + hooks: [], + contracts: { + tools: ["healthy-tool"], + }, + origin: "workspace", + }, + ], + diagnostics: [], + }); + + expect( + resolveManifestActivationPluginIds({ + trigger: { + kind: "capability", + capability: "tool", + }, + }), + ).toEqual(["healthy-tool-owner"]); + }); + it("returns capability reasons from explicit hints and manifest ownership", () => { mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ plugins: [ diff --git a/src/plugins/activation-planner.ts b/src/plugins/activation-planner.ts index 034faf8d93fd..9e379013116e 100644 --- a/src/plugins/activation-planner.ts +++ b/src/plugins/activation-planner.ts @@ -1,5 +1,9 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { OpenClawConfig } from "../config/types.js"; import { normalizePluginsConfig } from "./config-state.js"; @@ -63,6 +67,32 @@ type ResolveManifestActivationPlanParams = { allowRestrictiveAllowlistBypass?: boolean; }; +type ActivationPlannerManifestRecord = Pick & { + activation?: { + onAgentHarnesses?: readonly string[]; + onCapabilities?: readonly PluginManifestActivationCapability[]; + onChannels?: readonly string[]; + onCommands?: readonly string[]; + onProviders?: readonly string[]; + onRoutes?: readonly string[]; + }; + channels: readonly string[]; + commandAliases?: readonly { + cliCommand?: string; + name?: string; + }[]; + contracts?: { + tools?: readonly string[]; + }; + hooks: readonly string[]; + providers: readonly string[]; + setup?: { + providers?: readonly { + id: string; + }[]; + }; +}; + export function resolveManifestActivationPlan( params: ResolveManifestActivationPlanParams, ): PluginActivationPlan { @@ -77,7 +107,11 @@ export function resolveManifestActivationPlan( includeDisabled: true, }); const entries = registry.plugins - .flatMap((plugin) => { + .flatMap((manifestRecord) => { + const plugin = createActivationPlannerManifestRecord(manifestRecord); + if (!plugin) { + return []; + } if (params.origin && plugin.origin !== params.origin) { return []; } @@ -122,7 +156,7 @@ export function resolveManifestActivationPluginIds( } function listManifestActivationTriggerReasons( - plugin: PluginManifestRecord, + plugin: ActivationPlannerManifestRecord, trigger: PluginActivationPlannerTrigger, ): PluginActivationPlannerReason[] { switch (trigger.kind) { @@ -144,7 +178,7 @@ function listManifestActivationTriggerReasons( } function listAgentHarnessTriggerReasons( - plugin: PluginManifestRecord, + plugin: ActivationPlannerManifestRecord, runtime: string, ): PluginActivationPlannerReason[] { return listHasNormalizedValue(plugin.activation?.onAgentHarnesses, runtime, normalizeCommandId) @@ -153,25 +187,21 @@ function listAgentHarnessTriggerReasons( } function listCommandTriggerReasons( - plugin: PluginManifestRecord, + plugin: ActivationPlannerManifestRecord, command: string, ): PluginActivationPlannerReason[] { return dedupeReasons([ listHasNormalizedValue(plugin.activation?.onCommands, command, normalizeCommandId) ? "activation-command-hint" : null, - listHasNormalizedValue( - (plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name), - command, - normalizeCommandId, - ) + listHasNormalizedValue(listCommandAliasIds(plugin.commandAliases), command, normalizeCommandId) ? "manifest-command-alias" : null, ]); } function listProviderTriggerReasons( - plugin: PluginManifestRecord, + plugin: ActivationPlannerManifestRecord, provider: string, ): PluginActivationPlannerReason[] { return dedupeReasons([ @@ -191,8 +221,16 @@ function listProviderTriggerReasons( ]); } +function listCommandAliasIds( + commandAliases: ActivationPlannerManifestRecord["commandAliases"], +): string[] { + return (commandAliases ?? []) + .map((alias) => alias.cliCommand ?? alias.name) + .filter((value): value is string => Boolean(value)); +} + function listChannelTriggerReasons( - plugin: PluginManifestRecord, + plugin: ActivationPlannerManifestRecord, channel: string, ): PluginActivationPlannerReason[] { return dedupeReasons([ @@ -206,7 +244,7 @@ function listChannelTriggerReasons( } function listRouteTriggerReasons( - plugin: PluginManifestRecord, + plugin: ActivationPlannerManifestRecord, route: string, ): PluginActivationPlannerReason[] { return listHasNormalizedValue(plugin.activation?.onRoutes, route, normalizeCommandId) @@ -215,7 +253,7 @@ function listRouteTriggerReasons( } function listCapabilityTriggerReasons( - plugin: PluginManifestRecord, + plugin: ActivationPlannerManifestRecord, capability: PluginManifestActivationCapability, ): PluginActivationPlannerReason[] { switch (capability) { @@ -280,3 +318,139 @@ function dedupeReasons( function normalizeCommandId(value: string | undefined): string { return normalizeOptionalLowercaseString(value) ?? ""; } + +function createActivationPlannerManifestRecord( + plugin: PluginManifestRecord, +): ActivationPlannerManifestRecord | null { + const id = normalizeOptionalString(readRecordValue(plugin, "id")); + const origin = normalizePluginOrigin(readRecordValue(plugin, "origin")); + if (!id || !origin) { + return null; + } + + const activation = readActivationMetadata(readRecordValue(plugin, "activation")); + return { + id, + origin, + activation, + channels: readStringArray(readRecordValue(plugin, "channels")), + commandAliases: readCommandAliases(readRecordValue(plugin, "commandAliases")), + contracts: readContracts(readRecordValue(plugin, "contracts")), + hooks: readStringArray(readRecordValue(plugin, "hooks")), + providers: readStringArray(readRecordValue(plugin, "providers")), + setup: readSetup(readRecordValue(plugin, "setup")), + }; +} + +function readActivationMetadata( + value: unknown, +): ActivationPlannerManifestRecord["activation"] | undefined { + if (!isRecord(value)) { + return undefined; + } + + return { + onAgentHarnesses: readStringArray(readRecordValue(value, "onAgentHarnesses")), + onCapabilities: readCapabilityArray(readRecordValue(value, "onCapabilities")), + onChannels: readStringArray(readRecordValue(value, "onChannels")), + onCommands: readStringArray(readRecordValue(value, "onCommands")), + onProviders: readStringArray(readRecordValue(value, "onProviders")), + onRoutes: readStringArray(readRecordValue(value, "onRoutes")), + }; +} + +function readCommandAliases(value: unknown): ActivationPlannerManifestRecord["commandAliases"] { + return readArrayEntries(value) + .map((entry) => { + if (!isRecord(entry)) { + return null; + } + const name = normalizeOptionalString(readRecordValue(entry, "name")); + const cliCommand = normalizeOptionalString(readRecordValue(entry, "cliCommand")); + return name || cliCommand ? { name, cliCommand } : null; + }) + .filter((entry): entry is NonNullable => Boolean(entry)); +} + +function readContracts(value: unknown): ActivationPlannerManifestRecord["contracts"] | undefined { + if (!isRecord(value)) { + return undefined; + } + return { + tools: readStringArray(readRecordValue(value, "tools")), + }; +} + +function readSetup(value: unknown): ActivationPlannerManifestRecord["setup"] | undefined { + if (!isRecord(value)) { + return undefined; + } + + const providers = readArrayEntries(readRecordValue(value, "providers")) + .map((entry) => { + if (!isRecord(entry)) { + return null; + } + const id = normalizeOptionalString(readRecordValue(entry, "id")); + return id ? { id } : null; + }) + .filter((entry): entry is NonNullable => Boolean(entry)); + + return { providers }; +} + +function readStringArray(value: unknown): string[] { + return readArrayEntries(value) + .map((entry) => normalizeOptionalString(entry)) + .filter((entry): entry is string => Boolean(entry)); +} + +function readCapabilityArray(value: unknown): PluginManifestActivationCapability[] { + return readStringArray(value).filter(isPluginManifestActivationCapability); +} + +function readArrayEntries(value: unknown): unknown[] { + if (!Array.isArray(value)) { + return []; + } + + let length: number; + try { + length = value.length; + } catch { + return []; + } + + const entries: unknown[] = []; + for (let index = 0; index < length; index += 1) { + try { + entries.push(value[index]); + } catch { + // A poisoned manifest entry should not prevent later healthy owners from planning. + } + } + return entries; +} + +function readRecordValue(value: unknown, key: string): unknown { + if (!isRecord(value)) { + return undefined; + } + try { + return value[key]; + } catch { + return undefined; + } +} + +function normalizePluginOrigin(value: unknown): PluginOrigin | undefined { + return value === "bundled" || value === "global" || value === "workspace" || value === "config" + ? value + : undefined; +} + +function isPluginManifestActivationCapability( + value: string, +): value is PluginManifestActivationCapability { + return value === "provider" || value === "channel" || value === "tool" || value === "hook"; +}