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:
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user