fix(auth): scope external CLI credential discovery

This commit is contained in:
Peter Steinberger
2026-04-30 19:38:18 +01:00
parent 54e6e3d7da
commit 581fbea1d6
6 changed files with 208 additions and 26 deletions

View File

@@ -37,7 +37,7 @@ describe("external CLI auth scope", () => {
expect(scope?.providerIds).not.toContain("minimax-portal");
});
it("collects model, auth order, media model, and runtime signals", () => {
it("collects active model, auth order, media model, and runtime signals", () => {
const cfg = {
auth: {
order: {
@@ -54,6 +54,9 @@ describe("external CLI auth scope", () => {
cliBackends: {
"claude-cli": { command: "claude" },
},
models: {
"claude-cli/claude-opus-4-7": { alias: "opus" },
},
},
list: [
{
@@ -74,13 +77,29 @@ describe("external CLI auth scope", () => {
"openai",
"openai-codex",
"minimax-portal",
"claude-cli",
"codex-app-server",
"opencode-go",
"z.ai",
"zai",
]),
);
expect(scope?.providerIds).not.toContain("claude-cli");
expect(scope?.profileIds).toContain("openai-codex:default");
});
it("includes a CLI provider only when it is the active runtime", () => {
const scope = resolveExternalCliAuthScopeFromConfig({
agents: {
defaults: {
model: "openai/gpt-5.5",
agentRuntime: { id: "claude-cli" },
cliBackends: {
"claude-cli": { command: "claude" },
},
},
},
});
expect(scope?.providerIds).toContain("claude-cli");
});
});

View File

@@ -243,7 +243,7 @@ describe("external cli oauth resolution", () => {
expect(credential).toBeNull();
});
it("bootstraps the default codex profile from Codex CLI credentials when missing locally", () => {
it("bootstraps the default codex profile from Codex CLI credentials when in scope", () => {
mocks.readCodexCliCredentialsCached.mockReturnValue(
makeOAuthCredential({
provider: "openai-codex",
@@ -254,7 +254,9 @@ describe("external cli oauth resolution", () => {
}),
);
const profiles = resolveExternalCliAuthProfiles(makeStore());
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["openai-codex"],
});
expect(profiles).toEqual([
{
@@ -318,7 +320,9 @@ describe("external cli oauth resolution", () => {
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore());
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli"],
});
expect(profiles).toEqual([
{
@@ -344,6 +348,51 @@ describe("external cli oauth resolution", () => {
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("does not scan missing external CLI profiles without an explicit scope", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore());
expect(profiles).toEqual([]);
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
});
it("refreshes a stored external CLI profile without an explicit scope", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-fresh-access",
refresh: "claude-cli-fresh-refresh",
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(
makeStore(CLAUDE_CLI_PROFILE_ID, {
type: "oauth",
provider: "claude-cli",
access: "claude-cli-stale-access",
refresh: "claude-cli-stale-refresh",
expires: Date.now() - 5_000,
}),
);
expect(profiles).toEqual([
{
profileId: CLAUDE_CLI_PROFILE_ID,
credential: expect.objectContaining({
provider: "claude-cli",
access: "claude-cli-fresh-access",
}),
},
]);
});
it("passes non-prompting keychain policy to scoped Claude CLI credential reads", () => {
mocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
@@ -412,7 +461,9 @@ describe("external cli oauth resolution", () => {
expires: Date.now() + 5 * 24 * 60 * 60_000,
});
const profiles = resolveExternalCliAuthProfiles(makeStore());
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli"],
});
expect(profiles).toEqual([]);
});

View File

@@ -91,14 +91,8 @@ export function resolveExternalCliAuthScopeFromConfig(
addProviderScopeFromModelConfig(providerIds, defaults?.videoGenerationModel);
addProviderScopeFromModelConfig(providerIds, defaults?.musicGenerationModel);
addProviderScopeFromModelConfig(providerIds, defaults?.pdfModel);
for (const modelRef of Object.keys(defaults?.models ?? {})) {
addProviderScopeFromModelRef(providerIds, modelRef);
}
addExternalCliRuntimeScope(providerIds, defaults?.agentRuntime?.id);
addExternalCliRuntimeScope(providerIds, defaults?.embeddedHarness?.runtime);
for (const backendId of Object.keys(defaults?.cliBackends ?? {})) {
addExternalCliRuntimeScope(providerIds, backendId);
}
for (const agent of cfg.agents?.list ?? []) {
addProviderScopeFromModelConfig(providerIds, agent.model);

View File

@@ -199,14 +199,17 @@ function normalizeProfileScope(values: Iterable<string> | undefined): Set<string
return out;
}
function isExternalCliProviderInScope(
providerConfig: ExternalCliSyncProvider,
options?: ExternalCliAuthProfileOptions,
): boolean {
function isExternalCliProviderInScope(params: {
providerConfig: ExternalCliSyncProvider;
store: AuthProfileStore;
options?: ExternalCliAuthProfileOptions;
}): boolean {
const { providerConfig, options, store } = params;
const providerScope = normalizeProviderScope(options?.providerIds);
const profileScope = normalizeProfileScope(options?.profileIds);
if (providerScope === undefined && profileScope === undefined) {
return true;
const existing = store.profiles[providerConfig.profileId];
return existing?.type === "oauth" && existing.provider === providerConfig.provider;
}
if (profileScope?.has(providerConfig.profileId.toLowerCase())) {
return true;
@@ -229,7 +232,7 @@ export function resolveExternalCliAuthProfiles(
const profiles: ExternalCliResolvedProfile[] = [];
const now = Date.now();
for (const providerConfig of EXTERNAL_CLI_SYNC_PROVIDERS) {
if (!isExternalCliProviderInScope(providerConfig, options)) {
if (!isExternalCliProviderInScope({ providerConfig, store, options })) {
continue;
}
const creds = providerConfig.readCredentials({

View File

@@ -10,6 +10,8 @@ import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
} from "./auth-profiles/store.js";
import type { OAuthCredential } from "./auth-profiles/types.js";
import type { ClaudeCliCredential } from "./cli-credentials.js";
import {
getApiKeyForModel,
hasAvailableAuthForProvider,
@@ -206,14 +208,21 @@ vi.mock("../plugins/providers.js", () => ({
provider === "openai" ? ["openai"] : [],
}));
vi.mock("./cli-credentials.js", () => ({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
const cliCredentialMocks = vi.hoisted(() => ({
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>(
() => null,
),
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
}));
vi.mock("./cli-credentials.js", () => cliCredentialMocks);
beforeEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
cliCredentialMocks.readClaudeCliCredentialsCached.mockReset().mockReturnValue(null);
cliCredentialMocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
cliCredentialMocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
});
afterEach(() => {
@@ -386,6 +395,67 @@ describe("getApiKeyForModel", () => {
);
});
it("does not read unrelated external CLI credentials when resolving provider auth", async () => {
cliCredentialMocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: createUsableOAuthExpiry(),
});
await withOpenClawTestState(
{
layout: "state-only",
prefix: "openclaw-auth-scope-",
agentEnv: "main",
env: {
OPENAI_API_KEY: undefined,
},
},
async () => {
await expect(resolveApiKeyForProvider({ provider: "openai" })).rejects.toThrow(
'No API key found for provider "openai".',
);
},
);
expect(cliCredentialMocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
expect(cliCredentialMocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
expect(cliCredentialMocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("reads Claude CLI credentials when the Claude CLI provider is resolved", async () => {
cliCredentialMocks.readClaudeCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires: createUsableOAuthExpiry(),
});
await withOpenClawTestState(
{
layout: "state-only",
prefix: "openclaw-auth-claude-cli-",
agentEnv: "main",
},
async () => {
const resolved = await resolveApiKeyForProvider({ provider: "claude-cli" });
expect(resolved).toMatchObject({
apiKey: "claude-cli-access",
profileId: "anthropic:claude-cli",
source: "profile:anthropic:claude-cli",
mode: "oauth",
});
},
);
expect(cliCredentialMocks.readClaudeCliCredentialsCached).toHaveBeenCalledWith(
expect.objectContaining({ allowKeychainPrompt: false }),
);
});
it("throws when ZAI API key is missing", async () => {
await withEnvAsync(
{

View File

@@ -489,6 +489,24 @@ function shouldDeferSyntheticProfileAuth(params: {
);
}
function resolveScopedAuthProfileStore(params: {
agentDir?: string;
cfg?: OpenClawConfig;
provider: string;
profileId?: string;
preferredProfile?: string;
}): AuthProfileStore {
const profileIds = [params.profileId, params.preferredProfile]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
return ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
config: params.cfg,
externalCliProviderIds: [params.provider],
...(profileIds.length > 0 ? { externalCliProfileIds: profileIds } : {}),
});
}
export async function resolveApiKeyForProvider(params: {
provider: string;
cfg?: OpenClawConfig;
@@ -505,7 +523,15 @@ export async function resolveApiKeyForProvider(params: {
const { provider, cfg, profileId, preferredProfile } = params;
if (profileId) {
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
const store =
params.store ??
resolveScopedAuthProfileStore({
agentDir: params.agentDir,
cfg,
provider,
profileId,
preferredProfile,
});
const resolved = await resolveApiKeyForProfile({
cfg,
store,
@@ -591,7 +617,14 @@ export async function resolveApiKeyForProvider(params: {
mode: "api-key",
};
}
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
const store =
params.store ??
resolveScopedAuthProfileStore({
agentDir: params.agentDir,
cfg,
provider,
preferredProfile,
});
const order = resolveAuthProfileOrder({
cfg,
store,
@@ -719,7 +752,12 @@ export function resolveModelAuthMode(
return "aws-sdk";
}
const authStore = store ?? ensureAuthProfileStore();
const authStore =
store ??
resolveScopedAuthProfileStore({
cfg,
provider: resolved,
});
const profiles = listProfilesForProvider(authStore, resolved);
if (profiles.length > 0) {
const modes = new Set(
@@ -794,7 +832,14 @@ export async function hasAvailableAuthForProvider(params: {
return true;
}
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
const store =
params.store ??
resolveScopedAuthProfileStore({
agentDir: params.agentDir,
cfg,
provider,
preferredProfile,
});
const order = resolveAuthProfileOrder({
cfg,
store,