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:
Josh Avant
2026-03-10 17:05:57 -05:00
committed by GitHub
parent ff2e7a2945
commit a76e810193
21 changed files with 1188 additions and 80 deletions

View File

@@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
## 2026.3.8

View File

@@ -131,6 +131,41 @@ private let defaultOperatorConnectScopes: [String] = [
"operator.pairing",
]
private enum GatewayConnectErrorCodes {
static let authTokenMismatch = "AUTH_TOKEN_MISMATCH"
static let authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
static let authTokenMissing = "AUTH_TOKEN_MISSING"
static let authPasswordMissing = "AUTH_PASSWORD_MISSING"
static let authPasswordMismatch = "AUTH_PASSWORD_MISMATCH"
static let authRateLimited = "AUTH_RATE_LIMITED"
static let pairingRequired = "PAIRING_REQUIRED"
static let controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED"
static let deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED"
}
private struct GatewayConnectAuthError: LocalizedError {
let message: String
let detailCode: String?
let canRetryWithDeviceToken: Bool
var errorDescription: String? { self.message }
var isNonRecoverable: Bool {
switch self.detailCode {
case GatewayConnectErrorCodes.authTokenMissing,
GatewayConnectErrorCodes.authPasswordMissing,
GatewayConnectErrorCodes.authPasswordMismatch,
GatewayConnectErrorCodes.authRateLimited,
GatewayConnectErrorCodes.pairingRequired,
GatewayConnectErrorCodes.controlUiDeviceIdentityRequired,
GatewayConnectErrorCodes.deviceIdentityRequired:
return true
default:
return false
}
}
}
public actor GatewayChannelActor {
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
private var task: WebSocketTaskBox?
@@ -160,6 +195,9 @@ public actor GatewayChannelActor {
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private var keepaliveTask: Task<Void, Never>?
private var pendingDeviceTokenRetry = false
private var deviceTokenRetryBudgetUsed = false
private var reconnectPausedForAuthFailure = false
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
@@ -232,10 +270,19 @@ public actor GatewayChannelActor {
while self.shouldReconnect {
guard await self.sleepUnlessCancelled(nanoseconds: 30 * 1_000_000_000) else { return } // 30s cadence
guard self.shouldReconnect else { return }
if self.reconnectPausedForAuthFailure { continue }
if self.connected { continue }
do {
try await self.connect()
} catch {
if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true
self.logger.error(
"gateway watchdog reconnect paused for non-recoverable auth failure " +
"\(error.localizedDescription, privacy: .public)"
)
continue
}
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)")
}
@@ -267,7 +314,12 @@ public actor GatewayChannelActor {
},
operation: { try await self.sendConnect() })
} catch {
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
let wrapped: Error
if let authError = error as? GatewayConnectAuthError {
wrapped = authError
} else {
wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
}
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)")
@@ -281,6 +333,7 @@ public actor GatewayChannelActor {
}
self.listen()
self.connected = true
self.reconnectPausedForAuthFailure = false
self.backoffMs = 500
self.lastSeq = nil
self.startKeepalive()
@@ -371,11 +424,18 @@ public actor GatewayChannelActor {
(includeDeviceIdentity && identity != nil)
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
: nil
// If we're not sending a device identity, a device token can't be validated server-side.
// In that mode we always use the shared gateway token/password.
let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token
let shouldUseDeviceRetryToken =
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint()
if shouldUseDeviceRetryToken {
self.pendingDeviceTokenRetry = false
}
// Keep shared credentials explicit when provided. Device token retry is attached
// only on a bounded second attempt after token mismatch.
let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil)
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource
if storedToken != nil {
if authDeviceToken != nil || (self.token == nil && storedToken != nil) {
authSource = .deviceToken
} else if authToken != nil {
authSource = .sharedToken
@@ -386,9 +446,12 @@ public actor GatewayChannelActor {
}
self.lastAuthSource = authSource
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil
if let authToken {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)]
if let authDeviceToken {
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
}
params["auth"] = ProtoAnyCodable(auth)
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
@@ -426,11 +489,24 @@ public actor GatewayChannelActor {
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(response, identity: identity, role: role)
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
if canFallbackToShared {
if let identity {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
error: error,
explicitGatewayToken: self.token,
storedToken: storedToken,
attemptedDeviceTokenRetry: authDeviceToken != nil)
if shouldRetryWithDeviceToken {
self.pendingDeviceTokenRetry = true
self.deviceTokenRetryBudgetUsed = true
self.backoffMs = min(self.backoffMs, 250)
} else if authDeviceToken != nil,
let identity,
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
throw error
}
@@ -443,7 +519,13 @@ public actor GatewayChannelActor {
) async throws {
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
let detailCode = details?["code"]?.value as? String
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
throw GatewayConnectAuthError(
message: msg,
detailCode: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken)
}
guard let payload = res.payload else {
throw NSError(
@@ -616,19 +698,91 @@ public actor GatewayChannelActor {
private func scheduleReconnect() async {
guard self.shouldReconnect else { return }
guard !self.reconnectPausedForAuthFailure else { return }
let delay = self.backoffMs / 1000
self.backoffMs = min(self.backoffMs * 2, 30000)
guard await self.sleepUnlessCancelled(nanoseconds: UInt64(delay * 1_000_000_000)) else { return }
guard self.shouldReconnect else { return }
guard !self.reconnectPausedForAuthFailure else { return }
do {
try await self.connect()
} catch {
if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true
self.logger.error(
"gateway reconnect paused for non-recoverable auth failure " +
"\(error.localizedDescription, privacy: .public)"
)
return
}
let wrapped = self.wrap(error, context: "gateway reconnect")
self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)")
await self.scheduleReconnect()
}
}
private func shouldRetryWithStoredDeviceToken(
error: Error,
explicitGatewayToken: String?,
storedToken: String?,
attemptedDeviceTokenRetry: Bool
) -> Bool {
if self.deviceTokenRetryBudgetUsed {
return false
}
if attemptedDeviceTokenRetry {
return false
}
guard explicitGatewayToken != nil, storedToken != nil else {
return false
}
guard self.isTrustedDeviceRetryEndpoint() else {
return false
}
guard let authError = error as? GatewayConnectAuthError else {
return false
}
return authError.canRetryWithDeviceToken ||
authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch
}
private func shouldPauseReconnectAfterAuthFailure(_ error: Error) -> Bool {
guard let authError = error as? GatewayConnectAuthError else {
return false
}
if authError.isNonRecoverable {
return true
}
if authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch &&
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
{
return true
}
return false
}
private func shouldClearStoredDeviceTokenAfterRetry(_ error: Error) -> Bool {
guard let authError = error as? GatewayConnectAuthError else {
return false
}
return authError.detailCode == GatewayConnectErrorCodes.authDeviceTokenMismatch
}
private func isTrustedDeviceRetryEndpoint() -> Bool {
// This client currently treats loopback as the only trusted retry target.
// Unlike the Node gateway client, it does not yet expose a pinned TLS-fingerprint
// trust path for remote retry, so remote fallback remains disabled by default.
guard let host = self.url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!host.isEmpty
else {
return false
}
if host == "localhost" || host == "::1" || host == "127.0.0.1" || host.hasPrefix("127.") {
return true
}
return false
}
private nonisolated func sleepUnlessCancelled(nanoseconds: UInt64) async -> Bool {
do {
try await Task.sleep(nanoseconds: nanoseconds)
@@ -756,7 +910,8 @@ public actor GatewayChannelActor {
return (id: id, data: data)
} catch {
self.logger.error(
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
"gateway \(kind) encode failed \(method, privacy: .public) " +
"error=\(error.localizedDescription, privacy: .public)")
throw error
}
}

View File

@@ -92,3 +92,40 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er
- These commands require `operator.pairing` (or `operator.admin`) scope.
- `devices clear` is intentionally gated by `--yes`.
- If pairing scope is unavailable on local loopback (and no explicit `--url` is passed), list/approve can use a local pairing fallback.
## Token drift recovery checklist
Use this when Control UI or other clients keep failing with `AUTH_TOKEN_MISMATCH` or `AUTH_DEVICE_TOKEN_MISMATCH`.
1. Confirm current gateway token source:
```bash
openclaw config get gateway.auth.token
```
2. List paired devices and identify the affected device id:
```bash
openclaw devices list
```
3. Rotate operator token for the affected device:
```bash
openclaw devices rotate --device <deviceId> --role operator
```
4. If rotation is not enough, remove stale pairing and approve again:
```bash
openclaw devices remove <deviceId>
openclaw devices list
openclaw devices approve <requestId>
```
5. Retry client connection with the current shared token/password.
Related:
- [Dashboard auth troubleshooting](/web/dashboard#if-you-see-unauthorized-1008)
- [Gateway troubleshooting](/gateway/troubleshooting#dashboard-control-ui-connectivity)

View File

@@ -206,6 +206,12 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
persisted by the client for future connects.
- Device tokens can be rotated/revoked via `device.token.rotate` and
`device.token.revoke` (requires `operator.pairing` scope).
- Auth failures include `error.details.code` plus recovery hints:
- `error.details.canRetryWithDeviceToken` (boolean)
- `error.details.recommendedNextStep` (`retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration`)
- Client behavior for `AUTH_TOKEN_MISMATCH`:
- Trusted clients may attempt one bounded retry with a cached per-device token.
- If that retry fails, clients should stop automatic reconnect loops and surface operator action guidance.
## Device identity + pairing
@@ -217,8 +223,9 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- **Local** connects include loopback and the gateway hosts own tailnet address
(so samehost tailnet binds can still autoapprove).
- All WS clients must include `device` identity during `connect` (operator + node).
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
is enabled for break-glass use.
Control UI can omit it only in these modes:
- `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure HTTP compatibility.
- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe security downgrade).
- All connections must sign the server-provided `connect.challenge` nonce.
### Device auth migration diagnostics

View File

@@ -262,9 +262,14 @@ High-signal `checkId` values you will most likely see in real deployments (not e
## Control UI over HTTP
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. `gateway.controlUi.allowInsecureAuth` does **not** bypass secure-context,
device-identity, or device-pairing checks. Prefer HTTPS (Tailscale Serve) or open
the UI on `127.0.0.1`.
identity. `gateway.controlUi.allowInsecureAuth` is a local compatibility toggle:
- On localhost, it allows Control UI auth without device identity when the page
is loaded over non-secure HTTP.
- It does not bypass pairing checks.
- It does not relax remote (non-localhost) device identity requirements.
Prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth`
disables device identity checks entirely. This is a severe security downgrade;

View File

@@ -113,9 +113,21 @@ Common signatures:
challenge-based device auth flow (`connect.challenge` + `device.nonce`).
- `device signature invalid` / `device signature expired` → client signed the wrong
payload (or stale timestamp) for the current handshake.
- `unauthorized` / reconnect loop → token/password mismatch.
- `AUTH_TOKEN_MISMATCH` with `canRetryWithDeviceToken=true` → client can do one trusted retry with cached device token.
- repeated `unauthorized` after that retry → shared token/device token drift; refresh token config and re-approve/rotate device token if needed.
- `gateway connect failed:` → wrong host/port/url target.
### Auth detail codes quick map
Use `error.details.code` from the failed `connect` response to pick the next action:
| Detail code | Meaning | Recommended action |
| ---------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | Paste/set token in the client and retry. For dashboard paths: `openclaw config get gateway.auth.token` then paste into Control UI settings. |
| `AUTH_TOKEN_MISMATCH` | Shared token did not match gateway auth token. | If `canRetryWithDeviceToken=true`, allow one trusted retry. If still failing, run the [token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). |
| `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. |
| `PAIRING_REQUIRED` | Device identity is known but not approved for this role. | Approve pending request: `openclaw devices list` then `openclaw devices approve <requestId>`. |
Device auth v2 migration check:
```bash
@@ -135,6 +147,7 @@ Related:
- [/web/control-ui](/web/control-ui)
- [/gateway/authentication](/gateway/authentication)
- [/gateway/remote](/gateway/remote)
- [/cli/devices](/cli/devices)
## Gateway service not running

View File

@@ -2512,6 +2512,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
Facts (from code):
- The Control UI keeps the token in `sessionStorage` for the current browser tab session and selected gateway URL, so same-tab refreshes keep working without restoring long-lived localStorage token persistence.
- On `AUTH_TOKEN_MISMATCH`, trusted clients can attempt one bounded retry with a cached device token when the gateway returns retry hints (`canRetryWithDeviceToken=true`, `recommendedNextStep=retry_with_device_token`).
Fix:
@@ -2520,6 +2521,9 @@ Fix:
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`.
- Set `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) on the gateway host.
- In the Control UI settings, paste the same token.
- If mismatch persists after the one retry, rotate/re-approve the paired device token:
- `openclaw devices list`
- `openclaw devices rotate --device <id> --role operator`
- Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
### I set gatewaybind tailnet but it can't bind nothing listens

View File

@@ -136,7 +136,8 @@ flowchart TD
Common log signatures:
- `device identity required` → HTTP/non-secure context cannot complete device auth.
- `unauthorized` / reconnect loopwrong token/password or auth mode mismatch.
- `AUTH_TOKEN_MISMATCH` with retry hints (`canRetryWithDeviceToken=true`) → one trusted device-token retry may occur automatically.
- repeated `unauthorized` after that retry → wrong token/password, auth mode mismatch, or stale paired device token.
- `gateway connect failed:` → UI is targeting the wrong URL/port or unreachable gateway.
Deep pages:

View File

@@ -174,7 +174,12 @@ OpenClaw **blocks** Control UI connections without device identity.
}
```
`allowInsecureAuth` does not bypass Control UI device identity or pairing checks.
`allowInsecureAuth` is a local compatibility toggle only:
- It allows localhost Control UI sessions to proceed without device identity in
non-secure HTTP contexts.
- It does not bypass pairing checks.
- It does not relax remote (non-localhost) device identity requirements.
**Break-glass only:**

View File

@@ -45,6 +45,8 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
## If you see “unauthorized” / 1008
- Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`).
- For `AUTH_TOKEN_MISMATCH`, clients may do one trusted retry with a cached device token when the gateway returns retry hints. If auth still fails after that retry, resolve token drift manually.
- For token drift repair steps, follow [Token drift recovery checklist](/cli/devices#token-drift-recovery-checklist).
- Retrieve or supply the token from the gateway host:
- Plaintext config: `openclaw config get gateway.auth.token`
- SecretRef-managed config: resolve the external secret provider or export `OPENCLAW_GATEWAY_TOKEN` in this shell, then rerun `openclaw dashboard`

View File

@@ -299,6 +299,7 @@
"start": "node scripts/run-node.mjs",
"test": "node scripts/test-parallel.mjs",
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
"test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts",
"test:channels": "vitest run --config vitest.channels.config.ts",
"test:coverage": "vitest run --config vitest.unit.config.ts --coverage",
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",

View File

@@ -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();
});
});

View File

@@ -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) {

View 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 });
});
});

View File

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

View File

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

View 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();
}
});
});
});

View File

@@ -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");
},
},
{

View File

@@ -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));

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { storeDeviceAuthToken } from "./device-auth.ts";
import { loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts";
import type { DeviceIdentity } from "./device-identity.ts";
const wsInstances = vi.hoisted((): MockWebSocket[] => []);
@@ -54,6 +54,12 @@ class MockWebSocket {
this.readyState = 3;
}
emitClose(code = 1000, reason = "") {
for (const handler of this.handlers.close) {
handler({ code, reason });
}
}
emitOpen() {
for (const handler of this.handlers.open) {
handler();
@@ -106,6 +112,7 @@ describe("GatewayBrowserClient", () => {
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -166,4 +173,212 @@ describe("GatewayBrowserClient", () => {
const signedPayload = signDevicePayloadMock.mock.calls[0]?.[1];
expect(signedPayload).toContain("|stored-device-token|nonce-1");
});
it("retries once with device token after token mismatch when shared token is explicit", async () => {
vi.useFakeTimers();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token",
});
client.start();
const ws1 = getLatestWebSocket();
ws1.emitOpen();
ws1.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0));
const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as {
id: string;
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(firstConnect.params?.auth?.token).toBe("shared-auth-token");
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
ws1.emitMessage({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
},
});
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
ws1.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(800);
const ws2 = getLatestWebSocket();
expect(ws2).not.toBe(ws1);
ws2.emitOpen();
ws2.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-2" },
});
await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0));
const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as {
id: string;
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(secondConnect.params?.auth?.token).toBe("shared-auth-token");
expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token");
ws2.emitMessage({
type: "res",
id: secondConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISMATCH" },
},
});
await vi.waitFor(() => expect(ws2.readyState).toBe(3));
ws2.emitClose(4008, "connect failed");
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe(
"stored-device-token",
);
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(2);
vi.useRealTimers();
});
it("treats IPv6 loopback as trusted for bounded device-token retry", async () => {
vi.useFakeTimers();
const client = new GatewayBrowserClient({
url: "ws://[::1]:18789",
token: "shared-auth-token",
});
client.start();
const ws1 = getLatestWebSocket();
ws1.emitOpen();
ws1.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0));
const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as {
id: string;
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(firstConnect.params?.auth?.token).toBe("shared-auth-token");
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
ws1.emitMessage({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
},
});
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
ws1.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(800);
const ws2 = getLatestWebSocket();
expect(ws2).not.toBe(ws1);
ws2.emitOpen();
ws2.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-2" },
});
await vi.waitFor(() => expect(ws2.sent.length).toBeGreaterThan(0));
const secondConnect = JSON.parse(ws2.sent.at(-1) ?? "{}") as {
params?: { auth?: { token?: string; deviceToken?: string } };
};
expect(secondConnect.params?.auth?.token).toBe("shared-auth-token");
expect(secondConnect.params?.auth?.deviceToken).toBe("stored-device-token");
client.stop();
vi.useRealTimers();
});
it("continues reconnecting on first token mismatch when no retry was attempted", async () => {
vi.useFakeTimers();
window.localStorage.clear();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "shared-auth-token",
});
client.start();
const ws1 = getLatestWebSocket();
ws1.emitOpen();
ws1.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0));
const firstConnect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { id: string };
ws1.emitMessage({
type: "res",
id: firstConnect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISMATCH" },
},
});
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
ws1.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(800);
expect(wsInstances).toHaveLength(2);
client.stop();
vi.useRealTimers();
});
it("does not auto-reconnect on AUTH_TOKEN_MISSING", async () => {
vi.useFakeTimers();
window.localStorage.clear();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
});
client.start();
const ws1 = getLatestWebSocket();
ws1.emitOpen();
ws1.emitMessage({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-1" },
});
await vi.waitFor(() => expect(ws1.sent.length).toBeGreaterThan(0));
const connect = JSON.parse(ws1.sent.at(-1) ?? "{}") as { id: string };
ws1.emitMessage({
type: "res",
id: connect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_TOKEN_MISSING" },
},
});
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
ws1.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
vi.useRealTimers();
});
});

View File

@@ -7,6 +7,7 @@ import {
} from "../../../src/gateway/protocol/client-info.js";
import {
ConnectErrorDetailCodes,
readConnectErrorRecoveryAdvice,
readConnectErrorDetailCode,
} from "../../../src/gateway/protocol/connect-error-details.js";
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth.ts";
@@ -57,11 +58,9 @@ export function resolveGatewayErrorDetailCode(
* 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.
* browser client supports a bounded one-time retry with a cached device token
* when the endpoint is trusted. Reconnect suppression for mismatch is handled
* with client state (after retry budget is exhausted).
*/
export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined): boolean {
if (!error) {
@@ -72,10 +71,30 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
code === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
);
}
function isTrustedRetryEndpoint(url: string): boolean {
try {
const gatewayUrl = new URL(url, window.location.href);
const host = gatewayUrl.hostname.trim().toLowerCase();
const isLoopbackHost =
host === "localhost" || host === "::1" || host === "[::1]" || host === "127.0.0.1";
const isLoopbackIPv4 = host.startsWith("127.");
if (isLoopbackHost || isLoopbackIPv4) {
return true;
}
const pageUrl = new URL(window.location.href);
return gatewayUrl.host === pageUrl.host;
} catch {
return false;
}
}
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
@@ -127,6 +146,8 @@ export class GatewayBrowserClient {
private connectTimer: number | null = null;
private backoffMs = 800;
private pendingConnectError: GatewayErrorInfo | undefined;
private pendingDeviceTokenRetry = false;
private deviceTokenRetryBudgetUsed = false;
constructor(private opts: GatewayBrowserClientOptions) {}
@@ -140,6 +161,8 @@ export class GatewayBrowserClient {
this.ws?.close();
this.ws = null;
this.pendingConnectError = undefined;
this.pendingDeviceTokenRetry = false;
this.deviceTokenRetryBudgetUsed = false;
this.flushPending(new Error("gateway client stopped"));
}
@@ -161,6 +184,14 @@ export class GatewayBrowserClient {
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason, error: connectError });
const connectErrorCode = resolveGatewayErrorDetailCode(connectError);
if (
connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH &&
this.deviceTokenRetryBudgetUsed &&
!this.pendingDeviceTokenRetry
) {
return;
}
if (!isNonRecoverableAuthError(connectError)) {
this.scheduleReconnect();
}
@@ -215,9 +246,20 @@ export class GatewayBrowserClient {
deviceId: deviceIdentity.deviceId,
role,
})?.token;
deviceToken = !(explicitGatewayToken || this.opts.password?.trim())
? (storedToken ?? undefined)
: undefined;
const shouldUseDeviceRetryToken =
this.pendingDeviceTokenRetry &&
!deviceToken &&
Boolean(explicitGatewayToken) &&
Boolean(storedToken) &&
isTrustedRetryEndpoint(this.opts.url);
if (shouldUseDeviceRetryToken) {
deviceToken = storedToken ?? undefined;
this.pendingDeviceTokenRetry = false;
} else {
deviceToken = !(explicitGatewayToken || this.opts.password?.trim())
? (storedToken ?? undefined)
: undefined;
}
canFallbackToShared = Boolean(deviceToken && explicitGatewayToken);
}
authToken = explicitGatewayToken ?? deviceToken;
@@ -225,6 +267,7 @@ export class GatewayBrowserClient {
authToken || this.opts.password
? {
token: authToken,
deviceToken,
password: this.opts.password,
}
: undefined;
@@ -282,6 +325,8 @@ export class GatewayBrowserClient {
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
this.pendingDeviceTokenRetry = false;
this.deviceTokenRetryBudgetUsed = false;
if (hello?.auth?.deviceToken && deviceIdentity) {
storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
@@ -294,6 +339,33 @@ export class GatewayBrowserClient {
this.opts.onHello?.(hello);
})
.catch((err: unknown) => {
const connectErrorCode =
err instanceof GatewayRequestError ? resolveGatewayErrorDetailCode(err) : null;
const recoveryAdvice =
err instanceof GatewayRequestError ? readConnectErrorRecoveryAdvice(err.details) : {};
const retryWithDeviceTokenRecommended =
recoveryAdvice.recommendedNextStep === "retry_with_device_token";
const canRetryWithDeviceTokenHint =
recoveryAdvice.canRetryWithDeviceToken === true ||
retryWithDeviceTokenRecommended ||
connectErrorCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH;
const shouldRetryWithDeviceToken =
!this.deviceTokenRetryBudgetUsed &&
!deviceToken &&
Boolean(explicitGatewayToken) &&
Boolean(deviceIdentity) &&
Boolean(
loadDeviceAuthToken({
deviceId: deviceIdentity?.deviceId ?? "",
role,
})?.token,
) &&
canRetryWithDeviceTokenHint &&
isTrustedRetryEndpoint(this.opts.url);
if (shouldRetryWithDeviceToken) {
this.pendingDeviceTokenRetry = true;
this.deviceTokenRetryBudgetUsed = true;
}
if (err instanceof GatewayRequestError) {
this.pendingConnectError = {
code: err.gatewayCode,
@@ -303,7 +375,11 @@ export class GatewayBrowserClient {
} else {
this.pendingConnectError = undefined;
}
if (canFallbackToShared && deviceIdentity) {
if (
canFallbackToShared &&
deviceIdentity &&
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");