fix: Windows platform improvements — re-enable Chroma, fix DB race, simplify env isolation

1. ProcessManager: Migrate spawnDaemon() from WMIC to PowerShell Start-Process
   - WMIC deprecated in Windows 11, PowerShell inherits env vars properly
   - Use -WindowStyle Hidden to prevent console popups
   - Fix redundant backslash escaping in PowerShell $_ variables

2. ChromaSync: Re-enable vector search on Windows
   - Remove overly defensive platform check that disabled all semantic search
   - Worker daemon starts with -WindowStyle Hidden; child processes inherit
   - MCP SDK's StdioClientTransport uses shell:false, no new console created

3. worker-service: Unified DB-ready gate middleware
   - Replace single-endpoint /api/sessions/init wait with global middleware
   - Hold all DB-dependent requests until database is initialized (30s timeout)
   - Whitelist static assets, /health, and viewer page for immediate response
   - Separate dbReadyPromise (DB only) from initializationComplete (full init)
   - Fixes "Database not initialized" errors on /stream, /summarize, /init

4. EnvManager: Switch from allowlist to blocklist for subprocess env
   - Only strip ANTHROPIC_API_KEY to prevent Issue #733 billing hijack
   - Pass through all other vars (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.)
   - Simpler, less fragile than maintaining an exhaustive system vars allowlist
This commit is contained in:
xingyu
2026-02-07 16:17:18 +08:00
parent dac989c697
commit e4e1d3fb92
3 changed files with 49 additions and 78 deletions
+13 -15
View File
@@ -101,7 +101,7 @@ export async function getChildProcesses(parentPid: number): Promise<number[]> {
try { try {
// PowerShell Get-Process instead of WMIC (deprecated in Windows 11) // 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 }); const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
// PowerShell outputs just numbers (one per line), simpler than WMIC's "ProcessId=1234" format // PowerShell outputs just numbers (one per line), simpler than WMIC's "ProcessId=1234" format
return stdout return stdout
@@ -226,10 +226,10 @@ export async function cleanupOrphanedProcesses(): Promise<void> {
if (isWindows) { if (isWindows) {
// Windows: Use PowerShell Get-CimInstance with JSON output for age filtering // Windows: Use PowerShell Get-CimInstance with JSON output for age filtering
const patternConditions = ORPHAN_PROCESS_PATTERNS const patternConditions = ORPHAN_PROCESS_PATTERNS
.map(p => `\\$_.CommandLine -like '*${p}*'`) .map(p => `$_.CommandLine -like '*${p}*'`)
.join(' -or '); .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 }); const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
if (!stdout.trim() || stdout.trim() === 'null') { if (!stdout.trim() || stdout.trim() === 'null') {
@@ -339,9 +339,9 @@ export async function cleanupOrphanedProcesses(): Promise<void> {
* Spawn a detached daemon process * Spawn a detached daemon process
* Returns the child PID or undefined if spawn failed * Returns the child PID or undefined if spawn failed
* *
* On Windows, uses WMIC to spawn a truly independent process that * On Windows, uses PowerShell Start-Process with -WindowStyle Hidden to spawn
* survives parent exit without console popups. WMIC creates processes * a truly independent process without console popups. Unlike WMIC, PowerShell
* that are not associated with the parent's console. * inherits environment variables from the parent process.
* *
* On Unix, uses standard detached spawn. * On Unix, uses standard detached spawn.
* *
@@ -361,21 +361,19 @@ export function spawnDaemon(
}; };
if (isWindows) { if (isWindows) {
// Use WMIC to spawn a process that's independent of the parent console // Use PowerShell Start-Process to spawn a hidden, independent process
// This avoids the console popup that occurs with detached: true // Unlike WMIC, PowerShell inherits environment variables from parent
// Paths must be individually quoted for WMIC when they contain spaces // -WindowStyle Hidden prevents console popup
const execPath = process.execPath; const execPath = process.execPath;
const script = scriptPath; const script = scriptPath;
// WMIC command format: wmic process call create "\"path1\" \"path2\" args" const psCommand = `Start-Process -FilePath '${execPath}' -ArgumentList '${script}','--daemon' -WindowStyle Hidden`;
const command = `wmic process call create "\\"${execPath}\\" \\"${script}\\" --daemon"`;
try { try {
execSync(command, { execSync(`powershell -NoProfile -Command "${psCommand}"`, {
stdio: 'ignore', 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; return 0;
} catch { } catch {
return undefined; return undefined;
+7 -21
View File
@@ -85,25 +85,15 @@ export class ChromaSync {
private readonly VECTOR_DB_DIR: string; private readonly VECTOR_DB_DIR: string;
private readonly BATCH_SIZE = 100; private readonly BATCH_SIZE = 100;
// Windows: Chroma disabled due to MCP SDK spawning console popups // Windows popup concern resolved: the worker daemon starts with -WindowStyle Hidden,
// See: https://github.com/anthropics/claude-mem/issues/675 // so child processes (uvx/chroma-mcp) inherit the hidden console and don't create new windows.
// Will be re-enabled when we migrate to persistent HTTP server // MCP SDK's StdioClientTransport uses shell:false and no detached flag, so console is inherited.
private readonly disabled: boolean; private readonly disabled: boolean = false;
constructor(project: string) { constructor(project: string) {
this.project = project; this.project = project;
this.collectionName = `cm__${project}`; this.collectionName = `cm__${project}`;
this.VECTOR_DB_DIR = path.join(os.homedir(), '.claude-mem', 'vector-db'); 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) // See: https://github.com/thedotmack/claude-mem/issues/170 (Python 3.14 incompatibility)
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH); const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const pythonVersion = settings.CLAUDE_MEM_PYTHON_VERSION; const pythonVersion = settings.CLAUDE_MEM_PYTHON_VERSION;
const isWindows = process.platform === 'win32';
// Get combined SSL certificate bundle for Zscaler/corporate proxy environments // Get combined SSL certificate bundle for Zscaler/corporate proxy environments
const combinedCertPath = this.getCombinedCertPath(); 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 is not needed here because the worker daemon starts with
// Note: windowsHide may not be supported by MCP SDK's StdioClientTransport // -WindowStyle Hidden, so child processes inherit the hidden console.
if (isWindows) { // The MCP SDK ignores custom windowsHide anyway (overridden internally).
transportOptions.windowsHide = true;
logger.debug('CHROMA_SYNC', 'Windows detected, attempting to hide console window', { project: this.project });
}
this.transport = new StdioClientTransport(transportOptions); this.transport = new StdioClientTransport(transportOptions);
+29 -42
View File
@@ -18,33 +18,15 @@ import { logger } from '../utils/logger.js';
const DATA_DIR = join(homedir(), '.claude-mem'); const DATA_DIR = join(homedir(), '.claude-mem');
export const ENV_FILE_PATH = join(DATA_DIR, '.env'); export const ENV_FILE_PATH = join(DATA_DIR, '.env');
// Essential system environment variables that subprocesses need to function // Environment variables to STRIP from subprocess environment (blocklist approach)
const ESSENTIAL_SYSTEM_VARS = [ // Only ANTHROPIC_API_KEY is stripped because it's the specific variable that causes
'PATH', // Issue #733: project .env files set ANTHROPIC_API_KEY which the SDK auto-discovers,
'HOME', // causing memory operations to bill personal API accounts instead of CLI subscription.
'USER', //
'SHELL', // All other env vars (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, system vars, etc.)
'TMPDIR', // are passed through to avoid breaking CLI authentication, proxies, and platform features.
'TMP', const BLOCKED_ENV_VARS = [
'TEMP', 'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
'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 // Credential keys that claude-mem manages
@@ -191,39 +173,44 @@ 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: * Uses a BLOCKLIST approach: inherits the full process environment but strips
* - Includes only essential system variables (PATH, HOME, etc.) * only ANTHROPIC_API_KEY to prevent Issue #733 (accidental billing from project .env files).
* - 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) * 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<string, string> { export function buildIsolatedEnv(includeCredentials: boolean = true): Record<string, string> {
// 1. Start with full process environment
const isolatedEnv: Record<string, string> = {}; const isolatedEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
// 1. Copy essential system variables from current process if (value !== undefined && !BLOCKED_ENV_VARS.includes(key)) {
for (const key of ESSENTIAL_SYSTEM_VARS) {
const value = process.env[key];
if (value !== undefined) {
isolatedEnv[key] = value; isolatedEnv[key] = value;
} }
} }
// 2. Add SDK entrypoint marker // 2. Override SDK entrypoint marker
isolatedEnv.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts'; 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) { if (includeCredentials) {
const credentials = loadClaudeMemEnv(); const credentials = loadClaudeMemEnv();
// Only add ANTHROPIC_API_KEY if explicitly configured in claude-mem // 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) { if (credentials.ANTHROPIC_API_KEY) {
isolatedEnv.ANTHROPIC_API_KEY = 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) { if (credentials.GEMINI_API_KEY) {
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY; isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
} }