Files
OmniRoute/src/shared/services/cliRuntime.ts
2026-07-18 15:14:23 -03:00

1126 lines
35 KiB
TypeScript

import fs from "fs/promises";
import fsSync from "fs";
import os from "os";
import path from "path";
import { spawn, execFileSync } from "child_process";
import { getHermesHome } from "@/lib/cli-helper/config-generator/hermesHome";
import { getCachedLoginShellPath, mergeShellPath } from "./loginShellPath";
import { withSettingsFallback } from "./cliInstallFallback";
import { GROK_BUILD_RUNTIME_ENTRY, AMP_RUNTIME_ENTRY } from "./cliRuntimeGrokBuild";
const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]);
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
const CLI_TOOLS: Record<string, any> = {
claude: {
defaultCommand: "claude",
envBinKey: "CLI_CLAUDE_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 4000,
paths: {
settings: ".claude/settings.json",
auth: [".claude/.credentials.json", ".config/claude/credentials.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: {},
},
devin: {
defaultCommand: "devin",
envBinKey: "CLI_DEVIN_BIN",
requiresBinary: true,
// devin acp cold-start can take a few seconds on first run
healthcheckTimeoutMs: 12000,
paths: {
// %APPDATA%\devin\config.json (Windows)
// ~/.config/devin/config.json (Linux/macOS)
get config() {
return isWindows()
? path.join(
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
"devin",
"config.json"
)
: path.join(os.homedir(), ".config", "devin", "config.json");
},
},
},
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: "cn",
envBinKey: "CLI_CONTINUE_BIN",
requiresBinary: true,
// opencode and continue may take up to 15s on first run / cold start on VPS
healthcheckTimeoutMs: 15000,
paths: {
settings: ".continue/config.yaml",
},
},
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",
},
},
hermes: {
// Original / legacy simple Hermes entry (recovered from origin/main)
defaultCommand: "hermes",
envBinKey: "CLI_HERMES_BIN",
requiresBinary: false,
healthcheckTimeoutMs: 4000,
paths: {
config: ".config/hermes/config.json",
},
},
"hermes-agent": {
// Rich first-class support for the advanced Hermes Agent (multi-role: default, delegation, auxiliary.*)
defaultCommand: "hermes",
envBinKey: "CLI_HERMES_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 4000,
paths: {
// The relative path is kept for documentation purposes; getCliConfigPaths()
// has a special case for hermes-agent that calls getHermesHome() instead of
// getCliConfigHome(), so HERMES_HOME is always honoured (#3628).
config: "config.yaml",
},
},
amp: AMP_RUNTIME_ENTRY,
qoder: {
defaultCommand: "qodercli",
envBinKey: "CLI_QODER_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 12000,
paths: {
config: ".qoder/settings.json",
auth: ".qoder/auth.json",
},
},
qwen: {
defaultCommand: "qwen",
envBinKey: "CLI_QWEN_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 12000,
paths: {
settings: ".qwen/settings.json",
env: ".qwen/.env",
},
},
// ── Plan 14 — new "custom" configType tools ───────────────────────────────
forge: {
defaultCommand: "forge",
envBinKey: "CLI_FORGE_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 8000,
paths: {
config: ".forge/config.toml",
},
},
jcode: {
defaultCommand: "jcode",
envBinKey: "CLI_JCODE_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 8000,
paths: {
config: ".jcode/config.json",
},
},
"grok-build": GROK_BUILD_RUNTIME_ENTRY,
"deepseek-tui": {
defaultCommand: "deepseek-tui",
envBinKey: "CLI_DEEPSEEK_TUI_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 8000,
paths: {
config: ".config/deepseek-tui/config.toml",
},
},
omp: {
defaultCommand: "omp",
envBinKey: "CLI_OMP_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 8000,
paths: {
config: ".omp/agent/models.yml",
},
},
letta: {
defaultCommand: "letta",
envBinKey: "CLI_LETTA_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 8000,
paths: {
config: ".letta/lc-local-backend/providers/auth.json",
},
},
codewhale: {
defaultCommand: "codewhale",
envBinKey: "CLI_CODEWHALE_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 8000,
paths: {
config: ".codewhale/config.toml",
},
},
smelt: {
defaultCommand: "smelt",
envBinKey: "CLI_SMELT_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 8000,
paths: {
config: ".smelt/config.json",
},
},
pi: {
defaultCommand: "pi",
envBinKey: "CLI_PI_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 8000,
paths: {
config: ".pi/config.json",
},
},
// Config path reconciled with bin/cli/commands/setup-crush.mjs::resolveCrushTarget's
// default (~/.config/crush/crush.json) so the dashboard and `omniroute setup-crush`
// agree on one canonical config location.
crush: {
defaultCommand: "crush",
envBinKey: "CLI_CRUSH_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 8000,
paths: {
config: ".config/crush/crush.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());
};
export const shouldUseShellForCommand = (command: string): boolean => {
if (!isWindows()) return false;
// Windows npm CLI wrappers are usually .cmd/.bat files and require cmd.exe.
// Direct executables should not go through the shell: absolute paths with spaces
// (for example C:\Users\Name With Spaces\...\claude.exe) are split by cmd.exe.
return /\.(?:cmd|bat)$/i.test(command);
};
const runProcess = (
command: string,
args: string[],
{
env,
timeoutMs = 3000,
useShell = shouldUseShellForCommand(command),
}: {
env?: Record<string, string | undefined>;
timeoutMs?: number;
useShell?: boolean;
} = {}
): Promise<any> =>
new Promise((resolve) => {
// Guard: reject commands with shell metacharacters — command comes from
// server-controlled env vars/config, not HTTP input, but belt-and-suspenders.
if (/[;&|`$<>\n\r]/.test(command)) {
resolve({ ok: false, stdout: "", stderr: "rejected: unsafe command path", exitCode: -1 });
return;
}
let stdout = "";
let stderr = "";
let timedOut = false;
let settled = false;
// Do NOT string-interpolate the path into a quoted shell command (hard rule
// #13). When useShell is false (.exe and all non-Windows), spawn passes
// `command` as a raw argv[0] and the OS loader handles spaces. When useShell
// is true (.cmd/.bat on Windows), Node quotes the command for cmd.exe itself.
const child = spawn(command, args, {
env,
stdio: ["ignore", "pipe", "pipe"],
// On Windows, npm installs CLI wrappers as .cmd/.bat scripts. Those still
// need cmd.exe, but direct .exe paths must avoid the shell so paths with
// spaces are not split before execution.
...(useShell ? { 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.
*/
export const getKnownToolPaths = (toolId: string): string[] => {
const home = os.homedir();
const paths: string[] = [];
const npmPrefix = getNpmGlobalPrefix();
const nvmNodePath = getNvmNodePath();
const toolBins: Record<string, [string, string][]> = {
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"]],
continue: [["cn.cmd", "cn"]],
opencode: [["opencode.cmd", "opencode"]],
qoder: [
["qodercli.cmd", "qodercli"],
["qodercli.exe", "qodercli"],
],
devin: [
["devin.exe", "devin"],
["devin.cmd", "devin"],
],
};
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"));
paths.push(
path.join(
localAppData,
"Microsoft",
"WinGet",
"Packages",
"Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe",
"claude.exe"
)
);
}
}
if (toolId === "droid") {
paths.push(path.join(home, "bin", "droid.exe"));
}
// Devin CLI installs to %LOCALAPPDATA%\devin\cli\bin\devin.exe
if (toolId === "devin" && localAppData) {
paths.push(path.join(localAppData, "devin", "cli", "bin", "devin.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));
}
// Devin CLI installs to ~/.local/share/devin/bin/devin (Linux)
// or via shell installer to ~/.devin/bin/devin
if (toolId === "devin") {
paths.push(path.join(home, ".local", "share", "devin", "bin", "devin"));
paths.push(path.join(home, ".devin", "bin", "devin"));
}
}
}
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;
};
export const getLookupEnv = () => {
const env = { ...process.env };
const extraPaths = getExtraPaths();
const basePath = env.PATH || env.Path || "";
// #3321: on macOS GUI/Electron the inherited PATH is truncated (no Homebrew/nvm/volta),
// so CLI detection and CLI spawns can't find tools the user actually has installed.
// Enrich with the login-shell PATH (cached, darwin-only, fail-safe → null elsewhere).
const loginShellPath = getCachedLoginShellPath();
const enrichedPath = loginShellPath ? mergeShellPath(basePath, loginShellPath) : basePath;
// 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 || enrichedPath !== basePath || isWindows()) {
const mergedPath = [...extraPaths, enrichedPath].filter(Boolean).join(path.delimiter);
if (mergedPath) {
env.PATH = mergedPath;
if (isWindows()) {
env.Path = mergedPath;
}
}
}
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" };
}
};
export const locateCommand = async (command: string, env: Record<string, string | undefined>) => {
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.exe", [command], {
env,
timeoutMs: 3000,
useShell: false,
});
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: normalizeMsys2Path(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).
// On macOS temp directories often resolve from /var -> /private/var, so compare both
// the configured parent and its canonical realpath when available.
let isWithinExpected = false;
for (const parent of EXPECTED_PARENT_PATHS) {
if (isPathWithin(realPath, parent)) {
isWithinExpected = true;
break;
}
try {
const resolvedParent = await fs.realpath(parent);
if (isPathWithin(realPath, resolvedParent)) {
isWithinExpected = true;
break;
}
} catch {
// Ignore missing/unresolvable parents and continue checking the remaining ones.
}
}
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<string, string | undefined>,
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,
};
}
if (result.installed && result.reason === "not_executable") {
return {
command: commands[0],
installed: true,
commandPath: result.commandPath,
reason: "not_executable",
};
}
if (result.reason && result.reason !== "not_found") {
return { command: commands[0], ...result };
}
}
}
// 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<string, string | undefined>,
timeoutMs = 4000
) => {
// Minimal environment to prevent credential leakage to potentially malicious binaries
const minimalEnv: Record<string, string | undefined> = {
PATH: env.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
};
if (isWindows() && minimalEnv.PATH) {
minimalEnv.Path = minimalEnv.PATH;
}
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()
) => {
// #3330: OpenCode reads its config from XDG `~/.config/opencode/` on ALL
// platforms — including Windows, where it uses `%USERPROFILE%\.config`, NOT
// `%APPDATA%`. Writing to %APPDATA% on Windows put the file where OpenCode
// never looks, so dashboard-saved config silently had no effect. `_platform`
// is kept in the signature for call-site/test compatibility.
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(),
};
}
// hermes-agent: honour HERMES_HOME env var instead of the generic CLI_CONFIG_HOME (#3628).
if (toolId === "hermes-agent") {
return {
config: path.join(getHermesHome(), "config.yaml"),
};
}
const home = getCliConfigHome();
return Object.fromEntries(
Object.entries(tool.paths).map(([key, relativePath]) => {
let resolvedPath = "";
if (Array.isArray(relativePath)) {
// Find the first path that exists, or default to the first one
resolvedPath = path.join(home, relativePath[0]);
for (const p of relativePath) {
const candidate = path.join(home, p);
if (fsSync.existsSync(candidate)) {
resolvedPath = candidate;
break;
}
}
} else {
resolvedPath = path.join(home, relativePath as string);
}
return [key, resolvedPath];
})
);
};
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 withSettingsFallback(getCliConfigPaths(toolId)?.settings, {
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 || command || "", // located + executable ⇒ commandPath set
env,
Number(tool.healthcheckTimeoutMs || 4000)
);
return {
installed: true,
runnable: healthcheck.runnable,
version: healthcheck.version,
command,
commandPath: located.commandPath,
reason: healthcheck.reason,
runtimeMode,
requiresBinary,
};
};
export const CLI_TOOL_IDS = Object.keys(CLI_TOOLS);