diff --git a/src/agents/auth-profiles.external-cli-scope.test.ts b/src/agents/auth-profiles.external-cli-scope.test.ts index a3db6d645bf..e2f064356b0 100644 --- a/src/agents/auth-profiles.external-cli-scope.test.ts +++ b/src/agents/auth-profiles.external-cli-scope.test.ts @@ -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"); + }); }); diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index c408ae875c6..acbf4ae96bb 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -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([]); }); diff --git a/src/agents/auth-profiles/external-cli-scope.ts b/src/agents/auth-profiles/external-cli-scope.ts index aa0ef47541e..3ab0158c845 100644 --- a/src/agents/auth-profiles/external-cli-scope.ts +++ b/src/agents/auth-profiles/external-cli-scope.ts @@ -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); diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 70cade2a39b..ca92743d192 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -199,14 +199,17 @@ function normalizeProfileScope(values: Iterable | undefined): Set ({ 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( { diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index c540db12401..561d5e7da4c 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -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,