diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 09c112b4..05d9632c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "claude-mem", - "version": "9.1.1", + "version": "10.0.1", "source": "./plugin", "description": "Persistent memory system for Claude Code - context compression across sessions" } diff --git a/CHANGELOG.md b/CHANGELOG.md index a9def6d0..8b3b5b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,102 @@ All notable changes to claude-mem. +## [v10.0.1] - 2026-02-11 + +## What's Changed + +### OpenClaw Observation Feed +- Enabled SSE observation feed for OpenClaw agent sessions, allowing real-time streaming of observations to connected OpenClaw clients +- Fixed `ObservationSSEPayload.project` type to be nullable, preventing type errors when project context is unavailable +- Added `EnvManager` support for OpenClaw environment configuration + +### Build Artifacts +- Rebuilt worker service and MCP server with latest changes + +## [v10.0.0] - 2026-02-11 + +## OpenClaw Plugin — Persistent Memory for OpenClaw Agents + +Claude-mem now has an official [OpenClaw](https://openclaw.ai) plugin, bringing persistent memory to agents running on the OpenClaw gateway. This is a major milestone — claude-mem's memory system is no longer limited to Claude Code sessions. + +### What It Does + +The plugin bridges claude-mem's observation pipeline with OpenClaw's embedded runner (`pi-embedded`), which calls the Anthropic API directly without spawning a `claude` process. Three core capabilities: + +1. **Observation Recording** — Captures every tool call from OpenClaw agents and sends it to the claude-mem worker for AI-powered compression and storage +2. **MEMORY.md Live Sync** — Writes a continuously-updated memory timeline to each agent's workspace, so agents start every session with full context from previous work +3. **Observation Feed** — Streams new observations to messaging channels (Telegram, Discord, Slack, Signal, WhatsApp, LINE) in real-time via SSE + +### Quick Start + +Add claude-mem to your OpenClaw gateway config: + +```json +{ + "plugins": { + "claude-mem": { + "enabled": true, + "config": { + "project": "my-project", + "syncMemoryFile": true, + "observationFeed": { + "enabled": true, + "channel": "telegram", + "to": "your-chat-id" + } + } + } + } +} +``` + +The claude-mem worker service must be running on the same machine (`localhost:37777`). + +### Commands + +- `/claude-mem-status` — Worker health check, active sessions, feed connection state +- `/claude-mem-feed` — Show/toggle observation feed status +- `/claude-mem-feed on|off` — Enable/disable feed + +### How the Event Lifecycle Works + +``` +OpenClaw Gateway + ├── session_start ──────────→ Init claude-mem session + ├── before_agent_start ─────→ Sync MEMORY.md + track workspace + ├── tool_result_persist ────→ Record observation + re-sync MEMORY.md + ├── agent_end ──────────────→ Summarize + complete session + ├── session_end ────────────→ Clean up session tracking + └── gateway_start ──────────→ Reset all tracking +``` + +All observation recording and MEMORY.md syncs are fire-and-forget — they never block the agent. + +📖 Full documentation: [OpenClaw Integration Guide](https://docs.claude-mem.ai/docs/openclaw-integration) + +--- + +## Windows Platform Improvements + +- **ProcessManager**: Migrated daemon spawning from deprecated WMIC to PowerShell `Start-Process` with `-WindowStyle Hidden` +- **ChromaSync**: Re-enabled vector search on Windows (was previously disabled entirely) +- **Worker Service**: Added unified DB-ready gate middleware — all DB-dependent endpoints now wait for initialization instead of returning "Database not initialized" errors +- **EnvManager**: Switched from fragile allowlist to simple blocklist for subprocess env vars (only strips `ANTHROPIC_API_KEY` per Issue #733) + +## Session Management Fixes + +- Fixed unbounded session tracking map growth — maps are now cleaned up on `session_end` +- Session init moved to `session_start` and `after_compaction` hooks for correct lifecycle handling + +## SSE Fixes + +- Fixed stream URL consistency across the codebase +- Fixed multi-line SSE data frame parsing (concatenates `data:` lines per SSE spec) + +## Issue Triage + +Closed 37+ duplicate/stale/invalid issues across multiple triage phases, significantly cleaning up the issue tracker. + ## [v9.1.1] - 2026-02-07 ## Critical Bug Fix: Worker Initialization Failure @@ -1410,56 +1506,3 @@ Set in ~/.claude-mem/settings.json: 🤖 Generated with [Claude Code](https://claude.com/claude-code) -## [v8.0.0] - 2025-12-23 - -## 🌍 Major Features - -### **Mode System**: Context-aware observation capture tailored to different workflows -- **Code Development mode** (default): Tracks bugfixes, features, refactors, and more -- **Email Investigation mode**: Optimized for email analysis workflows -- Extensible architecture for custom domains - -### **28 Language Support**: Full multilingual memory -- Arabic, Bengali, Chinese, Czech, Danish, Dutch, Finnish, French, German, Greek -- Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Norwegian, Polish -- Portuguese (Brazilian), Romanian, Russian, Spanish, Swedish, Thai, Turkish -- Ukrainian, Vietnamese -- All observations, summaries, and narratives generated in your chosen language - -### **Inheritance Architecture**: Language modes inherit from base modes -- Consistent observation types across languages -- Locale-specific output while maintaining structural integrity -- JSON-based configuration for easy customization - -## 🔧 Technical Improvements - -- **ModeManager**: Centralized mode loading and configuration validation -- **Dynamic Prompts**: SDK prompts now adapt based on active mode -- **Mode-Specific Icons**: Observation types display contextual icons/emojis per mode -- **Fail-Fast Error Handling**: Complete removal of silent failures across all layers - -## 📚 Documentation - -- New docs/public/modes.mdx documenting the mode system -- 28 translated README files for multilingual community support -- Updated configuration guide for mode selection - -## 🔨 Breaking Changes - -- **None** - Mode system is fully backward compatible -- Default mode is 'code' (existing behavior) -- Settings: New `CLAUDE_MEM_MODE` option (defaults to 'code') - ---- - -**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.4.5...v8.0.0 -**View PR**: https://github.com/thedotmack/claude-mem/pull/412 - -## [v7.4.5] - 2025-12-21 - -## Bug Fixes - -- Fix missing `formatDateTime` import in SearchManager that broke `get_context_timeline` mem-search function - -🤖 Generated with [Claude Code](https://claude.com/claude-code) - diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json index 6ba0b5a5..092420ce 100644 --- a/openclaw/openclaw.plugin.json +++ b/openclaw/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "claude-mem", "name": "Claude-Mem (Persistent Memory)", "description": "Official OpenClaw plugin for Claude-Mem. Records observations from embedded runner sessions and streams them to messaging channels.", - "kind": "memory", + "kind": "integration", "version": "1.0.0", "author": "thedotmack", "homepage": "https://claude-mem.com", diff --git a/openclaw/package.json b/openclaw/package.json index ad95ca25..e74ec764 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -1,5 +1,5 @@ { - "name": "@claude-mem/openclaw-plugin", + "name": "@openclaw/claude-mem", "version": "1.0.0", "private": true, "type": "module", @@ -11,5 +11,10 @@ "devDependencies": { "@types/node": "^25.2.1", "typescript": "^5.3.0" + }, + "openclaw": { + "extensions": [ + "./dist/index.js" + ] } } diff --git a/openclaw/src/index.test.ts b/openclaw/src/index.test.ts index 6b3a08d5..0c6bee6f 100644 --- a/openclaw/src/index.test.ts +++ b/openclaw/src/index.test.ts @@ -346,7 +346,7 @@ describe("Observation I/O event handlers", () => { assert.equal(initRequests.length, 1, "should re-init after compaction"); }); - it("before_agent_start does not call init", async () => { + it("before_agent_start calls init for session privacy check", async () => { const { api, fireEvent } = createMockApi({ workerPort }); claudeMemPlugin(api); @@ -354,7 +354,7 @@ describe("Observation I/O event handlers", () => { await new Promise((resolve) => setTimeout(resolve, 100)); const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init"); - assert.equal(initRequests.length, 0, "before_agent_start should not init"); + assert.equal(initRequests.length, 1, "before_agent_start should init session"); }); it("tool_result_persist sends observation to worker", async () => { diff --git a/openclaw/src/index.ts b/openclaw/src/index.ts index f750023d..ea33da74 100644 --- a/openclaw/src/index.ts +++ b/openclaw/src/index.ts @@ -124,7 +124,7 @@ interface ObservationSSEPayload { concepts: string | null; files_read: string | null; files_modified: string | null; - project: string; + project: string | null; prompt_number: number; created_at_epoch: number; } @@ -160,6 +160,44 @@ const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB const DEFAULT_WORKER_PORT = 37777; const TOOL_RESULT_MAX_LENGTH = 1000; +// Agent emoji map for observation feed messages. +// When creating a new OpenClaw agent, add its agentId and emoji here. +const AGENT_EMOJI_MAP: Record = { + "main": "🦞", + "openclaw": "🦞", + "devops": "🔧", + "architect": "📐", + "researcher": "🔍", + "code-reviewer": "🔎", + "coder": "💻", + "tester": "🧪", + "debugger": "🐛", + "opsec": "🛡️", + "cloudfarm": "☁️", + "extractor": "📦", +}; + +// Project prefixes that indicate Claude Code sessions (not OpenClaw agents) +const CLAUDE_CODE_EMOJI = "⌨️"; +const OPENCLAW_DEFAULT_EMOJI = "🦀"; + +function getSourceLabel(project: string | null | undefined): string { + if (!project) return OPENCLAW_DEFAULT_EMOJI; + // OpenClaw agent projects are formatted as "openclaw-" + if (project.startsWith("openclaw-")) { + const agentId = project.slice("openclaw-".length); + const emoji = AGENT_EMOJI_MAP[agentId] || OPENCLAW_DEFAULT_EMOJI; + return `${emoji} ${agentId}`; + } + // OpenClaw project without agent suffix + if (project === "openclaw") { + return `🦞 openclaw`; + } + // Everything else is from Claude Code (project = working directory name) + const emoji = CLAUDE_CODE_EMOJI; + return `${emoji} ${project}`; +} + // ============================================================================ // Worker HTTP Client // ============================================================================ @@ -233,7 +271,8 @@ async function workerGetText( function formatObservationMessage(observation: ObservationSSEPayload): string { const title = observation.title || "Untitled"; - let message = `🧠 Claude-Mem Observation\n**${title}**`; + const source = getSourceLabel(observation.project); + let message = `${source}\n**${title}**`; if (observation.subtitle) { message += `\n${observation.subtitle}`; } @@ -387,7 +426,14 @@ async function connectToSSEStream( export default function claudeMemPlugin(api: OpenClawPluginApi): void { const userConfig = (api.pluginConfig || {}) as ClaudeMemPluginConfig; const workerPort = userConfig.workerPort || DEFAULT_WORKER_PORT; - const projectName = userConfig.project || "openclaw"; + const baseProjectName = userConfig.project || "openclaw"; + + function getProjectName(ctx: EventContext): string { + if (ctx.agentId) { + return `openclaw-${ctx.agentId}`; + } + return baseProjectName; + } // ------------------------------------------------------------------ // Session tracking for observation I/O @@ -407,7 +453,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void { async function syncMemoryToWorkspace(workspaceDir: string): Promise { const contextText = await workerGetText( workerPort, - `/api/context/inject?projects=${encodeURIComponent(projectName)}`, + `/api/context/inject?projects=${encodeURIComponent(baseProjectName)}`, api.logger ); if (contextText && contextText.trim().length > 0) { @@ -429,7 +475,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void { await workerPost(workerPort, "/api/sessions/init", { contentSessionId, - project: projectName, + project: getProjectName(ctx), prompt: "", }, api.logger); @@ -444,7 +490,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void { await workerPost(workerPort, "/api/sessions/init", { contentSessionId, - project: projectName, + project: getProjectName(ctx), prompt: "", }, api.logger); @@ -452,7 +498,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void { }); // ------------------------------------------------------------------ - // Event: before_agent_start — sync MEMORY.md + track workspace + // Event: before_agent_start — init session + sync MEMORY.md + track workspace // ------------------------------------------------------------------ api.on("before_agent_start", async (_event, ctx) => { // Track workspace dir so tool_result_persist can sync MEMORY.md later @@ -460,6 +506,15 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void { workspaceDirsBySessionKey.set(ctx.sessionKey || "default", ctx.workspaceDir); } + // Initialize session in the worker so observations are not skipped + // (the privacy check requires a stored user prompt to exist) + const contentSessionId = getContentSessionId(ctx.sessionKey); + await workerPost(workerPort, "/api/sessions/init", { + contentSessionId, + project: getProjectName(ctx), + prompt: ctx.sessionKey || "agent run", + }, api.logger); + // Sync MEMORY.md before agent runs (provides context to agent) if (syncMemoryFile && ctx.workspaceDir) { await syncMemoryToWorkspace(ctx.workspaceDir); @@ -470,6 +525,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void { // Event: tool_result_persist — record tool observations + sync MEMORY.md // ------------------------------------------------------------------ api.on("tool_result_persist", (event, ctx) => { + api.logger.info(`[claude-mem] tool_result_persist fired: tool=${event.toolName ?? "unknown"} agent=${ctx.agentId ?? "none"} session=${ctx.sessionKey ?? "none"}`); const toolName = event.toolName; if (!toolName || toolName.startsWith("memory_")) return; @@ -527,7 +583,10 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void { } } - workerPostFireAndForget(workerPort, "/api/sessions/summarize", { + // Await summarize so the worker receives it before complete. + // This also gives in-flight tool_result_persist observations time to arrive + // (they use fire-and-forget and may still be in transit). + await workerPost(workerPort, "/api/sessions/summarize", { contentSessionId, last_assistant_message: lastAssistantMessage, }, api.logger); diff --git a/package.json b/package.json index 34ecbd9a..ad9368bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-mem", - "version": "9.1.1", + "version": "10.0.1", "description": "Memory compression system for Claude Code - persist context across sessions", "keywords": [ "claude", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 948f2854..95fae8c5 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "claude-mem", - "version": "9.1.1", + "version": "10.0.1", "description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions", "author": { "name": "Alex Newman" diff --git a/plugin/package.json b/plugin/package.json index d9efb1dd..1e3dffaa 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,6 +1,6 @@ { "name": "claude-mem-plugin", - "version": "9.1.1", + "version": "10.0.1", "private": true, "description": "Runtime dependencies for claude-mem bundled hooks", "type": "module", diff --git a/src/services/infrastructure/ProcessManager.ts b/src/services/infrastructure/ProcessManager.ts index 8d5a33cc..39f15e06 100644 --- a/src/services/infrastructure/ProcessManager.ts +++ b/src/services/infrastructure/ProcessManager.ts @@ -105,7 +105,7 @@ export async function getChildProcesses(parentPid: number): Promise { try { // PowerShell Get-Process instead of WMIC (deprecated in Windows 11) - const cmd = `powershell -NoProfile -NonInteractive -Command "Get-Process | Where-Object { \\$_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty Id"`; + const cmd = `powershell -NoProfile -NonInteractive -Command "Get-Process | Where-Object { $_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty Id"`; const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND }); // PowerShell outputs just numbers (one per line), simpler than WMIC's "ProcessId=1234" format return stdout @@ -230,10 +230,10 @@ export async function cleanupOrphanedProcesses(): Promise { if (isWindows) { // Windows: Use PowerShell Get-CimInstance with JSON output for age filtering const patternConditions = ORPHAN_PROCESS_PATTERNS - .map(p => `\\$_.CommandLine -like '*${p}*'`) + .map(p => `$_.CommandLine -like '*${p}*'`) .join(' -or '); - const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { (${patternConditions}) -and \\$_.ProcessId -ne ${currentPid} } | Select-Object ProcessId, CreationDate | ConvertTo-Json"`; + const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { (${patternConditions}) -and $_.ProcessId -ne ${currentPid} } | Select-Object ProcessId, CreationDate | ConvertTo-Json"`; const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND }); if (!stdout.trim() || stdout.trim() === 'null') { @@ -343,9 +343,9 @@ export async function cleanupOrphanedProcesses(): Promise { * Spawn a detached daemon process * Returns the child PID or undefined if spawn failed * - * On Windows, uses WMIC to spawn a truly independent process that - * survives parent exit without console popups. WMIC creates processes - * that are not associated with the parent's console. + * On Windows, uses PowerShell Start-Process with -WindowStyle Hidden to spawn + * a truly independent process without console popups. Unlike WMIC, PowerShell + * inherits environment variables from the parent process. * * On Unix, uses standard detached spawn. * @@ -365,21 +365,19 @@ export function spawnDaemon( }; if (isWindows) { - // Use WMIC to spawn a process that's independent of the parent console - // This avoids the console popup that occurs with detached: true - // Paths must be individually quoted for WMIC when they contain spaces + // Use PowerShell Start-Process to spawn a hidden, independent process + // Unlike WMIC, PowerShell inherits environment variables from parent + // -WindowStyle Hidden prevents console popup const execPath = process.execPath; const script = scriptPath; - // WMIC command format: wmic process call create "\"path1\" \"path2\" args" - const command = `wmic process call create "\\"${execPath}\\" \\"${script}\\" --daemon"`; + const psCommand = `Start-Process -FilePath '${execPath}' -ArgumentList '${script}','--daemon' -WindowStyle Hidden`; try { - execSync(command, { + execSync(`powershell -NoProfile -Command "${psCommand}"`, { stdio: 'ignore', - windowsHide: true + windowsHide: true, + env }); - // WMIC returns immediately, we can't get the spawned PID easily - // Worker will write its own PID file after listen() return 0; } catch { return undefined; diff --git a/src/services/sync/ChromaSync.ts b/src/services/sync/ChromaSync.ts index 98d2fa5b..95bb1018 100644 --- a/src/services/sync/ChromaSync.ts +++ b/src/services/sync/ChromaSync.ts @@ -85,25 +85,15 @@ export class ChromaSync { private readonly VECTOR_DB_DIR: string; private readonly BATCH_SIZE = 100; - // Windows: Chroma disabled due to MCP SDK spawning console popups - // See: https://github.com/anthropics/claude-mem/issues/675 - // Will be re-enabled when we migrate to persistent HTTP server - private readonly disabled: boolean; + // Windows popup concern resolved: the worker daemon starts with -WindowStyle Hidden, + // so child processes (uvx/chroma-mcp) inherit the hidden console and don't create new windows. + // MCP SDK's StdioClientTransport uses shell:false and no detached flag, so console is inherited. + private readonly disabled: boolean = false; constructor(project: string) { this.project = project; this.collectionName = `cm__${project}`; this.VECTOR_DB_DIR = path.join(os.homedir(), '.claude-mem', 'vector-db'); - - // Disable on Windows to prevent console popups from MCP subprocess spawning - // The MCP SDK's StdioClientTransport spawns Python processes that create visible windows - this.disabled = process.platform === 'win32'; - if (this.disabled) { - logger.warn('CHROMA_SYNC', 'Vector search disabled on Windows (prevents console popups)', { - project: this.project, - reason: 'MCP SDK subprocess spawning causes visible console windows' - }); - } } /** @@ -203,7 +193,6 @@ export class ChromaSync { // See: https://github.com/thedotmack/claude-mem/issues/170 (Python 3.14 incompatibility) const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH); const pythonVersion = settings.CLAUDE_MEM_PYTHON_VERSION; - const isWindows = process.platform === 'win32'; // Get combined SSL certificate bundle for Zscaler/corporate proxy environments const combinedCertPath = this.getCombinedCertPath(); @@ -232,12 +221,9 @@ export class ChromaSync { }); } - // CRITICAL: On Windows, try to hide console window to prevent PowerShell popups - // Note: windowsHide may not be supported by MCP SDK's StdioClientTransport - if (isWindows) { - transportOptions.windowsHide = true; - logger.debug('CHROMA_SYNC', 'Windows detected, attempting to hide console window', { project: this.project }); - } + // Note: windowsHide is not needed here because the worker daemon starts with + // -WindowStyle Hidden, so child processes inherit the hidden console. + // The MCP SDK ignores custom windowsHide anyway (overridden internally). this.transport = new StdioClientTransport(transportOptions); diff --git a/src/shared/EnvManager.ts b/src/shared/EnvManager.ts index 8c1518d7..6ec3c625 100644 --- a/src/shared/EnvManager.ts +++ b/src/shared/EnvManager.ts @@ -18,33 +18,15 @@ import { logger } from '../utils/logger.js'; 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', +// Environment variables to STRIP from subprocess environment (blocklist approach) +// Only ANTHROPIC_API_KEY is stripped because it's the specific variable that causes +// Issue #733: project .env files set ANTHROPIC_API_KEY which the SDK auto-discovers, +// causing memory operations to bill personal API accounts instead of CLI subscription. +// +// All other env vars (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, system vars, etc.) +// are passed through to avoid breaking CLI authentication, proxies, and platform features. +const BLOCKED_ENV_VARS = [ + 'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files ]; // Credential keys that claude-mem manages @@ -191,45 +173,58 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void { } /** - * Build a clean, isolated environment for spawning SDK subprocesses + * Build a clean 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 + * Uses a BLOCKLIST approach: inherits the full process environment but strips + * only ANTHROPIC_API_KEY to prevent Issue #733 (accidental billing from project .env files). * - * @param includeCredentials - Whether to include API keys (default: true) + * All other variables pass through, including: + * - ANTHROPIC_AUTH_TOKEN (CLI subscription auth) + * - ANTHROPIC_BASE_URL (custom proxy endpoints) + * - Platform-specific vars (USERPROFILE, XDG_*, etc.) + * + * If claude-mem has an explicit ANTHROPIC_API_KEY in ~/.claude-mem/.env, it's re-injected + * after stripping, so the managed credential takes precedence over any ambient value. + * + * @param includeCredentials - Whether to include API keys from ~/.claude-mem/.env (default: true) */ export function buildIsolatedEnv(includeCredentials: boolean = true): Record { + // 1. Start with full process environment 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) { + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !BLOCKED_ENV_VARS.includes(key)) { isolatedEnv[key] = value; } } - // 2. Add SDK entrypoint marker + // 2. Override SDK entrypoint marker isolatedEnv.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts'; - // 3. Add credentials from claude-mem's .env file (NOT from process.env) + // 3. Re-inject managed credentials from claude-mem's .env file 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 not configured, CLI billing will be used (via ANTHROPIC_AUTH_TOKEN passthrough) 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 + // Note: GEMINI_API_KEY and OPENROUTER_API_KEY pass through from process.env, + // but claude-mem's .env takes precedence if configured 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; } + + // 4. Pass through Claude CLI's OAuth token if available (fallback for CLI subscription billing) + // When no ANTHROPIC_API_KEY is configured, the spawned CLI uses subscription billing + // which requires either ~/.claude/.credentials.json or CLAUDE_CODE_OAUTH_TOKEN. + // The worker inherits this token from the Claude Code session that started it. + if (!isolatedEnv.ANTHROPIC_API_KEY && process.env.CLAUDE_CODE_OAUTH_TOKEN) { + isolatedEnv.CLAUDE_CODE_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN; + } } return isolatedEnv; @@ -270,5 +265,8 @@ export function getAuthMethodDescription(): string { if (hasAnthropicApiKey()) { return 'API key (from ~/.claude-mem/.env)'; } + if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { + return 'Claude Code OAuth token (from parent process)'; + } return 'Claude Code CLI (subscription billing)'; }