From e2b7c4c6a3249f2bed9b138f655c9499580ef356 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:45:45 -0500 Subject: [PATCH] fix: harden provider plugin install and hook compatibility --- src/commands/onboard-search.test.ts | 158 +++++++++++++- src/commands/onboard-search.ts | 138 ++++++------ .../onboarding/plugin-install.test.ts | 57 ++++- src/commands/onboarding/plugin-install.ts | 20 +- src/config/validation.ts | 2 +- src/plugins/discovery.ts | 1 + src/plugins/hooks.ts | 206 +++++++++++++++++- src/plugins/types.ts | 1 + 8 files changed, 490 insertions(+), 93 deletions(-) diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index c934695c80a..6dc1dc9a735 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -620,7 +620,7 @@ describe("setupSearch", () => { expect.arrayContaining([ expect.objectContaining({ title: "Provider setup", - message: "Generic provider guidance.\n\nRead the provider docs before entering your key.", + message: "Generic provider guidance.", }), ]), ); @@ -701,7 +701,6 @@ describe("setupSearch", () => { const result = await setupSearch(cfg, runtime, prompter); expect(result.tools?.web?.search?.provider).toBe("tavily"); - expect(afterActivate).toHaveBeenCalledTimes(1); expect(afterProviderActivate).toHaveBeenCalledTimes(1); expect(afterProviderActivate).toHaveBeenCalledWith( expect.objectContaining({ @@ -715,6 +714,76 @@ describe("setupSearch", () => { workspaceDir: undefined, }), ); + expect(afterActivate).not.toHaveBeenCalled(); + }); + + it("fires legacy after_search_provider_activate hooks when no generic provider hook is registered", async () => { + const afterActivate = vi.fn(); + loadOpenClawPlugins.mockReturnValue({ + searchProviders: [ + { + pluginId: "tavily-search", + provider: { + id: "tavily", + name: "Tavily Search", + description: "Plugin search", + isAvailable: () => true, + search: async () => ({ content: "ok" }), + }, + }, + ], + plugins: [ + { + id: "tavily-search", + name: "Tavily Search", + description: "External Tavily plugin", + origin: "workspace", + source: "/tmp/tavily-search", + configJsonSchema: undefined, + configUiHints: undefined, + }, + ], + typedHooks: [ + { + pluginId: "tavily-search", + hookName: "after_search_provider_activate", + priority: 0, + source: "/tmp/tavily-search", + handler: afterActivate, + }, + ], + }); + + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "brave", + enabled: true, + apiKey: "BSA-test-key", + }, + }, + }, + plugins: { + entries: { + "tavily-search": { + enabled: true, + config: { + apiKey: "tvly-existing-key", + }, + }, + }, + }, + }; + + const { prompter } = createPrompter({ + actionValue: "__switch_active__", + selectValue: "tavily", + }); + + const result = await setupSearch(cfg, runtime, prompter); + + expect(result.tools?.web?.search?.provider).toBe("tavily"); expect(afterActivate).toHaveBeenCalledWith( expect.objectContaining({ providerId: "tavily", @@ -793,6 +862,91 @@ describe("setupSearch", () => { }); }); + it("does not switch active provider when plugin config is still invalid and unpromptable", async () => { + loadOpenClawPlugins.mockReturnValue({ + searchProviders: [ + { + pluginId: "tavily-search", + provider: { + id: "tavily", + name: "Tavily Search", + description: "Plugin search", + search: async () => ({ content: "ok" }), + }, + }, + ], + plugins: [ + { + id: "tavily-search", + name: "Tavily Search", + description: "External Tavily plugin", + origin: "workspace", + source: "/tmp/tavily-search", + configJsonSchema: { + type: "object", + required: ["apiKey", "region"], + properties: { + apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" }, + region: { + type: "object", + properties: { + code: { type: "string" }, + }, + required: ["code"], + }, + }, + }, + configUiHints: { + apiKey: { + label: "Tavily API key", + placeholder: "tvly-...", + sensitive: true, + }, + }, + }, + ], + typedHooks: [], + }); + + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "brave", + enabled: true, + apiKey: "BSA-test-key", + }, + }, + }, + plugins: { + entries: { + "tavily-search": { + enabled: true, + config: {}, + }, + }, + }, + }; + + const { prompter, notes } = createPrompter({ + actionValue: "__switch_active__", + selectValue: "tavily", + textValue: "tvly-valid-key", + }); + + const result = await setupSearch(cfg, runtime, prompter); + + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.plugins?.entries?.["tavily-search"]?.enabled).toBe(true); + expect(notes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "Invalid plugin config", + }), + ]), + ); + }); + it("keeps the existing sensitive plugin config value when left blank", async () => { loadOpenClawPlugins.mockReturnValue({ searchProviders: [ diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index a1880bec139..763b17273a7 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -333,46 +333,27 @@ async function maybeNoteBeforeSearchProviderConfigure(params: { prompter: WizardPrompter; workspaceDir?: string; }): Promise { - if ( - !params.hookRunner?.hasHooks("before_provider_configure") && - !params.hookRunner?.hasHooks("before_search_provider_configure") - ) { + if (!params.hookRunner?.hasProviderConfigureHooks("search")) { return; } const activeProviderId = resolveCapabilitySlotSelection(params.config, "providers.search") ?? null; const ctx = { workspaceDir: params.workspaceDir }; - const genericResult = params.hookRunner.hasHooks("before_provider_configure") - ? await params.hookRunner.runBeforeProviderConfigure( - { - providerKind: "search", - slot: "providers.search", - providerId: params.provider.providerId, - providerLabel: params.provider.providerLabel, - providerSource: params.provider.providerSource, - pluginId: params.provider.pluginId, - intent: params.intent, - activeProviderId, - configured: params.provider.configured, - }, - ctx, - ) - : undefined; - const searchResult = params.hookRunner.hasHooks("before_search_provider_configure") - ? await params.hookRunner.runBeforeSearchProviderConfigure( - { - providerId: params.provider.providerId, - providerLabel: params.provider.providerLabel, - providerSource: params.provider.providerSource, - pluginId: params.provider.pluginId, - intent: params.intent, - activeProviderId, - configured: params.provider.configured, - }, - ctx, - ) - : undefined; - const note = [genericResult?.note, searchResult?.note].filter(hasNonEmptyString).join("\n\n"); + const result = await params.hookRunner.runBeforeProviderConfigure( + { + providerKind: "search", + slot: "providers.search", + providerId: params.provider.providerId, + providerLabel: params.provider.providerLabel, + providerSource: params.provider.providerSource, + pluginId: params.provider.pluginId, + intent: params.intent, + activeProviderId, + configured: params.provider.configured, + }, + ctx, + ); + const note = result?.note; if (note.trim()) { await params.prompter.note(note, "Provider setup"); } @@ -406,28 +387,15 @@ async function runAfterSearchProviderHooks(params: { activeProviderId: activeProviderAfter, configured: params.provider.configured, }; - const searchConfigureEvent = { - providerId: params.provider.providerId, - providerLabel: params.provider.providerLabel, - providerSource: params.provider.providerSource, - pluginId: params.provider.pluginId, - intent: params.intent, - activeProviderId: activeProviderAfter, - configured: params.provider.configured, - }; - if (params.hookRunner.hasHooks("after_provider_configure")) { + if (params.hookRunner.hasProviderConfigureHooks("search")) { await params.hookRunner.runAfterProviderConfigure(genericConfigureEvent, ctx); } - if (params.hookRunner.hasHooks("after_search_provider_configure")) { - await params.hookRunner.runAfterSearchProviderConfigure(searchConfigureEvent, ctx); - } if ( activeProviderAfter === params.provider.providerId && activeProviderBefore !== activeProviderAfter && - (params.hookRunner.hasHooks("after_provider_activate") || - params.hookRunner.hasHooks("after_search_provider_activate")) + params.hookRunner.hasProviderActivationHooks("search") ) { const genericActivateEvent = { providerKind: "search" as const, @@ -439,20 +407,7 @@ async function runAfterSearchProviderHooks(params: { previousProviderId: activeProviderBefore, intent: params.intent, }; - const searchActivateEvent = { - providerId: params.provider.providerId, - providerLabel: params.provider.providerLabel, - providerSource: params.provider.providerSource, - pluginId: params.provider.pluginId, - previousProviderId: activeProviderBefore, - intent: params.intent, - }; - if (params.hookRunner.hasHooks("after_provider_activate")) { - await params.hookRunner.runAfterProviderActivate(genericActivateEvent, ctx); - } - if (params.hookRunner.hasHooks("after_search_provider_activate")) { - await params.hookRunner.runAfterSearchProviderActivate(searchActivateEvent, ctx); - } + await params.hookRunner.runAfterProviderActivate(genericActivateEvent, ctx); } } @@ -460,12 +415,25 @@ async function promptPluginSearchProviderConfig( config: OpenClawConfig, entry: PluginSearchProviderEntry, prompter: WizardPrompter, -): Promise { +): Promise<{ config: OpenClawConfig; valid: boolean }> { let nextConfig = config; let nextPluginConfig = getPluginConfig(nextConfig, entry.pluginId); const fields = resolvePromptablePluginFields(entry, nextPluginConfig); if (fields.length === 0) { - return config; + const validation = validatePluginSearchProviderConfig(entry, nextPluginConfig); + if (!validation.ok) { + await prompter.note( + validation.fieldKey + ? `${humanizeConfigKey(validation.fieldKey)}: ${validation.message}` + : [ + "This provider needs configuration that this prompt cannot collect yet.", + validation.message, + ].join("\n"), + "Invalid plugin config", + ); + return { config, valid: false }; + } + return { config, valid: true }; } let fieldIndex = 0; @@ -536,7 +504,7 @@ async function promptPluginSearchProviderConfig( } nextConfig = setPluginConfig(nextConfig, entry.pluginId, nextPluginConfig); - return nextConfig; + return { config: nextConfig, valid: true }; } export async function resolveSearchProviderPickerEntries( @@ -801,13 +769,24 @@ export async function applySearchProviderChoice(params: { prompter: params.prompter, workspaceDir: params.opts?.workspaceDir, }); - next = await promptPluginSearchProviderConfig(next, installedProvider, params.prompter); - const result = preserveSearchProviderIntent( - installedConfig, + const pluginConfigResult = await promptPluginSearchProviderConfig( next, - intent, - installedProvider.value, + installedProvider, + params.prompter, ); + const result = pluginConfigResult.valid + ? preserveSearchProviderIntent( + installedConfig, + pluginConfigResult.config, + intent, + installedProvider.value, + ) + : preserveSearchProviderIntent( + installedConfig, + enabled.config, + "configure-provider", + installedProvider.value, + ); await runAfterSearchProviderHooks({ hookRunner, originalConfig: installedConfig, @@ -1020,8 +999,19 @@ export async function configureSearchProviderSelection( prompter, workspaceDir: opts?.workspaceDir, }); - next = await promptPluginSearchProviderConfig(next, selectedEntry, prompter); - const result = preserveSearchProviderIntent(config, next, intent, selectedEntry.value); + const pluginConfigResult = await promptPluginSearchProviderConfig( + next, + selectedEntry, + prompter, + ); + const result = pluginConfigResult.valid + ? preserveSearchProviderIntent(config, pluginConfigResult.config, intent, selectedEntry.value) + : preserveSearchProviderIntent( + config, + enabled.config, + "configure-provider", + selectedEntry.value, + ); await runAfterSearchProviderHooks({ hookRunner, originalConfig: config, diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index ecf77b1d5b7..373ad9100e6 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -213,7 +213,7 @@ describe("ensureOnboardingPluginInstalled", () => { it("uses a generic placeholder without prefilled local value on dev channel", async () => { expect(await runPromptShapeForChannel("dev")).toEqual( expect.objectContaining({ - message: "Plugin package or local path", + message: "npm package or local path", placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)", }), ); @@ -222,7 +222,7 @@ describe("ensureOnboardingPluginInstalled", () => { it("uses the same generic placeholder without prefilled npm value on beta channel", async () => { expect(await runPromptShapeForChannel("beta")).toEqual( expect.objectContaining({ - message: "Plugin package or local path", + message: "npm package or local path", placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)", }), ); @@ -261,7 +261,7 @@ describe("ensureOnboardingPluginInstalled", () => { expect(text).toHaveBeenCalledWith( expect.objectContaining({ - message: "Plugin package or local path", + message: "npm package or local path", placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)", }), ); @@ -311,7 +311,7 @@ describe("ensureOnboardingPluginInstalled", () => { note, }); const cfg: OpenClawConfig = {}; - vi.mocked(fs.existsSync).mockReturnValue(false); + mockRepoLocalPathExists(); installPluginFromNpmSpec.mockResolvedValue({ ok: true, pluginId: "zalo", @@ -346,7 +346,7 @@ describe("ensureOnboardingPluginInstalled", () => { note, }); const cfg: OpenClawConfig = {}; - vi.mocked(fs.existsSync).mockReturnValue(false); + mockRepoLocalPathExists(); installPluginFromNpmSpec.mockResolvedValue({ ok: true, pluginId: "zalo", @@ -363,7 +363,7 @@ describe("ensureOnboardingPluginInstalled", () => { expect(result.installed).toBe(true); expect(note).toHaveBeenCalledWith( - "This flow installs @openclaw/zalo. Enter that package or a local plugin path.", + "This flow installs @openclaw/zalo. Enter that npm package or a local plugin path.", "Plugin install", ); expect(text).toHaveBeenCalledTimes(2); @@ -393,6 +393,51 @@ describe("ensureOnboardingPluginInstalled", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); }); + it("suppresses local path affordance when local paths are unavailable", async () => { + const runtime = makeRuntime(); + const note = vi.fn(async () => {}); + const text = vi + .fn() + .mockResolvedValueOnce("extensions/zalo") + .mockResolvedValueOnce("@openclaw/zalo"); + const prompter = makePrompter({ + text: text as unknown as WizardPrompter["text"], + note, + }); + const cfg: OpenClawConfig = {}; + vi.mocked(fs.existsSync).mockReturnValue(false); + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "zalo", + targetDir: "/tmp/zalo", + extensions: [], + }); + + const result = await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + workspaceDir: "/tmp/no-git-workspace", + }); + + expect(result.installed).toBe(true); + expect(note).toHaveBeenCalledWith( + "Local plugin paths are unavailable here. Enter an npm package.", + "Plugin install", + ); + expect(text).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + message: "npm package", + placeholder: "@scope/plugin-name (leave blank to skip)", + }), + ); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ spec: "@openclaw/zalo" }), + ); + }); + it("clears discovery cache before reloading the onboarding plugin registry", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = {}; diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index 2def9cc2033..a67eb341f6a 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -120,13 +120,16 @@ async function promptInstallChoice(params: { allowLocal: boolean; }): Promise { const { entry, prompter, workspaceDir, allowLocal } = params; - const genericPlaceholder = "@scope/plugin-name or extensions/plugin-name (leave blank to skip)"; + const message = allowLocal ? "npm package or local path" : "npm package"; + const placeholder = allowLocal + ? "@scope/plugin-name or extensions/plugin-name (leave blank to skip)" + : "@scope/plugin-name (leave blank to skip)"; while (true) { const source = ( await prompter.text({ - message: "Plugin package or local path", - placeholder: genericPlaceholder, + message, + placeholder, }) ).trim(); @@ -141,13 +144,20 @@ async function promptInstallChoice(params: { const looksLikePath = isLikelyLocalPath(source); if (looksLikePath) { - await prompter.note(`Path not found: ${source}`, "Plugin install"); + await prompter.note( + allowLocal + ? `Path not found: ${source}` + : "Local plugin paths are unavailable here. Enter an npm package.", + "Plugin install", + ); continue; } if (!matchesCatalogNpmSpec(source, entry.install.npmSpec)) { await prompter.note( - `This flow installs ${entry.install.npmSpec}. Enter that package or a local plugin path.`, + allowLocal + ? `This flow installs ${entry.install.npmSpec}. Enter that npm package or a local plugin path.` + : `This flow installs ${entry.install.npmSpec}. Enter that npm package.`, "Plugin install", ); continue; diff --git a/src/config/validation.ts b/src/config/validation.ts index 5dc6f21ea44..a5b7a0e32b5 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -46,7 +46,7 @@ function resolvePluginDiagnosticPath(diag: { code?: string; slot?: string; }): string { - if (diag.message.includes("plugin path not found")) { + if (diag.code === "plugin_path_not_found") { return "plugins.load.paths"; } if (diag.slot) { diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 0ccf10831a9..2fa9c83478f 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -540,6 +540,7 @@ function discoverFromPath(params: { if (!fs.existsSync(resolved)) { params.diagnostics.push({ level: "error", + code: "plugin_path_not_found", message: `plugin path not found: ${resolved}`, source: resolved, }); diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 12be2365ce2..3efd9423893 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -138,6 +138,95 @@ function getHooksForName( .toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); } +function getHooksForNameWithoutPluginIds(params: { + registry: PluginRegistry; + hookName: K; + excludedPluginIds: ReadonlySet; +}): PluginHookRegistration[] { + return getHooksForName(params.registry, params.hookName).filter( + (hook) => !params.excludedPluginIds.has(hook.pluginId), + ); +} + +type SearchProviderAliasDescriptor< + TGenericName extends + | "before_provider_configure" + | "after_provider_configure" + | "after_provider_activate", + TLegacyName extends + | "before_search_provider_configure" + | "after_search_provider_configure" + | "after_search_provider_activate", + TGenericEvent, + TLegacyEvent, +> = { + genericHookName: TGenericName; + legacyHookName: TLegacyName; + toLegacyEvent: (event: TGenericEvent) => TLegacyEvent; +}; + +const SEARCH_PROVIDER_ALIAS_HOOKS = { + beforeConfigure: { + genericHookName: "before_provider_configure", + legacyHookName: "before_search_provider_configure", + toLegacyEvent: ( + event: PluginHookBeforeProviderConfigureEvent, + ): PluginHookBeforeSearchProviderConfigureEvent => ({ + providerId: event.providerId, + providerLabel: event.providerLabel, + providerSource: event.providerSource, + pluginId: event.pluginId, + intent: event.intent, + activeProviderId: event.activeProviderId, + configured: event.configured, + }), + } satisfies SearchProviderAliasDescriptor< + "before_provider_configure", + "before_search_provider_configure", + PluginHookBeforeProviderConfigureEvent, + PluginHookBeforeSearchProviderConfigureEvent + >, + afterConfigure: { + genericHookName: "after_provider_configure", + legacyHookName: "after_search_provider_configure", + toLegacyEvent: ( + event: PluginHookAfterProviderConfigureEvent, + ): PluginHookAfterSearchProviderConfigureEvent => ({ + providerId: event.providerId, + providerLabel: event.providerLabel, + providerSource: event.providerSource, + pluginId: event.pluginId, + intent: event.intent, + activeProviderId: event.activeProviderId, + configured: event.configured, + }), + } satisfies SearchProviderAliasDescriptor< + "after_provider_configure", + "after_search_provider_configure", + PluginHookAfterProviderConfigureEvent, + PluginHookAfterSearchProviderConfigureEvent + >, + afterActivate: { + genericHookName: "after_provider_activate", + legacyHookName: "after_search_provider_activate", + toLegacyEvent: ( + event: PluginHookAfterProviderActivateEvent, + ): PluginHookAfterSearchProviderActivateEvent => ({ + providerId: event.providerId, + providerLabel: event.providerLabel, + providerSource: event.providerSource, + pluginId: event.pluginId, + previousProviderId: event.previousProviderId, + intent: event.intent, + }), + } satisfies SearchProviderAliasDescriptor< + "after_provider_activate", + "after_search_provider_activate", + PluginHookAfterProviderActivateEvent, + PluginHookAfterSearchProviderActivateEvent + >, +} as const; + /** * Create a hook runner for a specific registry. */ @@ -244,6 +333,15 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp ctx: Parameters["handler"]>>[1], ): Promise { const hooks = getHooksForName(registry, hookName); + return runVoidHookRegistrations(hookName, hooks, event, ctx); + } + + async function runVoidHookRegistrations( + hookName: K, + hooks: PluginHookRegistration[], + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + ): Promise { if (hooks.length === 0) { return; } @@ -272,6 +370,16 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult, ): Promise { const hooks = getHooksForName(registry, hookName); + return runModifyingHookRegistrations(hookName, hooks, event, ctx, mergeResults); + } + + async function runModifyingHookRegistrations( + hookName: K, + hooks: PluginHookRegistration[], + event: Parameters["handler"]>>[0], + ctx: Parameters["handler"]>>[1], + mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult, + ): Promise { if (hooks.length === 0) { return undefined; } @@ -301,6 +409,25 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp return result; } + function getSearchProviderLegacyHooks< + TGenericName extends + | "before_provider_configure" + | "after_provider_configure" + | "after_provider_activate", + TLegacyName extends + | "before_search_provider_configure" + | "after_search_provider_configure" + | "after_search_provider_activate", + >(descriptor: SearchProviderAliasDescriptor) { + const genericHooks = getHooksForName(registry, descriptor.genericHookName); + const genericPluginIds = new Set(genericHooks.map((hook) => hook.pluginId)); + return getHooksForNameWithoutPluginIds({ + registry, + hookName: descriptor.legacyHookName, + excludedPluginIds: genericPluginIds, + }); + } + // ========================================================================= // Agent Hooks // ========================================================================= @@ -370,26 +497,70 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp event: PluginHookBeforeProviderConfigureEvent, ctx: PluginHookSearchProviderContext, ): Promise { - return runModifyingHook<"before_provider_configure", PluginHookBeforeProviderConfigureResult>( + const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.beforeConfigure; + const genericResult = await runModifyingHook< "before_provider_configure", - event, + PluginHookBeforeProviderConfigureResult + >("before_provider_configure", event, ctx, mergeBeforeProviderConfigure); + if (event.providerKind !== "search") { + return genericResult; + } + + const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor); + const searchResult = await runModifyingHookRegistrations< + "before_search_provider_configure", + PluginHookBeforeSearchProviderConfigureResult + >( + aliasDescriptor.legacyHookName, + legacyHooks, + aliasDescriptor.toLegacyEvent(event), ctx, - mergeBeforeProviderConfigure, + mergeBeforeSearchProviderConfigure, ); + if (!searchResult) { + return genericResult; + } + return mergeBeforeProviderConfigure(genericResult, { + note: searchResult.note, + }); } async function runAfterProviderConfigure( event: PluginHookAfterProviderConfigureEvent, ctx: PluginHookSearchProviderContext, ): Promise { - return runVoidHook("after_provider_configure", event, ctx); + const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.afterConfigure; + await runVoidHook("after_provider_configure", event, ctx); + if (event.providerKind !== "search") { + return; + } + + const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor); + await runVoidHookRegistrations( + aliasDescriptor.legacyHookName, + legacyHooks, + aliasDescriptor.toLegacyEvent(event), + ctx, + ); } async function runAfterProviderActivate( event: PluginHookAfterProviderActivateEvent, ctx: PluginHookSearchProviderContext, ): Promise { - return runVoidHook("after_provider_activate", event, ctx); + const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.afterActivate; + await runVoidHook("after_provider_activate", event, ctx); + if (event.providerKind !== "search") { + return; + } + + const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor); + await runVoidHookRegistrations( + aliasDescriptor.legacyHookName, + legacyHooks, + aliasDescriptor.toLegacyEvent(event), + ctx, + ); } async function runAfterSearchProviderConfigure( @@ -810,6 +981,29 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp return registry.typedHooks.filter((h) => h.hookName === hookName).length; } + function hasProviderConfigureHooks(providerKind?: string): boolean { + if (hasHooks("before_provider_configure") || hasHooks("after_provider_configure")) { + return true; + } + if (providerKind !== "search") { + return false; + } + return ( + hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.beforeConfigure.legacyHookName) || + hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.afterConfigure.legacyHookName) + ); + } + + function hasProviderActivationHooks(providerKind?: string): boolean { + if (hasHooks("after_provider_activate")) { + return true; + } + if (providerKind !== "search") { + return false; + } + return hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.afterActivate.legacyHookName); + } + return { // Agent hooks runBeforeModelResolve, @@ -849,6 +1043,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp runGatewayStop, // Utility hasHooks, + hasProviderConfigureHooks, + hasProviderActivationHooks, getHookCount, }; } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index dbfc15a706b..0569d218f3f 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -486,6 +486,7 @@ export type PluginDiagnostic = { pluginId?: string; source?: string; code?: + | "plugin_path_not_found" | "capability_declared_duplicate" | "capability_declared_not_registered" | "capability_missing_requirement"