From 2d97eae53e212ae26f3aebcd6a50ffc6877f770d Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Wed, 8 Apr 2026 23:08:14 +0530 Subject: [PATCH] fix(plugins): prevent untrusted workspace plugins from hijacking bundled provider auth choices [AI] (#62368) * fix: address issue * fix: address review feedback * docs(changelog): add onboarding auth-choice guard entry * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback * fix: address PR review feedback --------- Co-authored-by: Devin Robison --- CHANGELOG.md | 1 + .../auth-choice.preferred-provider.test.ts | 27 +++ ...ve.workspace-provider-choice-guard.test.ts | 3 - .../auth-choice.plugin-providers.test.ts | 99 ++++++++- .../local/auth-choice.plugin-providers.ts | 75 +++---- .../provider-auth-choice-preference.ts | 1 + src/plugins/provider-auth-choices.test.ts | 110 +++++++++ src/plugins/provider-auth-choices.ts | 210 ++++++++++++++---- src/plugins/providers.runtime.ts | 2 + src/plugins/providers.test.ts | 74 ++++++ src/plugins/providers.ts | 36 ++- 11 files changed, 531 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d28db609e..72ecb3eb391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Gateway tool/exec config: block model-facing `gateway config.apply` and `config.patch` writes from changing exec approval paths such as `safeBins`, `safeBinProfiles`, `safeBinTrustedDirs`, and `strictInlineEval`, while still allowing unchanged structured values through. (#62001) Thanks @eleqtrizit. - Host exec/env sanitization: block dangerous Java, Rust, Cargo, Git, Kubernetes, cloud credential, config-path, and Helm env overrides so host-run tools cannot be redirected to attacker-chosen code, config, credentials, or repository state. (#59119, #62002, #62291) Thanks @eleqtrizit and contributors. - Commands/allowlist: require owner authorization for `/allowlist add` and `/allowlist remove` before channel resolution, so non-owner but command-authorized senders can no longer persistently rewrite allowlist policy state. (#62383) Thanks @pgondhi987. +- Plugins/onboarding auth choices: prevent untrusted workspace plugins from colliding with bundled provider auth-choice ids during non-interactive onboarding, so bundled provider setup keeps operator secrets out of untrusted workspace plugin handlers unless those plugins are explicitly trusted. (#62368) Thanks @pgondhi987. - Feishu/docx uploads: honor `tools.fs.workspaceOnly` for local `upload_file` and `upload_image` paths by forwarding workspace-constrained `localRoots` into the media loader, so docx uploads can no longer read host-local files outside the workspace when workspace-only mode is active. (#62369) Thanks @pgondhi987. - Network/fetch guard: drop request bodies and body-describing headers on cross-origin `307` and `308` redirects by default, so attacker-controlled redirect hops cannot receive secret-bearing POST payloads from SSRF-guarded fetch flows unless a caller explicitly opts in. (#62357) Thanks @pgondhi987. - Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987. diff --git a/src/commands/auth-choice.preferred-provider.test.ts b/src/commands/auth-choice.preferred-provider.test.ts index 6fc1ac347f1..3de18971427 100644 --- a/src/commands/auth-choice.preferred-provider.test.ts +++ b/src/commands/auth-choice.preferred-provider.test.ts @@ -101,4 +101,31 @@ describe("resolvePreferredProviderForAuthChoice", () => { ); expect(resolvePluginProviders).not.toHaveBeenCalled(); }); + + it("passes untrusted-workspace filtering through setup-provider fallback lookup", async () => { + resolvePluginProviders.mockReturnValue([ + { + id: "demo-provider", + label: "Demo Provider", + auth: [{ id: "api-key", label: "API key", kind: "api_key" }], + }, + ] as never); + resolveProviderPluginChoice.mockReturnValue({ + provider: { id: "demo-provider" }, + method: { id: "api-key" }, + }); + + await expect( + resolvePreferredProviderForAuthChoice({ + choice: "demo-provider", + includeUntrustedWorkspacePlugins: false, + }), + ).resolves.toBe("demo-provider"); + expect(resolvePluginProviders).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "setup", + includeUntrustedWorkspacePlugins: false, + }), + ); + }); }); diff --git a/src/commands/onboard-non-interactive.workspace-provider-choice-guard.test.ts b/src/commands/onboard-non-interactive.workspace-provider-choice-guard.test.ts index 07b10af000b..1effbacdbb7 100644 --- a/src/commands/onboard-non-interactive.workspace-provider-choice-guard.test.ts +++ b/src/commands/onboard-non-interactive.workspace-provider-choice-guard.test.ts @@ -115,9 +115,6 @@ async function writeWorkspaceChoiceHijackPlugin(workspaceDir: string): Promise", }, ], configSchema: { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index 7d2e35582ab..86c06cec668 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -6,6 +6,10 @@ const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => vi.mock("../../../plugins/provider-auth-choice-preference.js", () => ({ resolvePreferredProviderForAuthChoice, })); +const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined)); +vi.mock("../../../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoice, +})); const resolveOwningPluginIdsForProvider = vi.hoisted(() => vi.fn(() => undefined)); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); @@ -20,6 +24,11 @@ vi.mock("./auth-choice.plugin-providers.runtime.js", () => ({ beforeEach(() => { vi.clearAllMocks(); + resolvePreferredProviderForAuthChoice.mockResolvedValue(undefined); + resolveManifestProviderAuthChoice.mockReturnValue(undefined); + resolveOwningPluginIdsForProvider.mockReturnValue(undefined as never); + resolveProviderPluginChoice.mockReturnValue(undefined); + resolvePluginProviders.mockReturnValue([] as never); }); function createRuntime() { @@ -61,6 +70,7 @@ describe("applyNonInteractivePluginProviderChoice", () => { expect(resolvePluginProviders).toHaveBeenCalledWith( expect.objectContaining({ onlyPluginIds: ["vllm"], + includeUntrustedWorkspacePlugins: false, }), ); expect(resolveProviderPluginChoice).toHaveBeenCalledOnce(); @@ -68,7 +78,78 @@ describe("applyNonInteractivePluginProviderChoice", () => { expect(result).toEqual({ plugins: { allow: ["vllm"] } }); }); - it("enables owning plugin ids when they differ from the provider id", async () => { + it("fails explicitly when a provider-plugin auth choice resolves to no trusted setup provider", async () => { + const runtime = createRuntime(); + + const result = await applyNonInteractivePluginProviderChoice({ + nextConfig: { agents: { defaults: {} } } as OpenClawConfig, + authChoice: "provider-plugin:workspace-provider:api-key", + opts: {} as never, + runtime: runtime as never, + baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + resolveApiKey: vi.fn(), + toApiKeyCredential: vi.fn(), + }); + + expect(result).toBeNull(); + expect(resolvePreferredProviderForAuthChoice).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining( + 'Auth choice "provider-plugin:workspace-provider:api-key" was not matched to a trusted provider plugin.', + ), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("fails explicitly when a non-prefixed auth choice resolves only with untrusted providers", async () => { + const runtime = createRuntime(); + resolvePreferredProviderForAuthChoice.mockResolvedValue(undefined); + resolveManifestProviderAuthChoice.mockReturnValueOnce(undefined).mockReturnValueOnce({ + pluginId: "workspace-provider", + providerId: "workspace-provider", + } as never); + + const result = await applyNonInteractivePluginProviderChoice({ + nextConfig: { agents: { defaults: {} } } as OpenClawConfig, + authChoice: "workspace-provider-api-key", + opts: {} as never, + runtime: runtime as never, + baseConfig: { agents: { defaults: {} } } as OpenClawConfig, + resolveApiKey: vi.fn(), + toApiKeyCredential: vi.fn(), + }); + + expect(result).toBeNull(); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining( + 'Auth choice "workspace-provider-api-key" matched a provider plugin that is not trusted or enabled for setup.', + ), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(resolvePluginProviders).toHaveBeenCalledWith( + expect.objectContaining({ + includeUntrustedWorkspacePlugins: false, + }), + ); + expect(resolveProviderPluginChoice).toHaveBeenCalledTimes(1); + expect(resolvePluginProviders).toHaveBeenCalledTimes(1); + expect(resolveManifestProviderAuthChoice).toHaveBeenCalledWith( + "workspace-provider-api-key", + expect.objectContaining({ + includeUntrustedWorkspacePlugins: false, + }), + ); + expect(resolveManifestProviderAuthChoice).toHaveBeenCalledWith( + "workspace-provider-api-key", + expect.objectContaining({ + config: expect.objectContaining({ agents: { defaults: {} } }), + workspaceDir: expect.any(String), + includeUntrustedWorkspacePlugins: true, + }), + ); + }); + + it("limits setup-provider resolution to owning plugin ids without pre-enabling them", async () => { const runtime = createRuntime(); const runNonInteractive = vi.fn(async () => ({ plugins: { allow: ["demo-plugin"] } })); resolveOwningPluginIdsForProvider.mockReturnValue(["demo-plugin"] as never); @@ -92,16 +173,9 @@ describe("applyNonInteractivePluginProviderChoice", () => { expect(resolvePluginProviders).toHaveBeenCalledWith( expect.objectContaining({ - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: expect.arrayContaining(["demo-provider", "demo-plugin"]), - entries: expect.objectContaining({ - "demo-provider": expect.objectContaining({ enabled: true }), - "demo-plugin": expect.objectContaining({ enabled: true }), - }), - }), - }), + config: expect.objectContaining({ agents: { defaults: {} } }), onlyPluginIds: ["demo-plugin"], + includeUntrustedWorkspacePlugins: false, }), ); expect(runNonInteractive).toHaveBeenCalledOnce(); @@ -128,5 +202,10 @@ describe("applyNonInteractivePluginProviderChoice", () => { includeUntrustedWorkspacePlugins: false, }), ); + expect(resolvePluginProviders).toHaveBeenCalledWith( + expect.objectContaining({ + includeUntrustedWorkspacePlugins: false, + }), + ); }); }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 3386d957067..cccef071704 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -8,6 +8,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { enablePluginInConfig } from "../../../plugins/enable.js"; import { resolvePreferredProviderForAuthChoice } from "../../../plugins/provider-auth-choice-preference.js"; +import { resolveManifestProviderAuthChoice } from "../../../plugins/provider-auth-choices.js"; import type { ProviderAuthOptionBag, ProviderNonInteractiveApiKeyCredentialParams, @@ -28,40 +29,6 @@ const loadAuthChoicePluginProvidersRuntime = createLazyRuntimeSurface( ({ authChoicePluginProvidersRuntime }) => authChoicePluginProvidersRuntime, ); -function buildIsolatedProviderResolutionConfig( - cfg: OpenClawConfig, - ids: Iterable, -): OpenClawConfig { - const allow = new Set(cfg.plugins?.allow ?? []); - const entries = { - ...cfg.plugins?.entries, - }; - let changed = false; - for (const rawId of ids) { - const id = rawId?.trim(); - if (!id) { - continue; - } - allow.add(id); - entries[id] = { - ...cfg.plugins?.entries?.[id], - enabled: true, - }; - changed = true; - } - if (!changed) { - return cfg; - } - return { - ...cfg, - plugins: { - ...cfg.plugins, - allow: Array.from(allow), - entries, - }, - }; -} - export async function applyNonInteractivePluginProviderChoice(params: { nextConfig: OpenClawConfig; authChoice: string; @@ -101,20 +68,50 @@ export async function applyNonInteractivePluginProviderChoice(params: { workspaceDir, }) : undefined; - const resolutionConfig = buildIsolatedProviderResolutionConfig(params.nextConfig, [ - preferredProviderId, - ...(owningPluginIds ?? []), - ]); const providerChoice = resolveProviderPluginChoice({ providers: resolvePluginProviders({ - config: resolutionConfig, + config: params.nextConfig, workspaceDir, onlyPluginIds: owningPluginIds, mode: "setup", + includeUntrustedWorkspacePlugins: false, }), choice: params.authChoice, }); if (!providerChoice) { + if (prefixedProviderId) { + params.runtime.error( + [ + `Auth choice "${params.authChoice}" was not matched to a trusted provider plugin.`, + "If this provider comes from a workspace plugin, trust/allow it first and retry.", + ].join("\n"), + ); + params.runtime.exit(1); + return null; + } + // Keep mismatch diagnostics metadata-only so untrusted workspace plugins are not loaded. + const trustedManifestMatch = resolveManifestProviderAuthChoice(params.authChoice, { + config: params.nextConfig, + workspaceDir, + includeUntrustedWorkspacePlugins: false, + }); + const untrustedOnlyManifestMatch = + !trustedManifestMatch && + resolveManifestProviderAuthChoice(params.authChoice, { + config: params.nextConfig, + workspaceDir, + includeUntrustedWorkspacePlugins: true, + }); + if (untrustedOnlyManifestMatch) { + params.runtime.error( + [ + `Auth choice "${params.authChoice}" matched a provider plugin that is not trusted or enabled for setup.`, + "If this provider comes from a workspace plugin, trust/allow it first and retry.", + ].join("\n"), + ); + params.runtime.exit(1); + return null; + } return undefined; } diff --git a/src/plugins/provider-auth-choice-preference.ts b/src/plugins/provider-auth-choice-preference.ts index 8bfe7377a81..80435f449e5 100644 --- a/src/plugins/provider-auth-choice-preference.ts +++ b/src/plugins/provider-auth-choice-preference.ts @@ -26,6 +26,7 @@ export async function resolvePreferredProviderForAuthChoice(params: { workspaceDir: params.workspaceDir, env: params.env, mode: "setup", + includeUntrustedWorkspacePlugins: params.includeUntrustedWorkspacePlugins, }); const pluginResolved = resolveProviderPluginChoice({ providers, diff --git a/src/plugins/provider-auth-choices.test.ts b/src/plugins/provider-auth-choices.test.ts index a25f0028562..cee7c5c12ee 100644 --- a/src/plugins/provider-auth-choices.test.ts +++ b/src/plugins/provider-auth-choices.test.ts @@ -228,4 +228,114 @@ describe("provider auth choice manifest helpers", () => { }, ]); }); + + it("prefers bundled auth-choice handlers when choice IDs collide across origins", () => { + setManifestPlugins([ + { + id: "evil-openai-hijack", + origin: "workspace", + providers: ["evil-openai"], + providerAuthChoices: [ + { + provider: "evil-openai", + method: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + optionKey: "openaiApiKey", + cliFlag: "--openai-api-key", + cliOption: "--openai-api-key ", + }, + ], + }, + { + id: "openai", + origin: "bundled", + providers: ["openai"], + providerAuthChoices: [ + { + provider: "openai", + method: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + optionKey: "openaiApiKey", + cliFlag: "--openai-api-key", + cliOption: "--openai-api-key ", + }, + ], + }, + ]); + + expect(resolveManifestProviderAuthChoices()).toEqual([ + expect.objectContaining({ + pluginId: "openai", + providerId: "openai", + choiceId: "openai-api-key", + }), + ]); + expect(resolveManifestProviderAuthChoice("openai-api-key")?.providerId).toBe("openai"); + expect(resolveManifestProviderOnboardAuthFlags()).toEqual([ + { + optionKey: "openaiApiKey", + authChoice: "openai-api-key", + cliFlag: "--openai-api-key", + cliOption: "--openai-api-key ", + description: "OpenAI API key", + }, + ]); + }); + + it("prefers trusted config auth-choice handlers over bundled collisions", () => { + setManifestPlugins([ + { + id: "openai", + origin: "bundled", + providers: ["openai"], + providerAuthChoices: [ + { + provider: "openai", + method: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + optionKey: "openaiApiKey", + cliFlag: "--openai-api-key", + cliOption: "--openai-api-key ", + }, + ], + }, + { + id: "custom-openai", + origin: "config", + providers: ["custom-openai"], + providerAuthChoices: [ + { + provider: "custom-openai", + method: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + optionKey: "openaiApiKey", + cliFlag: "--openai-api-key", + cliOption: "--openai-api-key ", + }, + ], + }, + ]); + + expect(resolveManifestProviderAuthChoices()).toEqual([ + expect.objectContaining({ + pluginId: "custom-openai", + providerId: "custom-openai", + choiceId: "openai-api-key", + }), + ]); + expect(resolveManifestProviderAuthChoice("openai-api-key")?.providerId).toBe("custom-openai"); + expect(resolveManifestProviderOnboardAuthFlags()).toEqual([ + { + optionKey: "openaiApiKey", + authChoice: "openai-api-key", + cliFlag: "--openai-api-key", + cliOption: "--openai-api-key ", + description: "OpenAI API key", + }, + ]); + }); }); diff --git a/src/plugins/provider-auth-choices.ts b/src/plugins/provider-auth-choices.ts index 618deb561d4..5c316a55197 100644 --- a/src/plugins/provider-auth-choices.ts +++ b/src/plugins/provider-auth-choices.ts @@ -1,7 +1,8 @@ import { normalizeProviderIdForAuth } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; +import type { PluginOrigin } from "./types.js"; export type ProviderAuthChoiceMetadata = { pluginId: string; @@ -31,55 +32,148 @@ export type ProviderOnboardAuthFlag = { description: string; }; -export function resolveManifestProviderAuthChoices(params?: { +type ProviderAuthChoiceCandidate = ProviderAuthChoiceMetadata & { + origin: PluginOrigin; +}; +type ProviderOnboardAuthFlagCandidate = ProviderAuthChoiceCandidate & { + optionKey: string; + cliFlag: string; + cliOption: string; +}; + +const PROVIDER_AUTH_CHOICE_ORIGIN_PRIORITY: Readonly> = { + config: 0, + bundled: 1, + global: 2, + workspace: 3, +}; + +function resolveProviderAuthChoiceOriginPriority(origin: PluginOrigin | undefined): number { + if (!origin) { + return Number.MAX_SAFE_INTEGER; + } + return PROVIDER_AUTH_CHOICE_ORIGIN_PRIORITY[origin] ?? Number.MAX_SAFE_INTEGER; +} + +function toProviderAuthChoiceCandidate(params: { + pluginId: string; + origin: PluginOrigin; + choice: NonNullable[number]; +}): ProviderAuthChoiceCandidate { + const { pluginId, origin, choice } = params; + return { + pluginId, + origin, + providerId: choice.provider, + methodId: choice.method, + choiceId: choice.choiceId, + choiceLabel: choice.choiceLabel ?? choice.choiceId, + ...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}), + ...(choice.assistantPriority !== undefined + ? { assistantPriority: choice.assistantPriority } + : {}), + ...(choice.assistantVisibility ? { assistantVisibility: choice.assistantVisibility } : {}), + ...(choice.deprecatedChoiceIds ? { deprecatedChoiceIds: choice.deprecatedChoiceIds } : {}), + ...(choice.groupId ? { groupId: choice.groupId } : {}), + ...(choice.groupLabel ? { groupLabel: choice.groupLabel } : {}), + ...(choice.groupHint ? { groupHint: choice.groupHint } : {}), + ...(choice.optionKey ? { optionKey: choice.optionKey } : {}), + ...(choice.cliFlag ? { cliFlag: choice.cliFlag } : {}), + ...(choice.cliOption ? { cliOption: choice.cliOption } : {}), + ...(choice.cliDescription ? { cliDescription: choice.cliDescription } : {}), + ...(choice.onboardingScopes ? { onboardingScopes: choice.onboardingScopes } : {}), + }; +} + +function stripChoiceOrigin(choice: ProviderAuthChoiceCandidate): ProviderAuthChoiceMetadata { + const { origin: _origin, ...metadata } = choice; + return metadata; +} + +function resolveManifestProviderAuthChoiceCandidates(params?: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; includeUntrustedWorkspacePlugins?: boolean; -}): ProviderAuthChoiceMetadata[] { +}): ProviderAuthChoiceCandidate[] { const registry = loadPluginManifestRegistry({ config: params?.config, workspaceDir: params?.workspaceDir, env: params?.env, }); const normalizedConfig = normalizePluginsConfig(params?.config?.plugins); + return registry.plugins.flatMap((plugin) => { + if ( + plugin.origin === "workspace" && + params?.includeUntrustedWorkspacePlugins === false && + !resolveEffectiveEnableState({ + id: plugin.id, + origin: plugin.origin, + config: normalizedConfig, + rootConfig: params?.config, + }).enabled + ) { + return []; + } + return (plugin.providerAuthChoices ?? []).map((choice) => + toProviderAuthChoiceCandidate({ + pluginId: plugin.id, + origin: plugin.origin, + choice, + }), + ); + }); +} - return registry.plugins.flatMap((plugin) => - plugin.origin === "workspace" && - params?.includeUntrustedWorkspacePlugins === false && - !resolveEffectiveEnableState({ - id: plugin.id, - origin: plugin.origin, - config: normalizedConfig, - rootConfig: params?.config, - }).enabled - ? [] - : (plugin.providerAuthChoices ?? []).map((choice) => ({ - pluginId: plugin.id, - providerId: choice.provider, - methodId: choice.method, - choiceId: choice.choiceId, - choiceLabel: choice.choiceLabel ?? choice.choiceId, - ...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}), - ...(choice.assistantPriority !== undefined - ? { assistantPriority: choice.assistantPriority } - : {}), - ...(choice.assistantVisibility - ? { assistantVisibility: choice.assistantVisibility } - : {}), - ...(choice.deprecatedChoiceIds - ? { deprecatedChoiceIds: choice.deprecatedChoiceIds } - : {}), - ...(choice.groupId ? { groupId: choice.groupId } : {}), - ...(choice.groupLabel ? { groupLabel: choice.groupLabel } : {}), - ...(choice.groupHint ? { groupHint: choice.groupHint } : {}), - ...(choice.optionKey ? { optionKey: choice.optionKey } : {}), - ...(choice.cliFlag ? { cliFlag: choice.cliFlag } : {}), - ...(choice.cliOption ? { cliOption: choice.cliOption } : {}), - ...(choice.cliDescription ? { cliDescription: choice.cliDescription } : {}), - ...(choice.onboardingScopes ? { onboardingScopes: choice.onboardingScopes } : {}), - })), - ); +function pickPreferredManifestAuthChoice( + candidates: readonly ProviderAuthChoiceCandidate[], +): ProviderAuthChoiceCandidate | undefined { + let preferred: ProviderAuthChoiceCandidate | undefined; + for (const candidate of candidates) { + if (!preferred) { + preferred = candidate; + continue; + } + if ( + resolveProviderAuthChoiceOriginPriority(candidate.origin) < + resolveProviderAuthChoiceOriginPriority(preferred.origin) + ) { + preferred = candidate; + } + } + return preferred; +} + +function resolvePreferredManifestAuthChoicesByChoiceId( + candidates: readonly ProviderAuthChoiceCandidate[], +): ProviderAuthChoiceCandidate[] { + const preferredByChoiceId = new Map(); + for (const candidate of candidates) { + const normalizedChoiceId = candidate.choiceId.trim(); + if (!normalizedChoiceId) { + continue; + } + const existing = preferredByChoiceId.get(normalizedChoiceId); + if ( + !existing || + resolveProviderAuthChoiceOriginPriority(candidate.origin) < + resolveProviderAuthChoiceOriginPriority(existing.origin) + ) { + preferredByChoiceId.set(normalizedChoiceId, candidate); + } + } + return [...preferredByChoiceId.values()]; +} + +export function resolveManifestProviderAuthChoices(params?: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + includeUntrustedWorkspacePlugins?: boolean; +}): ProviderAuthChoiceMetadata[] { + return resolvePreferredManifestAuthChoicesByChoiceId( + resolveManifestProviderAuthChoiceCandidates(params), + ).map(stripChoiceOrigin); } export function resolveManifestProviderAuthChoice( @@ -95,9 +189,11 @@ export function resolveManifestProviderAuthChoice( if (!normalized) { return undefined; } - return resolveManifestProviderAuthChoices(params).find( + const candidates = resolveManifestProviderAuthChoiceCandidates(params).filter( (choice) => choice.choiceId === normalized, ); + const preferred = pickPreferredManifestAuthChoice(candidates); + return preferred ? stripChoiceOrigin(preferred) : undefined; } export function resolveManifestProviderApiKeyChoice(params: { @@ -111,13 +207,14 @@ export function resolveManifestProviderApiKeyChoice(params: { if (!normalizedProviderId) { return undefined; } - - return resolveManifestProviderAuthChoices(params).find((choice) => { + const candidates = resolveManifestProviderAuthChoiceCandidates(params).filter((choice) => { if (!choice.optionKey) { return false; } return normalizeProviderIdForAuth(choice.providerId) === normalizedProviderId; }); + const preferred = pickPreferredManifestAuthChoice(candidates); + return preferred ? stripChoiceOrigin(preferred) : undefined; } export function resolveManifestDeprecatedProviderAuthChoice( @@ -133,9 +230,11 @@ export function resolveManifestDeprecatedProviderAuthChoice( if (!normalized) { return undefined; } - return resolveManifestProviderAuthChoices(params).find((choice) => + const candidates = resolveManifestProviderAuthChoiceCandidates(params).filter((choice) => choice.deprecatedChoiceIds?.includes(normalized), ); + const preferred = pickPreferredManifestAuthChoice(candidates); + return preferred ? stripChoiceOrigin(preferred) : undefined; } export function resolveManifestProviderOnboardAuthFlags(params?: { @@ -144,18 +243,32 @@ export function resolveManifestProviderOnboardAuthFlags(params?: { env?: NodeJS.ProcessEnv; includeUntrustedWorkspacePlugins?: boolean; }): ProviderOnboardAuthFlag[] { - const flags: ProviderOnboardAuthFlag[] = []; - const seen = new Set(); + const preferredByFlag = new Map(); - for (const choice of resolveManifestProviderAuthChoices(params)) { + for (const choice of resolveManifestProviderAuthChoiceCandidates(params)) { if (!choice.optionKey || !choice.cliFlag || !choice.cliOption) { continue; } + const normalizedChoice: ProviderOnboardAuthFlagCandidate = { + ...choice, + optionKey: choice.optionKey, + cliFlag: choice.cliFlag, + cliOption: choice.cliOption, + }; const dedupeKey = `${choice.optionKey}::${choice.cliFlag}`; - if (seen.has(dedupeKey)) { + const existing = preferredByFlag.get(dedupeKey); + if ( + existing && + resolveProviderAuthChoiceOriginPriority(normalizedChoice.origin) >= + resolveProviderAuthChoiceOriginPriority(existing.origin) + ) { continue; } - seen.add(dedupeKey); + preferredByFlag.set(dedupeKey, normalizedChoice); + } + + const flags: ProviderOnboardAuthFlag[] = []; + for (const choice of preferredByFlag.values()) { flags.push({ optionKey: choice.optionKey, authChoice: choice.choiceId, @@ -164,6 +277,5 @@ export function resolveManifestProviderOnboardAuthFlags(params?: { description: choice.cliDescription ?? choice.choiceLabel, }); } - return flags; } diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index da7ae51eeda..b96da79b9f1 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -89,6 +89,7 @@ function resolveSetupProviderPluginLoadState( workspaceDir: base.workspaceDir, env: base.env, onlyPluginIds: base.requestedPluginIds, + includeUntrustedWorkspacePlugins: params.includeUntrustedWorkspacePlugins, }); if (providerPluginIds.length === 0) { return undefined; @@ -192,6 +193,7 @@ export function resolvePluginProviders(params: { cache?: boolean; pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"]; mode?: "runtime" | "setup"; + includeUntrustedWorkspacePlugins?: boolean; }): ProviderPlugin[] { const base = resolvePluginProviderLoadBase(params); if (params.mode === "setup") { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index f242785d87f..e2724dfa2f9 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -548,6 +548,80 @@ describe("resolvePluginProviders", () => { ); }); + it("excludes untrusted workspace provider plugins from setup discovery when requested", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + mode: "setup", + includeUntrustedWorkspacePlugins: false, + }); + + expectLastSetupRegistryLoad({ + onlyPluginIds: ["google", "kilocode", "moonshot"], + }); + }); + + it("keeps trusted but disabled workspace provider plugins eligible in setup discovery", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter", "workspace-provider"], + entries: { + "workspace-provider": { enabled: false }, + }, + }, + }, + mode: "setup", + includeUntrustedWorkspacePlugins: false, + }); + + expectLastSetupRegistryLoad({ + onlyPluginIds: ["google", "kilocode", "moonshot", "workspace-provider"], + }); + }); + + it("does not include trusted-but-disabled workspace providers when denylist blocks them", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter", "workspace-provider"], + deny: ["workspace-provider"], + entries: { + "workspace-provider": { enabled: false }, + }, + }, + }, + mode: "setup", + includeUntrustedWorkspacePlugins: false, + }); + + expectLastSetupRegistryLoad({ + onlyPluginIds: ["google", "kilocode", "moonshot"], + }); + }); + + it("does not include workspace providers blocked by allowlist gating", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter"], + entries: { + "workspace-provider": { enabled: true }, + }, + }, + }, + mode: "setup", + includeUntrustedWorkspacePlugins: false, + }); + + expectLastSetupRegistryLoad({ + onlyPluginIds: ["google", "kilocode", "moonshot"], + }); + }); + it("loads provider plugins from the auto-enabled config snapshot", () => { const { rawConfig, autoEnabledConfig } = createAutoEnabledProviderConfig(); applyPluginAutoEnableMock.mockReturnValue({ diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 299fca1c589..19958a315ad 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -74,17 +74,41 @@ export function resolveDiscoveredProviderPluginIds(params: { workspaceDir?: string; env?: PluginLoadOptions["env"]; onlyPluginIds?: readonly string[]; + includeUntrustedWorkspacePlugins?: boolean; }): string[] { const onlyPluginIdSet = params.onlyPluginIds ? new Set(params.onlyPluginIds) : null; - return loadPluginManifestRegistry({ + const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, - }) - .plugins.filter( - (plugin) => - plugin.providers.length > 0 && (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)), - ) + }); + const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; + const normalizedConfig = normalizePluginsConfig(params.config?.plugins); + return registry.plugins + .filter((plugin) => { + if (!(plugin.providers.length > 0 && (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)))) { + return false; + } + if (!shouldFilterUntrustedWorkspacePlugins || plugin.origin !== "workspace") { + return true; + } + const activation = resolveEffectivePluginActivationState({ + id: plugin.id, + origin: plugin.origin, + config: normalizedConfig, + rootConfig: params.config, + enabledByDefault: plugin.enabledByDefault, + }); + if (activation.activated) { + return true; + } + const explicitlyTrustedButDisabled = + normalizedConfig.enabled && + !normalizedConfig.deny.includes(plugin.id) && + normalizedConfig.allow.includes(plugin.id) && + normalizedConfig.entries[plugin.id]?.enabled === false; + return explicitlyTrustedButDisabled; + }) .map((plugin) => plugin.id) .toSorted((left, right) => left.localeCompare(right)); }