mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
812 lines
25 KiB
TypeScript
812 lines
25 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";
|
|
|
|
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",
|
|
},
|
|
},
|
|
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",
|
|
},
|
|
},
|
|
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",
|
|
},
|
|
},
|
|
};
|
|
|
|
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<string, string | undefined>; timeoutMs?: number } = {}
|
|
): Promise<any> =>
|
|
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();
|
|
|
|
return [
|
|
home,
|
|
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<string, [string, string][]> = {
|
|
claude: [
|
|
["claude.cmd", "claude"],
|
|
["claude.exe", "claude"],
|
|
],
|
|
codex: [["codex.cmd", "codex"]],
|
|
droid: [["droid.cmd", "droid"]],
|
|
openclaw: [["openclaw.cmd", "openclaw"]],
|
|
cursor: [
|
|
["agent.cmd", "agent"],
|
|
["cursor.cmd", "cursor"],
|
|
],
|
|
cline: [["cline.cmd", "cline"]],
|
|
kilo: [["kilocode.cmd", "kilocode"]],
|
|
opencode: [["opencode.cmd", "opencode"]],
|
|
};
|
|
|
|
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"));
|
|
}
|
|
}
|
|
|
|
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<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", [command], { env, timeoutMs: 3000 });
|
|
if (!located.ok || !located.stdout) {
|
|
return { installed: false, commandPath: null, reason: "not_found" };
|
|
}
|
|
const first =
|
|
located.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => normalizeMsys2Path(line.trim()))
|
|
.find(Boolean) || null;
|
|
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
|
|
}
|
|
|
|
const located = await runProcess("sh", ["-c", 'command -v -- "$1"', "sh", command], {
|
|
env,
|
|
timeoutMs: 3000,
|
|
});
|
|
if (!located.ok || !located.stdout) {
|
|
return { installed: false, commandPath: null, reason: "not_found" };
|
|
}
|
|
const first =
|
|
located.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => normalizeMsys2Path(line.trim()))
|
|
.find(Boolean) || null;
|
|
return { installed: !!first, commandPath: first, reason: first ? null : "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 < 100MB
|
|
// 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
|
|
if (stat.size < 30 || stat.size > 100 * 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,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
HOME: env.HOME || env.USERPROFILE,
|
|
SystemRoot: env.SystemRoot, // Windows needs this
|
|
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 located = await locateCommandCandidate(commands, env, 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);
|