import fs from "fs/promises"; import fsSync from "fs"; import os from "os"; import path from "path"; import { spawn, execFileSync } from "child_process"; const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]); const FALSE_VALUES = new Set(["0", "false", "no", "off"]); const CLI_TOOLS: Record = { claude: { defaultCommand: "claude", envBinKey: "CLI_CLAUDE_BIN", requiresBinary: true, healthcheckTimeoutMs: 4000, paths: { settings: ".claude/settings.json", }, }, codex: { defaultCommand: "codex", envBinKey: "CLI_CODEX_BIN", requiresBinary: true, healthcheckTimeoutMs: 4000, paths: { config: ".codex/config.toml", auth: ".codex/auth.json", }, }, droid: { defaultCommand: "droid", envBinKey: "CLI_DROID_BIN", requiresBinary: true, // Droid CLI can be slow on some environments; 4s was causing false negatives. healthcheckTimeoutMs: 8000, paths: { settings: ".factory/settings.json", }, }, openclaw: { defaultCommand: "openclaw", envBinKey: "CLI_OPENCLAW_BIN", requiresBinary: true, // openclaw CLI may take >4s on cold start in containers. healthcheckTimeoutMs: 15000, paths: { settings: ".openclaw/openclaw.json", }, }, cursor: { defaultCommands: ["agent", "cursor"], envBinKey: "CLI_CURSOR_BIN", requiresBinary: true, // Cursor startup can be slower on first run in containerized host-mount mode. healthcheckTimeoutMs: 15000, paths: { config: ".cursor/cli-config.json", auth: ".config/cursor/auth.json", state: ".cursor/agent-cli-state.json", }, }, windsurf: { defaultCommand: null, envBinKey: "CLI_WINDSURF_BIN", requiresBinary: false, healthcheckTimeoutMs: 4000, paths: {}, }, cline: { defaultCommand: "cline", envBinKey: "CLI_CLINE_BIN", requiresBinary: true, // Cline startup/version check can take >4s on some environments. healthcheckTimeoutMs: 12000, paths: { globalState: ".cline/data/globalState.json", secrets: ".cline/data/secrets.json", }, }, kilo: { defaultCommand: "kilocode", envBinKey: "CLI_KILO_BIN", requiresBinary: true, // kilocode renders an ASCII logo banner on startup which can take >4s // on cold-start or low-resource environments (VPS, CI). Increase timeout // to avoid false healthcheck_failed results. healthcheckTimeoutMs: 15000, paths: { auth: ".local/share/kilo/auth.json", }, }, continue: { defaultCommand: null, envBinKey: "CLI_CONTINUE_BIN", requiresBinary: false, // opencode and continue may take up to 15s on first run / cold start on VPS healthcheckTimeoutMs: 15000, paths: { settings: ".continue/config.json", }, }, opencode: { defaultCommand: "opencode", envBinKey: "CLI_OPENCODE_BIN", requiresBinary: true, // opencode takes several seconds on cold start environments healthcheckTimeoutMs: 15000, paths: { config: ".config/opencode/opencode.json", }, }, qoder: { defaultCommand: "qodercli", envBinKey: "CLI_QODER_BIN", requiresBinary: true, healthcheckTimeoutMs: 12000, paths: { config: ".qoder/settings.json", auth: ".qoder/auth.json", }, }, }; const isWindows = () => process.platform === "win32"; /** * (#510) Normalize MSYS2/Git-Bash style paths to Windows-native paths. * On Windows with Git Bash, 'where claude' may return '/c/Program Files/...' * instead of 'C:\\Program Files\\...'. Convert these so the path is usable * by Node's fs and child_process modules. */ const normalizeMsys2Path = (p: string): string => { if (!p || !isWindows()) return p; // Match /letter/rest-of-path — MSYS2 POSIX-style drive mount const msys2Match = p.match(/^\/([a-zA-Z])\/(.+)$/); if (msys2Match) { const drive = msys2Match[1].toUpperCase(); const rest = msys2Match[2].replace(/\//g, "\\"); return `${drive}:\\${rest}`; } return p; }; const parseBoolean = (value: unknown, defaultValue = true) => { if (value == null || value === "") return defaultValue; return !FALSE_VALUES.has(String(value).trim().toLowerCase()); }; const runProcess = ( command: string, args: string[], { env, timeoutMs = 3000 }: { env?: Record; timeoutMs?: number } = {} ): Promise => new Promise((resolve) => { let stdout = ""; let stderr = ""; let timedOut = false; let settled = false; const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"], // On Windows, npm installs CLI wrappers as .cmd scripts (e.g. claude.cmd). // Without shell:true, spawn cannot resolve them via PATHEXT and the // healthcheck fails even when the CLI is correctly installed (#447). ...(isWindows() ? { shell: true } : {}), }); const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs); const done = (result: any) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result); }; child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", (error) => { done({ ok: false, code: null, stdout: stdout.trim(), stderr: stderr.trim(), timedOut, error: error?.message || "spawn_error", }); }); child.on("close", (code) => { done({ ok: !timedOut && code === 0, code, stdout: stdout.trim(), stderr: stderr.trim(), timedOut, error: timedOut ? "timeout" : null, }); }); }); const getRuntimeMode = () => { const mode = String(process.env.CLI_MODE || "auto") .trim() .toLowerCase(); return VALID_RUNTIME_MODES.has(mode) ? mode : "auto"; }; /** * T12: Validate a CLI executable path to prevent shell injection. * Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file. * Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026). */ const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"]; /** * Check if a path is within a parent directory (case-insensitive, handles mixed separators). * Normalizes both paths to forward slashes before comparison to handle * inconsistent separator styles on Windows. */ const isPathWithin = (childPath: string, parentPath: string): boolean => { // Normalize to forward slashes for consistent comparison const normalize = (p: string) => path.normalize(p).toLowerCase().replace(/\\/g, "/"); const normalizedChild = normalize(childPath); const normalizedParent = normalize(parentPath); if (normalizedChild === normalizedParent) return true; // Ensure parent ends with / for proper prefix matching const parentWithSep = normalizedParent.endsWith("/") ? normalizedParent : normalizedParent + "/"; return normalizedChild.startsWith(parentWithSep); }; const isSafePath = (execPath: string): boolean => { if (!execPath || !path.isAbsolute(execPath)) return false; if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false; // Allow path.sep and path.delimiter — no further character filtering needed return true; }; /** * Validate that an environment variable value is a safe, absolute path * within acceptable directory trees. Rejects traversal, special chars, * and paths outside expected locations. */ const validateEnvPath = (value: string | undefined, allowedParents: string[]): string => { if (!value) return ""; const trimmed = value.trim(); // Reject if not absolute if (!path.isAbsolute(trimmed)) return ""; // Reject dangerous characters (same as isSafePath but applied to env vars) if (DANGEROUS_PATH_CHARS.some((c) => trimmed.includes(c))) return ""; // Reject if contains path traversal segments const normalized = path.normalize(trimmed); if (normalized.includes("..")) return ""; // Reject if outside allowed parent directories if (allowedParents.length > 0) { const withinAllowed = allowedParents.some((parent) => isPathWithin(normalized, parent)); if (!withinAllowed) return ""; } return normalized; }; /** * Detect the npm global bin directory. * Cached on first call — `execFileSync` is expensive, only run once. */ let _npmGlobalPrefix: string | undefined; const getNpmGlobalPrefix = (): string => { if (_npmGlobalPrefix !== undefined) return _npmGlobalPrefix; const envPrefix = String(process.env.npm_config_prefix || "").trim(); if (envPrefix && path.isAbsolute(envPrefix)) { _npmGlobalPrefix = envPrefix; return _npmGlobalPrefix; } try { const result = execFileSync("npm", ["config", "get", "prefix"], { timeout: 5000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...(isWindows() ? { shell: true } : {}), }); const prefix = result.trim(); if ( prefix && path.isAbsolute(prefix) && !DANGEROUS_PATH_CHARS.some((c) => prefix.includes(c)) ) { _npmGlobalPrefix = prefix; return _npmGlobalPrefix; } } catch {} _npmGlobalPrefix = ""; return _npmGlobalPrefix; }; /** * Pre-compute expected parent directories at module startup for performance. * These are the allowed directories for CLI binary installation locations. */ const getExpectedParentPaths = (): string[] => { const home = os.homedir(); const userProfile = process.env.USERPROFILE || home; const validatedAppData = validateEnvPath(process.env.APPDATA, [home, userProfile]); const validatedLocalAppData = validateEnvPath(process.env.LOCALAPPDATA, [ path.join(home, "AppData", "Local"), path.join(userProfile, "AppData", "Local"), userProfile, ]); const validatedProgramFiles = validateEnvPath(process.env.ProgramFiles, [ "C:\\Program Files", "C:\\Program Files (x86)", ]); const validatedProgramFilesX86 = validateEnvPath(process.env["ProgramFiles(x86)"], [ "C:\\Program Files", "C:\\Program Files (x86)", ]); const npmPrefix = getNpmGlobalPrefix(); // Add common user bin directories const userBinPaths = [path.join(home, "bin"), path.join(home, ".local", "bin")]; return [ home, ...userBinPaths, userProfile, validatedAppData, validatedLocalAppData, validatedProgramFiles, validatedProgramFilesX86, npmPrefix, ].filter(Boolean); }; // Cache expected parent paths at module startup (avoid recalculation on every checkKnownPath call) const EXPECTED_PARENT_PATHS = getExpectedParentPaths(); const getExtraPaths = () => String(process.env.CLI_EXTRA_PATHS || "") .split(path.delimiter) .map((segment) => segment.trim()) .filter(Boolean) .filter((p) => { // Must be absolute if (!path.isAbsolute(p)) return false; // No dangerous characters if (DANGEROUS_PATH_CHARS.some((c) => p.includes(c))) return false; // No path traversal if (path.normalize(p).includes("..")) return false; return true; }); /** * Get known installation paths for a specific CLI tool. * Checks npm global prefix, NVM locations, standalone installer paths. * Works on all platforms — Windows checks .cmd wrappers, Linux/macOS checks bare names. */ const getKnownToolPaths = (toolId: string): string[] => { const home = os.homedir(); const paths: string[] = []; const npmPrefix = getNpmGlobalPrefix(); const nvmNodePath = getNvmNodePath(); const toolBins: Record = { claude: [ ["claude.cmd", "claude"], ["claude.exe", "claude"], ], codex: [["codex.cmd", "codex"]], droid: [ ["droid.cmd", "droid"], ["droid.exe", "droid"], ], openclaw: [["openclaw.cmd", "openclaw"]], cursor: [ ["agent.cmd", "agent"], ["cursor.cmd", "cursor"], ], cline: [["cline.cmd", "cline"]], kilo: [["kilocode.cmd", "kilocode"]], opencode: [["opencode.cmd", "opencode"]], qoder: [["qodercli.exe", "qodercli"]], }; const bins = toolBins[toolId] || []; if (isWindows()) { const userProfile = process.env.USERPROFILE || home; const appData = validateEnvPath(process.env.APPDATA, [home, userProfile]); const localAppData = validateEnvPath(process.env.LOCALAPPDATA, [ path.join(home, "AppData", "Local"), path.join(userProfile, "AppData", "Local"), userProfile, ]); if (toolId === "claude") { paths.push(path.join(home, ".local", "bin", "claude.exe")); if (localAppData) { paths.push(path.join(localAppData, "Programs", "Claude", "claude.exe")); paths.push(path.join(localAppData, "claude-code", "claude.exe")); } } if (toolId === "droid") { paths.push(path.join(home, "bin", "droid.exe")); } for (const [winName] of bins) { if (npmPrefix) paths.push(path.join(npmPrefix, winName)); if (appData) { const appDataPath = path.join(appData, "npm", winName); if ( !npmPrefix || path.normalize(appDataPath) !== path.normalize(path.join(npmPrefix, winName)) ) { paths.push(appDataPath); } } if (nvmNodePath) paths.push(path.join(nvmNodePath, winName)); } } else { for (const [, posixName] of bins) { const nodeBinDir = path.dirname(process.execPath); paths.push(path.join(nodeBinDir, posixName)); if (npmPrefix) { paths.push(path.join(npmPrefix, "bin", posixName)); } paths.push(path.join(home, ".local", "bin", posixName)); // Only add system paths if they exist (avoids unnecessary stat calls) if (fsSync.existsSync("/usr/local/bin")) { paths.push(path.join("/usr", "local", "bin", posixName)); } if (fsSync.existsSync("/usr/bin")) { paths.push(path.join("/usr", "bin", posixName)); } if (toolId === "opencode") { paths.push(path.join(home, ".opencode", "bin", posixName)); } if (toolId === "claude") { paths.push(path.join(home, ".claude", "bin", posixName)); } } } return paths; }; /** * Detect nvm-windows installation path dynamically from current Node.js executable. * Returns the directory containing node.exe if nvm is detected, null otherwise. */ const getNvmNodePath = (): string | null => { // Simple heuristic: if process.execPath includes "nvm", use its directory if (process.execPath.toLowerCase().includes("nvm")) { return path.dirname(process.execPath); } return null; }; const getLookupEnv = () => { const env = { ...process.env }; const extraPaths = getExtraPaths(); // Only add user-specified extra paths, NOT generic user directories // This is more secure - user explicitly opts in via CLI_EXTRA_PATHS if (extraPaths.length > 0) { env.PATH = [...extraPaths, env.PATH || ""].filter(Boolean).join(path.delimiter); } return env; }; const resolveToolCommands = (toolId: string): string[] => { const tool = CLI_TOOLS[toolId]; if (!tool) return []; const envCommand = String(process.env[tool.envBinKey] || "").trim(); if (envCommand) return [envCommand]; if (Array.isArray(tool.defaultCommands) && tool.defaultCommands.length > 0) { return tool.defaultCommands.filter(Boolean); } return tool.defaultCommand ? [tool.defaultCommand] : []; }; const checkExplicitPath = async (commandPath: string) => { // Reject paths that look like injection attempts if (!isSafePath(commandPath)) { return { installed: false, commandPath: null, reason: "unsafe_path" }; } try { await fs.access(commandPath, fs.constants.F_OK); } catch { return { installed: false, commandPath: null, reason: "not_found" }; } try { await fs.access(commandPath, fs.constants.X_OK); return { installed: true, commandPath, reason: null }; } catch { return { installed: true, commandPath, reason: "not_executable" }; } }; const locateCommand = async (command: string, env: Record) => { if (!command) { return { installed: false, commandPath: null, reason: "missing_command" }; } if (command.includes("/") || command.includes("\\")) { return checkExplicitPath(command); } if (isWindows()) { const located = await runProcess("where", [command], { env, timeoutMs: 3000 }); if (located.ok && located.stdout) { // `where` may return multiple matches (e.g. `opencode` + `opencode.cmd`). // npm global installs on Windows create both a Unix shell script (no extension) // and a .cmd wrapper. We must prefer the Windows executable extension. const lines = located.stdout .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); if (lines.length === 0) { return { installed: false, commandPath: null, reason: "not_found" }; } const winExt = /\.(cmd|exe|bat|com)$/i; const preferred = lines.find((l) => winExt.test(l)) || lines[0]; return { installed: true, commandPath: preferred, reason: null }; } return { installed: false, commandPath: null, reason: "not_found" }; } const located = await runProcess("sh", ["-c", 'command -v -- "$1"', "sh", command], { env, timeoutMs: 3000, }); if (located.ok && located.stdout) { return { installed: true, commandPath: command, reason: null }; } return { installed: false, commandPath: null, reason: "not_found" }; }; /** * Check if a command exists at a specific absolute path. * Used for known installation locations. * * Security hardening: * - Resolves symlinks and verifies target stays within expected directories * - Verifies file is a regular file (not directory, pipe, or device) * - Checks file size bounds (30B - 100MB) to detect suspicious binaries */ const checkKnownPath = async (commandPath: string) => { if (!path.isAbsolute(commandPath)) { return { installed: false, commandPath: null, reason: "not_absolute" }; } if (!isSafePath(commandPath)) { return { installed: false, commandPath: null, reason: "unsafe_path" }; } try { // Resolve symlinks to get the real path and detect symlink escapes const realPath = await fs.realpath(commandPath); // Verify the resolved path is still within expected directories // Use pre-computed expected parent paths (cached at module startup for performance) const isWithinExpected = EXPECTED_PARENT_PATHS.some((parent) => isPathWithin(realPath, parent)); if (!isWithinExpected) { return { installed: false, commandPath: null, reason: "symlink_escape" }; } // Verify it's a regular file with reasonable size const stat = await fs.stat(realPath); if (!stat.isFile()) { return { installed: false, commandPath: null, reason: "not_file" }; } // CLI binaries should be > 30 bytes and < 350MB // npm .cmd wrappers on Windows are ~300-500 bytes, JS wrappers on Linux can be ~44 bytes // Minimum catches empty/suspicious files while allowing legitimate thin wrappers // Many modern CLIs (like Claude Code and OpenCode) build as single ~150-250MB binaries if (stat.size < 30 || stat.size > 350 * 1024 * 1024) { return { installed: false, commandPath: null, reason: "suspicious_size" }; } } catch (error) { const errorCode = (error as NodeJS.ErrnoException).code; if (errorCode === "ENOENT") { return { installed: false, commandPath: null, reason: "not_found" }; } if (errorCode === "EINVAL") { return { installed: false, commandPath: null, reason: "invalid_path" }; } return { installed: false, commandPath: null, reason: "access_error" }; } try { await fs.access(commandPath, fs.constants.X_OK); return { installed: true, commandPath, reason: null }; } catch { return { installed: true, commandPath, reason: "not_executable" }; } }; const locateCommandCandidate = async ( commands: string[], env: Record, toolId?: string ) => { if (!Array.isArray(commands) || commands.length === 0) { return { command: null, installed: false, commandPath: null, reason: "missing_command" }; } // SECURITY: First check known installation paths for this specific tool // This avoids searching PATH and reduces attack surface if (toolId) { const knownPaths = getKnownToolPaths(toolId); for (const knownPath of knownPaths) { const result = await checkKnownPath(knownPath); if (result.installed && result.reason === null) { return { command: commands[0], installed: true, commandPath: result.commandPath, reason: null, }; } } } // Fallback: search PATH (user can set CLI_EXTRA_PATHS if needed) for (const command of commands) { const located = await locateCommand(command, env); if (located.installed || located.reason !== "not_found") { return { command, ...located }; } } return { command: commands[0], installed: false, commandPath: null, reason: "not_found" }; }; const checkRunnable = async ( commandPath: string, env: Record, timeoutMs = 4000 ) => { // Minimal environment to prevent credential leakage to potentially malicious binaries const minimalEnv: Record = { PATH: env.PATH, HOME: env.HOME || env.USERPROFILE, USERPROFILE: env.USERPROFILE, // Windows needs this for os.homedir() APPDATA: env.APPDATA, // Many npm CLI tools rely on APPDATA LOCALAPPDATA: env.LOCALAPPDATA, TEMP: env.TEMP, TMP: env.TMP, SystemRoot: env.SystemRoot, // Windows needs this ComSpec: env.ComSpec, // Windows shell PATHEXT: env.PATHEXT, // Windows cmd.exe needs this to resolve .cmd/.bat/.exe extensions }; for (const args of [["--version"], ["-v"]]) { const result = await runProcess(commandPath, args, { env: minimalEnv, timeoutMs }); // Validate output: must be non-empty and reasonable length (< 4KB) if (result.ok && result.stdout.length > 0 && result.stdout.length < 4096) { return { runnable: true, reason: null, version: result.stdout.trim() }; } } return { runnable: false, reason: "healthcheck_failed" }; }; export const isCliConfigWriteAllowed = () => parseBoolean(process.env.CLI_ALLOW_CONFIG_WRITES, true); export const ensureCliConfigWriteAllowed = () => { if (isCliConfigWriteAllowed()) return null; return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)"; }; export const getCliConfigHome = () => { const override = String(process.env.CLI_CONFIG_HOME || "").trim(); if (!override) return os.homedir(); // Must be absolute if (!path.isAbsolute(override)) return os.homedir(); // Must not contain dangerous characters if (DANGEROUS_PATH_CHARS.some((c) => override.includes(c))) return os.homedir(); // Must not contain path traversal if (path.normalize(override).includes("..")) return os.homedir(); // Must be within user's home directory (prevent reading from system dirs) const home = os.homedir(); const normalized = path.normalize(override); if (!isPathWithin(normalized, home)) { return home; // Silently fall back to home } return normalized; }; export const resolveOpencodeConfigDir = ( platform = process.platform, env: NodeJS.ProcessEnv = process.env, homeDir = os.homedir() ) => { const isWin = platform === "win32"; if (isWin) { const appData = String(env.APPDATA || "").trim(); return appData || path.join(homeDir, "AppData", "Roaming"); } const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim(); return xdgConfigHome || path.join(homeDir, ".config"); }; export const resolveOpencodeConfigPath = ( platform = process.platform, env: NodeJS.ProcessEnv = process.env, homeDir = os.homedir() ) => path.join(resolveOpencodeConfigDir(platform, env, homeDir), "opencode", "opencode.json"); export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath(); export const getCliConfigPaths = (toolId: string) => { const tool = CLI_TOOLS[toolId]; if (!tool) return null; if (toolId === "opencode") { return { config: getOpenCodeConfigPath(), }; } const home = getCliConfigHome(); return Object.fromEntries( Object.entries(tool.paths).map(([key, relativePath]) => [ key, path.join(home, relativePath as string), ]) ); }; export const getCliPrimaryConfigPath = (toolId: string) => { const paths = getCliConfigPaths(toolId); if (!paths) return null; const firstKey = Object.keys(paths)[0]; return firstKey ? paths[firstKey] : null; }; export const getCliRuntimeStatus = async (toolId: string) => { const tool = CLI_TOOLS[toolId]; const runtimeMode = getRuntimeMode(); if (!tool) { return { installed: false, runnable: false, command: null, commandPath: null, reason: "unknown_tool", runtimeMode, requiresBinary: false, }; } const env = getLookupEnv(); const commands = resolveToolCommands(toolId); const requiresBinary = tool.requiresBinary !== false; if (!requiresBinary && commands.length === 0) { return { installed: true, runnable: true, command: null, commandPath: null, reason: "not_required", runtimeMode, requiresBinary, }; } const envCommand = String(process.env[tool.envBinKey] || "").trim(); const hasEnvOverride = !!envCommand; const located = await locateCommandCandidate(commands, env, hasEnvOverride ? undefined : toolId); const command = located.command; if (!located.installed) { return { installed: false, runnable: false, command, commandPath: null, reason: located.reason || "not_found", runtimeMode, requiresBinary, }; } if (located.reason === "not_executable") { return { installed: true, runnable: false, command, commandPath: located.commandPath, reason: "not_executable", runtimeMode, requiresBinary, }; } const healthcheck = await checkRunnable( located.commandPath, env, Number(tool.healthcheckTimeoutMs || 4000) ); return { installed: true, runnable: healthcheck.runnable, command, commandPath: located.commandPath, reason: healthcheck.reason, runtimeMode, requiresBinary, }; }; export const CLI_TOOL_IDS = Object.keys(CLI_TOOLS);