From 47ae15c0591fbd59a616a9af1df13cfa13cd1304 Mon Sep 17 00:00:00 2001 From: Shakker Date: Thu, 23 Apr 2026 06:34:12 +0100 Subject: [PATCH] feat: add external auth provider contracts --- src/plugins/manifest-registry.ts | 1 + src/plugins/manifest.ts | 8 ++++++ src/plugins/providers.test.ts | 48 ++++++++++++++++++++++++++++++++ src/plugins/providers.ts | 41 +++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index fb02c065f8a..1101eec459d 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -47,6 +47,7 @@ import { resolvePluginCacheInputs } from "./roots.js"; type PluginManifestContractListKey = | "speechProviders" + | "externalAuthProviders" | "mediaUnderstandingProviders" | "realtimeVoiceProviders" | "realtimeTranscriptionProviders" diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 129b3bf258c..eaaa01eed5c 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -233,6 +233,12 @@ export type PluginManifest = { export type PluginManifestContracts = { embeddedExtensionFactories?: string[]; + /** + * Provider ids whose external auth profile hook can contribute runtime-only + * credentials. Declaring this lets auth-store overlays load only the owning + * plugin instead of every provider plugin. + */ + externalAuthProviders?: string[]; memoryEmbeddingProviders?: string[]; speechProviders?: string[]; realtimeTranscriptionProviders?: string[]; @@ -420,6 +426,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u } const embeddedExtensionFactories = normalizeTrimmedStringList(value.embeddedExtensionFactories); + const externalAuthProviders = normalizeTrimmedStringList(value.externalAuthProviders); const memoryEmbeddingProviders = normalizeTrimmedStringList(value.memoryEmbeddingProviders); const speechProviders = normalizeTrimmedStringList(value.speechProviders); const realtimeTranscriptionProviders = normalizeTrimmedStringList( @@ -435,6 +442,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u const tools = normalizeTrimmedStringList(value.tools); const contracts = { ...(embeddedExtensionFactories.length > 0 ? { embeddedExtensionFactories } : {}), + ...(externalAuthProviders.length > 0 ? { externalAuthProviders } : {}), ...(memoryEmbeddingProviders.length > 0 ? { memoryEmbeddingProviders } : {}), ...(speechProviders.length > 0 ? { speechProviders } : {}), ...(realtimeTranscriptionProviders.length > 0 ? { realtimeTranscriptionProviders } : {}), diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 5776b8afdea..b2475f6e242 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -10,6 +10,8 @@ type LoadOpenClawPlugins = typeof import("./loader.js").loadOpenClawPlugins; type IsPluginRegistryLoadInFlight = typeof import("./loader.js").isPluginRegistryLoadInFlight; type LoadPluginManifestRegistry = typeof import("./manifest-registry.js").loadPluginManifestRegistry; +type ResolveManifestContractPluginIds = + typeof import("./manifest-registry.js").resolveManifestContractPluginIds; type ApplyPluginAutoEnable = typeof import("../config/plugin-auto-enable.js").applyPluginAutoEnable; type SetActivePluginRegistry = typeof import("./runtime.js").setActivePluginRegistry; @@ -17,12 +19,30 @@ const resolveRuntimePluginRegistryMock = vi.fn(); const loadOpenClawPluginsMock = vi.fn(); const isPluginRegistryLoadInFlightMock = vi.fn((_) => false); const loadPluginManifestRegistryMock = vi.fn(); +const resolveManifestContractPluginIdsMock = vi.fn((params) => { + const onlyPluginIds = + params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null; + return loadPluginManifestRegistryMock({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter( + (plugin) => + (!params.origin || plugin.origin === params.origin) && + (!onlyPluginIds || onlyPluginIds.has(plugin.id)) && + (plugin.contracts?.[params.contract] ?? []).length > 0, + ) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +}); const applyPluginAutoEnableMock = vi.fn(); let resolveOwningPluginIdsForProvider: typeof import("./providers.js").resolveOwningPluginIdsForProvider; let resolveOwningPluginIdsForModelRef: typeof import("./providers.js").resolveOwningPluginIdsForModelRef; let resolveActivatableProviderOwnerPluginIds: typeof import("./providers.js").resolveActivatableProviderOwnerPluginIds; let resolveEnabledProviderPluginIds: typeof import("./providers.js").resolveEnabledProviderPluginIds; +let resolveExternalAuthProfileProviderPluginIds: typeof import("./providers.js").resolveExternalAuthProfileProviderPluginIds; let resolveDiscoveredProviderPluginIds: typeof import("./providers.js").resolveDiscoveredProviderPluginIds; let resolveDiscoverableProviderOwnerPluginIds: typeof import("./providers.js").resolveDiscoverableProviderOwnerPluginIds; let resolvePluginProviders: typeof import("./providers.runtime.js").resolvePluginProviders; @@ -37,6 +57,7 @@ function createManifestProviderPlugin(params: { modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] }; activation?: PluginManifestRecord["activation"]; setup?: PluginManifestRecord["setup"]; + contracts?: PluginManifestRecord["contracts"]; }): PluginManifestRecord { return { id: params.id, @@ -47,6 +68,7 @@ function createManifestProviderPlugin(params: { modelSupport: params.modelSupport, activation: params.activation, setup: params.setup, + contracts: params.contracts, skills: [], hooks: [], origin: params.origin ?? "bundled", @@ -285,12 +307,15 @@ describe("resolvePluginProviders", () => { vi.doMock("./manifest-registry.js", () => ({ loadPluginManifestRegistry: (...args: Parameters) => loadPluginManifestRegistryMock(...args), + resolveManifestContractPluginIds: (...args: Parameters) => + resolveManifestContractPluginIdsMock(...args), })); ({ resolveActivatableProviderOwnerPluginIds, resolveOwningPluginIdsForProvider, resolveOwningPluginIdsForModelRef, resolveEnabledProviderPluginIds, + resolveExternalAuthProfileProviderPluginIds, resolveDiscoveredProviderPluginIds, resolveDiscoverableProviderOwnerPluginIds, } = await import("./providers.js")); @@ -321,6 +346,7 @@ describe("resolvePluginProviders", () => { resolveRuntimePluginRegistryMock.mockReturnValue(registry); loadOpenClawPluginsMock.mockReturnValue(registry); loadPluginManifestRegistryMock.mockReset(); + resolveManifestContractPluginIdsMock.mockClear(); applyPluginAutoEnableMock.mockReset(); applyPluginAutoEnableMock.mockImplementation( (params): PluginAutoEnableResult => ({ @@ -387,6 +413,28 @@ describe("resolvePluginProviders", () => { ]); }); + it("resolves external auth hook plugin ids from manifest contracts without runtime loading", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "external-auth-owner", + providerIds: ["demo"], + contracts: { externalAuthProviders: ["demo"] }, + }), + createManifestProviderPlugin({ + id: "regular-provider", + providerIds: ["regular"], + }), + ]); + + expect( + resolveExternalAuthProfileProviderPluginIds({ + config: {}, + env: {} as NodeJS.ProcessEnv, + }), + ).toEqual(["external-auth-owner"]); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + }); + it("treats explicit empty provider scopes as scoped-empty in provider helpers", () => { expect( resolveEnabledProviderPluginIds({ diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 07067b1ad70..adb43761385 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -8,6 +8,7 @@ import { } from "./manifest-owner-policy.js"; import { loadPluginManifestRegistry, + resolveManifestContractPluginIds, type PluginManifestRecord, type PluginManifestRegistry, } from "./manifest-registry.js"; @@ -118,6 +119,44 @@ export function resolveEnabledProviderPluginIds(params: { ); } +export function resolveExternalAuthProfileProviderPluginIds(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] { + return resolveManifestContractPluginIds({ + contract: "externalAuthProviders", + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); +} + +export function resolveExternalAuthProfileCompatFallbackPluginIds(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] { + // Compatibility fallback for provider plugins that implemented the public + // external auth hook before contracts.externalAuthProviders existed. Remove + // this with the warning path in provider-runtime after the deprecation window. + const declaredPluginIds = new Set(resolveExternalAuthProfileProviderPluginIds(params)); + const registry = loadProviderManifestRegistry(params); + const normalizedConfig = normalizePluginsConfig(params.config?.plugins); + return listManifestPluginIds( + registry, + (plugin) => + plugin.origin !== "bundled" && + plugin.providers.length > 0 && + !declaredPluginIds.has(plugin.id) && + isProviderPluginEligibleForRuntimeOwnerActivation({ + plugin, + normalizedConfig, + rootConfig: params.config, + }), + ); +} + export function resolveDiscoveredProviderPluginIds(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -229,6 +268,8 @@ export function resolveActivatableProviderOwnerPluginIds(params: { export const __testing = { resolveActivatableProviderOwnerPluginIds, resolveEnabledProviderPluginIds, + resolveExternalAuthProfileCompatFallbackPluginIds, + resolveExternalAuthProfileProviderPluginIds, resolveDiscoveredProviderPluginIds, resolveDiscoverableProviderOwnerPluginIds, resolveBundledProviderCompatPluginIds,