mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
fix(gateway): prevent three crashes from non-ASCII names, client disconnect, and immutable headers
Three independent crashes in src/server/gateway/service.ts, each surfacing
as either a 502 or an Electron Uncaught Exception dialog.
== 1. Non-ASCII provider/model names -> 502 ByteString error ==
When a provider name or model selector contains non-ASCII characters
(CJK, emoji, accented Latin — e.g. "小米mimo/mimo-v2.5-pro-ultraspeed"),
every routed request fails with:
Unexpected status 502 Bad Gateway: Cannot convert argument to a
ByteString because the character at index 0 has a value of 23567
which is greater than 255.
Several observability headers derived their values from raw,
unsanitized user-facing strings (x-ccr-logical-provider, x-ccr-routed-
model, x-ccr-route-reason, the model-discovery/app-rewrite/codex-patch
diagnostics, x-ccr-fallback-model). Node/undici enforces the HTTP
ByteString contract (code point <= 255), so any non-ASCII char throws a
TypeError that bubbles to the catch-all as 502.
Fix: add sanitizeHeaderValue() that replaces characters outside the
printable ASCII range with "-", and wrap all ten affected assignments.
The core gateway (@the-next-ai/ai-gateway) does not read any x-ccr-*
header (verified), so values stay observability-only.
== 2. Client disconnect mid-stream -> Uncaught Exception ==
When a client closes the SSE connection mid-stream — normal during
local tool execution (file creation) — the gateway emitted an Uncaught
Exception that crashed the Electron main process:
Uncaught Exception:
Error: Client connection closed before response completed.
Two gaps let the error escape:
- responseBody.once("error", ...) consumes only the FIRST error, but
a disconnect triggers several in quick succession (abort, destroy,
EPIPE). Remaining errors re-throw as unhandled.
- The destination ServerResponse had NO "error" listener, so write
failures on a closed socket bubbled up directly.
Fix: once("error") -> on("error") on the response body so every error
is logged via writeStreamLog; add response.on("error") that marks the
disconnect and aborts upstream. Only the spurious crash is removed —
disconnects are still recorded and upstream still aborted.
== 3. Codex apply_patch -> 502 "immutable" ==
Creating a file via Codex (apply_patch) fails with:
Unexpected status 502 Bad Gateway: immutable
Undici's fetch Response.headers can be immutable; mergeFallbackResponse
Headers returns it as-is when no fallback occurred, and the codex patch
bridge then calls responseHeaders.delete("content-length"), which
throws TypeError: immutable.
Fix: wrap the merged headers in new Headers(...) to guarantee a mutable
copy regardless of upstream immutability or merge early-return.
== Verification ==
- sanitizeHeaderValue: "小米mimo/mimo-v2.5-pro-ultraspeed" ->
"mimo/mimo-v2.5-pro-ultraspeed" (ASCII-safe); real repro confirmed.
- Disconnect crash: previously fired on every file-creating tool call;
after fix, no crash, model continues normally.
- immutable 502: previously fired on every apply_patch; after fix,
files are created successfully.
- ASCII-only configs unaffected (sanitization is a no-op; error
handlers only affect the failure path).
This commit is contained in:
parent
87a666df21
commit
ee44357a34
1 changed files with 42 additions and 12 deletions
|
|
@ -635,7 +635,7 @@ class GatewayService {
|
|||
const client = inferGatewayClient(apiKey, request.headers);
|
||||
const cursorCompatPreparation = prepareCursorOpenAICompatChatBody(this.config, client, method, path, requestBody);
|
||||
if (cursorCompatPreparation) {
|
||||
headers["x-ccr-cursor-openai-compat"] = cursorCompatPreparation.diagnostic;
|
||||
headers["x-ccr-cursor-openai-compat"] = sanitizeHeaderValue(cursorCompatPreparation.diagnostic);
|
||||
}
|
||||
let bodyToForward: Buffer | undefined = cursorCompatPreparation?.body ?? requestBody;
|
||||
let routeFallback = this.config.Router.fallback;
|
||||
|
|
@ -643,12 +643,12 @@ class GatewayService {
|
|||
let codexApplyPatchBridgeActive = false;
|
||||
const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward);
|
||||
if (claudeModelRewrite) {
|
||||
headers["x-ccr-claude-model-discovery"] = claudeModelRewrite.diagnostic;
|
||||
headers["x-ccr-claude-model-discovery"] = sanitizeHeaderValue(claudeModelRewrite.diagnostic);
|
||||
bodyToForward = claudeModelRewrite.body;
|
||||
}
|
||||
const claudeAppModelRewrite = prepareClaudeAppFallbackModelRequest(this.config, method, path, bodyToForward);
|
||||
if (claudeAppModelRewrite) {
|
||||
headers["x-ccr-claude-app-model-rewrite"] = claudeAppModelRewrite.diagnostic;
|
||||
headers["x-ccr-claude-app-model-rewrite"] = sanitizeHeaderValue(claudeAppModelRewrite.diagnostic);
|
||||
bodyToForward = claudeAppModelRewrite.body;
|
||||
routedModel = claudeAppModelRewrite.routedModel;
|
||||
}
|
||||
|
|
@ -678,6 +678,16 @@ class GatewayService {
|
|||
upstreamAbortController.abort(new Error(clientDisconnectMessage));
|
||||
onClientDisconnect?.();
|
||||
});
|
||||
response.on("error", (error) => {
|
||||
// Client-side write failures (EPIPE / ECONNRESET when the client closes
|
||||
// mid-stream, common during tool execution) must not crash the main
|
||||
// process as an Uncaught Exception. Swallow them here; the close handler
|
||||
// above already records the disconnect via writeStreamLog.
|
||||
if (!clientDisconnected) {
|
||||
clientDisconnected = true;
|
||||
upstreamAbortController.abort(new Error(clientDisconnectMessage));
|
||||
}
|
||||
});
|
||||
|
||||
const writeRequestLog = (
|
||||
statusCode: number,
|
||||
|
|
@ -743,10 +753,10 @@ class GatewayService {
|
|||
});
|
||||
const serialized = Buffer.from(`${JSON.stringify(routed.body)}\n`, "utf8");
|
||||
headers["content-type"] = "application/json";
|
||||
headers["x-ccr-route-reason"] = routed.decision.reason;
|
||||
headers["x-ccr-route-reason"] = sanitizeHeaderValue(routed.decision.reason);
|
||||
routeFallback = routed.decision.fallback ?? routeFallback;
|
||||
if (routed.decision.model) {
|
||||
headers["x-ccr-routed-model"] = routed.decision.model;
|
||||
headers["x-ccr-routed-model"] = sanitizeHeaderValue(routed.decision.model);
|
||||
routedModel = routed.decision.model;
|
||||
}
|
||||
bodyToForward = serialized;
|
||||
|
|
@ -761,10 +771,10 @@ class GatewayService {
|
|||
});
|
||||
const serialized = Buffer.from(`${JSON.stringify(routed.body)}\n`, "utf8");
|
||||
headers["content-type"] = "application/json";
|
||||
headers["x-ccr-route-reason"] = routed.decision.reason;
|
||||
headers["x-ccr-route-reason"] = sanitizeHeaderValue(routed.decision.reason);
|
||||
routeFallback = routed.decision.fallback ?? routeFallback;
|
||||
if (routed.decision.model) {
|
||||
headers["x-ccr-routed-model"] = routed.decision.model;
|
||||
headers["x-ccr-routed-model"] = sanitizeHeaderValue(routed.decision.model);
|
||||
routedModel = routed.decision.model;
|
||||
}
|
||||
bodyToForward = serialized;
|
||||
|
|
@ -781,7 +791,7 @@ class GatewayService {
|
|||
if (codexApplyPatchBridgeRequest) {
|
||||
bodyToForward = codexApplyPatchBridgeRequest.body;
|
||||
codexApplyPatchBridgeActive = true;
|
||||
headers["x-ccr-codex-patch-bridge"] = codexApplyPatchBridgeRequest.diagnostic;
|
||||
headers["x-ccr-codex-patch-bridge"] = sanitizeHeaderValue(codexApplyPatchBridgeRequest.diagnostic);
|
||||
headers["content-type"] = "application/json";
|
||||
}
|
||||
|
||||
|
|
@ -906,7 +916,13 @@ class GatewayService {
|
|||
bodyToForward = upstreamResult.attempt.body ?? bodyToForward;
|
||||
routedModel = upstreamResult.attempt.model ?? routedModel;
|
||||
const responseHeaders = rewriteCapabilityResponseHeaders(
|
||||
mergeFallbackResponseHeaders(upstreamResponseHeaders(upstreamResult), upstreamResult),
|
||||
// Copy into a mutable Headers instance: upstream fetch Response.headers
|
||||
// can be immutable (TypeError: immutable on .delete/.set), and
|
||||
// mergeFallbackResponseHeaders returns the original object as-is when
|
||||
// no fallback occurred. Codex apply_patch / web-search paths call
|
||||
// .delete("content-length") below, which would otherwise throw and
|
||||
// surface as a 502.
|
||||
new Headers(mergeFallbackResponseHeaders(upstreamResponseHeaders(upstreamResult), upstreamResult)),
|
||||
this.config
|
||||
);
|
||||
const upstreamResponse = upstreamResult.response;
|
||||
|
|
@ -995,7 +1011,7 @@ class GatewayService {
|
|||
writeStreamLog();
|
||||
}
|
||||
});
|
||||
responseBody.once("error", (error) => {
|
||||
responseBody.on("error", (error) => {
|
||||
streamDetectedError ??= sseErrorDetector.finish();
|
||||
writeStreamLog(clientDisconnected ? clientDisconnectMessage : formatError(error));
|
||||
});
|
||||
|
|
@ -5592,7 +5608,7 @@ function prepareUpstreamCredentialAttempt(input: {
|
|||
const headers: Record<string, string> = {
|
||||
...input.headers,
|
||||
"x-target-providers": selection.credentials.map((candidate) => candidate.internalName).join(","),
|
||||
"x-ccr-logical-provider": target.provider.name,
|
||||
"x-ccr-logical-provider": providerRuntimeId(target.provider),
|
||||
"x-ccr-provider-credential-chain": selection.credentials.map((candidate) => candidate.credentialId).join(",")
|
||||
};
|
||||
delete headers["x-target-provider"];
|
||||
|
|
@ -6002,7 +6018,7 @@ function mergeFallbackResponseHeaders(headers: Headers, result: UpstreamFetchRes
|
|||
merged.set("x-ccr-fallback-delays-ms", formatFallbackDelays(result.failedAttempts));
|
||||
}
|
||||
if (result.attempt.model) {
|
||||
merged.set("x-ccr-fallback-model", result.attempt.model);
|
||||
merged.set("x-ccr-fallback-model", sanitizeHeaderValue(result.attempt.model));
|
||||
}
|
||||
}
|
||||
if (credentialIds.length) {
|
||||
|
|
@ -6379,6 +6395,20 @@ function sanitizeProviderHeaderId(value: string | undefined): string | undefined
|
|||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function sanitizeHeaderValue(value: unknown): string {
|
||||
// HTTP header values must be ByteString (code point <= 255). Values derived
|
||||
// from user-facing names — model selectors like "小米mimo/...", provider
|
||||
// names, route reasons — can contain non-ASCII characters that crash Node's
|
||||
// fetch/undici with "Cannot convert argument to a ByteString" (surfaced as
|
||||
// 502). Normalize to ASCII while preserving case and printable punctuation.
|
||||
const text = typeof value === "string" && value.trim() ? value : "unknown";
|
||||
const sanitized = text
|
||||
.replace(/[^\x20-\x7E]+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return sanitized || "unknown";
|
||||
}
|
||||
|
||||
function providerCredentialInternalName(
|
||||
provider: GatewayProviderConfig,
|
||||
protocol: GatewayProviderProtocol,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue