Reuse healthy external gateways and decode Claude App routes

This commit is contained in:
musistudio 2026-08-11 10:00:33 +08:00
parent e8db9406a7
commit 551fc0e8ef
11 changed files with 704 additions and 36 deletions

View file

@ -1809,6 +1809,7 @@ export type GatewayStatus = {
coreEndpoint: string;
coreManagedExternally?: boolean;
endpoint: string;
gatewayManagedExternally?: boolean;
lastError?: string;
lastStartedAt?: string;
networkEndpoints: GatewayNetworkEndpoint[];

View file

@ -12,6 +12,7 @@ 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 { 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";
@ -210,6 +211,7 @@ class GatewayService {
this.status = {
...this.status,
coreManagedExternally: this.status.coreManagedExternally,
gatewayManagedExternally: undefined,
lastStartedAt: new Date().toISOString(),
pid: this.child?.pid,
state: "running"
@ -226,6 +228,37 @@ class GatewayService {
}
}
async ensureStarted(config: AppConfig): Promise<GatewayStatus> {
const desiredEndpoint = endpoint(config.gateway.host, config.gateway.port);
const currentStatus = this.getStatus();
if (currentStatus.state === "running" && currentStatus.endpoint === desiredEndpoint) {
if (currentStatus.gatewayManagedExternally) {
const existingGateway = await probeExistingCcrGateway(config);
if (existingGateway.state === "usable") {
this.markExternalGatewayRunning(config, existingGateway.endpoint);
return this.getStatus();
}
}
if (!currentStatus.gatewayManagedExternally) {
await this.updateConfig(config);
return this.getStatus();
}
}
const status = await this.start(config);
if (status.state !== "error" || !isAddressInUseMessage(status.lastError)) {
return status;
}
const existingGateway = await probeExistingCcrGateway(config);
if (existingGateway.state !== "usable") {
return status;
}
this.markExternalGatewayRunning(config, existingGateway.endpoint);
return this.getStatus();
}
async stop(options: GatewayStopOptions = {}): Promise<GatewayStatus> {
const child = this.child;
this.child = undefined;
@ -259,6 +292,7 @@ class GatewayService {
this.status = {
...this.status,
coreManagedExternally: undefined,
gatewayManagedExternally: undefined,
pid: undefined,
state: "stopped"
};
@ -276,6 +310,11 @@ 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));
return;
}
const scriptValidationErrors = await this.routeScriptRuntime.prepare(config.Router.rules);
const nextPlugin = new ClaudeCodeRouterPlugin(config, {
scriptRuntime: this.routeScriptRuntime,
@ -293,6 +332,7 @@ class GatewayService {
...this.status,
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
endpoint: endpoint(config.gateway.host, config.gateway.port),
gatewayManagedExternally: undefined,
networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port)
};
}
@ -412,6 +452,20 @@ class GatewayService {
return this.requestPipeline.proxyRequest(request, response, path, apiKey);
}
private markExternalGatewayRunning(config: AppConfig, externalEndpoint: string): void {
this.config = config;
this.child = undefined;
this.server = undefined;
this.coreAuthToken = "";
this.status = {
coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort),
endpoint: externalEndpoint,
gatewayManagedExternally: true,
networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port),
state: "running"
};
}
}
function assertManagedGatewayStartupContinues(child: ChildProcess, startupFailure: Error | undefined): void {

View file

@ -0,0 +1,188 @@
import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app";
export type ExistingCcrGatewayProbe =
| { endpoint: string; reason?: string; state: "unavailable" }
| { endpoint: string; status?: number; state: "not-ccr" }
| { endpoint: string; message?: string; status: number; state: "unauthorized" }
| { endpoint: string; status: number; state: "unusable" }
| { apiKey?: string; endpoint: string; state: "usable" };
type ExistingGatewayHttpProbe = {
payload?: unknown;
reason?: string;
status?: number;
};
const existingGatewayFetchAttempts = 3;
export async function probeExistingCcrGateway(
config: Pick<AppConfig, "APIKEY" | "APIKEYS" | "gateway">
): Promise<ExistingCcrGatewayProbe> {
const endpoint = publicGatewayEndpoint(config);
const health = await fetchExistingGateway(endpoint, "/health");
let ccrGateway = isCcrGatewayHealth(health.payload);
let root: ExistingGatewayHttpProbe | undefined;
if (!ccrGateway) {
root = await fetchExistingGateway(endpoint, "/");
ccrGateway = isCcrGatewayRoot(root.payload);
}
if (!ccrGateway) {
if (health.status === undefined && root?.status === undefined) {
return { endpoint, reason: health.reason || root?.reason, state: "unavailable" };
}
return { endpoint, status: health.status ?? root?.status, state: "not-ccr" };
}
const candidates = existingGatewayApiKeyCandidates(config);
if (candidates.length === 0) {
return { endpoint, message: "No configured CCR API key is available.", status: 401, state: "unauthorized" };
}
let lastUnauthorized: ExistingGatewayHttpProbe | undefined;
for (const apiKey of candidates) {
const models = await fetchExistingGateway(endpoint, "/v1/models", {
headers: {
authorization: `Bearer ${apiKey.key}`,
"user-agent": "Claude Code"
}
});
if (models.status === 200) {
return { apiKey: apiKey.key, endpoint, state: "usable" };
}
if (models.status === 401 || models.status === 403) {
lastUnauthorized = models;
continue;
}
return { endpoint, status: models.status ?? 0, state: "unusable" };
}
if (lastUnauthorized?.status === 401 || lastUnauthorized?.status === 403) {
return {
endpoint,
message: readGatewayErrorMessage(lastUnauthorized.payload),
status: lastUnauthorized.status,
state: "unauthorized"
};
}
return { endpoint, status: 0, state: "unusable" };
}
export function publicGatewayEndpoint(config: Pick<AppConfig, "gateway">): string {
const host = probeGatewayHost(config.gateway.host);
return `http://${formatEndpointHost(host)}:${config.gateway.port}/`;
}
export function isAddressInUseMessage(message: string | undefined): boolean {
return /\bEADDRINUSE\b/i.test(message || "");
}
async function fetchExistingGateway(
endpoint: string,
pathname: string,
init: RequestInit = {}
): Promise<ExistingGatewayHttpProbe> {
let reason: string | undefined;
for (let attempt = 0; attempt < existingGatewayFetchAttempts; attempt += 1) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1200);
try {
const response = await fetch(new URL(pathname, endpoint).toString(), {
...init,
signal: controller.signal
});
return {
payload: await readResponseJson(response),
status: response.status
};
} catch (error) {
reason = formatError(error);
} finally {
clearTimeout(timeout);
}
}
return { reason };
}
function existingGatewayApiKeyCandidates(config: Pick<AppConfig, "APIKEY" | "APIKEYS">): ApiKeyConfig[] {
const candidates = [
...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []),
...(config.APIKEY ? [{ createdAt: "", id: "legacy", key: config.APIKEY, name: "Legacy API Key" }] : [])
];
const seen = new Set<string>();
const result: ApiKeyConfig[] = [];
for (const candidate of candidates) {
const key = candidate.key?.trim();
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
result.push({ ...candidate, key });
}
return result;
}
function isCcrGatewayHealth(value: unknown): boolean {
return isRecord(value) &&
typeof value.status === "string" &&
typeof value.core === "string";
}
function isCcrGatewayRoot(value: unknown): boolean {
return isRecord(value) &&
(value.name === "claude-code-router" ||
value.plugin === "claude-code-router" ||
(value.core === "next-ai-gateway" && Array.isArray(value.endpoints)));
}
function readGatewayErrorMessage(value: unknown): string | undefined {
if (!isRecord(value)) {
return undefined;
}
const error = value.error;
if (typeof error === "string") {
return error;
}
if (isRecord(error) && typeof error.message === "string") {
return error.message;
}
if (typeof value.message === "string") {
return value.message;
}
return undefined;
}
async function readResponseJson(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
function probeGatewayHost(host: string): string {
if (!host || host === "0.0.0.0") {
return "127.0.0.1";
}
if (host === "::") {
return "::1";
}
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;
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { REQUEST_LOGS_DB_FILE } from "@ccr/core/config/constants";
import { decodeClaudeAppGatewayRouteId } from "@ccr/core/agents/claude-app/gateway-routes";
import {
estimateUsageCostUsd,
estimateUsageCostUsdFromLoadedCatalog,
@ -518,9 +519,12 @@ export class RequestLogStore {
providerProtocol: input.providerProtocol,
usageHint: bodyUsage
}) ?? {};
const route = splitRouteSelector(input.fallbackModel);
const route = splitRequestLogRouteSelector(input.fallbackModel);
const bodyModel = requestLogRequestedModel(input.requestBody, input.path);
const requestModel = normalizeFilterValue(input.model) ?? bodyModel;
const requestModelForStorage = requestLogStorageModel(requestModel);
const usageRoute = decodedClaudeAppGatewayRouteParts(usage.model);
const usageModelForStorage = usageRoute?.model ?? normalizeFilterValue(usage.model);
const requestedModel = normalizeFilterValue(input.requestedModel) ?? bodyModel ?? "";
const resolvedModel = normalizeFilterValue(input.resolvedModel) ??
normalizeFilterValue(input.model) ??
@ -535,7 +539,8 @@ export class RequestLogStore {
normalizeFilterValue(input.providerName) ??
readResponseHeader(input.responseHeaders, "x-gateway-target-provider-name") ??
readResponseHeader(input.responseHeaders, "x-gateway-target-provider") ??
route.provider;
route.provider ??
usageRoute?.provider;
const inputTokens = normalizeCount(usage.inputTokens);
const outputTokens = normalizeCount(usage.outputTokens);
const reasoningTokens = normalizeCount(usage.reasoningTokens);
@ -546,7 +551,7 @@ export class RequestLogStore {
const totalTokens =
normalizeCount(usage.totalTokens) ||
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
const model = normalizeLabel(usage.model ?? route.model ?? requestModel ?? input.fallbackModel, "unknown");
const model = normalizeLabel(usageModelForStorage ?? route.model ?? requestModelForStorage ?? input.fallbackModel, "unknown");
const providerName = normalizeLabel(provider, "unknown");
// CCR credential IDs are structured metadata, but their header names also
// match the fail-closed secret classifier. Extract them before sanitizing;
@ -785,7 +790,9 @@ export class RequestLogStore {
const url = normalizeFilterValue(input.url);
const path = normalizeFilterValue(input.path) ?? pathFromUrl(url);
const usagePath = path ?? existingUsageContext.path;
const modelFromTrace = normalizeFilterValue(input.model);
const rawModelFromTrace = normalizeFilterValue(input.model);
const modelFromTrace = requestLogStorageModel(rawModelFromTrace);
const resolvedModelFromTrace = requestLogStorageModelSelector(rawModelFromTrace);
const responseModelFromTrace = rawInput.responseBodyText === undefined
? undefined
: requestLogResponseModel(rawInput.responseBodyText);
@ -806,7 +813,7 @@ export class RequestLogStore {
pushValue("url", url);
pushValue("provider", providerFromTrace);
pushValue("model", modelFromTrace);
pushValue("resolved_model", modelFromTrace);
pushValue("resolved_model", resolvedModelFromTrace);
pushValue("response_model", responseModelFromTrace);
// The gateway's terminal failure is authoritative, even when it has only
// an HTTP error status and no error string. A final-attempt raw failure may
@ -847,7 +854,7 @@ export class RequestLogStore {
const totalTokens =
normalizeCount(usage.totalTokens) ||
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
const model = normalizeLabel(usage.model ?? modelFromTrace ?? existingUsageContext.model, "unknown");
const model = normalizeLabel(requestLogStorageModel(usage.model) ?? modelFromTrace ?? existingUsageContext.model, "unknown");
const provider = normalizeLabel(providerFromTrace ?? existingUsageContext.provider, "unknown");
const costInput = {
cacheReadTokens,
@ -4986,6 +4993,23 @@ function parseJson(value: string): unknown | undefined {
}
}
function decodedClaudeAppGatewayRouteParts(value: string | undefined): { model?: string; provider?: string } | undefined {
const decoded = decodeClaudeAppGatewayRouteId(value ?? "");
return decoded ? splitRouteSelector(decoded) : undefined;
}
function requestLogStorageModel(value: string | undefined): string | undefined {
return decodedClaudeAppGatewayRouteParts(value)?.model ?? normalizeFilterValue(value);
}
function requestLogStorageModelSelector(value: string | undefined): string | undefined {
return normalizeFilterValue(decodeClaudeAppGatewayRouteId(value ?? "") ?? value);
}
function splitRequestLogRouteSelector(value: string | undefined): { model?: string; provider?: string } {
return splitRouteSelector(decodeClaudeAppGatewayRouteId(value ?? "") ?? value);
}
function splitRouteSelector(value: string | undefined): { model?: string; provider?: string } {
const trimmed = value?.trim();
if (!trimmed) {

View file

@ -366,7 +366,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
openProfile: async (request) => {
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
const config = syncedClaudeAppConfig.config;
const status = await gatewayService.start(config);
const status = await gatewayService.ensureStarted(config);
if (status.state !== "running") {
throw new Error(status.lastError || "CCR gateway did not start.");
}
@ -444,7 +444,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
startGateway: async () => {
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
const config = syncedClaudeAppConfig.config;
const status = await gatewayService.start(config);
const status = await gatewayService.ensureStarted(config);
await applyProfileIfServiceRunning(config, status);
return status;
},
@ -469,7 +469,7 @@ async function startConfiguredServices(reason: string): Promise<void> {
} catch (error) {
console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`);
}
const status = await gatewayService.start(config);
const status = await gatewayService.ensureStarted(config);
if (status.state === "error") {
console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
}

View file

@ -316,6 +316,85 @@ test("RequestLogStore keeps list rows lightweight and detail rows complete", asy
}
});
test("RequestLogStore stores decoded Claude App route models for observability", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-claude-app-model-test-"));
try {
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
const sessionId = "claude-app-hex-session";
const routedModel = "Provider/real-model";
const encodedModel = `anthropic/claude-ccr-h${Buffer.from(routedModel, "utf8").toString("hex")}`;
const startedAt = new Date().toISOString();
const responseBodyText = [
"event: message_start",
`data: ${JSON.stringify({
message: {
model: encodedModel,
usage: {
input_tokens: 3,
output_tokens: 5,
total_tokens: 8
}
},
type: "message_start"
})}`,
"",
"event: message_stop",
"data: {\"type\":\"message_stop\"}",
""
].join("\n");
await store.record({
completedAt: startedAt,
durationMs: 42,
fallbackModel: routedModel,
method: "POST",
model: routedModel,
path: "/v1/messages",
providerName: "Provider",
providerProtocol: "anthropic_messages",
requestedModel: encodedModel,
requestBody: Buffer.from(JSON.stringify({
messages: [{ content: "hello", role: "user" }],
model: routedModel
}), "utf8"),
requestHeaders: {
"content-type": "application/json",
"user-agent": "openai-codex test",
"x-codex-session-id": sessionId
},
requestId: "claude-app-hex-request",
resolvedModel: routedModel,
responseBodyText,
responseHeaders: { "content-type": "text/event-stream" },
startedAt,
statusCode: 200,
url: "http://127.0.0.1:3456/v1/messages"
});
const page = await store.list({ pageSize: 25 });
assert.equal(page.items.length, 1);
assert.equal(page.items[0].provider, "Provider");
assert.equal(page.items[0].model, "real-model");
assert.equal(page.items[0].requestedModel, encodedModel);
assert.equal(page.items[0].resolvedModel, routedModel);
assert.equal(page.items[0].responseModel, encodedModel);
const selected = await store.analyze({
range: "30d",
sessionAgent: "codex",
sessionId
});
assert.equal(selected.selectedSession?.requests[0]?.model, "real-model");
assert.deepEqual(selected.selectedSession?.session.models, ["real-model"]);
assert.equal(
selected.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.model,
"real-model"
);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("RequestLogStore persists actively reported route hops without synthesizing Core diffs", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-route-trace-test-"));
try {

View file

@ -1,8 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeExternalHttpTarget, startWebManagementServer } from "@ccr/core/web/management-server.ts";
import { mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import test, { after } from "node:test";
test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin URLs only", () => {
const previousRuntimeEnv = {
appData: process.env.CCR_INTERNAL_APP_DATA_DIR,
home: process.env.CCR_INTERNAL_HOME_DIR,
userData: process.env.CCR_INTERNAL_USER_DATA_DIR
};
const runtimeRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-web-management-test-"));
process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(runtimeRoot, "app-data");
process.env.CCR_INTERNAL_HOME_DIR = path.join(runtimeRoot, "home");
process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(runtimeRoot, "user-data");
after(() => {
setOptionalEnv("CCR_INTERNAL_APP_DATA_DIR", previousRuntimeEnv.appData);
setOptionalEnv("CCR_INTERNAL_HOME_DIR", previousRuntimeEnv.home);
setOptionalEnv("CCR_INTERNAL_USER_DATA_DIR", previousRuntimeEnv.userData);
rmSync(runtimeRoot, { force: true, recursive: true });
});
test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin URLs only", async () => {
const { normalizeExternalHttpTarget } = await import("@ccr/core/web/management-server.ts");
assert.equal(normalizeExternalHttpTarget(""), undefined);
assert.equal(normalizeExternalHttpTarget(undefined), undefined);
assert.equal(normalizeExternalHttpTarget("about:blank"), undefined);
@ -14,6 +36,7 @@ test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin U
});
test("web RPC ignores Origin and Referer when the auth token is valid", async () => {
const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts");
const authToken = "test-web-auth-token";
const runtime = await startWebManagementServer({
authToken,
@ -42,3 +65,149 @@ test("web RPC ignores Origin and Referer when the auth token is valid", async ()
await runtime.close();
}
});
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 { gatewayService } = await import("@ccr/core/gateway/service.ts");
const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts");
await gatewayService.stop();
const gatewayPort = await findAvailablePort();
const webAuthToken = "test-web-auth-token";
const config = createDefaultAppConfig();
config.gateway.port = gatewayPort;
config.gateway.corePort = await findAvailablePort();
config.Providers = [{
api_base_url: "http://127.0.0.1:9/v1",
api_key: "test-provider-key",
id: "test-provider",
models: ["test-model"],
name: "Test Provider",
type: "openai_chat_completions"
}];
const savedConfig = await saveAppConfig(config);
const externalGateway = createHealthyCcrGateway(savedConfig.APIKEY);
await listen(externalGateway, gatewayPort);
const runtime = await startWebManagementServer({
authToken: webAuthToken,
host: "127.0.0.1",
port: 0,
startGateway: false
});
try {
const payload = await rpc(runtime.url, webAuthToken, "startGateway");
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.gatewayManagedExternally, true);
assert.equal(gatewayService.getStatus().state, "running");
assert.equal(gatewayService.getStatus().gatewayManagedExternally, true);
} finally {
await runtime.close();
await gatewayService.stop();
await closeServer(externalGateway);
}
});
async function rpc(baseUrl, authToken, method, args = []) {
const response = await fetch(new URL("/api/ccr/rpc", baseUrl), {
body: JSON.stringify({ args, method }),
headers: {
"content-type": "application/json",
"x-ccr-web-auth": authToken
},
method: "POST"
});
assert.equal(response.status, 200);
return response.json();
}
function createHealthyCcrGateway(apiKey) {
return createServer((request, response) => {
const url = new URL(request.url || "/", "http://127.0.0.1");
if (url.pathname === "/health") {
sendJson(response, 200, {
core: "http://127.0.0.1:3457",
status: "running",
timestamp: new Date().toISOString()
});
return;
}
if (url.pathname === "/") {
sendJson(response, 200, {
core: "next-ai-gateway",
endpoints: ["GET /v1/models"],
name: "claude-code-router",
plugin: "claude-code-router"
});
return;
}
if (url.pathname === "/v1/models") {
if (request.headers.authorization !== `Bearer ${apiKey}`) {
sendJson(response, 401, { error: { message: "Invalid API key." } });
return;
}
sendJson(response, 200, { data: [{ id: "test-model", object: "model" }], object: "list" });
return;
}
sendJson(response, 404, { error: { message: "Not found." } });
});
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, { "content-type": "application/json" });
response.end(JSON.stringify(payload));
}
async function findAvailablePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : undefined;
server.close(() => {
if (port) {
resolve(port);
} else {
reject(new Error("Failed to allocate a web management test port."));
}
});
});
});
}
function setOptionalEnv(key, value) {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}
async function listen(server, port) {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
}
async function closeServer(server) {
if (!server.listening) {
return;
}
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}

View file

@ -991,7 +991,7 @@ async function routeMockRequest(runtime, method, url, request, requestBody) {
}
if (isDesignRestApiRoutePath(path)) {
return await handleDesignRestApi(runtime, method, path, request, requestBody);
return await handleDesignRestApi(runtime, method, path, url, request, requestBody);
}
if (path.startsWith("/v1/code/")) {
@ -9812,7 +9812,7 @@ function upsertGenericRecord(store, collection, uuid, title, model, data) {
store.persist();
}
async function handleDesignRestApi(runtime, method, path, request, requestBody) {
async function handleDesignRestApi(runtime, method, path, url, request, requestBody) {
const restPath = normalizeDesignRestPath(path);
if (method === "GET" && restPath === "/v1/design/agents") {
@ -9991,19 +9991,18 @@ async function handleDesignRestApi(runtime, method, path, request, requestBody)
});
}
const downloadMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/download$/);
if (method === "GET" && downloadMatch) {
const downloadMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/download(?:\/(.+))?$/);
if ((method === "GET" || method === "HEAD") && downloadMatch) {
const projectId = decodeURIComponent(downloadMatch[1]);
const manifest = listProjectFileRows(runtime, projectId, "").map((row) => ({
content_type: row.content_type,
path: row.path,
size: Buffer.from(row.body_base64 || "", "base64").length,
version: row.version
}));
return textResponse(200, JSON.stringify({ files: manifest, project_id: projectId }, null, 2), {
"content-disposition": `attachment; filename="${projectId}.json"`,
"content-type": "application/json; charset=utf-8"
});
const filePath = designDownloadFilePath(downloadMatch[2], url);
if (filePath) {
const row = getProjectFileRow(runtime, projectId, filePath);
if (!row) {
return jsonResponse(404, { error: { message: `File not found: ${filePath}` } });
}
return serveProjectFileDownloadResponse(method, row, filePath);
}
return serveProjectArchiveDownloadResponse(method, runtime, projectId);
}
if (method === "POST" && restPath === "/v1/design/drop-suggestions") {
@ -10170,6 +10169,56 @@ function renderClaudeDesignPendingPreviewHtml() {
</html>`;
}
function designDownloadFilePath(pathSuffix, url) {
const suffixPath = safelyDecodeDesignPath(pathSuffix);
if (suffixPath) {
return suffixPath;
}
for (const key of ["path", "file", "file_path", "download_path", "asset_path"]) {
const queryPath = safelyDecodeDesignPath(url.searchParams.get(key));
if (queryPath) {
return queryPath;
}
}
return "";
}
function safelyDecodeDesignPath(value) {
const raw = stringValue(value);
if (!raw) {
return "";
}
try {
return sanitizeProjectFilePath(decodeURIComponent(raw));
} catch {
return sanitizeProjectFilePath(raw);
}
}
function serveProjectFileDownloadResponse(method, row, filePath) {
const body = method === "HEAD" ? Buffer.alloc(0) : Buffer.from(row.body_base64 || "", "base64");
const contentType = row.content_type || guessContentType(filePath);
return binaryResponse(200, body, {
"cache-control": "no-store",
"content-disposition": attachmentContentDisposition(pathModule.posix.basename(filePath) || "download"),
"content-type": contentType
});
}
function serveProjectArchiveDownloadResponse(method, runtime, projectId) {
const rows = listProjectFileRows(runtime, projectId, "");
const files = rows.map((row) => ({
body: Buffer.from(row.body_base64 || "", "base64"),
path: row.path
}));
const archive = method === "HEAD" ? Buffer.alloc(0) : createZipArchive(files);
return binaryResponse(200, archive, {
"cache-control": "no-store",
"content-disposition": attachmentContentDisposition(`${projectId || "claude-design-project"}.zip`),
"content-type": "application/zip"
});
}
function serveProjectFileResponse(runtime, projectId, row, filePath) {
const body = Buffer.from(row.body_base64 || "", "base64");
const contentType = row.content_type || guessContentType(filePath);
@ -16652,6 +16701,21 @@ function escapeHtmlAttribute(value) {
return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
function attachmentContentDisposition(fileName) {
const basename = pathModule.posix.basename(sanitizeProjectFilePath(fileName) || "download") || "download";
const fallback = basename
.replace(/[^\x20-\x7e]+/g, "_")
.replace(/["\\;\r\n]/g, "_")
.trim() || "download";
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeRfc5987ValueChars(basename)}`;
}
function encodeRfc5987ValueChars(value) {
return encodeURIComponent(value).replace(/['()*]/g, (char) =>
`%${char.charCodeAt(0).toString(16).toUpperCase()}`
);
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -195,7 +195,7 @@ ipcMain.handle(IPC_CHANNELS.appOpenProfile, async (_event, request: ProfileOpenR
const config = profile.agent === CLAUDE_DESIGN_PLUGIN_ID
? withClaudeDesignRuntimePluginConfig(syncedClaudeAppConfig.config)
: syncedClaudeAppConfig.config;
const status = await gatewayService.start(config);
const status = await gatewayService.ensureStarted(config);
if (status.state !== "running") {
throw new Error(status.lastError || "CCR gateway did not start.");
}
@ -366,7 +366,7 @@ ipcMain.handle(IPC_CHANNELS.appRestartGateway, async () => {
ipcMain.handle(IPC_CHANNELS.appStartGateway, async () => {
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
const config = syncedClaudeAppConfig.config;
const status = await gatewayService.start(config);
const status = await gatewayService.ensureStarted(config);
await applyProfileIfServiceRunning(config, status);
await builtInBrowserService.syncProxy(config);
return status;

View file

@ -229,7 +229,7 @@ function startConfiguredServices(reason: string): Promise<void> {
} catch (error) {
console.error(`Failed to sync launch-at-login setting during ${reason}: ${formatError(error)}`);
}
const status = await gatewayService.start(config);
const status = await gatewayService.ensureStarted(config);
if (status.state === "error") {
console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
}

View file

@ -196,7 +196,70 @@ test("Claude Design mock covers local frontend project runtime routes", async ()
assert.equal(typeof events.stream, "function");
});
function fakeClaudeDesignRuntime() {
test("Claude Design downloads return concrete files or a project zip", async () => {
const projectId = "project-download-test";
const pngBody = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]);
const runtime = fakeClaudeDesignRuntime([
{
body: Buffer.from("<!doctype html><title>Test</title>", "utf8"),
contentType: "text/html; charset=utf-8",
path: "index.html",
projectId
},
{
body: pngBody,
contentType: "image/png",
path: "分享大纲.png",
projectId
}
]);
const image = await claudeDesignPlugin.__test.routeMockRequest(
runtime,
"GET",
new URL("http://127.0.0.1/v1/design/projects/project-download-test/download?path=%E5%88%86%E4%BA%AB%E5%A4%A7%E7%BA%B2.png"),
{ headers: {} },
Buffer.alloc(0)
);
assert.equal(image.status, 200);
assert.equal(image.headers["content-type"], "image/png");
assert.match(image.headers["content-disposition"], /attachment/);
assert.match(image.headers["content-disposition"], /filename\*=UTF-8''%E5%88%86%E4%BA%AB%E5%A4%A7%E7%BA%B2\.png/);
assert.deepEqual(image.body, pngBody);
const archive = await claudeDesignPlugin.__test.routeMockRequest(
runtime,
"GET",
new URL("http://127.0.0.1/v1/design/projects/project-download-test/download"),
{ headers: {} },
Buffer.alloc(0)
);
assert.equal(archive.status, 200);
assert.equal(archive.headers["content-type"], "application/zip");
assert.match(archive.headers["content-disposition"], /project-download-test\.zip/);
assert.deepEqual(archive.body.subarray(0, 4), Buffer.from([0x50, 0x4b, 0x03, 0x04]));
assert.match(archive.body.toString("utf8"), /index\.html/);
assert.match(archive.body.toString("utf8"), /分享大纲\.png/);
});
type FakeProjectFile = {
body: Buffer;
contentType: string;
path: string;
projectId: string;
};
function fakeClaudeDesignRuntime(files: FakeProjectFile[] = []) {
const fileRows = files.map((file, index) => ({
body_base64: file.body.toString("base64"),
content_type: file.contentType,
created_at: "2026-08-10T00:00:00.000Z",
path: file.path,
project_id: file.projectId,
updated_at: `2026-08-10T00:00:${String(index).padStart(2, "0")}.000Z`,
version: 1
}));
return {
assetDir: "",
frontendUrl: "http://127.0.0.1:6173/design",
@ -209,19 +272,28 @@ function fakeClaudeDesignRuntime() {
pluginId: "claude-design",
store: {
database: {
prepare() {
return {
bind() {
return this;
prepare(sql: string) {
let rows = fakeClaudeDesignRows(sql, [], fileRows);
let index = -1;
const statement = {
bind(params: unknown[]) {
rows = fakeClaudeDesignRows(sql, Array.from(params || []), fileRows);
index = -1;
return statement;
},
free() {},
getAsObject() {
return {};
return rows[index] || {};
},
step() {
return false;
if (index + 1 >= rows.length) {
return false;
}
index += 1;
return true;
}
};
return statement;
},
run() {}
},
@ -229,3 +301,20 @@ function fakeClaudeDesignRuntime() {
}
};
}
function fakeClaudeDesignRows(sql: string, params: unknown[], fileRows: Record<string, unknown>[]) {
if (!sql.includes("FROM claude_design_files")) {
return [];
}
if (sql.includes("WHERE project_id = ? AND path = ?")) {
const [projectId, filePath] = params;
return fileRows.filter((row) => row.project_id === projectId && row.path === filePath);
}
if (sql.includes("WHERE project_id = ?")) {
const [projectId] = params;
return fileRows.filter((row) => row.project_id === projectId).sort((left, right) =>
String(left.path).localeCompare(String(right.path))
);
}
return [];
}