From 448a8aedc72ee59f5e5db4d4a28ba629193bb747 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Thu, 16 Jul 2026 16:52:37 +0700 Subject: [PATCH] feat: add the feature for user save combo --- CHANGELOG.md | 1 + docker-compose.yml | 2 +- .../cli-tools/[toolId]/ToolDetailClient.js | 43 +++- .../cli-tools/components/ApiKeySelect.js | 56 +++-- .../cli-tools/components/BaseUrlSelect.js | 72 +++---- .../components/ConfigGeneratorCard.js | 100 +++++++-- .../cli-tools/components/DefaultToolCard.js | 68 +++++- .../api/cli-tools/config/[toolId]/route.js | 65 ++++++ src/lib/db/index.js | 25 +++ src/lib/db/repos/cliToolConfigsRepo.js | 49 +++++ src/lib/db/repos/usersRepo.js | 9 +- src/lib/db/schema.js | 13 +- src/lib/localDb.js | 2 + src/shared/constants/cliToolConfig.js | 168 +++++++++++++++ src/shared/utils/cliToolEndpoint.js | 68 ++++++ .../golden-url-header.test.js.snap | 201 ++++++++++++++++++ tests/unit/cli-tool-config-contract.test.js | 98 +++++++++ tests/unit/cli-tool-config-db.test.js | 104 +++++++++ tests/unit/cli-tool-config-route.test.js | 120 +++++++++++ tests/unit/cli-tool-endpoint.test.js | 65 ++++++ 20 files changed, 1215 insertions(+), 114 deletions(-) create mode 100644 src/app/api/cli-tools/config/[toolId]/route.js create mode 100644 src/lib/db/repos/cliToolConfigsRepo.js create mode 100644 src/shared/constants/cliToolConfig.js create mode 100644 src/shared/utils/cliToolEndpoint.js create mode 100644 tests/unit/cli-tool-config-contract.test.js create mode 100644 tests/unit/cli-tool-config-db.test.js create mode 100644 tests/unit/cli-tool-config-route.test.js create mode 100644 tests/unit/cli-tool-endpoint.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ba2133..bd94bd0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409) ## Fixes +- **CLI Tools**: persist non-MITM tool configurations per dashboard user, select the runtime deployment endpoint, and keep custom API keys out of storage - **Cloudflare-AI**: support accountId in bulk key import (#2449) - **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM - **Codex**: avoid bare-email OAuth dedup (#2477) diff --git a/docker-compose.yml b/docker-compose.yml index 86badd9c..d2a4c5ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: resources: limits: cpus: "0.5" - memory: 2G + memory: 512M # Dokploy/Traefik should route to this internal port. Do not publish it # with `ports:`; public HTTP(S) is provided by the Dokploy domain router. expose: diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index 73ad91fa..664bab40 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -1,12 +1,14 @@ "use client"; -import { useState, useEffect } from "react"; +import { useCallback, useState, useEffect } from "react"; import Link from "next/link"; import { CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; +import { resolveCliToolBaseUrl } from "@/shared/utils/cliToolEndpoint"; import { ConfigGeneratorCard, DefaultToolCard } from "../components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +const CONFIGURED_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL; export default function ToolDetailClient({ toolId, machineId }) { const tool = CLI_TOOLS[toolId]; @@ -19,17 +21,19 @@ export default function ToolDetailClient({ toolId, machineId }) { const [tailscaleUrl, setTailscaleUrl] = useState(""); const [apiKeys, setApiKeys] = useState([]); const [availableModels, setAvailableModels] = useState([]); + const [initialConfig, setInitialConfig] = useState(null); useEffect(() => { let mounted = true; (async () => { try { - const [provRes, settingsRes, tunnelRes, keysRes, modelsRes] = await Promise.all([ + const [provRes, settingsRes, tunnelRes, keysRes, modelsRes, configRes] = await Promise.all([ fetch("/api/providers"), fetch("/api/settings"), fetch("/api/tunnel/status"), fetch("/api/keys"), fetch("/api/models/connected", { cache: "no-store" }), + fetch(`/api/cli-tools/config/${encodeURIComponent(toolId)}`, { cache: "no-store" }), ]); if (!mounted) return; if (provRes.ok) { @@ -55,6 +59,10 @@ export default function ToolDetailClient({ toolId, machineId }) { const data = await modelsRes.json(); setAvailableModels((data.models || []).filter((model) => !model.disabled)); } + if (configRes.ok) { + const data = await configRes.json(); + setInitialConfig(data.config || null); + } } catch (error) { console.log("Error loading tool data:", error); } finally { @@ -62,15 +70,34 @@ export default function ToolDetailClient({ toolId, machineId }) { } })(); return () => { mounted = false; }; - }, []); + }, [toolId]); + + const saveConfig = useCallback(async (config) => { + const response = await fetch(`/api/cli-tools/config/${encodeURIComponent(toolId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || "Failed to save configuration"); + setInitialConfig(data.config); + return data.config; + }, [toolId]); const getActiveProviders = () => connections.filter(c => c.isActive !== false); const getBaseUrl = () => { - if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl; - if (cloudEnabled && CLOUD_URL) return CLOUD_URL; - if (typeof window !== "undefined") return window.location.origin; - return "http://localhost:20128"; + return resolveCliToolBaseUrl({ + appUrl: typeof window !== "undefined" ? window.location.origin : "", + configuredBaseUrl: CONFIGURED_BASE_URL, + requiresExternalUrl: tool?.requiresExternalUrl === true, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, + cloudEnabled, + cloudUrl: CLOUD_URL, + }); }; const renderToolCard = () => { @@ -86,6 +113,8 @@ export default function ToolDetailClient({ toolId, machineId }) { activeProviders: getActiveProviders(), availableModels, cloudEnabled, + initialConfig, + onSaveConfig: saveConfig, }; if (tool.configType === "guide") return ; diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js index e0f24f36..e55853e6 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ApiKeySelect.js @@ -1,65 +1,57 @@ "use client"; -import { useState } from "react"; - const CUSTOM_VALUE = "__custom__"; +const UNSET_VALUE = "__unset__"; -export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) { - const isCustom = !apiKeys.some((k) => k.key === value) && value !== ""; - const [mode, setMode] = useState(() => { - if (!value) return apiKeys.length > 0 ? apiKeys[0].key : CUSTOM_VALUE; - if (apiKeys.some((k) => k.key === value)) return value; - return CUSTOM_VALUE; - }); - const [customInput, setCustomInput] = useState(isCustom ? value : ""); +export default function ApiKeySelect({ value, onChange, apiKeys = [], className = "", mode = "managed", onModeChange }) { + const matchingKey = apiKeys.find((key) => key.key === value); + const selectedMode = mode === "custom" ? CUSTOM_VALUE : (matchingKey?.key || UNSET_VALUE); const handleSelect = (e) => { const next = e.target.value; - setMode(next); + if (next === UNSET_VALUE) return; if (next === CUSTOM_VALUE) { - setCustomInput(""); + onModeChange?.("custom"); onChange(""); } else { + onModeChange?.("managed"); onChange(next); } }; const handleCustomInput = (e) => { const v = e.target.value; - setCustomInput(v); + onModeChange?.("custom"); onChange(v); }; - const noKeys = apiKeys.length === 0 && mode !== CUSTOM_VALUE; - - if (noKeys && mode !== CUSTOM_VALUE) { - return ( - - {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} - - ); - } - return (
- {mode === CUSTOM_VALUE && ( - + {selectedMode === CUSTOM_VALUE && ( + <> + + {mode === "custom" && !value && ( + Custom keys are not saved. Enter it again to generate the configuration. + )} + )}
); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js index 36f65006..c8c1b019 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js @@ -1,18 +1,13 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { APP_CONFIG } from "@/shared/constants/config"; +import { ensureCliToolV1Endpoint, isLocalCliToolUrl } from "@/shared/utils/cliToolEndpoint"; const STORAGE_KEY = "9router.cliToolEndpointPresets"; const CUSTOM_VALUE = "__custom__"; const SAVE_VALUE = "__save__"; -const ensureV1 = (url) => { - const trimmed = (url || "").replace(/\/+$/, ""); - if (!trimmed) return ""; - return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`; -}; - const readSavedPresets = () => { if (typeof window === "undefined") return []; try { @@ -29,12 +24,15 @@ const writeSavedPresets = (presets) => { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); }; -const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => { +const buildOptions = ({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => { const opts = []; - const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, "")); - if (!requiresExternalUrl) { - const localUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`); - opts.push({ value: "local", label: localUrl, url: localUrl }); + const wrap = (url) => (withV1 ? ensureCliToolV1Endpoint(url) : (url || "").replace(/\/+$/, "")); + const runtimeUrl = wrap(appUrl); + if (runtimeUrl && (!requiresExternalUrl || !isLocalCliToolUrl(runtimeUrl))) { + opts.push({ value: isLocalCliToolUrl(runtimeUrl) ? "local" : "deployment", label: runtimeUrl, url: runtimeUrl }); + } else if (!requiresExternalUrl) { + const fallbackLocalUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`); + opts.push({ value: "local", label: fallbackLocalUrl, url: fallbackLocalUrl }); } if (tunnelEnabled && tunnelPublicUrl) { const u = wrap(tunnelPublicUrl); @@ -58,6 +56,7 @@ const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tai export default function BaseUrlSelect({ value, onChange, + appUrl = "", requiresExternalUrl = false, tunnelEnabled = false, tunnelPublicUrl = "", @@ -69,31 +68,24 @@ export default function BaseUrlSelect({ }) { const [savedPresets, setSavedPresets] = useState([]); const [mode, setMode] = useState(""); - const [customInput, setCustomInput] = useState(""); - const initializedRef = useRef(false); useEffect(() => { - setSavedPresets(readSavedPresets()); + queueMicrotask(() => setSavedPresets(readSavedPresets())); }, []); const options = useMemo( - () => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }), - [requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1] + () => buildOptions({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }), + [appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1] ); - // Always default to first option (127.0.0.1) on mount, ignore persisted value - useEffect(() => { - if (initializedRef.current) return; - if (options.length === 0) return; - initializedRef.current = true; - const first = options.find((o) => o.value !== CUSTOM_VALUE); - if (first) { - setMode(first.value); - onChange(first.url); - } else { - setMode(CUSTOM_VALUE); - } - }, [options, onChange]); + const effectiveMode = useMemo(() => { + if (mode) return mode; + const normalizedValue = (value || "").replace(/\/+$/, ""); + const matchingOption = options.find((option) => option.value !== CUSTOM_VALUE && option.url.replace(/\/+$/, "") === normalizedValue); + if (matchingOption) return matchingOption.value; + if (value) return CUSTOM_VALUE; + return options.find((option) => option.value !== CUSTOM_VALUE)?.value || CUSTOM_VALUE; + }, [mode, options, value]); const handleSelect = (e) => { const next = e.target.value; @@ -112,7 +104,6 @@ export default function BaseUrlSelect({ } setMode(next); if (next === CUSTOM_VALUE) { - setCustomInput(""); onChange(""); return; } @@ -121,31 +112,28 @@ export default function BaseUrlSelect({ }; const handleCustomInput = (e) => { - const v = e.target.value; - setCustomInput(v); - onChange(v); + onChange(e.target.value); }; const handleDeleteSaved = () => { - if (!mode.startsWith("saved:")) return; - const name = mode.slice(6); + if (!effectiveMode.startsWith("saved:")) return; + const name = effectiveMode.slice(6); const updated = savedPresets.filter((p) => p.name !== name); setSavedPresets(updated); writeSavedPresets(updated); setMode(CUSTOM_VALUE); - setCustomInput(""); onChange(""); }; - const isSaved = mode.startsWith("saved:"); - const isCustom = mode === CUSTOM_VALUE; - const canSave = isCustom && (customInput || "").trim().length > 0; + const isSaved = effectiveMode.startsWith("saved:"); + const isCustom = effectiveMode === CUSTOM_VALUE; + const canSave = isCustom && (value || "").trim().length > 0; return (
apiKeys?.[0]?.key || ""); - const [selectedModels, setSelectedModels] = useState([]); - const [claudeModels, setClaudeModels] = useState({ sonnet: "", opus: "", haiku: "" }); - const [claudeThinking, setClaudeThinking] = useState({ sonnet: "", opus: "", haiku: "" }); + const restoredApiKey = initialConfig?.apiKeyId + ? apiKeys.find((key) => key.id === initialConfig.apiKeyId)?.key || "" + : ""; + const [selectedApiKey, setSelectedApiKey] = useState(() => ( + initialConfig?.apiKeyMode === "custom" + ? "" + : initialConfig?.apiKeyId ? restoredApiKey : apiKeys?.[0]?.key || "" + )); + const [apiKeyMode, setApiKeyMode] = useState(() => initialConfig?.apiKeyMode || "managed"); + const [selectedModels, setSelectedModels] = useState(() => initialConfig?.selectedModels || []); + const [claudeModels, setClaudeModels] = useState(() => initialConfig?.claudeModels || { sonnet: "", opus: "", haiku: "" }); + const [claudeThinking, setClaudeThinking] = useState(() => initialConfig?.claudeThinking || { sonnet: "", opus: "", haiku: "" }); const [claudeModelSlot, setClaudeModelSlot] = useState(""); - const [codexModel, setCodexModel] = useState(""); - const [codexThinking, setCodexThinking] = useState(""); - const [opencodeModels, setOpencodeModels] = useState([]); - const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(""); - const [coworkThinking, setCoworkThinking] = useState({}); - const [copilotTokens, setCopilotTokens] = useState({}); - const [copilotThinking, setCopilotThinking] = useState({}); + const [codexModel, setCodexModel] = useState(() => initialConfig?.codexModel || ""); + const [codexThinking, setCodexThinking] = useState(() => initialConfig?.codexThinking || ""); + const [opencodeModels, setOpencodeModels] = useState(() => initialConfig?.opencodeModels || []); + const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(() => initialConfig?.opencodeDefaultModel || ""); + const [coworkThinking, setCoworkThinking] = useState(() => initialConfig?.coworkThinking || {}); + const [copilotTokens, setCopilotTokens] = useState(() => initialConfig?.copilotTokens || {}); + const [copilotThinking, setCopilotThinking] = useState(() => initialConfig?.copilotThinking || {}); const connectedModels = availableModels; - const [customBaseUrl, setCustomBaseUrl] = useState(""); + const [customBaseUrl, setCustomBaseUrl] = useState(() => resolveInitialCliToolBaseUrl(initialConfig?.baseUrl, baseUrl)); const [modelModalOpen, setModelModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false); + const [saveStatus, setSaveStatus] = useState("idle"); + const [saveError, setSaveError] = useState(""); + const initializedSaveState = useRef(false); const effectiveBaseUrl = customBaseUrl || baseUrl; const apiKey = toolId === "copilot" @@ -198,6 +212,43 @@ export default function ConfigGeneratorCard({ [toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking, connectedModels] ); + const buildPersistableConfig = () => { + const config = { baseUrl: effectiveBaseUrl }; + if (toolId !== "copilot") { + config.apiKeyMode = apiKeyMode; + config.apiKeyId = apiKeyMode === "managed" + ? apiKeys.find((key) => key.key === selectedApiKey)?.id || null + : null; + } + if (toolId === "claude") Object.assign(config, { claudeModels, claudeThinking }); + if (toolId === "codex") Object.assign(config, { codexModel, codexThinking }); + if (toolId === "opencode") Object.assign(config, { opencodeModels, opencodeDefaultModel }); + if (toolId === "cowork") Object.assign(config, { selectedModels, coworkThinking }); + if (toolId === "copilot") Object.assign(config, { selectedModels, copilotThinking, copilotTokens }); + return config; + }; + + const handleSave = async () => { + setSaveStatus("saving"); + setSaveError(""); + try { + await onSaveConfig(buildPersistableConfig()); + setSaveStatus("saved"); + } catch (error) { + setSaveStatus("error"); + setSaveError(error.message || "Failed to save configuration"); + } + }; + + useEffect(() => { + if (!initializedSaveState.current) { + initializedSaveState.current = true; + return; + } + setSaveStatus((current) => current === "saving" ? current : "dirty"); + setSaveError(""); + }, [effectiveBaseUrl, apiKeyMode, selectedApiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking]); + const getThinkingLevelsForModel = (fullModel) => { const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel); if (!connectedModel?.provider?.id || !connectedModel.model) return null; @@ -307,8 +358,9 @@ export default function ConfigGeneratorCard({ )} {toolId === "claude" ? ( @@ -558,10 +610,20 @@ export default function ConfigGeneratorCard({ ) :

No model selected. The generated file uses {DEFAULT_MODEL} as a placeholder.

}
)} - +
+ + + {saveStatus === "saved" && Configuration saved.} + {saveStatus === "dirty" && Unsaved changes} + {saveStatus === "error" && {saveError}} +
+ {apiKeyMode === "custom" && toolId !== "copilot" &&

The custom API key is never saved and must be entered again after reload.

}

9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.

diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index 58b5058e..d745d964 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -1,22 +1,38 @@ "use client"; -import { useEffect, useState } from "react"; -import { Card, ModelSelectModal } from "@/shared/components"; +import { useEffect, useRef, useState } from "react"; +import { Button, Card, ModelSelectModal } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import Image from "next/image"; import ApiKeySelect from "./ApiKeySelect"; -export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, tunnelEnabled = false }) { +export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, tunnelEnabled = false, initialConfig, onSaveConfig }) { const [copiedField, setCopiedField] = useState(null); const [showModelModal, setShowModelModal] = useState(false); const [modelValue, setModelValue] = useState(""); - const [selectedModels, setSelectedModels] = useState([]); + const [selectedModels, setSelectedModels] = useState(() => initialConfig?.selectedModels || []); const [isExpanded, setIsExpanded] = useState(true); - - // Initialize state directly with computed value - no need for useEffect - const [selectedApiKey, setSelectedApiKey] = useState(() => - apiKeys?.length > 0 ? apiKeys[0].key : "" - ); + const restoredApiKey = initialConfig?.apiKeyId + ? apiKeys.find((key) => key.id === initialConfig.apiKeyId)?.key || "" + : ""; + const [selectedApiKey, setSelectedApiKey] = useState(() => ( + initialConfig?.apiKeyMode === "custom" + ? "" + : initialConfig?.apiKeyId ? restoredApiKey : apiKeys?.[0]?.key || "" + )); + const [apiKeyMode, setApiKeyMode] = useState(() => initialConfig?.apiKeyMode || "managed"); + const [saveStatus, setSaveStatus] = useState("idle"); + const [saveError, setSaveError] = useState(""); + const initializedSaveState = useRef(false); + + useEffect(() => { + if (!initializedSaveState.current) { + initializedSaveState.current = true; + return; + } + setSaveStatus((current) => current === "saving" ? current : "dirty"); + setSaveError(""); + }, [apiKeyMode, selectedApiKey, selectedModels]); const replaceVars = (text) => { const keyToUse = (selectedApiKey && selectedApiKey.trim()) @@ -57,11 +73,29 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active setSelectedModels((current) => current.filter((value) => value !== model.value)); }; + const handleSave = async () => { + setSaveStatus("saving"); + setSaveError(""); + try { + await onSaveConfig({ + selectedModels, + apiKeyMode, + apiKeyId: apiKeyMode === "managed" + ? apiKeys.find((key) => key.key === selectedApiKey)?.id || null + : null, + }); + setSaveStatus("saved"); + } catch (error) { + setSaveStatus("error"); + setSaveError(error.message || "Failed to save configuration"); + } + }; + const hasActiveProviders = activeProviders.length > 0; const renderApiKeySelector = () => (
- +
); @@ -249,6 +283,20 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active )} + {canShowGuide() && ( +
+
+ + {saveStatus === "saved" && Configuration saved.} + {saveStatus === "dirty" && Unsaved changes} + {saveStatus === "error" && {saveError}} +
+ {apiKeyMode === "custom" &&

The custom API key is never saved and must be entered again after reload.

} +
+ )} ); }; diff --git a/src/app/api/cli-tools/config/[toolId]/route.js b/src/app/api/cli-tools/config/[toolId]/route.js new file mode 100644 index 00000000..6016af3b --- /dev/null +++ b/src/app/api/cli-tools/config/[toolId]/route.js @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { requireCurrentDashboardUser } from "@/lib/auth/currentUser"; +import { + getApiKeyByIdAndOwnerId, + getCliToolConfig, + upsertCliToolConfig, +} from "@/lib/db/index.js"; +import { + CliToolConfigValidationError, + isPersistableCliTool, + normalizeCliToolConfig, +} from "@/shared/constants/cliToolConfig.js"; + +export const dynamic = "force-dynamic"; + +function json(body, init = {}) { + const response = NextResponse.json(body, init); + response.headers.set("Cache-Control", "no-store"); + return response; +} + +function errorResponse(error, operation) { + if (error?.message === "Unauthorized") return json({ error: "Unauthorized" }, { status: 401 }); + if (error instanceof CliToolConfigValidationError || error instanceof SyntaxError) { + return json({ error: error instanceof SyntaxError ? "Invalid JSON payload" : error.message }, { status: 400 }); + } + console.log(`Error ${operation} CLI tool configuration:`, error); + return json({ error: `Failed to ${operation} CLI tool configuration` }, { status: 500 }); +} + +export async function GET(request, { params }) { + try { + const user = await requireCurrentDashboardUser(); + const { toolId } = await params; + if (!isPersistableCliTool(toolId)) { + return json({ error: "Unsupported CLI tool" }, { status: 404 }); + } + + const saved = await getCliToolConfig(user.id, toolId); + return json({ config: saved?.config || null, updatedAt: saved?.updatedAt || null }); + } catch (error) { + return errorResponse(error, "load"); + } +} + +export async function PUT(request, { params }) { + try { + const user = await requireCurrentDashboardUser(); + const { toolId } = await params; + if (!isPersistableCliTool(toolId)) { + return json({ error: "Unsupported CLI tool" }, { status: 404 }); + } + + const config = normalizeCliToolConfig(toolId, await request.json()); + if (config.apiKeyMode === "managed" && config.apiKeyId) { + const apiKey = await getApiKeyByIdAndOwnerId(config.apiKeyId, user.id); + if (!apiKey) return json({ error: "API key not found" }, { status: 404 }); + } + + const saved = await upsertCliToolConfig(user.id, toolId, config); + return json({ config: saved.config, updatedAt: saved.updatedAt }); + } catch (error) { + return errorResponse(error, "save"); + } +} diff --git a/src/lib/db/index.js b/src/lib/db/index.js index ddaedfcb..602d635c 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -1,6 +1,7 @@ // Public API barrel — all DB functions import { getAdapter } from "./driver.js"; import { stringifyJson, parseJson } from "./helpers/jsonCol.js"; +import { normalizeCliToolConfig, isPersistableCliTool } from "@/shared/constants/cliToolConfig.js"; // Settings export { @@ -45,6 +46,12 @@ export { createCombo, updateCombo, deleteCombo, } from "./repos/combosRepo.js"; +// Per-user CLI tool configurations +export { + getCliToolConfig, getCliToolConfigsByOwnerId, + upsertCliToolConfig, deleteCliToolConfigsByOwnerId, +} from "./repos/cliToolConfigsRepo.js"; + // Aliases (model + custom + mitm) export { getModelAliases, setModelAlias, deleteModelAlias, @@ -87,6 +94,7 @@ export async function exportDb() { proxyPools: db.all(`SELECT * FROM proxyPools`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, isActive: r.isActive === 1, testStatus: r.testStatus, createdAt: r.createdAt, updatedAt: r.updatedAt })), apiKeys: db.all(`SELECT * FROM apiKeys`).map((r) => ({ id: r.id, key: r.key, name: r.name, machineId: r.machineId, ownerId: r.ownerId, isActive: r.isActive === 1, createdAt: r.createdAt })), combos: db.all(`SELECT * FROM combos`).map((r) => ({ id: r.id, name: r.name, ownerId: r.ownerId, kind: r.kind, models: parseJson(r.models, []), createdAt: r.createdAt, updatedAt: r.updatedAt })), + cliToolConfigs: db.all(`SELECT * FROM cliToolConfigs`).map((r) => ({ ownerId: r.ownerId, toolId: r.toolId, config: parseJson(r.data, {}), createdAt: r.createdAt, updatedAt: r.updatedAt })), modelAliases: {}, customModels: [], mitmAlias: {}, @@ -121,6 +129,7 @@ export async function importDb(payload) { db.transaction(() => { // Wipe all tables (keep _meta) db.run(`DELETE FROM settings`); + db.run(`DELETE FROM cliToolConfigs`); // Old backups predate multi-user authentication. Preserve the local // administrator unless the payload explicitly carries a users array. if (Array.isArray(payload.users)) db.run(`DELETE FROM users`); @@ -186,6 +195,22 @@ export async function importDb(payload) { [c.id, c.name, c.ownerId || fallbackOwnerId, c.kind || null, stringifyJson(c.models || []), c.createdAt || new Date().toISOString(), c.updatedAt || new Date().toISOString()] ); } + const userOwnerIds = new Set(db.all(`SELECT id FROM users`).map((user) => user.id)); + const apiKeyOwners = new Map(db.all(`SELECT id, ownerId FROM apiKeys`).map((key) => [key.id, key.ownerId])); + for (const row of payload.cliToolConfigs || []) { + if (!row?.ownerId || !userOwnerIds.has(row.ownerId) || !isPersistableCliTool(row.toolId)) continue; + try { + const config = normalizeCliToolConfig(row.toolId, row.config); + if (config.apiKeyMode === "managed" && config.apiKeyId && apiKeyOwners.get(config.apiKeyId) !== row.ownerId) continue; + const timestamp = new Date().toISOString(); + db.run( + `INSERT OR REPLACE INTO cliToolConfigs(ownerId, toolId, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?)`, + [row.ownerId, row.toolId, stringifyJson(config), row.createdAt || timestamp, row.updatedAt || timestamp], + ); + } catch { + // Skip malformed or secret-bearing configuration rows from backups. + } + } for (const [a, m] of Object.entries(payload.modelAliases || {})) { db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('modelAliases', ?, ?)`, [a, stringifyJson(m)]); } diff --git a/src/lib/db/repos/cliToolConfigsRepo.js b/src/lib/db/repos/cliToolConfigsRepo.js new file mode 100644 index 00000000..59284c94 --- /dev/null +++ b/src/lib/db/repos/cliToolConfigsRepo.js @@ -0,0 +1,49 @@ +import { getAdapter } from "../driver.js"; +import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; +import { normalizeCliToolConfig } from "@/shared/constants/cliToolConfig.js"; + +function rowToConfig(row) { + if (!row) return null; + return { + ownerId: row.ownerId, + toolId: row.toolId, + config: parseJson(row.data, {}), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export async function getCliToolConfig(ownerId, toolId) { + const db = await getAdapter(); + return rowToConfig(db.get( + `SELECT * FROM cliToolConfigs WHERE ownerId = ? AND toolId = ?`, + [ownerId, toolId], + )); +} + +export async function getCliToolConfigsByOwnerId(ownerId) { + const db = await getAdapter(); + return db.all( + `SELECT * FROM cliToolConfigs WHERE ownerId = ? ORDER BY toolId ASC`, + [ownerId], + ).map(rowToConfig); +} + +export async function upsertCliToolConfig(ownerId, toolId, input) { + const config = normalizeCliToolConfig(toolId, input); + const db = await getAdapter(); + const timestamp = new Date().toISOString(); + db.run( + `INSERT INTO cliToolConfigs(ownerId, toolId, data, createdAt, updatedAt) + VALUES(?, ?, ?, ?, ?) + ON CONFLICT(ownerId, toolId) DO UPDATE SET data = excluded.data, updatedAt = excluded.updatedAt`, + [ownerId, toolId, stringifyJson(config), timestamp, timestamp], + ); + return getCliToolConfig(ownerId, toolId); +} + +export async function deleteCliToolConfigsByOwnerId(ownerId) { + const db = await getAdapter(); + const result = db.run(`DELETE FROM cliToolConfigs WHERE ownerId = ?`, [ownerId]); + return result?.changes ?? 0; +} \ No newline at end of file diff --git a/src/lib/db/repos/usersRepo.js b/src/lib/db/repos/usersRepo.js index d788adda..5b36a812 100644 --- a/src/lib/db/repos/usersRepo.js +++ b/src/lib/db/repos/usersRepo.js @@ -117,8 +117,13 @@ export async function countActiveAdmins() { export async function deleteUser(id) { const db = await getAdapter(); - const result = db.run(`DELETE FROM users WHERE id = ?`, [id]); - return (result?.changes ?? 0) > 0; + let deleted = false; + db.transaction(() => { + db.run(`DELETE FROM cliToolConfigs WHERE ownerId = ?`, [id]); + const result = db.run(`DELETE FROM users WHERE id = ?`, [id]); + deleted = (result?.changes ?? 0) > 0; + }); + return deleted; } export async function verifyUserCredentials(username, password) { diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index fe81886c..c2b5b0f9 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -3,7 +3,7 @@ // pre-change safety backup in migrate.js: when the stored version is lower, // one lightweight DB backup is taken before applying schema changes. Forgetting // to bump only skips that backup — it does NOT break the additive auto-sync. -export const SCHEMA_VERSION = 7; +export const SCHEMA_VERSION = 8; export const PRAGMA_SQL = ` PRAGMA journal_mode = WAL; @@ -123,6 +123,17 @@ export const TABLES = { "CREATE INDEX IF NOT EXISTS idx_combo_owner ON combos(ownerId)", ], }, + cliToolConfigs: { + columns: { + ownerId: "TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE", + toolId: "TEXT NOT NULL", + data: "TEXT NOT NULL", + createdAt: "TEXT NOT NULL", + updatedAt: "TEXT NOT NULL", + }, + primaryKey: "PRIMARY KEY (ownerId, toolId)", + indexes: ["CREATE INDEX IF NOT EXISTS idx_cli_tool_configs_owner ON cliToolConfigs(ownerId)"], + }, kv: { columns: { scope: "TEXT NOT NULL", diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 4eed423a..9e654b43 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -16,6 +16,8 @@ export { createApiKey, updateApiKey, deleteApiKey, validateApiKey, getCombos, getComboById, getComboByName, createCombo, updateCombo, deleteCombo, + getCliToolConfig, getCliToolConfigsByOwnerId, + upsertCliToolConfig, deleteCliToolConfigsByOwnerId, getModelAliases, setModelAlias, deleteModelAlias, getCustomModels, addCustomModel, deleteCustomModel, getMitmAlias, setMitmAliasAll, diff --git a/src/shared/constants/cliToolConfig.js b/src/shared/constants/cliToolConfig.js new file mode 100644 index 00000000..6b90076c --- /dev/null +++ b/src/shared/constants/cliToolConfig.js @@ -0,0 +1,168 @@ +import { CLI_TOOLS } from "./cliTools.js"; + +const SUPPORTED_TOOL_IDS = new Set(Object.keys(CLI_TOOLS)); +const TOOLS_WITH_API_KEYS = new Set(["claude", "codex", "opencode", "cowork", "cursor"]); +const TOOLS_WITH_BASE_URLS = new Set(["claude", "codex", "opencode", "cowork", "copilot"]); +const CLAUDE_SLOTS = ["sonnet", "opus", "haiku"]; +const MAX_MODEL_COUNT = 100; +const MAX_MODEL_ID_LENGTH = 512; +const MAX_URL_LENGTH = 2048; +const MAX_THINKING_LENGTH = 32; +const MAX_TOKEN_LIMIT = 10_000_000; + +export class CliToolConfigValidationError extends Error { + constructor(message) { + super(message); + this.name = "CliToolConfigValidationError"; + this.status = 400; + } +} + +export function isPersistableCliTool(toolId) { + return typeof toolId === "string" && SUPPORTED_TOOL_IDS.has(toolId); +} + +function assertObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new CliToolConfigValidationError("Configuration must be an object"); + } + if (Object.hasOwn(value, "apiKey") || Object.hasOwn(value, "customApiKey") || Object.hasOwn(value, "key")) { + throw new CliToolConfigValidationError("Plaintext API keys cannot be saved"); + } +} + +function normalizeBaseUrl(value) { + if (typeof value !== "string" || !value.trim()) { + throw new CliToolConfigValidationError("Endpoint is required"); + } + const trimmed = value.trim().replace(/\/+$/, ""); + if (trimmed.length > MAX_URL_LENGTH) { + throw new CliToolConfigValidationError("Endpoint is too long"); + } + let url; + try { + url = new URL(trimmed); + } catch { + throw new CliToolConfigValidationError("Endpoint must be a valid HTTP(S) URL"); + } + if (!['http:', 'https:'].includes(url.protocol)) { + throw new CliToolConfigValidationError("Endpoint must use HTTP or HTTPS"); + } + return trimmed; +} + +function normalizeOptionalString(value, field, maxLength = MAX_MODEL_ID_LENGTH) { + if (value === undefined || value === null || value === "") return ""; + if (typeof value !== "string") throw new CliToolConfigValidationError(`${field} must be a string`); + const normalized = value.trim(); + if (normalized.length > maxLength) throw new CliToolConfigValidationError(`${field} is too long`); + return normalized; +} + +function normalizeModels(value, field = "selectedModels") { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new CliToolConfigValidationError(`${field} must be an array`); + if (value.length > MAX_MODEL_COUNT) throw new CliToolConfigValidationError(`${field} has too many models`); + const models = value.map((model) => { + const normalized = normalizeOptionalString(model, field); + if (!normalized) throw new CliToolConfigValidationError(`${field} cannot contain empty model IDs`); + return normalized; + }); + return [...new Set(models)]; +} + +function normalizeThinking(value, field) { + return normalizeOptionalString(value, field, MAX_THINKING_LENGTH); +} + +function normalizeThinkingMap(value, allowedKeys, field) { + if (value === undefined) return {}; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new CliToolConfigValidationError(`${field} must be an object`); + } + const normalized = {}; + for (const key of allowedKeys) { + if (!Object.hasOwn(value, key)) continue; + const thinking = normalizeThinking(value[key], `${field}.${key}`); + if (thinking) normalized[key] = thinking; + } + return normalized; +} + +function normalizeApiKeyReference(input) { + const mode = input.apiKeyMode === undefined ? "managed" : input.apiKeyMode; + if (!['managed', 'custom'].includes(mode)) { + throw new CliToolConfigValidationError("apiKeyMode must be managed or custom"); + } + const apiKeyId = normalizeOptionalString(input.apiKeyId, "apiKeyId", 128) || null; + return { apiKeyMode: mode, apiKeyId: mode === "managed" ? apiKeyId : null }; +} + +function normalizeTokenMap(value, selectedModels) { + if (value === undefined) return {}; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new CliToolConfigValidationError("copilotTokens must be an object"); + } + const normalized = {}; + for (const model of selectedModels) { + const limits = value[model]; + if (limits === undefined) continue; + if (!limits || typeof limits !== "object" || Array.isArray(limits)) { + throw new CliToolConfigValidationError(`copilotTokens.${model} must be an object`); + } + const next = {}; + for (const field of ["maxInputTokens", "maxOutputTokens"]) { + if (limits[field] === undefined || limits[field] === null || limits[field] === "") continue; + const number = Number(limits[field]); + if (!Number.isSafeInteger(number) || number <= 0 || number > MAX_TOKEN_LIMIT) { + throw new CliToolConfigValidationError(`${field} must be a positive integer no greater than ${MAX_TOKEN_LIMIT}`); + } + next[field] = number; + } + if (Object.keys(next).length) normalized[model] = next; + } + return normalized; +} + +export function normalizeCliToolConfig(toolId, input) { + if (!isPersistableCliTool(toolId)) { + throw new CliToolConfigValidationError("Unsupported CLI tool"); + } + assertObject(input); + + const config = {}; + if (TOOLS_WITH_BASE_URLS.has(toolId)) config.baseUrl = normalizeBaseUrl(input.baseUrl); + if (TOOLS_WITH_API_KEYS.has(toolId)) Object.assign(config, normalizeApiKeyReference(input)); + + if (toolId === "claude") { + const modelsInput = input.claudeModels === undefined ? {} : input.claudeModels; + if (!modelsInput || typeof modelsInput !== "object" || Array.isArray(modelsInput)) { + throw new CliToolConfigValidationError("claudeModels must be an object"); + } + config.claudeModels = Object.fromEntries(CLAUDE_SLOTS.map((slot) => [ + slot, + normalizeOptionalString(modelsInput[slot], `claudeModels.${slot}`), + ])); + config.claudeThinking = normalizeThinkingMap(input.claudeThinking, CLAUDE_SLOTS, "claudeThinking"); + } else if (toolId === "codex") { + config.codexModel = normalizeOptionalString(input.codexModel, "codexModel"); + config.codexThinking = normalizeThinking(input.codexThinking, "codexThinking"); + } else if (toolId === "opencode") { + config.opencodeModels = normalizeModels(input.opencodeModels, "opencodeModels"); + const requestedDefault = normalizeOptionalString(input.opencodeDefaultModel, "opencodeDefaultModel"); + config.opencodeDefaultModel = config.opencodeModels.includes(requestedDefault) + ? requestedDefault + : (config.opencodeModels[0] || ""); + } else if (toolId === "cowork") { + config.selectedModels = normalizeModels(input.selectedModels); + config.coworkThinking = normalizeThinkingMap(input.coworkThinking, config.selectedModels, "coworkThinking"); + } else if (toolId === "cursor") { + config.selectedModels = normalizeModels(input.selectedModels); + } else if (toolId === "copilot") { + config.selectedModels = normalizeModels(input.selectedModels); + config.copilotThinking = normalizeThinkingMap(input.copilotThinking, config.selectedModels, "copilotThinking"); + config.copilotTokens = normalizeTokenMap(input.copilotTokens, config.selectedModels); + } + + return config; +} \ No newline at end of file diff --git a/src/shared/utils/cliToolEndpoint.js b/src/shared/utils/cliToolEndpoint.js new file mode 100644 index 00000000..cb7651bf --- /dev/null +++ b/src/shared/utils/cliToolEndpoint.js @@ -0,0 +1,68 @@ +import { APP_CONFIG } from "@/shared/constants/config"; + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]); + +function trimTrailingSlashes(value) { + return typeof value === "string" ? value.trim().replace(/\/+$/, "") : ""; +} + +export function ensureCliToolV1Endpoint(value) { + const normalized = trimTrailingSlashes(value); + if (!normalized) return ""; + return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`; +} + +export function isLocalCliToolUrl(value) { + try { + const hostname = new URL(value).hostname.toLowerCase(); + return LOOPBACK_HOSTS.has(hostname) || hostname.endsWith(".localhost"); + } catch { + return false; + } +} + +export function resolveCliToolBaseUrl({ + appUrl = "", + requiresExternalUrl = false, + tunnelEnabled = false, + tunnelPublicUrl = "", + tailscaleEnabled = false, + tailscaleUrl = "", + cloudEnabled = false, + cloudUrl = "", + configuredBaseUrl = "", +} = {}) { + const runtimeUrl = trimTrailingSlashes(appUrl); + + // When the dashboard itself is deployed, its browser origin is the most + // accurate public gateway URL (including custom domains and reverse proxies). + if (runtimeUrl && !isLocalCliToolUrl(runtimeUrl)) return runtimeUrl; + + // Tools such as Cursor cannot call a loopback URL from their remote service. + if (requiresExternalUrl) { + if (tunnelEnabled && tunnelPublicUrl) return trimTrailingSlashes(tunnelPublicUrl); + if (tailscaleEnabled && tailscaleUrl) return trimTrailingSlashes(tailscaleUrl); + if (cloudEnabled && cloudUrl) return trimTrailingSlashes(cloudUrl); + } + + // A locally opened dashboard should generate a local endpoint, preserving a + // custom development port from window.location.origin when present. + if (runtimeUrl) return runtimeUrl; + + const configuredUrl = trimTrailingSlashes(configuredBaseUrl); + if (configuredUrl) return configuredUrl; + return `http://127.0.0.1:${APP_CONFIG.defaultPort}`; +} + +export function resolveInitialCliToolBaseUrl(savedBaseUrl, runtimeBaseUrl) { + const savedUrl = trimTrailingSlashes(savedBaseUrl); + const defaultUrl = trimTrailingSlashes(runtimeBaseUrl); + + // Previous CLI Tools behavior could save the hardcoded loopback endpoint on + // a deployed dashboard. Treat that specific mismatch as a stale default. + if (savedUrl && defaultUrl && isLocalCliToolUrl(savedUrl) && !isLocalCliToolUrl(defaultUrl)) { + return ensureCliToolV1Endpoint(defaultUrl); + } + + return ensureCliToolV1Endpoint(savedUrl || defaultUrl); +} diff --git a/tests/translator/__snapshots__/golden-url-header.test.js.snap b/tests/translator/__snapshots__/golden-url-header.test.js.snap index 9482052c..c9a0db21 100644 --- a/tests/translator/__snapshots__/golden-url-header.test.js.snap +++ b/tests/translator/__snapshots__/golden-url-header.test.js.snap @@ -268,6 +268,52 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a } `; +exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "9Router/0.5.30", + "X-CLIENT-TYPE": "9router", + "X-CLIENT-VERSION": "0.5.30", + "X-CORE-VERSION": "0.5.30", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "linux", + "X-PLATFORM-VERSION": "v24.15.0", + "X-Title": "Cline", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "9Router/0.5.30", + "X-CLIENT-TYPE": "9router", + "X-CLIENT-VERSION": "0.5.30", + "X-CORE-VERSION": "0.5.30", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "linux", + "X-PLATFORM-VERSION": "v24.15.0", + "X-Title": "Cline", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "9Router/0.5.30", + "X-CLIENT-TYPE": "9router", + "X-CLIENT-VERSION": "0.5.30", + "X-CORE-VERSION": "0.5.30", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "linux", + "X-PLATFORM-VERSION": "v24.15.0", + "X-Title": "Cline", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -381,6 +427,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers } `; +exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -482,6 +547,40 @@ exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers ( } `; +exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-authenticateresponse": "authenticate-response", + "x-grok-client-identifier": "grok-pager", + "x-grok-client-version": "0.2.93", + "x-xai-token-auth": "xai-grok-cli", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-authenticateresponse": "authenticate-response", + "x-grok-client-identifier": "grok-pager", + "x-grok-client-version": "0.2.93", + "x-xai-token-auth": "xai-grok-cli", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-authenticateresponse": "authenticate-response", + "x-grok-client-identifier": "grok-pager", + "x-grok-client-version": "0.2.93", + "x-xai-token-auth": "xai-grok-cli", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -539,6 +638,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers } `; +exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "kimchi/0.1.50", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "kimchi/0.1.50", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "kimchi/0.1.50", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -828,6 +949,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity → heade } `; +exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -866,6 +1006,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > together → headers } `; +exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -1012,6 +1171,13 @@ exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream + } `; +exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.cline.bot/api/v1/chat/completions", + "stream": "https://api.cline.bot/api/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions", @@ -1047,6 +1213,13 @@ exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream } `; +exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.featherless.ai/v1/chat/completions", + "stream": "https://api.featherless.ai/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.fireworks.ai/inference/v1/chat/completions", @@ -1082,6 +1255,13 @@ exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream + } `; +exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://cli-chat-proxy.grok.com/v1/responses", + "stream": "https://cli-chat-proxy.grok.com/v1/responses", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.groq.com/openai/v1/chat/completions", @@ -1103,6 +1283,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream } `; +exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions", + "stream": "https://llm.kimchi.dev/openai/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.kimi.com/coding/v1/messages?beta=true", @@ -1194,6 +1381,13 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stre } `; +exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.perplexity.ai/v1/responses", + "stream": "https://api.perplexity.ai/v1/responses", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.siliconflow.com/v1/chat/completions", @@ -1208,6 +1402,13 @@ exports[`GOLDEN buildUrl (default executor providers) > together → url (stream } `; +exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.venice.ai/api/v1/chat/completions", + "stream": "https://api.venice.ai/api/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = ` { "nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions", diff --git a/tests/unit/cli-tool-config-contract.test.js b/tests/unit/cli-tool-config-contract.test.js new file mode 100644 index 00000000..4c57b175 --- /dev/null +++ b/tests/unit/cli-tool-config-contract.test.js @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + CliToolConfigValidationError, + isPersistableCliTool, + normalizeCliToolConfig, +} from "@/shared/constants/cliToolConfig.js"; + +describe("CLI tool configuration contract", () => { + it("allows every non-MITM CLI tool and rejects MITM tool IDs", () => { + for (const toolId of ["claude", "codex", "opencode", "cowork", "cursor", "copilot"]) { + expect(isPersistableCliTool(toolId)).toBe(true); + } + for (const toolId of ["antigravity", "kiro", "unknown", ""]) { + expect(isPersistableCliTool(toolId)).toBe(false); + } + }); + + it("normalizes Claude config and strips unknown fields", () => { + expect(normalizeCliToolConfig("claude", { + baseUrl: "https://router.example/v1/", + apiKeyMode: "managed", + apiKeyId: "key-1", + claudeModels: { sonnet: " cc/sonnet ", opus: "cc/opus", haiku: "" }, + claudeThinking: { sonnet: "high", opus: "", ignored: "max" }, + transientModalOpen: true, + })).toEqual({ + baseUrl: "https://router.example/v1", + apiKeyMode: "managed", + apiKeyId: "key-1", + claudeModels: { sonnet: "cc/sonnet", opus: "cc/opus", haiku: "" }, + claudeThinking: { sonnet: "high" }, + }); + }); + + it("normalizes tool-specific model selections", () => { + expect(normalizeCliToolConfig("codex", { + baseUrl: "http://localhost:20128/v1", + apiKeyMode: "custom", + apiKeyId: "must-be-removed", + codexModel: "cx/gpt", + codexThinking: "xhigh", + })).toMatchObject({ apiKeyMode: "custom", apiKeyId: null, codexModel: "cx/gpt", codexThinking: "xhigh" }); + + expect(normalizeCliToolConfig("opencode", { + baseUrl: "https://router.example", + apiKeyMode: "managed", + apiKeyId: null, + opencodeModels: ["cc/a", "cc/a", "cx/b"], + opencodeDefaultModel: "missing/model", + })).toMatchObject({ opencodeModels: ["cc/a", "cx/b"], opencodeDefaultModel: "cc/a" }); + + expect(normalizeCliToolConfig("cowork", { + baseUrl: "https://router.example", + apiKeyMode: "managed", + selectedModels: ["cc/a"], + coworkThinking: { "cc/a": "high", "stale/model": "low" }, + })).toMatchObject({ selectedModels: ["cc/a"], coworkThinking: { "cc/a": "high" } }); + + expect(normalizeCliToolConfig("cursor", { + apiKeyMode: "managed", + apiKeyId: "key-1", + selectedModels: ["cc/a", "cx/b"], + })).toEqual({ apiKeyMode: "managed", apiKeyId: "key-1", selectedModels: ["cc/a", "cx/b"] }); + + expect(normalizeCliToolConfig("copilot", { + baseUrl: "https://router.example/v1", + selectedModels: ["cc/a"], + copilotThinking: { "cc/a": "high", "stale/model": "low" }, + copilotTokens: { + "cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 }, + "stale/model": { maxInputTokens: 1 }, + }, + })).toEqual({ + baseUrl: "https://router.example/v1", + selectedModels: ["cc/a"], + copilotThinking: { "cc/a": "high" }, + copilotTokens: { "cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 } }, + }); + }); + + it("rejects plaintext secrets, invalid URLs, and invalid token limits", () => { + expect(() => normalizeCliToolConfig("claude", { + baseUrl: "https://router.example", + apiKey: "secret", + })).toThrowError(CliToolConfigValidationError); + + expect(() => normalizeCliToolConfig("codex", { + baseUrl: "file:///tmp/socket", + apiKeyMode: "managed", + })).toThrow("Endpoint must use HTTP or HTTPS"); + + expect(() => normalizeCliToolConfig("copilot", { + baseUrl: "https://router.example", + selectedModels: ["cc/a"], + copilotTokens: { "cc/a": { maxInputTokens: -1 } }, + })).toThrow("maxInputTokens must be a positive integer"); + }); +}); diff --git a/tests/unit/cli-tool-config-db.test.js b/tests/unit/cli-tool-config-db.test.js new file mode 100644 index 00000000..d5ec03df --- /dev/null +++ b/tests/unit/cli-tool-config-db.test.js @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const originalDataDir = process.env.DATA_DIR; +let tempDir; +let db; +let ownerOne; +let ownerTwo; + +beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cli-tool-config-")); + process.env.DATA_DIR = tempDir; + try { global._dbAdapter?.instance?.close?.(); } catch {} + delete global._dbAdapter; + vi.resetModules(); + db = await import("@/lib/db/index.js"); + await db.initDb(); + ownerOne = await db.createUser({ username: "cli-owner-one", password: "password", role: "admin" }); + ownerTwo = await db.createUser({ username: "cli-owner-two", password: "password", role: "user" }); +}); + +afterAll(() => { + try { global._dbAdapter?.instance?.close?.(); } catch {} + delete global._dbAdapter; + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +const claudeConfig = (model) => ({ + baseUrl: "https://router.example/v1", + apiKeyMode: "managed", + apiKeyId: null, + claudeModels: { sonnet: model, opus: "", haiku: "" }, + claudeThinking: { sonnet: "high" }, +}); + +describe("CLI tool configuration persistence", () => { + it("round-trips and atomically overwrites one owner/tool row", async () => { + const created = await db.upsertCliToolConfig(ownerOne.id, "claude", claudeConfig("cc/sonnet-a")); + expect(created).toMatchObject({ ownerId: ownerOne.id, toolId: "claude", config: claudeConfig("cc/sonnet-a") }); + + const updated = await db.upsertCliToolConfig(ownerOne.id, "claude", claudeConfig("cc/sonnet-b")); + expect(updated.config.claudeModels.sonnet).toBe("cc/sonnet-b"); + expect((await db.getCliToolConfigsByOwnerId(ownerOne.id)).filter((row) => row.toolId === "claude")).toHaveLength(1); + }); + + it("isolates users and permits concurrent saves to different tools", async () => { + await Promise.all([ + db.upsertCliToolConfig(ownerOne.id, "cursor", { + apiKeyMode: "custom", + selectedModels: ["cc/a", "cx/b"], + }), + db.upsertCliToolConfig(ownerTwo.id, "cursor", { + apiKeyMode: "managed", + apiKeyId: null, + selectedModels: ["gg/c"], + }), + db.upsertCliToolConfig(ownerOne.id, "codex", { + baseUrl: "https://router.example/v1", + apiKeyMode: "managed", + apiKeyId: null, + codexModel: "cx/gpt", + codexThinking: "high", + }), + ]); + + expect((await db.getCliToolConfig(ownerOne.id, "cursor")).config.selectedModels).toEqual(["cc/a", "cx/b"]); + expect((await db.getCliToolConfig(ownerTwo.id, "cursor")).config.selectedModels).toEqual(["gg/c"]); + expect((await db.getCliToolConfig(ownerOne.id, "codex")).config.codexModel).toBe("cx/gpt"); + }); + + it("includes valid configurations in export/import and skips malformed rows", async () => { + const exported = await db.exportDb(); + expect(exported.cliToolConfigs).toEqual(expect.arrayContaining([ + expect.objectContaining({ ownerId: ownerOne.id, toolId: "claude" }), + expect.objectContaining({ ownerId: ownerTwo.id, toolId: "cursor" }), + ])); + + exported.cliToolConfigs.push({ + ownerId: ownerOne.id, + toolId: "kiro", + config: { apiKey: "must-not-import" }, + }); + await db.importDb(exported); + + expect(await db.getCliToolConfig(ownerOne.id, "claude")).not.toBeNull(); + expect(await db.getCliToolConfig(ownerOne.id, "kiro")).toBeNull(); + }); + + it("removes configurations when their owner is deleted", async () => { + const disposable = await db.createUser({ username: "cli-disposable", password: "password", role: "user" }); + await db.upsertCliToolConfig(disposable.id, "cursor", { + apiKeyMode: "managed", + apiKeyId: null, + selectedModels: ["cc/a"], + }); + + expect(await db.deleteUser(disposable.id)).toBe(true); + expect(await db.getCliToolConfig(disposable.id, "cursor")).toBeNull(); + }); +}); diff --git a/tests/unit/cli-tool-config-route.test.js b/tests/unit/cli-tool-config-route.test.js new file mode 100644 index 00000000..05304e80 --- /dev/null +++ b/tests/unit/cli-tool-config-route.test.js @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const requireCurrentDashboardUser = vi.fn(); +const getApiKeyByIdAndOwnerId = vi.fn(); +const getCliToolConfig = vi.fn(); +const upsertCliToolConfig = vi.fn(); + +vi.mock("next/server", () => ({ + NextResponse: { + json(body, init = {}) { + return new Response(JSON.stringify(body), { + status: init.status || 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, +})); +vi.mock("@/lib/auth/currentUser", () => ({ requireCurrentDashboardUser })); +vi.mock("@/lib/db/index.js", () => ({ + getApiKeyByIdAndOwnerId, + getCliToolConfig, + upsertCliToolConfig, +})); + +const { GET, PUT } = await import("@/app/api/cli-tools/config/[toolId]/route.js"); +const context = (toolId) => ({ params: Promise.resolve({ toolId }) }); +const putRequest = (body) => new Request("https://9router.local/api/cli-tools/config/claude", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); + +describe("/api/cli-tools/config/[toolId]", () => { + beforeEach(() => { + requireCurrentDashboardUser.mockReset(); + getApiKeyByIdAndOwnerId.mockReset(); + getCliToolConfig.mockReset(); + upsertCliToolConfig.mockReset(); + requireCurrentDashboardUser.mockResolvedValue({ id: "user-1", role: "user" }); + }); + + it("returns only the authenticated user's saved config without caching", async () => { + getCliToolConfig.mockResolvedValue({ + config: { apiKeyMode: "custom", apiKeyId: null, selectedModels: ["cc/a"] }, + updatedAt: "2026-07-16T00:00:00.000Z", + }); + + const response = await GET(new Request("https://9router.local"), context("cursor")); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(getCliToolConfig).toHaveBeenCalledWith("user-1", "cursor"); + await expect(response.json()).resolves.toEqual({ + config: { apiKeyMode: "custom", apiKeyId: null, selectedModels: ["cc/a"] }, + updatedAt: "2026-07-16T00:00:00.000Z", + }); + }); + + it("rejects unauthenticated and MITM tool requests", async () => { + requireCurrentDashboardUser.mockRejectedValueOnce(new Error("Unauthorized")); + expect((await GET(new Request("https://9router.local"), context("cursor"))).status).toBe(401); + expect((await GET(new Request("https://9router.local"), context("kiro"))).status).toBe(404); + expect(getCliToolConfig).not.toHaveBeenCalled(); + }); + + it("validates managed key ownership before saving", async () => { + getApiKeyByIdAndOwnerId.mockResolvedValue(null); + const response = await PUT(putRequest({ + baseUrl: "https://router.example/v1", + apiKeyMode: "managed", + apiKeyId: "foreign-key", + claudeModels: {}, + claudeThinking: {}, + }), context("claude")); + + expect(response.status).toBe(404); + expect(getApiKeyByIdAndOwnerId).toHaveBeenCalledWith("foreign-key", "user-1"); + expect(upsertCliToolConfig).not.toHaveBeenCalled(); + }); + + it("normalizes a custom-key config without persisting plaintext", async () => { + upsertCliToolConfig.mockImplementation(async (ownerId, toolId, config) => ({ + ownerId, + toolId, + config, + updatedAt: "2026-07-16T00:00:00.000Z", + })); + const response = await PUT(putRequest({ + baseUrl: "https://router.example/v1/", + apiKeyMode: "custom", + apiKeyId: "ignored-key-id", + claudeModels: { sonnet: "cc/sonnet" }, + claudeThinking: { sonnet: "high" }, + }), context("claude")); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.config).toMatchObject({ apiKeyMode: "custom", apiKeyId: null }); + expect(JSON.stringify(body)).not.toContain("ignored-key-id"); + expect(getApiKeyByIdAndOwnerId).not.toHaveBeenCalled(); + }); + + it("rejects plaintext API keys and malformed JSON", async () => { + const secretResponse = await PUT(putRequest({ + baseUrl: "https://router.example/v1", + apiKeyMode: "custom", + apiKey: "secret-value", + claudeModels: {}, + }), context("claude")); + expect(secretResponse.status).toBe(400); + expect(JSON.stringify(await secretResponse.json())).not.toContain("secret-value"); + + const malformedResponse = await PUT(new Request("https://9router.local", { + method: "PUT", + body: "{", + }), context("claude")); + expect(malformedResponse.status).toBe(400); + expect(upsertCliToolConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/cli-tool-endpoint.test.js b/tests/unit/cli-tool-endpoint.test.js new file mode 100644 index 00000000..2d4f44e0 --- /dev/null +++ b/tests/unit/cli-tool-endpoint.test.js @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + ensureCliToolV1Endpoint, + isLocalCliToolUrl, + resolveCliToolBaseUrl, + resolveInitialCliToolBaseUrl, +} from "@/shared/utils/cliToolEndpoint.js"; + +describe("CLI tool endpoint resolution", () => { + it("keeps local dashboard origins local, including custom ports", () => { + expect(resolveCliToolBaseUrl({ appUrl: "http://localhost:30100" })).toBe("http://localhost:30100"); + expect(resolveCliToolBaseUrl({ appUrl: "http://127.0.0.1:20128/" })).toBe("http://127.0.0.1:20128"); + expect(isLocalCliToolUrl("http://app.localhost:20128")).toBe(true); + }); + + it("uses the browser deployment origin instead of localhost or a generic cloud URL", () => { + expect(resolveCliToolBaseUrl({ + appUrl: "https://router.customer.example", + cloudEnabled: true, + cloudUrl: "https://9router.com", + configuredBaseUrl: "http://localhost:20128", + })).toBe("https://router.customer.example"); + }); + + it("uses a public endpoint for externally hosted tools when the dashboard is local", () => { + expect(resolveCliToolBaseUrl({ + appUrl: "http://localhost:20128", + requiresExternalUrl: true, + tunnelEnabled: true, + tunnelPublicUrl: "https://tunnel.example/", + cloudEnabled: true, + cloudUrl: "https://cloud.example", + })).toBe("https://tunnel.example"); + + expect(resolveCliToolBaseUrl({ + appUrl: "http://localhost:20128", + requiresExternalUrl: true, + cloudEnabled: true, + cloudUrl: "https://cloud.example/", + })).toBe("https://cloud.example"); + }); + + it("adds /v1 exactly once to generated endpoints", () => { + expect(ensureCliToolV1Endpoint("https://router.example/")).toBe("https://router.example/v1"); + expect(ensureCliToolV1Endpoint("https://router.example/v1/")).toBe("https://router.example/v1"); + }); + + it("migrates the old hardcoded localhost default when opened on a deployment", () => { + expect(resolveInitialCliToolBaseUrl( + "http://127.0.0.1:20128/v1", + "https://router.customer.example", + )).toBe("https://router.customer.example/v1"); + }); + + it("preserves an explicit remote saved endpoint and local saved endpoint on local runtime", () => { + expect(resolveInitialCliToolBaseUrl( + "https://custom-gateway.example/v1", + "https://router.customer.example", + )).toBe("https://custom-gateway.example/v1"); + expect(resolveInitialCliToolBaseUrl( + "http://localhost:30100/v1", + "http://localhost:20128", + )).toBe("http://localhost:30100/v1"); + }); +});