Refine provider docs and gateway API key handling

This commit is contained in:
musi
2026-06-29 22:49:39 +08:00
parent 7cfe854336
commit 589f299319
34 changed files with 750 additions and 110 deletions
@@ -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.
<div class="provider-import-grid" aria-label="Preset provider import buttons">
<a class="provider-import-button provider-openai" href="ccr://provider?name=OpenAI&amp;base_url=https%3A%2F%2Fapi.openai.com%2Fv1&amp;protocol=openai_responses&amp;models=gpt-5.5%2Cgpt-5.5-pro%2Cgpt-5.5-instant%2Cgpt-5.4-mini" aria-label="Import OpenAI provider">
@@ -50,9 +50,13 @@ The buttons below open the CCR desktop app's provider import confirmation dialog
<span class="provider-import-icon-shell"><img src="../../../provider-icons/mistral.webp" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Mistral</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Moonshot+Kimi&amp;base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&amp;protocol=openai_chat_completions&amp;models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="Import Moonshot Kimi provider">
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Kimi+API+%28China%29&amp;base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&amp;protocol=openai_chat_completions&amp;models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="Import Kimi API China provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Moonshot Kimi</span><span class="provider-import-meta">Chat Completions</span></span>
<span class="provider-import-copy"><span class="provider-import-name">Kimi API (China)</span><span class="provider-import-meta">China platform</span></span>
</a>
<a class="provider-import-button provider-moonshot-global" href="ccr://provider?name=Kimi+API+%28Global%29&amp;base_url=https%3A%2F%2Fapi.moonshot.ai%2Fv1&amp;protocol=openai_chat_completions&amp;models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="Import Kimi API Global provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Kimi API (Global)</span><span class="provider-import-meta">Global platform</span></span>
</a>
<a class="provider-import-button provider-kimi-coding" href="ccr://provider?name=Kimi+Code+-+Coding+Plan&amp;base_url=https%3A%2F%2Fapi.kimi.com%2Fcoding%2Fv1&amp;protocol=openai_chat_completions&amp;models=kimi-for-coding" aria-label="Import Kimi Code Coding Plan provider">
<span class="provider-import-icon-shell"><img src="../../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
@@ -2,12 +2,12 @@
title: 一键导入供应商
pageTitle: 一键导入供应商
eyebrow: 详细配置
lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有 preset 供应商按钮快速打开导入确认页
lead: 快速添加常见模型供应商,确认无误后即可保存,减少手动配置的繁琐步骤
---
## 一键导入
下面的按钮会打开 CCR 桌面 App 的供应商导入确认页。预设按钮不会携带 API Key;自定义供应商链接可以携带 Key。导入前始终确认供应商名称、Base URL、协议和模型
选择下面的供应商即可开始添加。CCR 会先显示即将添加的内容,确认无误后再保存;使用自定义入口时,请确保来源可信
<div class="provider-import-grid" aria-label="Preset provider import buttons">
<a class="provider-import-button provider-openai" href="ccr://provider?name=OpenAI&amp;base_url=https%3A%2F%2Fapi.openai.com%2Fv1&amp;protocol=openai_responses&amp;models=gpt-5.5%2Cgpt-5.5-pro%2Cgpt-5.5-instant%2Cgpt-5.4-mini" aria-label="导入 OpenAI 官方供应商">
@@ -50,9 +50,13 @@ lead: 使用 ccr://provider 链接把供应商配置带入 CCR,并通过现有
<span class="provider-import-icon-shell"><img src="../../provider-icons/mistral.webp" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Mistral 官方</span><span class="provider-import-meta">Chat Completions</span></span>
</a>
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Moonshot+Kimi&amp;base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&amp;protocol=openai_chat_completions&amp;models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="导入月之暗面 Kimi 供应商">
<a class="provider-import-button provider-moonshot" href="ccr://provider?name=Kimi+API+%28China%29&amp;base_url=https%3A%2F%2Fapi.moonshot.cn%2Fv1&amp;protocol=openai_chat_completions&amp;models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="导入 Kimi API 国内供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">月之暗面 Kimi</span><span class="provider-import-meta">Chat Completions</span></span>
<span class="provider-import-copy"><span class="provider-import-name">Kimi API(国内)</span><span class="provider-import-meta">国内平台</span></span>
</a>
<a class="provider-import-button provider-moonshot-global" href="ccr://provider?name=Kimi+API+%28Global%29&amp;base_url=https%3A%2F%2Fapi.moonshot.ai%2Fv1&amp;protocol=openai_chat_completions&amp;models=kimi-k2.7-code%2Ckimi-k2.6%2Ckimi-latest%2Ckimi-thinking-preview" aria-label="导入 Kimi API 海外供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
<span class="provider-import-copy"><span class="provider-import-name">Kimi API(海外)</span><span class="provider-import-meta">海外平台</span></span>
</a>
<a class="provider-import-button provider-kimi-coding" href="ccr://provider?name=Kimi+Code+-+Coding+Plan&amp;base_url=https%3A%2F%2Fapi.kimi.com%2Fcoding%2Fv1&amp;protocol=openai_chat_completions&amp;models=kimi-for-coding" aria-label="导入 Kimi Code Coding Plan 供应商">
<span class="provider-import-icon-shell"><img src="../../provider-icons/moonshot.ico" alt="" loading="lazy" /></span>
+2 -2
View File
@@ -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";
}
})();
</script>
<link rel="icon" href={faviconHref} type="image/svg+xml" />
<link rel="icon" href={faviconHref} type="image/png" />
{
resolvedLanguageOptions.map((option) => (
<link
+6
View File
@@ -1322,6 +1322,12 @@ h1 {
--provider-brand-3: #e4ddff;
}
.doc-markdown a.provider-import-button.provider-moonshot-global {
--provider-brand: #0f1f42;
--provider-brand-2: #4f7cff;
--provider-brand-3: #dce7ff;
}
.doc-markdown a.provider-import-button.provider-kimi-coding {
--provider-brand: #111237;
--provider-brand-2: #5c6bff;
+31 -10
View File
@@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { createHash, randomBytes } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { loadPersistedAppConfig, replacePersistedAppConfig } from "./app-config-store";
import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store";
@@ -98,6 +98,7 @@ const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([
"legacy-image"
]);
const INTERNAL_GATEWAY_CORE_HOST = "127.0.0.1";
const GENERATED_GATEWAY_API_KEY_ID = "local-gateway";
const DEFAULT_CONFIG: AppConfig = {
APIKEY: "",
@@ -356,8 +357,9 @@ export async function loadAppConfig(): Promise<AppConfig> {
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<AppConfig> {
},
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<AppConfig> {
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<AppConfig> {
export async function saveAppConfig(config: AppConfig): Promise<AppConfig> {
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<AppConfig> {
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(),
+40 -5
View File
@@ -523,14 +523,49 @@ async function loadPluginModule(modulePath: string): Promise<unknown> {
}
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 {
+2 -1
View File
@@ -14,5 +14,6 @@ export const anthropicProviderPreset: ProviderPreset = {
name: "Anthropic",
officialApiKeyPatterns: [
{ flags: "i", source: "^sk-ant-[a-z0-9_-]+$" }
]
],
websiteUrl: "https://www.anthropic.com/"
};
+2 -1
View File
@@ -10,5 +10,6 @@ export const bailianProviderPreset: ProviderPreset = {
}
],
id: "bailian",
name: "Alibaba Bailian"
name: "Alibaba Bailian",
websiteUrl: "https://bailian.console.aliyun.com/"
};
+2 -1
View File
@@ -47,5 +47,6 @@ export const deepSeekProviderPreset: ProviderPreset = {
}
],
id: "deepseek",
name: "DeepSeek"
name: "DeepSeek",
websiteUrl: "https://www.deepseek.com/"
};
+2 -1
View File
@@ -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/"
};
+3 -2
View File
@@ -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
];
+5 -3
View File
@@ -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"
};
+2 -1
View File
@@ -41,5 +41,6 @@ export const mistralProviderPreset: ProviderPreset = {
}
],
id: "mistral",
name: "Mistral"
name: "Mistral",
websiteUrl: "https://mistral.ai/"
};
+61 -11
View File
@@ -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"
};
+2 -1
View File
@@ -14,5 +14,6 @@ export const openaiProviderPreset: ProviderPreset = {
name: "OpenAI",
officialApiKeyPatterns: [
{ flags: "i", source: "^sk-(?:proj|svcacct)-[a-z0-9_-]+$" }
]
],
websiteUrl: "https://openai.com/"
};
+2 -1
View File
@@ -51,5 +51,6 @@ export const openRouterProviderPreset: ProviderPreset = {
name: "OpenRouter",
officialApiKeyPatterns: [
{ flags: "i", source: "^sk-or-v1-[a-z0-9_-]+$" }
]
],
websiteUrl: "https://openrouter.ai/"
};
+2 -1
View File
@@ -47,5 +47,6 @@ export const siliconFlowProviderPreset: ProviderPreset = {
}
],
id: "siliconflow",
name: "SiliconFlow"
name: "SiliconFlow",
websiteUrl: "https://siliconflow.cn/"
};
+2 -1
View File
@@ -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/"
};
+2 -1
View File
@@ -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/"
};
+2 -1
View File
@@ -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/"
};
+2 -1
View File
@@ -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/"
};
+1
View File
@@ -38,6 +38,7 @@ const presetCatalogProviderIds: Record<string, string[]> = {
"kimi-coding": ["kimi-for-coding"],
mistral: ["mistral"],
moonshot: ["moonshotai-cn"],
"moonshot-global": ["moonshotai"],
openai: ["openai"],
openrouter: ["openrouter"],
siliconflow: ["siliconflow-cn"],
+46 -1
View File
@@ -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<unknown> {
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<unknown> {
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;
}
+148 -7
View File
@@ -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> | unknown;
type WebManagementSecurityContext = {
allowIpLiteralHosts: boolean;
allowedHostnames: Set<string>;
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<WebManagementServerRuntime> {
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<void> {
async function handleRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise<void> {
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<void> {
async function handleRpcRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise<void> {
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<string>(["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<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
+25 -1
View File
@@ -147,6 +147,13 @@ function providerNameSlug(value: string): string {
.replace(/^-+|-+$/g, "") || "provider";
}
async function loadProviderAccountSnapshots(forceRefresh = false): Promise<ProviderAccountSnapshot[]> {
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<UsageStatsRange>("7d");
const [usageStats, setUsageStats] = useState<UsageStatsSnapshot>(fallbackUsageStats);
const [providerAccountSnapshots, setProviderAccountSnapshots] = useState<ProviderAccountSnapshot[]>([]);
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
+106 -17
View File
@@ -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<void>;
setUsageRange: (range: UsageStatsRange) => void;
usageRange: UsageStatsRange;
usageStats: UsageStatsSnapshot;
@@ -248,6 +252,8 @@ export function OverviewView({
>
<OverviewWidgetRenderer
providerAccounts={providerAccounts}
providerAccountRefreshing={providerAccountRefreshing}
refreshProviderAccounts={refreshProviderAccounts}
usageRange={usageRange}
usageStats={usageStats}
widget={widget}
@@ -267,6 +273,8 @@ export function OverviewView({
{activeWidget ? (
<OverviewWidgetDragOverlay
providerAccounts={providerAccounts}
providerAccountRefreshing={providerAccountRefreshing}
refreshProviderAccounts={refreshProviderAccounts}
usageRange={usageRange}
usageStats={usageStats}
widget={activeWidget}
@@ -554,11 +562,15 @@ function SortableOverviewWidget({
function OverviewWidgetDragOverlay({
providerAccounts,
providerAccountRefreshing = false,
refreshProviderAccounts,
usageRange,
usageStats,
widget
}: {
providerAccounts: ProviderAccountSnapshot[];
providerAccountRefreshing?: boolean;
refreshProviderAccounts?: () => void | Promise<void>;
usageRange: UsageStatsRange;
usageStats: UsageStatsSnapshot;
widget: OverviewWidgetConfig;
@@ -567,6 +579,8 @@ function OverviewWidgetDragOverlay({
<div className={cn("pointer-events-none overflow-hidden opacity-95 shadow-2xl", overviewWidgetOverlaySizeClass(widget.size))}>
<OverviewWidgetRenderer
providerAccounts={providerAccounts}
providerAccountRefreshing={providerAccountRefreshing}
refreshProviderAccounts={refreshProviderAccounts}
usageRange={usageRange}
usageStats={usageStats}
widget={widget}
@@ -802,11 +816,15 @@ function overviewWidgetResizeCursor(axis: OverviewWidgetResizeAxis): string {
function OverviewWidgetRenderer({
providerAccounts,
providerAccountRefreshing = false,
refreshProviderAccounts,
usageRange,
usageStats,
widget
}: {
providerAccounts: ProviderAccountSnapshot[];
providerAccountRefreshing?: boolean;
refreshProviderAccounts?: () => void | Promise<void>;
usageRange: UsageStatsRange;
usageStats: UsageStatsSnapshot;
widget: OverviewWidgetConfig;
@@ -816,7 +834,7 @@ function OverviewWidgetRenderer({
if (widget.type === "system-status") {
content = <SystemStatusBar usageRange={usageRange} usageStats={usageStats} variant={widget.variant === "compact" ? "compact" : "timeline"} />;
} else if (widget.type === "account-balance") {
content = <ProviderAccountsOverview accountProvider={widget.accountProvider} accounts={providerAccounts} dimensions={dimensions} variant={overviewAccountVariant(widget.variant)} />;
content = <ProviderAccountsOverview accountProvider={widget.accountProvider} accounts={providerAccounts} dimensions={dimensions} refreshing={providerAccountRefreshing} variant={overviewAccountVariant(widget.variant)} onRefresh={refreshProviderAccounts} />;
} else if (widget.type === "metric") {
content = <OverviewMetricWidget metric={widget.metric ?? "requests"} totals={usageStats.totals} variant={overviewMetricVariant(widget.variant)} />;
} else if (widget.type === "usage-trend") {
@@ -952,19 +970,19 @@ function UsageTrendWidget({
<Bar barSize={12} dataKey="requestCount" fill="#2563eb" name={t("Requests")} radius={[3, 3, 0, 0]} yAxisId="requests">
<LabelList content={<RequestHealthBarLabel />} dataKey="requestCount" />
</Bar>
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke="#be123c" strokeWidth={2} type="monotone" yAxisId="tokens" />
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke={overviewCacheColor} strokeWidth={2} type="monotone" yAxisId="tokens" />
</>
) : null}
{variant === "area" ? (
<>
<Area dataKey="totalTokens" fill="#0f766e" fillOpacity={0.18} name={t("Total tokens")} stroke="#0f766e" strokeWidth={2} type="monotone" yAxisId="tokens" />
<Area dataKey="cacheTokens" fill="#be123c" fillOpacity={0.12} name={t("Cache tokens")} stroke="#be123c" strokeWidth={2} type="monotone" yAxisId="tokens" />
<Area dataKey="cacheTokens" fill={overviewCacheColor} fillOpacity={0.12} name={t("Cache tokens")} stroke={overviewCacheColor} strokeWidth={2} type="monotone" yAxisId="tokens" />
</>
) : null}
{variant === "line" ? (
<>
<Line dataKey="totalTokens" dot={false} name={t("Total tokens")} stroke="#0f766e" strokeWidth={2.5} type="monotone" yAxisId="tokens" />
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke="#be123c" strokeWidth={2} type="monotone" yAxisId="tokens" />
<Line dataKey="cacheTokens" dot={false} name={t("Cache tokens")} stroke={overviewCacheColor} strokeWidth={2} type="monotone" yAxisId="tokens" />
</>
) : 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<void>;
refreshing?: boolean;
variant?: OverviewAccountVariant;
}) {
const t = useAppText();
@@ -1921,7 +1945,7 @@ function ProviderAccountsOverview({
{t("No account balance connectors configured")}
</div>
) : isSingleAccount ? (
<ProviderAccountSinglePanel account={visibleAccounts[0]} dimensions={dimensions} variant={variant} />
<ProviderAccountSinglePanel account={visibleAccounts[0]} dimensions={dimensions} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />
) : variant === "compact" ? (
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions))}>
{visibleAccounts.map((account) => {
@@ -1931,10 +1955,11 @@ function ProviderAccountsOverview({
<div className="min-w-0">
<div className="truncate text-[12px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
{providerAccountShowSource(dimensions) && meter ? <div className="truncate text-[11px] text-muted-foreground">{t(meter.label)}</div> : null}
{providerAccountShowRefreshTime(dimensions) ? <div className="truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
</div>
<div className="shrink-0 text-right">
{providerAccountShowStatus(dimensions) ? <Badge variant={providerAccountBadgeVariant(account.status)}>{account.status}</Badge> : null}
{meter ? <div className="mt-1 text-[12px] font-semibold">{formatProviderAccountMeterValue(meter)}</div> : null}
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
{meter ? <div className="text-[12px] font-semibold">{formatProviderAccountMeterValue(meter)}</div> : null}
</div>
</div>
);
@@ -1951,8 +1976,12 @@ function ProviderAccountsOverview({
<div className="min-w-0">
<div className="truncate text-[12px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
{providerAccountShowSource(dimensions) && meter ? <div className="truncate text-[11px] text-muted-foreground">{t(meter.label)}</div> : null}
{providerAccountShowRefreshTime(dimensions) ? <div className="truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
</div>
<div className="flex shrink-0 items-center gap-2 text-[12px] font-semibold">
{meter ? <span>{formatProviderAccountMeterValue(meter)}</span> : null}
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
</div>
<div className="shrink-0 text-[12px] font-semibold">{meter ? formatProviderAccountMeterValue(meter) : account.status}</div>
</div>
{progress !== undefined ? (
<div className="mt-1.5 h-2 overflow-hidden rounded-full bg-muted">
@@ -1966,7 +1995,7 @@ function ProviderAccountsOverview({
) : (
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions))}>
{visibleAccounts.map((account) => {
return <ProviderAccountSummaryCard account={account} dimensions={dimensions} key={providerAccountSnapshotKey(account)} variant={variant} />;
return <ProviderAccountSummaryCard account={account} dimensions={dimensions} key={providerAccountSnapshotKey(account)} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />;
})}
</div>
)}
@@ -1978,10 +2007,14 @@ function ProviderAccountsOverview({
function ProviderAccountSinglePanel({
account,
dimensions,
onRefresh,
refreshing = false,
variant
}: {
account: ProviderAccountSnapshot;
dimensions: OverviewWidgetDimensions;
onRefresh?: () => void | Promise<void>;
refreshing?: boolean;
variant: OverviewAccountVariant;
}) {
const t = useAppText();
@@ -1995,8 +2028,9 @@ function ProviderAccountSinglePanel({
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className={cn("truncate font-semibold", dimensions.height <= 1 ? "text-[12px]" : "text-[13px]")}>{providerAccountSnapshotLabel(account)}</div>
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
</div>
{providerAccountShowStatus(dimensions) ? <Badge variant={providerAccountBadgeVariant(account.status)}>{account.status}</Badge> : null}
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
</div>
{showQuotaVisual ? (
<ProviderAccountQuotaVisual account={account} dimensions={dimensions} meters={quotaMeters} variant={variant} />
@@ -2021,10 +2055,14 @@ function ProviderAccountSinglePanel({
function ProviderAccountSummaryCard({
account,
dimensions,
onRefresh,
refreshing = false,
variant
}: {
account: ProviderAccountSnapshot;
dimensions: OverviewWidgetDimensions;
onRefresh?: () => void | Promise<void>;
refreshing?: boolean;
variant: OverviewAccountVariant;
}) {
const t = useAppText();
@@ -2038,8 +2076,9 @@ function ProviderAccountSummaryCard({
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
</div>
{providerAccountShowStatus(dimensions) ? <Badge variant={providerAccountBadgeVariant(account.status)}>{account.status}</Badge> : null}
{providerAccountShowRefresh(dimensions) ? <ProviderAccountRefreshButton account={account} refreshing={refreshing} onRefresh={onRefresh} /> : null}
</div>
{showQuotaVisual ? (
<div className="mt-2 min-h-0 overflow-hidden">
@@ -2065,6 +2104,52 @@ function ProviderAccountSummaryCard({
);
}
function ProviderAccountRefreshButton({
account,
onRefresh,
refreshing = false
}: {
account: ProviderAccountSnapshot;
onRefresh?: () => void | Promise<void>;
refreshing?: boolean;
}) {
const t = useAppText();
const label = refreshing ? t("Refreshing account") : t("Refresh account");
return (
<button
aria-label={label}
className="m-0 inline-flex shrink-0 appearance-none items-center justify-center border-0 bg-transparent p-0 text-muted-foreground shadow-none transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-45"
disabled={refreshing || !onRefresh}
title={`${label} (${account.status})`}
type="button"
onClick={(event) => {
event.stopPropagation();
void onRefresh?.();
}}
>
{refreshing ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</button>
);
}
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;
}
+133 -18
View File
@@ -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 (
<div className="relative min-w-0" ref={rootRef}>
<button
<div
aria-controls="provider-preset-options"
aria-expanded={open}
aria-haspopup="listbox"
className={cn(
"flex h-8 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-background px-3 text-left text-[12px] font-medium shadow-[inset_0_1px_1px_rgba(0,0,0,0.03)] outline-none transition-[background-color,border-color,box-shadow,color] hover:border-muted-foreground/45 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/25",
open && "border-ring/35 bg-muted/40"
"flex min-h-[62px] w-full min-w-0 cursor-pointer items-center gap-3 rounded-md border border-border bg-background px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow,color] hover:border-muted-foreground/45 hover:bg-muted/20 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/25",
open && "border-ring/35 bg-muted/30"
)}
onClick={() => setOpen((current) => !current)}
onClick={toggleOpen}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpen(true);
}
}}
type="button"
role="button"
tabIndex={0}
>
<ProviderPresetIcon className="h-4 w-4 rounded-[4px]" iconUrl={selected?.iconUrl} preset={selected?.preset} />
<span className="min-w-0 flex-1 truncate">{selected ? selected.label : t("Select preset provider")}</span>
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
</button>
<ProviderPresetIcon className="h-10 w-10 rounded-md" iconUrl={selected?.iconUrl} preset={selected?.preset} />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-foreground">{selected ? selected.label : t("Select preset provider")}</div>
{selectedDetail ? (
<div className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground" title={selectedDetail}>{selectedDetail}</div>
) : null}
</div>
{selectedExternalUrl ? (
<Button
aria-label={t("Open provider website")}
onKeyDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
openSelectedExternalUrl();
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
size="iconSm"
title={t("Open provider website")}
type="button"
variant="ghost"
>
<ExternalLink className="h-4 w-4" />
</Button>
) : null}
</div>
<AnimatePresence initial={false}>
{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<typeof primaryProviderPresetEndpoint> | 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}
/>
<Field label={t("Preset provider")}>
<div className="block min-w-0 space-y-1 sm:col-span-2">
<span className="block truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{t("Select preset provider")}</span>
<ProviderPresetCombobox
value={draft.presetId}
onChange={updatePreset}
options={providerPresetOptions}
/>
</Field>
</div>
</>
)}
<Field label={t("Name")}>
+9 -1
View File
@@ -268,6 +268,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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<ResolvedLanguage, AppCopy> = {
"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",
@@ -328,6 +328,7 @@ export const providerPresetIconUrls: Record<string, string> = {
"kimi-coding": moonshotProviderIconUrl,
mistral: mistralProviderIconUrl,
moonshot: moonshotProviderIconUrl,
"moonshot-global": moonshotProviderIconUrl,
openai: openaiProviderIconUrl,
openrouter: openrouterProviderIconUrl,
siliconflow: siliconflowProviderIconUrl,
+4 -1
View File
@@ -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);
}
@@ -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,
+1 -4
View File
@@ -3139,12 +3139,9 @@ function applyCors(response: ServerResponse, config?: AppConfig): void {
async function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): Promise<ApiKeyAuthorizationResult> {
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 };
+25 -5
View File
@@ -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 {
+1
View File
@@ -4,6 +4,7 @@ export type ProviderPresetEndpoint = {
baseUrl: string;
label?: string;
protocols: GatewayProviderProtocol[];
websiteUrl?: string;
};
export type ProviderOfficialKeyPattern = {