Files
OmniRoute/src/shared/services/cliRuntime.ts

464 lines
13 KiB
TypeScript

import fs from "fs/promises";
import os from "os";
import path from "path";
import { spawn } 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";
};
const getExtraPaths = () =>
String(process.env.CLI_EXTRA_PATHS || "")
.split(path.delimiter)
.map((segment) => segment.trim())
.filter(Boolean);
const getLookupEnv = () => {
const env = { ...process.env };
const extraPaths = getExtraPaths();
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] : [];
};
/**
* 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 = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"];
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;
};
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" };
};
const locateCommandCandidate = async (
commands: string[],
env: Record<string, string | undefined>
) => {
if (!Array.isArray(commands) || commands.length === 0) {
return { command: null, installed: false, commandPath: null, reason: "missing_command" };
}
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
) => {
for (const args of [["--version"], ["-v"]]) {
const result = await runProcess(commandPath, args, { env, timeoutMs });
if (result.ok) {
return { runnable: true, reason: null };
}
}
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 = () =>
String(process.env.CLI_CONFIG_HOME || "").trim() || os.homedir();
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);
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);