Skip to content

Commit 52d42da

Browse files
fix: resolve Windows CLI spawn ENOENT error in Agent Manager (#4584)
* fix: resolve Windows CLI spawn ENOENT error in Agent Manager Replace shell command-based CLI detection with filesystem-based executable resolution using PATHEXT environment variable. * fix: address PR review comments - Use error.code instead of error.message for EACCES detection - Rename fileExistsAsFile to pathExistsAsFile - Remove redundant isSymbolicLink check (stat follows symlinks) - Add clarifying comment about symlink behavior * chore: restore slackbot.md to match main * fix: add missing getPlatformDiagnostics mock in AgentManagerProvider tests * fix: use platform-appropriate paths in Windows tests * fix: separate Windows simulation tests from native Windows tests - Skip platform-switching tests when already on target platform - Add dedicated native Windows tests that run only on Windows CI - Add proper lstat mock to fs mocks (code uses both stat and lstat) - Use proper error codes in mock rejections * fix: remove platform simulation tests, keep only native platform tests Platform simulation (mocking process.platform) is fragile and doesn't truly test platform-specific behavior. Instead: - Windows tests run only on Windows CI (skipped elsewhere) - Non-Windows tests run only on non-Windows (skipped on Windows) - Let actual CI environments test their native platform behavior * fix: simplify tests by removing fragile Windows integration test The Windows .cmd shell:true behavior is already tested in CliProcessHandler. The PATHEXT resolution is tested in CliPathResolver.spec.ts. Production code works on Windows (confirmed), so remove complex integration test. * fix: skip Unix path tests on Windows Unix-style paths like /usr/bin/kilocode are not absolute on Windows (Windows requires drive letters like C:\). Skip these tests on Windows since the Windows-specific behavior is already tested by the PATHEXT tests. * fix: mock CliInstaller in tests to work on Windows On Windows, login shell is skipped and findExecutable uses fs.promises.stat instead of execSync. The tests were relying on execSync returning MOCK_CLI_PATH, which doesn't work on Windows. Fix: Mock getLocalCliPath() to return MOCK_CLI_PATH and make fileExistsAtPath return true for that path. This ensures findKilocodeCli finds the CLI via the local path check on all platforms.
1 parent 074f311 commit 52d42da

File tree

5 files changed

+328
-136
lines changed

5 files changed

+328
-136
lines changed

src/core/kilocode/agent-manager/CliPathResolver.ts

Lines changed: 101 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,97 @@ import { execSync } from "node:child_process"
44
import { fileExistsAtPath } from "../../../utils/fs"
55
import { getLocalCliPath } from "./CliInstaller"
66

7+
/**
8+
* Case-insensitive lookup for environment variables.
9+
* Windows environment variables can have inconsistent casing (PATH, Path, path).
10+
*/
11+
function getCaseInsensitive(target: NodeJS.ProcessEnv, key: string): string | undefined {
12+
const lowercaseKey = key.toLowerCase()
13+
const equivalentKey = Object.keys(target).find((k) => k.toLowerCase() === lowercaseKey)
14+
return equivalentKey ? target[equivalentKey] : target[key]
15+
}
16+
17+
/**
18+
* Check if a path exists and is a file (not a directory).
19+
* Follows symlinks - a symlink to a file returns true, symlink to a directory returns false.
20+
*/
21+
async function pathExistsAsFile(filePath: string): Promise<boolean> {
22+
try {
23+
const stat = await fs.promises.stat(filePath)
24+
return stat.isFile()
25+
} catch (e: unknown) {
26+
if (e instanceof Error && "code" in e && e.code === "EACCES") {
27+
try {
28+
const lstat = await fs.promises.lstat(filePath)
29+
return lstat.isFile() || lstat.isSymbolicLink()
30+
} catch {
31+
return false
32+
}
33+
}
34+
return false
35+
}
36+
}
37+
38+
/**
39+
* Find an executable by name, resolving it against PATH and PATHEXT (on Windows).
40+
*/
41+
export async function findExecutable(
42+
command: string,
43+
cwd?: string,
44+
paths?: string[],
45+
env: NodeJS.ProcessEnv = process.env,
46+
): Promise<string | undefined> {
47+
if (path.isAbsolute(command)) {
48+
return (await pathExistsAsFile(command)) ? command : undefined
49+
}
50+
51+
if (cwd === undefined) {
52+
cwd = process.cwd()
53+
}
54+
55+
const dir = path.dirname(command)
56+
if (dir !== ".") {
57+
const fullPath = path.join(cwd, command)
58+
return (await pathExistsAsFile(fullPath)) ? fullPath : undefined
59+
}
60+
61+
const envPath = getCaseInsensitive(env, "PATH")
62+
if (paths === undefined && typeof envPath === "string") {
63+
paths = envPath.split(path.delimiter)
64+
}
65+
66+
if (paths === undefined || paths.length === 0) {
67+
const fullPath = path.join(cwd, command)
68+
return (await pathExistsAsFile(fullPath)) ? fullPath : undefined
69+
}
70+
71+
for (const pathEntry of paths) {
72+
let fullPath: string
73+
if (path.isAbsolute(pathEntry)) {
74+
fullPath = path.join(pathEntry, command)
75+
} else {
76+
fullPath = path.join(cwd, pathEntry, command)
77+
}
78+
79+
if (process.platform === "win32") {
80+
const pathExt = getCaseInsensitive(env, "PATHEXT") || ".COM;.EXE;.BAT;.CMD"
81+
for (const ext of pathExt.split(";")) {
82+
const withExtension = fullPath + ext
83+
if (await pathExistsAsFile(withExtension)) {
84+
return withExtension
85+
}
86+
}
87+
}
88+
89+
if (await pathExistsAsFile(fullPath)) {
90+
return fullPath
91+
}
92+
}
93+
94+
const fullPath = path.join(cwd, command)
95+
return (await pathExistsAsFile(fullPath)) ? fullPath : undefined
96+
}
97+
798
/**
899
* Find the kilocode CLI executable.
9100
*
@@ -12,7 +103,7 @@ import { getLocalCliPath } from "./CliInstaller"
12103
* 2. Workspace-local build at <workspace>/cli/dist/index.js
13104
* 3. Local installation at ~/.kilocode/cli/pkg (for immutable systems like NixOS)
14105
* 4. Login shell lookup (respects user's nvm, fnm, volta, asdf config)
15-
* 5. Direct PATH lookup (fallback for system-wide installs)
106+
* 5. Direct PATH lookup using findExecutable (handles PATHEXT on Windows)
16107
* 6. Common npm installation paths (last resort)
17108
*
18109
* IMPORTANT: Login shell is checked BEFORE direct PATH because:
@@ -50,22 +141,23 @@ export async function findKilocodeCli(log?: (msg: string) => void): Promise<stri
50141
}
51142

52143
// 3) Check local installation (for immutable systems like NixOS)
53-
// This is checked early because it's a deliberate user choice for systems that can't use global install
54144
const localCliPath = getLocalCliPath()
55145
if (await fileExistsAtPath(localCliPath)) {
56146
log?.(`Found local CLI installation: ${localCliPath}`)
57147
return localCliPath
58148
}
59149

60150
// 4) Try login shell FIRST to pick up user's shell environment (nvm, fnm, volta, asdf, etc.)
61-
// This is preferred because it respects the user's actual node environment.
62-
// When we run `npm install -g`, it installs to this environment, so we should find CLI here.
63151
const loginShellResult = findViaLoginShell(log)
64152
if (loginShellResult) return loginShellResult
65153

66-
// 5) Fall back to direct PATH lookup (for users without version managers)
67-
const directPathResult = findInPath(log)
68-
if (directPathResult) return directPathResult
154+
// 5) Use findExecutable to resolve CLI path (handles PATHEXT on Windows)
155+
const executablePath = await findExecutable("kilocode")
156+
if (executablePath) {
157+
log?.(`Found CLI via PATH: ${executablePath}`)
158+
return executablePath
159+
}
160+
log?.("kilocode not found in PATH lookup")
69161

70162
// 6) Last resort: scan common npm installation paths
71163
log?.("Falling back to scanning common installation paths...")
@@ -84,53 +176,28 @@ export async function findKilocodeCli(log?: (msg: string) => void): Promise<stri
84176
return null
85177
}
86178

87-
/**
88-
* Try to find kilocode in the current process PATH.
89-
* This works when CLI is installed in a system-wide location.
90-
*/
91-
function findInPath(log?: (msg: string) => void): string | null {
92-
const cmd = process.platform === "win32" ? "where kilocode" : "which kilocode"
93-
try {
94-
const result = execSync(cmd, { encoding: "utf-8", timeout: 5000 }).split(/\r?\n/)[0]?.trim()
95-
if (result) {
96-
log?.(`Found CLI in PATH: ${result}`)
97-
return result
98-
}
99-
} catch {
100-
log?.("kilocode not found in direct PATH lookup")
101-
}
102-
return null
103-
}
104-
105179
/**
106180
* Try to find kilocode by running `which` in a login shell.
107181
* This sources the user's shell profile (~/.zshrc, ~/.bashrc, etc.)
108182
* which sets up version managers like nvm, fnm, volta, asdf, etc.
109-
*
110-
* This is the most reliable way to find CLI installed via version managers
111-
* because VS Code's extension host doesn't inherit the user's shell environment.
112183
*/
113184
function findViaLoginShell(log?: (msg: string) => void): string | null {
114185
if (process.platform === "win32") {
115-
// Windows doesn't have the same shell environment concept
116186
return null
117187
}
118188

119-
// Detect user's shell from SHELL env var, default to bash
120189
const userShell = process.env.SHELL || "/bin/bash"
121190
const shellName = path.basename(userShell)
122191

123-
// Use login shell (-l) to source profile files, interactive (-i) for some shells
124-
// that only source certain files in interactive mode
125192
const shellFlags = shellName === "zsh" ? "-l -i" : "-l"
126193
const cmd = `${userShell} ${shellFlags} -c 'which kilocode' 2>/dev/null`
127194

128195
try {
129196
log?.(`Trying login shell lookup: ${cmd}`)
130197
const result = execSync(cmd, {
131198
encoding: "utf-8",
132-
timeout: 10000, // 10s timeout - login shells can be slow
133-
env: { ...process.env, HOME: process.env.HOME }, // Ensure HOME is set
199+
timeout: 10000,
200+
env: { ...process.env, HOME: process.env.HOME },
134201
})
135202
.split(/\r?\n/)[0]
136203
?.trim()
@@ -140,7 +207,6 @@ function findViaLoginShell(log?: (msg: string) => void): string | null {
140207
return result
141208
}
142209
} catch (error) {
143-
// This is expected if CLI is not installed or shell init is slow/broken
144210
log?.(`Login shell lookup failed (this is normal if CLI not installed via version manager): ${error}`)
145211
}
146212

@@ -149,7 +215,6 @@ function findViaLoginShell(log?: (msg: string) => void): string | null {
149215

150216
/**
151217
* Get fallback paths to check for CLI installation.
152-
* This is used when login shell lookup fails or on Windows.
153218
*/
154219
function getNpmPaths(log?: (msg: string) => void): string[] {
155220
const home = process.env.HOME || process.env.USERPROFILE || ""
@@ -164,27 +229,16 @@ function getNpmPaths(log?: (msg: string) => void): string[] {
164229
].filter(Boolean)
165230
}
166231

167-
// macOS and Linux paths
168232
const paths = [
169-
// Local installation (for immutable systems like NixOS)
170233
getLocalCliPath(),
171-
// macOS Homebrew (Apple Silicon)
172234
"/opt/homebrew/bin/kilocode",
173-
// macOS Homebrew (Intel) and Linux standard
174235
"/usr/local/bin/kilocode",
175-
// Common user-local npm prefix
176236
path.join(home, ".npm-global", "bin", "kilocode"),
177-
// nvm: scan installed versions
178237
...getNvmPaths(home, log),
179-
// fnm
180238
path.join(home, ".local", "share", "fnm", "aliases", "default", "bin", "kilocode"),
181-
// volta
182239
path.join(home, ".volta", "bin", "kilocode"),
183-
// asdf nodejs plugin
184240
path.join(home, ".asdf", "shims", "kilocode"),
185-
// Linux snap
186241
"/snap/bin/kilocode",
187-
// Linux user local bin
188242
path.join(home, ".local", "bin", "kilocode"),
189243
]
190244

@@ -193,35 +247,27 @@ function getNpmPaths(log?: (msg: string) => void): string[] {
193247

194248
/**
195249
* Get potential nvm paths for the kilocode CLI.
196-
* nvm installs node versions in ~/.nvm/versions/node/
197-
*
198-
* Note: This is a fallback - the login shell approach (findViaLoginShell)
199-
* is preferred because it respects the user's shell configuration.
200250
*/
201251
function getNvmPaths(home: string, log?: (msg: string) => void): string[] {
202252
const nvmDir = process.env.NVM_DIR || path.join(home, ".nvm")
203253
const versionsDir = path.join(nvmDir, "versions", "node")
204254

205255
const paths: string[] = []
206256

207-
// Check NVM_BIN if set (current nvm version in the shell)
208257
if (process.env.NVM_BIN) {
209258
paths.push(path.join(process.env.NVM_BIN, "kilocode"))
210259
}
211260

212-
// Scan the nvm versions directory for installed node versions
213261
try {
214262
if (fs.existsSync(versionsDir)) {
215263
const versions = fs.readdirSync(versionsDir)
216-
// Sort versions in reverse order to check newer versions first
217264
versions.sort().reverse()
218265
log?.(`Found ${versions.length} nvm node versions to check`)
219266
for (const version of versions) {
220267
paths.push(path.join(versionsDir, version, "bin", "kilocode"))
221268
}
222269
}
223270
} catch (error) {
224-
// This is normal if user doesn't have nvm installed
225271
log?.(`Could not scan nvm versions directory: ${error}`)
226272
}
227273

src/core/kilocode/agent-manager/CliProcessHandler.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { spawn, ChildProcess } from "node:child_process"
2+
import * as path from "node:path"
23
import {
34
CliOutputParser,
45
type StreamEvent,
@@ -37,6 +38,7 @@ interface PendingProcessInfo {
3738
gitUrl?: string
3839
stderrBuffer: string[] // Capture stderr for error detection
3940
timeoutId?: NodeJS.Timeout // Timer for auto-failing stuck pending sessions
41+
cliPath?: string // CLI path for error telemetry
4042
provisionalSessionId?: string // Temporary session ID created when api_req_started arrives (before session_created)
4143
}
4244

@@ -211,6 +213,7 @@ export class CliProcessHandler {
211213
gitUrl: options?.gitUrl,
212214
stderrBuffer: [],
213215
timeoutId: setTimeout(() => this.handlePendingTimeout(), PENDING_SESSION_TIMEOUT_MS),
216+
cliPath,
214217
}
215218
}
216219

@@ -666,10 +669,24 @@ export class CliProcessHandler {
666669

667670
private handleProcessError(proc: ChildProcess, error: Error): void {
668671
if (this.pendingProcess && this.pendingProcess.process === proc) {
672+
const cliPath = this.pendingProcess.cliPath
669673
this.clearPendingTimeout()
670674
this.registry.clearPendingSession()
671675
this.callbacks.onPendingSessionChanged(null)
672676
this.pendingProcess = null
677+
678+
// Capture spawn error telemetry with context for debugging
679+
const { platform, shell } = getPlatformDiagnostics()
680+
const cliPathExtension = cliPath ? path.extname(cliPath).slice(1).toLowerCase() || undefined : undefined
681+
captureAgentManagerLoginIssue({
682+
issueType: "cli_spawn_error",
683+
platform,
684+
shell,
685+
errorMessage: error.message,
686+
cliPath,
687+
cliPathExtension,
688+
})
689+
673690
this.callbacks.onStartSessionFailed({
674691
type: "spawn_error",
675692
message: error.message,

0 commit comments

Comments
 (0)