From 8fc97a7f91e4a44152f5686fd24fd1c289dfdf6e Mon Sep 17 00:00:00 2001 From: oyi77 Date: Thu, 2 Apr 2026 13:27:51 +0700 Subject: [PATCH 1/3] feat(cliproxyapi): add DB schema, upstream proxy config & settings UI Part 1 of CLIProxyAPI integration (#902). - DB migration for version_manager + upstream_proxy_config tables - DB modules: versionManager.ts (tool lifecycle CRUD), upstreamProxy.ts (proxy config CRUD + SSRF guard) - localDb.ts re-exports for new modules - Provider registration: UPSTREAM_PROXY_PROVIDERS in providers.ts - Zod validation schemas for version-manager API routes - Settings UI: CliproxyapiSettingsTab (Advanced tab) - 47 unit tests for DB modules --- .../components/CliproxyapiSettingsTab.tsx | 193 +++++++++ .../(dashboard)/dashboard/settings/page.tsx | 8 +- .../016_version_manager_upstream_proxy.sql | 56 +++ src/lib/db/upstreamProxy.ts | 230 +++++++++++ src/lib/db/versionManager.ts | 300 ++++++++++++++ src/lib/localDb.ts | 22 ++ src/shared/constants/providers.ts | 25 +- src/shared/validation/schemas.ts | 8 + tests/unit/db-upstreamProxy.test.mjs | 340 ++++++++++++++++ tests/unit/db-versionManager.test.mjs | 368 ++++++++++++++++++ 10 files changed, 1548 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx create mode 100644 src/lib/db/migrations/016_version_manager_upstream_proxy.sql create mode 100644 src/lib/db/upstreamProxy.ts create mode 100644 src/lib/db/versionManager.ts create mode 100644 tests/unit/db-upstreamProxy.test.mjs create mode 100644 tests/unit/db-versionManager.test.mjs diff --git a/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx new file mode 100644 index 0000000000..12094623ed --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Input, Toggle } from "@/shared/components"; + +export default function CliproxyapiSettingsTab() { + const [settings, setSettings] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: string; text: string } | null>(null); + const [toolState, setToolState] = useState(null); + + useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((data) => { + setSettings(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + + fetch("/api/version-manager/status") + .then((r) => r.json()) + .then((data) => { + const entry = Array.isArray(data) ? data.find((t: any) => t.tool === "cliproxyapi") : null; + setToolState(entry); + }) + .catch(() => {}); + }, []); + + const updateSetting = async (key: string, value: any) => { + setSaving(true); + setMessage(null); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [key]: value }), + }); + if (res.ok) { + setSettings((prev) => ({ ...prev, [key]: value })); + setMessage({ type: "success", text: "Setting saved" }); + } + } catch { + setMessage({ type: "error", text: "Failed to save setting" }); + } finally { + setSaving(false); + } + }; + + const cpaEnabled = settings.cliproxyapi_fallback_enabled === true; + const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317"; + const cpaCodes = settings.cliproxyapi_fallback_codes || "502,401,403,429,503"; + + const statusColor = + toolState?.status === "running" + ? "text-green-600 dark:text-green-400" + : toolState?.status === "error" + ? "text-red-600 dark:text-red-400" + : "text-text-muted"; + + const statusIcon = + toolState?.status === "running" + ? "check_circle" + : toolState?.status === "error" + ? "error" + : "help"; + + return ( +
+ {message && ( +
+ + {message.type === "success" ? "check_circle" : "error"} + + {message.text} +
+ )} + + +
+
+ swap_horiz +
+
+

CLIProxyAPI Fallback

+

+ When enabled, failed requests are retried through CLIProxyAPI (localhost:8317) +

+
+
+ +
+
+ + updateSetting("cliproxyapi_fallback_enabled", checked)} + /> +
+ + {cpaEnabled && ( + <> +
+ + updateSetting("cliproxyapi_url", e.target.value)} + placeholder="http://127.0.0.1:8317" + className="w-full" + /> +
+ +
+ + updateSetting("cliproxyapi_fallback_codes", e.target.value)} + placeholder="502,401,403,429,503" + className="w-full" + /> +
+ + )} +
+
+ + +

CLIProxyAPI Status

+ {loading ? ( +
+ + progress_activity + + Loading... +
+ ) : toolState ? ( +
+
+

Status

+
+ + {statusIcon} + +

+ {toolState.status?.replace("_", " ") || "Unknown"} +

+
+
+
+

Version

+

+ {toolState.installedVersion ? `v${toolState.installedVersion}` : "Not installed"} +

+
+
+

Health

+

+ {toolState.healthStatus === "healthy" + ? "Healthy" + : toolState.healthStatus === "unhealthy" + ? "Unhealthy" + : "Unknown"} +

+
+
+

Port

+

{toolState.port || 8317}

+
+
+ ) : ( +

CLIProxyAPI not detected

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index 640fcea3ea..a9a25e4aca 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -21,6 +21,7 @@ import CacheStatsCard from "./components/CacheStatsCard"; import CacheSettingsTab from "./components/CacheSettingsTab"; import MemorySkillsTab from "./components/MemorySkillsTab"; import ResilienceTab from "./components/ResilienceTab"; +import CliproxyapiSettingsTab from "./components/CliproxyapiSettingsTab"; const tabs = [ { id: "general", labelKey: "general", icon: "settings" }, @@ -113,7 +114,12 @@ export default function SettingsPage() { {activeTab === "resilience" && } - {activeTab === "advanced" && } + {activeTab === "advanced" && ( +
+ + +
+ )} {/* App Info */} diff --git a/src/lib/db/migrations/016_version_manager_upstream_proxy.sql b/src/lib/db/migrations/016_version_manager_upstream_proxy.sql new file mode 100644 index 0000000000..337e7d9d7a --- /dev/null +++ b/src/lib/db/migrations/016_version_manager_upstream_proxy.sql @@ -0,0 +1,56 @@ +-- Migration 016: Version Manager & Upstream Proxy tables +-- +-- Adds two tables for CLIProxyAPI integration: +-- version_manager — binary lifecycle management for CLI tools (CLIProxyAPI, etc.) +-- upstream_proxy_config — per-provider routing mode (native vs CLIProxyAPI vs fallback) + +-- -------------------------------------------------------------------------- +-- Table: version_manager +-- Tracks installed versions, process state, and update settings for +-- externally managed CLI tools (initially CLIProxyAPI). +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS version_manager ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool TEXT NOT NULL UNIQUE, -- 'cliproxyapi' | 'omniroute' + current_version TEXT, -- latest known release version + installed_version TEXT, -- currently installed version + pinned_version TEXT, -- user-pinned version (null = auto) + binary_path TEXT, -- absolute path to binary + status TEXT NOT NULL DEFAULT 'not_installed', -- not_installed | installed | running | stopped | error + pid INTEGER, -- process ID when running + port INTEGER DEFAULT 8317, -- managed tool's port + api_key TEXT, -- API key for CLIProxyAPI (from its config) + management_key TEXT, -- CLIProxyAPI management secret key + auto_update INTEGER NOT NULL DEFAULT 1, -- 1 = auto-update enabled + auto_start INTEGER NOT NULL DEFAULT 0, -- 1 = start with OmniRoute + last_health_check TEXT, -- ISO timestamp + last_update_check TEXT, -- ISO timestamp + health_status TEXT DEFAULT 'unknown', -- unknown | healthy | unhealthy | timeout + config_overrides TEXT, -- JSON blob for CLIProxyAPI config overrides + error_message TEXT, -- last error message + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- -------------------------------------------------------------------------- +-- Table: upstream_proxy_config +-- Per-provider routing configuration for CLIProxyAPI passthrough mode. +-- Determines whether each provider uses OmniRoute's native executor, +-- delegates to CLIProxyAPI, or uses fallback (native first, then CLIProxyAPI). +-- -------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS upstream_proxy_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL UNIQUE, -- e.g., 'antigravity', 'claude', 'codex' + mode TEXT NOT NULL DEFAULT 'native', -- 'native' | 'cliproxyapi' | 'fallback' + cliproxyapi_model_mapping TEXT, -- JSON: { "ag/gemini-3-pro": "gemini-3-pro-high", ... } + native_priority INTEGER NOT NULL DEFAULT 1, -- order in fallback chain (1 = first) + cliproxyapi_priority INTEGER NOT NULL DEFAULT 2, -- order in fallback chain + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_upc_provider ON upstream_proxy_config(provider_id); +CREATE INDEX IF NOT EXISTS idx_upc_mode ON upstream_proxy_config(mode); +CREATE INDEX IF NOT EXISTS idx_vm_tool ON version_manager(tool); +CREATE INDEX IF NOT EXISTS idx_vm_status ON version_manager(status); diff --git a/src/lib/db/upstreamProxy.ts b/src/lib/db/upstreamProxy.ts new file mode 100644 index 0000000000..fdad87e73c --- /dev/null +++ b/src/lib/db/upstreamProxy.ts @@ -0,0 +1,230 @@ +/** Upstream proxy config persistence for upstream_proxy_config table. */ +import { getDbInstance } from "./core"; + +interface UpstreamProxyConfig { + id: number; + providerId: string; + mode: string; + cliproxyapiModelMapping: Record | null; + nativePriority: number; + cliproxyapiPriority: number; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +interface UpstreamProxyRow { + id: unknown; + provider_id: unknown; + mode: unknown; + cliproxyapi_model_mapping: unknown; + native_priority: unknown; + cliproxyapi_priority: unknown; + enabled: unknown; + created_at: unknown; + updated_at: unknown; +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +const BLOCKED_HOSTNAMES = ["metadata.google.internal", "169.254.169.254", "metadata.aws.internal"]; + +function isPrivateHost(hostname: string): boolean { + if (BLOCKED_HOSTNAMES.includes(hostname)) return true; + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return false; + if ( + /^10\./.test(hostname) || + /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || + /^192\.168\./.test(hostname) + ) + return true; + if ( + /^0\./.test(hostname) || + /^127\./.test(hostname) || + /^224\./.test(hostname) || + /^169\.254\./.test(hostname) + ) + return true; + return false; +} + +export function validateProxyUrl( + url: string +): { valid: true; url: string } | { valid: false; error: string } { + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) { + return { + valid: false, + error: `Unsupported protocol "${parsed.protocol}" — use http or https`, + }; + } + if (isPrivateHost(parsed.hostname)) { + return { + valid: false, + error: `Proxy URL cannot point to private/internal address "${parsed.hostname}"`, + }; + } + return { valid: true, url }; + } catch { + return { valid: false, error: `Invalid URL: "${url}"` }; + } +} + +function rowToConfig(record: Record): UpstreamProxyConfig { + return { + id: record.id as number, + providerId: record.provider_id as string, + mode: record.mode as string, + cliproxyapiModelMapping: + record.cliproxyapi_model_mapping && typeof record.cliproxyapi_model_mapping === "string" + ? JSON.parse(record.cliproxyapi_model_mapping) + : null, + nativePriority: record.native_priority as number, + cliproxyapiPriority: record.cliproxyapi_priority as number, + enabled: record.enabled === 1 || record.enabled === true, + createdAt: record.created_at as string, + updatedAt: record.updated_at as string, + }; +} + +export async function getUpstreamProxyConfigs() { + const db = getDbInstance(); + const rows = db + .prepare("SELECT * FROM upstream_proxy_config ORDER BY provider_id") + .all() as UpstreamProxyRow[]; + return rows.map((row) => rowToConfig(toRecord(row))); +} + +export async function getUpstreamProxyConfig(providerId: string) { + const db = getDbInstance(); + const row = db + .prepare("SELECT * FROM upstream_proxy_config WHERE provider_id = ?") + .get(providerId) as UpstreamProxyRow | undefined; + if (!row) return null; + return rowToConfig(toRecord(row)); +} + +export async function upsertUpstreamProxyConfig(data: { + providerId: string; + mode?: string; + cliproxyapiModelMapping?: Record | null; + nativePriority?: number; + cliproxyapiPriority?: number; + enabled?: boolean; +}) { + const db = getDbInstance(); + const mode = data.mode ?? "native"; + const cliproxyapiModelMapping = + data.cliproxyapiModelMapping !== undefined + ? JSON.stringify(data.cliproxyapiModelMapping) + : null; + const nativePriority = data.nativePriority ?? 1; + const cliproxyapiPriority = data.cliproxyapiPriority ?? 2; + const enabled = data.enabled !== false ? 1 : 0; + + db.prepare( + `INSERT INTO upstream_proxy_config + (provider_id, mode, cliproxyapi_model_mapping, native_priority, cliproxyapi_priority, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(provider_id) DO UPDATE SET + mode = excluded.mode, + cliproxyapi_model_mapping = excluded.cliproxyapi_model_mapping, + native_priority = excluded.native_priority, + cliproxyapi_priority = excluded.cliproxyapi_priority, + enabled = excluded.enabled, + updated_at = datetime('now')` + ).run( + data.providerId, + mode, + cliproxyapiModelMapping, + nativePriority, + cliproxyapiPriority, + enabled + ); + + return getUpstreamProxyConfig(data.providerId); +} + +export async function updateUpstreamProxyConfig( + providerId: string, + updates: Record +) { + const db = getDbInstance(); + const current = await getUpstreamProxyConfig(providerId); + if (!current) { + throw new Error(`Provider ${providerId} not found`); + } + + const sets: string[] = ["updated_at = datetime('now')"]; + const params: unknown[] = []; + + if (updates.mode !== undefined) { + sets.push("mode = ?"); + params.push(updates.mode); + } + if (updates.cliproxyapiModelMapping !== undefined) { + sets.push("cliproxyapi_model_mapping = ?"); + params.push( + updates.cliproxyapiModelMapping === null + ? null + : JSON.stringify(updates.cliproxyapiModelMapping) + ); + } + if (updates.nativePriority !== undefined) { + sets.push("native_priority = ?"); + params.push(updates.nativePriority); + } + if (updates.cliproxyapiPriority !== undefined) { + sets.push("cliproxyapi_priority = ?"); + params.push(updates.cliproxyapiPriority); + } + if (updates.enabled !== undefined) { + sets.push("enabled = ?"); + params.push(updates.enabled === true ? 1 : 0); + } + + params.push(providerId); + db.prepare(`UPDATE upstream_proxy_config SET ${sets.join(", ")} WHERE provider_id = ?`).run( + ...params + ); + + return getUpstreamProxyConfig(providerId); +} + +export async function deleteUpstreamProxyConfig(providerId: string) { + const db = getDbInstance(); + const result = db + .prepare("DELETE FROM upstream_proxy_config WHERE provider_id = ?") + .run(providerId); + return result.changes > 0; +} + +export async function getProvidersByMode(mode: string) { + const db = getDbInstance(); + const rows = db + .prepare( + "SELECT * FROM upstream_proxy_config WHERE mode = ? AND enabled = 1 ORDER BY provider_id" + ) + .all(mode) as UpstreamProxyRow[]; + return rows.map((row) => rowToConfig(toRecord(row))); +} + +export async function getFallbackChainForProvider(providerId: string) { + const config = await getUpstreamProxyConfig(providerId); + if (!config) return []; + + const chain: { executor: "native" | "cliproxyapi"; priority: number }[] = []; + + if (config.enabled) { + chain.push({ executor: "native", priority: config.nativePriority }); + if (config.mode === "cliproxyapi" || config.mode === "fallback") { + chain.push({ executor: "cliproxyapi", priority: config.cliproxyapiPriority }); + } + } + + chain.sort((a, b) => a.priority - b.priority); + return chain; +} diff --git a/src/lib/db/versionManager.ts b/src/lib/db/versionManager.ts new file mode 100644 index 0000000000..d72339b80e --- /dev/null +++ b/src/lib/db/versionManager.ts @@ -0,0 +1,300 @@ +/** Version manager tool state persistence. */ + +import { getDbInstance } from "./core"; + +interface VersionManagerRow { + id?: unknown; + tool?: unknown; + current_version?: unknown; + installed_version?: unknown; + pinned_version?: unknown; + binary_path?: unknown; + status?: unknown; + pid?: unknown; + port?: unknown; + api_key?: unknown; + management_key?: unknown; + auto_update?: unknown; + auto_start?: unknown; + last_health_check?: unknown; + last_update_check?: unknown; + health_status?: unknown; + config_overrides?: unknown; + error_message?: unknown; + created_at?: unknown; + updated_at?: unknown; +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +function parseConfigOverrides(value: unknown): Record | null { + if (!value || typeof value !== "string" || value.trim() === "") return null; + try { + const parsed = JSON.parse(value); + return typeof parsed === "object" && parsed !== null ? parsed : null; + } catch { + return null; + } +} + +function stringifyConfigOverrides(value: Record | null): string | null { + if (value === null) return null; + try { + return JSON.stringify(value); + } catch { + return null; + } +} + +interface VersionManagerTool { + id: number; + tool: string; + currentVersion: string | null; + installedVersion: string | null; + pinnedVersion: string | null; + binaryPath: string | null; + status: string; + pid: number | null; + port: number; + apiKey: string | null; + managementKey: string | null; + autoUpdate: boolean; + autoStart: boolean; + lastHealthCheck: string | null; + lastUpdateCheck: string | null; + healthStatus: string; + configOverrides: Record | null; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +} + +function rowToVersionManager(row: VersionManagerRow): VersionManagerTool { + const record = toRecord(row); + return { + id: typeof record.id === "number" ? record.id : 0, + tool: typeof record.tool === "string" ? record.tool : "", + currentVersion: + record.current_version === null + ? null + : typeof record.current_version === "string" + ? record.current_version + : null, + installedVersion: + record.installed_version === null + ? null + : typeof record.installed_version === "string" + ? record.installed_version + : null, + pinnedVersion: + record.pinned_version === null + ? null + : typeof record.pinned_version === "string" + ? record.pinned_version + : null, + binaryPath: + record.binary_path === null + ? null + : typeof record.binary_path === "string" + ? record.binary_path + : null, + status: typeof record.status === "string" ? record.status : "not_installed", + pid: record.pid === null ? null : typeof record.pid === "number" ? record.pid : null, + port: typeof record.port === "number" ? record.port : 8317, + apiKey: + record.api_key === null ? null : typeof record.api_key === "string" ? record.api_key : null, + managementKey: + record.management_key === null + ? null + : typeof record.management_key === "string" + ? record.management_key + : null, + autoUpdate: + record.auto_update === 1 || record.auto_update === true || record.auto_update === "1", + autoStart: record.auto_start === 1 || record.auto_start === true || record.auto_start === "1", + lastHealthCheck: + record.last_health_check === null + ? null + : typeof record.last_health_check === "string" + ? record.last_health_check + : null, + lastUpdateCheck: + record.last_update_check === null + ? null + : typeof record.last_update_check === "string" + ? record.last_update_check + : null, + healthStatus: typeof record.health_status === "string" ? record.health_status : "unknown", + configOverrides: parseConfigOverrides(record.config_overrides), + errorMessage: + record.error_message === null + ? null + : typeof record.error_message === "string" + ? record.error_message + : null, + createdAt: typeof record.created_at === "string" ? record.created_at : "", + updatedAt: typeof record.updated_at === "string" ? record.updated_at : "", + }; +} + +export async function getVersionManagerStatus(): Promise { + const db = getDbInstance(); + const rows = db.prepare("SELECT * FROM version_manager").all() as VersionManagerRow[]; + return rows.map(rowToVersionManager); +} + +export async function getVersionManagerTool(tool: string): Promise { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM version_manager WHERE tool = ?").get(tool) as + | VersionManagerRow + | undefined; + if (!row) return null; + return rowToVersionManager(row); +} + +export async function upsertVersionManagerTool(data: { + tool: string; + currentVersion?: string | null; + installedVersion?: string | null; + pinnedVersion?: string | null; + binaryPath?: string | null; + status?: string; + pid?: number | null; + port?: number; + apiKey?: string | null; + managementKey?: string | null; + autoUpdate?: boolean; + autoStart?: boolean; + healthStatus?: string; + configOverrides?: Record | null; + errorMessage?: string | null; +}): Promise { + const db = getDbInstance(); + db.prepare( + ` + INSERT INTO version_manager ( + tool, current_version, installed_version, pinned_version, binary_path, + status, pid, port, api_key, management_key, auto_update, auto_start, + health_status, config_overrides, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(tool) DO UPDATE SET + current_version = excluded.current_version, + installed_version = excluded.installed_version, + pinned_version = excluded.pinned_version, + binary_path = excluded.binary_path, + status = excluded.status, + pid = excluded.pid, + port = excluded.port, + api_key = excluded.api_key, + management_key = excluded.management_key, + auto_update = excluded.auto_update, + auto_start = excluded.auto_start, + health_status = excluded.health_status, + config_overrides = excluded.config_overrides, + error_message = excluded.error_message, + updated_at = datetime('now') + ` + ).run( + data.tool, + data.currentVersion ?? null, + data.installedVersion ?? null, + data.pinnedVersion ?? null, + data.binaryPath ?? null, + data.status ?? "not_installed", + data.pid ?? null, + data.port ?? 8317, + data.apiKey ?? null, + data.managementKey ?? null, + data.autoUpdate !== undefined ? (data.autoUpdate ? 1 : 0) : 1, + data.autoStart !== undefined ? (data.autoStart ? 1 : 0) : 0, + data.healthStatus ?? "unknown", + stringifyConfigOverrides(data.configOverrides ?? null), + data.errorMessage ?? null + ); + const result = await getVersionManagerTool(data.tool); + if (!result) throw new Error("Failed to retrieve inserted version manager tool"); + return result; +} + +export async function updateVersionManagerTool( + tool: string, + updates: Record +): Promise { + const db = getDbInstance(); + const existing = await getVersionManagerTool(tool); + if (!existing) return null; + + const sets: string[] = ["updated_at = datetime('now')"]; + const params: Record = { tool }; + + for (const [key, value] of Object.entries(updates)) { + const dbKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + + if (key === "configOverrides") { + sets.push("config_overrides = @configOverrides"); + params.configOverrides = stringifyConfigOverrides(value as Record | null); + } else if (key === "autoUpdate" || key === "autoStart") { + sets.push(`${dbKey} = @${key}`); + params[key] = value === true ? 1 : 0; + } else if (value === null) { + sets.push(`${dbKey} = null`); + } else { + sets.push(`${dbKey} = @${key}`); + params[key] = value; + } + } + + db.prepare(`UPDATE version_manager SET ${sets.join(", ")} WHERE tool = @tool`).run(params); + return getVersionManagerTool(tool); +} + +export async function deleteVersionManagerTool(tool: string): Promise { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM version_manager WHERE tool = ?").run(tool); + return result.changes > 0; +} + +export async function updateToolHealth(tool: string, healthStatus: string): Promise { + const db = getDbInstance(); + const result = db + .prepare( + "UPDATE version_manager SET health_status = ?, last_health_check = datetime('now') WHERE tool = ?" + ) + .run(healthStatus, tool); + return result.changes > 0; +} + +export async function updateToolVersion( + tool: string, + field: "current_version" | "installed_version", + version: string +): Promise { + const db = getDbInstance(); + const result = db + .prepare(`UPDATE version_manager SET ${field} = ?, updated_at = datetime('now') WHERE tool = ?`) + .run(version, tool); + return result.changes > 0; +} + +export async function setToolStatus( + tool: string, + status: string, + pid?: number, + errorMessage?: string +): Promise { + const db = getDbInstance(); + const result = db + .prepare( + pid !== undefined + ? "UPDATE version_manager SET status = ?, pid = ?, error_message = ?, updated_at = datetime('now') WHERE tool = ?" + : "UPDATE version_manager SET status = ?, error_message = ?, updated_at = datetime('now') WHERE tool = ?" + ) + .run( + ...(pid !== undefined + ? [status, pid, errorMessage ?? null, tool] + : [status, errorMessage ?? null, tool]) + ); + return result.changes > 0; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 0294b0620c..31301a67e7 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -210,6 +210,28 @@ export { export type { QuotaSnapshotRow, ProviderUtilizationPoint } from "@/shared/types/utilization"; +export { + getVersionManagerStatus, + getVersionManagerTool, + upsertVersionManagerTool, + updateVersionManagerTool, + deleteVersionManagerTool, + updateToolHealth, + updateToolVersion, + setToolStatus, +} from "./db/versionManager"; + +export { + getUpstreamProxyConfigs, + getUpstreamProxyConfig, + upsertUpstreamProxyConfig, + updateUpstreamProxyConfig, + deleteUpstreamProxyConfig, + getProvidersByMode, + getFallbackChainForProvider, + validateProxyUrl, +} from "./db/upstreamProxy"; + export { getProviderLimitsCache, getAllProviderLimitsCache, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 934e675a3a..3f20e5ca52 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -611,12 +611,35 @@ export function isAnthropicCompatibleProvider(providerId) { return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); } +export const UPSTREAM_PROXY_PROVIDERS = { + cliproxyapi: { + id: "cliproxyapi", + alias: "cpa", + name: "CLIProxyAPI", + icon: "proxy", + color: "#6366F1", + textIcon: "CPA", + website: "https://github.com/router-for-me/CLIProxyAPI", + defaultPort: 8317, + healthEndpoint: "/v1/models", + managementPrefix: "/v0/management", + configDir: "~/.cli-proxy-api", + binaryName: "cli-proxy-api", + githubRepo: "router-for-me/CLIProxyAPI", + }, +}; + export function isClaudeCodeCompatibleProvider(providerId) { return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); } // All providers (combined) -export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; +export const AI_PROVIDERS = { + ...FREE_PROVIDERS, + ...OAUTH_PROVIDERS, + ...APIKEY_PROVIDERS, + ...UPSTREAM_PROXY_PROVIDERS, +}; // Auth methods export const AUTH_METHODS = { diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index b49901a076..330f7c165c 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1320,3 +1320,11 @@ export const updateAutoDisableAccountsSchema = z threshold: z.number().int().min(1).max(10).optional(), }) .strict(); + +export const versionManagerToolSchema = z.object({ + tool: z.string().trim().min(1), +}); + +export const versionManagerInstallSchema = versionManagerToolSchema.extend({ + version: z.string().trim().optional(), +}); diff --git a/tests/unit/db-upstreamProxy.test.mjs b/tests/unit/db-upstreamProxy.test.mjs new file mode 100644 index 0000000000..44ce32ff68 --- /dev/null +++ b/tests/unit/db-upstreamProxy.test.mjs @@ -0,0 +1,340 @@ +import { describe, it, beforeEach, afterEach, after } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import Database from "better-sqlite3"; + +const fileTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-dbupc-test-")); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS upstream_proxy_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL UNIQUE, + mode TEXT NOT NULL DEFAULT 'native', + cliproxyapi_model_mapping TEXT, + native_priority INTEGER NOT NULL DEFAULT 1, + cliproxyapi_priority INTEGER NOT NULL DEFAULT 2, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`; + +let testDb; +let testDbPath; + +beforeEach(() => { + testDbPath = path.join(fileTmpDir, `test-${Date.now()}.db`); + testDb = new Database(testDbPath); + testDb.exec(SCHEMA); +}); + +afterEach(() => { + testDb.close(); + try { + fs.unlinkSync(testDbPath); + } catch {} +}); + +after(() => { + if (fs.existsSync(fileTmpDir)) fs.rmSync(fileTmpDir, { recursive: true, force: true }); +}); + +function upsert(db, data) { + db.prepare( + ` + INSERT OR REPLACE INTO upstream_proxy_config + (provider_id, mode, cliproxyapi_model_mapping, native_priority, cliproxyapi_priority, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ` + ).run( + data.providerId, + data.mode ?? "native", + data.cliproxyapiModelMapping !== undefined + ? JSON.stringify(data.cliproxyapiModelMapping) + : null, + data.nativePriority ?? 1, + data.cliproxyapiPriority ?? 2, + data.enabled !== false ? 1 : 0 + ); + return getConfig(db, data.providerId); +} + +function getConfig(db, providerId) { + const row = db + .prepare("SELECT * FROM upstream_proxy_config WHERE provider_id = ?") + .get(providerId); + if (!row) return null; + return { + id: row.id, + providerId: row.provider_id, + mode: row.mode, + cliproxyapiModelMapping: + row.cliproxyapi_model_mapping && typeof row.cliproxyapi_model_mapping === "string" + ? JSON.parse(row.cliproxyapi_model_mapping) + : null, + nativePriority: row.native_priority, + cliproxyapiPriority: row.cliproxyapi_priority, + enabled: row.enabled === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function getAllConfigs(db) { + return db + .prepare("SELECT * FROM upstream_proxy_config ORDER BY provider_id") + .all() + .map((r) => ({ + id: r.id, + providerId: r.provider_id, + mode: r.mode, + cliproxyapiModelMapping: + r.cliproxyapi_model_mapping && typeof r.cliproxyapi_model_mapping === "string" + ? JSON.parse(r.cliproxyapi_model_mapping) + : null, + nativePriority: r.native_priority, + cliproxyapiPriority: r.cliproxyapi_priority, + enabled: r.enabled === 1, + createdAt: r.created_at, + updatedAt: r.updated_at, + })); +} + +function getProvidersByMode(db, mode) { + return db + .prepare( + "SELECT * FROM upstream_proxy_config WHERE mode = ? AND enabled = 1 ORDER BY provider_id" + ) + .all(mode) + .map((r) => ({ + id: r.id, + providerId: r.provider_id, + mode: r.mode, + cliproxyapiModelMapping: r.cliproxyapi_model_mapping + ? JSON.parse(r.cliproxyapi_model_mapping) + : null, + nativePriority: r.native_priority, + cliproxyapiPriority: r.cliproxyapi_priority, + enabled: r.enabled === 1, + })); +} + +function getFallbackChain(db, providerId) { + const config = getConfig(db, providerId); + if (!config) return []; + const chain = []; + if (config.enabled) { + chain.push({ executor: "native", priority: config.nativePriority }); + if (config.mode === "cliproxyapi" || config.mode === "fallback") { + chain.push({ executor: "cliproxyapi", priority: config.cliproxyapiPriority }); + } + } + chain.sort((a, b) => a.priority - b.priority); + return chain; +} + +describe("db/upstreamProxy (logic)", () => { + describe("upsertUpstreamProxyConfig", () => { + it("should insert and read back", () => { + const config = upsert(testDb, { providerId: "claude", mode: "native" }); + assert.equal(config.providerId, "claude"); + assert.equal(config.mode, "native"); + assert.equal(config.enabled, true); + assert.equal(config.nativePriority, 1); + assert.equal(config.cliproxyapiPriority, 2); + assert.equal(config.cliproxyapiModelMapping, null); + assert.ok(config.id > 0); + }); + + it("should replace on conflict", () => { + upsert(testDb, { providerId: "t", mode: "native" }); + const replaced = upsert(testDb, { providerId: "t", mode: "fallback" }); + assert.equal(replaced.mode, "fallback"); + }); + + it("should store model mapping as JSON", () => { + const mapping = { "ag/gemini-3-pro": "gemini-3-pro-high" }; + const config = upsert(testDb, { providerId: "mapped", cliproxyapiModelMapping: mapping }); + assert.deepEqual(config.cliproxyapiModelMapping, mapping); + }); + + it("should store null model mapping", () => { + const config = upsert(testDb, { providerId: "nomap", cliproxyapiModelMapping: null }); + assert.equal(config.cliproxyapiModelMapping, null); + }); + + it("should handle enabled=false", () => { + assert.equal(upsert(testDb, { providerId: "dis", enabled: false }).enabled, false); + }); + + it("should set custom priorities", () => { + const config = upsert(testDb, { + providerId: "pri", + nativePriority: 5, + cliproxyapiPriority: 10, + }); + assert.equal(config.nativePriority, 5); + assert.equal(config.cliproxyapiPriority, 10); + }); + }); + + describe("getUpstreamProxyConfig", () => { + it("should return null for non-existent provider", () => { + assert.equal(getConfig(testDb, "ghost"), null); + }); + + it("should return config for provider", () => { + upsert(testDb, { providerId: "claude", mode: "fallback" }); + assert.equal(getConfig(testDb, "claude").mode, "fallback"); + }); + }); + + describe("getUpstreamProxyConfigs", () => { + it("should return all ordered by provider_id", () => { + upsert(testDb, { providerId: "zebra" }); + upsert(testDb, { providerId: "alpha" }); + const configs = getAllConfigs(testDb); + assert.equal(configs.length, 2); + assert.equal(configs[0].providerId, "alpha"); + assert.equal(configs[1].providerId, "zebra"); + }); + + it("should return empty array", () => { + assert.equal(getAllConfigs(testDb).length, 0); + }); + }); + + describe("updateUpstreamProxyConfig", () => { + it("should update individual fields", () => { + upsert(testDb, { providerId: "u", mode: "native" }); + testDb + .prepare( + "UPDATE upstream_proxy_config SET mode = ?, updated_at = datetime('now') WHERE provider_id = ?" + ) + .run("cliproxyapi", "u"); + assert.equal(getConfig(testDb, "u").mode, "cliproxyapi"); + }); + + it("should update model mapping", () => { + upsert(testDb, { providerId: "u2" }); + testDb + .prepare( + "UPDATE upstream_proxy_config SET cliproxyapi_model_mapping = ?, updated_at = datetime('now') WHERE provider_id = ?" + ) + .run(JSON.stringify({ k: "v" }), "u2"); + assert.deepEqual(getConfig(testDb, "u2").cliproxyapiModelMapping, { k: "v" }); + }); + + it("should update multiple fields", () => { + upsert(testDb, { providerId: "m" }); + testDb + .prepare( + "UPDATE upstream_proxy_config SET mode = ?, native_priority = ?, enabled = ?, updated_at = datetime('now') WHERE provider_id = ?" + ) + .run("fallback", 3, 0, "m"); + const config = getConfig(testDb, "m"); + assert.equal(config.mode, "fallback"); + assert.equal(config.nativePriority, 3); + assert.equal(config.enabled, false); + }); + + it("should set model mapping to null", () => { + upsert(testDb, { providerId: "n", cliproxyapiModelMapping: { a: 1 } }); + testDb + .prepare( + "UPDATE upstream_proxy_config SET cliproxyapi_model_mapping = NULL WHERE provider_id = ?" + ) + .run("n"); + assert.equal(getConfig(testDb, "n").cliproxyapiModelMapping, null); + }); + }); + + describe("deleteUpstreamProxyConfig", () => { + it("should delete existing config", () => { + upsert(testDb, { providerId: "del" }); + assert.equal( + testDb.prepare("DELETE FROM upstream_proxy_config WHERE provider_id = ?").run("del") + .changes, + 1 + ); + assert.equal(getConfig(testDb, "del"), null); + }); + + it("should return 0 for non-existent", () => { + assert.equal( + testDb.prepare("DELETE FROM upstream_proxy_config WHERE provider_id = ?").run("ghost") + .changes, + 0 + ); + }); + }); + + describe("getProvidersByMode", () => { + it("should filter by mode and enabled", () => { + upsert(testDb, { providerId: "p1", mode: "fallback", enabled: true }); + upsert(testDb, { providerId: "p2", mode: "fallback", enabled: true }); + upsert(testDb, { providerId: "p3", mode: "native", enabled: true }); + upsert(testDb, { providerId: "p4", mode: "fallback", enabled: false }); + const results = getProvidersByMode(testDb, "fallback"); + assert.equal(results.length, 2); + assert.ok(results.every((r) => r.enabled)); + }); + + it("should return empty for no matches", () => { + assert.equal(getProvidersByMode(testDb, "cliproxyapi").length, 0); + }); + }); + + describe("getFallbackChainForProvider", () => { + it("should return empty for non-existent provider", () => { + assert.deepEqual(getFallbackChain(testDb, "ghost"), []); + }); + + it("should return native-only for native mode", () => { + upsert(testDb, { providerId: "native-only", mode: "native" }); + const chain = getFallbackChain(testDb, "native-only"); + assert.equal(chain.length, 1); + assert.equal(chain[0].executor, "native"); + }); + + it("should return native+cliproxyapi for fallback mode", () => { + upsert(testDb, { + providerId: "fb", + mode: "fallback", + nativePriority: 1, + cliproxyapiPriority: 2, + }); + const chain = getFallbackChain(testDb, "fb"); + assert.equal(chain.length, 2); + assert.equal(chain[0].executor, "native"); + assert.equal(chain[1].executor, "cliproxyapi"); + }); + + it("should return both for cliproxyapi mode", () => { + upsert(testDb, { providerId: "cpa", mode: "cliproxyapi" }); + const chain = getFallbackChain(testDb, "cpa"); + assert.equal(chain.length, 2); + }); + + it("should sort by priority", () => { + upsert(testDb, { + providerId: "rev", + mode: "fallback", + nativePriority: 10, + cliproxyapiPriority: 1, + }); + const chain = getFallbackChain(testDb, "rev"); + assert.equal(chain[0].executor, "cliproxyapi"); + assert.equal(chain[0].priority, 1); + assert.equal(chain[1].executor, "native"); + assert.equal(chain[1].priority, 10); + }); + + it("should return empty chain when disabled", () => { + upsert(testDb, { providerId: "dis-fb", mode: "fallback", enabled: false }); + assert.deepEqual(getFallbackChain(testDb, "dis-fb"), []); + }); + }); +}); diff --git a/tests/unit/db-versionManager.test.mjs b/tests/unit/db-versionManager.test.mjs new file mode 100644 index 0000000000..aaefb08699 --- /dev/null +++ b/tests/unit/db-versionManager.test.mjs @@ -0,0 +1,368 @@ +import { describe, it, beforeEach, afterEach, after } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import Database from "better-sqlite3"; + +const fileTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-dbvm-test-")); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS version_manager ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool TEXT NOT NULL UNIQUE, + current_version TEXT, + installed_version TEXT, + pinned_version TEXT, + binary_path TEXT, + status TEXT NOT NULL DEFAULT 'not_installed', + pid INTEGER, + port INTEGER DEFAULT 8317, + api_key TEXT, + management_key TEXT, + auto_update INTEGER NOT NULL DEFAULT 1, + auto_start INTEGER NOT NULL DEFAULT 0, + last_health_check TEXT, + last_update_check TEXT, + health_status TEXT DEFAULT 'unknown', + config_overrides TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`; + +let testDb; +let testDbPath; + +beforeEach(() => { + testDbPath = path.join(fileTmpDir, `test-${Date.now()}.db`); + testDb = new Database(testDbPath); + testDb.exec(SCHEMA); +}); + +afterEach(() => { + testDb.close(); + try { + fs.unlinkSync(testDbPath); + } catch {} +}); + +after(() => { + if (fs.existsSync(fileTmpDir)) fs.rmSync(fileTmpDir, { recursive: true, force: true }); +}); + +function upsertTool(db, data) { + db.prepare( + ` + INSERT INTO version_manager ( + tool, current_version, installed_version, pinned_version, binary_path, + status, pid, port, api_key, management_key, auto_update, auto_start, + health_status, config_overrides, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT(tool) DO UPDATE SET + current_version = excluded.current_version, + installed_version = excluded.installed_version, + pinned_version = excluded.pinned_version, + binary_path = excluded.binary_path, + status = excluded.status, + pid = excluded.pid, + port = excluded.port, + api_key = excluded.api_key, + management_key = excluded.management_key, + auto_update = excluded.auto_update, + auto_start = excluded.auto_start, + health_status = excluded.health_status, + config_overrides = excluded.config_overrides, + error_message = excluded.error_message, + updated_at = datetime('now') + ` + ).run( + data.tool, + data.currentVersion ?? null, + data.installedVersion ?? null, + data.pinnedVersion ?? null, + data.binaryPath ?? null, + data.status ?? "not_installed", + data.pid ?? null, + data.port ?? 8317, + data.apiKey ?? null, + data.managementKey ?? null, + data.autoUpdate !== undefined ? (data.autoUpdate ? 1 : 0) : 1, + data.autoStart !== undefined ? (data.autoStart ? 1 : 0) : 0, + data.healthStatus ?? "unknown", + data.configOverrides !== undefined + ? data.configOverrides + ? JSON.stringify(data.configOverrides) + : null + : null, + data.errorMessage ?? null + ); + return db.prepare("SELECT * FROM version_manager WHERE tool = ?").get(data.tool); +} + +function parseConfigOverrides(value) { + if (!value || typeof value !== "string" || value.trim() === "") return null; + try { + const parsed = JSON.parse(value); + return typeof parsed === "object" && parsed !== null ? parsed : null; + } catch { + return null; + } +} + +function toTool(row) { + if (!row) return null; + return { + id: row.id, + tool: row.tool, + currentVersion: row.current_version, + installedVersion: row.installed_version, + pinnedVersion: row.pinned_version, + binaryPath: row.binary_path, + status: row.status, + pid: row.pid, + port: row.port, + apiKey: row.api_key, + managementKey: row.management_key, + autoUpdate: row.auto_update === 1, + autoStart: row.auto_start === 1, + lastHealthCheck: row.last_health_check, + lastUpdateCheck: row.last_update_check, + healthStatus: row.health_status, + configOverrides: parseConfigOverrides(row.config_overrides), + errorMessage: row.error_message, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +describe("db/versionManager (logic)", () => { + describe("upsertVersionManagerTool", () => { + it("should insert a new tool and read it back", () => { + const tool = toTool( + upsertTool(testDb, { + tool: "cliproxyapi", + installedVersion: "6.9.7", + binaryPath: "/tmp/bin/cliproxyapi", + status: "installed", + }) + ); + assert.equal(tool.tool, "cliproxyapi"); + assert.equal(tool.installedVersion, "6.9.7"); + assert.equal(tool.binaryPath, "/tmp/bin/cliproxyapi"); + assert.equal(tool.status, "installed"); + assert.equal(tool.port, 8317); + assert.equal(tool.autoUpdate, true); + assert.equal(tool.autoStart, false); + assert.equal(tool.healthStatus, "unknown"); + assert.ok(tool.id > 0); + }); + + it("should update on conflict (upsert)", () => { + upsertTool(testDb, { tool: "test-tool", status: "installed" }); + const tool = toTool( + upsertTool(testDb, { tool: "test-tool", installedVersion: "7.0.0", status: "running" }) + ); + assert.equal(tool.installedVersion, "7.0.0"); + assert.equal(tool.status, "running"); + }); + + it("should store boolean fields as 0/1", () => { + const tool = toTool( + upsertTool(testDb, { tool: "bool-test", autoUpdate: false, autoStart: true }) + ); + assert.equal(tool.autoUpdate, false); + assert.equal(tool.autoStart, true); + }); + + it("should store config overrides as JSON", () => { + const tool = toTool(upsertTool(testDb, { tool: "cfg", configOverrides: { port: 9999 } })); + assert.deepEqual(tool.configOverrides, { port: 9999 }); + }); + + it("should handle null config overrides", () => { + const tool = toTool(upsertTool(testDb, { tool: "null-cfg" })); + assert.equal(tool.configOverrides, null); + }); + + it("should handle pid and error message", () => { + const tool = toTool( + upsertTool(testDb, { tool: "pid-test", pid: 12345, errorMessage: "err" }) + ); + assert.equal(tool.pid, 12345); + assert.equal(tool.errorMessage, "err"); + }); + }); + + describe("getVersionManagerTool", () => { + it("should return null for non-existent tool", () => { + assert.equal( + toTool(testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("ghost")), + null + ); + }); + + it("should return tool by name", () => { + upsertTool(testDb, { tool: "findme", installedVersion: "1.0.0" }); + const tool = toTool( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("findme") + ); + assert.equal(tool.installedVersion, "1.0.0"); + }); + }); + + describe("getVersionManagerStatus", () => { + it("should return all tools", () => { + upsertTool(testDb, { tool: "a" }); + upsertTool(testDb, { tool: "b" }); + assert.equal(testDb.prepare("SELECT * FROM version_manager").all().length, 2); + }); + + it("should return empty when no tools", () => { + assert.equal(testDb.prepare("SELECT * FROM version_manager").all().length, 0); + }); + }); + + describe("updateVersionManagerTool (partial)", () => { + it("should update pinnedVersion and autoUpdate", () => { + upsertTool(testDb, { tool: "upd", status: "installed" }); + testDb + .prepare( + "UPDATE version_manager SET pinned_version = ?, auto_update = ?, updated_at = datetime('now') WHERE tool = ?" + ) + .run("6.8.0", 0, "upd"); + const tool = toTool( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("upd") + ); + assert.equal(tool.pinnedVersion, "6.8.0"); + assert.equal(tool.autoUpdate, false); + assert.equal(tool.status, "installed"); + }); + + it("should set fields to null", () => { + upsertTool(testDb, { tool: "nulls", installedVersion: "1.0.0", pid: 42 }); + testDb + .prepare("UPDATE version_manager SET installed_version = NULL, pid = NULL WHERE tool = ?") + .run("nulls"); + const tool = toTool( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("nulls") + ); + assert.equal(tool.installedVersion, null); + assert.equal(tool.pid, null); + }); + }); + + describe("deleteVersionManagerTool", () => { + it("should delete existing tool", () => { + upsertTool(testDb, { tool: "del" }); + assert.equal( + testDb.prepare("DELETE FROM version_manager WHERE tool = ?").run("del").changes, + 1 + ); + assert.equal( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("del"), + undefined + ); + }); + + it("should return 0 changes for non-existent", () => { + assert.equal( + testDb.prepare("DELETE FROM version_manager WHERE tool = ?").run("ghost").changes, + 0 + ); + }); + }); + + describe("updateToolHealth", () => { + it("should update health_status and last_health_check", () => { + upsertTool(testDb, { tool: "h" }); + const r = testDb + .prepare( + "UPDATE version_manager SET health_status = ?, last_health_check = datetime('now') WHERE tool = ?" + ) + .run("healthy", "h"); + assert.equal(r.changes, 1); + const tool = toTool(testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("h")); + assert.equal(tool.healthStatus, "healthy"); + assert.ok(tool.lastHealthCheck); + }); + + it("should return 0 changes for non-existent", () => { + assert.equal( + testDb + .prepare("UPDATE version_manager SET health_status = ? WHERE tool = ?") + .run("healthy", "ghost").changes, + 0 + ); + }); + }); + + describe("updateToolVersion", () => { + it("should update current_version", () => { + upsertTool(testDb, { tool: "v1" }); + testDb + .prepare("UPDATE version_manager SET current_version = ? WHERE tool = ?") + .run("7.0.0", "v1"); + assert.equal( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("v1").current_version, + "7.0.0" + ); + }); + + it("should update installed_version", () => { + upsertTool(testDb, { tool: "v2" }); + testDb + .prepare("UPDATE version_manager SET installed_version = ? WHERE tool = ?") + .run("7.0.0", "v2"); + assert.equal( + testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("v2").installed_version, + "7.0.0" + ); + }); + }); + + describe("setToolStatus", () => { + it("should update status with pid", () => { + upsertTool(testDb, { tool: "s1" }); + testDb + .prepare("UPDATE version_manager SET status = ?, pid = ?, error_message = ? WHERE tool = ?") + .run("running", 9999, "ok", "s1"); + const row = testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("s1"); + assert.equal(row.status, "running"); + assert.equal(row.pid, 9999); + assert.equal(row.error_message, "ok"); + }); + + it("should update status without pid", () => { + upsertTool(testDb, { tool: "s2" }); + testDb + .prepare("UPDATE version_manager SET status = ?, error_message = ? WHERE tool = ?") + .run("error", "crashed", "s2"); + const row = testDb.prepare("SELECT * FROM version_manager WHERE tool = ?").get("s2"); + assert.equal(row.status, "error"); + }); + + it("should return 0 for non-existent", () => { + assert.equal( + testDb + .prepare("UPDATE version_manager SET status = ? WHERE tool = ?") + .run("running", "ghost").changes, + 0 + ); + }); + }); + + describe("parseConfigOverrides", () => { + it("should parse valid JSON", () => { + assert.deepEqual(parseConfigOverrides('{"key":"val"}'), { key: "val" }); + }); + + it("should return null for invalid JSON", () => { + assert.equal(parseConfigOverrides("not-json"), null); + assert.equal(parseConfigOverrides(""), null); + assert.equal(parseConfigOverrides(null), null); + assert.equal(parseConfigOverrides("123"), null); + }); + }); +}); From 90ed6163f521e1234e84dbb8aa76057b73f5a4e2 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Thu, 2 Apr 2026 15:06:09 +0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(cliproxyapi):=20address=20PR=20#914=20r?= =?UTF-8?q?eview=20=E2=80=94=20types,=20SSRF,=20SQL=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add proper TS interfaces to CliproxyapiSettingsTab (replace any/Record) - Add HTTP status checks and error handling on all fetches - Add client-side URL validation before saving proxy URL - Add error state display for version manager service - Document localhost SSRF exception in isPrivateHost - Add ALLOWED_COLUMNS whitelist to updateVersionManagerTool --- .omc/project-memory.json | 250 ++++++++++++++++++ .../53c002c3-36a6-47c3-a52d-a8f756c264eb.json | 8 + .../components/CliproxyapiSettingsTab.tsx | 82 ++++-- src/lib/db/migrations/014_create_memories.sql | 22 ++ .../014_create_memories_down.sql.bak | 4 + src/lib/db/migrations/015_create_skills.sql | 37 +++ .../migrations/015_create_skills_down.sql.bak | 5 + src/lib/db/upstreamProxy.ts | 3 +- src/lib/db/versionManager.ts | 18 ++ 9 files changed, 413 insertions(+), 16 deletions(-) create mode 100644 .omc/project-memory.json create mode 100644 .omc/sessions/53c002c3-36a6-47c3-a52d-a8f756c264eb.json create mode 100644 src/lib/db/migrations/014_create_memories.sql create mode 100644 src/lib/db/migrations/014_create_memories_down.sql.bak create mode 100644 src/lib/db/migrations/015_create_skills.sql create mode 100644 src/lib/db/migrations/015_create_skills_down.sql.bak diff --git a/.omc/project-memory.json b/.omc/project-memory.json new file mode 100644 index 0000000000..7018fa00e1 --- /dev/null +++ b/.omc/project-memory.json @@ -0,0 +1,250 @@ +{ + "version": "1.0.0", + "lastScanned": 1775016362438, + "projectRoot": "/home/openclaw/omniroute-src", + "techStack": { + "languages": [ + { + "name": "JavaScript/TypeScript", + "version": ">=18.0.0 <24.0.0", + "confidence": "high", + "markers": ["package.json"] + }, + { + "name": "TypeScript", + "version": null, + "confidence": "high", + "markers": ["tsconfig.json"] + } + ], + "frameworks": [ + { + "name": "express", + "version": "5.2.1", + "category": "backend" + }, + { + "name": "next", + "version": "16.0.10", + "category": "fullstack" + }, + { + "name": "react", + "version": "19.2.4", + "category": "frontend" + }, + { + "name": "react-dom", + "version": "19.2.4", + "category": "frontend" + }, + { + "name": "@playwright/test", + "version": "1.58.2", + "category": "testing" + }, + { + "name": "vitest", + "version": "4.0.18", + "category": "testing" + } + ], + "packageManager": "npm", + "runtime": "Node.js 18.0.024.0.0" + }, + "build": { + "buildCommand": "npm run build", + "testCommand": "npm test", + "lintCommand": "npm run lint", + "devCommand": "npm run dev", + "scripts": { + "dev": "node scripts/run-next.mjs dev", + "build": "node scripts/build-next-isolated.mjs", + "build:cli": "node scripts/prepublish.mjs", + "start": "node scripts/run-next.mjs start", + "lint": "eslint .", + "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"", + "electron:build": "npm run build && cd electron && npm run build", + "electron:build:win": "npm run build && cd electron && npm run build:win", + "electron:build:mac": "npm run build && cd electron && npm run build:mac", + "electron:build:linux": "npm run build && cd electron && npm run build:linux", + "test": "node --import tsx/esm --test tests/unit/*.test.mjs", + "test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs", + "test:plan3": "node --import tsx/esm --test tests/unit/plan3-p0.test.mjs", + "test:fixes": "node --import tsx/esm --test tests/unit/fixes-p1.test.mjs", + "test:security": "node --import tsx/esm --test tests/unit/security-fase01.test.mjs", + "check:cycles": "node scripts/check-cycles.mjs", + "check:route-validation:t06": "node scripts/check-route-validation.mjs", + "check:any-budget:t11": "node scripts/check-t11-any-budget.mjs", + "check:docs-sync": "node scripts/check-docs-sync.mjs", + "typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json", + "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", + "test:integration": "node --import tsx/esm --test tests/integration/*.test.mjs", + "test:e2e": "node scripts/run-playwright-tests.mjs test tests/e2e/*.spec.ts", + "test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs", + "test:vitest": "vitest run open-sse/mcp-server/__tests__/*.test.ts open-sse/services/autoCombo/__tests__/*.test.ts", + "test:ecosystem": "node scripts/run-ecosystem-tests.mjs", + "test:coverage": "c8 --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs", + "test:coverage:legacy": "c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs", + "coverage:report": "c8 report --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", + "coverage:report:legacy": "c8 report --exclude=open-sse --reporter=text --reporter=text-summary", + "test:all": "npm run test:unit && npm run test:vitest && npm run test:ecosystem && npm run test:e2e", + "check": "npm run lint && npm run test", + "prepublishOnly": "npm run build:cli", + "postinstall": "node scripts/postinstall.mjs", + "prepare": "husky", + "system-info": "node scripts/system-info.mjs" + } + }, + "conventions": { + "namingStyle": "camelCase", + "importStyle": null, + "testPattern": null, + "fileOrganization": null + }, + "structure": { + "isMonorepo": true, + "workspaces": ["open-sse"], + "mainDirectories": ["bin", "docs", "public", "scripts", "src", "tests"], + "gitBranches": { + "defaultBranch": "main", + "branchingStrategy": null + } + }, + "customNotes": [], + "directoryMap": { + "bin": { + "path": "bin", + "purpose": "Executable scripts", + "fileCount": 3, + "lastAccessed": 1775016362426, + "keyFiles": ["mcp-server.mjs", "omniroute.mjs", "reset-password.mjs"] + }, + "docs": { + "path": "docs", + "purpose": "Documentation", + "fileCount": 14, + "lastAccessed": 1775016362426, + "keyFiles": [ + "A2A-SERVER.md", + "API_REFERENCE.md", + "ARCHITECTURE.md", + "AUTO-COMBO.md", + "CLI-TOOLS.md" + ] + }, + "electron": { + "path": "electron", + "purpose": null, + "fileCount": 5, + "lastAccessed": 1775016362431, + "keyFiles": ["README.md", "main.js", "package.json", "preload.js", "types.d.ts"] + }, + "images": { + "path": "images", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1775016362434, + "keyFiles": ["omniroute.png"] + }, + "logs": { + "path": "logs", + "purpose": null, + "fileCount": 3, + "lastAccessed": 1775016362434, + "keyFiles": ["build_clean_tools.log", "build_debug.log", "build_force_clean.log"] + }, + "open-sse": { + "path": "open-sse", + "purpose": null, + "fileCount": 5, + "lastAccessed": 1775016362434, + "keyFiles": ["index.ts", "package.json", "tsconfig.json", "types.d.ts"] + }, + "public": { + "path": "public", + "purpose": "Public files", + "fileCount": 3, + "lastAccessed": 1775016362435, + "keyFiles": ["apple-touch-icon.svg", "favicon.svg", "icon-192.svg"] + }, + "scripts": { + "path": "scripts", + "purpose": "Build/utility scripts", + "fileCount": 23, + "lastAccessed": 1775016362435, + "keyFiles": [ + "bootstrap-env.mjs", + "build-next-isolated.mjs", + "check-cycles.mjs", + "check-docs-sync.mjs", + "check-route-validation.mjs" + ] + }, + "src": { + "path": "src", + "purpose": "Source code", + "fileCount": 4, + "lastAccessed": 1775016362435, + "keyFiles": ["instrumentation-node.ts", "instrumentation.ts", "proxy.ts", "server-init.ts"] + }, + "tests": { + "path": "tests", + "purpose": "Test files", + "fileCount": 0, + "lastAccessed": 1775016362435, + "keyFiles": [] + }, + "electron/assets": { + "path": "electron/assets", + "purpose": "Static assets", + "fileCount": 4, + "lastAccessed": 1775016362436, + "keyFiles": ["icon.icns", "icon.ico", "icon.png"] + }, + "open-sse/config": { + "path": "open-sse/config", + "purpose": "Configuration files", + "fileCount": 17, + "lastAccessed": 1775016362436, + "keyFiles": ["audioRegistry.ts", "cliFingerprints.ts", "codexInstructions.ts"] + }, + "open-sse/services": { + "path": "open-sse/services", + "purpose": "Business logic services", + "fileCount": 35, + "lastAccessed": 1775016362437, + "keyFiles": ["accountFallback.ts", "accountSelector.ts", "apiKeyRotator.ts"] + }, + "src/app": { + "path": "src/app", + "purpose": "Application code", + "fileCount": 7, + "lastAccessed": 1775016362438, + "keyFiles": ["error.tsx", "global-error.tsx", "globals.css"] + }, + "src/lib": { + "path": "src/lib", + "purpose": "Library code", + "fileCount": 30, + "lastAccessed": 1775016362438, + "keyFiles": ["apiBridgeServer.ts", "apiKeyExposure.ts", "cacheControlSettings.ts"] + }, + "src/middleware": { + "path": "src/middleware", + "purpose": "Middleware", + "fileCount": 1, + "lastAccessed": 1775016362438, + "keyFiles": ["promptInjectionGuard.ts"] + }, + "src/models": { + "path": "src/models", + "purpose": "Data models", + "fileCount": 1, + "lastAccessed": 1775016362438, + "keyFiles": ["index.ts"] + } + }, + "hotPaths": [], + "userDirectives": [] +} diff --git a/.omc/sessions/53c002c3-36a6-47c3-a52d-a8f756c264eb.json b/.omc/sessions/53c002c3-36a6-47c3-a52d-a8f756c264eb.json new file mode 100644 index 0000000000..f3dfd0f299 --- /dev/null +++ b/.omc/sessions/53c002c3-36a6-47c3-a52d-a8f756c264eb.json @@ -0,0 +1,8 @@ +{ + "session_id": "53c002c3-36a6-47c3-a52d-a8f756c264eb", + "ended_at": "2026-04-01T04:06:04.924Z", + "reason": "prompt_input_exit", + "agents_spawned": 0, + "agents_completed": 0, + "modes_used": [] +} diff --git a/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx index 12094623ed..43ee16d302 100644 --- a/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/CliproxyapiSettingsTab.tsx @@ -1,34 +1,82 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Card, Button, Input, Toggle } from "@/shared/components"; +interface Settings { + cliproxyapi_fallback_enabled?: boolean; + cliproxyapi_url?: string; + cliproxyapi_fallback_codes?: string; + [key: string]: unknown; +} + +interface VersionManagerEntry { + tool: string; + status: string; + installedVersion: string | null; + healthStatus: string; + port: number; +} + +function isValidUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + export default function CliproxyapiSettingsTab() { - const [settings, setSettings] = useState>({}); + const [settings, setSettings] = useState({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: string; text: string } | null>(null); - const [toolState, setToolState] = useState(null); + const [toolState, setToolState] = useState(null); + const [toolStateError, setToolStateError] = useState(null); useEffect(() => { fetch("/api/settings") - .then((r) => r.json()) + .then((r) => { + if (!r.ok) throw new Error(`Settings API returned ${r.status}`); + return r.json(); + }) .then((data) => { setSettings(data); setLoading(false); }) - .catch(() => setLoading(false)); + .catch((err) => { + console.error("Failed to load settings:", err); + setLoading(false); + }); fetch("/api/version-manager/status") - .then((r) => r.json()) - .then((data) => { - const entry = Array.isArray(data) ? data.find((t: any) => t.tool === "cliproxyapi") : null; - setToolState(entry); + .then((r) => { + if (!r.ok) throw new Error(`Version manager API returned ${r.status}`); + return r.json(); }) - .catch(() => {}); + .then((data) => { + const entry = Array.isArray(data) + ? data.find((t: VersionManagerEntry) => t.tool === "cliproxyapi") + : null; + setToolState(entry ?? null); + setToolStateError(null); + }) + .catch((err) => { + console.error("Failed to load version manager status:", err); + setToolStateError("Unable to reach version manager service"); + setToolState(null); + }); }, []); - const updateSetting = async (key: string, value: any) => { + const updateSetting = useCallback(async (key: string, value: boolean | string) => { + if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") { + if (!isValidUrl(value)) { + setMessage({ type: "error", text: "Invalid URL format. Use http:// or https://" }); + return; + } + } + setSaving(true); setMessage(null); try { @@ -37,16 +85,18 @@ export default function CliproxyapiSettingsTab() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ [key]: value }), }); - if (res.ok) { - setSettings((prev) => ({ ...prev, [key]: value })); - setMessage({ type: "success", text: "Setting saved" }); + if (!res.ok) { + throw new Error(`Server returned ${res.status}`); } + await res.json(); + setSettings((prev) => ({ ...prev, [key]: value })); + setMessage({ type: "success", text: "Setting saved" }); } catch { setMessage({ type: "error", text: "Failed to save setting" }); } finally { setSaving(false); } - }; + }, []); const cpaEnabled = settings.cliproxyapi_fallback_enabled === true; const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317"; @@ -142,6 +192,8 @@ export default function CliproxyapiSettingsTab() { Loading... + ) : toolStateError ? ( +

{toolStateError}

) : toolState ? (
diff --git a/src/lib/db/migrations/014_create_memories.sql b/src/lib/db/migrations/014_create_memories.sql new file mode 100644 index 0000000000..e98b094391 --- /dev/null +++ b/src/lib/db/migrations/014_create_memories.sql @@ -0,0 +1,22 @@ +-- 014_create_memories.sql +-- Memories table for persistent context storage. +-- Stores structured conversation memories with support for different memory types. + +CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL, + session_id TEXT, + type TEXT NOT NULL CHECK(type IN ('factual', 'episodic', 'procedural', 'semantic')), + key TEXT, + content TEXT NOT NULL, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT +); + +-- Indexes for performance optimization +CREATE INDEX IF NOT EXISTS idx_memories_api_key ON memories(api_key_id); +CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id); +CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type); +CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at); diff --git a/src/lib/db/migrations/014_create_memories_down.sql.bak b/src/lib/db/migrations/014_create_memories_down.sql.bak new file mode 100644 index 0000000000..68a218d09a --- /dev/null +++ b/src/lib/db/migrations/014_create_memories_down.sql.bak @@ -0,0 +1,4 @@ +-- 014_create_memories_down.sql +-- DOWN Migration: Remove memories table (Rollback) + +DROP TABLE IF EXISTS memories; diff --git a/src/lib/db/migrations/015_create_skills.sql b/src/lib/db/migrations/015_create_skills.sql new file mode 100644 index 0000000000..f765efb284 --- /dev/null +++ b/src/lib/db/migrations/015_create_skills.sql @@ -0,0 +1,37 @@ +-- 015_create_skills.sql +-- Skills table for tool/function capability injection. +-- Stores skill definitions with schemas and execution tracking. + +CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '1.0.0', + description TEXT, + schema TEXT NOT NULL, + handler TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS skill_executions ( + id TEXT PRIMARY KEY, + skill_id TEXT NOT NULL, + api_key_id TEXT NOT NULL, + session_id TEXT, + input TEXT NOT NULL, + output TEXT, + status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'success', 'error', 'timeout')), + error_message TEXT, + duration_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_skills_api_key ON skills(api_key_id); +CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name); +CREATE INDEX IF NOT EXISTS idx_skill_executions_skill ON skill_executions(skill_id); +CREATE INDEX IF NOT EXISTS idx_skill_executions_api_key ON skill_executions(api_key_id); +CREATE INDEX IF NOT EXISTS idx_skill_executions_status ON skill_executions(status); +CREATE INDEX IF NOT EXISTS idx_skill_executions_created ON skill_executions(created_at); \ No newline at end of file diff --git a/src/lib/db/migrations/015_create_skills_down.sql.bak b/src/lib/db/migrations/015_create_skills_down.sql.bak new file mode 100644 index 0000000000..9bc12d0247 --- /dev/null +++ b/src/lib/db/migrations/015_create_skills_down.sql.bak @@ -0,0 +1,5 @@ +-- 015_create_skills_down.sql +-- Rollback skills and skill_executions tables + +DROP TABLE IF EXISTS skill_executions; +DROP TABLE IF EXISTS skills; \ No newline at end of file diff --git a/src/lib/db/upstreamProxy.ts b/src/lib/db/upstreamProxy.ts index fdad87e73c..e91a61b094 100644 --- a/src/lib/db/upstreamProxy.ts +++ b/src/lib/db/upstreamProxy.ts @@ -32,8 +32,9 @@ function toRecord(value: unknown): Record { const BLOCKED_HOSTNAMES = ["metadata.google.internal", "169.254.169.254", "metadata.aws.internal"]; function isPrivateHost(hostname: string): boolean { - if (BLOCKED_HOSTNAMES.includes(hostname)) return true; + // CLIProxyAPI runs on localhost:8317 — allow loopback explicitly if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return false; + if (BLOCKED_HOSTNAMES.includes(hostname)) return true; if ( /^10\./.test(hostname) || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || diff --git a/src/lib/db/versionManager.ts b/src/lib/db/versionManager.ts index d72339b80e..894efb9de5 100644 --- a/src/lib/db/versionManager.ts +++ b/src/lib/db/versionManager.ts @@ -226,10 +226,28 @@ export async function updateVersionManagerTool( const existing = await getVersionManagerTool(tool); if (!existing) return null; + const ALLOWED_COLUMNS = new Set([ + "currentVersion", + "installedVersion", + "pinnedVersion", + "binaryPath", + "status", + "pid", + "port", + "apiKey", + "managementKey", + "autoUpdate", + "autoStart", + "healthStatus", + "configOverrides", + "errorMessage", + ]); + const sets: string[] = ["updated_at = datetime('now')"]; const params: Record = { tool }; for (const [key, value] of Object.entries(updates)) { + if (!ALLOWED_COLUMNS.has(key)) continue; const dbKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); if (key === "configOverrides") { From 12513536941452901cbe6f1bb6f4a39ac53c557c Mon Sep 17 00:00:00 2001 From: oyi77 Date: Thu, 2 Apr 2026 15:58:22 +0700 Subject: [PATCH 3/3] fix(cliproxyapi): wire validateProxyUrl and sync settings to upstream_proxy_config - Validate CLIProxyAPI URL with validateProxyUrl before saving - Sync cliproxyapi_url and cliproxyapi_fallback_enabled to upstream_proxy_config DB - Bridge the gap between settings UI and executor data sources --- src/app/api/settings/route.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 575090df5c..525bcf849d 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -8,6 +8,7 @@ import { updateSettingsSchema } from "@/shared/validation/settingsSchemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { setCliCompatProviders } from "../../../../open-sse/config/cliFingerprints"; import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { validateProxyUrl, upsertUpstreamProxyConfig } from "@/lib/db/upstreamProxy"; export async function GET() { try { @@ -102,6 +103,29 @@ export async function PATCH(request) { const settings = await updateSettings(body); + // Sync CLIProxyAPI settings to upstream_proxy_config table + const cpaUrl = rawBody.cliproxyapi_url as string | undefined; + const cpaFallback = rawBody.cliproxyapi_fallback_enabled as boolean | undefined; + if (cpaUrl && typeof cpaUrl === "string") { + const urlValidation = validateProxyUrl(cpaUrl); + if (urlValidation.valid === false) { + return NextResponse.json( + { error: `Invalid CLIProxyAPI URL: ${urlValidation.error}` }, + { status: 400 } + ); + } + } + if (cpaFallback !== undefined || cpaUrl !== undefined) { + const enabled = + cpaFallback ?? (settings as Record).cliproxyapi_fallback_enabled; + const mode = enabled ? "fallback" : "native"; + await upsertUpstreamProxyConfig({ + providerId: "cliproxyapi", + mode, + enabled: !!enabled, + }); + } + // Clear health check log cache if that setting was updated if ("hideHealthCheckLogs" in body) { clearHealthCheckLogCache();