From 968a982b8bf7b68269837c162791aa47e145f9b7 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 27 Dec 2025 20:46:45 +0100 Subject: [PATCH] fix: merge auth methods from multiple plugins for the same provider - Replace fromEntries() with merge loop in provider/auth.ts - Deduplicate aliased plugin exports in plugin/index.ts - Aggregate methods from all plugins in CLI auth.ts - Add source labels for non-native plugins (except generic API key methods) --- packages/opencode/src/cli/cmd/auth.ts | 145 ++++++++++++++++++-- packages/opencode/src/plugin/index.ts | 21 ++- packages/opencode/src/provider/auth.ts | 176 +++++++++++++++++++++++-- 3 files changed, 319 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 658329fb6ef7..ef56e42e1a47 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -13,19 +13,142 @@ import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" type PluginAuth = NonNullable +type PluginMethod = PluginAuth["methods"][number] + +type MergedMethod = PluginMethod & { + /** Display label (may include plugin source suffix) */ + displayLabel: string +} + +interface MergedPluginAuth { + provider: string + methods: MergedMethod[] + loader?: PluginAuth["loader"] +} + +/** + * Check if a plugin is a native OpenCode plugin (doesn't need source label) + */ +function isNativePlugin(pkg: string | undefined): boolean { + if (!pkg) return false + return Plugin.DEFAULT_PLUGINS.some((native) => pkg === native || pkg.startsWith(`${native}@`)) +} + +/** + * Extract a short display name from a package name + */ +function getPluginDisplayName(pkg: string | undefined): string | undefined { + if (!pkg) return undefined + const name = pkg.startsWith("@") ? pkg.split("/")[1] ?? pkg : pkg + return name + .replace(/^opencode-/, "") + .replace(/-auth$/, "") + .replace(/-plugin$/, "") +} + +/** + * Check if a method label is a generic API key entry (shouldn't be labeled with plugin source) + * These are functionally identical to the native fallback mechanism + */ +function isGenericApiKeyLabel(label: string): boolean { + const normalized = label.toLowerCase().trim() + return ( + normalized === "manually enter api key" || + normalized === "api key" || + normalized === "enter api key" + ) +} + +/** + * Build label for an auth method, adding plugin source for non-native plugins + */ +function buildMethodLabel(baseLabel: string, pluginSource: string | undefined, methodType: "oauth" | "api"): string { + // Native OpenCode plugins don't need source labels + if (isNativePlugin(pluginSource)) { + return baseLabel + } + // Generic API key methods don't need source labels (same as native fallback) + if (methodType === "api" && isGenericApiKeyLabel(baseLabel)) { + return baseLabel + } + // Non-native plugins show their source for distinctive methods + const displayName = getPluginDisplayName(pluginSource) + return displayName ? `${baseLabel} (${displayName})` : baseLabel +} + +/** + * Get merged auth methods for a provider from all plugins + */ +async function getMergedAuthForProvider(provider: string): Promise { + const plugins = await Plugin.list() + const matchingPlugins = plugins.filter((x) => x.auth?.provider === provider) + + if (matchingPlugins.length === 0) return undefined + + const merged: MergedPluginAuth = { + provider, + methods: [], + loader: undefined, + } + + const seenAuthorizeFns = new Set() + const seenLabels = new Set() + + for (const plugin of matchingPlugins) { + if (!plugin.auth) continue + + // Keep the first loader + if (!merged.loader && plugin.auth.loader) { + merged.loader = plugin.auth.loader + } + + for (const method of plugin.auth.methods) { + // Deduplicate by authorize function reference (for OAuth) or type+label (for API) + if (method.type === "oauth") { + if (seenAuthorizeFns.has(method.authorize)) continue + seenAuthorizeFns.add(method.authorize) + } else { + const key = `${method.type}:${method.label}` + if (seenLabels.has(key)) continue + seenLabels.add(key) + } + + // Build display label with plugin source for non-native plugins + let displayLabel = buildMethodLabel(method.label, plugin._source, method.type) + + // Handle display label collisions + const existingDisplayLabels = new Set(merged.methods.map((m) => m.displayLabel)) + if (existingDisplayLabels.has(displayLabel)) { + let counter = 2 + const baseLabel = displayLabel + while (existingDisplayLabels.has(displayLabel)) { + displayLabel = `${baseLabel} ${counter}` + counter++ + } + } + + merged.methods.push({ + ...method, + displayLabel, + }) + } + } + + return merged.methods.length > 0 ? merged : undefined +} /** * Handle plugin-based authentication flow. * Returns true if auth was handled, false if it should fall through to default handling. */ -async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise { +async function handlePluginAuth(merged: MergedPluginAuth, provider: string): Promise { let index = 0 - if (plugin.auth.methods.length > 1) { + if (merged.methods.length > 1) { const method = await prompts.select({ message: "Login method", options: [ - ...plugin.auth.methods.map((x, index) => ({ - label: x.label, + ...merged.methods.map((x, index) => ({ + label: x.displayLabel, value: index.toString(), })), ], @@ -33,7 +156,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): if (prompts.isCancel(method)) throw new UI.CancelledError() index = parseInt(method) } - const method = plugin.auth.methods[index] + const method = merged.methods[index] // Handle prompts for all auth types await new Promise((resolve) => setTimeout(resolve, 10)) @@ -306,9 +429,9 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() - const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) - if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider) + const mergedAuth = await getMergedAuthForProvider(provider) + if (mergedAuth) { + const handled = await handlePluginAuth(mergedAuth, provider) if (handled) return } @@ -322,9 +445,9 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() // Check if a plugin provides auth for this custom provider - const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) - if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider) + const customMergedAuth = await getMergedAuthForProvider(provider) + if (customMergedAuth) { + const handled = await handlePluginAuth(customMergedAuth, provider) if (handled) return } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b492c7179e60..21076ea1c848 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -11,6 +11,14 @@ import { Flag } from "../flag/flag" export namespace Plugin { const log = Log.create({ service: "plugin" }) + /** Default plugins bundled with OpenCode - these don't need source labels in auth UI */ + export const DEFAULT_PLUGINS = ["opencode-copilot-auth", "opencode-anthropic-auth"] as const + + export interface LoadedHooks extends Hooks { + /** The package name this plugin was loaded from */ + _source?: string + } + const state = Instance.state(async () => { const client = createOpencodeClient({ baseUrl: "http://localhost:4096", @@ -18,7 +26,7 @@ export namespace Plugin { fetch: async (...args) => Server.App().fetch(...args), }) const config = await Config.get() - const hooks = [] + const hooks: LoadedHooks[] = [] const input: PluginInput = { client, project: Instance.project, @@ -33,15 +41,22 @@ export namespace Plugin { } for (let plugin of plugins) { log.info("loading plugin", { path: plugin }) + let pkg: string | undefined if (!plugin.startsWith("file://")) { const lastAtIndex = plugin.lastIndexOf("@") - const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin + pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" plugin = await BunProc.install(pkg, version) } const mod = await import(plugin) + // Track which plugin functions we've already called to avoid duplicates + // (some plugins export aliases like `GoogleOAuthPlugin = AntigravityCLIOAuthPlugin`) + const calledFns = new Set() for (const [_name, fn] of Object.entries(mod)) { - const init = await fn(input) + if (calledFns.has(fn)) continue + calledFns.add(fn) + const init: LoadedHooks = await fn(input) + init._source = pkg hooks.push(init) } } diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index d06253ab4ade..44490b171487 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,20 +1,174 @@ import { Instance } from "@/project/instance" import { Plugin } from "../plugin" -import { map, filter, pipe, fromEntries, mapValues } from "remeda" +import { mapValues } from "remeda" import z from "zod" import { fn } from "@/util/fn" -import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin" +import type { AuthOuathResult, AuthHook, Hooks } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" import { Auth } from "@/auth" export namespace ProviderAuth { - const state = Instance.state(async () => { - const methods = pipe( - await Plugin.list(), - filter((x) => x.auth?.provider !== undefined), - map((x) => [x.auth!.provider, x.auth!] as const), - fromEntries(), + // Extended method type that tracks the authorize function for OAuth routing + interface TrackedMethod { + type: "oauth" | "api" + label: string + prompts?: AuthHook["methods"][number]["prompts"] + authorize?: (inputs?: Record) => Promise + apiAuthorize?: (inputs?: Record) => Promise< + | { type: "success"; key: string; provider?: string } + | { type: "failed" } + > + } + + interface MergedAuth { + provider: string + methods: TrackedMethod[] + loader?: AuthHook["loader"] + } + + /** + * Check if a plugin is a native OpenCode plugin (doesn't need source label) + */ + function isNativePlugin(pkg: string | undefined): boolean { + if (!pkg) return false + return Plugin.DEFAULT_PLUGINS.some((native) => pkg === native || pkg.startsWith(`${native}@`)) + } + + /** + * Extract a short display name from a package name + * e.g. "opencode-antigravity-auth" -> "antigravity" + * "@tarquinen/opencode-dcp" -> "dcp" + * "opencode-websearch-cited" -> "websearch-cited" + */ + function getPluginDisplayName(pkg: string | undefined): string | undefined { + if (!pkg) return undefined + // Remove scope prefix (@foo/) + const name = pkg.startsWith("@") ? pkg.split("/")[1] ?? pkg : pkg + // Remove common prefixes/suffixes + return name + .replace(/^opencode-/, "") + .replace(/-auth$/, "") + .replace(/-plugin$/, "") + } + + /** + * Check if a method label is a generic API key entry (shouldn't be labeled with plugin source) + * These are functionally identical to the native fallback mechanism + */ + function isGenericApiKeyLabel(label: string): boolean { + const normalized = label.toLowerCase().trim() + return ( + normalized === "manually enter api key" || + normalized === "api key" || + normalized === "enter api key" ) + } + + /** + * Build label for an auth method, adding plugin source for non-native plugins + */ + function buildMethodLabel(baseLabel: string, pluginSource: string | undefined, methodType: "oauth" | "api"): string { + // Native OpenCode plugins don't need source labels + if (isNativePlugin(pluginSource)) { + return baseLabel + } + // Generic API key methods don't need source labels (same as native fallback) + if (methodType === "api" && isGenericApiKeyLabel(baseLabel)) { + return baseLabel + } + // Non-native plugins show their source for distinctive methods + const displayName = getPluginDisplayName(pluginSource) + return displayName ? `${baseLabel} (${displayName})` : baseLabel + } + + const state = Instance.state(async () => { + const plugins = await Plugin.list() + const methods: Record = {} + + for (const plugin of plugins) { + if (!plugin.auth?.provider) continue + + const providerId = plugin.auth.provider + const pluginSource = plugin._source + const existing = methods[providerId] + + if (existing) { + // Merge methods from additional plugins, deduplicating by function reference or type+label + for (const method of plugin.auth.methods) { + // Check for duplicates: + // - For OAuth methods: same authorize function reference means duplicate + // - For API methods: same type + label means duplicate + const isDuplicate = existing.methods.some((m) => { + if (method.type === "oauth" && m.type === "oauth") { + // Same authorize function = same method (handles aliased exports) + return m.authorize === method.authorize + } + // For API methods or mixed comparison, check type + label + return m.type === method.type && m.label === method.label + }) + + if (isDuplicate) continue + + // Build label with plugin source for non-native plugins + let label = buildMethodLabel(method.label, pluginSource, method.type) + + // Handle label collisions + const existingLabels = new Set(existing.methods.map((m) => m.label)) + if (existingLabels.has(label)) { + let counter = 2 + const baseLabel = label + while (existingLabels.has(label)) { + label = `${baseLabel} ${counter}` + counter++ + } + } + + existing.methods.push({ + type: method.type, + label, + prompts: method.prompts, + authorize: method.type === "oauth" ? method.authorize : undefined, + apiAuthorize: method.type === "api" && "authorize" in method ? method.authorize : undefined, + }) + } + + // Keep the first loader (don't overwrite) + if (!existing.loader && plugin.auth.loader) { + existing.loader = plugin.auth.loader + } + } else { + // First plugin for this provider + const seenMethods: TrackedMethod[] = [] + const seenAuthorizeFns = new Set() + const seenApiLabels = new Set() + + for (const m of plugin.auth.methods) { + if (m.type === "oauth") { + if (seenAuthorizeFns.has(m.authorize)) continue + seenAuthorizeFns.add(m.authorize) + } else { + const key = `${m.type}:${m.label}` + if (seenApiLabels.has(key)) continue + seenApiLabels.add(key) + } + + seenMethods.push({ + type: m.type, + label: buildMethodLabel(m.label, pluginSource, m.type), + prompts: m.prompts, + authorize: m.type === "oauth" ? m.authorize : undefined, + apiAuthorize: m.type === "api" && "authorize" in m ? m.authorize : undefined, + }) + } + + methods[providerId] = { + provider: providerId, + methods: seenMethods, + loader: plugin.auth.loader, + } + } + } + return { methods, pending: {} as Record } }) @@ -58,8 +212,12 @@ export namespace ProviderAuth { }), async (input): Promise => { const auth = await state().then((s) => s.methods[input.providerID]) + if (!auth) return undefined + const method = auth.methods[input.method] - if (method.type === "oauth") { + if (!method) return undefined + + if (method.type === "oauth" && method.authorize) { const result = await method.authorize() await state().then((s) => (s.pending[input.providerID] = result)) return {