Refactor router configuration and request handling

This commit is contained in:
musistudio
2026-08-11 10:53:45 +08:00
parent 28f0a2f2b9
commit e38f9bc2dd
17 changed files with 548 additions and 669 deletions
+75 -630
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -78,7 +78,8 @@
"electron-updater": "^6.8.9",
"node-forge": "^1.4.0",
"openai": "^6.27.0",
"undici": "^7.27.2"
"pm2": "^7.0.3",
"undici": "^7.29.0"
},
"devDependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -96,7 +97,7 @@
"electron": "^42.3.3",
"electron-builder": "^26.8.1",
"esbuild": "^0.27.7",
"js-yaml": "^4.2.0",
"js-yaml": "^4.3.1",
"lucide-react": "^1.17.0",
"motion": "^12.40.0",
"react": "^18.3.1",
@@ -109,6 +110,18 @@
"typescript": "^5.9.3"
},
"overrides": {
"@types/react": "$@types/react"
"@types/react": "$@types/react",
"fast-uri@3.1.2": "3.1.5",
"find-my-way@9.6.0": "9.7.0",
"js-yaml": "$js-yaml",
"minimatch@10.2.5": {
"brace-expansion": "5.0.9"
},
"pm2": {
"js-yaml": "4.3.1"
},
"socks": {
"ip-address": "10.5.0"
}
}
}
+1 -1
View File
@@ -45,6 +45,6 @@
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
"undici": "^7.27.2"
"undici": "^7.29.0"
}
}
+2 -2
View File
@@ -20,7 +20,7 @@
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
"better-sqlite3": "^12.11.1",
"node-forge": "^1.4.0",
"pm2": "^6.0.13",
"undici": "^7.27.2"
"pm2": "^7.0.3",
"undici": "^7.29.0"
}
}
@@ -6,13 +6,14 @@ import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { ApiKeyConfig, AppConfig, GatewayStatus, RouteScriptTestRequest, RouteScriptTestResult, RouteScriptValidationRequest, RouteScriptValidationResult, RouterRule } from "@ccr/core/contracts/app";
import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels } from "@ccr/core/contracts/app";
import { loadAppConfig } from "@ccr/core/config/config";
import { backendService } from "@ccr/core/plugins/backend-service";
import { getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch";
import { pluginService } from "@ccr/core/plugins/service";
import { proxyService } from "@ccr/core/proxy/service";
import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin";
import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler";
import { isAddressInUseMessage, probeExistingCcrGateway } from "@ccr/core/gateway/existing-gateway-probe";
import { isAddressInUseMessage, probeExistingCcrGateway, reloadExistingCcrGatewayConfig } from "@ccr/core/gateway/existing-gateway-probe";
import { closeServer, formatError } from "@ccr/core/gateway/http/io";
import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync";
import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync";
@@ -21,6 +22,8 @@ import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared";
import type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, GatewayStopOptions } from "@ccr/core/gateway/internal/shared";
import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline";
import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler";
import { gatewayRuntimeConfigRevision } from "@ccr/core/gateway/runtime-config-control";
import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/runtime-change";
import { RouteScriptRuntime } from "@ccr/core/routing/route-script-runtime";
import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context";
import { compileRouterConfig } from "@ccr/core/routing/config-compiler";
@@ -61,6 +64,10 @@ class GatewayService {
getBrowserAutomationMcpIntegration: () => this.browserAutomationMcpIntegration,
getConfig: () => this.config,
getPlugin: () => this.plugin,
getRuntimeConfigControlStatus: () => ({
...(this.runtimeConfigReloadError ? { lastError: this.runtimeConfigReloadError } : {}),
revision: gatewayRuntimeConfigRevision(this.config)
}),
getStatus: () => ({
coreEndpoint: this.status.coreEndpoint,
coreManagedExternally: this.status.coreManagedExternally,
@@ -70,6 +77,9 @@ class GatewayService {
handleRawTraceSync: (request, response) => this.rawTraceSynchronizer.handle(request, response),
handleBillingUsageSync: (request, response) => this.billingSynchronizer.handle(request, response),
proxyRequest: (request, response, path, apiKey) => this.proxyRequest(request, response, path, apiKey),
requestRuntimeConfigReload: (expectedRevision, forceRestart) => {
this.schedulePersistedRuntimeConfigReload(expectedRevision, forceRestart);
},
replayContextArchive: (input) => this.requestPipeline.replayContextArchive(input)
});
@@ -90,11 +100,14 @@ class GatewayService {
private child?: ChildProcess;
private config?: AppConfig;
private coreAuthToken = "";
private externalGatewayApiKey?: string;
private plugin?: ClaudeCodeRouterPlugin;
private readonly rawTraceSynchronizer = new RawTraceSynchronizer({
getConfig: () => this.config
});
private readonly routeScriptRuntime = new RouteScriptRuntime();
private runtimeConfigReloadError?: string;
private runtimeConfigReloadQueue: Promise<void> = Promise.resolve();
private server?: Server;
private status: GatewayStatus = {
coreEndpoint: "",
@@ -216,6 +229,7 @@ class GatewayService {
pid: this.child?.pid,
state: "running"
};
this.runtimeConfigReloadError = undefined;
return this.status;
} catch (error) {
await this.stop();
@@ -235,7 +249,12 @@ class GatewayService {
if (currentStatus.gatewayManagedExternally) {
const existingGateway = await probeExistingCcrGateway(config);
if (existingGateway.state === "usable") {
this.markExternalGatewayRunning(config, existingGateway.endpoint);
this.markExternalGatewayRunning(config, existingGateway.endpoint, existingGateway.apiKey);
try {
await this.reloadExternalGatewayConfig(config, false);
} catch {
return this.getStatus();
}
return this.getStatus();
}
}
@@ -255,14 +274,28 @@ class GatewayService {
return status;
}
this.markExternalGatewayRunning(config, existingGateway.endpoint);
this.markExternalGatewayRunning(config, existingGateway.endpoint, existingGateway.apiKey);
try {
await this.reloadExternalGatewayConfig(config, false);
} catch {
return this.getStatus();
}
return this.getStatus();
}
async restart(config: AppConfig): Promise<GatewayStatus> {
if (this.status.gatewayManagedExternally) {
await this.reloadExternalGatewayConfig(config, true);
return this.getStatus();
}
return this.start(config);
}
async stop(options: GatewayStopOptions = {}): Promise<GatewayStatus> {
const child = this.child;
this.child = undefined;
this.coreAuthToken = "";
this.externalGatewayApiKey = undefined;
if (child && !child.killed) {
child.kill();
}
@@ -311,7 +344,7 @@ class GatewayService {
async updateConfig(config: AppConfig): Promise<void> {
assertLoopbackCoreHost(config.gateway.coreHost);
if (this.status.gatewayManagedExternally) {
this.markExternalGatewayRunning(config, endpoint(config.gateway.host, config.gateway.port));
await this.reloadExternalGatewayConfig(config, false);
return;
}
@@ -452,11 +485,65 @@ class GatewayService {
return this.requestPipeline.proxyRequest(request, response, path, apiKey);
}
private markExternalGatewayRunning(config: AppConfig, externalEndpoint: string): void {
private async reloadExternalGatewayConfig(config: AppConfig, forceRestart: boolean): Promise<void> {
const currentEndpoint = this.status.endpoint || endpoint(config.gateway.host, config.gateway.port);
try {
const externalGateway = await reloadExistingCcrGatewayConfig(
currentEndpoint,
config,
this.externalGatewayApiKey,
{ forceRestart }
);
this.markExternalGatewayRunning(config, externalGateway.endpoint, externalGateway.apiKey);
} catch (error) {
const message = `Failed to update the externally managed CCR gateway: ${formatError(error)}`;
this.status = {
...this.status,
lastError: message,
state: "error"
};
throw new Error(message, { cause: error });
}
}
private schedulePersistedRuntimeConfigReload(expectedRevision: string, forceRestart: boolean): void {
this.runtimeConfigReloadError = undefined;
this.runtimeConfigReloadQueue = this.runtimeConfigReloadQueue.then(
() => this.reloadPersistedRuntimeConfig(expectedRevision, forceRestart),
() => this.reloadPersistedRuntimeConfig(expectedRevision, forceRestart)
);
}
private async reloadPersistedRuntimeConfig(expectedRevision: string, forceRestart: boolean): Promise<void> {
try {
const nextConfig = await loadAppConfig();
const actualRevision = gatewayRuntimeConfigRevision(nextConfig);
if (actualRevision !== expectedRevision) {
throw new Error(`Persisted configuration revision ${actualRevision || "(missing)"} does not match the requested revision ${expectedRevision}.`);
}
const restartRequired = forceRestart || !this.config ||
shouldRestartGatewayForRuntimeConfigChange(this.config, nextConfig);
if (restartRequired) {
const status = await this.start(nextConfig);
if (status.state === "error") {
throw new Error(status.lastError || "CCR gateway failed to restart with the updated configuration.");
}
} else {
await this.updateConfig(nextConfig);
}
this.runtimeConfigReloadError = undefined;
} catch (error) {
this.runtimeConfigReloadError = formatError(error);
console.error(`[gateway] Failed to reload persisted runtime configuration: ${this.runtimeConfigReloadError}`);
}
}
private markExternalGatewayRunning(config: AppConfig, externalEndpoint: string, apiKey?: string): void {
this.config = config;
this.child = undefined;
this.server = undefined;
this.coreAuthToken = "";
this.externalGatewayApiKey = apiKey;
this.status = {
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
endpoint: externalEndpoint,
@@ -522,7 +522,15 @@ function mergeNoProxy(current: string | undefined, values: string[]): string {
}
export function endpoint(host: string, port: number): string {
const endpointHost = host === "0.0.0.0" ? "127.0.0.1" : host;
const unwrappedHost = host.startsWith("[") && host.endsWith("]")
? host.slice(1, -1)
: host;
const connectHost = !unwrappedHost || unwrappedHost === "0.0.0.0"
? "127.0.0.1"
: unwrappedHost === "::"
? "::1"
: unwrappedHost;
const endpointHost = connectHost.includes(":") ? `[${connectHost}]` : connectHost;
return `http://${endpointHost}:${port}`;
}
@@ -1,4 +1,6 @@
import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
import { endpoint as gatewayEndpoint } from "@ccr/core/gateway/core-runtime/supervisor";
import { gatewayRuntimeConfigControlPath, gatewayRuntimeConfigRevision } from "@ccr/core/gateway/runtime-config-control";
export type ExistingCcrGatewayProbe =
| { endpoint: string; reason?: string; state: "unavailable" }
@@ -14,6 +16,7 @@ type ExistingGatewayHttpProbe = {
};
const existingGatewayFetchAttempts = 3;
const runtimeConfigReloadTimeoutMs = 20_000;
export async function probeExistingCcrGateway(
config: Pick<AppConfig, "APIKEY" | "APIKEYS" | "gateway">
@@ -71,7 +74,78 @@ export async function probeExistingCcrGateway(
export function publicGatewayEndpoint(config: Pick<AppConfig, "gateway">): string {
const host = probeGatewayHost(config.gateway.host);
return `http://${formatEndpointHost(host)}:${config.gateway.port}/`;
return gatewayEndpoint(host, config.gateway.port);
}
export async function reloadExistingCcrGatewayConfig(
currentEndpoint: string,
config: AppConfig,
currentApiKey: string | undefined,
options: { forceRestart?: boolean } = {}
): Promise<{ apiKey: string; endpoint: string }> {
const configRevision = gatewayRuntimeConfigRevision(config);
if (!configRevision) {
throw new Error("Cannot determine the saved CCR configuration revision.");
}
const currentKey = currentApiKey?.trim();
if (!currentKey) {
throw new Error("The API key accepted by the externally managed CCR gateway is unavailable.");
}
if (options.forceRestart !== true) {
const currentStatus = await fetchExistingGateway(currentEndpoint, gatewayRuntimeConfigControlPath, {
headers: { authorization: `Bearer ${currentKey}` }
}, 1, 700);
if (currentStatus.status === 200 && isRecord(currentStatus.payload) &&
currentStatus.payload.revision === configRevision) {
return { apiKey: currentKey, endpoint: publicGatewayEndpoint(config) };
}
}
const submission = await fetchExistingGateway(currentEndpoint, gatewayRuntimeConfigControlPath, {
body: JSON.stringify({
configRevision,
forceRestart: options.forceRestart === true
}),
headers: {
authorization: `Bearer ${currentKey}`,
"content-type": "application/json"
},
method: "POST"
});
if (submission.status === 404) {
throw new Error("The running CCR gateway does not support runtime configuration reloads. Restart that gateway process once, then try again.");
}
if (submission.status !== 200 && submission.status !== 202) {
const detail = readGatewayErrorMessage(submission.payload) || submission.reason ||
`HTTP ${submission.status ?? 0}`;
throw new Error(`The running CCR gateway rejected the configuration reload request: ${detail}`);
}
const expectedEndpoint = publicGatewayEndpoint(config);
const apiKeys = externalGatewayReloadApiKeys(config, currentKey);
const deadline = Date.now() + runtimeConfigReloadTimeoutMs;
let lastReason: string | undefined;
while (Date.now() < deadline) {
for (const apiKey of apiKeys) {
const status = await fetchExistingGateway(expectedEndpoint, gatewayRuntimeConfigControlPath, {
headers: { authorization: `Bearer ${apiKey}` }
}, 1, 700);
if (status.status === 200 && isRecord(status.payload)) {
if (status.payload.revision === configRevision) {
return { apiKey, endpoint: expectedEndpoint };
}
if (typeof status.payload.lastError === "string" && status.payload.lastError.trim()) {
throw new Error(`The running CCR gateway could not apply the saved configuration: ${status.payload.lastError}`);
}
}
if (status.status !== 401 && status.status !== 403) {
lastReason = status.reason || (status.status ? `HTTP ${status.status}` : lastReason);
}
}
await wait(100);
}
throw new Error(`Timed out waiting for the running CCR gateway to load configuration revision ${configRevision}${lastReason ? ` (${lastReason})` : ""}.`);
}
export function isAddressInUseMessage(message: string | undefined): boolean {
@@ -81,12 +155,14 @@ export function isAddressInUseMessage(message: string | undefined): boolean {
async function fetchExistingGateway(
endpoint: string,
pathname: string,
init: RequestInit = {}
init: RequestInit = {},
attempts = existingGatewayFetchAttempts,
timeoutMs = 1200
): Promise<ExistingGatewayHttpProbe> {
let reason: string | undefined;
for (let attempt = 0; attempt < existingGatewayFetchAttempts; attempt += 1) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1200);
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(new URL(pathname, endpoint).toString(), {
...init,
@@ -105,6 +181,17 @@ async function fetchExistingGateway(
return { reason };
}
function externalGatewayReloadApiKeys(
config: Pick<AppConfig, "APIKEY" | "APIKEYS">,
currentApiKey: string
): string[] {
const apiKeys = existingGatewayApiKeyCandidates(config).map((candidate) => candidate.key);
if (!apiKeys.includes(currentApiKey)) {
apiKeys.push(currentApiKey);
}
return apiKeys;
}
function existingGatewayApiKeyCandidates(config: Pick<AppConfig, "APIKEY" | "APIKEYS">): ApiKeyConfig[] {
const candidates = [
...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []),
@@ -175,10 +262,6 @@ function probeGatewayHost(host: string): string {
return host;
}
function formatEndpointHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -186,3 +269,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function wait(milliseconds: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
}
@@ -14,6 +14,7 @@ import {
type ContextArchiveReplayExecutor
} from "@ccr/core/gateway/context-archive";
import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/gateway/remote-control-service";
import { gatewayRuntimeConfigControlPath } from "@ccr/core/gateway/runtime-config-control";
import { authorize, claudeCodeWifTokenPath, handleClaudeCodeWifTokenRequest, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer";
import { parseJsonObject, readRequestBody, sendJson } from "@ccr/core/gateway/http/io";
import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync";
@@ -27,10 +28,12 @@ export type GatewayHttpRequestHandlerDependencies = {
getBrowserAutomationMcpIntegration: () => BrowserAutomationMcpIntegration | undefined;
getConfig: () => AppConfig | undefined;
getPlugin: () => ClaudeCodeRouterPlugin | undefined;
getRuntimeConfigControlStatus: () => { lastError?: string; revision?: string };
getStatus: () => { coreEndpoint: string; coreManagedExternally?: boolean; endpoint: string; state: string };
handleBillingUsageSync: (request: IncomingMessage, response: ServerResponse) => Promise<void>;
handleRawTraceSync: (request: IncomingMessage, response: ServerResponse) => Promise<void>;
proxyRequest: (request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig) => Promise<void>;
requestRuntimeConfigReload: (expectedRevision: string, forceRestart: boolean) => void;
replayContextArchive: ContextArchiveReplayExecutor;
};
@@ -40,10 +43,12 @@ export class GatewayHttpRequestHandler {
private get browserAutomationMcpIntegration() { return this.dependencies.getBrowserAutomationMcpIntegration(); }
private get config() { return this.dependencies.getConfig(); }
private get plugin() { return this.dependencies.getPlugin(); }
private get runtimeConfigControlStatus() { return this.dependencies.getRuntimeConfigControlStatus(); }
private get status() { return this.dependencies.getStatus(); }
private handleBillingUsageSync(request: IncomingMessage, response: ServerResponse) { return this.dependencies.handleBillingUsageSync(request, response); }
private handleRawTraceSync(request: IncomingMessage, response: ServerResponse) { return this.dependencies.handleRawTraceSync(request, response); }
private proxyRequest(request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig) { return this.dependencies.proxyRequest(request, response, path, apiKey); }
private requestRuntimeConfigReload(expectedRevision: string, forceRestart: boolean) { return this.dependencies.requestRuntimeConfigReload(expectedRevision, forceRestart); }
private replayContextArchive(input: Parameters<ContextArchiveReplayExecutor>[0]) { return this.dependencies.replayContextArchive(input); }
async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
@@ -75,6 +80,46 @@ export class GatewayHttpRequestHandler {
return;
}
if (path === gatewayRuntimeConfigControlPath) {
const authorization = await authorize(request, response, this.config);
if (!authorization.ok) {
return;
}
if (!this.config.APIKEY || authorization.apiKey?.key !== this.config.APIKEY) {
sendJson(response, 403, { error: { message: "The primary CCR API key is required for runtime configuration control." } });
return;
}
if (request.method === "GET") {
sendJson(response, 200, {
...this.runtimeConfigControlStatus,
state: this.status.state
});
return;
}
if (request.method !== "POST") {
sendJson(response, 405, { error: { message: "Method not allowed." } });
return;
}
const body = parseJsonObject(await readRequestBody(request));
const expectedRevision = typeof body.configRevision === "string"
? body.configRevision.trim()
: "";
if (!/^[a-f0-9]{64}$/i.test(expectedRevision)) {
sendJson(response, 400, { error: { message: "A valid configRevision is required." } });
return;
}
const forceRestart = body.forceRestart === true;
response.once("finish", () => {
this.requestRuntimeConfigReload(expectedRevision, forceRestart);
});
sendJson(response, 202, {
accepted: true,
configRevision: expectedRevision,
restarting: forceRestart
});
return;
}
if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) {
const authorization = await authorize(request, response, this.config);
if (!authorization.ok) {
@@ -0,0 +1,28 @@
import { createHash } from "node:crypto";
import type { AppConfig } from "@ccr/core/contracts/app";
export const gatewayRuntimeConfigControlPath = "/__ccr/runtime/config";
export function gatewayRuntimeConfigRevision(config: AppConfig | undefined): string | undefined {
if (!config) {
return undefined;
}
return createHash("sha256")
.update(stableJson(config))
.digest("hex");
}
function stableJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(",")}]`;
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.filter((key) => record[key] !== undefined)
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
+2 -5
View File
@@ -14,6 +14,7 @@ import { findRunningOpenCodeAppPid, launchOpenCodeAppProfile, openCodeAppLaunchS
import { writeOpenCodeGatewayConfig } from "@ccr/core/agents/opencode/profile-config";
import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime";
import { CONFIGDIR } from "@ccr/core/config/constants";
import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor";
import { gatewayService } from "@ccr/core/gateway/service";
import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config";
import { mediaToolsGatewayEndpoint } from "@ccr/core/mcp/grok-media-config";
@@ -737,7 +738,7 @@ function isAddressInUseError(message: string | undefined): boolean {
function profileGatewayEndpoint(config: AppConfig): string {
const host = probeGatewayHost(config.gateway.host);
return `http://${formatEndpointHost(host)}:${config.gateway.port}/`;
return endpoint(host, config.gateway.port);
}
function probeGatewayHost(host: string): string {
@@ -750,10 +751,6 @@ function probeGatewayHost(host: string): string {
return host;
}
function formatEndpointHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function profileGatewayConfigFor(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): AppConfig {
const token = findProfileApiKey(config, profile);
if (!token) {
+23 -4
View File
@@ -265,7 +265,17 @@ const rpcHandlers: Record<string, RpcHandler> = {
const synced = await syncClaudeAppGatewayConfig(baseConfig);
const savedConfig = synced.config;
let runtimeStatus = gatewayService.getStatus();
if (synced.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") {
const restartRequired = synced.configChanged ||
shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) ||
runtimeStatus.state !== "running";
if (runtimeStatus.gatewayManagedExternally) {
if (restartRequired) {
runtimeStatus = await gatewayService.restart(savedConfig);
} else {
await gatewayService.updateConfig(savedConfig);
runtimeStatus = gatewayService.getStatus();
}
} else if (restartRequired) {
runtimeStatus = await gatewayService.start(savedConfig);
} else {
await gatewayService.updateConfig(savedConfig);
@@ -382,14 +392,14 @@ const rpcHandlers: Record<string, RpcHandler> = {
restartGateway: async () => {
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
const config = syncedClaudeAppConfig.config;
const status = await gatewayService.start(config);
const status = await gatewayService.restart(config);
await applyProfileIfServiceRunning(config, status);
return status;
},
restartProxy: async () => {
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
const config = syncedClaudeAppConfig.config;
const status = await gatewayService.start(config);
const status = await gatewayService.restart(config);
await applyProfileIfServiceRunning(config, status);
return proxyService.getStatus();
},
@@ -420,7 +430,16 @@ const rpcHandlers: Record<string, RpcHandler> = {
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig);
savedConfig = syncedClaudeAppConfig.config;
let runtimeStatus = gatewayService.getStatus();
if (syncedClaudeAppConfig.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig)) {
const restartRequired = syncedClaudeAppConfig.configChanged ||
shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig);
if (runtimeStatus.gatewayManagedExternally) {
if (restartRequired) {
runtimeStatus = await gatewayService.restart(savedConfig);
} else {
await gatewayService.updateConfig(savedConfig);
runtimeStatus = gatewayService.getStatus();
}
} else if (restartRequired) {
runtimeStatus = await gatewayService.start(savedConfig);
} else {
await gatewayService.updateConfig(savedConfig);
@@ -11,6 +11,8 @@ import { waitForTcpListener } from "../../support/loopback-listener.mjs";
test("gateway HTTP model discovery and request access honor profile model allowlists", async (t) => {
const config = createProfileAllowlistHttpTestConfig();
const plugin = new ClaudeCodeRouterPlugin(config);
const runtimeConfigRevision = "a".repeat(64);
const runtimeConfigReloads = [];
const status = {
coreEndpoint: "http://127.0.0.1:1",
endpoint: "http://127.0.0.1:0",
@@ -27,10 +29,14 @@ test("gateway HTTP model discovery and request access honor profile model allowl
getBrowserAutomationMcpIntegration: () => undefined,
getConfig: () => config,
getPlugin: () => plugin,
getRuntimeConfigControlStatus: () => ({ revision: runtimeConfigRevision }),
getStatus: () => status,
handleBillingUsageSync: unsupportedHandler,
handleRawTraceSync: unsupportedHandler,
proxyRequest: (request, response, path, apiKey) => pipeline.proxyRequest(request, response, path, apiKey),
requestRuntimeConfigReload: (expectedRevision, forceRestart) => {
runtimeConfigReloads.push({ expectedRevision, forceRestart });
},
replayContextArchive: async () => ({ ok: false, statusCode: 404, message: "not configured" })
});
const server = createServer((request, response) => {
@@ -56,6 +62,29 @@ test("gateway HTTP model discovery and request access honor profile model allowl
const endpoint = `http://127.0.0.1:${serverPort(server)}`;
status.endpoint = endpoint;
const runtimeConfigStatus = await fetchJson(`${endpoint}/__ccr/runtime/config`, {
headers: authHeaders("gateway-key")
});
assert.equal(runtimeConfigStatus.revision, runtimeConfigRevision);
assert.equal(runtimeConfigStatus.state, "running");
const deniedRuntimeConfigStatus = await fetch(`${endpoint}/__ccr/runtime/config`, {
headers: authHeaders("profile-alpha-key")
});
assert.equal(deniedRuntimeConfigStatus.status, 403);
const runtimeConfigReload = await fetch(`${endpoint}/__ccr/runtime/config`, {
body: JSON.stringify({ configRevision: runtimeConfigRevision, forceRestart: true }),
headers: {
...authHeaders("gateway-key"),
"content-type": "application/json"
},
method: "POST"
});
assert.equal(runtimeConfigReload.status, 202, await runtimeConfigReload.text());
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(runtimeConfigReloads, [{ expectedRevision: runtimeConfigRevision, forceRestart: true }]);
const alphaModels = await fetchJson(`${endpoint}/v1/models`, {
headers: authHeaders("profile-alpha-key")
});
@@ -11,8 +11,16 @@ import {
replacePersistedRuntimeState
} from "@ccr/core/config/config-repository.ts";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor.ts";
import { gatewayService } from "@ccr/core/gateway/service.ts";
test("gateway endpoints normalize wildcard and IPv6 hosts", () => {
assert.equal(endpoint("0.0.0.0", 3456), "http://127.0.0.1:3456");
assert.equal(endpoint("::", 3456), "http://[::1]:3456");
assert.equal(endpoint("::1", 3456), "http://[::1]:3456");
assert.equal(endpoint("[::1]", 3456), "http://[::1]:3456");
});
test("gateway start persists preflight validation failures for status polling", async () => {
await gatewayService.stop();
@@ -1,13 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CLAUDE_CODE_AUTH_MODE_ENV } from "@ccr/core/agents/claude-code/auth-mode.ts";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
import { ensureProfileGateway } from "@ccr/core/profiles/launch-service.ts";
function claudeProfileConfig() {
function claudeProfileConfig(authMode = "api-key-helper") {
const profile = {
agent: "claude-code",
enabled: true,
env: {},
env: {
[CLAUDE_CODE_AUTH_MODE_ENV]: authMode
},
id: "claude-main",
model: "Provider/model",
name: "Claude Main",
@@ -99,6 +102,47 @@ test("existing profile gateway falls back to the root identity response", async
}
});
test("existing profile gateway rejects WIF profiles when the root identity lacks the token endpoint", async () => {
const previousFetch = globalThis.fetch;
const paths = [];
globalThis.fetch = async (input) => {
const url = new URL(String(input));
paths.push(url.pathname);
if (url.pathname === "/health") {
return Response.json({
core: "http://127.0.0.1:3467",
status: "running",
timestamp: "2026-01-01T00:00:00.000Z"
});
}
if (url.pathname === "/v1/models") {
return Response.json({ data: [], object: "list" });
}
if (url.pathname === "/") {
return Response.json({
endpoints: ["GET /v1/models"],
name: "claude-code-router"
});
}
throw new Error(`Unexpected gateway probe: ${url.pathname}`);
};
try {
const { config, profile } = claudeProfileConfig("wif");
await assert.rejects(
ensureProfileGateway(config, profile, "Local subscription router", {
reuseExisting: true,
startIfMissing: false
}),
/does not advertise the Claude Code WIF token endpoint/
);
assert.deepEqual(paths, ["/health", "/v1/models", "/"]);
} finally {
globalThis.fetch = previousFetch;
}
});
test("existing profile gateway retries a transient transport failure", async () => {
const previousFetch = globalThis.fetch;
const paths = [];
@@ -69,6 +69,7 @@ test("web RPC ignores Origin and Referer when the auth token is valid", async ()
test("startGateway reuses an already healthy CCR gateway on the configured port", async () => {
const { saveAppConfig } = await import("@ccr/core/config/config.ts");
const { createDefaultAppConfig } = await import("@ccr/core/config/default-config.ts");
const { gatewayRuntimeConfigRevision } = await import("@ccr/core/gateway/runtime-config-control.ts");
const { gatewayService } = await import("@ccr/core/gateway/service.ts");
const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts");
await gatewayService.stop();
@@ -86,7 +87,11 @@ test("startGateway reuses an already healthy CCR gateway on the configured port"
type: "openai_chat_completions"
}];
const savedConfig = await saveAppConfig(config);
const externalGateway = createHealthyCcrGateway(savedConfig.APIKEY);
const externalGatewayState = {
reloadRequests: [],
revision: gatewayRuntimeConfigRevision(savedConfig)
};
const externalGateway = createHealthyCcrGateway(savedConfig.APIKEY, externalGatewayState);
await listen(externalGateway, gatewayPort);
const runtime = await startWebManagementServer({
authToken: webAuthToken,
@@ -100,10 +105,25 @@ test("startGateway reuses an already healthy CCR gateway on the configured port"
assert.equal(payload.ok, true);
assert.equal(payload.value.state, "running", payload.value.lastError);
assert.equal(payload.value.endpoint, `http://127.0.0.1:${gatewayPort}/`);
assert.equal(payload.value.endpoint, `http://127.0.0.1:${gatewayPort}`);
assert.equal(payload.value.gatewayManagedExternally, true);
assert.equal(gatewayService.getStatus().state, "running");
assert.equal(gatewayService.getStatus().gatewayManagedExternally, true);
const secondStart = await rpc(runtime.url, webAuthToken, "startGateway");
assert.equal(secondStart.ok, true);
assert.equal(secondStart.value.endpoint, `http://127.0.0.1:${gatewayPort}`);
assert.equal(secondStart.value.gatewayManagedExternally, true);
const nextConfig = structuredClone(savedConfig);
nextConfig.Providers[0].name = "Updated Test Provider";
const reloadCountBeforeSave = externalGatewayState.reloadRequests.length;
const saveResult = await rpc(runtime.url, webAuthToken, "saveConfig", [nextConfig, { applyProfile: false }]);
assert.equal(saveResult.ok, true);
assert.equal(externalGatewayState.reloadRequests.length, reloadCountBeforeSave + 1);
assert.equal(externalGatewayState.reloadRequests.at(-1).forceRestart, true);
assert.equal(externalGatewayState.revision, gatewayRuntimeConfigRevision(saveResult.value));
assert.equal(gatewayService.getStatus().gatewayManagedExternally, true);
} finally {
await runtime.close();
await gatewayService.stop();
@@ -124,7 +144,7 @@ async function rpc(baseUrl, authToken, method, args = []) {
return response.json();
}
function createHealthyCcrGateway(apiKey) {
function createHealthyCcrGateway(apiKey, state) {
return createServer((request, response) => {
const url = new URL(request.url || "/", "http://127.0.0.1");
if (url.pathname === "/health") {
@@ -152,10 +172,40 @@ function createHealthyCcrGateway(apiKey) {
sendJson(response, 200, { data: [{ id: "test-model", object: "model" }], object: "list" });
return;
}
if (url.pathname === "/__ccr/runtime/config") {
if (request.headers.authorization !== `Bearer ${apiKey}`) {
sendJson(response, 401, { error: { message: "Invalid API key." } });
return;
}
if (request.method === "GET") {
sendJson(response, 200, { revision: state.revision, state: "running" });
return;
}
if (request.method === "POST") {
void readJsonRequest(request).then((body) => {
state.reloadRequests.push(body);
state.revision = body.configRevision;
sendJson(response, 202, {
accepted: true,
configRevision: body.configRevision,
restarting: body.forceRestart === true
});
});
return;
}
}
sendJson(response, 404, { error: { message: "Not found." } });
});
}
async function readJsonRequest(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(Buffer.from(chunk));
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { "content-type": "application/json" });
response.end(JSON.stringify(payload));
+23 -4
View File
@@ -220,8 +220,18 @@ ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: Ap
const synced = await syncClaudeAppGatewayConfig(baseConfig);
const savedConfig = synced.config;
let runtimeStatus = gatewayService.getStatus();
const restartRequired = synced.configChanged ||
shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) ||
runtimeStatus.state !== "running";
if (synced.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") {
if (runtimeStatus.gatewayManagedExternally) {
if (restartRequired) {
runtimeStatus = await gatewayService.restart(savedConfig);
} else {
await gatewayService.updateConfig(savedConfig);
runtimeStatus = gatewayService.getStatus();
}
} else if (restartRequired) {
runtimeStatus = await gatewayService.start(savedConfig);
} else {
await gatewayService.updateConfig(savedConfig);
@@ -323,7 +333,16 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig);
savedConfig = syncedClaudeAppConfig.config;
let runtimeStatus = gatewayService.getStatus();
if (syncedClaudeAppConfig.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig)) {
const restartRequired = syncedClaudeAppConfig.configChanged ||
shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig);
if (runtimeStatus.gatewayManagedExternally) {
if (restartRequired) {
runtimeStatus = await gatewayService.restart(savedConfig);
} else {
await gatewayService.updateConfig(savedConfig);
runtimeStatus = gatewayService.getStatus();
}
} else if (restartRequired) {
runtimeStatus = await gatewayService.start(savedConfig);
} else {
await gatewayService.updateConfig(savedConfig);
@@ -360,7 +379,7 @@ ipcMain.handle(IPC_CHANNELS.appSetOnboardingFinished, async () => {
ipcMain.handle(IPC_CHANNELS.appRestartGateway, async () => {
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
const config = syncedClaudeAppConfig.config;
const status = await gatewayService.start(config);
const status = await gatewayService.restart(config);
await applyProfileIfServiceRunning(config, status);
await builtInBrowserService.syncProxy(config);
return status;
@@ -391,7 +410,7 @@ ipcMain.handle(IPC_CHANNELS.appShowMainWindow, () => {
ipcMain.handle(IPC_CHANNELS.appRestartProxy, async () => {
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
const config = syncedClaudeAppConfig.config;
const status = await gatewayService.start(config);
const status = await gatewayService.restart(config);
await applyProfileIfServiceRunning(config, status);
await builtInBrowserService.syncProxy(config);
return proxyService.getStatus();
@@ -997,7 +997,7 @@ export function AddProfileForm({
/>
{validation.allowedModels ? <ProfileFieldHint>{t(validation.allowedModels)}</ProfileFieldHint> : null}
</Field>
{draft.agent === "codex" && draft.agent !== "workbuddy" ? (
{draft.agent === "codex" ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 px-3 py-2">
<span className="text-[12px] font-medium">{t("Show all sessions")}</span>
<Toggle checked={draft.showAllSessions} onChange={(showAllSessions) => onChange({ showAllSessions })} />