From aae4ee42f2d3e6c9db0233fe3940225f910b348c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 21:37:11 -0400 Subject: [PATCH] fix: scope usage auth credential gates --- src/infra/provider-usage.auth.plugin.test.ts | 91 +++++++++++++++++++- src/infra/provider-usage.auth.ts | 38 +++++++- src/infra/provider-usage.shared.ts | 8 -- 3 files changed, 123 insertions(+), 14 deletions(-) diff --git a/src/infra/provider-usage.auth.plugin.test.ts b/src/infra/provider-usage.auth.plugin.test.ts index 49d791fbcf5..8e135062cfc 100644 --- a/src/infra/provider-usage.auth.plugin.test.ts +++ b/src/infra/provider-usage.auth.plugin.test.ts @@ -6,17 +6,19 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const resolveProviderUsageAuthWithPluginMock = vi.fn( async (..._args: unknown[]): Promise => null, ); +const hasAnyAuthProfileStoreSourceMock = vi.fn(() => false); const ensureAuthProfileStoreMock = vi.fn(() => ({ profiles: {}, })); +const resolveAuthProfileOrderMock = vi.fn((_params: unknown): string[] => []); vi.mock("../agents/auth-profiles.js", () => ({ dedupeProfileIds: (profileIds: string[]) => [...new Set(profileIds)], ensureAuthProfileStore: () => ensureAuthProfileStoreMock(), - hasAnyAuthProfileStoreSource: () => false, + hasAnyAuthProfileStoreSource: () => hasAnyAuthProfileStoreSourceMock(), listProfilesForProvider: () => [], resolveApiKeyForProfile: async () => null, - resolveAuthProfileOrder: () => [], + resolveAuthProfileOrder: (params: unknown) => resolveAuthProfileOrderMock(params), })); vi.mock("../plugins/provider-runtime.js", async () => { @@ -46,7 +48,14 @@ describe("resolveProviderAuths plugin boundary", () => { }); beforeEach(() => { + hasAnyAuthProfileStoreSourceMock.mockReset(); + hasAnyAuthProfileStoreSourceMock.mockReturnValue(false); ensureAuthProfileStoreMock.mockClear(); + ensureAuthProfileStoreMock.mockReturnValue({ + profiles: {}, + }); + resolveAuthProfileOrderMock.mockReset(); + resolveAuthProfileOrderMock.mockReturnValue([]); resolveProviderUsageAuthWithPluginMock.mockReset(); resolveProviderUsageAuthWithPluginMock.mockResolvedValue(null); }); @@ -116,6 +125,84 @@ describe("resolveProviderAuths plugin boundary", () => { expect(ensureAuthProfileStoreMock).not.toHaveBeenCalled(); }); + it("keeps legacy plugin credential sources provider-specific", async () => { + await withTempHome(async (homeDir) => { + fs.mkdirSync(path.join(homeDir, ".pi", "agent"), { recursive: true }); + fs.writeFileSync( + path.join(homeDir, ".pi", "agent", "auth.json"), + `${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } })}\n`, + ); + resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ + token: "legacy-zai-token", + }); + + await expect( + resolveProviderAuths({ + providers: ["anthropic", "zai"], + skipPluginAuthWithoutCredentialSource: true, + env: { HOME: homeDir }, + }), + ).resolves.toEqual([ + { + provider: "zai", + token: "legacy-zai-token", + }, + ]); + }); + + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1); + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "zai", + }), + ); + }); + + it("keeps auth-profile credential sources provider-specific", async () => { + hasAnyAuthProfileStoreSourceMock.mockReturnValue(true); + ensureAuthProfileStoreMock.mockReturnValue({ + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-ant", + }, + }, + }); + resolveAuthProfileOrderMock.mockImplementation((params: unknown) => { + const provider = + params && typeof params === "object" && "provider" in params + ? (params as { provider?: unknown }).provider + : undefined; + return provider === "anthropic" ? ["anthropic:default"] : []; + }); + resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ + token: "plugin-anthropic-token", + }); + + await withTempHome(async (homeDir) => { + await expect( + resolveProviderAuths({ + providers: ["anthropic", "zai"], + skipPluginAuthWithoutCredentialSource: true, + env: { HOME: homeDir }, + }), + ).resolves.toEqual([ + { + provider: "anthropic", + token: "plugin-anthropic-token", + }, + ]); + }); + + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1); + expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "anthropic", + }), + ); + }); + it("skips plugin usage auth per provider when only another provider has direct credentials", async () => { await withTempHome(async (homeDir) => { await expect( diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 65beed09964..e5e11cfb806 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -13,7 +13,7 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; -import { hasLegacyPiAgentAuthSource } from "./provider-usage.shared.js"; +import { resolveLegacyPiAgentAccessToken } from "./provider-usage.shared.js"; import type { UsageProviderId } from "./provider-usage.types.js"; export type ProviderAuth = { @@ -226,6 +226,26 @@ async function resolveProviderUsageAuthFallback(params: { return null; } +function hasAuthProfileCredentialSource(params: { + state: UsageAuthState; + provider: UsageProviderId; +}): boolean { + const store = resolveUsageAuthStore(params.state); + 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"; + }); +} + +function resolveLegacyPiAgentProviderIds(provider: UsageProviderId): string[] { + return provider === "zai" ? ["z-ai", "zai"] : [provider]; +} + export async function resolveProviderAuths(params: { providers: UsageProviderId[]; auth?: ProviderAuth[]; @@ -244,7 +264,10 @@ export async function resolveProviderAuths(params: { agentDir: params.agentDir, }; const hasAuthProfileStoreSource = hasAnyAuthProfileStoreSource(params.agentDir); - const hasSharedPluginCredentialSource = hasLegacyPiAgentAuthSource(stateBase.env); + const authProfileSourceState: UsageAuthState = { + ...stateBase, + allowAuthProfileStore: true, + }; const auths: ProviderAuth[] = []; for (const provider of params.providers) { @@ -257,13 +280,20 @@ export async function resolveProviderAuths(params: { const allowAuthProfileStore = !params.skipPluginAuthWithoutCredentialSource || hasDirectCredentialSource || - hasAuthProfileStoreSource; + (hasAuthProfileStoreSource && + hasAuthProfileCredentialSource({ + state: authProfileSourceState, + provider, + })); const state: UsageAuthState = { ...stateBase, allowAuthProfileStore, }; + const hasLegacyPiAgentCredentialSource = Boolean( + resolveLegacyPiAgentAccessToken(stateBase.env, resolveLegacyPiAgentProviderIds(provider)), + ); const hasPluginCredentialSource = - hasDirectCredentialSource || hasAuthProfileStoreSource || hasSharedPluginCredentialSource; + hasDirectCredentialSource || allowAuthProfileStore || hasLegacyPiAgentCredentialSource; if (!params.skipPluginAuthWithoutCredentialSource || hasPluginCredentialSource) { const pluginAuth = await resolveProviderUsageAuthViaPlugin({ diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index 2a0fdda225b..4f6ab8c09d0 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -75,14 +75,6 @@ function resolveLegacyPiAgentAuthPath(env: NodeJS.ProcessEnv): string { return path.join(resolveRequiredHomeDir(env, os.homedir), ".pi", "agent", "auth.json"); } -export function hasLegacyPiAgentAuthSource(env: NodeJS.ProcessEnv): boolean { - try { - return fs.existsSync(resolveLegacyPiAgentAuthPath(env)); - } catch { - return false; - } -} - export function resolveLegacyPiAgentAccessToken( env: NodeJS.ProcessEnv, providerIds: string[],