diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d5d3ee8d5..da176b4b55f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Plugins/setup: report descriptor/runtime drift when setup-api registrations disagree with `setup.providers` or `setup.cliBackends`, without rejecting legacy setup plugins. Thanks @vincentkoc. - Plugin hooks: expose first-class run, message, sender, session, and trace correlation fields on message hook contexts and run lifecycle events. Thanks @vincentkoc. - Plugins/setup: include `setup.providers[].envVars` in generic provider auth/env lookups and warn non-bundled plugins that still rely on deprecated `providerAuthEnvVars` compatibility metadata. Thanks @vincentkoc. +- Plugins/setup: derive generic provider setup choices from descriptor-safe `setup.providers[].authMethods` before falling back to setup runtime. Thanks @vincentkoc. - Plugins/setup: surface manifest provider auth choices directly in provider setup flow before falling back to setup runtime or install-catalog choices. Thanks @vincentkoc. - Plugins/setup: warn when descriptor-only setup plugins still ship ignored setup runtime entries, keeping `setup.requiresRuntime: false` semantics explicit without breaking existing metadata. Thanks @vincentkoc. - Plugins/channels: use manifest `channelConfigs` for read-only external channel discovery when no setup entry is available or setup descriptors declare runtime unnecessary. Thanks @vincentkoc. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index e7d75746285..1cca08c9919 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -335,6 +335,11 @@ adapter during the deprecation window, but non-bundled plugins that still use it receive a manifest diagnostic. New plugins should put setup/status env metadata on `setup.providers[].envVars`. +OpenClaw can also derive simple setup choices from `setup.providers[].authMethods` +when no setup entry is available, or when `setup.requiresRuntime: false` +declares setup runtime unnecessary. Explicit `providerAuthChoices` entries stay +preferred for custom labels, CLI flags, onboarding scope, and assistant metadata. + Set `requiresRuntime: false` only when those descriptors are sufficient for the setup surface. OpenClaw treats explicit `false` as a descriptor-only contract and will not execute `setup-api` or `openclaw.setupEntry` for setup lookup. If diff --git a/extensions/slack/src/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts index bd32cf26664..7f4c5cf3484 100644 --- a/extensions/slack/src/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -287,6 +287,9 @@ vi.mock("@slack/bolt", () => { command() { /* no-op */ } + use() { + /* no-op */ + } start = vi.fn().mockResolvedValue(undefined); stop = vi.fn().mockResolvedValue(undefined); } diff --git a/src/plugins/provider-auth-choices.test.ts b/src/plugins/provider-auth-choices.test.ts index 52c08f032ee..877f4f9f968 100644 --- a/src/plugins/provider-auth-choices.test.ts +++ b/src/plugins/provider-auth-choices.test.ts @@ -230,6 +230,177 @@ describe("provider auth choice manifest helpers", () => { ]); }); + it("derives generic auth choices from descriptor-safe setup provider auth methods", () => { + setManifestPlugins([ + { + id: "demo-provider", + name: "Demo Provider", + origin: "global", + setup: { + providers: [ + { + id: "demo-provider", + authMethods: ["api-key", "oauth"], + }, + ], + requiresRuntime: false, + }, + }, + ]); + + expect(resolveManifestProviderAuthChoices()).toEqual([ + { + pluginId: "demo-provider", + providerId: "demo-provider", + methodId: "api-key", + choiceId: "demo-provider-api-key", + choiceLabel: "Demo Provider API key", + groupId: "demo-provider", + groupLabel: "Demo Provider", + }, + { + pluginId: "demo-provider", + providerId: "demo-provider", + methodId: "oauth", + choiceId: "demo-provider-oauth", + choiceLabel: "Demo Provider OAuth", + groupId: "demo-provider", + groupLabel: "Demo Provider", + }, + ]); + }); + + it("sanitizes setup provider auth descriptors before deriving prompt labels", () => { + setManifestPlugins([ + { + id: "evil-provider", + origin: "workspace", + setup: { + providers: [ + { + id: "evil\u001b[31m-provider", + authMethods: ["jwt\u001b[2K", "oidc"], + }, + ], + requiresRuntime: false, + }, + }, + ]); + + expect(resolveManifestProviderAuthChoices()).toEqual([ + { + pluginId: "evil-provider", + providerId: "evil-provider", + methodId: "jwt", + choiceId: "evil-provider-jwt", + choiceLabel: "Evil Provider JWT", + groupId: "evil-provider", + groupLabel: "Evil Provider", + }, + { + pluginId: "evil-provider", + providerId: "evil-provider", + methodId: "oidc", + choiceId: "evil-provider-oidc", + choiceLabel: "Evil Provider OIDC", + groupId: "evil-provider", + groupLabel: "Evil Provider", + }, + ]); + }); + + it("uses setup provider auth methods when no setup entry exists", () => { + setManifestPlugins([ + { + id: "no-runtime-provider", + origin: "global", + setup: { + providers: [ + { + id: "no-runtime-provider", + authMethods: ["api-key"], + }, + ], + }, + }, + ]); + + expect(resolveManifestProviderAuthChoice("no-runtime-provider-api-key")).toEqual({ + pluginId: "no-runtime-provider", + providerId: "no-runtime-provider", + methodId: "api-key", + choiceId: "no-runtime-provider-api-key", + choiceLabel: "No Runtime Provider API key", + groupId: "no-runtime-provider", + groupLabel: "No Runtime Provider", + }); + }); + + it("keeps setup-entry providers on explicit manifest or runtime auth choices", () => { + setManifestPlugins([ + { + id: "runtime-provider", + origin: "global", + setupSource: "/plugins/runtime-provider/setup-entry.cjs", + setup: { + providers: [ + { + id: "runtime-provider", + authMethods: ["api-key"], + }, + ], + }, + }, + ]); + + expect(resolveManifestProviderAuthChoices()).toEqual([]); + }); + + it("does not duplicate explicit provider auth choices with setup auth methods", () => { + setManifestPlugins([ + { + id: "explicit-provider", + origin: "global", + providerAuthChoices: [ + { + provider: "explicit-provider", + method: "api-key", + choiceId: "explicit-api-key", + choiceLabel: "Explicit API key", + }, + ], + setup: { + providers: [ + { + id: "explicit-provider", + authMethods: ["api-key", "oauth"], + }, + ], + requiresRuntime: false, + }, + }, + ]); + + expect(resolveManifestProviderAuthChoices()).toEqual([ + { + pluginId: "explicit-provider", + providerId: "explicit-provider", + methodId: "api-key", + choiceId: "explicit-api-key", + choiceLabel: "Explicit API key", + }, + { + pluginId: "explicit-provider", + providerId: "explicit-provider", + methodId: "oauth", + choiceId: "explicit-provider-oauth", + choiceLabel: "Explicit Provider OAuth", + groupId: "explicit-provider", + groupLabel: "Explicit Provider", + }, + ]); + }); + it("prefers bundled auth-choice handlers when choice IDs collide across origins", () => { setManifestPlugins([ { diff --git a/src/plugins/provider-auth-choices.ts b/src/plugins/provider-auth-choices.ts index ded665be636..c2ed3e7827a 100644 --- a/src/plugins/provider-auth-choices.ts +++ b/src/plugins/provider-auth-choices.ts @@ -1,5 +1,6 @@ import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; @@ -53,6 +54,15 @@ const PROVIDER_AUTH_CHOICE_ORIGIN_PRIORITY: Readonly = new Map([ + ["api", "API"], + ["jwt", "JWT"], + ["oauth", "OAuth"], + ["oidc", "OIDC"], + ["pkce", "PKCE"], + ["saml", "SAML"], + ["sso", "SSO"], +] as const); function resolveProviderAuthChoiceOriginPriority(origin: PluginOrigin | undefined): number { if (!origin) { @@ -91,6 +101,73 @@ function toProviderAuthChoiceCandidate(params: { }; } +function formatDescriptorLabel(value: string): string { + return sanitizeForLog(value) + .trim() + .split(/[-_\s]+/gu) + .filter(Boolean) + .map((part) => { + const lower = part.toLowerCase(); + const acronym = DESCRIPTOR_LABEL_ACRONYMS.get(lower); + if (acronym) { + return acronym; + } + return `${lower.slice(0, 1).toUpperCase()}${lower.slice(1)}`; + }) + .join(" "); +} + +function normalizeManifestAuthDescriptorId(value: string): string { + return sanitizeForLog(value).trim(); +} + +function toSetupProviderAuthChoiceCandidate(params: { + plugin: PluginManifestRecord; + providerId: string; + methodId: string; +}): ProviderAuthChoiceCandidate { + const providerLabel = formatDescriptorLabel(params.providerId); + const methodLabel = formatDescriptorLabel(params.methodId); + const choiceLabel = + params.methodId === "api-key" ? `${providerLabel} API key` : `${providerLabel} ${methodLabel}`; + return { + pluginId: params.plugin.id, + origin: params.plugin.origin, + providerId: params.providerId, + methodId: params.methodId, + choiceId: `${params.providerId}-${params.methodId}`, + choiceLabel, + groupId: params.providerId, + groupLabel: providerLabel, + }; +} + +function listSetupProviderAuthChoiceCandidates(plugin: PluginManifestRecord) { + if (plugin.setup?.requiresRuntime !== false && plugin.setupSource) { + return []; + } + const explicitProviderMethods = new Set( + (plugin.providerAuthChoices ?? []).map((choice) => `${choice.provider}::${choice.method}`), + ); + return (plugin.setup?.providers ?? []).flatMap((provider) => { + const providerId = normalizeManifestAuthDescriptorId(provider.id); + if (!providerId) { + return []; + } + return (provider.authMethods ?? []) + .map(normalizeManifestAuthDescriptorId) + .filter(Boolean) + .filter((methodId) => !explicitProviderMethods.has(`${providerId}::${methodId}`)) + .map((methodId) => + toSetupProviderAuthChoiceCandidate({ + plugin, + providerId, + methodId, + }), + ); + }); +} + function stripChoiceOrigin(choice: ProviderAuthChoiceCandidate): ProviderAuthChoiceMetadata { const { origin: _origin, ...metadata } = choice; return metadata; @@ -121,13 +198,18 @@ function resolveManifestProviderAuthChoiceCandidates(params?: { ) { return []; } - return (plugin.providerAuthChoices ?? []).map((choice) => - toProviderAuthChoiceCandidate({ - pluginId: plugin.id, - origin: plugin.origin, - choice, - }), - ); + const choices: ProviderAuthChoiceCandidate[] = []; + for (const choice of plugin.providerAuthChoices ?? []) { + choices.push( + toProviderAuthChoiceCandidate({ + pluginId: plugin.id, + origin: plugin.origin, + choice, + }), + ); + } + choices.push(...listSetupProviderAuthChoiceCandidates(plugin)); + return choices; }); }