From 06eda8b081cd94bb512658df231efd124a9b66f7 Mon Sep 17 00:00:00 2001 From: semihisikman Date: Mon, 28 Sep 2026 12:52:37 +0700 Subject: [PATCH] fix(cli-tools): replace sk_9router placeholder with first active dashboard API key --- .../api/cli-tools/codewhale-settings/route.js | 3 +- .../api/cli-tools/copilot-settings/route.js | 3 +- src/app/api/cli-tools/crush-settings/route.js | 3 +- .../cli-tools/deepseek-tui-settings/route.js | 3 +- src/app/api/cli-tools/forge-settings/route.js | 3 +- .../cli-tools/grok-build-settings/route.js | 3 +- src/app/api/cli-tools/omp-settings/route.js | 9 +- .../api/cli-tools/opencode-settings/route.js | 3 +- src/app/api/cli-tools/pi-settings/route.js | 6 +- src/app/api/cli-tools/resolveApiKey.js | 36 +++++++ src/app/api/cli-tools/smelt-settings/route.js | 3 +- .../unit/cli-tools-api-key-resolution.test.js | 97 +++++++++++++++++++ 12 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 src/app/api/cli-tools/resolveApiKey.js create mode 100644 tests/unit/cli-tools-api-key-resolution.test.js diff --git a/src/app/api/cli-tools/codewhale-settings/route.js b/src/app/api/cli-tools/codewhale-settings/route.js index f561dc06..0d85708e 100644 --- a/src/app/api/cli-tools/codewhale-settings/route.js +++ b/src/app/api/cli-tools/codewhale-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import fs from "fs/promises"; import path from "path"; import os from "os"; @@ -97,7 +98,7 @@ export async function POST(request) { existing.openai = { base_url: normalizedBaseUrl, - api_key: apiKey || "sk_9router", + api_key: await resolveCliApiKey(apiKey), model: model || "provider/model-id", }; diff --git a/src/app/api/cli-tools/copilot-settings/route.js b/src/app/api/cli-tools/copilot-settings/route.js index 3c0bd669..74303960 100644 --- a/src/app/api/cli-tools/copilot-settings/route.js +++ b/src/app/api/cli-tools/copilot-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import fs from "fs/promises"; import path from "path"; import os from "os"; @@ -81,7 +82,7 @@ export async function POST(request) { } catch { /* No existing config */ } const endpointUrl = `${baseUrl}/chat/completions#models.ai.azure.com`; - const keyToUse = apiKey || "sk_9router"; + const keyToUse = await resolveCliApiKey(apiKey); const newEntry = { name: "9Router", diff --git a/src/app/api/cli-tools/crush-settings/route.js b/src/app/api/cli-tools/crush-settings/route.js index dd96d948..72b1141e 100644 --- a/src/app/api/cli-tools/crush-settings/route.js +++ b/src/app/api/cli-tools/crush-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import fs from "fs/promises"; import path from "path"; import os from "os"; @@ -108,7 +109,7 @@ export async function POST(request) { existing.providers["9router"] = { type: "openai-compat", base_url: normalizedBaseUrl, - api_key: apiKey || "sk_9router", + api_key: await resolveCliApiKey(apiKey), models: [ { id: modelId, diff --git a/src/app/api/cli-tools/deepseek-tui-settings/route.js b/src/app/api/cli-tools/deepseek-tui-settings/route.js index 0edf74da..9fcf0edf 100644 --- a/src/app/api/cli-tools/deepseek-tui-settings/route.js +++ b/src/app/api/cli-tools/deepseek-tui-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import { exec } from "child_process"; import { promisify } from "util"; import fs from "fs/promises"; @@ -132,7 +133,7 @@ export async function POST(request) { const dir = getDeepSeekDir(); await fs.mkdir(dir, { recursive: true }); - const newConfig = build9RouterConfig(baseUrl, apiKey || "sk_9router", model); + const newConfig = build9RouterConfig(baseUrl, await resolveCliApiKey(apiKey), model); await fs.writeFile(getDeepSeekConfigPath(), newConfig); return NextResponse.json({ diff --git a/src/app/api/cli-tools/forge-settings/route.js b/src/app/api/cli-tools/forge-settings/route.js index 2f7c45dc..ca35412e 100644 --- a/src/app/api/cli-tools/forge-settings/route.js +++ b/src/app/api/cli-tools/forge-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import fs from "fs/promises"; import path from "path"; import os from "os"; @@ -96,7 +97,7 @@ export async function POST(request) { const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; existing.openai = { - api_key: apiKey || "sk_9router", + api_key: await resolveCliApiKey(apiKey), base_url: normalizedBaseUrl, model: model || "provider/model-id", }; diff --git a/src/app/api/cli-tools/grok-build-settings/route.js b/src/app/api/cli-tools/grok-build-settings/route.js index 02299a3b..4d7da8c1 100644 --- a/src/app/api/cli-tools/grok-build-settings/route.js +++ b/src/app/api/cli-tools/grok-build-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import { exec } from "child_process"; import { promisify } from "util"; import fs from "fs/promises"; @@ -108,7 +109,7 @@ export async function POST(request) { const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; const toml = applyGrokBuildConfig(await readConfigToml(), { baseUrl: normalizedBaseUrl, - apiKey: apiKey || "sk_9router", + apiKey: await resolveCliApiKey(apiKey), model: selectedModel, contextWindow: normalizeContextWindow(contextWindow, selectedModel), subagentModels: normalizeSubagentModels(subagentModels), diff --git a/src/app/api/cli-tools/omp-settings/route.js b/src/app/api/cli-tools/omp-settings/route.js index 52df53e1..ff7a4350 100644 --- a/src/app/api/cli-tools/omp-settings/route.js +++ b/src/app/api/cli-tools/omp-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import fs from "fs/promises"; import path from "path"; import os from "os"; @@ -51,7 +52,7 @@ const has9RouterInYml = (content) => { // Build standard 9Router provider block for models.yml const buildOmpProviderYaml = (baseUrl, apiKey) => { const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - const key = apiKey || "sk_9router"; + const key = apiKey || ""; return ` ${PROVIDER_ID}: baseUrl: ${normalizedBaseUrl} apiKey: ${key} @@ -100,10 +101,12 @@ export async function POST(request) { return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 }); } + const resolvedKey = await resolveCliApiKey(apiKey); + await fs.mkdir(getOmpDir(), { recursive: true }); let ymlContent = await readModelsYml(); - const providerBlock = buildOmpProviderYaml(baseUrl, apiKey); + const providerBlock = buildOmpProviderYaml(baseUrl, resolvedKey); // Remove existing 9router provider if present const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g"); @@ -137,7 +140,7 @@ export async function POST(request) { ).run( PROVIDER_ID, "api_key", - JSON.stringify({ apiKey: apiKey || "sk_9router", baseUrl }), + JSON.stringify({ apiKey: resolvedKey, baseUrl }), Math.floor(Date.now() / 1000), Math.floor(Date.now() / 1000) ); diff --git a/src/app/api/cli-tools/opencode-settings/route.js b/src/app/api/cli-tools/opencode-settings/route.js index 03819c66..b77c0263 100644 --- a/src/app/api/cli-tools/opencode-settings/route.js +++ b/src/app/api/cli-tools/opencode-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import { exec } from "child_process"; import { promisify } from "util"; import fs from "fs/promises"; @@ -112,7 +113,7 @@ export async function POST(request) { } catch { /* No existing config */ } const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - const keyToUse = apiKey || "sk_9router"; + const keyToUse = await resolveCliApiKey(apiKey); const effectiveSubagentModel = subagentModel || modelsArray[0]; // Ensure provider object diff --git a/src/app/api/cli-tools/pi-settings/route.js b/src/app/api/cli-tools/pi-settings/route.js index ee5c507b..812c5b0f 100644 --- a/src/app/api/cli-tools/pi-settings/route.js +++ b/src/app/api/cli-tools/pi-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import fs from "fs/promises"; import path from "path"; import os from "os"; @@ -145,9 +146,10 @@ export async function POST(request) { } existing.providers["9router"] = { + ...existingProvider, baseUrl: normalizedBaseUrl, - apiKey: apiKey || "sk_9router", - api: "openai-completions", + apiKey: apiKey || existingProvider.apiKey || await resolveCliApiKey(null), + api: existingProvider.api || "openai-completions", models: modelList, }; diff --git a/src/app/api/cli-tools/resolveApiKey.js b/src/app/api/cli-tools/resolveApiKey.js new file mode 100644 index 00000000..1567f3ce --- /dev/null +++ b/src/app/api/cli-tools/resolveApiKey.js @@ -0,0 +1,36 @@ +/** + * Resolves the API key to write into a CLI tool config. + * + * CLI tool cards send an empty string when no key is explicitly selected + * (e.g. the existing config already has a provider block but the frontend + * can't read the stored Authorization header back). The routes previously + * fell back to the literal placeholder "sk_9router", which causes 401 + * "Invalid API key" for any deployment with requireApiKey=true (#4399). + * + * Resolution order: + * 1. The key supplied by the caller (non-empty string). + * 2. The first active key in the dashboard's apiKeys table. + * 3. Empty string — the route writes no Authorization header value, + * which is fine for requireApiKey=false deployments. + * + * The placeholder "sk_9router" is NEVER written; it was never a real key. + */ + +import { getApiKeys } from "@/lib/db"; + +/** + * @param {string|null|undefined} callerKey Key sent by the frontend. + * @returns {Promise} + */ +export async function resolveCliApiKey(callerKey) { + if (callerKey && callerKey.trim() && callerKey.trim() !== "sk_9router") { + return callerKey.trim(); + } + try { + const keys = await getApiKeys(); + const active = keys.find((k) => k.isActive); + return active?.key || ""; + } catch { + return ""; + } +} \ No newline at end of file diff --git a/src/app/api/cli-tools/smelt-settings/route.js b/src/app/api/cli-tools/smelt-settings/route.js index 6592d0d9..bc7fa143 100644 --- a/src/app/api/cli-tools/smelt-settings/route.js +++ b/src/app/api/cli-tools/smelt-settings/route.js @@ -1,6 +1,7 @@ "use server"; import { NextResponse } from "next/server"; +import { resolveCliApiKey } from "../resolveApiKey.js"; import fs from "fs/promises"; import path from "path"; import os from "os"; @@ -96,7 +97,7 @@ export async function POST(request) { const updated = { ...existing, baseUrl: normalizedBaseUrl, - apiKey: apiKey || "sk_9router", + apiKey: await resolveCliApiKey(apiKey), model: model || existing.model || "provider/model-id", _managedBy: "9router", }; diff --git a/tests/unit/cli-tools-api-key-resolution.test.js b/tests/unit/cli-tools-api-key-resolution.test.js new file mode 100644 index 00000000..44f3ebe3 --- /dev/null +++ b/tests/unit/cli-tools-api-key-resolution.test.js @@ -0,0 +1,97 @@ +/** + * Tests for #4399 — CLI Tools Apply writes placeholder "sk_9router" key. + * + * When requireApiKey=true, the written "sk_9router" caused 401 on every + * CLI tool request. The fix: replace the literal fallback with + * resolveCliApiKey(), which reads the first active key from the DB + * (or returns "" if none exist — never the placeholder). + */ + +import { describe, it, expect } from "vitest"; +import fs from "fs"; +import path from "path"; + +// Test the resolveCliApiKey logic in isolation. +// The actual module reads from SQLite; we replicate the decision logic here. + +function resolveCliApiKeyLogic(callerKey, activeKeys = []) { + if (callerKey && callerKey.trim() && callerKey.trim() !== "sk_9router") { + return callerKey.trim(); + } + const active = activeKeys.find((k) => k.isActive); + return active?.key || ""; +} + +describe("resolveCliApiKey logic (#4399)", () => { + it("returns the caller key when non-empty and not the placeholder", () => { + expect(resolveCliApiKeyLogic("sk-real-key-123", [])).toBe("sk-real-key-123"); + }); + + it("falls back to first active DB key when caller key is empty", () => { + const keys = [{ key: "sk-db-key", isActive: true }]; + expect(resolveCliApiKeyLogic("", keys)).toBe("sk-db-key"); + }); + + it("falls back to first active DB key when caller key is null", () => { + const keys = [{ key: "sk-db-key", isActive: true }]; + expect(resolveCliApiKeyLogic(null, keys)).toBe("sk-db-key"); + }); + + it("falls back to first active DB key when caller key is undefined", () => { + const keys = [{ key: "sk-db-key", isActive: true }]; + expect(resolveCliApiKeyLogic(undefined, keys)).toBe("sk-db-key"); + }); + + it("falls back to first active DB key when caller is the placeholder itself", () => { + const keys = [{ key: "sk-db-key", isActive: true }]; + expect(resolveCliApiKeyLogic("sk_9router", keys)).toBe("sk-db-key"); + }); + + it("skips inactive keys and picks the first active one", () => { + const keys = [ + { key: "sk-inactive", isActive: false }, + { key: "sk-active", isActive: true }, + ]; + expect(resolveCliApiKeyLogic("", keys)).toBe("sk-active"); + }); + + it("returns empty string when no active DB key exists and caller is empty", () => { + const keys = [{ key: "sk-inactive", isActive: false }]; + expect(resolveCliApiKeyLogic("", keys)).toBe(""); + }); + + it("returns empty string when DB is empty and caller is empty", () => { + expect(resolveCliApiKeyLogic("", [])).toBe(""); + }); + + it("trims whitespace from caller key", () => { + expect(resolveCliApiKeyLogic(" sk-real ", [])).toBe("sk-real"); + }); + + it("never returns sk_9router", () => { + expect(resolveCliApiKeyLogic("sk_9router", [])).not.toBe("sk_9router"); + expect(resolveCliApiKeyLogic("", [])).not.toBe("sk_9router"); + }); +}); + +describe("resolveApiKey.js source checks (#4399)", () => { + const src = fs.readFileSync( + new URL("../../src/app/api/cli-tools/resolveApiKey.js", import.meta.url), + "utf-8" + ); + + it("resolveApiKey.js exports resolveCliApiKey", () => { + expect(src).toContain("resolveCliApiKey"); + }); + + it("resolveApiKey.js imports getApiKeys from DB", () => { + expect(src).toContain("getApiKeys"); + }); + + it("resolveApiKey.js does not return sk_9router as a fallback value", () => { + // The helper may reference "sk_9router" to guard against it, but must + // never use it as a return / fallback value (e.g. `return "sk_9router"`). + expect(src).not.toMatch(/return\s+"sk_9router"/); + expect(src).not.toMatch(/\|\|\s*"sk_9router"/); + }); +}); \ No newline at end of file