diff --git a/src/plugins/config-normalization-shared.ts b/src/plugins/config-normalization-shared.ts new file mode 100644 index 00000000000..b9708378013 --- /dev/null +++ b/src/plugins/config-normalization-shared.ts @@ -0,0 +1,191 @@ +import { normalizeChatChannelId } from "../channels/ids.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { defaultSlotIdForKey } from "./slots.js"; + +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; + } + >; +}; + +export type NormalizePluginId = (id: string) => string; + +export const identityNormalizePluginId: NormalizePluginId = (id) => id.trim(); + +function normalizeList(value: unknown, normalizePluginId: NormalizePluginId): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : "")) + .filter(Boolean); +} + +function 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; +} + +function 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), + }; +} + +export function hasExplicitPluginConfig(plugins?: OpenClawConfig["plugins"]): boolean { + if (!plugins) { + return false; + } + if (typeof plugins.enabled === "boolean") { + return true; + } + if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { + return true; + } + if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { + return true; + } + if (plugins.load?.paths && Array.isArray(plugins.load.paths) && plugins.load.paths.length > 0) { + return true; + } + if (plugins.slots && Object.keys(plugins.slots).length > 0) { + return true; + } + if (plugins.entries && Object.keys(plugins.entries).length > 0) { + return true; + } + return false; +} + +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; +} diff --git a/src/plugins/config-policy.test.ts b/src/plugins/config-policy.test.ts new file mode 100644 index 00000000000..0f8cdf97de7 --- /dev/null +++ b/src/plugins/config-policy.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + hasExplicitPluginConfig, + isBundledChannelEnabledByChannelConfig, + normalizePluginsConfigWithResolver, +} from "./config-policy.js"; + +describe("normalizePluginsConfigWithResolver", () => { + it("uses the provided plugin id resolver for allow deny and entry keys", () => { + const normalized = normalizePluginsConfigWithResolver( + { + allow: [" alpha "], + deny: [" beta "], + entries: { + " gamma ": { + enabled: true, + }, + }, + }, + (id) => id.trim().toUpperCase(), + ); + + expect(normalized.allow).toEqual(["ALPHA"]); + expect(normalized.deny).toEqual(["BETA"]); + expect(normalized.entries).toHaveProperty("GAMMA"); + }); +}); + +describe("hasExplicitPluginConfig", () => { + it("detects explicit config from slots and entry keys", () => { + expect(hasExplicitPluginConfig({ slots: { memory: "none" } })).toBe(true); + expect(hasExplicitPluginConfig({ entries: { foo: {} } })).toBe(true); + expect(hasExplicitPluginConfig({})).toBe(false); + }); +}); + +describe("isBundledChannelEnabledByChannelConfig", () => { + it("only treats enabled channel entries as bundled plugin enablement", () => { + const cfg = { + channels: { + telegram: { enabled: true }, + slack: { enabled: false }, + }, + } as OpenClawConfig; + + expect(isBundledChannelEnabledByChannelConfig(cfg, "telegram")).toBe(true); + expect(isBundledChannelEnabledByChannelConfig(cfg, "slack")).toBe(false); + expect(isBundledChannelEnabledByChannelConfig(cfg, "not-a-channel")).toBe(false); + }); +}); diff --git a/src/plugins/config-policy.ts b/src/plugins/config-policy.ts index 40916863eab..c2309643198 100644 --- a/src/plugins/config-policy.ts +++ b/src/plugins/config-policy.ts @@ -1,6 +1,13 @@ -import { normalizeChatChannelId } from "../channels/ids.js"; import type { OpenClawConfig } from "../config/config.js"; -import { defaultSlotIdForKey, hasKind } from "./slots.js"; +import { + hasExplicitPluginConfig as hasExplicitPluginConfigShared, + identityNormalizePluginId, + isBundledChannelEnabledByChannelConfig as isBundledChannelEnabledByChannelConfigShared, + normalizePluginsConfigWithResolver as normalizePluginsConfigWithResolverShared, + type NormalizePluginId, + type NormalizedPluginsConfig as SharedNormalizedPluginsConfig, +} from "./config-normalization-shared.js"; +import { hasKind } from "./slots.js"; import type { PluginKind, PluginOrigin } from "./types.js"; export type PluginActivationSource = "disabled" | "explicit" | "auto" | "default"; @@ -13,148 +20,13 @@ export type PluginActivationState = { 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 type NormalizedPluginsConfig = SharedNormalizedPluginsConfig; 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), - }; + return normalizePluginsConfigWithResolverShared(config, normalizePluginId); } function resolveExplicitPluginSelection(params: { @@ -319,28 +191,7 @@ export function resolvePluginActivationState(params: { }; } export function hasExplicitPluginConfig(plugins?: OpenClawConfig["plugins"]): boolean { - if (!plugins) { - return false; - } - if (typeof plugins.enabled === "boolean") { - return true; - } - if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { - return true; - } - if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { - return true; - } - if (plugins.load?.paths && Array.isArray(plugins.load.paths) && plugins.load.paths.length > 0) { - return true; - } - if (plugins.slots && Object.keys(plugins.slots).length > 0) { - return true; - } - if (plugins.entries && Object.keys(plugins.entries).length > 0) { - return true; - } - return false; + return hasExplicitPluginConfigShared(plugins); } export function resolveEnableState( id: string, @@ -361,19 +212,7 @@ 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; + return isBundledChannelEnabledByChannelConfigShared(cfg, pluginId); } export function resolveEffectiveEnableState(params: { diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index acc0b065718..1ead62534bd 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -1,7 +1,12 @@ -import { normalizeChatChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; +import { + hasExplicitPluginConfig as hasExplicitPluginConfigShared, + isBundledChannelEnabledByChannelConfig as isBundledChannelEnabledByChannelConfigShared, + normalizePluginsConfigWithResolver, + type NormalizedPluginsConfig as SharedNormalizedPluginsConfig, +} from "./config-normalization-shared.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; -import { defaultSlotIdForKey, hasKind } from "./slots.js"; +import { hasKind } from "./slots.js"; import type { PluginKind, PluginOrigin } from "./types.js"; export type PluginActivationSource = "disabled" | "explicit" | "auto" | "default"; @@ -46,30 +51,7 @@ export type PluginActivationConfigSource = { rootConfig?: OpenClawConfig; }; -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; - } - >; -}; +export type NormalizedPluginsConfig = SharedNormalizedPluginsConfig; let bundledPluginAliasLookupCache: ReadonlyMap | undefined; @@ -100,29 +82,6 @@ export function normalizePluginId(id: string): string { return getBundledPluginAliasLookup().get(trimmed.toLowerCase()) ?? trimmed; } -const normalizeList = (value: unknown): 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 PLUGIN_ACTIVATION_REASON_BY_CAUSE: Record = { "enabled-in-config": "enabled in config", "bundled-channel-enabled-in-config": "channel enabled in config", @@ -159,92 +118,10 @@ function toPluginActivationState(decision: PluginActivationDecision): PluginActi }; } -const normalizePluginEntries = (entries: unknown): 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 const normalizePluginsConfig = ( config?: OpenClawConfig["plugins"], ): NormalizedPluginsConfig => { - const memorySlot = normalizeSlotValue(config?.slots?.memory); - return { - enabled: config?.enabled !== false, - allow: normalizeList(config?.allow), - deny: normalizeList(config?.deny), - loadPaths: normalizeList(config?.load?.paths), - slots: { - memory: memorySlot === undefined ? defaultSlotIdForKey("memory") : memorySlot, - }, - entries: normalizePluginEntries(config?.entries), - }; + return normalizePluginsConfigWithResolver(config, normalizePluginId); }; export function createPluginActivationSource(params: { @@ -263,30 +140,8 @@ const hasExplicitMemorySlot = (plugins?: OpenClawConfig["plugins"]) => const hasExplicitMemoryEntry = (plugins?: OpenClawConfig["plugins"]) => Boolean(plugins?.entries && Object.prototype.hasOwnProperty.call(plugins.entries, "memory-core")); -export const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) => { - if (!plugins) { - return false; - } - if (typeof plugins.enabled === "boolean") { - return true; - } - if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { - return true; - } - if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { - return true; - } - if (plugins.load?.paths && Array.isArray(plugins.load.paths) && plugins.load.paths.length > 0) { - return true; - } - if (plugins.slots && Object.keys(plugins.slots).length > 0) { - return true; - } - if (plugins.entries && Object.keys(plugins.entries).length > 0) { - return true; - } - return false; -}; +export const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) => + hasExplicitPluginConfigShared(plugins); export function applyTestPluginDefaults( cfg: OpenClawConfig, @@ -535,19 +390,7 @@ 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; + return isBundledChannelEnabledByChannelConfigShared(cfg, pluginId); } export function resolveEffectiveEnableState(params: { diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index 409a463a961..b03ad66b65d 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -118,6 +118,22 @@ type PluginBindingGlobalState = { approvalsLoaded: boolean; }; +type PluginConversationBindingState = { + ref: ConversationRef; + record: + | { + bindingId: string; + conversation: ConversationRef; + boundAt: number; + metadata?: Record; + targetSessionKey: string; + } + | null + | undefined; + binding: PluginConversationBinding | null; + isLegacyForeignBinding: boolean; +}; + const pluginBindingGlobalStateKey = Symbol.for("openclaw.plugins.binding.global-state"); const pluginBindingGlobalState = resolveGlobalSingleton( pluginBindingGlobalStateKey, @@ -217,6 +233,42 @@ function buildPluginBindingSessionKey(params: { return `${PLUGIN_BINDING_SESSION_PREFIX}:${params.pluginId}:${hash}`; } +function buildPluginBindingIdentity(params: PluginBindingIdentity): PluginBindingIdentity { + return { + pluginId: params.pluginId, + pluginName: params.pluginName, + pluginRoot: params.pluginRoot, + }; +} + +function logPluginBindingLifecycleEvent(params: { + event: + | "migrating legacy record" + | "auto-refresh" + | "auto-approved" + | "requested" + | "detached" + | "denied" + | "approved"; + pluginId: string; + pluginRoot: string; + channel: string; + accountId: string; + conversationId: string; + decision?: PluginBindingApprovalDecision; +}): void { + const parts = [ + `plugin binding ${params.event}`, + `plugin=${params.pluginId}`, + `root=${params.pluginRoot}`, + ...(params.decision ? [`decision=${params.decision}`] : []), + `channel=${params.channel}`, + `account=${params.accountId}`, + `conversation=${params.conversationId}`, + ]; + log.info(parts.join(" ")); +} + function isLegacyPluginBindingRecord(params: { record: | { @@ -432,6 +484,89 @@ export function toPluginConversationBinding( }; } +function withConversationBindingContext( + binding: PluginConversationBinding, + conversation: PluginBindingConversation, +): PluginConversationBinding { + return { + ...binding, + parentConversationId: conversation.parentConversationId, + threadId: conversation.threadId, + }; +} + +function resolvePluginConversationBindingState(params: { + conversation: PluginBindingConversation; +}): PluginConversationBindingState { + const ref = toConversationRef(params.conversation); + const record = resolveConversationBindingRecord(ref); + const binding = toPluginConversationBinding(record); + return { + ref, + record, + binding, + isLegacyForeignBinding: isLegacyPluginBindingRecord({ record }), + }; +} + +function resolveOwnedPluginConversationBinding(params: { + pluginRoot: string; + conversation: PluginBindingConversation; +}): PluginConversationBinding | null { + const state = resolvePluginConversationBindingState({ + conversation: params.conversation, + }); + if (!state.binding || state.binding.pluginRoot !== params.pluginRoot) { + return null; + } + return withConversationBindingContext(state.binding, params.conversation); +} + +function bindConversationFromIdentity(params: { + identity: PluginBindingIdentity; + conversation: PluginBindingConversation; + summary?: string; + detachHint?: string; +}): Promise { + return bindConversationNow({ + identity: buildPluginBindingIdentity(params.identity), + conversation: params.conversation, + summary: params.summary, + detachHint: params.detachHint, + }); +} + +function bindConversationFromRequest( + request: Pick< + PendingPluginBindingRequest, + "pluginId" | "pluginName" | "pluginRoot" | "conversation" | "summary" | "detachHint" + >, +): Promise { + return bindConversationFromIdentity({ + identity: buildPluginBindingIdentity(request), + conversation: request.conversation, + summary: request.summary, + detachHint: request.detachHint, + }); +} + +function buildApprovalEntryFromRequest( + request: Pick< + PendingPluginBindingRequest, + "pluginRoot" | "pluginId" | "pluginName" | "conversation" + >, + approvedAt = Date.now(), +): PluginBindingApprovalEntry { + return { + pluginRoot: request.pluginRoot, + pluginId: request.pluginId, + pluginName: request.pluginName, + channel: request.conversation.channel, + accountId: request.conversation.accountId, + approvedAt, + }; +} + async function bindConversationNow(params: { identity: PluginBindingIdentity; conversation: PluginBindingConversation; @@ -462,11 +597,7 @@ async function bindConversationNow(params: { if (!binding) { throw new Error("plugin binding was created without plugin metadata"); } - return { - ...binding, - parentConversationId: params.conversation.parentConversationId, - threadId: params.conversation.threadId, - }; + return withConversationBindingContext(binding, params.conversation); } function buildApprovalMessage(request: PendingPluginBindingRequest): string { @@ -595,17 +726,19 @@ export async function requestPluginConversationBinding(params: { binding: PluginConversationBindingRequestParams | undefined; }): Promise { const conversation = normalizeConversation(params.conversation); - const ref = toConversationRef(conversation); - const existing = resolveConversationBindingRecord(ref); - const existingPluginBinding = toPluginConversationBinding(existing); - const existingLegacyPluginBinding = isLegacyPluginBindingRecord({ - record: existing, + const state = resolvePluginConversationBindingState({ + conversation, }); - if (existing && !existingPluginBinding) { - if (existingLegacyPluginBinding) { - log.info( - `plugin binding migrating legacy record plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, - ); + if (state.record && !state.binding) { + if (state.isLegacyForeignBinding) { + logPluginBindingLifecycleEvent({ + event: "migrating legacy record", + pluginId: params.pluginId, + pluginRoot: params.pluginRoot, + channel: state.ref.channel, + accountId: state.ref.accountId, + conversationId: state.ref.conversationId, + }); } else { return { status: "error", @@ -614,50 +747,52 @@ export async function requestPluginConversationBinding(params: { }; } } - if (existingPluginBinding && existingPluginBinding.pluginRoot !== params.pluginRoot) { + if (state.binding && state.binding.pluginRoot !== params.pluginRoot) { return { status: "error", - message: `This conversation is already bound by plugin "${existingPluginBinding.pluginName ?? existingPluginBinding.pluginId}".`, + message: `This conversation is already bound by plugin "${state.binding.pluginName ?? state.binding.pluginId}".`, }; } - if (existingPluginBinding && existingPluginBinding.pluginRoot === params.pluginRoot) { - const rebound = await bindConversationNow({ - identity: { - pluginId: params.pluginId, - pluginName: params.pluginName, - pluginRoot: params.pluginRoot, - }, + if (state.binding && state.binding.pluginRoot === params.pluginRoot) { + const rebound = await bindConversationFromIdentity({ + identity: buildPluginBindingIdentity(params), conversation, summary: params.binding?.summary, detachHint: params.binding?.detachHint, }); - log.info( - `plugin binding auto-refresh plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, - ); + logPluginBindingLifecycleEvent({ + event: "auto-refresh", + pluginId: params.pluginId, + pluginRoot: params.pluginRoot, + channel: state.ref.channel, + accountId: state.ref.accountId, + conversationId: state.ref.conversationId, + }); return { status: "bound", binding: rebound }; } if ( hasPersistentApproval({ pluginRoot: params.pluginRoot, - channel: ref.channel, - accountId: ref.accountId, + channel: state.ref.channel, + accountId: state.ref.accountId, }) ) { - const bound = await bindConversationNow({ - identity: { - pluginId: params.pluginId, - pluginName: params.pluginName, - pluginRoot: params.pluginRoot, - }, + const bound = await bindConversationFromIdentity({ + identity: buildPluginBindingIdentity(params), conversation, summary: params.binding?.summary, detachHint: params.binding?.detachHint, }); - log.info( - `plugin binding auto-approved plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, - ); + logPluginBindingLifecycleEvent({ + event: "auto-approved", + pluginId: params.pluginId, + pluginRoot: params.pluginRoot, + channel: state.ref.channel, + accountId: state.ref.accountId, + conversationId: state.ref.conversationId, + }); return { status: "bound", binding: bound }; } @@ -673,9 +808,14 @@ export async function requestPluginConversationBinding(params: { detachHint: params.binding?.detachHint?.trim() || undefined, }; pendingRequests.set(request.id, request); - log.info( - `plugin binding requested plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, - ); + logPluginBindingLifecycleEvent({ + event: "requested", + pluginId: params.pluginId, + pluginRoot: params.pluginRoot, + channel: state.ref.channel, + accountId: state.ref.accountId, + conversationId: state.ref.conversationId, + }); return { status: "pending", approvalId: request.id, @@ -687,35 +827,29 @@ export async function getCurrentPluginConversationBinding(params: { pluginRoot: string; conversation: PluginBindingConversation; }): Promise { - const record = resolveConversationBindingRecord(toConversationRef(params.conversation)); - const binding = toPluginConversationBinding(record); - if (!binding || binding.pluginRoot !== params.pluginRoot) { - return null; - } - return { - ...binding, - parentConversationId: params.conversation.parentConversationId, - threadId: params.conversation.threadId, - }; + return resolveOwnedPluginConversationBinding(params); } export async function detachPluginConversationBinding(params: { pluginRoot: string; conversation: PluginBindingConversation; }): Promise<{ removed: boolean }> { - const ref = toConversationRef(params.conversation); - const record = resolveConversationBindingRecord(ref); - const binding = toPluginConversationBinding(record); - if (!binding || binding.pluginRoot !== params.pluginRoot) { + const binding = resolveOwnedPluginConversationBinding(params); + if (!binding) { return { removed: false }; } await unbindConversationBindingRecord({ bindingId: binding.bindingId, reason: "plugin-detach", }); - log.info( - `plugin binding detached plugin=${binding.pluginId} root=${binding.pluginRoot} channel=${binding.channel} account=${binding.accountId} conversation=${binding.conversationId}`, - ); + logPluginBindingLifecycleEvent({ + event: "detached", + pluginId: binding.pluginId, + pluginRoot: binding.pluginRoot, + channel: binding.channel, + accountId: binding.accountId, + conversationId: binding.conversationId, + }); return { removed: true }; } @@ -742,34 +876,29 @@ export async function resolvePluginConversationBindingApproval(params: { decision: "deny", request, }); - log.info( - `plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, - ); + logPluginBindingLifecycleEvent({ + event: "denied", + pluginId: request.pluginId, + pluginRoot: request.pluginRoot, + channel: request.conversation.channel, + accountId: request.conversation.accountId, + conversationId: request.conversation.conversationId, + }); return { status: "denied", request }; } if (params.decision === "allow-always") { - await addPersistentApproval({ - pluginRoot: request.pluginRoot, - pluginId: request.pluginId, - pluginName: request.pluginName, - channel: request.conversation.channel, - accountId: request.conversation.accountId, - approvedAt: Date.now(), - }); + await addPersistentApproval(buildApprovalEntryFromRequest(request)); } - const binding = await bindConversationNow({ - identity: { - pluginId: request.pluginId, - pluginName: request.pluginName, - pluginRoot: request.pluginRoot, - }, - conversation: request.conversation, - summary: request.summary, - detachHint: request.detachHint, + const binding = await bindConversationFromRequest(request); + logPluginBindingLifecycleEvent({ + event: "approved", + pluginId: request.pluginId, + pluginRoot: request.pluginRoot, + decision: params.decision, + channel: request.conversation.channel, + accountId: request.conversation.accountId, + conversationId: request.conversation.conversationId, }); - log.info( - `plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, - ); dispatchPluginConversationBindingResolved({ status: "approved", binding, diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 67f9caf9a8f..227905705a8 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -277,6 +277,71 @@ function pickFileInstallCommonParams(params: FileInstallCommonParams): FileInsta }; } +type PreparedInstallTarget = { + targetPath: string; + effectiveMode: "install" | "update"; +}; + +async function ensureInstallTargetAvailableForMode(params: { + runtime: Awaited>; + targetPath: string; + mode: "install" | "update"; +}): Promise<{ ok: true } | { ok: false; error: string }> { + return await params.runtime.ensureInstallTargetAvailable({ + mode: params.mode, + targetDir: params.targetPath, + alreadyExistsError: `plugin already exists: ${params.targetPath} (delete it first)`, + }); +} + +async function resolvePreparedDirectoryInstallTarget(params: { + runtime: Awaited>; + pluginId: string; + extensionsDir?: string; + requestedMode: "install" | "update"; + nameEncoder?: (pluginId: string) => string; +}): Promise<{ ok: true; target: PreparedInstallTarget } | { ok: false; error: string }> { + const targetDirResult = await resolvePluginInstallTarget({ + runtime: params.runtime, + pluginId: params.pluginId, + extensionsDir: params.extensionsDir, + nameEncoder: params.nameEncoder, + }); + if (!targetDirResult.ok) { + return targetDirResult; + } + return { + ok: true, + target: { + targetPath: targetDirResult.targetDir, + effectiveMode: await resolveEffectiveInstallMode({ + runtime: params.runtime, + requestedMode: params.requestedMode, + targetPath: targetDirResult.targetDir, + }), + }, + }; +} + +async function runInstallSourceScan(params: { + subject: string; + scan: () => Promise; +}): Promise | null> { + try { + const scanResult = await params.scan(); + if (scanResult?.blocked) { + return buildBlockedInstallResult({ blocked: scanResult.blocked }); + } + return null; + } catch (err) { + return { + ok: false, + error: `${params.subject} installation blocked: code safety scan failed (${String(err)}). Run "openclaw security audit --deep" for details.`, + code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED, + }; + } +} + async function installPluginDirectoryIntoExtensions(params: { sourceDir: string; pluginId: string; @@ -309,10 +374,10 @@ async function installPluginDirectoryIntoExtensions(params: { } targetDir = targetDirResult.targetDir; } - const availability = await runtime.ensureInstallTargetAvailable({ + const availability = await ensureInstallTargetAvailableForMode({ + runtime, + targetPath: targetDir, mode: params.mode, - targetDir, - alreadyExistsError: `plugin already exists: ${targetDir} (delete it first)`, }); if (!availability.ok) { return availability; @@ -438,40 +503,32 @@ async function installBundleFromSourceDir( }; } - const targetDirResult = await resolvePluginInstallTarget({ + const targetResult = await resolvePreparedDirectoryInstallTarget({ runtime, pluginId, extensionsDir: params.extensionsDir, - }); - if (!targetDirResult.ok) { - return { ok: false, error: targetDirResult.error }; - } - const effectiveMode = await resolveEffectiveInstallMode({ - runtime, requestedMode: mode, - targetPath: targetDirResult.targetDir, }); + if (!targetResult.ok) { + return { ok: false, error: targetResult.error }; + } - try { - const scanResult = await runtime.scanBundleInstallSource({ - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - sourceDir: params.sourceDir, - pluginId, - logger, - requestKind: params.installPolicyRequest?.kind, - requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, - mode: effectiveMode, - version: manifestRes.manifest.version, - }); - if (scanResult?.blocked) { - return buildBlockedInstallResult({ blocked: scanResult.blocked }); - } - } catch (err) { - return { - ok: false, - error: `Bundle "${pluginId}" installation blocked: code safety scan failed (${String(err)}). Run "openclaw security audit --deep" for details.`, - code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED, - }; + const scanResult = await runInstallSourceScan({ + subject: `Bundle "${pluginId}"`, + scan: async () => + await runtime.scanBundleInstallSource({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + sourceDir: params.sourceDir, + pluginId, + logger, + requestKind: params.installPolicyRequest?.kind, + requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, + mode: targetResult.target.effectiveMode, + version: manifestRes.manifest.version, + }), + }); + if (scanResult) { + return scanResult; } return await installPluginDirectoryIntoExtensions({ @@ -480,11 +537,11 @@ async function installBundleFromSourceDir( manifestName: manifestRes.manifest.name, version: manifestRes.manifest.version, extensions: [], - targetDir: targetDirResult.targetDir, + targetDir: targetResult.target.targetPath, extensionsDir: params.extensionsDir, logger, timeoutMs, - mode: effectiveMode, + mode: targetResult.target.effectiveMode, dryRun, copyErrorPrefix: "failed to copy plugin bundle", hasDeps: false, @@ -633,43 +690,36 @@ async function installPluginFromPackageDir( }; } - const targetDirResult = await resolvePluginInstallTarget({ + const targetResult = await resolvePreparedDirectoryInstallTarget({ runtime, pluginId, extensionsDir: params.extensionsDir, + requestedMode: mode, nameEncoder: encodePluginInstallDirName, }); - if (!targetDirResult.ok) { - return { ok: false, error: targetDirResult.error }; + if (!targetResult.ok) { + return { ok: false, error: targetResult.error }; } - const effectiveMode = await resolveEffectiveInstallMode({ - runtime, - requestedMode: mode, - targetPath: targetDirResult.targetDir, + + const scanResult = await runInstallSourceScan({ + subject: `Plugin "${pluginId}"`, + scan: async () => + await runtime.scanPackageInstallSource({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + packageDir: params.packageDir, + pluginId, + logger, + extensions, + requestKind: params.installPolicyRequest?.kind, + requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, + mode: targetResult.target.effectiveMode, + packageName: pkgName || undefined, + manifestId: manifestPluginId, + version: typeof manifest.version === "string" ? manifest.version : undefined, + }), }); - try { - const scanResult = await runtime.scanPackageInstallSource({ - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - packageDir: params.packageDir, - pluginId, - logger, - extensions, - requestKind: params.installPolicyRequest?.kind, - requestedSpecifier: params.installPolicyRequest?.requestedSpecifier, - mode: effectiveMode, - packageName: pkgName || undefined, - manifestId: manifestPluginId, - version: typeof manifest.version === "string" ? manifest.version : undefined, - }); - if (scanResult?.blocked) { - return buildBlockedInstallResult({ blocked: scanResult.blocked }); - } - } catch (err) { - return { - ok: false, - error: `Plugin "${pluginId}" installation blocked: code safety scan failed (${String(err)}). Run "openclaw security audit --deep" for details.`, - code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED, - }; + if (scanResult) { + return scanResult; } const deps = manifest.dependencies ?? {}; @@ -679,11 +729,11 @@ async function installPluginFromPackageDir( manifestName: pkgName || undefined, version: typeof manifest.version === "string" ? manifest.version : undefined, extensions, - targetDir: targetDirResult.targetDir, + targetDir: targetResult.target.targetPath, extensionsDir: params.extensionsDir, logger, timeoutMs, - mode: effectiveMode, + mode: targetResult.target.effectiveMode, dryRun, copyErrorPrefix: "failed to copy plugin", hasDeps: Object.keys(deps).length > 0, @@ -807,57 +857,56 @@ export async function installPluginFromFile(params: { return { ok: false, error: pluginIdError }; } const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`); - const effectiveMode = await resolveEffectiveInstallMode({ - runtime, - requestedMode: mode, + const preparedTarget: PreparedInstallTarget = { targetPath: targetFile, - }); + effectiveMode: await resolveEffectiveInstallMode({ + runtime, + requestedMode: mode, + targetPath: targetFile, + }), + }; - const availability = await runtime.ensureInstallTargetAvailable({ - mode: effectiveMode, - targetDir: targetFile, - alreadyExistsError: `plugin already exists: ${targetFile} (delete it first)`, + const availability = await ensureInstallTargetAvailableForMode({ + runtime, + targetPath: preparedTarget.targetPath, + mode: preparedTarget.effectiveMode, }); if (!availability.ok) { return availability; } if (dryRun) { - return buildFileInstallResult(pluginId, targetFile); + return buildFileInstallResult(pluginId, preparedTarget.targetPath); } - try { - const scanResult = await runtime.scanFileInstallSource({ - dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - filePath, - logger, - mode: effectiveMode, - pluginId, - requestedSpecifier: installPolicyRequest.requestedSpecifier, - }); - if (scanResult?.blocked) { - return buildBlockedInstallResult({ blocked: scanResult.blocked }); - } - } catch (err) { - return { - ok: false, - error: `Plugin file "${pluginId}" installation blocked: code safety scan failed (${String(err)}). Run "openclaw security audit --deep" for details.`, - code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED, - }; + const scanResult = await runInstallSourceScan({ + subject: `Plugin file "${pluginId}"`, + scan: async () => + await runtime.scanFileInstallSource({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, + filePath, + logger, + mode: preparedTarget.effectiveMode, + pluginId, + requestedSpecifier: installPolicyRequest.requestedSpecifier, + }), + }); + if (scanResult) { + return scanResult; } - logger.info?.(`Installing to ${targetFile}…`); + logger.info?.(`Installing to ${preparedTarget.targetPath}…`); try { await runtime.writeFileFromPathWithinRoot({ rootDir: extensionsDir, - relativePath: path.basename(targetFile), + relativePath: path.basename(preparedTarget.targetPath), sourcePath: filePath, }); } catch (err) { return { ok: false, error: String(err) }; } - return buildFileInstallResult(pluginId, targetFile); + return buildFileInstallResult(pluginId, preparedTarget.targetPath); } export async function installPluginFromNpmSpec( diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index aa02523bd4e..66503420036 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -479,35 +479,64 @@ async function loadMarketplace(params: { logger?: MarketplaceLogger; timeoutMs?: number; }): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> { - const loadResolvedLocalMarketplace = async ( - local: ResolvedLocalMarketplaceSource, - sourceLabel: string, - ): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> => { - const raw = await fs.readFile(local.manifestPath, "utf-8"); - const parsed = parseMarketplaceManifest(raw, local.manifestPath); + const loadMarketplaceFromManifestFile = async (params: { + manifestPath: string; + sourceLabel: string; + rootDir: string; + origin: MarketplaceManifestOrigin; + cleanup?: () => Promise; + }): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> => { + const raw = await fs.readFile(params.manifestPath, "utf-8"); + const parsed = parseMarketplaceManifest(raw, params.manifestPath); if (!parsed.ok) { + await params.cleanup?.(); return parsed; } const validated = await validateMarketplaceManifest({ manifest: parsed.manifest, - sourceLabel: local.manifestPath, - rootDir: local.rootDir, - origin: "local", + sourceLabel: params.sourceLabel, + rootDir: params.rootDir, + origin: params.origin, }); if (!validated.ok) { + await params.cleanup?.(); return validated; } return { ok: true, marketplace: { manifest: validated.manifest, - rootDir: local.rootDir, - sourceLabel, - origin: "local", + rootDir: params.rootDir, + sourceLabel: params.sourceLabel, + origin: params.origin, + cleanup: params.cleanup, }, }; }; + const loadResolvedLocalMarketplace = async ( + local: ResolvedLocalMarketplaceSource, + sourceLabel: string, + ): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> => + loadMarketplaceFromManifestFile({ + manifestPath: local.manifestPath, + sourceLabel, + rootDir: local.rootDir, + origin: "local", + }); + + const resolveClonedMarketplaceManifestPath = async ( + rootDir: string, + ): Promise => { + for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) { + const next = path.join(rootDir, candidate); + if (await pathExists(next)) { + return next; + } + } + return undefined; + }; + const knownMarketplaces = await readClaudeKnownMarketplaces(); const known = knownMarketplaces[params.source]; if (known) { @@ -546,46 +575,19 @@ async function loadMarketplace(params: { return cloned; } - let manifestPath: string | undefined; - for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) { - const next = path.join(cloned.rootDir, candidate); - if (await pathExists(next)) { - manifestPath = next; - break; - } - } + const manifestPath = await resolveClonedMarketplaceManifestPath(cloned.rootDir); if (!manifestPath) { await cloned.cleanup(); return { ok: false, error: `marketplace manifest not found in ${cloned.label}` }; } - const raw = await fs.readFile(manifestPath, "utf-8"); - const parsed = parseMarketplaceManifest(raw, manifestPath); - if (!parsed.ok) { - await cloned.cleanup(); - return parsed; - } - const validated = await validateMarketplaceManifest({ - manifest: parsed.manifest, + return await loadMarketplaceFromManifestFile({ + manifestPath, sourceLabel: cloned.label, rootDir: cloned.rootDir, origin: "remote", + cleanup: cloned.cleanup, }); - if (!validated.ok) { - await cloned.cleanup(); - return validated; - } - - return { - ok: true, - marketplace: { - manifest: validated.manifest, - rootDir: cloned.rootDir, - sourceLabel: cloned.label, - origin: "remote", - cleanup: cloned.cleanup, - }, - }; } function resolveSafeMarketplaceDownloadFileName(url: string, fallback: string): string { diff --git a/src/plugins/provider-validation.ts b/src/plugins/provider-validation.ts index b5f15bb16ac..32db406f8df 100644 --- a/src/plugins/provider-validation.ts +++ b/src/plugins/provider-validation.ts @@ -1,5 +1,9 @@ import type { PluginDiagnostic, ProviderAuthMethod, ProviderPlugin } from "./types.js"; +type ProviderWizardSetup = NonNullable["setup"]>; +type ProviderWizardModelPicker = NonNullable["modelPicker"]>; +type ProviderWizardModelAllowlist = NonNullable; + function pushProviderDiagnostic(params: { level: PluginDiagnostic["level"]; pluginId: string; @@ -63,14 +67,104 @@ function normalizeProviderOAuthProfileIdRepairs( return normalized.length > 0 ? normalized : undefined; } +function resolveWizardMethodId(params: { + providerId: string; + pluginId: string; + source: string; + auth: ProviderAuthMethod[]; + methodId: string | undefined; + metadataKind: "setup" | "model-picker"; + pushDiagnostic: (diag: PluginDiagnostic) => void; +}): string | undefined { + if (!params.methodId) { + return undefined; + } + if (params.auth.some((method) => method.id === params.methodId)) { + return params.methodId; + } + pushProviderDiagnostic({ + level: "warn", + pluginId: params.pluginId, + source: params.source, + message: `provider "${params.providerId}" ${params.metadataKind} method "${params.methodId}" not found; falling back to available methods`, + pushDiagnostic: params.pushDiagnostic, + }); + return undefined; +} + +function buildNormalizedModelAllowlist( + modelAllowlist: ProviderWizardModelAllowlist | undefined, +): ProviderWizardModelAllowlist | undefined { + if (!modelAllowlist) { + return undefined; + } + const allowedKeys = normalizeTextList(modelAllowlist.allowedKeys); + const initialSelections = normalizeTextList(modelAllowlist.initialSelections); + const message = normalizeText(modelAllowlist.message); + if (!allowedKeys && !initialSelections && !message) { + return undefined; + } + return { + ...(allowedKeys ? { allowedKeys } : {}), + ...(initialSelections ? { initialSelections } : {}), + ...(message ? { message } : {}), + }; +} + +function buildNormalizedWizardSetup(params: { + setup: ProviderWizardSetup; + methodId: string | undefined; +}): ProviderWizardSetup { + const choiceId = normalizeText(params.setup.choiceId); + const choiceLabel = normalizeText(params.setup.choiceLabel); + const choiceHint = normalizeText(params.setup.choiceHint); + const groupId = normalizeText(params.setup.groupId); + const groupLabel = normalizeText(params.setup.groupLabel); + const groupHint = normalizeText(params.setup.groupHint); + const onboardingScopes = normalizeOnboardingScopes(params.setup.onboardingScopes); + const modelAllowlist = buildNormalizedModelAllowlist(params.setup.modelAllowlist); + return { + ...(choiceId ? { choiceId } : {}), + ...(choiceLabel ? { choiceLabel } : {}), + ...(choiceHint ? { choiceHint } : {}), + ...(typeof params.setup.assistantPriority === "number" && + Number.isFinite(params.setup.assistantPriority) + ? { assistantPriority: params.setup.assistantPriority } + : {}), + ...(params.setup.assistantVisibility === "manual-only" || + params.setup.assistantVisibility === "visible" + ? { assistantVisibility: params.setup.assistantVisibility } + : {}), + ...(groupId ? { groupId } : {}), + ...(groupLabel ? { groupLabel } : {}), + ...(groupHint ? { groupHint } : {}), + ...(params.methodId ? { methodId: params.methodId } : {}), + ...(onboardingScopes ? { onboardingScopes } : {}), + ...(modelAllowlist ? { modelAllowlist } : {}), + }; +} + +function buildNormalizedModelPicker( + modelPicker: ProviderWizardModelPicker, + methodId: string | undefined, +): ProviderWizardModelPicker { + const label = normalizeText(modelPicker.label); + const hint = normalizeText(modelPicker.hint); + return { + ...(label ? { label } : {}), + ...(hint ? { hint } : {}), + ...(methodId ? { methodId } : {}), + }; +} + function normalizeProviderWizardSetup(params: { providerId: string; pluginId: string; source: string; auth: ProviderAuthMethod[]; - setup: NonNullable["setup"]; + setup: ProviderWizardSetup; pushDiagnostic: (diag: PluginDiagnostic) => void; -}): NonNullable["setup"] { +}): ProviderWizardSetup | undefined { const hasAuthMethods = params.auth.length > 0; if (!params.setup) { return undefined; @@ -85,67 +179,19 @@ function normalizeProviderWizardSetup(params: { }); return undefined; } - const methodId = normalizeText(params.setup.methodId); - if (methodId && !params.auth.some((method) => method.id === methodId)) { - pushProviderDiagnostic({ - level: "warn", - pluginId: params.pluginId, - source: params.source, - message: `provider "${params.providerId}" setup method "${methodId}" not found; falling back to available methods`, - pushDiagnostic: params.pushDiagnostic, - }); - } - return { - ...(normalizeText(params.setup.choiceId) - ? { choiceId: normalizeText(params.setup.choiceId) } - : {}), - ...(normalizeText(params.setup.choiceLabel) - ? { choiceLabel: normalizeText(params.setup.choiceLabel) } - : {}), - ...(normalizeText(params.setup.choiceHint) - ? { choiceHint: normalizeText(params.setup.choiceHint) } - : {}), - ...(typeof params.setup.assistantPriority === "number" && - Number.isFinite(params.setup.assistantPriority) - ? { assistantPriority: params.setup.assistantPriority } - : {}), - ...(params.setup.assistantVisibility === "manual-only" || - params.setup.assistantVisibility === "visible" - ? { assistantVisibility: params.setup.assistantVisibility } - : {}), - ...(normalizeText(params.setup.groupId) - ? { groupId: normalizeText(params.setup.groupId) } - : {}), - ...(normalizeText(params.setup.groupLabel) - ? { groupLabel: normalizeText(params.setup.groupLabel) } - : {}), - ...(normalizeText(params.setup.groupHint) - ? { groupHint: normalizeText(params.setup.groupHint) } - : {}), - ...(methodId && params.auth.some((method) => method.id === methodId) ? { methodId } : {}), - ...(normalizeOnboardingScopes(params.setup.onboardingScopes) - ? { onboardingScopes: normalizeOnboardingScopes(params.setup.onboardingScopes) } - : {}), - ...(params.setup.modelAllowlist - ? { - modelAllowlist: { - ...(normalizeTextList(params.setup.modelAllowlist.allowedKeys) - ? { allowedKeys: normalizeTextList(params.setup.modelAllowlist.allowedKeys) } - : {}), - ...(normalizeTextList(params.setup.modelAllowlist.initialSelections) - ? { - initialSelections: normalizeTextList( - params.setup.modelAllowlist.initialSelections, - ), - } - : {}), - ...(normalizeText(params.setup.modelAllowlist.message) - ? { message: normalizeText(params.setup.modelAllowlist.message) } - : {}), - }, - } - : {}), - }; + const methodId = resolveWizardMethodId({ + providerId: params.providerId, + pluginId: params.pluginId, + source: params.source, + auth: params.auth, + methodId: normalizeText(params.setup.methodId), + metadataKind: "setup", + pushDiagnostic: params.pushDiagnostic, + }); + return buildNormalizedWizardSetup({ + setup: params.setup, + methodId, + }); } function normalizeProviderAuthMethods(params: { @@ -181,14 +227,17 @@ function normalizeProviderAuthMethods(params: { continue; } seenMethodIds.add(methodId); - const wizard = normalizeProviderWizardSetup({ - providerId: params.providerId, - pluginId: params.pluginId, - source: params.source, - auth: [{ ...method, id: methodId }], - setup: method.wizard, - pushDiagnostic: params.pushDiagnostic, - }); + const wizardSetup = method.wizard; + const wizard = wizardSetup + ? normalizeProviderWizardSetup({ + providerId: params.providerId, + pluginId: params.pluginId, + source: params.source, + auth: [{ ...method, id: methodId }], + setup: wizardSetup, + pushDiagnostic: params.pushDiagnostic, + }) + : undefined; normalized.push({ ...method, id: methodId, @@ -214,9 +263,6 @@ function normalizeProviderWizard(params: { } const hasAuthMethods = params.auth.length > 0; - const hasMethod = (methodId: string | undefined) => - Boolean(methodId && params.auth.some((method) => method.id === methodId)); - const normalizeSetup = () => { const setup = params.wizard?.setup; if (!setup) { @@ -247,21 +293,18 @@ function normalizeProviderWizard(params: { }); return undefined; } - const methodId = normalizeText(modelPicker.methodId); - if (methodId && !hasMethod(methodId)) { - pushProviderDiagnostic({ - level: "warn", + return buildNormalizedModelPicker( + modelPicker, + resolveWizardMethodId({ + providerId: params.providerId, pluginId: params.pluginId, source: params.source, - message: `provider "${params.providerId}" model-picker method "${methodId}" not found; falling back to available methods`, + auth: params.auth, + methodId: normalizeText(modelPicker.methodId), + metadataKind: "model-picker", pushDiagnostic: params.pushDiagnostic, - }); - } - return { - ...(normalizeText(modelPicker.label) ? { label: normalizeText(modelPicker.label) } : {}), - ...(normalizeText(modelPicker.hint) ? { hint: normalizeText(modelPicker.hint) } : {}), - ...(methodId && hasMethod(methodId) ? { methodId } : {}), - }; + }), + ); }; const setup = normalizeSetup();