fix(gateway): land #38725 from @ademczuk

Source: #38725 / 533ff3e70b by @ademczuk.
Thanks @ademczuk.

Co-authored-by: ademczuk <andrew.demczuk@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-07 22:35:38 +00:00
parent 8ca326caa9
commit 3a74dc00bf
5 changed files with 140 additions and 4 deletions

View File

@@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai
- Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.
- Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
- Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false `device token mismatch` disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.
- Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.
- Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.
- Agents/transcript policy: set `preserveSignatures` to Anthropic-only handling in `resolveTranscriptPolicy` so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.
- Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin.

View File

@@ -367,6 +367,58 @@ describe("gateway auth", () => {
expect(limiter.check).toHaveBeenCalledWith(undefined, "custom-scope");
expect(limiter.recordFailure).toHaveBeenCalledWith(undefined, "custom-scope");
});
it("does not record rate-limit failure for missing token (misconfigured client, not brute-force)", async () => {
const limiter = createLimiterSpy();
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: null,
rateLimiter: limiter,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("token_missing");
expect(limiter.recordFailure).not.toHaveBeenCalled();
});
it("does not record rate-limit failure for missing password (misconfigured client, not brute-force)", async () => {
const limiter = createLimiterSpy();
const res = await authorizeGatewayConnect({
auth: { mode: "password", password: "secret", allowTailscale: false },
connectAuth: null,
rateLimiter: limiter,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("password_missing");
expect(limiter.recordFailure).not.toHaveBeenCalled();
});
it("still records rate-limit failure for wrong token (brute-force attempt)", async () => {
const limiter = createLimiterSpy();
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: { token: "wrong" },
rateLimiter: limiter,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("token_mismatch");
expect(limiter.recordFailure).toHaveBeenCalled();
});
it("still records rate-limit failure for wrong password (brute-force attempt)", async () => {
const limiter = createLimiterSpy();
const res = await authorizeGatewayConnect({
auth: { mode: "password", password: "secret", allowTailscale: false },
connectAuth: { password: "wrong" },
rateLimiter: limiter,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("password_mismatch");
expect(limiter.recordFailure).toHaveBeenCalled();
});
});
describe("trusted-proxy auth", () => {

View File

@@ -439,7 +439,9 @@ export async function authorizeGatewayConnect(
return { ok: false, reason: "token_missing_config" };
}
if (!connectAuth?.token) {
limiter?.recordFailure(ip, rateLimitScope);
// Don't burn rate-limit slots for missing credentials — the client
// simply hasn't provided a token yet (e.g. bare browser open).
// Only actual *wrong* credentials should count as failures.
return { ok: false, reason: "token_missing" };
}
if (!safeEqualSecret(connectAuth.token, auth.token)) {
@@ -456,7 +458,7 @@ export async function authorizeGatewayConnect(
return { ok: false, reason: "password_missing_config" };
}
if (!password) {
limiter?.recordFailure(ip, rateLimitScope);
// Same as token_missing — don't penalize absent credentials.
return { ok: false, reason: "password_missing" };
}
if (!safeEqualSecret(password, auth.password)) {

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { type GatewayErrorInfo, isNonRecoverableAuthError } from "../../ui/src/ui/gateway.ts";
import { ConnectErrorDetailCodes } from "./protocol/connect-error-details.js";
function makeError(detailCode: string): GatewayErrorInfo {
return { code: "connect_failed", message: "auth failed", details: { code: detailCode } };
}
describe("isNonRecoverableAuthError", () => {
it("returns false for undefined error (normal disconnect)", () => {
expect(isNonRecoverableAuthError(undefined)).toBe(false);
});
it("returns false for errors without detail codes (network issues)", () => {
expect(isNonRecoverableAuthError({ code: "connect_failed", message: "timeout" })).toBe(false);
});
it("blocks reconnect for AUTH_TOKEN_MISSING (misconfigured client)", () => {
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISSING))).toBe(
true,
);
});
it("blocks reconnect for AUTH_PASSWORD_MISSING", () => {
expect(
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING)),
).toBe(true);
});
it("blocks reconnect for AUTH_PASSWORD_MISMATCH (wrong password won't self-correct)", () => {
expect(
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH)),
).toBe(true);
});
it("blocks reconnect for AUTH_RATE_LIMITED (reconnecting burns more slots)", () => {
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_RATE_LIMITED))).toBe(
true,
);
});
it("allows reconnect for AUTH_TOKEN_MISMATCH (device-token fallback flow)", () => {
// Browser client fallback: stale device token → mismatch → sendConnect() clears it →
// next reconnect uses opts.token (shared gateway token). Blocking here breaks recovery.
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH))).toBe(
false,
);
});
it("allows reconnect for unrecognized detail codes (future-proof)", () => {
expect(isNonRecoverableAuthError(makeError("SOME_FUTURE_CODE"))).toBe(false);
});
});

View File

@@ -5,7 +5,10 @@ import {
type GatewayClientMode,
type GatewayClientName,
} from "../../../src/gateway/protocol/client-info.js";
import { readConnectErrorDetailCode } from "../../../src/gateway/protocol/connect-error-details.js";
import {
ConnectErrorDetailCodes,
readConnectErrorDetailCode,
} from "../../../src/gateway/protocol/connect-error-details.js";
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts";
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity.ts";
import { generateUUID } from "./uuid.ts";
@@ -50,6 +53,29 @@ export function resolveGatewayErrorDetailCode(
return readConnectErrorDetailCode(error?.details);
}
/**
* Auth errors that won't resolve without user action — don't auto-reconnect.
*
* NOTE: AUTH_TOKEN_MISMATCH is intentionally NOT included here because the
* browser client has a device-token fallback flow: a stale cached device token
* triggers a mismatch, sendConnect() clears it, and the next reconnect retries
* with opts.token (the shared gateway token). Blocking reconnect on mismatch
* would break that fallback. The rate limiter still catches persistent wrong
* tokens after N failures → AUTH_RATE_LIMITED stops the loop.
*/
export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): boolean {
if (!error) {
return false;
}
const code = resolveGatewayErrorDetailCode(error);
return (
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED
);
}
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
@@ -135,7 +161,9 @@ export class GatewayBrowserClient {
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason, error: connectError });
this.scheduleReconnect();
if (!isNonRecoverableAuthError(connectError)) {
this.scheduleReconnect();
}
});
this.ws.addEventListener("error", () => {
// ignored; close handler will fire