From 05138029de420bcf631e580cf450ccd09fa8e6a7 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 2 Feb 2026 16:42:55 +0800 Subject: [PATCH] feat(credentials): add CLI credentials reader for OAuth providers Add support for reading OAuth credentials from external CLI tools: - Claude Code: ~/.claude/.credentials.json or macOS Keychain - Codex: ~/.codex/auth.json or macOS Keychain Includes provider management with status checking and login instructions. Co-Authored-By: Claude Opus 4.5 --- src/agent/credentials/cli-credentials.ts | 363 +++++++++++++++++++++++ src/agent/credentials/index.ts | 2 + src/agent/credentials/providers.ts | 271 +++++++++++++++++ 3 files changed, 636 insertions(+) create mode 100644 src/agent/credentials/cli-credentials.ts create mode 100644 src/agent/credentials/index.ts create mode 100644 src/agent/credentials/providers.ts diff --git a/src/agent/credentials/cli-credentials.ts b/src/agent/credentials/cli-credentials.ts new file mode 100644 index 00000000..2d1da4e0 --- /dev/null +++ b/src/agent/credentials/cli-credentials.ts @@ -0,0 +1,363 @@ +/** + * CLI Credentials Reader + * + * Read OAuth credentials from external CLI tools: + * - Claude Code: ~/.claude/.credentials.json or macOS Keychain + * - Codex: ~/.codex/auth.json or macOS Keychain + * + * Based on OpenClaw's implementation. + */ + +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// ============================================================ +// Types +// ============================================================ + +export type OAuthCredential = { + type: "oauth"; + provider: string; + access: string; + refresh: string; + expires: number; +}; + +export type TokenCredential = { + type: "token"; + provider: string; + token: string; + expires: number; +}; + +export type ClaudeCliCredential = (OAuthCredential | TokenCredential) & { + provider: "anthropic"; +}; + +export type CodexCliCredential = OAuthCredential & { + provider: "openai-codex"; + accountId?: string; +}; + +// ============================================================ +// Paths +// ============================================================ + +const CLAUDE_CLI_CREDENTIALS_PATH = ".claude/.credentials.json"; +const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; +const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code"; + +const CODEX_CLI_AUTH_FILENAME = "auth.json"; +const CODEX_CLI_KEYCHAIN_SERVICE = "Codex Auth"; + +function resolveHomePath(relativePath: string): string { + const home = os.homedir(); + return path.join(home, relativePath); +} + +function resolveCodexHomePath(): string { + const configured = process.env.CODEX_HOME; + const home = configured ? configured.replace(/^~/, os.homedir()) : resolveHomePath(".codex"); + try { + return fs.realpathSync(home); + } catch { + return home; + } +} + +function computeCodexKeychainAccount(codexHome: string): string { + const hash = createHash("sha256").update(codexHome).digest("hex"); + return `cli|${hash.slice(0, 16)}`; +} + +// ============================================================ +// Claude Code Credentials +// ============================================================ + +function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null { + if (process.platform !== "darwin") return null; + + try { + const result = execSync( + `security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`, + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ); + + const data = JSON.parse(result.trim()); + const claudeOauth = data?.claudeAiOauth; + if (!claudeOauth || typeof claudeOauth !== "object") return null; + + const accessToken = claudeOauth.accessToken; + const refreshToken = claudeOauth.refreshToken; + const expiresAt = claudeOauth.expiresAt; + + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof expiresAt !== "number" || expiresAt <= 0) return null; + + if (typeof refreshToken === "string" && refreshToken) { + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + } + + return { + type: "token", + provider: "anthropic", + token: accessToken, + expires: expiresAt, + }; + } catch { + return null; + } +} + +function readClaudeCliFileCredentials(): ClaudeCliCredential | null { + const credPath = resolveHomePath(CLAUDE_CLI_CREDENTIALS_PATH); + + try { + if (!fs.existsSync(credPath)) return null; + const raw = JSON.parse(fs.readFileSync(credPath, "utf8")); + if (!raw || typeof raw !== "object") return null; + + const claudeOauth = raw.claudeAiOauth; + if (!claudeOauth || typeof claudeOauth !== "object") return null; + + const accessToken = claudeOauth.accessToken; + const refreshToken = claudeOauth.refreshToken; + const expiresAt = claudeOauth.expiresAt; + + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof expiresAt !== "number" || expiresAt <= 0) return null; + + if (typeof refreshToken === "string" && refreshToken) { + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + } + + return { + type: "token", + provider: "anthropic", + token: accessToken, + expires: expiresAt, + }; + } catch { + return null; + } +} + +/** + * Read Claude Code CLI credentials. + * Priority: macOS Keychain > File (~/.claude/.credentials.json) + */ +export function readClaudeCliCredentials(): ClaudeCliCredential | null { + // Try keychain first (macOS only) + const keychainCreds = readClaudeCliKeychainCredentials(); + if (keychainCreds) return keychainCreds; + + // Fall back to file + return readClaudeCliFileCredentials(); +} + +/** + * Check if Claude Code credentials exist and are valid. + */ +export function hasValidClaudeCliCredentials(): boolean { + const creds = readClaudeCliCredentials(); + if (!creds) return false; + // Check if not expired (with 5 minute buffer) + return creds.expires > Date.now() + 5 * 60 * 1000; +} + +/** + * Get the access token from Claude Code credentials. + */ +export function getClaudeCliAccessToken(): string | null { + const creds = readClaudeCliCredentials(); + if (!creds) return null; + if (creds.type === "oauth") return creds.access; + if (creds.type === "token") return creds.token; + return null; +} + +// ============================================================ +// Codex CLI Credentials +// ============================================================ + +function readCodexKeychainCredentials(): CodexCliCredential | null { + if (process.platform !== "darwin") return null; + + const codexHome = resolveCodexHomePath(); + const account = computeCodexKeychainAccount(codexHome); + + try { + const secret = execSync( + `security find-generic-password -s "${CODEX_CLI_KEYCHAIN_SERVICE}" -a "${account}" -w`, + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ).trim(); + + const parsed = JSON.parse(secret); + const tokens = parsed.tokens; + const accessToken = tokens?.access_token; + const refreshToken = tokens?.refresh_token; + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof refreshToken !== "string" || !refreshToken) return null; + + const lastRefreshRaw = parsed.last_refresh; + const lastRefresh = + typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number" + ? new Date(lastRefreshRaw).getTime() + : Date.now(); + const expires = Number.isFinite(lastRefresh) + ? lastRefresh + 60 * 60 * 1000 + : Date.now() + 60 * 60 * 1000; + + return { + type: "oauth", + provider: "openai-codex", + access: accessToken, + refresh: refreshToken, + expires, + accountId: typeof tokens?.account_id === "string" ? tokens.account_id : undefined, + }; + } catch { + return null; + } +} + +function readCodexFileCredentials(): CodexCliCredential | null { + const authPath = path.join(resolveCodexHomePath(), CODEX_CLI_AUTH_FILENAME); + + try { + if (!fs.existsSync(authPath)) return null; + const raw = JSON.parse(fs.readFileSync(authPath, "utf8")); + if (!raw || typeof raw !== "object") return null; + + const tokens = raw.tokens; + if (!tokens || typeof tokens !== "object") return null; + + const accessToken = tokens.access_token; + const refreshToken = tokens.refresh_token; + if (typeof accessToken !== "string" || !accessToken) return null; + if (typeof refreshToken !== "string" || !refreshToken) return null; + + let expires: number; + try { + const stat = fs.statSync(authPath); + expires = stat.mtimeMs + 60 * 60 * 1000; + } catch { + expires = Date.now() + 60 * 60 * 1000; + } + + return { + type: "oauth", + provider: "openai-codex", + access: accessToken, + refresh: refreshToken, + expires, + accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined, + }; + } catch { + return null; + } +} + +/** + * Read Codex CLI credentials. + * Priority: macOS Keychain > File (~/.codex/auth.json) + */ +export function readCodexCliCredentials(): CodexCliCredential | null { + // Try keychain first (macOS only) + const keychainCreds = readCodexKeychainCredentials(); + if (keychainCreds) return keychainCreds; + + // Fall back to file + return readCodexFileCredentials(); +} + +/** + * Check if Codex credentials exist and are valid. + */ +export function hasValidCodexCliCredentials(): boolean { + const creds = readCodexCliCredentials(); + if (!creds) return false; + return creds.expires > Date.now() + 5 * 60 * 1000; +} + +/** + * Get the access token from Codex credentials. + */ +export function getCodexCliAccessToken(): string | null { + const creds = readCodexCliCredentials(); + if (!creds) return null; + return creds.access; +} + +// ============================================================ +// Unified Interface +// ============================================================ + +export type CliCredentialSource = "claude-code" | "codex"; + +export interface CliCredentialStatus { + source: CliCredentialSource; + available: boolean; + expires?: number; + expiresIn?: string; +} + +/** + * Get status of all CLI credential sources. + */ +export function getCliCredentialStatus(): CliCredentialStatus[] { + const results: CliCredentialStatus[] = []; + + // Claude Code + const claudeCreds = readClaudeCliCredentials(); + if (claudeCreds) { + const expiresIn = claudeCreds.expires - Date.now(); + results.push({ + source: "claude-code", + available: expiresIn > 0, + expires: claudeCreds.expires, + expiresIn: formatDuration(expiresIn), + }); + } else { + results.push({ source: "claude-code", available: false }); + } + + // Codex + const codexCreds = readCodexCliCredentials(); + if (codexCreds) { + const expiresIn = codexCreds.expires - Date.now(); + results.push({ + source: "codex", + available: expiresIn > 0, + expires: codexCreds.expires, + expiresIn: formatDuration(expiresIn), + }); + } else { + results.push({ source: "codex", available: false }); + } + + return results; +} + +function formatDuration(ms: number): string { + if (ms <= 0) return "expired"; + const hours = Math.floor(ms / (60 * 60 * 1000)); + const minutes = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000)); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} diff --git a/src/agent/credentials/index.ts b/src/agent/credentials/index.ts new file mode 100644 index 00000000..9d740a39 --- /dev/null +++ b/src/agent/credentials/index.ts @@ -0,0 +1,2 @@ +export * from "./cli-credentials.js"; +export * from "./providers.js"; diff --git a/src/agent/credentials/providers.ts b/src/agent/credentials/providers.ts new file mode 100644 index 00000000..7a1d80a5 --- /dev/null +++ b/src/agent/credentials/providers.ts @@ -0,0 +1,271 @@ +/** + * Provider Management + * + * Manage LLM providers with support for: + * - API Key authentication (traditional) + * - OAuth authentication (Claude Code, Codex) + */ + +import { credentialManager } from "../credentials.js"; +import { + readClaudeCliCredentials, + readCodexCliCredentials, + hasValidClaudeCliCredentials, + hasValidCodexCliCredentials, + type ClaudeCliCredential, + type CodexCliCredential, +} from "./cli-credentials.js"; + +// ============================================================ +// Types +// ============================================================ + +export type AuthMethod = "api-key" | "oauth"; + +export interface ProviderInfo { + id: string; + name: string; + authMethod: AuthMethod; + available: boolean; + configured: boolean; + current: boolean; + models: string[]; + loginUrl?: string; + loginCommand?: string; +} + +export interface ProviderConfig { + provider: string; + model?: string; + apiKey?: string; + baseUrl?: string; + // OAuth specific + accessToken?: string; + refreshToken?: string; + expires?: number; +} + +// ============================================================ +// Provider Registry +// ============================================================ + +const PROVIDER_INFO: Record> = { + "anthropic": { + id: "anthropic", + name: "Anthropic (API Key)", + authMethod: "api-key", + models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-3-5-20241022"], + loginUrl: "https://console.anthropic.com/", + }, + "claude-code": { + id: "claude-code", + name: "Claude Code (OAuth)", + authMethod: "oauth", + models: ["claude-sonnet-4-20250514", "claude-opus-4-20250514"], + loginCommand: "claude login", + }, + "openai": { + id: "openai", + name: "OpenAI", + authMethod: "api-key", + models: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini"], + loginUrl: "https://platform.openai.com/api-keys", + }, + "openai-codex": { + id: "openai-codex", + name: "Codex (OAuth)", + authMethod: "oauth", + models: ["gpt-5.1", "gpt-5.1-codex-max"], + loginCommand: "codex login", + }, + "kimi-coding": { + id: "kimi-coding", + name: "Kimi Code", + authMethod: "api-key", + models: ["kimi-k2-thinking", "k2p5"], + loginUrl: "https://kimi.moonshot.cn/", + }, + "google": { + id: "google", + name: "Google AI", + authMethod: "api-key", + models: ["gemini-2.0-flash", "gemini-1.5-pro"], + loginUrl: "https://aistudio.google.com/apikey", + }, + "groq": { + id: "groq", + name: "Groq", + authMethod: "api-key", + models: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"], + loginUrl: "https://console.groq.com/keys", + }, + "mistral": { + id: "mistral", + name: "Mistral", + authMethod: "api-key", + models: ["mistral-large-latest", "codestral-latest"], + loginUrl: "https://console.mistral.ai/api-keys", + }, + "xai": { + id: "xai", + name: "xAI (Grok)", + authMethod: "api-key", + models: ["grok-beta", "grok-vision-beta"], + loginUrl: "https://console.x.ai/", + }, + "openrouter": { + id: "openrouter", + name: "OpenRouter", + authMethod: "api-key", + models: ["anthropic/claude-3.5-sonnet", "openai/gpt-4o"], + loginUrl: "https://openrouter.ai/keys", + }, +}; + +// ============================================================ +// Provider Status +// ============================================================ + +/** + * Check if a provider is configured with API key in credentials.json5 + */ +function isApiKeyConfigured(providerId: string): boolean { + const config = credentialManager.getLlmProviderConfig(providerId); + return !!config?.apiKey; +} + +/** + * Check if OAuth provider has valid credentials + */ +function isOAuthAvailable(providerId: string): boolean { + if (providerId === "claude-code") { + return hasValidClaudeCliCredentials(); + } + if (providerId === "openai-codex") { + return hasValidCodexCliCredentials(); + } + return false; +} + +/** + * Get current provider from credentials + */ +export function getCurrentProvider(): string { + return credentialManager.getLlmProvider() ?? "kimi-coding"; +} + +/** + * Get list of all providers with their status + */ +export function getProviderList(): ProviderInfo[] { + const currentProvider = getCurrentProvider(); + + return Object.values(PROVIDER_INFO).map((info) => { + const isOAuth = info.authMethod === "oauth"; + const available = isOAuth ? isOAuthAvailable(info.id) : isApiKeyConfigured(info.id); + const configured = isOAuth ? isOAuthAvailable(info.id) : isApiKeyConfigured(info.id); + + // Check if this is the current provider + // For claude-code, check if current is "anthropic" and OAuth is available + let isCurrent = currentProvider === info.id; + if (info.id === "claude-code" && currentProvider === "anthropic") { + // If anthropic is current and claude-code OAuth is available, mark both + isCurrent = hasValidClaudeCliCredentials(); + } + + return { + ...info, + available, + configured, + current: isCurrent, + }; + }); +} + +/** + * Get available providers only + */ +export function getAvailableProviders(): ProviderInfo[] { + return getProviderList().filter((p) => p.available); +} + +// ============================================================ +// Provider Resolution +// ============================================================ + +/** + * Get provider config for making API calls + */ +export function resolveProviderConfig(providerId: string): ProviderConfig | null { + const info = PROVIDER_INFO[providerId]; + if (!info) return null; + + if (info.authMethod === "oauth") { + if (providerId === "claude-code") { + const creds = readClaudeCliCredentials(); + if (!creds) return null; + + const accessToken = creds.type === "oauth" ? creds.access : creds.token; + return { + provider: "anthropic", // Use anthropic API + apiKey: accessToken, + accessToken, + refreshToken: creds.type === "oauth" ? creds.refresh : undefined, + expires: creds.expires, + }; + } + + if (providerId === "openai-codex") { + const creds = readCodexCliCredentials(); + if (!creds) return null; + + return { + provider: "openai-codex", + accessToken: creds.access, + refreshToken: creds.refresh, + expires: creds.expires, + }; + } + } + + // API Key based + const config = credentialManager.getLlmProviderConfig(providerId); + if (!config?.apiKey) return null; + + return { + provider: providerId, + model: config.model, + apiKey: config.apiKey, + baseUrl: config.baseUrl, + }; +} + +/** + * Format provider for display + */ +export function formatProviderStatus(provider: ProviderInfo): string { + const status = provider.available ? "✓" : "✗"; + const current = provider.current ? " (current)" : ""; + const auth = provider.authMethod === "oauth" ? " [OAuth]" : ""; + return `${status} ${provider.name}${auth}${current}`; +} + +/** + * Get login instructions for a provider + */ +export function getLoginInstructions(providerId: string): string { + const info = PROVIDER_INFO[providerId]; + if (!info) return `Unknown provider: ${providerId}`; + + if (info.authMethod === "oauth") { + if (info.loginCommand) { + return `Run: ${info.loginCommand}\nThen restart Super Multica to use the credentials.`; + } + } + + if (info.loginUrl) { + return `Get your API key at: ${info.loginUrl}\nThen add it to ~/.super-multica/credentials.json5`; + } + + return "No login instructions available."; +}