From da6c29b3d95340bdc93d9efe67cf66576a166b2f Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 24 Apr 2026 05:21:51 +0100 Subject: [PATCH] fix: bound unscoped provider discovery fallback --- extensions/arcee/openclaw.plugin.json | 1 - extensions/arcee/provider-discovery.ts | 17 ---- .../provider-discovery.runtime.test.ts | 52 ++++++++++-- src/plugins/provider-discovery.runtime.ts | 84 +++++++++++++++---- 4 files changed, 114 insertions(+), 40 deletions(-) delete mode 100644 extensions/arcee/provider-discovery.ts diff --git a/extensions/arcee/openclaw.plugin.json b/extensions/arcee/openclaw.plugin.json index f625eb46a43..03d1c4ec49b 100644 --- a/extensions/arcee/openclaw.plugin.json +++ b/extensions/arcee/openclaw.plugin.json @@ -1,7 +1,6 @@ { "id": "arcee", "enabledByDefault": true, - "providerDiscoveryEntry": "./provider-discovery.ts", "providers": ["arcee"], "providerAuthEnvVars": { "arcee": ["ARCEEAI_API_KEY"] diff --git a/extensions/arcee/provider-discovery.ts b/extensions/arcee/provider-discovery.ts deleted file mode 100644 index 009ead49bda..00000000000 --- a/extensions/arcee/provider-discovery.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; -import { buildArceeProvider } from "./provider-catalog.js"; - -export const arceeProviderDiscovery: ProviderPlugin = { - id: "arcee", - label: "Arcee AI", - docsPath: "/providers/models", - auth: [], - staticCatalog: { - order: "simple", - run: async () => ({ - provider: buildArceeProvider(), - }), - }, -}; - -export default arceeProviderDiscovery; diff --git a/src/plugins/provider-discovery.runtime.test.ts b/src/plugins/provider-discovery.runtime.test.ts index c091ea6a167..eb8f1b6152b 100644 --- a/src/plugins/provider-discovery.runtime.test.ts +++ b/src/plugins/provider-discovery.runtime.test.ts @@ -44,6 +44,19 @@ function createManifestPlugin(id: string): PluginManifestRecord { }; } +function createManifestPluginWithoutDiscovery(params: { + id: string; + providerAuthEnvVars?: Record; +}): PluginManifestRecord { + const { providerDiscoverySource: _providerDiscoverySource, ...plugin } = createManifestPlugin( + params.id, + ); + return { + ...plugin, + ...(params.providerAuthEnvVars ? { providerAuthEnvVars: params.providerAuthEnvVars } : {}), + }; +} + function createProvider(params: { id: string; mode: "static" | "catalog" }): ProviderPlugin { const hook = { run: async () => ({ @@ -80,28 +93,55 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { expect(mocks.resolvePluginProviders).toHaveBeenCalledWith( expect.objectContaining({ bundledProviderAllowlistCompat: true, + onlyPluginIds: ["deepseek"], }), ); }); - it("falls back to full provider plugins for mixed live and static-only entries", () => { + it("keeps unscoped discovery bounded for mixed live and static-only entries", () => { + const codexEntryProvider = createProvider({ id: "codex", mode: "catalog" }); const fullProviders = [ - createProvider({ id: "codex", mode: "catalog" }), createProvider({ id: "deepseek", mode: "catalog" }), + createProvider({ id: "kilocode", mode: "catalog" }), ]; - mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["codex", "deepseek"]); + mocks.resolveDiscoveredProviderPluginIds.mockReturnValue([ + "codex", + "deepseek", + "kilocode", + "unused", + ]); mocks.loadPluginManifestRegistry.mockReturnValue({ - plugins: [createManifestPlugin("codex"), createManifestPlugin("deepseek")], + plugins: [ + createManifestPlugin("codex"), + createManifestPlugin("deepseek"), + createManifestPluginWithoutDiscovery({ + id: "kilocode", + providerAuthEnvVars: { kilocode: ["KILOCODE_API_KEY"] }, + }), + createManifestPluginWithoutDiscovery({ + id: "unused", + providerAuthEnvVars: { unused: ["UNUSED_API_KEY"] }, + }), + ], diagnostics: [], }); mocks.loadSource.mockImplementation((modulePath: string) => modulePath.includes("/codex/") - ? createProvider({ id: "codex", mode: "catalog" }) + ? codexEntryProvider : createProvider({ id: "deepseek", mode: "static" }), ); mocks.resolvePluginProviders.mockReturnValue(fullProviders); - expect(resolvePluginDiscoveryProvidersRuntime({})).toEqual(fullProviders); + expect( + resolvePluginDiscoveryProvidersRuntime({ + env: { KILOCODE_API_KEY: "sk-test" } as NodeJS.ProcessEnv, + }), + ).toEqual([{ ...codexEntryProvider, pluginId: "codex" }, ...fullProviders]); + expect(mocks.resolvePluginProviders).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["deepseek", "kilocode"], + }), + ); }); it("returns static-only discovery entries for callers that explicitly request them", () => { diff --git a/src/plugins/provider-discovery.runtime.ts b/src/plugins/provider-discovery.runtime.ts index d45c0d7db8f..000a91c0e11 100644 --- a/src/plugins/provider-discovery.runtime.ts +++ b/src/plugins/provider-discovery.runtime.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import { resolveDiscoveredProviderPluginIds } from "./providers.js"; import { resolvePluginProviders } from "./providers.runtime.js"; import { createPluginSourceLoader } from "./source-loader.js"; @@ -17,6 +17,8 @@ type ProviderDiscoveryModule = type ProviderDiscoveryEntryResult = { providers: ProviderPlugin[]; complete: boolean; + pluginRecords: PluginManifestRecord[]; + entryPluginIds: Set; }; function normalizeDiscoveryModule(value: ProviderDiscoveryModule): ProviderPlugin[] { @@ -48,6 +50,22 @@ function hasLiveProviderDiscoveryHook(provider: ProviderPlugin): boolean { ); } +function hasProviderAuthEnvCredential( + plugin: PluginManifestRecord, + env: NodeJS.ProcessEnv, +): boolean { + return Object.values(plugin.providerAuthEnvVars ?? {}).some((envVars) => + envVars.some((name) => { + const value = env[name]?.trim(); + return value !== undefined && value !== ""; + }), + ); +} + +function dedupeSorted(values: Iterable): string[] { + return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); +} + function resolveProviderDiscoveryEntryPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; @@ -59,19 +77,21 @@ function resolveProviderDiscoveryEntryPlugins(params: { }): ProviderDiscoveryEntryResult { const pluginIds = resolveDiscoveredProviderPluginIds(params); const pluginIdSet = new Set(pluginIds); - const records = loadPluginManifestRegistry(params).plugins.filter( - (plugin) => plugin.providerDiscoverySource && pluginIdSet.has(plugin.id), + const pluginRecords = loadPluginManifestRegistry(params).plugins.filter((plugin) => + pluginIdSet.has(plugin.id), ); - if (records.length === 0) { - return { providers: [], complete: false }; + const entryRecords = pluginRecords.filter((plugin) => plugin.providerDiscoverySource); + const entryPluginIds = new Set(entryRecords.map((plugin) => plugin.id)); + if (entryRecords.length === 0) { + return { providers: [], complete: false, pluginRecords, entryPluginIds }; } - const complete = records.length === pluginIdSet.size; + const complete = entryRecords.length === pluginIdSet.size; if (params.requireCompleteDiscoveryEntryCoverage && !complete) { - return { providers: [], complete: false }; + return { providers: [], complete: false, pluginRecords, entryPluginIds }; } const loadSource = createPluginSourceLoader(); const providers: ProviderPlugin[] = []; - for (const manifest of records) { + for (const manifest of entryRecords) { try { const moduleExport = loadSource(manifest.providerDiscoverySource!) as ProviderDiscoveryModule; providers.push( @@ -82,10 +102,26 @@ function resolveProviderDiscoveryEntryPlugins(params: { } catch { // Discovery fast path is optional. Fall back to the full plugin loader // below so existing plugin diagnostics/load behavior remains canonical. - return { providers: [], complete: false }; + return { providers: [], complete: false, pluginRecords, entryPluginIds }; } } - return { providers, complete }; + return { providers, complete, pluginRecords, entryPluginIds }; +} + +function resolveSelectiveFullPluginIds(params: { + entryResult: ProviderDiscoveryEntryResult; + entryProviders: ProviderPlugin[]; + env: NodeJS.ProcessEnv; +}): string[] { + const staticOnlyEntryPluginIds = params.entryProviders + .filter((provider) => !hasLiveProviderDiscoveryHook(provider)) + .map((provider) => provider.pluginId) + .filter((pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== ""); + const missingEntryCredentialPluginIds = params.entryResult.pluginRecords + .filter((plugin) => !params.entryResult.entryPluginIds.has(plugin.id)) + .filter((plugin) => hasProviderAuthEnvCredential(plugin, params.env)) + .map((plugin) => plugin.id); + return dedupeSorted([...staticOnlyEntryPluginIds, ...missingEntryCredentialPluginIds]); } export function resolvePluginDiscoveryProvidersRuntime(params: { @@ -97,19 +133,35 @@ export function resolvePluginDiscoveryProvidersRuntime(params: { requireCompleteDiscoveryEntryCoverage?: boolean; discoveryEntriesOnly?: boolean; }): ProviderPlugin[] { + const env = params.env ?? process.env; const entryResult = resolveProviderDiscoveryEntryPlugins(params); if (params.discoveryEntriesOnly === true) { return entryResult.providers; } - if ( - entryResult.complete && - entryResult.providers.length > 0 && - entryResult.providers.every(hasLiveProviderDiscoveryHook) - ) { - return entryResult.providers; + const liveEntryProviders = entryResult.providers.filter(hasLiveProviderDiscoveryHook); + if (entryResult.complete && liveEntryProviders.length === entryResult.providers.length) { + return liveEntryProviders; + } + if (params.onlyPluginIds === undefined && entryResult.providers.length > 0) { + const fullPluginIds = resolveSelectiveFullPluginIds({ + entryResult, + entryProviders: entryResult.providers, + env, + }); + const fullProviders = + fullPluginIds.length > 0 + ? resolvePluginProviders({ + ...params, + env, + onlyPluginIds: fullPluginIds, + bundledProviderAllowlistCompat: true, + }) + : []; + return [...liveEntryProviders, ...fullProviders]; } return resolvePluginProviders({ ...params, + env, bundledProviderAllowlistCompat: true, }); }