From 589f29931991a87f288a0756c84facafdcbaf233 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 29 Jun 2026 22:49:39 +0800 Subject: [PATCH] Refine provider docs and gateway API key handling --- .../en/configuration/provider-deeplink.md | 12 +- .../zh/configuration/provider-deeplink.md | 12 +- docs/src/layouts/DocsLayout.astro | 4 +- docs/src/styles/global.css | 6 + src/main/config.ts | 41 +++-- src/main/plugins/service.ts | 45 ++++- src/main/presets/anthropic/index.ts | 3 +- src/main/presets/bailian/index.ts | 3 +- src/main/presets/deepseek/index.ts | 3 +- src/main/presets/gemini/index.ts | 3 +- src/main/presets/index.ts | 5 +- src/main/presets/kimi-coding/index.ts | 8 +- src/main/presets/mistral/index.ts | 3 +- src/main/presets/moonshot/index.ts | 72 ++++++-- src/main/presets/openai/index.ts | 3 +- src/main/presets/openrouter/index.ts | 3 +- src/main/presets/siliconflow/index.ts | 3 +- src/main/presets/zai-global-coding/index.ts | 3 +- src/main/presets/zai-global-general/index.ts | 3 +- src/main/presets/zhipu-cn-coding/index.ts | 3 +- src/main/presets/zhipu-cn-general/index.ts | 3 +- src/main/provider-model-catalog.ts | 1 + src/main/web-client-bridge.ts | 47 +++++- src/main/web-management-server.ts | 155 +++++++++++++++++- src/renderer/pages/home/App.tsx | 26 ++- .../pages/home/components/dashboard.tsx | 123 ++++++++++++-- .../pages/home/components/providers.tsx | 151 +++++++++++++++-- src/renderer/pages/home/shared/i18n.tsx | 10 +- src/renderer/pages/home/shared/options.ts | 1 + src/renderer/pages/home/shared/providers.ts | 5 +- .../gateway/claude-code-router-plugin.ts | 64 +++++++- src/server/gateway/service.ts | 5 +- src/server/proxy/certificates.ts | 30 +++- src/shared/provider-presets.ts | 1 + 34 files changed, 750 insertions(+), 110 deletions(-) diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md index f4a3818..b1b9a43 100644 --- a/docs/src/content/docs/en/configuration/provider-deeplink.md +++ b/docs/src/content/docs/en/configuration/provider-deeplink.md @@ -2,12 +2,12 @@ title: One click import pageTitle: One click import eyebrow: Detailed Configuration -lead: Use ccr://provider links to hand provider configuration to CCR, or open the import confirmation page with preset provider buttons. +lead: Quickly add common model providers, review the details, and save them without filling everything in by hand. --- ## One-Click Import -The buttons below open the CCR desktop app's provider import confirmation dialog. Preset buttons do not include API keys; custom provider links may include one. Always confirm the provider name, Base URL, protocol, and models before importing a key. +Choose a provider below to get started. CCR shows what will be added before saving it; when using a custom entry point, make sure the source is one you trust.
@@ -50,9 +50,13 @@ The buttons below open the CCR desktop app's provider import confirmation dialog MistralChat Completions - + - Moonshot KimiChat Completions + Kimi API (China)China platform + + + + Kimi API (Global)Global platform diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md index d51e045..bdbb48f 100644 --- a/docs/src/content/docs/zh/configuration/provider-deeplink.md +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -2,12 +2,12 @@ title: 一键导入供应商 pageTitle: 一键导入供应商 eyebrow: 详细配置 -lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有 preset 供应商按钮快速打开导入确认页。 +lead: 快速添加常见模型供应商,确认无误后即可保存,减少手动配置的繁琐步骤。 --- ## 一键导入 -下面的按钮会打开 CCR 桌面 App 的供应商导入确认页。预设按钮不会携带 API Key;自定义供应商链接可以携带 Key。导入前始终确认供应商名称、Base URL、协议和模型。 +选择下面的供应商即可开始添加。CCR 会先显示即将添加的内容,确认无误后再保存;使用自定义入口时,请确保来源可信。
@@ -50,9 +50,13 @@ lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有 Mistral 官方Chat Completions - + - 月之暗面 KimiChat Completions + Kimi API(国内)国内平台 + + + + Kimi API(海外)海外平台 diff --git a/docs/src/layouts/DocsLayout.astro b/docs/src/layouts/DocsLayout.astro index ea5dde8..c803dc3 100644 --- a/docs/src/layouts/DocsLayout.astro +++ b/docs/src/layouts/DocsLayout.astro @@ -80,7 +80,7 @@ const resolvedNavItems = navItems.map((item, index) => { }; }); const homeHref = withBase(locale === "en" ? "/en/" : "/"); -const faviconHref = withBase("/favicon.svg"); +const faviconHref = withBase("/ccr-icon.png"); const logoSrc = withBase("/logo.png"); const sidebarIcons = { rocket: Rocket, @@ -177,7 +177,7 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation"; } })(); - + { resolvedLanguageOptions.map((option) => ( { const gatewayConfig = picked.gateway ?? {}; const corePort = gatewayConfig.corePort ?? nextPort(port); const configFileApiKeys = normalizeApiKeys(picked.APIKEYS, picked.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); - const persistedApiKeys = await loadPersistedApiKeys(); - const apiKeys = uniqueApiKeyConfigs([...persistedApiKeys, ...configFileApiKeys]); + const persistedApiKeys = (await loadPersistedApiKeys()).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); + const loadedApiKeys = uniqueApiKeyConfigs([...persistedApiKeys, ...configFileApiKeys]); + const apiKeys = ensureGatewayApiKeys(loadedApiKeys); const config: AppConfig = withSingleEnabledGlobalProfiles({ ...DEFAULT_CONFIG, ...picked, @@ -413,11 +415,11 @@ export async function loadAppConfig(): Promise { }, routerEndpoint: endpoint }); - const shouldMigrateApiKeys = hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0; - if (shouldMigrateApiKeys) { + const shouldPersistApiKeys = loadedApiKeys.length === 0 || hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0; + if (shouldPersistApiKeys) { await replacePersistedApiKeys(apiKeys); } - if (loadedRawConfig.source !== "sqlite" || shouldMigrateApiKeys) { + if (loadedRawConfig.source !== "sqlite" || shouldPersistApiKeys) { await writeSanitizedConfig(config); } return config; @@ -427,10 +429,16 @@ export async function loadAppConfig(): Promise { console.warn(`[config] Failed to load API keys: ${formatError(storeError)}`); return [] as ApiKeyConfig[]; }); + const apiKeys = ensureGatewayApiKeys(persistedApiKeys.filter((apiKey) => !isDefaultSeedApiKey(apiKey))); + if (persistedApiKeys.length === 0) { + await replacePersistedApiKeys(apiKeys).catch((storeError) => { + console.warn(`[config] Failed to persist generated API key: ${formatError(storeError)}`); + }); + } return { ...DEFAULT_CONFIG, - APIKEY: persistedApiKeys[0]?.key ?? "", - APIKEYS: persistedApiKeys + APIKEY: apiKeys[0]?.key ?? "", + APIKEYS: apiKeys }; } } @@ -438,7 +446,7 @@ export async function loadAppConfig(): Promise { export async function saveAppConfig(config: AppConfig): Promise { const normalizedConfig = withSingleEnabledGlobalProfiles(config); assertProviderApiKeysAreSafe(normalizedConfig); - const apiKeys = normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); + const apiKeys = ensureGatewayApiKeys(normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey))); await replacePersistedApiKeys(apiKeys); await writeSanitizedConfig({ ...normalizedConfig, @@ -564,7 +572,7 @@ function providerCredentialApiKey(credential: ProviderCredentialConfig): string } export async function saveApiKeysConfig(apiKeys: ApiKeyConfig[]): Promise { - const normalized = normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); + const normalized = ensureGatewayApiKeys(normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey))); await replacePersistedApiKeys(normalized); return loadAppConfig(); } @@ -2516,6 +2524,19 @@ function normalizeApiKeys(value: ApiKeyConfig[] | undefined, legacyKey: string | return uniqueApiKeyConfigs([...(value ?? []), ...(legacyKey ? [createApiKeyConfig(legacyKey, value?.length ?? 0)] : [])]); } +function ensureGatewayApiKeys(apiKeys: ApiKeyConfig[]): ApiKeyConfig[] { + return apiKeys.length ? apiKeys : [createGeneratedGatewayApiKey()]; +} + +function createGeneratedGatewayApiKey(): ApiKeyConfig { + return { + createdAt: new Date().toISOString(), + id: GENERATED_GATEWAY_API_KEY_ID, + key: `sk-ccr-${randomBytes(32).toString("base64url")}`, + name: "Local Gateway" + }; +} + function createApiKeyConfig(key: string, index: number): ApiKeyConfig { return { createdAt: new Date(0).toISOString(), diff --git a/src/main/plugins/service.ts b/src/main/plugins/service.ts index 16da667..83dfcdc 100644 --- a/src/main/plugins/service.ts +++ b/src/main/plugins/service.ts @@ -523,14 +523,49 @@ async function loadPluginModule(modulePath: string): Promise { } function resolvePluginModule(modulePath: string): string { - const expanded = expandHome(modulePath); + const resolved = requireFromHere.resolve(resolveLocalModulePath(modulePath, "Plugin module")); + assertJavaScriptModulePath(resolved, "Plugin module"); + return resolved; +} + +function resolveLocalModulePath(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${label} path is required.`); + } + + const expanded = expandHome(trimmed); if (path.isAbsolute(expanded)) { - return requireFromHere.resolve(expanded); + return expanded; } - if (expanded.startsWith(".")) { - return requireFromHere.resolve(path.resolve(CONFIGDIR, expanded)); + if (isProtocolSpecifier(expanded)) { + throw new Error(`${label} must be a local JavaScript file path, not a URL or protocol specifier.`); } - return requireFromHere.resolve(expanded, { paths: [CONFIGDIR, process.cwd()] }); + if (!expanded.startsWith(".")) { + throw new Error(`${label} must be an explicit local JavaScript path. Package specifiers are not loaded from configuration.`); + } + + const resolved = path.resolve(CONFIGDIR, expanded); + if (!isPathInside(resolved, CONFIGDIR)) { + throw new Error(`${label} relative paths must stay inside the CCR config directory.`); + } + return resolved; +} + +function assertJavaScriptModulePath(resolved: string, label: string): void { + const extension = path.extname(resolved).toLowerCase(); + if (![".cjs", ".js", ".mjs"].includes(extension)) { + throw new Error(`${label} must resolve to a JavaScript module file.`); + } +} + +function isProtocolSpecifier(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); +} + +function isPathInside(file: string, root: string): boolean { + const relative = path.relative(root, file); + return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative)); } function normalizeLoadedPlugin(moduleValue: unknown): LoadedPlugin { diff --git a/src/main/presets/anthropic/index.ts b/src/main/presets/anthropic/index.ts index 1408c31..7cdc93b 100644 --- a/src/main/presets/anthropic/index.ts +++ b/src/main/presets/anthropic/index.ts @@ -14,5 +14,6 @@ export const anthropicProviderPreset: ProviderPreset = { name: "Anthropic", officialApiKeyPatterns: [ { flags: "i", source: "^sk-ant-[a-z0-9_-]+$" } - ] + ], + websiteUrl: "https://www.anthropic.com/" }; diff --git a/src/main/presets/bailian/index.ts b/src/main/presets/bailian/index.ts index f9c596e..f52da99 100644 --- a/src/main/presets/bailian/index.ts +++ b/src/main/presets/bailian/index.ts @@ -10,5 +10,6 @@ export const bailianProviderPreset: ProviderPreset = { } ], id: "bailian", - name: "Alibaba Bailian" + name: "Alibaba Bailian", + websiteUrl: "https://bailian.console.aliyun.com/" }; diff --git a/src/main/presets/deepseek/index.ts b/src/main/presets/deepseek/index.ts index dfbc177..ee87dc6 100644 --- a/src/main/presets/deepseek/index.ts +++ b/src/main/presets/deepseek/index.ts @@ -47,5 +47,6 @@ export const deepSeekProviderPreset: ProviderPreset = { } ], id: "deepseek", - name: "DeepSeek" + name: "DeepSeek", + websiteUrl: "https://www.deepseek.com/" }; diff --git a/src/main/presets/gemini/index.ts b/src/main/presets/gemini/index.ts index 808a043..432d4ff 100644 --- a/src/main/presets/gemini/index.ts +++ b/src/main/presets/gemini/index.ts @@ -13,5 +13,6 @@ export const geminiProviderPreset: ProviderPreset = { name: "Google Gemini", officialApiKeyPatterns: [ { flags: "i", source: "^AIza[a-z0-9_-]{20,}$" } - ] + ], + websiteUrl: "https://gemini.google.com/" }; diff --git a/src/main/presets/index.ts b/src/main/presets/index.ts index 2c48c59..62cec8e 100644 --- a/src/main/presets/index.ts +++ b/src/main/presets/index.ts @@ -4,7 +4,7 @@ import { deepSeekProviderPreset } from "./deepseek"; import { geminiProviderPreset } from "./gemini"; import { kimiCodingProviderPreset } from "./kimi-coding"; import { mistralProviderPreset } from "./mistral"; -import { moonshotProviderPreset } from "./moonshot"; +import { moonshotChinaProviderPreset, moonshotGlobalProviderPreset } from "./moonshot"; import { openaiProviderPreset } from "./openai"; import { openRouterProviderPreset } from "./openrouter"; import { siliconFlowProviderPreset } from "./siliconflow"; @@ -35,7 +35,8 @@ export const providerPresets: ProviderPreset[] = [ zaiGlobalCodingProviderPreset, zaiGlobalGeneralProviderPreset, mistralProviderPreset, - moonshotProviderPreset, + moonshotChinaProviderPreset, + moonshotGlobalProviderPreset, bailianProviderPreset, siliconFlowProviderPreset ]; diff --git a/src/main/presets/kimi-coding/index.ts b/src/main/presets/kimi-coding/index.ts index 9ff6dd8..6848c28 100644 --- a/src/main/presets/kimi-coding/index.ts +++ b/src/main/presets/kimi-coding/index.ts @@ -26,14 +26,16 @@ export const kimiCodingProviderPreset: ProviderPreset = { endpoints: [ { baseUrl: "https://api.kimi.com/coding/v1", - protocols: ["openai_chat_completions"] + protocols: ["openai_chat_completions"], + websiteUrl: "https://www.kimi.com/code?aff=ccr" }, { baseUrl: "https://api.kimi.com/coding/", - protocols: ["anthropic_messages"] + protocols: ["anthropic_messages"], + websiteUrl: "https://www.kimi.com/code?aff=ccr" } ], id: "kimi-coding", name: "Kimi Code - Coding Plan", - websiteUrl: "https://www.kimi.com/code/docs/" + websiteUrl: "https://www.kimi.com/code?aff=ccr" }; diff --git a/src/main/presets/mistral/index.ts b/src/main/presets/mistral/index.ts index 10ec1fa..ff351f4 100644 --- a/src/main/presets/mistral/index.ts +++ b/src/main/presets/mistral/index.ts @@ -41,5 +41,6 @@ export const mistralProviderPreset: ProviderPreset = { } ], id: "mistral", - name: "Mistral" + name: "Mistral", + websiteUrl: "https://mistral.ai/" }; diff --git a/src/main/presets/moonshot/index.ts b/src/main/presets/moonshot/index.ts index c7c9125..dd2c72f 100644 --- a/src/main/presets/moonshot/index.ts +++ b/src/main/presets/moonshot/index.ts @@ -1,7 +1,7 @@ import type { ProviderAccountConfig } from "../../../shared/app"; import type { ProviderPreset } from "../../../shared/provider-presets"; -const moonshotProviderAccountConfig: ProviderAccountConfig = { +const moonshotGlobalProviderAccountConfig: ProviderAccountConfig = { connectors: [ { auth: "provider-api-key", @@ -37,20 +37,70 @@ const moonshotProviderAccountConfig: ProviderAccountConfig = { enabled: true }; -export const moonshotProviderPreset: ProviderPreset = { - account: moonshotProviderAccountConfig, - aliases: ["kimi", "moonshot"], +const moonshotChinaProviderAccountConfig: ProviderAccountConfig = { + connectors: [ + { + auth: "provider-api-key", + endpoint: "https://api.moonshot.cn/v1/users/me/balance", + mapping: { + meters: [ + { + id: "balance", + kind: "balance", + label: "Balance", + remaining: "$.data.available_balance", + unit: "CNY" + }, + { + id: "voucher_balance", + kind: "balance", + label: "Voucher balance", + remaining: "$.data.voucher_balance", + unit: "CNY" + }, + { + id: "cash_balance", + kind: "balance", + label: "Cash balance", + remaining: "$.data.cash_balance", + unit: "CNY" + } + ] + }, + type: "http-json" + } + ], + enabled: true +}; + +export const moonshotChinaProviderPreset: ProviderPreset = { + account: moonshotChinaProviderAccountConfig, + aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"], + defaultModels: ["moonshot-v1-8k"], + endpoints: [ + { + baseUrl: "https://api.moonshot.cn/v1", + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.kimi.com/?aff=ccr" + } + ], + id: "moonshot", + name: "Kimi API (China)", + websiteUrl: "https://platform.kimi.com/?aff=ccr" +}; + +export const moonshotGlobalProviderPreset: ProviderPreset = { + account: moonshotGlobalProviderAccountConfig, + aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"], defaultModels: ["moonshot-v1-8k"], endpoints: [ { baseUrl: "https://api.moonshot.ai/v1", - protocols: ["openai_chat_completions"] - }, - { - baseUrl: "https://api.moonshot.cn/v1", - protocols: ["openai_chat_completions"] + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.kimi.ai/?aff=ccr" } ], - id: "moonshot", - name: "Moonshot Kimi" + id: "moonshot-global", + name: "Kimi API (Global)", + websiteUrl: "https://platform.kimi.ai/?aff=ccr" }; diff --git a/src/main/presets/openai/index.ts b/src/main/presets/openai/index.ts index cec8000..e2d5cf9 100644 --- a/src/main/presets/openai/index.ts +++ b/src/main/presets/openai/index.ts @@ -14,5 +14,6 @@ export const openaiProviderPreset: ProviderPreset = { name: "OpenAI", officialApiKeyPatterns: [ { flags: "i", source: "^sk-(?:proj|svcacct)-[a-z0-9_-]+$" } - ] + ], + websiteUrl: "https://openai.com/" }; diff --git a/src/main/presets/openrouter/index.ts b/src/main/presets/openrouter/index.ts index 1294dae..86d56da 100644 --- a/src/main/presets/openrouter/index.ts +++ b/src/main/presets/openrouter/index.ts @@ -51,5 +51,6 @@ export const openRouterProviderPreset: ProviderPreset = { name: "OpenRouter", officialApiKeyPatterns: [ { flags: "i", source: "^sk-or-v1-[a-z0-9_-]+$" } - ] + ], + websiteUrl: "https://openrouter.ai/" }; diff --git a/src/main/presets/siliconflow/index.ts b/src/main/presets/siliconflow/index.ts index 8436699..737c338 100644 --- a/src/main/presets/siliconflow/index.ts +++ b/src/main/presets/siliconflow/index.ts @@ -47,5 +47,6 @@ export const siliconFlowProviderPreset: ProviderPreset = { } ], id: "siliconflow", - name: "SiliconFlow" + name: "SiliconFlow", + websiteUrl: "https://siliconflow.cn/" }; diff --git a/src/main/presets/zai-global-coding/index.ts b/src/main/presets/zai-global-coding/index.ts index 8e0e41e..d757fcc 100644 --- a/src/main/presets/zai-global-coding/index.ts +++ b/src/main/presets/zai-global-coding/index.ts @@ -58,5 +58,6 @@ export const zaiGlobalCodingProviderPreset: ProviderPreset = { } ], id: "zai-global-coding", - name: "Z.ai (Global) - Coding Plan" + name: "Z.ai (Global) - Coding Plan", + websiteUrl: "https://z.ai/" }; diff --git a/src/main/presets/zai-global-general/index.ts b/src/main/presets/zai-global-general/index.ts index b0d63c5..44612fd 100644 --- a/src/main/presets/zai-global-general/index.ts +++ b/src/main/presets/zai-global-general/index.ts @@ -54,5 +54,6 @@ export const zaiGlobalGeneralProviderPreset: ProviderPreset = { } ], id: "zai-global-general", - name: "Z.ai (Global) - General Endpoint" + name: "Z.ai (Global) - General Endpoint", + websiteUrl: "https://z.ai/" }; diff --git a/src/main/presets/zhipu-cn-coding/index.ts b/src/main/presets/zhipu-cn-coding/index.ts index 17456d3..a28dda1 100644 --- a/src/main/presets/zhipu-cn-coding/index.ts +++ b/src/main/presets/zhipu-cn-coding/index.ts @@ -58,5 +58,6 @@ export const zhipuCnCodingProviderPreset: ProviderPreset = { } ], id: "zhipu-cn-coding", - name: "Zhipu AI (China) - Coding Plan" + name: "Zhipu AI (China) - Coding Plan", + websiteUrl: "https://www.bigmodel.cn/" }; diff --git a/src/main/presets/zhipu-cn-general/index.ts b/src/main/presets/zhipu-cn-general/index.ts index c178ba5..c0c0e52 100644 --- a/src/main/presets/zhipu-cn-general/index.ts +++ b/src/main/presets/zhipu-cn-general/index.ts @@ -54,5 +54,6 @@ export const zhipuCnGeneralProviderPreset: ProviderPreset = { } ], id: "zhipu-cn-general", - name: "Zhipu AI (China) - General Endpoint" + name: "Zhipu AI (China) - General Endpoint", + websiteUrl: "https://www.bigmodel.cn/" }; diff --git a/src/main/provider-model-catalog.ts b/src/main/provider-model-catalog.ts index e9e465c..70e9b48 100644 --- a/src/main/provider-model-catalog.ts +++ b/src/main/provider-model-catalog.ts @@ -38,6 +38,7 @@ const presetCatalogProviderIds: Record = { "kimi-coding": ["kimi-for-coding"], mistral: ["mistral"], moonshot: ["moonshotai-cn"], + "moonshot-global": ["moonshotai"], openai: ["openai"], openrouter: ["openrouter"], siliconflow: ["siliconflow-cn"], diff --git a/src/main/web-client-bridge.ts b/src/main/web-client-bridge.ts index f720d17..a9096f2 100644 --- a/src/main/web-client-bridge.ts +++ b/src/main/web-client-bridge.ts @@ -1,4 +1,8 @@ const rpcEndpoint = "/api/ccr/rpc"; +const webAuthHeader = "x-ccr-web-auth"; +const webAuthQueryParam = "ccr_web_token"; +const webAuthStorageKey = "ccr.webAuthToken"; +const webAuthToken = readWebAuthToken(); type RpcResponse = | { ok: true; value: unknown } @@ -8,7 +12,8 @@ async function rpc(method: string, args: unknown[] = []): Promise { const response = await fetch(rpcEndpoint, { body: JSON.stringify({ args, method }), headers: { - "content-type": "application/json" + "content-type": "application/json", + ...(webAuthToken ? { [webAuthHeader]: webAuthToken } : {}) }, method: "POST" }); @@ -27,6 +32,46 @@ async function rpc(method: string, args: unknown[] = []): Promise { return payload.value; } +function readWebAuthToken(): string { + const tokenFromUrl = readWebAuthTokenFromUrl(); + if (tokenFromUrl) { + writeStoredWebAuthToken(tokenFromUrl); + return tokenFromUrl; + } + return readStoredWebAuthToken(); +} + +function readWebAuthTokenFromUrl(): string { + try { + const url = new URL(window.location.href); + const token = url.searchParams.get(webAuthQueryParam)?.trim() ?? ""; + if (!token) { + return ""; + } + url.searchParams.delete(webAuthQueryParam); + window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`); + return token; + } catch { + return ""; + } +} + +function readStoredWebAuthToken(): string { + try { + return window.sessionStorage.getItem(webAuthStorageKey)?.trim() ?? ""; + } catch { + return ""; + } +} + +function writeStoredWebAuthToken(token: string): void { + try { + window.sessionStorage.setItem(webAuthStorageKey, token); + } catch { + // The in-memory token still works for the current page load if storage is unavailable. + } +} + function noopSubscription(): () => void { return () => undefined; } diff --git a/src/main/web-management-server.ts b/src/main/web-management-server.ts index c22697f..4af4773 100644 --- a/src/main/web-management-server.ts +++ b/src/main/web-management-server.ts @@ -1,6 +1,8 @@ import { spawn } from "node:child_process"; +import { randomBytes, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -80,10 +82,19 @@ type RpcRequest = { type RpcHandler = (...args: unknown[]) => Promise | unknown; +type WebManagementSecurityContext = { + allowIpLiteralHosts: boolean; + allowedHostnames: Set; + authToken: string; + port: number; +}; + const defaultWebHost = "127.0.0.1"; const defaultWebPort = 3458; const onboardingFinishedAtSettingKey = "onboardingFinishedAt"; const maxRpcBodyBytes = 8 * 1024 * 1024; +const webAuthHeader = "x-ccr-web-auth"; +const webAuthQueryParam = "ccr_web_token"; const staticRoot = path.resolve(__dirname, "..", "renderer"); const homeHtmlFile = path.join(staticRoot, "pages", "home", "index.html"); const rendererAssetsRoot = path.join(staticRoot, "assets"); @@ -111,8 +122,14 @@ const pluginMarketplace: PluginMarketplaceEntry[] = [ export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise { const host = options.host?.trim() || readEnvString("CCR_WEB_HOST") || defaultWebHost; const requestedPort = options.port ?? readEnvPort("CCR_WEB_PORT") ?? defaultWebPort; + const authToken = randomBytes(32).toString("base64url"); + let security: WebManagementSecurityContext | undefined; const server = createServer((request, response) => { - void handleRequest(request, response).catch((error) => { + if (!security) { + sendJson(response, 503, { error: { message: "CCR web management server is not ready." }, ok: false }); + return; + } + void handleRequest(request, response, security).catch((error) => { sendJson(response, 500, { error: { message: formatError(error) }, ok: false }); }); }); @@ -120,7 +137,9 @@ export async function startWebManagementServer(options: WebManagementServerOptio const listenedPort = await listenWithFallback(server, requestedPort, host); const address = server.address(); const port = typeof address === "object" && address ? address.port : listenedPort; - const url = `http://${formatListenHost(host)}:${port}/`; + const baseUrl = `http://${formatListenHost(host)}:${port}/`; + const url = urlWithWebAuthToken(baseUrl, authToken); + security = createWebManagementSecurityContext(host, port, authToken); if (options.startGateway !== false) { await startConfiguredServices("web startup"); @@ -141,10 +160,15 @@ export async function startWebManagementServer(options: WebManagementServerOptio }; } -async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise { +async function handleRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise { + if (!isAllowedWebRequestHost(request, security)) { + sendText(response, 403, "Forbidden host"); + return; + } + const url = requestUrl(request); if (url.pathname === "/api/ccr/rpc") { - await handleRpcRequest(request, response); + await handleRpcRequest(request, response, security); return; } if (request.method !== "GET" && request.method !== "HEAD") { @@ -162,11 +186,23 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) sendText(response, 404, "Not found"); } -async function handleRpcRequest(request: IncomingMessage, response: ServerResponse): Promise { +async function handleRpcRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise { if (request.method !== "POST") { sendJson(response, 405, { error: { message: "RPC only supports POST." }, ok: false }); return; } + if (!isJsonRequest(request)) { + sendJson(response, 415, { error: { message: "RPC requests must use application/json." }, ok: false }); + return; + } + if (!isAllowedWebRequestOrigin(request, security)) { + sendJson(response, 403, { error: { message: "Forbidden RPC origin." }, ok: false }); + return; + } + if (!hasValidWebAuthToken(request, security)) { + sendJson(response, 401, { error: { message: "CCR web authentication token is missing or invalid." }, ok: false }); + return; + } let payload: RpcRequest; try { @@ -498,7 +534,8 @@ function sendBuffer(response: ServerResponse, status: number, body: Buffer, cont response.writeHead(status, { "cache-control": "no-store", "content-length": body.length, - "content-type": contentType + "content-type": contentType, + "x-content-type-options": "nosniff" }); if (headOnly) { response.end(); @@ -512,7 +549,8 @@ function sendJson(response: ServerResponse, status: number, payload: unknown): v response.writeHead(status, { "cache-control": "no-store", "content-length": body.length, - "content-type": "application/json; charset=utf-8" + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff" }); response.end(body); } @@ -525,6 +563,109 @@ function requestUrl(request: IncomingMessage): URL { return new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); } +function createWebManagementSecurityContext(host: string, port: number, authToken: string): WebManagementSecurityContext { + const normalizedHost = normalizeHostname(host); + const allowedHostnames = new Set(["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"]); + if (normalizedHost) { + allowedHostnames.add(normalizedHost); + } + return { + allowIpLiteralHosts: isWildcardBindHost(normalizedHost), + allowedHostnames, + authToken, + port + }; +} + +function urlWithWebAuthToken(value: string, authToken: string): string { + const url = new URL(value); + url.searchParams.set(webAuthQueryParam, authToken); + return url.toString(); +} + +function isAllowedWebRequestHost(request: IncomingMessage, security: WebManagementSecurityContext): boolean { + const hostname = requestHostname(request); + return Boolean(hostname && isAllowedWebHostname(hostname, security)); +} + +function isAllowedWebRequestOrigin(request: IncomingMessage, security: WebManagementSecurityContext): boolean { + const origin = readHeaderValue(request.headers.origin); + if (origin && !isAllowedWebOriginValue(origin, security)) { + return false; + } + + const referer = readHeaderValue(request.headers.referer); + if (!origin && referer && !isAllowedWebOriginValue(referer, security)) { + return false; + } + + return true; +} + +function isAllowedWebOriginValue(value: string, security: WebManagementSecurityContext): boolean { + try { + const url = new URL(value); + const port = url.port ? Number(url.port) : url.protocol === "http:" ? 80 : url.protocol === "https:" ? 443 : undefined; + return url.protocol === "http:" && + port === security.port && + isAllowedWebHostname(normalizeHostname(url.hostname), security); + } catch { + return false; + } +} + +function isAllowedWebHostname(hostname: string, security: WebManagementSecurityContext): boolean { + const normalized = normalizeHostname(hostname); + return security.allowedHostnames.has(normalized) || + (security.allowIpLiteralHosts && Boolean(net.isIP(normalized))); +} + +function requestHostname(request: IncomingMessage): string | undefined { + const host = readHeaderValue(request.headers.host); + if (!host) { + return undefined; + } + try { + return normalizeHostname(new URL(`http://${host}`).hostname); + } catch { + return undefined; + } +} + +function isJsonRequest(request: IncomingMessage): boolean { + const contentType = readHeaderValue(request.headers["content-type"])?.toLowerCase() ?? ""; + return contentType.split(";")[0]?.trim() === "application/json"; +} + +function hasValidWebAuthToken(request: IncomingMessage, security: WebManagementSecurityContext): boolean { + const token = readHeaderValue(request.headers[webAuthHeader]); + return constantTimeEquals(token, security.authToken); +} + +function constantTimeEquals(value: string | undefined, expected: string): boolean { + if (!value) { + return false; + } + const valueBuffer = Buffer.from(value); + const expectedBuffer = Buffer.from(expected); + return valueBuffer.length === expectedBuffer.length && timingSafeEqual(valueBuffer, expectedBuffer); +} + +function readHeaderValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return readString(value[0]); + } + return readString(value); +} + +function normalizeHostname(value: string): string { + return value.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); +} + +function isWildcardBindHost(host: string): boolean { + return host === "" || host === "0.0.0.0" || host === "::" || host === "::0"; +} + function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index d536974..df5de23 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -147,6 +147,13 @@ function providerNameSlug(value: string): string { .replace(/^-+|-+$/g, "") || "provider"; } +async function loadProviderAccountSnapshots(forceRefresh = false): Promise { + if (!window.ccr) { + return []; + } + return window.ccr.getProviderAccountSnapshots(undefined, forceRefresh ? { forceRefresh: true } : undefined); +} + function extensionActionIndexes(index: number, groupIndexes?: number[]): number[] { const indexes = groupIndexes?.length ? groupIndexes : [index]; return [...new Set(indexes.filter((item) => Number.isInteger(item) && item >= 0))]; @@ -252,6 +259,7 @@ function App() { const [usageRange, setUsageRange] = useState("7d"); const [usageStats, setUsageStats] = useState(fallbackUsageStats); const [providerAccountSnapshots, setProviderAccountSnapshots] = useState([]); + const [providerAccountRefreshing, setProviderAccountRefreshing] = useState(false); const updateActionBusyRef = useRef(false); const resolvedLanguage = languagePreference === "system" ? systemLanguage : languagePreference; const copy = appCopy[resolvedLanguage]; @@ -443,7 +451,7 @@ function App() { let cancelled = false; const refreshProviderAccounts = () => { - void window.ccr?.getProviderAccountSnapshots() + void loadProviderAccountSnapshots() .then((snapshots) => { if (!cancelled) { setProviderAccountSnapshots(snapshots); @@ -463,6 +471,20 @@ function App() { }; }, [draftConfig.Providers]); + async function refreshProviderAccountsNow() { + if (providerAccountRefreshing) { + return; + } + setProviderAccountRefreshing(true); + try { + setProviderAccountSnapshots(await loadProviderAccountSnapshots(true)); + } catch { + setProviderAccountSnapshots([]); + } finally { + setProviderAccountRefreshing(false); + } + } + const requestLogsEnabled = Boolean(draftConfig.observability.requestLogs); const agentAnalysisEnabled = Boolean(draftConfig.observability.agentAnalysis); const agentAnalysisFilterKey = JSON.stringify({ @@ -2813,6 +2835,8 @@ function App() { onWidgetsChange: changeOverviewWidgets, overviewWidgets: normalizeOverviewWidgets(draftConfig.overviewWidgets), providerAccounts: providerAccountSnapshots, + providerAccountRefreshing, + refreshProviderAccounts: () => void refreshProviderAccountsNow(), setUsageRange, usageRange, usageStats diff --git a/src/renderer/pages/home/components/dashboard.tsx b/src/renderer/pages/home/components/dashboard.tsx index ac4aec4..da62c80 100644 --- a/src/renderer/pages/home/components/dashboard.tsx +++ b/src/renderer/pages/home/components/dashboard.tsx @@ -8,11 +8,11 @@ import { DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber, formatBytes, formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountMeterTitle, formatProviderAccountMeterValue, formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor, - LabelList, LayoutGroup, Line, MeasuringStrategy, MetricCard, MetricTone, + LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricCard, MetricTone, metricToneBar, metricToneStroke, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets, OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions, OverviewWidgetType, OverviewWidgetVariant, Pencil, Pie, PieChart, Plus, - PointerSensor, primaryProviderAccountMeter, providerAccountBadgeVariant, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, + PointerSensor, primaryProviderAccountMeter, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, providerAccountSnapshotKey, providerAccountSnapshotLabel, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select, SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass, @@ -26,6 +26,8 @@ export function OverviewView({ onWidgetsChange, overviewWidgets, providerAccounts, + providerAccountRefreshing = false, + refreshProviderAccounts, setUsageRange, usageRange, usageStats @@ -33,6 +35,8 @@ export function OverviewView({ onWidgetsChange: (widgets: OverviewWidgetConfig[]) => void; overviewWidgets: OverviewWidgetConfig[]; providerAccounts: ProviderAccountSnapshot[]; + providerAccountRefreshing?: boolean; + refreshProviderAccounts?: () => void | Promise; setUsageRange: (range: UsageStatsRange) => void; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; @@ -248,6 +252,8 @@ export function OverviewView({ > void | Promise; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; widget: OverviewWidgetConfig; @@ -567,6 +579,8 @@ function OverviewWidgetDragOverlay({
void | Promise; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; widget: OverviewWidgetConfig; @@ -816,7 +834,7 @@ function OverviewWidgetRenderer({ if (widget.type === "system-status") { content = ; } else if (widget.type === "account-balance") { - content = ; + content = ; } else if (widget.type === "metric") { content = ; } else if (widget.type === "usage-trend") { @@ -952,19 +970,19 @@ function UsageTrendWidget({ } dataKey="requestCount" /> - + ) : null} {variant === "area" ? ( <> - + ) : null} {variant === "line" ? ( <> - + ) : null} {variant === "bar" ? ( @@ -1185,7 +1203,7 @@ function TokenMixOverviewWidget({ const tokenMix = [ { color: "#2563eb", name: t("Input"), value: totals.inputTokens }, { color: "#d97706", name: t("Output"), value: totals.outputTokens }, - { color: "#be123c", name: t("Cache"), value: totals.cacheTokens } + { color: overviewCacheColor, name: t("Cache"), value: totals.cacheTokens } ]; const total = tokenMix.reduce((sum, item) => sum + item.value, 0); const showLegend = dimensions.height >= 2 && dimensions.width >= 2; @@ -1665,6 +1683,8 @@ function overviewWidgetOverlaySizeClass(size: OverviewWidgetSize): string { type OverviewWidgetDimensions = { height: 1 | 2 | 3 | 4; width: 1 | 2 | 3 | 4 }; +const overviewCacheColor = "#6366f1"; + function overviewWidgetDimensions(size: OverviewWidgetSize): OverviewWidgetDimensions { const [widthText, heightText] = size.split(":"); const width = overviewWidgetDimensionValue(widthText); @@ -1752,7 +1772,7 @@ function overviewMetricDatum(metric: OverviewMetricKind, totals: UsageTotals, tr return { label: translate("Output tokens"), ratio: totals.totalTokens > 0 ? totals.outputTokens / totals.totalTokens : 0, tone: "amber", value: formatCompactNumber(totals.outputTokens) }; } if (metric === "cache-tokens") { - return { label: translate("Cache tokens"), ratio: totals.totalTokens > 0 ? totals.cacheTokens / totals.totalTokens : 0, tone: "rose", value: formatCompactNumber(totals.cacheTokens) }; + return { label: translate("Cache tokens"), ratio: totals.totalTokens > 0 ? totals.cacheTokens / totals.totalTokens : 0, tone: "indigo", value: formatCompactNumber(totals.cacheTokens) }; } if (metric === "cache-ratio") { return { label: translate("Cache ratio"), ratio: totals.cacheRatio, tone: "indigo", value: formatPercent(totals.cacheRatio) }; @@ -1897,11 +1917,15 @@ function ProviderAccountsOverview({ accountProvider, accounts, dimensions, + onRefresh, + refreshing = false, variant = "cards" }: { accountProvider?: string; accounts: ProviderAccountSnapshot[]; dimensions: OverviewWidgetDimensions; + onRefresh?: () => void | Promise; + refreshing?: boolean; variant?: OverviewAccountVariant; }) { const t = useAppText(); @@ -1921,7 +1945,7 @@ function ProviderAccountsOverview({ {t("No account balance connectors configured")}
) : isSingleAccount ? ( - + ) : variant === "compact" ? (
{visibleAccounts.map((account) => { @@ -1931,10 +1955,11 @@ function ProviderAccountsOverview({
{providerAccountSnapshotLabel(account)}
{providerAccountShowSource(dimensions) && meter ?
{t(meter.label)}
: null} + {providerAccountShowRefreshTime(dimensions) ?
{formatProviderAccountRefreshTime(account, t)}
: null}
-
- {providerAccountShowStatus(dimensions) ? {account.status} : null} - {meter ?
{formatProviderAccountMeterValue(meter)}
: null} +
+ {providerAccountShowRefresh(dimensions) ? : null} + {meter ?
{formatProviderAccountMeterValue(meter)}
: null}
); @@ -1951,8 +1976,12 @@ function ProviderAccountsOverview({
{providerAccountSnapshotLabel(account)}
{providerAccountShowSource(dimensions) && meter ?
{t(meter.label)}
: null} + {providerAccountShowRefreshTime(dimensions) ?
{formatProviderAccountRefreshTime(account, t)}
: null} +
+
+ {meter ? {formatProviderAccountMeterValue(meter)} : null} + {providerAccountShowRefresh(dimensions) ? : null}
-
{meter ? formatProviderAccountMeterValue(meter) : account.status}
{progress !== undefined ? (
@@ -1966,7 +1995,7 @@ function ProviderAccountsOverview({ ) : (
{visibleAccounts.map((account) => { - return ; + return ; })}
)} @@ -1978,10 +2007,14 @@ function ProviderAccountsOverview({ function ProviderAccountSinglePanel({ account, dimensions, + onRefresh, + refreshing = false, variant }: { account: ProviderAccountSnapshot; dimensions: OverviewWidgetDimensions; + onRefresh?: () => void | Promise; + refreshing?: boolean; variant: OverviewAccountVariant; }) { const t = useAppText(); @@ -1995,8 +2028,9 @@ function ProviderAccountSinglePanel({
{providerAccountSnapshotLabel(account)}
+ {providerAccountShowRefreshTime(dimensions) ?
{formatProviderAccountRefreshTime(account, t)}
: null}
- {providerAccountShowStatus(dimensions) ? {account.status} : null} + {providerAccountShowRefresh(dimensions) ? : null}
{showQuotaVisual ? ( @@ -2021,10 +2055,14 @@ function ProviderAccountSinglePanel({ function ProviderAccountSummaryCard({ account, dimensions, + onRefresh, + refreshing = false, variant }: { account: ProviderAccountSnapshot; dimensions: OverviewWidgetDimensions; + onRefresh?: () => void | Promise; + refreshing?: boolean; variant: OverviewAccountVariant; }) { const t = useAppText(); @@ -2038,8 +2076,9 @@ function ProviderAccountSummaryCard({
{providerAccountSnapshotLabel(account)}
+ {providerAccountShowRefreshTime(dimensions) ?
{formatProviderAccountRefreshTime(account, t)}
: null}
- {providerAccountShowStatus(dimensions) ? {account.status} : null} + {providerAccountShowRefresh(dimensions) ? : null}
{showQuotaVisual ? (
@@ -2065,6 +2104,52 @@ function ProviderAccountSummaryCard({ ); } +function ProviderAccountRefreshButton({ + account, + onRefresh, + refreshing = false +}: { + account: ProviderAccountSnapshot; + onRefresh?: () => void | Promise; + refreshing?: boolean; +}) { + const t = useAppText(); + const label = refreshing ? t("Refreshing account") : t("Refresh account"); + return ( + + ); +} + +function formatProviderAccountRefreshTime(account: ProviderAccountSnapshot, t: (value: string) => string): string { + return `${t("Last updated")}: ${formatProviderAccountUpdatedAt(account.updatedAt) || "-"}`; +} + +function formatProviderAccountUpdatedAt(value: string): string { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + return ""; + } + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + function ProviderAccountMeterLine({ account, dimensions, @@ -2394,7 +2479,11 @@ function providerAccountShowSource(dimensions: OverviewWidgetDimensions): boolea return dimensions.height >= 2 && dimensions.width >= 2; } -function providerAccountShowStatus(dimensions: OverviewWidgetDimensions): boolean { +function providerAccountShowRefreshTime(dimensions: OverviewWidgetDimensions): boolean { + return dimensions.height >= 2 && dimensions.width >= 2; +} + +function providerAccountShowRefresh(dimensions: OverviewWidgetDimensions): boolean { return dimensions.width >= 2; } diff --git a/src/renderer/pages/home/components/providers.tsx b/src/renderer/pages/home/components/providers.tsx index 153e912..4cd8d89 100644 --- a/src/renderer/pages/home/components/providers.tsx +++ b/src/renderer/pages/home/components/providers.tsx @@ -629,6 +629,8 @@ function ProviderPresetCombobox({ const filteredOptions = normalizedQuery ? options.filter((option) => providerPresetOptionMatchesQuery(option, normalizedQuery)) : options; + const selectedExternalUrl = providerPresetOptionPlatformUrl(selected); + const selectedDetail = providerPresetOptionDetail(selected, t); useEffect(() => { if (!open) { @@ -662,29 +664,66 @@ function ProviderPresetCombobox({ setOpen(false); } + function toggleOpen() { + setOpen((current) => !current); + } + + function openSelectedExternalUrl() { + if (!selectedExternalUrl) { + return; + } + openExternalUrl(selectedExternalUrl); + } + return (
- + +
+
{selected ? selected.label : t("Select preset provider")}
+ {selectedDetail ? ( +
{selectedDetail}
+ ) : null} +
+ {selectedExternalUrl ? ( + + ) : null} +
{open ? ( @@ -794,11 +833,7 @@ function ProviderImportHeader({ if (!platformUrl) { return; } - if (window.ccr?.openExternal) { - void window.ccr.openExternal(platformUrl).catch(() => undefined); - return; - } - window.open(platformUrl, "_blank", "noopener,noreferrer"); + openExternalUrl(platformUrl); } return ( @@ -825,7 +860,15 @@ function ProviderImportHeader({ } function providerImportPlatformUrl(provider: ProviderDeepLinkPayload, baseUrl: string, preset: ProviderPreset | undefined): string | undefined { - return normalizedHttpUrl(provider.source) ?? normalizedHttpUrl(preset?.websiteUrl) ?? providerBaseOrigin(baseUrl || provider.baseUrl); + return normalizedHttpUrl(provider.source) ?? providerPresetWebsiteUrlForBaseUrl(preset, baseUrl || provider.baseUrl); +} + +function openExternalUrl(url: string) { + if (window.ccr?.openExternal) { + void window.ccr.openExternal(url).catch(() => undefined); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); } function providerBaseOrigin(value: string): string | undefined { @@ -874,6 +917,76 @@ function providerPresetOptionMatchesQuery( return haystack.includes(query); } +function providerPresetOptionDetail(option: ProviderPresetComboboxOption | undefined, t: (value: string) => string): string { + if (!option) { + return ""; + } + if (option.preset) { + return primaryProviderPresetEndpoint(option.preset)?.baseUrl ?? option.preset.websiteUrl ?? ""; + } + if (option.value === customProviderPresetId) { + return t("API endpoint"); + } + return ""; +} + +function providerPresetOptionPlatformUrl(option: ProviderPresetComboboxOption | undefined): string | undefined { + if (!option?.preset) { + return undefined; + } + return providerPresetWebsiteUrlForEndpoint(option.preset, primaryProviderPresetEndpoint(option.preset)); +} + +function providerPresetWebsiteUrlForBaseUrl(preset: ProviderPreset | undefined, baseUrl: string): string | undefined { + if (!preset) { + return undefined; + } + const normalizedBaseUrl = baseUrl.trim(); + const endpoint = normalizedBaseUrl + ? preset.endpoints.find((item) => item.baseUrl.trim() === normalizedBaseUrl) + : undefined; + return providerPresetWebsiteUrlForEndpoint(preset, endpoint); +} + +function providerPresetWebsiteUrlForEndpoint( + preset: ProviderPreset, + endpoint: ReturnType | undefined +): string | undefined { + return normalizedHttpUrl(endpoint?.websiteUrl) ?? normalizedHttpUrl(preset.websiteUrl); +} + +function providerDraftNameShouldFollowPreset( + name: string, + previousPreset: ProviderPreset | undefined, + t: (value: string) => string +): boolean { + const trimmed = name.trim(); + if (!trimmed || /^provider-\d+$/i.test(trimmed)) { + return true; + } + if (!previousPreset) { + return false; + } + return [previousPreset.name, t(previousPreset.name)].some((baseName) => + providerNameMatchesGeneratedPresetName(trimmed, baseName) + ); +} + +function providerNameMatchesGeneratedPresetName(name: string, baseName: string): boolean { + const normalizedName = name.trim().toLowerCase(); + const normalizedBaseName = baseName.trim().toLowerCase(); + if (!normalizedBaseName) { + return false; + } + if (normalizedName === normalizedBaseName) { + return true; + } + if (!normalizedName.startsWith(`${normalizedBaseName} `)) { + return false; + } + return /^\d+$/.test(normalizedName.slice(normalizedBaseName.length + 1)); +} + function LocalAgentProviderImportPanel({ mode, onChange, @@ -1209,7 +1322,8 @@ export function AddProviderForm({ const preset = findProviderPreset(presetId); const endpoint = preset ? primaryProviderPresetEndpoint(preset) : undefined; - const generatedName = !draft.name.trim() || /^provider-\d+$/i.test(draft.name.trim()); + const previousPreset = findProviderPreset(draft.presetId); + const generatedName = providerDraftNameShouldFollowPreset(draft.name, previousPreset, t); const accountDraft = createProviderAccountDraftFromConfig(defaultProviderAccountConfigForPreset(presetId)); onChange({ ...accountDraft, @@ -1240,13 +1354,14 @@ export function AddProviderForm({ providerPlugins={[...providerPlugins, ...draft.providerPlugins]} providers={providers} /> - +
+ {t("Select preset provider")} - +
)} diff --git a/src/renderer/pages/home/shared/i18n.tsx b/src/renderer/pages/home/shared/i18n.tsx index 596d195..bedf620 100644 --- a/src/renderer/pages/home/shared/i18n.tsx +++ b/src/renderer/pages/home/shared/i18n.tsx @@ -268,6 +268,9 @@ export const appCopy: Record = { "Account component": "Account component", "All accounts": "All accounts", "All credentials": "All credentials", + "Last updated": "Last updated", + "Refresh account": "Refresh account", + "Refreshing account": "Refreshing account", "Add widget": "Add widget", "Analysis component": "Analysis component", "Arc": "Arc", @@ -953,6 +956,9 @@ export const appCopy: Record = { "Account component": "账户组件", "All accounts": "所有账户", "All credentials": "全部凭据", + "Last updated": "上次更新", + "Refresh account": "刷新账户", + "Refreshing account": "正在刷新账户", "Add widget": "添加组件", "Analysis component": "分析组件", "Arc": "弧形", @@ -1385,11 +1391,13 @@ export const appCopy: Record = { "Priority spillover": "优先级溢出", "Ready": "可导入", "Scanning local agent logins": "正在扫描本机 Agent 登录态", - "Select preset provider": "选择预设供应商", + "Select preset provider": "选择 预设供应商", "ZCode login detected. Click Import to add it as a gateway provider.": "已检测到 ZCode 登录态。点击导入即可添加为网关供应商。", "ZCode login was detected, but its local credential is encrypted and cannot be imported automatically.": "已检测到 ZCode 登录态,但本机凭据已加密,无法自动导入。", "ZCode login was detected, but no usable provider API key was found in ZCode config.": "已检测到 ZCode 登录态,但 ZCode 配置中没有找到可用的供应商 API key。", "ZCode provider API key detected in local ZCode config. Click Import to add it as a gateway provider.": "已在本机 ZCode 配置中检测到供应商 API key。点击导入即可添加为网关供应商。", + "Kimi API (China)": "Kimi API(国内)", + "Kimi API (Global)": "Kimi API(海外)", "Kimi Code - Coding Plan": "Kimi Code - Coding Plan", "Zhipu AI (China)": "智谱 AI (国内)", "Zhipu AI (China) - Coding Plan": "智谱 AI (国内) - Coding Plan", diff --git a/src/renderer/pages/home/shared/options.ts b/src/renderer/pages/home/shared/options.ts index 03b1643..e0578e2 100644 --- a/src/renderer/pages/home/shared/options.ts +++ b/src/renderer/pages/home/shared/options.ts @@ -328,6 +328,7 @@ export const providerPresetIconUrls: Record = { "kimi-coding": moonshotProviderIconUrl, mistral: mistralProviderIconUrl, moonshot: moonshotProviderIconUrl, + "moonshot-global": moonshotProviderIconUrl, openai: openaiProviderIconUrl, openrouter: openrouterProviderIconUrl, siliconflow: siliconflowProviderIconUrl, diff --git a/src/renderer/pages/home/shared/providers.ts b/src/renderer/pages/home/shared/providers.ts index b58d908..71864da 100644 --- a/src/renderer/pages/home/shared/providers.ts +++ b/src/renderer/pages/home/shared/providers.ts @@ -1571,7 +1571,10 @@ export function cloneProviderAccountConnectors(connectors: ProviderAccountConnec return JSON.parse(JSON.stringify(connectors)) as ProviderAccountConnectorConfig[]; } -export function defaultProviderAccountConfigForPreset(_presetId: string | undefined): ProviderAccountConfig | undefined { +export function defaultProviderAccountConfigForPreset(presetId: string | undefined): ProviderAccountConfig | undefined { + if (presetId === "kimi-coding") { + return cloneProviderAccountConfig(findProviderPreset(presetId)?.account ?? defaultProviderAccountConfig); + } // Keep the advanced settings default on the standard endpoint; main resolves preset-specific connectors at runtime. return cloneProviderAccountConfig(defaultProviderAccountConfig); } diff --git a/src/server/gateway/claude-code-router-plugin.ts b/src/server/gateway/claude-code-router-plugin.ts index c33d28b..ab47884 100644 --- a/src/server/gateway/claude-code-router-plugin.ts +++ b/src/server/gateway/claude-code-router-plugin.ts @@ -1,6 +1,9 @@ import { createRequire } from "node:module"; import { EventEmitter } from "node:events"; +import os from "node:os"; +import path from "node:path"; import type { AppConfig, RouterConfig, RouterFallbackConfig, RouterRule, RouterRuleCondition, RouterRuleRewrite } from "../../shared/app"; +import { CONFIGDIR } from "../../main/constants"; type HeaderValue = string | string[] | undefined; @@ -99,8 +102,9 @@ export class ClaudeCodeRouterPlugin { } try { - delete requireFromHere.cache[requireFromHere.resolve(routerPath)]; - const loaded = requireFromHere(routerPath) as unknown; + const resolvedRouterPath = resolveCustomRouterModule(routerPath); + delete requireFromHere.cache[resolvedRouterPath]; + const loaded = requireFromHere(resolvedRouterPath) as unknown; const customRouter = typeof loaded === "function" ? loaded : readDefaultFunction(loaded); if (!customRouter) { request.log.warn(`Custom router does not export a function: ${routerPath}`); @@ -115,6 +119,62 @@ export class ClaudeCodeRouterPlugin { } } +function resolveCustomRouterModule(routerPath: string): string { + const resolved = requireFromHere.resolve(resolveLocalModulePath(routerPath, "Custom router")); + assertJavaScriptModulePath(resolved, "Custom router"); + return resolved; +} + +function resolveLocalModulePath(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${label} path is required.`); + } + + const expanded = expandHome(trimmed); + if (path.isAbsolute(expanded)) { + return expanded; + } + if (isProtocolSpecifier(expanded)) { + throw new Error(`${label} must be a local JavaScript file path, not a URL or protocol specifier.`); + } + if (!expanded.startsWith(".")) { + throw new Error(`${label} must be an explicit local JavaScript path. Package specifiers are not loaded from configuration.`); + } + + const resolved = path.resolve(CONFIGDIR, expanded); + if (!isPathInside(resolved, CONFIGDIR)) { + throw new Error(`${label} relative paths must stay inside the CCR config directory.`); + } + return resolved; +} + +function assertJavaScriptModulePath(resolved: string, label: string): void { + const extension = path.extname(resolved).toLowerCase(); + if (![".cjs", ".js", ".mjs"].includes(extension)) { + throw new Error(`${label} must resolve to a JavaScript module file.`); + } +} + +function expandHome(value: string): string { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/")) { + return path.join(os.homedir(), value.slice(2)); + } + return value; +} + +function isProtocolSpecifier(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); +} + +function isPathInside(file: string, root: string): boolean { + const relative = path.relative(root, file); + return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + function resolveConfiguredRouteDecision( request: MutableRequestLike, config: AppConfig, diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index a824fa0..7dac5f1 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -3139,12 +3139,9 @@ function applyCors(response: ServerResponse, config?: AppConfig): void { async function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): Promise { let apiKeys = await configuredApiKeys(config); if (apiKeys.length === 0) { - if (isLoopbackBindHost(config.gateway.host)) { - return { ok: true }; - } sendJson(response, 403, { error: { - message: "Configure at least one CCR API key before listening on a non-loopback gateway host." + message: "CCR API key is not initialized. Save a gateway API key or restart CCR to generate one." } }); return { ok: false }; diff --git a/src/server/proxy/certificates.ts b/src/server/proxy/certificates.ts index 29e4b4e..ea5ae91 100644 --- a/src/server/proxy/certificates.ts +++ b/src/server/proxy/certificates.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createHash, randomBytes } from "node:crypto"; import net from "node:net"; import os from "node:os"; @@ -7,6 +7,9 @@ import forge from "node-forge"; import { CERTDIR, PROXY_CA_CERT_DER_FILE, PROXY_CA_CERT_FILE, PROXY_CA_KEY_FILE } from "../../main/constants"; const pki = forge.pki; +const certificateDirectoryMode = 0o700; +const certificateFileMode = 0o644; +const privateKeyFileMode = 0o600; export type CertificateAuthority = { cert: forge.pki.Certificate; @@ -25,8 +28,10 @@ type SubjectAltName = { }; export function ensureProxyCertificateAuthority(): void { - mkdirSync(CERTDIR, { recursive: true }); + mkdirSync(CERTDIR, { mode: certificateDirectoryMode, recursive: true }); + securePathPermissions(CERTDIR, certificateDirectoryMode); if (existsSync(PROXY_CA_CERT_FILE) && existsSync(PROXY_CA_KEY_FILE)) { + secureCertificateAuthorityFilePermissions(); ensureProxyCertificateDerFile(); return; } @@ -68,8 +73,9 @@ export function ensureProxyCertificateAuthority(): void { ]); cert.sign(keys.privateKey, forge.md.sha256.create()); - writeFileSync(PROXY_CA_CERT_FILE, pki.certificateToPem(cert), "utf8"); - writeFileSync(PROXY_CA_KEY_FILE, pki.privateKeyToPem(keys.privateKey), "utf8"); + writeFileSync(PROXY_CA_CERT_FILE, pki.certificateToPem(cert), { encoding: "utf8", mode: certificateFileMode }); + writeFileSync(PROXY_CA_KEY_FILE, pki.privateKeyToPem(keys.privateKey), { encoding: "utf8", mode: privateKeyFileMode }); + secureCertificateAuthorityFilePermissions(); ensureProxyCertificateDerFile(); } @@ -214,7 +220,21 @@ function ensureProxyCertificateDerFile(): boolean { function writeProxyCertificateDerFile(cert: forge.pki.Certificate): void { const der = forge.asn1.toDer(pki.certificateToAsn1(cert)).getBytes(); - writeFileSync(PROXY_CA_CERT_DER_FILE, Buffer.from(der, "binary")); + writeFileSync(PROXY_CA_CERT_DER_FILE, Buffer.from(der, "binary"), { mode: certificateFileMode }); + securePathPermissions(PROXY_CA_CERT_DER_FILE, certificateFileMode); +} + +function secureCertificateAuthorityFilePermissions(): void { + securePathPermissions(PROXY_CA_CERT_FILE, certificateFileMode); + securePathPermissions(PROXY_CA_KEY_FILE, privateKeyFileMode); + securePathPermissions(PROXY_CA_CERT_DER_FILE, certificateFileMode); +} + +function securePathPermissions(file: string, mode: number): void { + if (process.platform === "win32" || !existsSync(file)) { + return; + } + chmodSync(file, mode); } function createSerialNumber(): string { diff --git a/src/shared/provider-presets.ts b/src/shared/provider-presets.ts index f33d067..e004fff 100644 --- a/src/shared/provider-presets.ts +++ b/src/shared/provider-presets.ts @@ -4,6 +4,7 @@ export type ProviderPresetEndpoint = { baseUrl: string; label?: string; protocols: GatewayProviderProtocol[]; + websiteUrl?: string; }; export type ProviderOfficialKeyPattern = {