import { execSync } 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.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 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; 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; }; type ClaudeCliFileOptions = { homeDir?: string; }; type ClaudeCliWriteOptions = ClaudeCliFileOptions & { platform?: NodeJS.Platform; writeKeychain?: (credentials: OAuthCredentials) => boolean; writeFile?: ( credentials: OAuthCredentials, options?: ClaudeCliFileOptions, ) => boolean; }; 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 computeCodexKeychainAccount(codexHome: string) { const hash = createHash("sha256").update(codexHome).digest("hex"); return `cli|${hash.slice(0, 16)}`; } function readCodexKeychainCredentials(options?: { platform?: NodeJS.Platform; }): CodexCliCredential | null { const platform = options?.platform ?? process.platform; if (platform !== "darwin") return null; const codexHome = resolveCodexHomePath(); const account = computeCodexKeychainAccount(codexHome); try { const secret = execSync( `security find-generic-password -s "Codex Auth" -a "${account}" -w`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, ).trim(); 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; 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, }; } catch { return null; } } function readClaudeCliKeychainCredentials(): ClaudeCliCredential | 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; } } export function readClaudeCliCredentials(options?: { allowKeychainPrompt?: boolean; platform?: NodeJS.Platform; homeDir?: string; }): ClaudeCliCredential | null { const platform = options?.platform ?? process.platform; if (platform === "darwin" && options?.allowKeychainPrompt !== false) { const keychainCreds = readClaudeCliKeychainCredentials(); 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; }): 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, }); if (ttlMs > 0) { claudeCliCache = { value, readAt: now, cacheKey }; } return value; } export function writeClaudeCliKeychainCredentials( newCredentials: OAuthCredentials, ): boolean { try { const existingResult = execSync( `security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w 2>/dev/null`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, ); const existingData = JSON.parse(existingResult.trim()); 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); execSync( `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"] }, ); 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; }): CodexCliCredential | null { const keychain = readCodexKeychainCredentials({ platform: options?.platform, }); 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, }; } export function readCodexCliCredentialsCached(options?: { ttlMs?: number; platform?: NodeJS.Platform; }): 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 }); if (ttlMs > 0) { codexCliCache = { value, readAt: now, cacheKey }; } return value; }