From cd348659ce2fb12cf85ee29f7d0ce263f12648e0 Mon Sep 17 00:00:00 2001 From: Tuyen Date: Sun, 5 Apr 2026 14:38:01 +0700 Subject: [PATCH] Anthropic: fix claude-cli runtime auth --- extensions/anthropic/index.test.ts | 41 ++++++++++++++++++- extensions/anthropic/register.runtime.ts | 29 +++++++++++++ src/agents/model-auth.test.ts | 29 +++++++++++++ .../model.forward-compat.test.ts | 29 ++++++++++++- .../model.provider-runtime.test-support.ts | 7 ++-- src/plugins/provider-runtime.test.ts | 19 +++++++++ src/plugins/providers.test.ts | 14 ++++++- src/plugins/providers.ts | 10 ++++- 8 files changed, 170 insertions(+), 8 deletions(-) diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index a6b325e4e12..7d4736e48e8 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -1,5 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js"; + +const { readClaudeCliCredentialsCachedMock } = vi.hoisted(() => ({ + readClaudeCliCredentialsCachedMock: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readClaudeCliCredentialsCached: readClaudeCliCredentialsCachedMock, + }; +}); + import anthropicPlugin from "./index.js"; describe("anthropic provider replay hooks", () => { @@ -83,4 +96,30 @@ describe("anthropic provider replay hooks", () => { next?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.params?.cacheRetention, ).toBe("short"); }); + + it("resolves claude-cli synthetic auth without allowing keychain prompts", async () => { + readClaudeCliCredentialsCachedMock.mockReset(); + readClaudeCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "anthropic", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }); + + const provider = await registerSingleProviderPlugin(anthropicPlugin); + + expect( + provider.resolveSyntheticAuth?.({ + provider: "claude-cli", + } as never), + ).toEqual({ + apiKey: "access-token", + source: "Claude CLI native auth", + mode: "oauth", + }); + expect(readClaudeCliCredentialsCachedMock).toHaveBeenCalledWith({ + allowKeychainPrompt: false, + }); + }); }); diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 08f7d1da61d..0433e7c0060 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -13,6 +13,7 @@ import { ensureApiKeyFromOptionEnvOrPrompt, listProfilesForProvider, normalizeApiKeyInput, + readClaudeCliCredentialsCached, type OpenClawConfig as ProviderAuthConfig, suggestOAuthProfileIdForLegacyDefault, type AuthProfileStore, @@ -25,6 +26,7 @@ import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shar import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; import { buildAnthropicCliBackend } from "./cli-backend.js"; import { buildAnthropicCliMigrationResult, hasClaudeCliAuth } from "./cli-migration.js"; +import { CLAUDE_CLI_BACKEND_ID } from "./cli-shared.js"; import { applyAnthropicConfigDefaults, normalizeAnthropicProviderConfig, @@ -212,6 +214,10 @@ function resolveAnthropic46ForwardCompatModel(params: { modelId: trimmedModelId, templateIds, ctx: params.ctx, + patch: + params.ctx.provider.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID + ? { provider: CLAUDE_CLI_BACKEND_ID } + : undefined, }); } @@ -278,6 +284,24 @@ function buildAnthropicAuthDoctorHint(params: { ].join("\n"); } +function resolveClaudeCliSyntheticAuth() { + const credential = readClaudeCliCredentialsCached({ allowKeychainPrompt: false }); + if (!credential) { + return undefined; + } + return credential.type === "oauth" + ? { + apiKey: credential.access, + source: "Claude CLI native auth", + mode: "oauth" as const, + } + : { + apiKey: credential.token, + source: "Claude CLI native auth", + mode: "token" as const, + }; +} + async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise { if (!hasClaudeCliAuth()) { throw new Error( @@ -347,6 +371,7 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void { id: providerId, label: "Anthropic", docsPath: "/providers/models", + hookAliases: [CLAUDE_CLI_BACKEND_ID], envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], deprecatedProfileIds: [claudeCliProfileId], oauthProfileIdRepairs: [ @@ -425,6 +450,10 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void { normalizeConfig: ({ providerConfig }) => normalizeAnthropicProviderConfig(providerConfig), applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }), resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx), + resolveSyntheticAuth: ({ provider }) => + provider.trim().toLowerCase() === CLAUDE_CLI_BACKEND_ID + ? resolveClaudeCliSyntheticAuth() + : undefined, buildReplayPolicy: buildAnthropicReplayPolicy, isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId), resolveReasoningOutputMode: () => "native", diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index c301cc3545d..331cdef1934 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -78,6 +78,13 @@ vi.mock("../plugins/provider-runtime.js", () => ({ } return undefined; } + if (params.provider === "claude-cli") { + return { + apiKey: "claude-cli-access-token", + source: "Claude CLI native auth", + mode: "oauth" as const, + }; + } if (params.provider !== "ollama") { return undefined; } @@ -486,6 +493,28 @@ describe("resolveApiKeyForProvider", () => { ).rejects.toThrow('No API key found for provider "xai"'); }); + it("reuses native Claude CLI auth for the claude-cli provider", async () => { + const resolved = await resolveApiKeyForProvider({ + provider: "claude-cli", + cfg: { + agents: { + defaults: { + model: { + primary: "claude-cli/claude-sonnet-4-6", + }, + }, + }, + }, + store: { version: 1, profiles: {} }, + }); + + expect(resolved).toEqual({ + apiKey: "claude-cli-access-token", + source: "Claude CLI native auth", + mode: "oauth", + }); + }); + it("prefers explicit api-key provider config over ambient auth profiles", async () => { const resolved = await resolveApiKeyForProvider({ provider: "openai", diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test.ts b/src/agents/pi-embedded-runner/model.forward-compat.test.ts index 9cca216dad1..8823e45a930 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.test.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.test.ts @@ -68,7 +68,7 @@ const ZAI_GLM5_CASE = { function createRuntimeHooks() { return createProviderRuntimeTestMock({ - handledDynamicProviders: ["anthropic", "zai", "openai-codex"], + handledDynamicProviders: ["anthropic", "claude-cli", "zai", "openai-codex"], }); } @@ -123,6 +123,28 @@ function runAnthropicSonnetForwardCompatFallback() { }); } +function runClaudeCliSonnetForwardCompatFallback() { + expectResolvedForwardCompatFallbackWithRegistryResult({ + result: resolveModelWithRegistry({ + provider: "claude-cli", + modelId: "claude-sonnet-4-6", + agentDir: "/tmp/agent", + modelRegistry: createRegistry([ + { + provider: "anthropic", + modelId: "claude-sonnet-4-5", + model: ANTHROPIC_SONNET_TEMPLATE, + }, + ]), + runtimeHooks: createRuntimeHooks(), + }), + expectedModel: { + ...ANTHROPIC_SONNET_EXPECTED, + provider: "claude-cli", + }, + }); +} + function runZaiForwardCompatFallback() { const result = resolveModelWithRegistry({ provider: ZAI_GLM5_CASE.provider, @@ -154,5 +176,10 @@ describe("resolveModel forward-compat tail", () => { runAnthropicSonnetForwardCompatFallback, ); + it( + "preserves the claude-cli provider for anthropic forward-compat fallback models", + runClaudeCliSonnetForwardCompatFallback, + ); + it("builds a zai forward-compat fallback for glm-5", runZaiForwardCompatFallback); }); diff --git a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts index 4260b4daa86..2001abd7954 100644 --- a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts +++ b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts @@ -324,7 +324,8 @@ function buildDynamicModel( maxTokens: patch.maxTokens ?? DEFAULT_CONTEXT_WINDOW, }); } - case "anthropic": { + case "anthropic": + case "claude-cli": { if (lower !== "claude-opus-4-6" && lower !== "claude-sonnet-4-6") { return undefined; } @@ -337,13 +338,13 @@ function buildDynamicModel( template, modelId, { - provider: "anthropic", + provider: params.provider, api: "anthropic-messages", baseUrl: ANTHROPIC_BASE_URL, reasoning: true, }, { - provider: "anthropic", + provider: params.provider, api: "anthropic-messages", baseUrl: ANTHROPIC_BASE_URL, reasoning: true, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 988d6668768..cbe06dd974c 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -300,6 +300,25 @@ describe("provider-runtime", () => { }); }); + it("returns no runtime plugin when the provider has no owning plugin", () => { + it("matches providers by hook alias for runtime hook lookup", () => { + resolveOwningPluginIdsForProviderMock.mockReturnValue(["anthropic"]); + resolvePluginProvidersMock.mockReturnValue([ + { + id: "anthropic", + label: "Anthropic", + hookAliases: ["claude-cli"], + auth: [], + }, + ]); + + expectProviderRuntimePluginLoad({ + provider: "claude-cli", + expectedPluginId: "anthropic", + expectedOnlyPluginIds: ["anthropic"], + }); + }); + it("returns no runtime plugin when the provider has no owning plugin", () => { expectProviderRuntimePluginLoad({ provider: "anthropic", diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 07c8e7a1af7..b04297bb603 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -26,6 +26,7 @@ let setActivePluginRegistry: SetActivePluginRegistry; function createManifestProviderPlugin(params: { id: string; providerIds: string[]; + cliBackends?: string[]; origin?: "bundled" | "workspace"; enabledByDefault?: boolean; modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] }; @@ -34,7 +35,7 @@ function createManifestProviderPlugin(params: { id: params.id, enabledByDefault: params.enabledByDefault, channels: [], - cliBackends: [], + cliBackends: params.cliBackends ?? [], providers: params.providerIds, modelSupport: params.modelSupport, skills: [], @@ -62,6 +63,7 @@ function setOwningProviderManifestPlugins() { createManifestProviderPlugin({ id: "openai", providerIds: ["openai", "openai-codex"], + cliBackends: ["codex-cli"], modelSupport: { modelPrefixes: ["gpt-", "o1", "o3", "o4"], }, @@ -69,6 +71,7 @@ function setOwningProviderManifestPlugins() { createManifestProviderPlugin({ id: "anthropic", providerIds: ["anthropic"], + cliBackends: ["claude-cli"], modelSupport: { modelPrefixes: ["claude-"], }, @@ -85,6 +88,7 @@ function setOwningProviderManifestPluginsWithWorkspace() { createManifestProviderPlugin({ id: "openai", providerIds: ["openai", "openai-codex"], + cliBackends: ["codex-cli"], modelSupport: { modelPrefixes: ["gpt-", "o1", "o3", "o4"], }, @@ -92,6 +96,7 @@ function setOwningProviderManifestPluginsWithWorkspace() { createManifestProviderPlugin({ id: "anthropic", providerIds: ["anthropic"], + cliBackends: ["claude-cli"], modelSupport: { modelPrefixes: ["claude-"], }, @@ -275,6 +280,13 @@ describe("resolvePluginProviders", () => { ({ setActivePluginRegistry } = await import("./runtime.js")); }); + it("maps cli backend ids to owning plugin ids via manifests", () => { + setOwningProviderManifestPlugins(); + + expectOwningPluginIds("claude-cli", ["anthropic"]); + expectOwningPluginIds("codex-cli", ["openai"]); + }); + beforeEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); resolveRuntimePluginRegistryMock.mockReset(); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index ddf32ea3164..299fca1c589 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -203,8 +203,14 @@ export function resolveOwningPluginIdsForProvider(params: { const registry = resolveManifestRegistry(params); const pluginIds = registry.plugins - .filter((plugin) => - plugin.providers.some((providerId) => normalizeProviderId(providerId) === normalizedProvider), + .filter( + (plugin) => + plugin.providers.some( + (providerId) => normalizeProviderId(providerId) === normalizedProvider, + ) || + plugin.cliBackends.some( + (backendId) => normalizeProviderId(backendId) === normalizedProvider, + ), ) .map((plugin) => plugin.id);