mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 10:02:04 +00:00
fix(gateway): harden token fallback/reconnect behavior and docs (#42507)
* fix(gateway): harden token fallback and auth reconnect handling * docs(gateway): clarify auth retry and token-drift recovery * fix(gateway): tighten auth reconnect gating across clients * fix: harden gateway token retry (#42507) (thanks @joshavant)
This commit is contained in:
@@ -7,7 +7,6 @@ const wsInstances = vi.hoisted((): MockWebSocket[] => []);
|
||||
const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
|
||||
const loadDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
|
||||
const storeDeviceAuthTokenMock = vi.hoisted(() => vi.fn());
|
||||
const clearDevicePairingMock = vi.hoisted(() => vi.fn());
|
||||
const logDebugMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type WsEvent = "open" | "message" | "close" | "error";
|
||||
@@ -52,7 +51,9 @@ class MockWebSocket {
|
||||
}
|
||||
}
|
||||
|
||||
close(_code?: number, _reason?: string): void {}
|
||||
close(code?: number, reason?: string): void {
|
||||
this.emitClose(code ?? 1000, reason ?? "");
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
@@ -91,14 +92,6 @@ vi.mock("../infra/device-auth-store.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/device-pairing.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/device-pairing.js")>();
|
||||
return {
|
||||
...actual,
|
||||
clearDevicePairing: (...args: unknown[]) => clearDevicePairingMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../logger.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../logger.js")>();
|
||||
return {
|
||||
@@ -250,8 +243,6 @@ describe("GatewayClient close handling", () => {
|
||||
wsInstances.length = 0;
|
||||
clearDeviceAuthTokenMock.mockClear();
|
||||
clearDeviceAuthTokenMock.mockImplementation(() => undefined);
|
||||
clearDevicePairingMock.mockClear();
|
||||
clearDevicePairingMock.mockResolvedValue(true);
|
||||
logDebugMock.mockClear();
|
||||
});
|
||||
|
||||
@@ -266,7 +257,7 @@ describe("GatewayClient close handling", () => {
|
||||
);
|
||||
|
||||
expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({ deviceId: "dev-1", role: "operator" });
|
||||
expect(clearDevicePairingMock).toHaveBeenCalledWith("dev-1");
|
||||
expect(logDebugMock).toHaveBeenCalledWith("cleared stale device-auth token for device dev-1");
|
||||
expect(onClose).toHaveBeenCalledWith(
|
||||
1008,
|
||||
"unauthorized: DEVICE token mismatch (rotate/reissue device token)",
|
||||
@@ -289,38 +280,18 @@ describe("GatewayClient close handling", () => {
|
||||
expect(logDebugMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("failed clearing stale device-auth token"),
|
||||
);
|
||||
expect(clearDevicePairingMock).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("does not break close flow when pairing clear rejects", async () => {
|
||||
clearDevicePairingMock.mockRejectedValue(new Error("pairing store unavailable"));
|
||||
const onClose = vi.fn();
|
||||
const client = createClientWithIdentity("dev-3", onClose);
|
||||
|
||||
client.start();
|
||||
expect(() => {
|
||||
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
|
||||
}).not.toThrow();
|
||||
|
||||
await Promise.resolve();
|
||||
expect(logDebugMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("failed clearing stale device pairing"),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("does not clear auth state for non-mismatch close reasons", () => {
|
||||
const onClose = vi.fn();
|
||||
const client = createClientWithIdentity("dev-4", onClose);
|
||||
const client = createClientWithIdentity("dev-3", onClose);
|
||||
|
||||
client.start();
|
||||
getLatestWs().emitClose(1008, "unauthorized: signature invalid");
|
||||
|
||||
expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled();
|
||||
expect(clearDevicePairingMock).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: signature invalid");
|
||||
client.stop();
|
||||
});
|
||||
@@ -328,7 +299,7 @@ describe("GatewayClient close handling", () => {
|
||||
it("does not clear persisted device auth when explicit shared token is provided", () => {
|
||||
const onClose = vi.fn();
|
||||
const identity: DeviceIdentity = {
|
||||
deviceId: "dev-5",
|
||||
deviceId: "dev-4",
|
||||
privateKeyPem: "private-key", // pragma: allowlist secret
|
||||
publicKeyPem: "public-key",
|
||||
};
|
||||
@@ -343,7 +314,6 @@ describe("GatewayClient close handling", () => {
|
||||
getLatestWs().emitClose(1008, "unauthorized: device token mismatch");
|
||||
|
||||
expect(clearDeviceAuthTokenMock).not.toHaveBeenCalled();
|
||||
expect(clearDevicePairingMock).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledWith(1008, "unauthorized: device token mismatch");
|
||||
client.stop();
|
||||
});
|
||||
@@ -458,4 +428,156 @@ describe("GatewayClient connect auth payload", () => {
|
||||
});
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("retries with stored device token after shared-token mismatch on trusted endpoints", async () => {
|
||||
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "shared-token",
|
||||
});
|
||||
|
||||
client.start();
|
||||
const ws1 = getLatestWs();
|
||||
ws1.emitOpen();
|
||||
emitConnectChallenge(ws1);
|
||||
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"'));
|
||||
expect(firstConnectRaw).toBeTruthy();
|
||||
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as {
|
||||
id?: string;
|
||||
params?: { auth?: { token?: string; deviceToken?: string } };
|
||||
};
|
||||
expect(firstConnect.params?.auth?.token).toBe("shared-token");
|
||||
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
|
||||
|
||||
ws1.emitMessage(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: firstConnect.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unauthorized",
|
||||
details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
|
||||
const ws2 = getLatestWs();
|
||||
ws2.emitOpen();
|
||||
emitConnectChallenge(ws2, "nonce-2");
|
||||
expect(connectFrameFrom(ws2)).toMatchObject({
|
||||
token: "shared-token",
|
||||
deviceToken: "stored-device-token",
|
||||
});
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("retries with stored device token when server recommends retry_with_device_token", async () => {
|
||||
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "shared-token",
|
||||
});
|
||||
|
||||
client.start();
|
||||
const ws1 = getLatestWs();
|
||||
ws1.emitOpen();
|
||||
emitConnectChallenge(ws1);
|
||||
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"'));
|
||||
expect(firstConnectRaw).toBeTruthy();
|
||||
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string };
|
||||
|
||||
ws1.emitMessage(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: firstConnect.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unauthorized",
|
||||
details: { code: "AUTH_UNAUTHORIZED", recommendedNextStep: "retry_with_device_token" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(wsInstances.length).toBeGreaterThan(1), { timeout: 3_000 });
|
||||
const ws2 = getLatestWs();
|
||||
ws2.emitOpen();
|
||||
emitConnectChallenge(ws2, "nonce-2");
|
||||
expect(connectFrameFrom(ws2)).toMatchObject({
|
||||
token: "shared-token",
|
||||
deviceToken: "stored-device-token",
|
||||
});
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("does not auto-reconnect on AUTH_TOKEN_MISSING connect failures", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new GatewayClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "shared-token",
|
||||
});
|
||||
|
||||
client.start();
|
||||
const ws1 = getLatestWs();
|
||||
ws1.emitOpen();
|
||||
emitConnectChallenge(ws1);
|
||||
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"'));
|
||||
expect(firstConnectRaw).toBeTruthy();
|
||||
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string };
|
||||
|
||||
ws1.emitMessage(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: firstConnect.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unauthorized",
|
||||
details: { code: "AUTH_TOKEN_MISSING" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(wsInstances).toHaveLength(1);
|
||||
client.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not auto-reconnect on token mismatch when retry is not trusted", async () => {
|
||||
vi.useFakeTimers();
|
||||
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
|
||||
const client = new GatewayClient({
|
||||
url: "wss://gateway.example.com:18789",
|
||||
token: "shared-token",
|
||||
});
|
||||
|
||||
client.start();
|
||||
const ws1 = getLatestWs();
|
||||
ws1.emitOpen();
|
||||
emitConnectChallenge(ws1);
|
||||
const firstConnectRaw = ws1.sent.find((frame) => frame.includes('"method":"connect"'));
|
||||
expect(firstConnectRaw).toBeTruthy();
|
||||
const firstConnect = JSON.parse(firstConnectRaw ?? "{}") as { id?: string };
|
||||
|
||||
ws1.emitMessage(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: firstConnect.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unauthorized",
|
||||
details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(wsInstances).toHaveLength(1);
|
||||
client.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
} from "../infra/device-identity.js";
|
||||
import { clearDevicePairing } from "../infra/device-pairing.js";
|
||||
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
@@ -23,7 +22,13 @@ import {
|
||||
} from "../utils/message-channel.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
|
||||
import { isSecureWebSocketUrl } from "./net.js";
|
||||
import { isLoopbackHost, isSecureWebSocketUrl } from "./net.js";
|
||||
import {
|
||||
ConnectErrorDetailCodes,
|
||||
readConnectErrorDetailCode,
|
||||
readConnectErrorRecoveryAdvice,
|
||||
type ConnectErrorRecoveryAdvice,
|
||||
} from "./protocol/connect-error-details.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
type EventFrame,
|
||||
@@ -41,6 +46,24 @@ type Pending = {
|
||||
expectFinal: boolean;
|
||||
};
|
||||
|
||||
type GatewayClientErrorShape = {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
class GatewayClientRequestError extends Error {
|
||||
readonly gatewayCode: string;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(error: GatewayClientErrorShape) {
|
||||
super(error.message ?? "gateway request failed");
|
||||
this.name = "GatewayClientRequestError";
|
||||
this.gatewayCode = error.code ?? "UNAVAILABLE";
|
||||
this.details = error.details;
|
||||
}
|
||||
}
|
||||
|
||||
export type GatewayClientOptions = {
|
||||
url?: string; // ws://127.0.0.1:18789
|
||||
connectDelayMs?: number;
|
||||
@@ -93,6 +116,9 @@ export class GatewayClient {
|
||||
private connectNonce: string | null = null;
|
||||
private connectSent = false;
|
||||
private connectTimer: NodeJS.Timeout | null = null;
|
||||
private pendingDeviceTokenRetry = false;
|
||||
private deviceTokenRetryBudgetUsed = false;
|
||||
private pendingConnectErrorDetailCode: string | null = null;
|
||||
// Track last tick to detect silent stalls.
|
||||
private lastTick: number | null = null;
|
||||
private tickIntervalMs = 30_000;
|
||||
@@ -184,6 +210,8 @@ export class GatewayClient {
|
||||
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
|
||||
this.ws.on("close", (code, reason) => {
|
||||
const reasonText = rawDataToString(reason);
|
||||
const connectErrorDetailCode = this.pendingConnectErrorDetailCode;
|
||||
this.pendingConnectErrorDetailCode = null;
|
||||
this.ws = null;
|
||||
// Clear persisted device auth state only when device-token auth was active.
|
||||
// Shared token/password failures can return the same close reason but should
|
||||
@@ -199,9 +227,6 @@ export class GatewayClient {
|
||||
const role = this.opts.role ?? "operator";
|
||||
try {
|
||||
clearDeviceAuthToken({ deviceId, role });
|
||||
void clearDevicePairing(deviceId).catch((err) => {
|
||||
logDebug(`failed clearing stale device pairing for device ${deviceId}: ${String(err)}`);
|
||||
});
|
||||
logDebug(`cleared stale device-auth token for device ${deviceId}`);
|
||||
} catch (err) {
|
||||
logDebug(
|
||||
@@ -210,6 +235,10 @@ export class GatewayClient {
|
||||
}
|
||||
}
|
||||
this.flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
|
||||
if (this.shouldPauseReconnectAfterAuthFailure(connectErrorDetailCode)) {
|
||||
this.opts.onClose?.(code, reasonText);
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
this.opts.onClose?.(code, reasonText);
|
||||
});
|
||||
@@ -223,6 +252,9 @@ export class GatewayClient {
|
||||
|
||||
stop() {
|
||||
this.closed = true;
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
this.pendingConnectErrorDetailCode = null;
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
@@ -253,11 +285,20 @@ export class GatewayClient {
|
||||
const storedToken = this.opts.deviceIdentity
|
||||
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
|
||||
: null;
|
||||
const shouldUseDeviceRetryToken =
|
||||
this.pendingDeviceTokenRetry &&
|
||||
!explicitDeviceToken &&
|
||||
Boolean(explicitGatewayToken) &&
|
||||
Boolean(storedToken) &&
|
||||
this.isTrustedDeviceRetryEndpoint();
|
||||
if (shouldUseDeviceRetryToken) {
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
}
|
||||
// Keep shared gateway credentials explicit. Persisted per-device tokens only
|
||||
// participate when no explicit shared token/password is provided.
|
||||
const resolvedDeviceToken =
|
||||
explicitDeviceToken ??
|
||||
(!(explicitGatewayToken || this.opts.password?.trim())
|
||||
(shouldUseDeviceRetryToken || !(explicitGatewayToken || this.opts.password?.trim())
|
||||
? (storedToken ?? undefined)
|
||||
: undefined);
|
||||
// Legacy compatibility: keep `auth.token` populated for device-token auth when
|
||||
@@ -327,6 +368,9 @@ export class GatewayClient {
|
||||
|
||||
void this.request<HelloOk>("connect", params)
|
||||
.then((helloOk) => {
|
||||
this.pendingDeviceTokenRetry = false;
|
||||
this.deviceTokenRetryBudgetUsed = false;
|
||||
this.pendingConnectErrorDetailCode = null;
|
||||
const authInfo = helloOk?.auth;
|
||||
if (authInfo?.deviceToken && this.opts.deviceIdentity) {
|
||||
storeDeviceAuthToken({
|
||||
@@ -346,6 +390,19 @@ export class GatewayClient {
|
||||
this.opts.onHelloOk?.(helloOk);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.pendingConnectErrorDetailCode =
|
||||
err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null;
|
||||
const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({
|
||||
error: err,
|
||||
explicitGatewayToken,
|
||||
resolvedDeviceToken,
|
||||
storedToken: storedToken ?? undefined,
|
||||
});
|
||||
if (shouldRetryWithDeviceToken) {
|
||||
this.pendingDeviceTokenRetry = true;
|
||||
this.deviceTokenRetryBudgetUsed = true;
|
||||
this.backoffMs = Math.min(this.backoffMs, 250);
|
||||
}
|
||||
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
|
||||
const msg = `gateway connect failed: ${String(err)}`;
|
||||
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) {
|
||||
@@ -357,6 +414,86 @@ export class GatewayClient {
|
||||
});
|
||||
}
|
||||
|
||||
private shouldPauseReconnectAfterAuthFailure(detailCode: string | null): boolean {
|
||||
if (!detailCode) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
|
||||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
|
||||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
|
||||
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
|
||||
detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
|
||||
detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
|
||||
detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (detailCode !== ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) {
|
||||
return false;
|
||||
}
|
||||
if (this.pendingDeviceTokenRetry) {
|
||||
return false;
|
||||
}
|
||||
// If the endpoint is not trusted for retry, mismatch is terminal until operator action.
|
||||
if (!this.isTrustedDeviceRetryEndpoint()) {
|
||||
return true;
|
||||
}
|
||||
// Pause mismatch reconnect loops once the one-shot device-token retry is consumed.
|
||||
return this.deviceTokenRetryBudgetUsed;
|
||||
}
|
||||
|
||||
private shouldRetryWithStoredDeviceToken(params: {
|
||||
error: unknown;
|
||||
explicitGatewayToken?: string;
|
||||
storedToken?: string;
|
||||
resolvedDeviceToken?: string;
|
||||
}): boolean {
|
||||
if (this.deviceTokenRetryBudgetUsed) {
|
||||
return false;
|
||||
}
|
||||
if (params.resolvedDeviceToken) {
|
||||
return false;
|
||||
}
|
||||
if (!params.explicitGatewayToken || !params.storedToken) {
|
||||
return false;
|
||||
}
|
||||
if (!this.isTrustedDeviceRetryEndpoint()) {
|
||||
return false;
|
||||
}
|
||||
if (!(params.error instanceof GatewayClientRequestError)) {
|
||||
return false;
|
||||
}
|
||||
const detailCode = readConnectErrorDetailCode(params.error.details);
|
||||
const advice: ConnectErrorRecoveryAdvice = readConnectErrorRecoveryAdvice(params.error.details);
|
||||
const retryWithDeviceTokenRecommended =
|
||||
advice.recommendedNextStep === "retry_with_device_token";
|
||||
return (
|
||||
advice.canRetryWithDeviceToken === true ||
|
||||
retryWithDeviceTokenRecommended ||
|
||||
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH
|
||||
);
|
||||
}
|
||||
|
||||
private isTrustedDeviceRetryEndpoint(): boolean {
|
||||
const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const protocol =
|
||||
parsed.protocol === "https:"
|
||||
? "wss:"
|
||||
: parsed.protocol === "http:"
|
||||
? "ws:"
|
||||
: parsed.protocol;
|
||||
if (isLoopbackHost(parsed.hostname)) {
|
||||
return true;
|
||||
}
|
||||
return protocol === "wss:" && Boolean(this.opts.tlsFingerprint?.trim());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(raw: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
@@ -402,7 +539,13 @@ export class GatewayClient {
|
||||
if (parsed.ok) {
|
||||
pending.resolve(parsed.payload);
|
||||
} else {
|
||||
pending.reject(new Error(parsed.error?.message ?? "unknown error"));
|
||||
pending.reject(
|
||||
new GatewayClientRequestError({
|
||||
code: parsed.error?.code,
|
||||
message: parsed.error?.message ?? "unknown error",
|
||||
details: parsed.error?.details,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
42
src/gateway/protocol/connect-error-details.test.ts
Normal file
42
src/gateway/protocol/connect-error-details.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
readConnectErrorDetailCode,
|
||||
readConnectErrorRecoveryAdvice,
|
||||
} from "./connect-error-details.js";
|
||||
|
||||
describe("readConnectErrorDetailCode", () => {
|
||||
it("reads structured detail codes", () => {
|
||||
expect(readConnectErrorDetailCode({ code: "AUTH_TOKEN_MISMATCH" })).toBe("AUTH_TOKEN_MISMATCH");
|
||||
});
|
||||
|
||||
it("returns null for invalid detail payloads", () => {
|
||||
expect(readConnectErrorDetailCode(null)).toBeNull();
|
||||
expect(readConnectErrorDetailCode("AUTH_TOKEN_MISMATCH")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readConnectErrorRecoveryAdvice", () => {
|
||||
it("reads retry advice fields when present", () => {
|
||||
expect(
|
||||
readConnectErrorRecoveryAdvice({
|
||||
canRetryWithDeviceToken: true,
|
||||
recommendedNextStep: "retry_with_device_token",
|
||||
}),
|
||||
).toEqual({
|
||||
canRetryWithDeviceToken: true,
|
||||
recommendedNextStep: "retry_with_device_token",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty advice for invalid payloads", () => {
|
||||
expect(readConnectErrorRecoveryAdvice(null)).toEqual({});
|
||||
expect(readConnectErrorRecoveryAdvice("x")).toEqual({});
|
||||
expect(readConnectErrorRecoveryAdvice({ canRetryWithDeviceToken: "yes" })).toEqual({});
|
||||
expect(
|
||||
readConnectErrorRecoveryAdvice({
|
||||
canRetryWithDeviceToken: true,
|
||||
recommendedNextStep: "retry_with_magic",
|
||||
}),
|
||||
).toEqual({ canRetryWithDeviceToken: true, recommendedNextStep: undefined });
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,26 @@ export const ConnectErrorDetailCodes = {
|
||||
export type ConnectErrorDetailCode =
|
||||
(typeof ConnectErrorDetailCodes)[keyof typeof ConnectErrorDetailCodes];
|
||||
|
||||
export type ConnectRecoveryNextStep =
|
||||
| "retry_with_device_token"
|
||||
| "update_auth_configuration"
|
||||
| "update_auth_credentials"
|
||||
| "wait_then_retry"
|
||||
| "review_auth_configuration";
|
||||
|
||||
export type ConnectErrorRecoveryAdvice = {
|
||||
canRetryWithDeviceToken?: boolean;
|
||||
recommendedNextStep?: ConnectRecoveryNextStep;
|
||||
};
|
||||
|
||||
const CONNECT_RECOVERY_NEXT_STEP_VALUES: ReadonlySet<ConnectRecoveryNextStep> = new Set([
|
||||
"retry_with_device_token",
|
||||
"update_auth_configuration",
|
||||
"update_auth_credentials",
|
||||
"wait_then_retry",
|
||||
"review_auth_configuration",
|
||||
]);
|
||||
|
||||
export function resolveAuthConnectErrorDetailCode(
|
||||
reason: string | undefined,
|
||||
): ConnectErrorDetailCode {
|
||||
@@ -91,3 +111,26 @@ export function readConnectErrorDetailCode(details: unknown): string | null {
|
||||
const code = (details as { code?: unknown }).code;
|
||||
return typeof code === "string" && code.trim().length > 0 ? code : null;
|
||||
}
|
||||
|
||||
export function readConnectErrorRecoveryAdvice(details: unknown): ConnectErrorRecoveryAdvice {
|
||||
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
||||
return {};
|
||||
}
|
||||
const raw = details as {
|
||||
canRetryWithDeviceToken?: unknown;
|
||||
recommendedNextStep?: unknown;
|
||||
};
|
||||
const canRetryWithDeviceToken =
|
||||
typeof raw.canRetryWithDeviceToken === "boolean" ? raw.canRetryWithDeviceToken : undefined;
|
||||
const normalizedNextStep =
|
||||
typeof raw.recommendedNextStep === "string" ? raw.recommendedNextStep.trim() : "";
|
||||
const recommendedNextStep = CONNECT_RECOVERY_NEXT_STEP_VALUES.has(
|
||||
normalizedNextStep as ConnectRecoveryNextStep,
|
||||
)
|
||||
? (normalizedNextStep as ConnectRecoveryNextStep)
|
||||
: undefined;
|
||||
return {
|
||||
canRetryWithDeviceToken,
|
||||
recommendedNextStep,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,9 +39,15 @@ describe("isNonRecoverableAuthError", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks reconnect for PAIRING_REQUIRED", () => {
|
||||
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).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.
|
||||
// Browser client can queue a single trusted-device retry after shared token mismatch.
|
||||
// Blocking reconnect on mismatch here would skip that bounded recovery attempt.
|
||||
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH))).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
196
src/gateway/server.auth.compat-baseline.test.ts
Normal file
196
src/gateway/server.auth.compat-baseline.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import {
|
||||
connectReq,
|
||||
CONTROL_UI_CLIENT,
|
||||
ConnectErrorDetailCodes,
|
||||
getFreePort,
|
||||
openWs,
|
||||
originForPort,
|
||||
restoreGatewayToken,
|
||||
startGatewayServer,
|
||||
testState,
|
||||
} from "./server.auth.shared.js";
|
||||
|
||||
function expectAuthErrorDetails(params: {
|
||||
details: unknown;
|
||||
expectedCode: string;
|
||||
canRetryWithDeviceToken?: boolean;
|
||||
recommendedNextStep?: string;
|
||||
}) {
|
||||
const details = params.details as
|
||||
| {
|
||||
code?: string;
|
||||
canRetryWithDeviceToken?: boolean;
|
||||
recommendedNextStep?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(details?.code).toBe(params.expectedCode);
|
||||
if (params.canRetryWithDeviceToken !== undefined) {
|
||||
expect(details?.canRetryWithDeviceToken).toBe(params.canRetryWithDeviceToken);
|
||||
}
|
||||
if (params.recommendedNextStep !== undefined) {
|
||||
expect(details?.recommendedNextStep).toBe(params.recommendedNextStep);
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway auth compatibility baseline", () => {
|
||||
describe("token mode", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port = 0;
|
||||
let prevToken: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "secret";
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("keeps valid shared-token connect behavior unchanged", async () => {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, { token: "secret" });
|
||||
expect(res.ok).toBe(true);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns stable token-missing details for control ui without token", async () => {
|
||||
const ws = await openWs(port, { origin: originForPort(port) });
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
client: { ...CONTROL_UI_CLIENT },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("Control UI settings");
|
||||
expectAuthErrorDetails({
|
||||
details: res.error?.details,
|
||||
expectedCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
|
||||
canRetryWithDeviceToken: false,
|
||||
recommendedNextStep: "update_auth_configuration",
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("provides one-time retry hint for shared token mismatches", async () => {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, { token: "wrong" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("gateway token mismatch");
|
||||
expectAuthErrorDetails({
|
||||
details: res.error?.details,
|
||||
expectedCode: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
|
||||
canRetryWithDeviceToken: true,
|
||||
recommendedNextStep: "retry_with_device_token",
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps explicit device token mismatch semantics stable", async () => {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, {
|
||||
skipDefaultAuth: true,
|
||||
deviceToken: "not-a-valid-device-token",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("device token mismatch");
|
||||
expectAuthErrorDetails({
|
||||
details: res.error?.details,
|
||||
expectedCode: ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
|
||||
canRetryWithDeviceToken: false,
|
||||
recommendedNextStep: "update_auth_credentials",
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("password mode", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port = 0;
|
||||
let prevToken: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("keeps valid shared-password connect behavior unchanged", async () => {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, { password: "secret" });
|
||||
expect(res.ok).toBe(true);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns stable password mismatch details", async () => {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, { password: "wrong" });
|
||||
expect(res.ok).toBe(false);
|
||||
expectAuthErrorDetails({
|
||||
details: res.error?.details,
|
||||
expectedCode: ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
|
||||
canRetryWithDeviceToken: false,
|
||||
recommendedNextStep: "update_auth_credentials",
|
||||
});
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("none mode", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port = 0;
|
||||
let prevToken: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
testState.gatewayAuth = { mode: "none" };
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
restoreGatewayToken(prevToken);
|
||||
});
|
||||
|
||||
test("keeps auth-none loopback behavior unchanged", async () => {
|
||||
const ws = await openWs(port);
|
||||
try {
|
||||
const res = await connectReq(ws, { skipDefaultAuth: true });
|
||||
expect(res.ok).toBe(true);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -391,9 +391,16 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("gateway token mismatch");
|
||||
expect(res.error?.message ?? "").not.toContain("device token mismatch");
|
||||
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
||||
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
|
||||
);
|
||||
const details = res.error?.details as
|
||||
| {
|
||||
code?: string;
|
||||
canRetryWithDeviceToken?: boolean;
|
||||
recommendedNextStep?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(details?.code).toBe(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH);
|
||||
expect(details?.canRetryWithDeviceToken).toBe(true);
|
||||
expect(details?.recommendedNextStep).toBe("retry_with_device_token");
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -562,6 +562,31 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
clientIp: browserRateLimitClientIp,
|
||||
});
|
||||
const rejectUnauthorized = (failedAuth: GatewayAuthResult) => {
|
||||
const canRetryWithDeviceToken =
|
||||
failedAuth.reason === "token_mismatch" &&
|
||||
Boolean(device) &&
|
||||
hasSharedAuth &&
|
||||
!connectParams.auth?.deviceToken;
|
||||
const recommendedNextStep = (() => {
|
||||
if (canRetryWithDeviceToken) {
|
||||
return "retry_with_device_token";
|
||||
}
|
||||
switch (failedAuth.reason) {
|
||||
case "token_missing":
|
||||
case "token_missing_config":
|
||||
case "password_missing":
|
||||
case "password_missing_config":
|
||||
return "update_auth_configuration";
|
||||
case "token_mismatch":
|
||||
case "password_mismatch":
|
||||
case "device_token_mismatch":
|
||||
return "update_auth_credentials";
|
||||
case "rate_limited":
|
||||
return "wait_then_retry";
|
||||
default:
|
||||
return "review_auth_configuration";
|
||||
}
|
||||
})();
|
||||
markHandshakeFailure("unauthorized", {
|
||||
authMode: resolvedAuth.mode,
|
||||
authProvided: connectParams.auth?.password
|
||||
@@ -594,6 +619,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
details: {
|
||||
code: resolveAuthConnectErrorDetailCode(failedAuth.reason),
|
||||
authReason: failedAuth.reason,
|
||||
canRetryWithDeviceToken,
|
||||
recommendedNextStep,
|
||||
},
|
||||
});
|
||||
close(1008, truncateCloseReason(authMessage));
|
||||
|
||||
Reference in New Issue
Block a user