feat: add the feature for user save combo

This commit is contained in:
2026-07-16 16:52:37 +07:00
parent e3cf2e7708
commit 448a8aedc7
20 changed files with 1215 additions and 114 deletions
+1
View File
@@ -12,6 +12,7 @@
- **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409) - **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409)
## Fixes ## 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) - **Cloudflare-AI**: support accountId in bulk key import (#2449)
- **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM - **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM
- **Codex**: avoid bare-email OAuth dedup (#2477) - **Codex**: avoid bare-email OAuth dedup (#2477)
+1 -1
View File
@@ -14,7 +14,7 @@ services:
resources: resources:
limits: limits:
cpus: "0.5" cpus: "0.5"
memory: 2G memory: 512M
# Dokploy/Traefik should route to this internal port. Do not publish it # Dokploy/Traefik should route to this internal port. Do not publish it
# with `ports:`; public HTTP(S) is provided by the Dokploy domain router. # with `ports:`; public HTTP(S) is provided by the Dokploy domain router.
expose: expose:
@@ -1,12 +1,14 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useCallback, useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { CardSkeleton } from "@/shared/components"; import { CardSkeleton } from "@/shared/components";
import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { CLI_TOOLS } from "@/shared/constants/cliTools";
import { resolveCliToolBaseUrl } from "@/shared/utils/cliToolEndpoint";
import { ConfigGeneratorCard, DefaultToolCard } from "../components"; import { ConfigGeneratorCard, DefaultToolCard } from "../components";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; 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 }) { export default function ToolDetailClient({ toolId, machineId }) {
const tool = CLI_TOOLS[toolId]; const tool = CLI_TOOLS[toolId];
@@ -19,17 +21,19 @@ export default function ToolDetailClient({ toolId, machineId }) {
const [tailscaleUrl, setTailscaleUrl] = useState(""); const [tailscaleUrl, setTailscaleUrl] = useState("");
const [apiKeys, setApiKeys] = useState([]); const [apiKeys, setApiKeys] = useState([]);
const [availableModels, setAvailableModels] = useState([]); const [availableModels, setAvailableModels] = useState([]);
const [initialConfig, setInitialConfig] = useState(null);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
(async () => { (async () => {
try { 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/providers"),
fetch("/api/settings"), fetch("/api/settings"),
fetch("/api/tunnel/status"), fetch("/api/tunnel/status"),
fetch("/api/keys"), fetch("/api/keys"),
fetch("/api/models/connected", { cache: "no-store" }), fetch("/api/models/connected", { cache: "no-store" }),
fetch(`/api/cli-tools/config/${encodeURIComponent(toolId)}`, { cache: "no-store" }),
]); ]);
if (!mounted) return; if (!mounted) return;
if (provRes.ok) { if (provRes.ok) {
@@ -55,6 +59,10 @@ export default function ToolDetailClient({ toolId, machineId }) {
const data = await modelsRes.json(); const data = await modelsRes.json();
setAvailableModels((data.models || []).filter((model) => !model.disabled)); setAvailableModels((data.models || []).filter((model) => !model.disabled));
} }
if (configRes.ok) {
const data = await configRes.json();
setInitialConfig(data.config || null);
}
} catch (error) { } catch (error) {
console.log("Error loading tool data:", error); console.log("Error loading tool data:", error);
} finally { } finally {
@@ -62,15 +70,34 @@ export default function ToolDetailClient({ toolId, machineId }) {
} }
})(); })();
return () => { mounted = false; }; 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 getActiveProviders = () => connections.filter(c => c.isActive !== false);
const getBaseUrl = () => { const getBaseUrl = () => {
if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl; return resolveCliToolBaseUrl({
if (cloudEnabled && CLOUD_URL) return CLOUD_URL; appUrl: typeof window !== "undefined" ? window.location.origin : "",
if (typeof window !== "undefined") return window.location.origin; configuredBaseUrl: CONFIGURED_BASE_URL,
return "http://localhost:20128"; requiresExternalUrl: tool?.requiresExternalUrl === true,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
cloudEnabled,
cloudUrl: CLOUD_URL,
});
}; };
const renderToolCard = () => { const renderToolCard = () => {
@@ -86,6 +113,8 @@ export default function ToolDetailClient({ toolId, machineId }) {
activeProviders: getActiveProviders(), activeProviders: getActiveProviders(),
availableModels, availableModels,
cloudEnabled, cloudEnabled,
initialConfig,
onSaveConfig: saveConfig,
}; };
if (tool.configType === "guide") return <DefaultToolCard toolId={toolId} {...commonProps} />; if (tool.configType === "guide") return <DefaultToolCard toolId={toolId} {...commonProps} />;
@@ -1,65 +1,57 @@
"use client"; "use client";
import { useState } from "react";
const CUSTOM_VALUE = "__custom__"; const CUSTOM_VALUE = "__custom__";
const UNSET_VALUE = "__unset__";
export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) { export default function ApiKeySelect({ value, onChange, apiKeys = [], className = "", mode = "managed", onModeChange }) {
const isCustom = !apiKeys.some((k) => k.key === value) && value !== ""; const matchingKey = apiKeys.find((key) => key.key === value);
const [mode, setMode] = useState(() => { const selectedMode = mode === "custom" ? CUSTOM_VALUE : (matchingKey?.key || UNSET_VALUE);
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 : "");
const handleSelect = (e) => { const handleSelect = (e) => {
const next = e.target.value; const next = e.target.value;
setMode(next); if (next === UNSET_VALUE) return;
if (next === CUSTOM_VALUE) { if (next === CUSTOM_VALUE) {
setCustomInput(""); onModeChange?.("custom");
onChange(""); onChange("");
} else { } else {
onModeChange?.("managed");
onChange(next); onChange(next);
} }
}; };
const handleCustomInput = (e) => { const handleCustomInput = (e) => {
const v = e.target.value; const v = e.target.value;
setCustomInput(v); onModeChange?.("custom");
onChange(v); onChange(v);
}; };
const noKeys = apiKeys.length === 0 && mode !== CUSTOM_VALUE;
if (noKeys && mode !== CUSTOM_VALUE) {
return (
<span className={`min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5 ${className}`}>
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
</span>
);
}
return ( return (
<div className={`flex flex-col gap-1.5 ${className}`}> <div className={`flex flex-col gap-1.5 ${className}`}>
<select <select
value={mode} value={selectedMode}
onChange={handleSelect} onChange={handleSelect}
className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
> >
{selectedMode === UNSET_VALUE && <option value={UNSET_VALUE}>No managed API key selected</option>}
{apiKeys.map((k) => ( {apiKeys.map((k) => (
<option key={k.id} value={k.key}>{k.key}</option> <option key={k.id} value={k.key}>{k.key}</option>
))} ))}
<option value={CUSTOM_VALUE}>Custom...</option> <option value={CUSTOM_VALUE}>Custom...</option>
</select> </select>
{mode === CUSTOM_VALUE && ( {selectedMode === CUSTOM_VALUE && (
<input <>
type="text" <input
value={customInput} type="password"
onChange={handleCustomInput} value={mode === "custom" ? value : ""}
placeholder="sk-..." onChange={handleCustomInput}
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" placeholder="sk-..."
/> autoComplete="off"
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{mode === "custom" && !value && (
<span className="text-[11px] font-normal text-amber-600 dark:text-amber-400">Custom keys are not saved. Enter it again to generate the configuration.</span>
)}
</>
)} )}
</div> </div>
); );
@@ -1,18 +1,13 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { APP_CONFIG } from "@/shared/constants/config"; import { APP_CONFIG } from "@/shared/constants/config";
import { ensureCliToolV1Endpoint, isLocalCliToolUrl } from "@/shared/utils/cliToolEndpoint";
const STORAGE_KEY = "9router.cliToolEndpointPresets"; const STORAGE_KEY = "9router.cliToolEndpointPresets";
const CUSTOM_VALUE = "__custom__"; const CUSTOM_VALUE = "__custom__";
const SAVE_VALUE = "__save__"; const SAVE_VALUE = "__save__";
const ensureV1 = (url) => {
const trimmed = (url || "").replace(/\/+$/, "");
if (!trimmed) return "";
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
};
const readSavedPresets = () => { const readSavedPresets = () => {
if (typeof window === "undefined") return []; if (typeof window === "undefined") return [];
try { try {
@@ -29,12 +24,15 @@ const writeSavedPresets = (presets) => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(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 opts = [];
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, "")); const wrap = (url) => (withV1 ? ensureCliToolV1Endpoint(url) : (url || "").replace(/\/+$/, ""));
if (!requiresExternalUrl) { const runtimeUrl = wrap(appUrl);
const localUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`); if (runtimeUrl && (!requiresExternalUrl || !isLocalCliToolUrl(runtimeUrl))) {
opts.push({ value: "local", label: localUrl, url: localUrl }); 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) { if (tunnelEnabled && tunnelPublicUrl) {
const u = wrap(tunnelPublicUrl); const u = wrap(tunnelPublicUrl);
@@ -58,6 +56,7 @@ const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tai
export default function BaseUrlSelect({ export default function BaseUrlSelect({
value, value,
onChange, onChange,
appUrl = "",
requiresExternalUrl = false, requiresExternalUrl = false,
tunnelEnabled = false, tunnelEnabled = false,
tunnelPublicUrl = "", tunnelPublicUrl = "",
@@ -69,31 +68,24 @@ export default function BaseUrlSelect({
}) { }) {
const [savedPresets, setSavedPresets] = useState([]); const [savedPresets, setSavedPresets] = useState([]);
const [mode, setMode] = useState(""); const [mode, setMode] = useState("");
const [customInput, setCustomInput] = useState("");
const initializedRef = useRef(false);
useEffect(() => { useEffect(() => {
setSavedPresets(readSavedPresets()); queueMicrotask(() => setSavedPresets(readSavedPresets()));
}, []); }, []);
const options = useMemo( const options = useMemo(
() => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }), () => buildOptions({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
[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 const effectiveMode = useMemo(() => {
useEffect(() => { if (mode) return mode;
if (initializedRef.current) return; const normalizedValue = (value || "").replace(/\/+$/, "");
if (options.length === 0) return; const matchingOption = options.find((option) => option.value !== CUSTOM_VALUE && option.url.replace(/\/+$/, "") === normalizedValue);
initializedRef.current = true; if (matchingOption) return matchingOption.value;
const first = options.find((o) => o.value !== CUSTOM_VALUE); if (value) return CUSTOM_VALUE;
if (first) { return options.find((option) => option.value !== CUSTOM_VALUE)?.value || CUSTOM_VALUE;
setMode(first.value); }, [mode, options, value]);
onChange(first.url);
} else {
setMode(CUSTOM_VALUE);
}
}, [options, onChange]);
const handleSelect = (e) => { const handleSelect = (e) => {
const next = e.target.value; const next = e.target.value;
@@ -112,7 +104,6 @@ export default function BaseUrlSelect({
} }
setMode(next); setMode(next);
if (next === CUSTOM_VALUE) { if (next === CUSTOM_VALUE) {
setCustomInput("");
onChange(""); onChange("");
return; return;
} }
@@ -121,31 +112,28 @@ export default function BaseUrlSelect({
}; };
const handleCustomInput = (e) => { const handleCustomInput = (e) => {
const v = e.target.value; onChange(e.target.value);
setCustomInput(v);
onChange(v);
}; };
const handleDeleteSaved = () => { const handleDeleteSaved = () => {
if (!mode.startsWith("saved:")) return; if (!effectiveMode.startsWith("saved:")) return;
const name = mode.slice(6); const name = effectiveMode.slice(6);
const updated = savedPresets.filter((p) => p.name !== name); const updated = savedPresets.filter((p) => p.name !== name);
setSavedPresets(updated); setSavedPresets(updated);
writeSavedPresets(updated); writeSavedPresets(updated);
setMode(CUSTOM_VALUE); setMode(CUSTOM_VALUE);
setCustomInput("");
onChange(""); onChange("");
}; };
const isSaved = mode.startsWith("saved:"); const isSaved = effectiveMode.startsWith("saved:");
const isCustom = mode === CUSTOM_VALUE; const isCustom = effectiveMode === CUSTOM_VALUE;
const canSave = isCustom && (customInput || "").trim().length > 0; const canSave = isCustom && (value || "").trim().length > 0;
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select
value={mode} value={effectiveMode}
onChange={handleSelect} onChange={handleSelect}
className="flex-1 min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" className="flex-1 min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
> >
@@ -163,7 +151,7 @@ export default function BaseUrlSelect({
{isCustom && ( {isCustom && (
<input <input
type="text" type="text"
value={customInput} value={value || ""}
onChange={handleCustomInput} onChange={handleCustomInput}
placeholder={withV1 ? "https://example.com/v1" : "https://example.com"} placeholder={withV1 ? "https://example.com/v1" : "https://example.com"}
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components"; import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components";
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js"; import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
import { DEFAULT_MODEL_TOKEN_LIMITS, getInputTokenOptions, getOutputTokenOptions } from "@/shared/constants/copilotModelTokens.js"; import { DEFAULT_MODEL_TOKEN_LIMITS, getInputTokenOptions, getOutputTokenOptions } from "@/shared/constants/copilotModelTokens.js";
import { resolveInitialCliToolBaseUrl } from "@/shared/utils/cliToolEndpoint";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
@@ -171,23 +172,36 @@ export default function ConfigGeneratorCard({
tunnelPublicUrl, tunnelPublicUrl,
tailscaleEnabled, tailscaleEnabled,
tailscaleUrl, tailscaleUrl,
initialConfig,
onSaveConfig,
}) { }) {
const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || ""); const restoredApiKey = initialConfig?.apiKeyId
const [selectedModels, setSelectedModels] = useState([]); ? apiKeys.find((key) => key.id === initialConfig.apiKeyId)?.key || ""
const [claudeModels, setClaudeModels] = useState({ sonnet: "", opus: "", haiku: "" }); : "";
const [claudeThinking, setClaudeThinking] = useState({ sonnet: "", opus: "", haiku: "" }); 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 [claudeModelSlot, setClaudeModelSlot] = useState("");
const [codexModel, setCodexModel] = useState(""); const [codexModel, setCodexModel] = useState(() => initialConfig?.codexModel || "");
const [codexThinking, setCodexThinking] = useState(""); const [codexThinking, setCodexThinking] = useState(() => initialConfig?.codexThinking || "");
const [opencodeModels, setOpencodeModels] = useState([]); const [opencodeModels, setOpencodeModels] = useState(() => initialConfig?.opencodeModels || []);
const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(""); const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(() => initialConfig?.opencodeDefaultModel || "");
const [coworkThinking, setCoworkThinking] = useState({}); const [coworkThinking, setCoworkThinking] = useState(() => initialConfig?.coworkThinking || {});
const [copilotTokens, setCopilotTokens] = useState({}); const [copilotTokens, setCopilotTokens] = useState(() => initialConfig?.copilotTokens || {});
const [copilotThinking, setCopilotThinking] = useState({}); const [copilotThinking, setCopilotThinking] = useState(() => initialConfig?.copilotThinking || {});
const connectedModels = availableModels; const connectedModels = availableModels;
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState(() => resolveInitialCliToolBaseUrl(initialConfig?.baseUrl, baseUrl));
const [modelModalOpen, setModelModalOpen] = useState(false); const [modelModalOpen, setModelModalOpen] = useState(false);
const [configModalOpen, setConfigModalOpen] = 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 effectiveBaseUrl = customBaseUrl || baseUrl;
const apiKey = toolId === "copilot" 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] [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 getThinkingLevelsForModel = (fullModel) => {
const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel); const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel);
if (!connectedModel?.provider?.id || !connectedModel.model) return null; if (!connectedModel?.provider?.id || !connectedModel.model) return null;
@@ -307,8 +358,9 @@ export default function ConfigGeneratorCard({
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted"> <label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
Endpoint Endpoint
<BaseUrlSelect <BaseUrlSelect
value={customBaseUrl || baseUrl} value={customBaseUrl}
onChange={setCustomBaseUrl} onChange={setCustomBaseUrl}
appUrl={baseUrl}
tunnelEnabled={tunnelEnabled} tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl} tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled} tailscaleEnabled={tailscaleEnabled}
@@ -320,7 +372,7 @@ export default function ConfigGeneratorCard({
{toolId !== "copilot" && ( {toolId !== "copilot" && (
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted"> <label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
API key API key
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} /> <ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} mode={apiKeyMode} onModeChange={setApiKeyMode} />
</label> </label>
)} )}
{toolId === "claude" ? ( {toolId === "claude" ? (
@@ -558,10 +610,20 @@ export default function ConfigGeneratorCard({
) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>} ) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>}
</div> </div>
)} )}
<Button onClick={() => setConfigModalOpen(true)} className="w-full sm:w-auto sm:self-start"> <div className="flex flex-wrap items-center gap-2">
<span className="material-symbols-outlined mr-1 text-[16px]">code</span> <Button onClick={handleSave} disabled={saveStatus === "saving"} variant="secondary" className="w-full sm:w-auto">
Show configuration file <span className="material-symbols-outlined mr-1 text-[16px]">{saveStatus === "saving" ? "progress_activity" : "save"}</span>
</Button> {saveStatus === "saving" ? "Saving..." : "Save"}
</Button>
<Button onClick={() => setConfigModalOpen(true)} className="w-full sm:w-auto">
<span className="material-symbols-outlined mr-1 text-[16px]">code</span>
Show configuration file
</Button>
{saveStatus === "saved" && <span className="text-xs text-green-600 dark:text-green-400">Configuration saved.</span>}
{saveStatus === "dirty" && <span className="text-xs text-text-muted">Unsaved changes</span>}
{saveStatus === "error" && <span className="text-xs text-red-600 dark:text-red-400">{saveError}</span>}
</div>
{apiKeyMode === "custom" && toolId !== "copilot" && <p className="text-xs text-amber-600 dark:text-amber-400">The custom API key is never saved and must be entered again after reload.</p>}
<p className="text-xs text-text-muted">9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.</p> <p className="text-xs text-text-muted">9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.</p>
</div> </div>
@@ -1,22 +1,38 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Card, ModelSelectModal } from "@/shared/components"; import { Button, Card, ModelSelectModal } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import Image from "next/image"; import Image from "next/image";
import ApiKeySelect from "./ApiKeySelect"; 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 [copiedField, setCopiedField] = useState(null);
const [showModelModal, setShowModelModal] = useState(false); const [showModelModal, setShowModelModal] = useState(false);
const [modelValue, setModelValue] = useState(""); const [modelValue, setModelValue] = useState("");
const [selectedModels, setSelectedModels] = useState([]); const [selectedModels, setSelectedModels] = useState(() => initialConfig?.selectedModels || []);
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
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);
// Initialize state directly with computed value - no need for useEffect useEffect(() => {
const [selectedApiKey, setSelectedApiKey] = useState(() => if (!initializedSaveState.current) {
apiKeys?.length > 0 ? apiKeys[0].key : "" initializedSaveState.current = true;
); return;
}
setSaveStatus((current) => current === "saving" ? current : "dirty");
setSaveError("");
}, [apiKeyMode, selectedApiKey, selectedModels]);
const replaceVars = (text) => { const replaceVars = (text) => {
const keyToUse = (selectedApiKey && selectedApiKey.trim()) 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)); 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 hasActiveProviders = activeProviders.length > 0;
const renderApiKeySelector = () => ( const renderApiKeySelector = () => (
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-2"> <div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-2">
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} className="flex-1" /> <ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} className="flex-1" mode={apiKeyMode} onModeChange={setApiKeyMode} />
</div> </div>
); );
@@ -249,6 +283,20 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
</pre> </pre>
</div> </div>
)} )}
{canShowGuide() && (
<div className="flex flex-col gap-2 border-t border-border pt-4">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={handleSave} disabled={saveStatus === "saving"}>
<span className="material-symbols-outlined mr-1 text-[16px]">{saveStatus === "saving" ? "progress_activity" : "save"}</span>
{saveStatus === "saving" ? "Saving..." : "Save"}
</Button>
{saveStatus === "saved" && <span className="text-xs text-green-600 dark:text-green-400">Configuration saved.</span>}
{saveStatus === "dirty" && <span className="text-xs text-text-muted">Unsaved changes</span>}
{saveStatus === "error" && <span className="text-xs text-red-600 dark:text-red-400">{saveError}</span>}
</div>
{apiKeyMode === "custom" && <p className="text-xs text-amber-600 dark:text-amber-400">The custom API key is never saved and must be entered again after reload.</p>}
</div>
)}
</div> </div>
); );
}; };
@@ -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");
}
}
+25
View File
@@ -1,6 +1,7 @@
// Public API barrel — all DB functions // Public API barrel — all DB functions
import { getAdapter } from "./driver.js"; import { getAdapter } from "./driver.js";
import { stringifyJson, parseJson } from "./helpers/jsonCol.js"; import { stringifyJson, parseJson } from "./helpers/jsonCol.js";
import { normalizeCliToolConfig, isPersistableCliTool } from "@/shared/constants/cliToolConfig.js";
// Settings // Settings
export { export {
@@ -45,6 +46,12 @@ export {
createCombo, updateCombo, deleteCombo, createCombo, updateCombo, deleteCombo,
} from "./repos/combosRepo.js"; } from "./repos/combosRepo.js";
// Per-user CLI tool configurations
export {
getCliToolConfig, getCliToolConfigsByOwnerId,
upsertCliToolConfig, deleteCliToolConfigsByOwnerId,
} from "./repos/cliToolConfigsRepo.js";
// Aliases (model + custom + mitm) // Aliases (model + custom + mitm)
export { export {
getModelAliases, setModelAlias, deleteModelAlias, 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 })), 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 })), 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 })), 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: {}, modelAliases: {},
customModels: [], customModels: [],
mitmAlias: {}, mitmAlias: {},
@@ -121,6 +129,7 @@ export async function importDb(payload) {
db.transaction(() => { db.transaction(() => {
// Wipe all tables (keep _meta) // Wipe all tables (keep _meta)
db.run(`DELETE FROM settings`); db.run(`DELETE FROM settings`);
db.run(`DELETE FROM cliToolConfigs`);
// Old backups predate multi-user authentication. Preserve the local // Old backups predate multi-user authentication. Preserve the local
// administrator unless the payload explicitly carries a users array. // administrator unless the payload explicitly carries a users array.
if (Array.isArray(payload.users)) db.run(`DELETE FROM users`); 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()] [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 || {})) { for (const [a, m] of Object.entries(payload.modelAliases || {})) {
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('modelAliases', ?, ?)`, [a, stringifyJson(m)]); db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('modelAliases', ?, ?)`, [a, stringifyJson(m)]);
} }
+49
View File
@@ -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;
}
+7 -2
View File
@@ -117,8 +117,13 @@ export async function countActiveAdmins() {
export async function deleteUser(id) { export async function deleteUser(id) {
const db = await getAdapter(); const db = await getAdapter();
const result = db.run(`DELETE FROM users WHERE id = ?`, [id]); let deleted = false;
return (result?.changes ?? 0) > 0; 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) { export async function verifyUserCredentials(username, password) {
+12 -1
View File
@@ -3,7 +3,7 @@
// pre-change safety backup in migrate.js: when the stored version is lower, // pre-change safety backup in migrate.js: when the stored version is lower,
// one lightweight DB backup is taken before applying schema changes. Forgetting // 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. // 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 = ` export const PRAGMA_SQL = `
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
@@ -123,6 +123,17 @@ export const TABLES = {
"CREATE INDEX IF NOT EXISTS idx_combo_owner ON combos(ownerId)", "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: { kv: {
columns: { columns: {
scope: "TEXT NOT NULL", scope: "TEXT NOT NULL",
+2
View File
@@ -16,6 +16,8 @@ export {
createApiKey, updateApiKey, deleteApiKey, validateApiKey, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
getCombos, getComboById, getComboByName, getCombos, getComboById, getComboByName,
createCombo, updateCombo, deleteCombo, createCombo, updateCombo, deleteCombo,
getCliToolConfig, getCliToolConfigsByOwnerId,
upsertCliToolConfig, deleteCliToolConfigsByOwnerId,
getModelAliases, setModelAlias, deleteModelAlias, getModelAliases, setModelAlias, deleteModelAlias,
getCustomModels, addCustomModel, deleteCustomModel, getCustomModels, addCustomModel, deleteCustomModel,
getMitmAlias, setMitmAliasAll, getMitmAlias, setMitmAliasAll,
+168
View File
@@ -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;
}
+68
View File
@@ -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);
}
@@ -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 <TOK>",
"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 <TOK>",
"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 <TOK>",
"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`] = ` exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
{ {
"apiKey": { "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 <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = ` exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
{ {
"apiKey": { "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 <TOK>",
"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 <TOK>",
"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 <TOK>",
"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`] = ` exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
{ {
"apiKey": { "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 <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"User-Agent": "kimchi/0.1.50",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = ` exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
{ {
"apiKey": { "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 <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = ` exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
{ {
"apiKey": { "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 <TOK>",
"Content-Type": "application/json",
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
},
}
`;
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = ` exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
{ {
"apiKey": { "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`] = ` 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", "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`] = ` exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
{ {
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions", "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`] = ` exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
{ {
"nonStream": "https://api.groq.com/openai/v1/chat/completions", "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`] = ` exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
{ {
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true", "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`] = ` exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
{ {
"nonStream": "https://api.siliconflow.com/v1/chat/completions", "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`] = ` exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
{ {
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions", "nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
@@ -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");
});
});
+104
View File
@@ -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();
});
});
+120
View File
@@ -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();
});
});
+65
View File
@@ -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");
});
});