diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index df8c4fe5..ee265d6f 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -1809,6 +1809,7 @@ export type GatewayStatus = { coreEndpoint: string; coreManagedExternally?: boolean; endpoint: string; + gatewayManagedExternally?: boolean; lastError?: string; lastStartedAt?: string; networkEndpoints: GatewayNetworkEndpoint[]; diff --git a/packages/core/src/gateway/application/gateway-service.ts b/packages/core/src/gateway/application/gateway-service.ts index f31b70cc..d41a68c5 100644 --- a/packages/core/src/gateway/application/gateway-service.ts +++ b/packages/core/src/gateway/application/gateway-service.ts @@ -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 { + 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 { 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 { 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 { diff --git a/packages/core/src/gateway/existing-gateway-probe.ts b/packages/core/src/gateway/existing-gateway-probe.ts new file mode 100644 index 00000000..216ce083 --- /dev/null +++ b/packages/core/src/gateway/existing-gateway-probe.ts @@ -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 +): Promise { + 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): 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 { + 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): 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(); + 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 { + 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 { + return typeof value === "object" && value !== null; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index 052c21d9..9b825897 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -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) { diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index 119e3843..007dcaa8 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -366,7 +366,7 @@ const rpcHandlers: Record = { 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 = { 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 { } 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}`); } diff --git a/packages/core/test/integration/observability/request-log-store.test.mjs b/packages/core/test/integration/observability/request-log-store.test.mjs index d78f1a44..fc906171 100644 --- a/packages/core/test/integration/observability/request-log-store.test.mjs +++ b/packages/core/test/integration/observability/request-log-store.test.mjs @@ -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 { diff --git a/packages/core/test/unit/web/web-management-server.test.mjs b/packages/core/test/unit/web/web-management-server.test.mjs index b0345925..cae58feb 100644 --- a/packages/core/test/unit/web/web-management-server.test.mjs +++ b/packages/core/test/unit/web/web-management-server.test.mjs @@ -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(); + }); + }); +} diff --git a/packages/electron/bundled-plugins/claude-design/index.cjs b/packages/electron/bundled-plugins/claude-design/index.cjs index cc8b1c15..b2ffc192 100644 --- a/packages/electron/bundled-plugins/claude-design/index.cjs +++ b/packages/electron/bundled-plugins/claude-design/index.cjs @@ -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() { `; } +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, "&").replace(/"/g, """).replace(/ + `%${char.charCodeAt(0).toString(16).toUpperCase()}` + ); +} + function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 09eedad9..226d0751 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -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; diff --git a/packages/electron/src/main/main-app.ts b/packages/electron/src/main/main-app.ts index 1c0b5439..979112d3 100644 --- a/packages/electron/src/main/main-app.ts +++ b/packages/electron/src/main/main-app.ts @@ -229,7 +229,7 @@ function startConfiguredServices(reason: string): Promise { } 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}`); } diff --git a/packages/electron/test/unit/claude-design-plugin-assets.test.ts b/packages/electron/test/unit/claude-design-plugin-assets.test.ts index 011fd51e..9fc285c7 100644 --- a/packages/electron/test/unit/claude-design-plugin-assets.test.ts +++ b/packages/electron/test/unit/claude-design-plugin-assets.test.ts @@ -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("Test", "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[]) { + 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 []; +}