fix: scope usage auth credential gates

This commit is contained in:
Gustavo Madeira Santana
2026-04-20 21:37:11 -04:00
parent b55d214e97
commit aae4ee42f2
3 changed files with 123 additions and 14 deletions

View File

@@ -6,17 +6,19 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const resolveProviderUsageAuthWithPluginMock = vi.fn(
async (..._args: unknown[]): Promise<unknown> => 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(

View File

@@ -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({

View File

@@ -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[],