mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
fix(auth): scope external CLI credential discovery
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user