From 54b5a22688bc0ab96db0b0f58fe167828e742b17 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 27 Mar 2026 20:50:18 +0700 Subject: [PATCH] fix(windows): broken PATH propagation, ps usage, and claude binary resolution - sherpa-runtime-env: use case-insensitive key lookup when modifying PATH on plain env objects. On Windows, `{...process.env}` stores PATH as `Path` but `applySherpaLoaderEnv` used hardcoded `"PATH"`, creating a duplicate key that could shadow the real system PATH in child processes. - runtime-toolchain: use `wmic` on Windows instead of Unix-only `ps -o` to resolve the node executable path from a PID. - claude-agent: pass `findExecutable("claude")` as `pathToClaudeCodeExecutable` so the SDK uses the user's installed binary when available. Update spawn hook to only replace bare "node"/"bun" with process.execPath, preserving native binary paths. - desktop/paseo.cmd: use ELECTRON_RUN_AS_NODE with node-entrypoint-runner for the Windows CLI wrapper. --- .../src/commands/daemon/runtime-toolchain.ts | 38 +++++++++++++++---- packages/desktop/bin/paseo.cmd | 5 ++- .../server/agent/providers/claude-agent.ts | 21 +++++++--- .../local/sherpa/sherpa-runtime-env.ts | 22 +++++++++-- 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/daemon/runtime-toolchain.ts b/packages/cli/src/commands/daemon/runtime-toolchain.ts index 86d23ccf0..ca4df8a3d 100644 --- a/packages/cli/src/commands/daemon/runtime-toolchain.ts +++ b/packages/cli/src/commands/daemon/runtime-toolchain.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { platform } from "node:os"; export interface NodePathFromPidResult { nodePath: string | null; @@ -12,17 +13,14 @@ function normalizeError(error: unknown): string { return String(error); } -export function resolveNodePathFromPid(pid: number): NodePathFromPidResult { +function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult { const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); if (result.error) { - return { - nodePath: null, - error: `ps failed: ${normalizeError(result.error)}`, - }; + return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` }; } if ((result.status ?? 1) !== 0) { @@ -34,12 +32,36 @@ export function resolveNodePathFromPid(pid: number): NodePathFromPidResult { } const resolved = result.stdout.trim(); - if (!resolved) { + return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" }; +} + +function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult { + const result = spawnSync( + "wmic", + ["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + + if (result.error) { + return { nodePath: null, error: `wmic failed: ${normalizeError(result.error)}` }; + } + + if ((result.status ?? 1) !== 0) { + const details = result.stderr?.trim(); return { nodePath: null, - error: "ps returned an empty command path", + error: details ? `wmic failed: ${details}` : `wmic exited with code ${result.status ?? 1}`, }; } - return { nodePath: resolved }; + // wmic output format: "ExecutablePath=C:\path\to\node.exe\r\n" + const match = result.stdout.match(/ExecutablePath=(.+)/); + const resolved = match?.[1]?.trim(); + return resolved ? { nodePath: resolved } : { nodePath: null, error: "wmic returned no executable path" }; +} + +export function resolveNodePathFromPid(pid: number): NodePathFromPidResult { + return platform() === "win32" + ? resolveNodePathFromPidWindows(pid) + : resolveNodePathFromPidUnix(pid); } diff --git a/packages/desktop/bin/paseo.cmd b/packages/desktop/bin/paseo.cmd index e0c4379ff..1e8321126 100644 --- a/packages/desktop/bin/paseo.cmd +++ b/packages/desktop/bin/paseo.cmd @@ -9,5 +9,6 @@ if not exist "%APP_EXECUTABLE%" ( exit /b 1 ) -set "PASEO_DESKTOP_CLI=1" -"%APP_EXECUTABLE%" %* +set "ELECTRON_RUN_AS_NODE=1" +"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %* +exit /b %errorlevel% diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index e38b2379a..47a426d01 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -67,7 +67,11 @@ import type { McpServerConfig, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; -import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js"; +import { + applyProviderEnv, + findExecutable, + type ProviderRuntimeSettings, +} from "../provider-launch-config.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; const fsPromises = promises; @@ -201,11 +205,14 @@ function applyRuntimeSettingsToClaudeOptions( ...options, spawnClaudeCodeProcess: (spawnOptions) => { const resolved = resolveClaudeSpawnCommand(spawnOptions, runtimeSettings); - // The SDK defaults to spawning "node" via PATH lookup, which fails when - // running from the managed runtime bundle where node isn't in PATH. - // Always use process.execPath — the actual node binary running the daemon. - const command = - resolved.command === spawnOptions.command ? process.execPath : resolved.command; + // When the SDK passes a default JS runtime ("node"/"bun"), replace it with + // process.execPath — the actual node binary running the daemon. This avoids + // PATH lookup failures in the managed runtime bundle. + // When the SDK passes a native binary path (from pathToClaudeCodeExecutable) + // or the user overrides the command via runtime settings, use that directly. + const isDefaultRuntime = + resolved.command === "node" || resolved.command === "bun"; + const command = isDefaultRuntime ? process.execPath : resolved.command; const child = spawn(command, resolved.args, { cwd: spawnOptions.cwd, env: { @@ -1855,12 +1862,14 @@ class ClaudeAgentSession implements AgentSession { .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) .join("\n\n"); + const claudeBinary = findExecutable("claude"); const base: ClaudeOptions = { cwd: this.config.cwd, includePartialMessages: true, permissionMode: this.currentMode, agents: this.defaults?.agents, canUseTool: this.handlePermissionRequest, + ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), // Use Claude Code preset system prompt and load CLAUDE.md files // Append provider-agnostic system prompt and orchestrator instructions for agents. systemPrompt: { diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts index 574e0bdbb..83844cc7f 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts @@ -70,6 +70,21 @@ export function resolveSherpaLoaderEnv( } } +/** + * Find the actual case-sensitive key in a plain object that matches the given + * key case-insensitively. On Windows, `{...process.env}` produces a plain + * (case-sensitive) object where PATH is typically stored as `Path`. Using a + * hardcoded `"PATH"` would miss the existing key and create a duplicate, + * breaking the child process's PATH. + */ +function findEnvKey(env: NodeJS.ProcessEnv, key: string): string { + const lower = key.toLowerCase(); + for (const k of Object.keys(env)) { + if (k.toLowerCase() === lower) return k; + } + return key; +} + export function applySherpaLoaderEnv( env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform, @@ -90,9 +105,10 @@ export function applySherpaLoaderEnv( }; } - const next = prependEnvPath(env[resolved.key], resolved.libDir); - const changed = next !== (env[resolved.key] ?? ""); - env[resolved.key] = next; + const actualKey = findEnvKey(env, resolved.key); + const next = prependEnvPath(env[actualKey], resolved.libDir); + const changed = next !== (env[actualKey] ?? ""); + env[actualKey] = next; return { changed, key: resolved.key,