diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index bb98b85..bdae4e2 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import type { @@ -9,6 +10,7 @@ import type { import { bearerAuthPlugin, findOauthTokenSet, + isRecord, missingCandidate, providerInternalNamePlaceholder, providerPayload, @@ -19,6 +21,7 @@ import { } from "@ccr/core/agents/local-providers/shared"; const claudeDefaultModels = ["claude-sonnet-5"]; +const claudeCodeKeychainService = "Claude Code-credentials"; const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({ id, @@ -148,6 +151,19 @@ function readClaudeCodeOauth(): OAuthTokenSet | undefined { sourceFile }; } + + const keychainRecord = readClaudeCodeKeychainRecord(); + if (keychainRecord) { + const credential = findOauthTokenSet(keychainRecord); + if (credential) { + return { + accessToken: credential.accessToken, + refreshToken: credential.refreshToken, + sourceFile: `keychain:${claudeCodeKeychainService}` + }; + } + } + return undefined; } @@ -158,3 +174,24 @@ function claudeCredentialFiles(): string[] { path.join(os.homedir(), ".config", "claude", "credentials.json") ]); } + +// Newer macOS builds of the Claude Code CLI store credentials in the +// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the +// standard macOS keychain access prompt (Allow / Always Allow); the user +// declining or the item not existing both surface as a non-zero exit here. +function readClaudeCodeKeychainRecord(): Record | undefined { + if (process.platform !== "darwin") { + return undefined; + } + try { + const output = execFileSync( + "security", + ["find-generic-password", "-s", claudeCodeKeychainService, "-w"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] } + ); + const parsed = JSON.parse(output.trim()) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 5bd3b5e..c6b61da 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -60,12 +60,17 @@ const localAgentProviderApiKey = "ccr-local-agent-login"; function materializeProviderPluginTemplates( templates: unknown[], providerName: string, - protocol: GatewayProviderConfig["type"] + protocol: GatewayProviderConfig["type"], + providerId: string ): unknown[] { if (templates.length === 0) { return []; } - const internalName = protocol ? `${providerName}::${protocol}` : providerName; + // The core gateway matches provider plugins against the provider's runtime + // identifier (provider.id, or its slug), not the human-readable display name + // — the internal name here must mirror providerCapabilityInternalName() in + // gateway/service.ts or the plugin's auth-header override silently never applies. + const internalName = protocol ? `${providerId}::${protocol}` : providerId; const replacements: Record = { [providerInternalNamePlaceholder]: internalName, [providerNamePlaceholder]: providerName, @@ -1431,6 +1436,8 @@ function App() { return false; } + const existingProvider = providerEditIndex !== undefined ? draftConfig.Providers[providerEditIndex] : undefined; + const providerId = existingProvider?.id ?? providerNameSlug(providerName); const provider: GatewayProviderConfig = { api_base_url: normalizeProviderBaseUrl(baseUrl, protocol), api_key: providerDraft.apiKey.trim(), @@ -1438,13 +1445,14 @@ function App() { account: accountConfig, credentials: credentials.length > 0 ? credentials : undefined, icon: providerDraft.icon.trim() || undefined, + id: providerId, modelDescriptions, modelDisplayNames, models, name: providerName, type: protocol }; - const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol); + const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol, providerId); const wasImport = providerImportOpen; const next = buildConfigUpdate((config) => {