diff --git a/src/services/worker/GeminiAgent.ts b/src/services/worker/GeminiAgent.ts index 9674a2d7..654bf988 100644 --- a/src/services/worker/GeminiAgent.ts +++ b/src/services/worker/GeminiAgent.ts @@ -17,6 +17,7 @@ import { SessionManager } from './SessionManager.js'; import { logger } from '../../utils/logger.js'; import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js'; import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js'; +import { getCredential } from '../../shared/EnvManager.js'; import type { ActiveSession, ConversationMessage } from '../worker-types.js'; import { ModeManager } from '../domain/ModeManager.js'; import { @@ -367,13 +368,15 @@ export class GeminiAgent { /** * Get Gemini configuration from settings or environment + * Issue #733: Uses centralized ~/.claude-mem/.env for credentials, not random project .env files */ private getGeminiConfig(): { apiKey: string; model: GeminiModel; rateLimitingEnabled: boolean } { const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json'); const settings = SettingsDefaultsManager.loadFromFile(settingsPath); - // API key: check settings first, then environment variable - const apiKey = settings.CLAUDE_MEM_GEMINI_API_KEY || process.env.GEMINI_API_KEY || ''; + // API key: check settings first, then centralized claude-mem .env (NOT process.env) + // This prevents Issue #733 where random project .env files could interfere + const apiKey = settings.CLAUDE_MEM_GEMINI_API_KEY || getCredential('GEMINI_API_KEY') || ''; // Model: from settings or default, with validation const defaultModel: GeminiModel = 'gemini-2.5-flash'; @@ -407,11 +410,12 @@ export class GeminiAgent { /** * Check if Gemini is available (has API key configured) + * Issue #733: Uses centralized ~/.claude-mem/.env, not random project .env files */ export function isGeminiAvailable(): boolean { const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json'); const settings = SettingsDefaultsManager.loadFromFile(settingsPath); - return !!(settings.CLAUDE_MEM_GEMINI_API_KEY || process.env.GEMINI_API_KEY); + return !!(settings.CLAUDE_MEM_GEMINI_API_KEY || getCredential('GEMINI_API_KEY')); } /** diff --git a/src/services/worker/OpenRouterAgent.ts b/src/services/worker/OpenRouterAgent.ts index 195e35a4..b04d03ce 100644 --- a/src/services/worker/OpenRouterAgent.ts +++ b/src/services/worker/OpenRouterAgent.ts @@ -17,6 +17,7 @@ import { logger } from '../../utils/logger.js'; import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js'; import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js'; import { USER_SETTINGS_PATH } from '../../shared/paths.js'; +import { getCredential } from '../../shared/EnvManager.js'; import type { ActiveSession, ConversationMessage } from '../worker-types.js'; import { ModeManager } from '../domain/ModeManager.js'; import { @@ -409,13 +410,15 @@ export class OpenRouterAgent { /** * Get OpenRouter configuration from settings or environment + * Issue #733: Uses centralized ~/.claude-mem/.env for credentials, not random project .env files */ private getOpenRouterConfig(): { apiKey: string; model: string; siteUrl?: string; appName?: string } { const settingsPath = USER_SETTINGS_PATH; const settings = SettingsDefaultsManager.loadFromFile(settingsPath); - // API key: check settings first, then environment variable - const apiKey = settings.CLAUDE_MEM_OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY || ''; + // API key: check settings first, then centralized claude-mem .env (NOT process.env) + // This prevents Issue #733 where random project .env files could interfere + const apiKey = settings.CLAUDE_MEM_OPENROUTER_API_KEY || getCredential('OPENROUTER_API_KEY') || ''; // Model: from settings or default const model = settings.CLAUDE_MEM_OPENROUTER_MODEL || 'xiaomi/mimo-v2-flash:free'; @@ -430,11 +433,12 @@ export class OpenRouterAgent { /** * Check if OpenRouter is available (has API key configured) + * Issue #733: Uses centralized ~/.claude-mem/.env, not random project .env files */ export function isOpenRouterAvailable(): boolean { const settingsPath = USER_SETTINGS_PATH; const settings = SettingsDefaultsManager.loadFromFile(settingsPath); - return !!(settings.CLAUDE_MEM_OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY); + return !!(settings.CLAUDE_MEM_OPENROUTER_API_KEY || getCredential('OPENROUTER_API_KEY')); } /** diff --git a/src/services/worker/SDKAgent.ts b/src/services/worker/SDKAgent.ts index 0ae35e63..b4fcb3c4 100644 --- a/src/services/worker/SDKAgent.ts +++ b/src/services/worker/SDKAgent.ts @@ -17,6 +17,7 @@ import { logger } from '../../utils/logger.js'; import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js'; import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js'; import { USER_SETTINGS_PATH, OBSERVER_SESSIONS_DIR, ensureDir } from '../../shared/paths.js'; +import { buildIsolatedEnv, getAuthMethodDescription } from '../../shared/EnvManager.js'; import type { ActiveSession, SDKUserMessage } from '../worker-types.js'; import { ModeManager } from '../domain/ModeManager.js'; import { processAgentResponse, type WorkerRef } from './agents/index.js'; @@ -76,13 +77,20 @@ export class SDKAgent { // NEVER use contentSessionId for resume - that would inject messages into the user's transcript! const hasRealMemorySessionId = !!session.memorySessionId; + // Build isolated environment from ~/.claude-mem/.env + // This prevents Issue #733: random ANTHROPIC_API_KEY from project .env files + // being used instead of the configured auth method (CLI subscription or explicit API key) + const isolatedEnv = buildIsolatedEnv(); + const authMethod = getAuthMethodDescription(); + logger.info('SDK', 'Starting SDK query', { sessionDbId: session.sessionDbId, contentSessionId: session.contentSessionId, memorySessionId: session.memorySessionId, hasRealMemorySessionId, resume_parameter: hasRealMemorySessionId ? session.memorySessionId : '(none - fresh start)', - lastPromptNumber: session.lastPromptNumber + lastPromptNumber: session.lastPromptNumber, + authMethod }); // Debug-level alignment logs for detailed tracing @@ -103,6 +111,7 @@ export class SDKAgent { // Use custom spawn to capture PIDs for zombie process cleanup (Issue #737) // Use dedicated cwd to isolate observer sessions from user's `claude --resume` list ensureDir(OBSERVER_SESSIONS_DIR); + // CRITICAL: Pass isolated env to prevent Issue #733 (API key pollution from project .env files) const queryResult = query({ prompt: messageGenerator, options: { @@ -118,7 +127,8 @@ export class SDKAgent { abortController: session.abortController, pathToClaudeCodeExecutable: claudePath, // Custom spawn function captures PIDs to fix zombie process accumulation - spawnClaudeCodeProcess: createPidCapturingSpawn(session.sessionDbId) + spawnClaudeCodeProcess: createPidCapturingSpawn(session.sessionDbId), + env: isolatedEnv // Use isolated credentials from ~/.claude-mem/.env, not process.env } }); diff --git a/src/shared/EnvManager.ts b/src/shared/EnvManager.ts new file mode 100644 index 00000000..9a387605 --- /dev/null +++ b/src/shared/EnvManager.ts @@ -0,0 +1,273 @@ +/** + * EnvManager - Centralized environment variable management for claude-mem + * + * Provides isolated credential storage in ~/.claude-mem/.env + * This ensures claude-mem uses its own configured credentials, + * not random ANTHROPIC_API_KEY values from project .env files. + * + * Issue #733: SDK was auto-discovering API keys from user's shell environment, + * causing memory operations to bill personal API accounts instead of CLI subscription. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +// Path to claude-mem's centralized .env file +const DATA_DIR = join(homedir(), '.claude-mem'); +export const ENV_FILE_PATH = join(DATA_DIR, '.env'); + +// Essential system environment variables that subprocesses need to function +const ESSENTIAL_SYSTEM_VARS = [ + 'PATH', + 'HOME', + 'USER', + 'SHELL', + 'TMPDIR', + 'TMP', + 'TEMP', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + // Node.js specific + 'NODE_ENV', + 'NODE_PATH', + // Platform specific + 'SYSTEMROOT', // Windows + 'WINDIR', // Windows + 'PROGRAMFILES', // Windows + 'APPDATA', // Windows + 'LOCALAPPDATA', // Windows + 'XDG_RUNTIME_DIR', // Linux + 'XDG_CONFIG_HOME', // Linux + 'XDG_DATA_HOME', // Linux + // Claude Code specific (not credentials) + 'CLAUDE_CONFIG_DIR', + 'CLAUDE_CODE_DEBUG_LOGS_DIR', +]; + +// Credential keys that claude-mem manages +export const MANAGED_CREDENTIAL_KEYS = [ + 'ANTHROPIC_API_KEY', + 'GEMINI_API_KEY', + 'OPENROUTER_API_KEY', +]; + +export interface ClaudeMemEnv { + // Credentials (optional - empty means use CLI billing for Claude) + ANTHROPIC_API_KEY?: string; + GEMINI_API_KEY?: string; + OPENROUTER_API_KEY?: string; +} + +/** + * Parse a .env file content into key-value pairs + */ +function parseEnvFile(content: string): Record { + const result: Record = {}; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#')) continue; + + // Parse KEY=value format + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) continue; + + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + + // Remove surrounding quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + if (key) { + result[key] = value; + } + } + + return result; +} + +/** + * Serialize key-value pairs to .env file format + */ +function serializeEnvFile(env: Record): string { + const lines: string[] = [ + '# claude-mem credentials', + '# This file stores API keys for claude-mem memory agent', + '# Edit this file or use claude-mem settings to configure', + '', + ]; + + for (const [key, value] of Object.entries(env)) { + if (value) { + // Quote values that contain spaces or special characters + const needsQuotes = /[\s#=]/.test(value); + lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`); + } + } + + return lines.join('\n') + '\n'; +} + +/** + * Load credentials from ~/.claude-mem/.env + * Returns empty object if file doesn't exist (means use CLI billing) + */ +export function loadClaudeMemEnv(): ClaudeMemEnv { + if (!existsSync(ENV_FILE_PATH)) { + return {}; + } + + try { + const content = readFileSync(ENV_FILE_PATH, 'utf-8'); + const parsed = parseEnvFile(content); + + // Only return managed credential keys + const result: ClaudeMemEnv = {}; + if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY; + if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY; + if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY; + + return result; + } catch (error) { + console.warn('[EnvManager] Failed to load .env file:', error); + return {}; + } +} + +/** + * Save credentials to ~/.claude-mem/.env + */ +export function saveClaudeMemEnv(env: ClaudeMemEnv): void { + try { + // Ensure directory exists + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); + } + + // Load existing to preserve any extra keys + const existing = existsSync(ENV_FILE_PATH) + ? parseEnvFile(readFileSync(ENV_FILE_PATH, 'utf-8')) + : {}; + + // Update with new values + const updated: Record = { ...existing }; + + // Only update managed keys + if (env.ANTHROPIC_API_KEY !== undefined) { + if (env.ANTHROPIC_API_KEY) { + updated.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY; + } else { + delete updated.ANTHROPIC_API_KEY; + } + } + if (env.GEMINI_API_KEY !== undefined) { + if (env.GEMINI_API_KEY) { + updated.GEMINI_API_KEY = env.GEMINI_API_KEY; + } else { + delete updated.GEMINI_API_KEY; + } + } + if (env.OPENROUTER_API_KEY !== undefined) { + if (env.OPENROUTER_API_KEY) { + updated.OPENROUTER_API_KEY = env.OPENROUTER_API_KEY; + } else { + delete updated.OPENROUTER_API_KEY; + } + } + + writeFileSync(ENV_FILE_PATH, serializeEnvFile(updated), 'utf-8'); + } catch (error) { + console.error('[EnvManager] Failed to save .env file:', error); + throw error; + } +} + +/** + * Build a clean, isolated environment for spawning SDK subprocesses + * + * This is the key function that prevents Issue #733: + * - Includes only essential system variables (PATH, HOME, etc.) + * - Adds credentials ONLY from claude-mem's .env file + * - Does NOT inherit random ANTHROPIC_API_KEY from user's shell + * + * @param includeCredentials - Whether to include API keys (default: true) + */ +export function buildIsolatedEnv(includeCredentials: boolean = true): Record { + const isolatedEnv: Record = {}; + + // 1. Copy essential system variables from current process + for (const key of ESSENTIAL_SYSTEM_VARS) { + const value = process.env[key]; + if (value !== undefined) { + isolatedEnv[key] = value; + } + } + + // 2. Add SDK entrypoint marker + isolatedEnv.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts'; + + // 3. Add credentials from claude-mem's .env file (NOT from process.env) + if (includeCredentials) { + const credentials = loadClaudeMemEnv(); + + // Only add ANTHROPIC_API_KEY if explicitly configured in claude-mem + // If not configured, CLI billing will be used (via pathToClaudeCodeExecutable) + if (credentials.ANTHROPIC_API_KEY) { + isolatedEnv.ANTHROPIC_API_KEY = credentials.ANTHROPIC_API_KEY; + } + // Note: GEMINI_API_KEY and OPENROUTER_API_KEY are handled by their respective agents + if (credentials.GEMINI_API_KEY) { + isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY; + } + if (credentials.OPENROUTER_API_KEY) { + isolatedEnv.OPENROUTER_API_KEY = credentials.OPENROUTER_API_KEY; + } + } + + return isolatedEnv; +} + +/** + * Get a specific credential from claude-mem's .env + * Returns undefined if not set (which means use default/CLI billing) + */ +export function getCredential(key: keyof ClaudeMemEnv): string | undefined { + const env = loadClaudeMemEnv(); + return env[key]; +} + +/** + * Set a specific credential in claude-mem's .env + * Pass empty string to remove the credential + */ +export function setCredential(key: keyof ClaudeMemEnv, value: string): void { + const env = loadClaudeMemEnv(); + env[key] = value || undefined; + saveClaudeMemEnv(env); +} + +/** + * Check if claude-mem has an Anthropic API key configured + * If false, it means CLI billing should be used + */ +export function hasAnthropicApiKey(): boolean { + const env = loadClaudeMemEnv(); + return !!env.ANTHROPIC_API_KEY; +} + +/** + * Get auth method description for logging + */ +export function getAuthMethodDescription(): string { + if (hasAnthropicApiKey()) { + return 'API key (from ~/.claude-mem/.env)'; + } + return 'Claude Code CLI (subscription billing)'; +} diff --git a/src/shared/SettingsDefaultsManager.ts b/src/shared/SettingsDefaultsManager.ts index f256504e..a5c8571e 100644 --- a/src/shared/SettingsDefaultsManager.ts +++ b/src/shared/SettingsDefaultsManager.ts @@ -20,6 +20,7 @@ export interface SettingsDefaults { CLAUDE_MEM_SKIP_TOOLS: string; // AI Provider Configuration CLAUDE_MEM_PROVIDER: string; // 'claude' | 'gemini' | 'openrouter' + CLAUDE_MEM_CLAUDE_AUTH_METHOD: string; // 'cli' | 'api' - how Claude provider authenticates CLAUDE_MEM_GEMINI_API_KEY: string; CLAUDE_MEM_GEMINI_MODEL: string; // 'gemini-2.5-flash-lite' | 'gemini-2.5-flash' | 'gemini-3-flash' CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string; // 'true' | 'false' - enable rate limiting for free tier @@ -64,6 +65,7 @@ export class SettingsDefaultsManager { CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion', // AI Provider Configuration CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude + CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli', // Default to CLI subscription billing (not API key) CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM) CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users