mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 10:12:11 +03:00
Compare commits
6 Commits
v3.0.0-rc.
...
v3.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ad5d42982 | ||
|
|
3912734498 | ||
|
|
0fa3f9a057 | ||
|
|
0fbabdcf25 | ||
|
|
67b7ae98a6 | ||
|
|
0f703c95dd |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -6,6 +6,27 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.12] — 2026-03-23
|
||||
|
||||
### 🔀 Community PRs Merged
|
||||
|
||||
| PR | Author | Summary |
|
||||
| -------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| **#546** | @k0valik | fix(cli): `--version` returning `unknown` on Windows — use `JSON.parse(readFileSync)` instead of ESM import |
|
||||
| **#555** | @k0valik | fix(sse): centralized `resolveDataDir()` for path resolution in credentials, autoCombo, responses logger, and request logger |
|
||||
| **#544** | @k0valik | fix(cli): secure CLI tool detection via known installation paths (8 tools) with symlink validation, file-type checks, size bounds, minimal env in healthcheck |
|
||||
| **#542** | @rdself | fix(ui): improve light mode contrast — add missing CSS theme variables (`bg-primary`, `bg-subtle`, `text-primary`) and fix dark-only colors in log detail |
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **TDZ fix in `cliRuntime.ts`** — `validateEnvPath` was used before initialization at module startup by `getExpectedParentPaths()`. Reordered declarations to fix `ReferenceError`.
|
||||
- **Build fixes** — Added `pino` and `pino-pretty` to `serverExternalPackages` to prevent Turbopack from breaking Pino's internal worker loading.
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Test suite: **905 tests, 0 failures**
|
||||
|
||||
---
|
||||
## [3.0.0-rc.10] — 2026-03-23
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
@@ -116,10 +116,8 @@ if (args.includes("--help") || args.includes("-h")) {
|
||||
|
||||
if (args.includes("--version") || args.includes("-v")) {
|
||||
try {
|
||||
const pkg = await import(join(ROOT, "package.json"), {
|
||||
with: { type: "json" },
|
||||
});
|
||||
console.log(pkg.default.version);
|
||||
const { version } = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
||||
console.log(version);
|
||||
} catch {
|
||||
console.log("unknown");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.0.0-rc.10
|
||||
version: 3.0.0-rc.12
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -13,6 +13,8 @@ const nextConfig = {
|
||||
},
|
||||
output: "standalone",
|
||||
serverExternalPackages: [
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
"thread-stream",
|
||||
"better-sqlite3",
|
||||
"zod",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { resolveDataDir } from "../../src/lib/dataPaths";
|
||||
|
||||
// Fields that can be overridden per provider
|
||||
const CREDENTIAL_FIELDS = ["clientId", "clientSecret", "tokenUrl", "authUrl", "refreshUrl"];
|
||||
@@ -30,8 +31,7 @@ let cachedProviders = null;
|
||||
* Priority: DATA_DIR env → ./data (project root)
|
||||
*/
|
||||
function resolveCredentialsPath() {
|
||||
const dataDir = process.env.DATA_DIR || join(process.cwd(), "data");
|
||||
return join(dataDir, "provider-credentials.json");
|
||||
return join(resolveDataDir(), "provider-credentials.json");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +93,11 @@ export function loadProviderCredentials(providers) {
|
||||
`[CREDENTIALS] ${isReload ? "Reloaded" : "Loaded"} external credentials: ${overrideCount} field(s) from ${credPath}`
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(`[CREDENTIALS] Error reading credentials file: ${err.message}. Using defaults.`);
|
||||
const reason =
|
||||
err instanceof SyntaxError
|
||||
? "Invalid JSON format"
|
||||
: (err as NodeJS.ErrnoException).code || "read error";
|
||||
console.log(`[CREDENTIALS] Error reading credentials file (${reason}). Using defaults.`);
|
||||
}
|
||||
|
||||
cachedProviders = providers;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { resolveDataDir } from "../../../src/lib/dataPaths";
|
||||
|
||||
export interface AdaptationState {
|
||||
comboId: string;
|
||||
@@ -23,7 +24,7 @@ export interface AdaptationState {
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
const PERSISTENCE_DIR = path.join(process.cwd(), "data");
|
||||
const PERSISTENCE_DIR = resolveDataDir();
|
||||
const STATE_FILE = path.join(PERSISTENCE_DIR, "auto_combo_state.json");
|
||||
|
||||
let stateCache = new Map<string, AdaptationState>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { resolveDataDir } from "../../src/lib/dataPaths";
|
||||
/**
|
||||
* Responses API Transformer
|
||||
* Converts OpenAI Chat Completions SSE to Codex Responses API SSE format
|
||||
@@ -39,7 +40,7 @@ export function createResponsesLogger(model, logsDir = null) {
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
||||
const uniqueId = Math.random().toString(36).slice(2, 8);
|
||||
const baseDir = logsDir || (typeof process !== "undefined" ? process.cwd() : ".");
|
||||
const baseDir = logsDir || resolveDataDir();
|
||||
const logDir = path.join(baseDir, "logs", `responses_${model}_${timestamp}_${uniqueId}`);
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,10 +16,8 @@ async function ensureNodeModules() {
|
||||
try {
|
||||
fs = await import("fs");
|
||||
path = await import("path");
|
||||
LOGS_DIR = path.join(
|
||||
typeof process !== "undefined" && process.cwd ? process.cwd() : ".",
|
||||
"logs"
|
||||
);
|
||||
const { resolveDataDir } = await import("../../src/lib/dataPaths");
|
||||
LOGS_DIR = path.join(resolveDataDir(), "logs");
|
||||
} catch {
|
||||
// Running in non-Node environment (Worker, Browser, etc.)
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0-rc.10",
|
||||
"version": "3.0.0-rc.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0-rc.10",
|
||||
"version": "3.0.0-rc.12",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0-rc.10",
|
||||
"version": "3.0.0-rc.12",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -36,10 +36,13 @@
|
||||
/* Light theme */
|
||||
--color-bg: #f9f9fb;
|
||||
--color-bg-alt: #f0f0f5;
|
||||
--color-bg-primary: #f9f9fb;
|
||||
--color-bg-subtle: #f0f0f5;
|
||||
--color-surface: #ffffff;
|
||||
--color-sidebar: rgba(245, 245, 250, 0.8);
|
||||
--color-border: rgba(0, 0, 0, 0.08);
|
||||
--color-text-main: #1a1a2e;
|
||||
--color-text-primary: #1a1a2e;
|
||||
--color-text-muted: #71717a;
|
||||
|
||||
/* Shadows */
|
||||
@@ -52,10 +55,13 @@
|
||||
/* Dark theme (ClawHub deep) */
|
||||
--color-bg: #0b0e14;
|
||||
--color-bg-alt: #111520;
|
||||
--color-bg-primary: #0b0e14;
|
||||
--color-bg-subtle: #111520;
|
||||
--color-surface: #161b22;
|
||||
--color-sidebar: rgba(16, 20, 30, 0.8);
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-text-main: #e6e6ef;
|
||||
--color-text-primary: #e6e6ef;
|
||||
--color-text-muted: #a1a1aa;
|
||||
|
||||
/* Dark shadows */
|
||||
@@ -81,10 +87,13 @@
|
||||
|
||||
/* Auto-switch colors (use CSS variables from :root/.dark) */
|
||||
--color-bg: var(--color-bg);
|
||||
--color-bg-primary: var(--color-bg-primary);
|
||||
--color-bg-subtle: var(--color-bg-subtle);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-sidebar: var(--color-sidebar);
|
||||
--color-border: var(--color-border);
|
||||
--color-text-main: var(--color-text-main);
|
||||
--color-text-primary: var(--color-text-primary);
|
||||
--color-text-muted: var(--color-text-muted);
|
||||
|
||||
/* Static colors (for explicit light/dark usage) */
|
||||
|
||||
@@ -36,7 +36,7 @@ function PayloadSection({ title, json, onCopy }) {
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-4 rounded-xl bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-primary max-h-[600px] overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
|
||||
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-[600px] overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
|
||||
{json}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
|
||||
In: {(detail?.tokens?.in || log.tokens?.in || 0).toLocaleString()}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400 text-xs font-bold">
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
|
||||
Out: {(detail?.tokens?.out || log.tokens?.out || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -213,7 +213,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Combo</div>
|
||||
{detail?.comboName || log.comboName ? (
|
||||
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30">
|
||||
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-500/30">
|
||||
{detail?.comboName || log.comboName}
|
||||
</span>
|
||||
) : (
|
||||
@@ -225,10 +225,12 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
{/* Error Message */}
|
||||
{(detail?.error || log.error) && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30">
|
||||
<div className="text-[10px] text-red-400 uppercase tracking-wider mb-1 font-bold">
|
||||
<div className="text-[10px] text-red-600 dark:text-red-400 uppercase tracking-wider mb-1 font-bold">
|
||||
Error
|
||||
</div>
|
||||
<div className="text-sm text-red-300 font-mono">{detail?.error || log.error}</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-300 font-mono">
|
||||
{detail?.error || log.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const CLI_TOOLS = {
|
||||
},
|
||||
modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"],
|
||||
settingsFile: "~/.claude/settings.json",
|
||||
defaultCommand: "claude",
|
||||
defaultModels: [
|
||||
{
|
||||
id: "opus",
|
||||
@@ -47,6 +48,7 @@ export const CLI_TOOLS = {
|
||||
color: "#10A37F",
|
||||
description: "OpenAI Codex CLI",
|
||||
configType: "custom",
|
||||
defaultCommand: "codex",
|
||||
},
|
||||
droid: {
|
||||
id: "droid",
|
||||
@@ -55,6 +57,7 @@ export const CLI_TOOLS = {
|
||||
color: "#00D4FF",
|
||||
description: "Factory Droid AI Assistant",
|
||||
configType: "custom",
|
||||
defaultCommand: "droid",
|
||||
},
|
||||
openclaw: {
|
||||
id: "openclaw",
|
||||
@@ -63,6 +66,7 @@ export const CLI_TOOLS = {
|
||||
color: "#FF6B35",
|
||||
description: "Open Claw AI Assistant",
|
||||
configType: "custom",
|
||||
defaultCommand: "openclaw",
|
||||
},
|
||||
cursor: {
|
||||
id: "cursor",
|
||||
@@ -72,6 +76,7 @@ export const CLI_TOOLS = {
|
||||
description: "Cursor AI Code Editor",
|
||||
configType: "guide",
|
||||
requiresCloud: true,
|
||||
defaultCommands: ["agent", "cursor"],
|
||||
notes: [
|
||||
{ type: "warning", text: "Requires Cursor Pro account to use this feature." },
|
||||
{
|
||||
@@ -95,6 +100,7 @@ export const CLI_TOOLS = {
|
||||
color: "#00D1B2",
|
||||
description: "Cline AI Coding Assistant CLI",
|
||||
configType: "custom",
|
||||
defaultCommand: "cline",
|
||||
},
|
||||
kilo: {
|
||||
id: "kilo",
|
||||
@@ -103,6 +109,7 @@ export const CLI_TOOLS = {
|
||||
color: "#FF6B6B",
|
||||
description: "Kilo Code AI Assistant CLI",
|
||||
configType: "custom",
|
||||
defaultCommand: "kilocode",
|
||||
},
|
||||
continue: {
|
||||
id: "continue",
|
||||
@@ -180,6 +187,7 @@ export const CLI_TOOLS = {
|
||||
color: "#FF6B35",
|
||||
description: "OpenCode AI coding agent (Terminal)",
|
||||
configType: "guide",
|
||||
defaultCommand: "opencode",
|
||||
notes: [
|
||||
{
|
||||
type: "warning",
|
||||
|
||||
@@ -197,15 +197,220 @@ const getRuntimeMode = () => {
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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)",
|
||||
]);
|
||||
|
||||
return [
|
||||
home,
|
||||
userProfile,
|
||||
validatedAppData,
|
||||
validatedLocalAppData,
|
||||
validatedProgramFiles,
|
||||
validatedProgramFilesX86,
|
||||
].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(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 on Windows.
|
||||
* Returns ONLY verified, tool-specific paths - NOT generic user bin directories.
|
||||
* This is more secure than searching PATH as it checks known locations only.
|
||||
*/
|
||||
const getKnownToolPaths = (toolId: string): string[] => {
|
||||
if (!isWindows()) return [];
|
||||
|
||||
const home = os.homedir();
|
||||
const userProfile = process.env.USERPROFILE || home;
|
||||
|
||||
// Validate environment paths against allowed parent directories
|
||||
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,
|
||||
]);
|
||||
|
||||
// Cache nvm node path to avoid duplicate detection calls
|
||||
const nvmNodePath = getNvmNodePath();
|
||||
|
||||
// Tool-specific known installation paths (verified locations only)
|
||||
const knownPaths: Record<string, string[]> = {
|
||||
claude: [
|
||||
// Official Claude Code standalone installer locations
|
||||
path.join(home, ".local", "bin", "claude.exe"),
|
||||
...(localAppData ? [path.join(localAppData, "Programs", "Claude", "claude.exe")] : []),
|
||||
...(localAppData ? [path.join(localAppData, "claude-code", "claude.exe")] : []),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "claude-code.cmd")] : []),
|
||||
],
|
||||
codex: [
|
||||
path.join(home, ".local", "bin", "codex"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "codex.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "codex.cmd")] : []),
|
||||
],
|
||||
droid: [
|
||||
path.join(home, ".local", "bin", "droid"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "droid.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "droid.cmd")] : []),
|
||||
],
|
||||
openclaw: [
|
||||
path.join(home, ".local", "bin", "openclaw"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "openclaw.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "openclaw.cmd")] : []),
|
||||
],
|
||||
cursor: [
|
||||
path.join(home, ".local", "bin", "agent"),
|
||||
path.join(home, ".local", "bin", "cursor"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "agent.cmd")] : []),
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "cursor.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "agent.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "cursor.cmd")] : []),
|
||||
],
|
||||
cline: [
|
||||
path.join(home, ".local", "bin", "cline"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "cline.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "cline.cmd")] : []),
|
||||
],
|
||||
kilo: [
|
||||
path.join(home, ".local", "bin", "kilocode"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "kilocode.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "kilocode.cmd")] : []),
|
||||
],
|
||||
opencode: [
|
||||
path.join(home, ".local", "bin", "opencode"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "opencode.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "opencode.cmd")] : []),
|
||||
],
|
||||
// Add other tools as needed with their specific known paths
|
||||
};
|
||||
|
||||
return knownPaths[toolId] || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -223,20 +428,6 @@ const resolveToolCommands = (toolId: string): string[] => {
|
||||
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)) {
|
||||
@@ -294,14 +485,93 @@ const locateCommand = async (command: string, env: Record<string, string | undef
|
||||
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 (1KB - 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 > 1KB and < 100MB
|
||||
// This catches suspicious files while allowing for wrapper scripts
|
||||
if (stat.size < 1024 || 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>
|
||||
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 && isWindows()) {
|
||||
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") {
|
||||
@@ -317,10 +587,18 @@ const checkRunnable = async (
|
||||
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
|
||||
};
|
||||
|
||||
for (const args of [["--version"], ["-v"]]) {
|
||||
const result = await runProcess(commandPath, args, { env, timeoutMs });
|
||||
if (result.ok) {
|
||||
return { runnable: true, reason: null };
|
||||
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" };
|
||||
@@ -334,8 +612,28 @@ export const ensureCliConfigWriteAllowed = () => {
|
||||
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 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,
|
||||
@@ -417,7 +715,7 @@ export const getCliRuntimeStatus = async (toolId: string) => {
|
||||
};
|
||||
}
|
||||
|
||||
const located = await locateCommandCandidate(commands, env);
|
||||
const located = await locateCommandCandidate(commands, env, toolId);
|
||||
const command = located.command;
|
||||
|
||||
if (!located.installed) {
|
||||
|
||||
Reference in New Issue
Block a user