import { execSync, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; const log = createSubsystemLogger("agents/auth-profiles"); const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json"; const CODEX_CLI_AUTH_FILENAME = "auth.json"; const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json"; const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code"; type CachedValue = { value: T | null; readAt: number; cacheKey: string; }; let claudeCliCache: CachedValue | null = null; let codexCliCache: CachedValue | null = null; let qwenCliCache: CachedValue | null = null; export function resetCliCredentialCachesForTest(): void { claudeCliCache = null; codexCliCache = null; qwenCliCache = null; } export type ClaudeCliCredential = | { type: "oauth"; provider: "anthropic"; access: string; refresh: string; expires: number; } | { type: "token"; provider: "anthropic"; token: string; expires: number; }; export type CodexCliCredential = { type: "oauth"; provider: OAuthProvider; access: string; refresh: string; expires: number; accountId?: string; }; export type QwenCliCredential = { type: "oauth"; provider: "qwen-portal"; access: string; refresh: string; expires: number; }; type ClaudeCliFileOptions = { homeDir?: string; }; type ClaudeCliWriteOptions = ClaudeCliFileOptions & { platform?: NodeJS.Platform; writeKeychain?: (credentials: OAuthCredentials) => boolean; writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean; }; type ExecSyncFn = typeof execSync; // Secure keychain operations using spawnSync to prevent shell injection function secureKeychainFind(service: string, account?: string): string | null { const args = ["find-generic-password", "-s", service]; if (account) { args.push("-a", account); } args.push("-w"); const result = spawnSync("security", args, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }); if (result.status !== 0 || result.error) { return null; } return result.stdout?.trim() ?? null; } function secureKeychainWrite(service: string, account: string, value: string): boolean { const result = spawnSync( "security", ["add-generic-password", "-U", "-s", service, "-a", account, "-w", value], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }, ); return result.status === 0 && !result.error; } function resolveClaudeCliCredentialsPath(homeDir?: string) { const baseDir = homeDir ?? resolveUserPath("~"); return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH); } function resolveCodexCliAuthPath() { return path.join(resolveCodexHomePath(), CODEX_CLI_AUTH_FILENAME); } function resolveCodexHomePath() { const configured = process.env.CODEX_HOME; const home = configured ? resolveUserPath(configured) : resolveUserPath("~/.codex"); try { return fs.realpathSync.native(home); } catch { return home; } } function resolveQwenCliCredentialsPath(homeDir?: string) { const baseDir = homeDir ?? resolveUserPath("~"); return path.join(baseDir, QWEN_CLI_CREDENTIALS_RELATIVE_PATH); } function computeCodexKeychainAccount(codexHome: string) { const hash = createHash("sha256").update(codexHome).digest("hex"); return `cli|${hash.slice(0, 16)}`; } function readCodexKeychainCredentials(options?: { platform?: NodeJS.Platform; execSync?: ExecSyncFn; }): CodexCliCredential | null { const platform = options?.platform ?? process.platform; if (platform !== "darwin") return null; // Note: execSync option preserved for test mocking but secure helper used by default const _execSyncImpl = options?.execSync; const codexHome = resolveCodexHomePath(); const account = computeCodexKeychainAccount(codexHome); try { // Use secure helper to prevent shell injection const secret = _execSyncImpl ? _execSyncImpl(`security find-generic-password -s "Codex Auth" -a "${account}" -w`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }).trim() : secureKeychainFind("Codex Auth", account); if (!secret) return null; const parsed = JSON.parse(secret) as Record; const tokens = parsed.tokens as Record | undefined; const accessToken = tokens?.access_token; const refreshToken = tokens?.refresh_token; if (typeof accessToken !== "string" || !accessToken) return null; if (typeof refreshToken !== "string" || !refreshToken) return null; // No explicit expiry stored; treat as fresh for an hour from last_refresh or now. 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; const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined; log.info("read codex credentials from keychain", { source: "keychain", expires: new Date(expires).toISOString(), }); return { type: "oauth", provider: "openai-codex" as OAuthProvider, access: accessToken, refresh: refreshToken, expires, accountId, }; } catch { return null; } } function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredential | null { const credPath = resolveQwenCliCredentialsPath(options?.homeDir); const raw = loadJsonFile(credPath); if (!raw || typeof raw !== "object") return null; const data = raw as Record; const accessToken = data.access_token; const refreshToken = data.refresh_token; const expiresAt = data.expiry_date; if (typeof accessToken !== "string" || !accessToken) return null; if (typeof refreshToken !== "string" || !refreshToken) return null; if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return null; return { type: "oauth", provider: "qwen-portal", access: accessToken, refresh: refreshToken, expires: expiresAt, }; } function readClaudeCliKeychainCredentials(execSyncImpl?: ExecSyncFn): ClaudeCliCredential | null { try { // Use secure helper to prevent shell injection (unless test mock provided) const result = execSyncImpl ? execSyncImpl(`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }) : secureKeychainFind(CLAUDE_CLI_KEYCHAIN_SERVICE); if (!result) return null; const data = JSON.parse(typeof result === "string" ? result.trim() : result); 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; } } export function readClaudeCliCredentials(options?: { allowKeychainPrompt?: boolean; platform?: NodeJS.Platform; homeDir?: string; execSync?: ExecSyncFn; }): ClaudeCliCredential | null { const platform = options?.platform ?? process.platform; if (platform === "darwin" && options?.allowKeychainPrompt !== false) { const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync); if (keychainCreds) { log.info("read anthropic credentials from claude cli keychain", { type: keychainCreds.type, }); return keychainCreds; } } const credPath = resolveClaudeCliCredentialsPath(options?.homeDir); const raw = loadJsonFile(credPath); if (!raw || typeof raw !== "object") return null; const data = raw as Record; const claudeOauth = data.claudeAiOauth as Record | undefined; 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, }; } export function readClaudeCliCredentialsCached(options?: { allowKeychainPrompt?: boolean; ttlMs?: number; platform?: NodeJS.Platform; homeDir?: string; execSync?: ExecSyncFn; }): ClaudeCliCredential | null { const ttlMs = options?.ttlMs ?? 0; const now = Date.now(); const cacheKey = resolveClaudeCliCredentialsPath(options?.homeDir); if ( ttlMs > 0 && claudeCliCache && claudeCliCache.cacheKey === cacheKey && now - claudeCliCache.readAt < ttlMs ) { return claudeCliCache.value; } const value = readClaudeCliCredentials({ allowKeychainPrompt: options?.allowKeychainPrompt, platform: options?.platform, homeDir: options?.homeDir, execSync: options?.execSync, }); if (ttlMs > 0) { claudeCliCache = { value, readAt: now, cacheKey }; } return value; } export function writeClaudeCliKeychainCredentials( newCredentials: OAuthCredentials, options?: { execSync?: ExecSyncFn }, ): boolean { const execSyncImpl = options?.execSync; try { // Use secure helper to prevent shell injection (unless test mock provided) const existingResult = execSyncImpl ? execSyncImpl( `security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, ) : secureKeychainFind(CLAUDE_CLI_KEYCHAIN_SERVICE); if (!existingResult) return false; const existingData = JSON.parse( typeof existingResult === "string" ? existingResult.trim() : existingResult, ); const existingOauth = existingData?.claudeAiOauth; if (!existingOauth || typeof existingOauth !== "object") { return false; } existingData.claudeAiOauth = { ...existingOauth, accessToken: newCredentials.access, refreshToken: newCredentials.refresh, expiresAt: newCredentials.expires, }; const newValue = JSON.stringify(existingData); // Use secure helper for write (unless test mock provided) if (execSyncImpl) { execSyncImpl( `security add-generic-password -U -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -a "${CLAUDE_CLI_KEYCHAIN_ACCOUNT}" -w '${newValue.replace(/'/g, "'\"'\"'")}'`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, ); } else { const writeOk = secureKeychainWrite( CLAUDE_CLI_KEYCHAIN_SERVICE, CLAUDE_CLI_KEYCHAIN_ACCOUNT, newValue, ); if (!writeOk) { log.warn("failed to write credentials to claude cli keychain via secure helper"); return false; } } log.info("wrote refreshed credentials to claude cli keychain", { expires: new Date(newCredentials.expires).toISOString(), }); return true; } catch (error) { log.warn("failed to write credentials to claude cli keychain", { error: error instanceof Error ? error.message : String(error), }); return false; } } export function writeClaudeCliFileCredentials( newCredentials: OAuthCredentials, options?: ClaudeCliFileOptions, ): boolean { const credPath = resolveClaudeCliCredentialsPath(options?.homeDir); if (!fs.existsSync(credPath)) { return false; } try { const raw = loadJsonFile(credPath); if (!raw || typeof raw !== "object") return false; const data = raw as Record; const existingOauth = data.claudeAiOauth as Record | undefined; if (!existingOauth || typeof existingOauth !== "object") return false; data.claudeAiOauth = { ...existingOauth, accessToken: newCredentials.access, refreshToken: newCredentials.refresh, expiresAt: newCredentials.expires, }; saveJsonFile(credPath, data); log.info("wrote refreshed credentials to claude cli file", { expires: new Date(newCredentials.expires).toISOString(), }); return true; } catch (error) { log.warn("failed to write credentials to claude cli file", { error: error instanceof Error ? error.message : String(error), }); return false; } } export function writeClaudeCliCredentials( newCredentials: OAuthCredentials, options?: ClaudeCliWriteOptions, ): boolean { const platform = options?.platform ?? process.platform; const writeKeychain = options?.writeKeychain ?? writeClaudeCliKeychainCredentials; const writeFile = options?.writeFile ?? ((credentials, fileOptions) => writeClaudeCliFileCredentials(credentials, fileOptions)); if (platform === "darwin") { const didWriteKeychain = writeKeychain(newCredentials); if (didWriteKeychain) { return true; } } return writeFile(newCredentials, { homeDir: options?.homeDir }); } export function readCodexCliCredentials(options?: { platform?: NodeJS.Platform; execSync?: ExecSyncFn; }): CodexCliCredential | null { const keychain = readCodexKeychainCredentials({ platform: options?.platform, execSync: options?.execSync, }); if (keychain) return keychain; const authPath = resolveCodexCliAuthPath(); const raw = loadJsonFile(authPath); if (!raw || typeof raw !== "object") return null; const data = raw as Record; const tokens = data.tokens as Record | undefined; 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" as OAuthProvider, access: accessToken, refresh: refreshToken, expires, accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined, }; } export function readCodexCliCredentialsCached(options?: { ttlMs?: number; platform?: NodeJS.Platform; execSync?: ExecSyncFn; }): CodexCliCredential | null { const ttlMs = options?.ttlMs ?? 0; const now = Date.now(); const cacheKey = `${options?.platform ?? process.platform}|${resolveCodexCliAuthPath()}`; if ( ttlMs > 0 && codexCliCache && codexCliCache.cacheKey === cacheKey && now - codexCliCache.readAt < ttlMs ) { return codexCliCache.value; } const value = readCodexCliCredentials({ platform: options?.platform, execSync: options?.execSync, }); if (ttlMs > 0) { codexCliCache = { value, readAt: now, cacheKey }; } return value; } export function readQwenCliCredentialsCached(options?: { ttlMs?: number; homeDir?: string; }): QwenCliCredential | null { const ttlMs = options?.ttlMs ?? 0; const now = Date.now(); const cacheKey = resolveQwenCliCredentialsPath(options?.homeDir); if ( ttlMs > 0 && qwenCliCache && qwenCliCache.cacheKey === cacheKey && now - qwenCliCache.readAt < ttlMs ) { return qwenCliCache.value; } const value = readQwenCliCredentials({ homeDir: options?.homeDir }); if (ttlMs > 0) { qwenCliCache = { value, readAt: now, cacheKey }; } return value; }