From 95d56ddf15a2ac49d4b3699b870d6976ef820ca4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 28 Mar 2026 23:54:50 +0700 Subject: [PATCH] fix(server): resolve claude from current windows path --- .../agent/provider-launch-config.test.ts | 34 +++++++++++++++ .../server/agent/provider-launch-config.ts | 43 ++++++++++++++++++- .../server/agent/providers/claude-agent.ts | 21 +++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 0fc5715af..85e12ff8a 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -134,6 +134,40 @@ describe("applyProviderEnv", () => { }); describe("findExecutable", () => { + test("on Windows, resolves executables using current machine and user PATH entries", () => { + findExecutableDependencies.platform = vi.fn(() => "win32"); + process.env.Path = "C:\\Windows\\System32"; + findExecutableDependencies.execFileSync.mockImplementation( + ((command: string, args?: string[]) => { + if (command === "powershell") { + return "C:\\Windows\\System32\r\nC:\\Users\\boudr\\.local\\bin\r\n"; + } + if (command === "where.exe") { + return "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n"; + } + throw new Error(`unexpected command ${command}`); + }) as any, + ); + + expect(findExecutable("claude", findExecutableDependencies)).toBe( + "C:\\Users\\boudr\\.local\\bin\\claude.exe", + ); + const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0]; + expect(powershellCall?.[0]).toBe("powershell"); + expect(powershellCall?.[1]).toContain("-NoProfile"); + expect(powershellCall?.[1]).toContain("-NonInteractive"); + expect(powershellCall?.[1]).toContain( + '$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }', + ); + const whereCall = findExecutableDependencies.execFileSync.mock.calls[1]; + expect(whereCall?.[0]).toBe("where.exe"); + expect(whereCall?.[1]).toEqual(["claude"]); + expect(whereCall?.[2]?.encoding).toBe("utf8"); + const env = whereCall?.[2]?.env as Record; + expect(env.PATH).toContain("C:\\Users\\boudr\\.local\\bin"); + expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin"); + }); + test("uses the last line from login-shell which output", () => { findExecutableDependencies.shell = "/bin/zsh"; findExecutableDependencies.execSync.mockReturnValue( diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 5da27bf1d..d03dba743 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -64,6 +64,33 @@ interface FindExecutableDependencies { shell: string | undefined; } +function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] { + try { + const output = deps.execFileSync( + "powershell", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + [ + '$machine = [Environment]::GetEnvironmentVariable("Path", "Machine")', + '$user = [Environment]::GetEnvironmentVariable("Path", "User")', + "if ($machine) { Write-Output $machine }", + "if ($user) { Write-Output $user }", + ].join("; "), + ], + { encoding: "utf8" }, + ); + return output + .split(/\r?\n/) + .flatMap((line) => line.split(";")) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } catch { + return []; + } +} + function resolveExecutableFromWhichOutput( name: string, output: string, @@ -186,7 +213,21 @@ export function findExecutable( if (deps.platform() === "win32") { try { - const out = deps.execSync(`where.exe ${trimmed}`, { encoding: "utf8" }).trim(); + const inheritedPath = process.env["Path"] ?? process.env["PATH"] ?? ""; + const resolvedPath = [ + ...inheritedPath.split(";"), + ...resolveWindowsPathEntries(deps), + ] + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .filter((entry, index, entries) => entries.indexOf(entry) === index) + .join(";"); + const env = { + ...process.env, + PATH: resolvedPath, + Path: resolvedPath, + }; + const out = deps.execFileSync("where.exe", [trimmed], { encoding: "utf8", env }).trim(); const firstLine = out.split(/\r?\n/)[0]?.trim(); return firstLine || null; } catch { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 47a426d01..9e2dd9237 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -258,6 +258,7 @@ type ClaudeOptionsLogSummary = { hasCanUseTool: boolean; hasSpawnOverride: boolean; hasStderrHandler: boolean; + pathToClaudeCodeExecutable: string | null; }; const MAX_RECENT_STDERR_CHARS = 4000; @@ -301,6 +302,10 @@ function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogS hasCanUseTool: typeof options.canUseTool === "function", hasSpawnOverride: typeof options.spawnClaudeCodeProcess === "function", hasStderrHandler: typeof options.stderr === "function", + pathToClaudeCodeExecutable: + typeof options.pathToClaudeCodeExecutable === "string" + ? options.pathToClaudeCodeExecutable + : null, }; } @@ -1863,6 +1868,22 @@ class ClaudeAgentSession implements AgentSession { .join("\n\n"); const claudeBinary = findExecutable("claude"); + this.logger.debug( + { + claudeBinary, + pathEnvKey: + process.env["Path"] !== undefined + ? "Path" + : process.env["PATH"] !== undefined + ? "PATH" + : null, + pathIncludesClaudeLocalBin: + (process.env["Path"] ?? process.env["PATH"] ?? "") + .toLowerCase() + .includes("\\.local\\bin"), + }, + "Resolved Claude executable", + ); const base: ClaudeOptions = { cwd: this.config.cwd, includePartialMessages: true,