fix(gateway+server): improve diagnostics for origin/auth rejection and silent upstream reject (#125)

* fix(gateway): don't surface protocol-mismatch hint when server rejects origin/auth

The heuristic that flags INVALID_REQUEST errors mentioning minProtocol
or maxProtocol as a possible protocol mismatch can fire on any schema
validation error that happens to mention those field names, including
rejections whose real cause is origin allowlist, missing device
identity, or upstream policy.

Prefer the structured details.code when the gateway provides one, and
treat known non-protocol codes (CONTROL_UI_ORIGIN_NOT_ALLOWED,
CONTROL_UI_DEVICE_IDENTITY_REQUIRED, UPSTREAM_NOT_ALLOWED) as
non-mismatches so the UI does not send operators down the wrong
diagnostic path.

* fix(server): log explicit warning when gateway proxy rejects an upstream

Previously isUpstreamAllowed returned false silently when
UPSTREAM_ALLOWLIST was empty in production, when the upstream host
was not in the allowlist, or when the URL could not be parsed. The
caller closes the connection without forwarding anything to the
browser, which sees an opaque 'WebSocket closed before the
connection is established' and the server logs contain no hint of
what happened.

Emit a console.warn in each of the three rejection branches with an
actionable message that names the missing env var or the rejected
hostname, so operators can diagnose the rejection from the server
logs without reading the proxy source.

* fix(gateway): don't bias the local-timeout hint toward a protocol mismatch

formatGatewayError unconditionally appended a hint suggesting the user
upgrade OpenClaw or switch to the Hermes adapter whenever the browser
reported a generic "timed out connecting to the gateway" Error. That
Error is raised by a local Promise.race timeout, so it carries no
information about why the upstream did not respond. Common real causes
are network reachability, nginx idle timeouts, origin allowlist,
missing UPSTREAM_ALLOWLIST in production, or credential mismatches —
none of which are addressed by upgrading the gateway.

Replace the biased suggestion with a neutral pointer to the likely
diagnostic paths so the hint helps in the common cases without
misleading away from origin/auth/policy issues.

---------

Co-authored-by: Jose Antonio Martinez <257598434+jamartineztelecoengineer84-dotcom@users.noreply.github.com>
This commit is contained in:
jamartineztelecoengineer84-dotcom
2026-04-25 14:36:42 -05:00
committed by GitHub
co-authored by Jose Antonio Martinez
parent 5f96276133
commit 51cd3a1e94
2 changed files with 48 additions and 4 deletions
+25 -3
View File
@@ -71,7 +71,15 @@ const createFrameRateLimiter = (
const isUpstreamAllowed = (url) => {
const allowlist = (process.env.UPSTREAM_ALLOWLIST || "").trim();
if (!allowlist) {
return process.env.NODE_ENV !== "production";
if (process.env.NODE_ENV === "production") {
console.warn(
"[gateway-proxy] refusing upstream connection: UPSTREAM_ALLOWLIST is " +
"empty in production. Set UPSTREAM_ALLOWLIST=host1,host2 (comma-" +
"separated hostnames) to allow specific upstream hosts."
);
return false;
}
return true;
}
try {
const parsed = new URL(url);
@@ -79,8 +87,22 @@ const isUpstreamAllowed = (url) => {
.split(",")
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
return allowed.includes(parsed.hostname.toLowerCase());
} catch {
const hostname = parsed.hostname.toLowerCase();
if (!allowed.includes(hostname)) {
console.warn(
`[gateway-proxy] refusing upstream connection to "${hostname}": host ` +
`not in UPSTREAM_ALLOWLIST (${allowlist}). Add the host to the ` +
`allowlist if you trust it.`
);
return false;
}
return true;
} catch (err) {
const reason = err && typeof err === "object" && "message" in err ? err.message : "invalid URL";
console.warn(
`[gateway-proxy] refusing upstream connection: could not parse URL ` +
`"${url}" (${reason}).`
);
return false;
}
};
+23 -1
View File
@@ -544,6 +544,23 @@ const requiresDeviceIdentityHint =
const isGatewayProtocolMismatchError = (error: GatewayResponseError) => {
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
// The gateway may provide a structured details.code alongside the
// generic INVALID_REQUEST. Known non-protocol rejection codes must
// not surface the "possible protocol mismatch" hint, since it
// misleads operators whose real problem is origin allowlist, missing
// device identity, or upstream policy.
const details = error.details;
if (details && typeof details === "object") {
const code = (details as { code?: unknown }).code;
if (typeof code === "string") {
const NON_PROTOCOL_DETAIL_CODES = new Set([
"CONTROL_UI_ORIGIN_NOT_ALLOWED",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"UPSTREAM_NOT_ALLOWED",
]);
if (NON_PROTOCOL_DETAIL_CODES.has(code)) return false;
}
}
const message = error.message.trim();
if (!message) return false;
return /minProtocol|maxProtocol/i.test(message);
@@ -573,7 +590,12 @@ const formatGatewayError = (error: unknown) => {
}
if (error instanceof Error) {
if (/timed out connecting to the gateway/i.test(error.message)) {
return `${error.message} If you are testing locally, an older OpenClaw build may be speaking an incompatible protocol. Try upgrading OpenClaw, using the Hermes adapter, or running \`npm run demo-gateway\`.`;
// A local timeout carries no information about why the upstream did
// not respond. Suggest the directions the operator can actually check,
// without biasing toward a protocol mismatch — that is only one of
// several possible root causes (network, origin allowlist, upstream
// policy, credentials, nginx idle timeout, ...).
return `${error.message} Verify that the gateway is reachable at the configured URL, that origin and credentials meet the gateway's requirements, and (if testing locally with a self-built gateway) consider \`npm run demo-gateway\` to isolate the problem.`;
}
return error.message;
}