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:
jesieleo 2026-07-04 18:52:03 +08:00
parent 87a666df21
commit ee44357a34

View file

@ -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,