diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts index fe1f5185e6f..93380d99f75 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -213,6 +213,56 @@ describe("resolveProviderAuths plugin boundary", () => { expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); }); + it("keeps plugin usage auth when an owned alias provider has auth-profile credentials", async () => { + hasAnyAuthProfileStoreSourceMock.mockReturnValue(true); + ensureAuthProfileStoreWithoutExternalProfilesMock.mockReturnValue({ + profiles: { + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + accessToken: "portal-oauth-token", + }, + }, + }); + resolveAuthProfileOrderMock.mockImplementation((params: unknown) => { + const provider = + params && typeof params === "object" && "provider" in params + ? (params as { provider?: unknown }).provider + : undefined; + return provider === "minimax-portal" ? ["minimax-portal:default"] : []; + }); + resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ + token: "plugin-minimax-token", + }); + + await withTempHome(async (homeDir) => { + await expect( + resolveProviderAuths({ + providers: ["minimax"], + skipPluginAuthWithoutCredentialSource: true, + env: { HOME: homeDir }, + }), + ).resolves.toEqual([ + { + provider: "minimax", + token: "plugin-minimax-token", + }, + ]); + }); + + expect(resolveAuthProfileOrderMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "minimax-portal", + }), + ); + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "minimax", + }), + ); + expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); + }); + it("does not overlay external auth profiles while checking the skip gate", async () => { hasAnyAuthProfileStoreSourceMock.mockReturnValue(true); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index be906caa169..26ff9275eb4 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -12,6 +12,15 @@ import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { normalizePluginsConfig } from "../plugins/config-state.js"; +import { + isActivatedManifestOwner, + passesManifestOwnerBasePolicy, +} from "../plugins/manifest-owner-policy.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, +} from "../plugins/manifest-registry.js"; import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { resolveLegacyPiAgentAccessToken } from "./provider-usage.shared.js"; @@ -110,6 +119,69 @@ function resolveProviderApiKeyFromConfigAndStore(params: { return undefined; } +function normalizeProviderIds(providerIds: Iterable): string[] { + return [ + ...new Set( + [...providerIds] + .map((providerId) => (providerId ? normalizeProviderId(providerId) : undefined)) + .filter((providerId): providerId is string => Boolean(providerId)), + ), + ]; +} + +function isUsageProviderManifestEligible(params: { + plugin: PluginManifestRecord; + state: UsageAuthState; +}): boolean { + const normalizedConfig = normalizePluginsConfig(params.state.cfg.plugins); + if ( + !passesManifestOwnerBasePolicy({ + plugin: params.plugin, + normalizedConfig, + }) + ) { + return false; + } + if (params.plugin.origin !== "workspace") { + return true; + } + return isActivatedManifestOwner({ + plugin: params.plugin, + normalizedConfig, + rootConfig: params.state.cfg, + }); +} + +function resolveUsageCredentialProviderIds(params: { + state: UsageAuthState; + provider: UsageProviderId; +}): string[] { + const providerIds = new Set(normalizeProviderIds([params.provider])); + const providerIdSet = new Set(providerIds); + try { + const registry = loadPluginManifestRegistry({ + config: params.state.cfg, + env: params.state.env, + }); + for (const plugin of registry.plugins) { + const pluginProviderIds = normalizeProviderIds(plugin.providers); + if (!pluginProviderIds.some((providerId) => providerIdSet.has(providerId))) { + continue; + } + if (!isUsageProviderManifestEligible({ plugin, state: params.state })) { + continue; + } + for (const providerId of pluginProviderIds) { + providerIds.add(providerId); + } + } + } catch { + // Credential-source checks are an optimization gate; preserve usage fallback + // behavior if manifest discovery is unavailable in a constrained environment. + } + return [...providerIds]; +} + async function resolveOAuthToken(params: { state: UsageAuthState; provider: string; @@ -229,20 +301,27 @@ async function resolveProviderUsageAuthFallback(params: { function hasAuthProfileCredentialSource(params: { state: UsageAuthState; - provider: UsageProviderId; + providerIds: string[]; }): boolean { const store = ensureAuthProfileStoreWithoutExternalProfiles(params.state.agentDir, { allowKeychainPrompt: false, }); - const order = resolveAuthProfileOrder({ - cfg: params.state.cfg, - store, - provider: params.provider, - }); - return dedupeProfileIds(order).some((profileId) => { - const cred = store.profiles[profileId]; - return cred?.type === "api_key" || cred?.type === "oauth" || cred?.type === "token"; - }); + for (const provider of params.providerIds) { + const order = resolveAuthProfileOrder({ + cfg: params.state.cfg, + store, + provider, + }); + if ( + dedupeProfileIds(order).some((profileId) => { + const cred = store.profiles[profileId]; + return cred?.type === "api_key" || cred?.type === "oauth" || cred?.type === "token"; + }) + ) { + return true; + } + } + return false; } function resolveLegacyPiAgentProviderIds(provider: UsageProviderId): string[] { @@ -274,10 +353,14 @@ export async function resolveProviderAuths(params: { const auths: ProviderAuth[] = []; for (const provider of params.providers) { + const credentialProviderIds = resolveUsageCredentialProviderIds({ + state: { ...stateBase, allowAuthProfileStore: false }, + provider, + }); const hasDirectCredentialSource = Boolean( resolveProviderApiKeyFromConfig({ state: { ...stateBase, allowAuthProfileStore: false }, - providerIds: [provider], + providerIds: credentialProviderIds, }), ); const allowAuthProfileStore = @@ -286,7 +369,7 @@ export async function resolveProviderAuths(params: { (hasAuthProfileStoreSource && hasAuthProfileCredentialSource({ state: authProfileSourceState, - provider, + providerIds: credentialProviderIds, })); const state: UsageAuthState = { ...stateBase,