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 (
)}
-
+
+
+
+ {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");
+ });
+});