From ebb9a171bab538844ac9c8a2bd8f24baffd2d42e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 5 Mar 2026 00:16:37 -0800 Subject: [PATCH 1/8] key improvements --- packages/openclaw/src/gateway.ts | 11 +++++++---- packages/openclaw/src/setup.ts | 7 +++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/openclaw/src/gateway.ts b/packages/openclaw/src/gateway.ts index 087e180ab..d13af2959 100644 --- a/packages/openclaw/src/gateway.ts +++ b/packages/openclaw/src/gateway.ts @@ -16,7 +16,7 @@ import type { } from '@relaycast/sdk'; import WebSocket from 'ws'; -import { openclawHome } from './config.js'; +import { openclawHome, detectOpenClaw } from './config.js'; import { DEFAULT_OPENCLAW_GATEWAY_PORT, type GatewayConfig, type InboundMessage, type DeliveryResult } from './types.js'; import { SpawnManager } from './spawn/manager.js'; import type { SpawnOptions } from './spawn/types.js'; @@ -93,9 +93,12 @@ function resolveAuthProfile(): AuthProfile { return AUTH_PROFILES[envVal]; } - // 2. Variant detection via config path - const home = process.env.OPENCLAW_HOME || process.env.OPENCLAW_CONFIG_PATH || ''; - if (home.includes('.clawdbot') || home.includes('clawdbot')) { + // 2. Variant detection via filesystem probing — delegates to openclawHome() + // which checks valid parseable config files, not just directory existence. + // Strict suffix check avoids false positives from substring matching. + const home = openclawHome(); + const homeSuffix = home.replace(/[/\\]+$/, '').split(/[/\\]/).pop(); + if (homeSuffix === '.clawdbot') { return AUTH_PROFILES['clawdbot-v1']; } diff --git a/packages/openclaw/src/setup.ts b/packages/openclaw/src/setup.ts index e217c3bec..e404174b0 100644 --- a/packages/openclaw/src/setup.ts +++ b/packages/openclaw/src/setup.ts @@ -30,8 +30,15 @@ function extractNestedValue(obj: unknown, path: string): unknown { * Set a deeply nested value in an object by dot-separated path, creating * intermediate objects as needed. */ +const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + function setNestedValue(obj: Record, path: string, value: unknown): void { const keys = path.split('.'); + for (const key of keys) { + if (DANGEROUS_KEYS.has(key)) { + throw new Error(`Refusing to set dangerous key "${key}" in path "${path}"`); + } + } let current: Record = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; From c6730af2e4f3e92e9a86a5944836292ac056e236 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 5 Mar 2026 00:28:12 -0800 Subject: [PATCH 2/8] build update --- packages/openclaw/src/gateway.ts | 105 +++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 18 deletions(-) diff --git a/packages/openclaw/src/gateway.ts b/packages/openclaw/src/gateway.ts index d13af2959..46a8e5c39 100644 --- a/packages/openclaw/src/gateway.ts +++ b/packages/openclaw/src/gateway.ts @@ -238,6 +238,68 @@ async function loadOrCreateDeviceIdentity(): Promise { return identity; } +/** Hash helper for diagnostics (no secrets leaked — just truncated SHA-256). */ +function shortHash(data: string | Buffer): string { + const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data; + return createHash('sha256').update(buf).digest('hex').slice(0, 16); +} + +/** + * Canonicalization variants to try for debugging. Each produces a different + * pipe-delimited payload string. The server should match exactly one. + */ +function buildCanonicalVariants( + device: DeviceIdentity, + params: { + clientId: string; + clientMode: string; + platform: string; + deviceFamily: string; + role: string; + scopes: string[]; + signedAt: number; + token: string; + nonce: string; + }, +): Array<{ name: string; payload: string }> { + const signedAtMs = String(params.signedAt); + const signedAtSec = String(Math.floor(params.signedAt / 1000)); + const scopesCsv = params.scopes.join(','); + + return [ + // V0: current default order (v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily) + { + name: 'v3-default-ms', + payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.token || '', params.nonce, params.platform, params.deviceFamily].join('|'), + }, + // V1: signedAt in seconds instead of milliseconds + { + name: 'v3-default-sec', + payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.token || '', params.nonce, params.platform, params.deviceFamily].join('|'), + }, + // V2: no token in payload (token omitted entirely) + { + name: 'v3-no-token-ms', + payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce, params.platform, params.deviceFamily].join('|'), + }, + // V3: nonce before token (swapped positions) + { + name: 'v3-nonce-first-ms', + payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce, params.token || '', params.platform, params.deviceFamily].join('|'), + }, + // V4: fewer fields — just core identity + nonce + signedAt (minimal) + { + name: 'v3-minimal', + payload: ['v3', device.deviceId, signedAtMs, params.nonce].join('|'), + }, + // V5: signedAt seconds + no token + { + name: 'v3-no-token-sec', + payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.nonce, params.platform, params.deviceFamily].join('|'), + }, + ]; +} + function signConnectPayload( device: DeviceIdentity, params: { @@ -254,26 +316,29 @@ function signConnectPayload( ): string { const profile = resolveAuthProfile(); - // v3 payload format: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily - const payload = [ - 'v3', - device.deviceId, - params.clientId, - params.clientMode, - params.role, - params.scopes.join(','), - String(params.signedAt), - params.token || '', - params.nonce, - params.platform, - params.deviceFamily, - ].join('|'); - - const payloadBytes = Buffer.from(payload, 'utf-8'); + // Build canonicalization variants for diagnostics + const variants = buildCanonicalVariants(device, params); + const primary = variants[0]; // v3-default-ms is the primary + + const payloadBytes = Buffer.from(primary.payload, 'utf-8'); // Diagnostic logging: selected profile + pre-auth fingerprint (no secrets). - const payloadHash = createHash('sha256').update(payloadBytes).digest('hex').slice(0, 16); - console.log(`[ws-auth] profile=${profile.name} deviceId=${device.deviceId.slice(0, 16)}... keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding} payloadHash=${payloadHash}`); + console.log(`[ws-auth] profile=${profile.name} deviceId=${device.deviceId.slice(0, 16)}... keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding}`); + console.log(`[ws-auth] signedAt=${params.signedAt} (ms) signedAtSec=${Math.floor(params.signedAt / 1000)} nonce=${shortHash(params.nonce)}`); + + // Log per-field hashes for debugging canonicalization mismatches + if (process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1') { + console.log(`[ws-auth-debug] field hashes: deviceId=${shortHash(device.deviceId)} clientId=${shortHash(params.clientId)} role=${shortHash(params.role)} scopes=${shortHash(params.scopes.join(','))} token=${shortHash(params.token || '')} nonce=${shortHash(params.nonce)}`); + + // Log all canonicalization variant hashes + console.log('[ws-auth-debug] canonicalization matrix:'); + for (const v of variants) { + console.log(` ${v.name}: hash=${shortHash(v.payload)}`); + } + } + + // Primary payload hash always logged + console.log(`[ws-auth] primaryPayload=${primary.name} payloadHash=${shortHash(primary.payload)}`); // Ed25519 sign — no hash algorithm needed (null), it's built into Ed25519 const signature = sign(null, payloadBytes, device.privateKeyObj); @@ -450,6 +515,10 @@ export class OpenClawGatewayClient { if (msg.type === 'event' && msg.event === 'connect.challenge') { const payload = msg.payload as { nonce: string; ts: number }; console.log('[openclaw-ws] Received connect.challenge, signing...'); + // Log raw challenge payload for debugging canonicalization issues + if (process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1') { + console.log(`[ws-auth-debug] challenge payload: ${JSON.stringify(payload)}`); + } const signedAt = Date.now(); const clientId = 'cli'; From a14e38d49375a9b885b573ff271d5f680d271c6c Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 5 Mar 2026 00:35:37 -0800 Subject: [PATCH 3/8] Fix Devin review findings + add WS auth canonicalization matrix - resolveAuthProfile(): match both '.clawdbot' and 'clawdbot' suffixes to handle OPENCLAW_HOME=/opt/clawdbot (no dot prefix) - setNestedValue(): reject __proto__/prototype/constructor keys (prototype pollution guard) - Add canonicalization matrix with 6 payload variants for WS auth debugging - Add per-field hash logging and raw challenge capture behind OPENCLAW_WS_DEBUG=1 Co-Authored-By: Claude Opus 4.6 --- packages/openclaw/src/gateway.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/openclaw/src/gateway.ts b/packages/openclaw/src/gateway.ts index 46a8e5c39..adb593f0e 100644 --- a/packages/openclaw/src/gateway.ts +++ b/packages/openclaw/src/gateway.ts @@ -97,8 +97,8 @@ function resolveAuthProfile(): AuthProfile { // which checks valid parseable config files, not just directory existence. // Strict suffix check avoids false positives from substring matching. const home = openclawHome(); - const homeSuffix = home.replace(/[/\\]+$/, '').split(/[/\\]/).pop(); - if (homeSuffix === '.clawdbot') { + const homeSuffix = home.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''; + if (homeSuffix === '.clawdbot' || homeSuffix === 'clawdbot') { return AUTH_PROFILES['clawdbot-v1']; } From 498ec23c78ac54ce0884453cf7d3cdb56b3e0f5d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 5 Mar 2026 00:40:58 -0800 Subject: [PATCH 4/8] Fix WS auth: align with server verifier from openclaw/openclaw source After reading the actual server-side verifier (openclaw/openclaw src/gateway/device-auth.ts, src/infra/device-identity.ts), confirmed: - Server accepts both PEM and raw-base64url public keys - Server decodes signatures in both base64url and standard base64 - Payload format (v3|deviceId|...) matches our v3-default-ms exactly Changes: - clawdbot-v1 profile now uses raw-base64url + base64url (matches server's own signDevicePayload output) instead of PEM + base64 - Add self-verification diagnostic: verify signature locally before sending, check deviceId derivation, verify encode/decode round-trip - Helps identify if the issue is key mismatch vs payload mismatch Co-Authored-By: Claude Opus 4.6 --- packages/openclaw/src/gateway.ts | 38 ++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/openclaw/src/gateway.ts b/packages/openclaw/src/gateway.ts index adb593f0e..c50c4caf2 100644 --- a/packages/openclaw/src/gateway.ts +++ b/packages/openclaw/src/gateway.ts @@ -1,4 +1,4 @@ -import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, type KeyObject } from 'node:crypto'; +import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, type KeyObject } from 'node:crypto'; import { chmod, readFile, rename, writeFile, mkdir } from 'node:fs/promises'; import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { join } from 'node:path'; @@ -71,9 +71,12 @@ const AUTH_PROFILES: Record = { signatureEncoding: 'base64url', }, 'clawdbot-v1': { + // Server (openclaw/openclaw device-identity.ts) accepts both PEM and raw-base64url + // public keys, and decodes signatures in both base64url and base64. Use base64url + // for consistency — matches the server's own signDevicePayload() output. name: 'clawdbot-v1', - publicKeyFormat: 'spki-pem', - signatureEncoding: 'base64', + publicKeyFormat: 'raw-base64url', + signatureEncoding: 'base64url', }, }; @@ -342,8 +345,35 @@ function signConnectPayload( // Ed25519 sign — no hash algorithm needed (null), it's built into Ed25519 const signature = sign(null, payloadBytes, device.privateKeyObj); + const encoded = Buffer.from(signature).toString(profile.signatureEncoding); + + // Self-verification: replicate what the server does to confirm our signature + // is valid before sending. If this fails, the key/payload are mismatched locally. + if (process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1') { + try { + // Derive public key from private key (same as server would use from our publicKey field) + const pubKey = createPublicKey(device.privateKeyObj); + const selfVerifyRaw = verify(null, payloadBytes, pubKey, signature); + + // Also verify the round-trip: decode our encoded signature like the server would + const decodedSig = Buffer.from(encoded, profile.signatureEncoding === 'base64url' ? 'base64url' : 'base64'); + const selfVerifyEncoded = verify(null, payloadBytes, pubKey, decodedSig); + + // Verify deviceId matches public key + const rawPubBytes = pubKey.export({ type: 'spki', format: 'der' }).subarray(12); + const derivedDeviceId = createHash('sha256').update(rawPubBytes).digest('hex'); + const deviceIdMatch = derivedDeviceId === device.deviceId; + + console.log(`[ws-auth-debug] self-verify: raw=${selfVerifyRaw} encoded=${selfVerifyEncoded} deviceIdMatch=${deviceIdMatch} derivedId=${derivedDeviceId.slice(0, 16)}...`); + if (!deviceIdMatch) { + console.error(`[ws-auth-debug] DEVICE ID MISMATCH: derived=${derivedDeviceId} sent=${device.deviceId}`); + } + } catch (err) { + console.error(`[ws-auth-debug] self-verify error: ${err instanceof Error ? err.message : String(err)}`); + } + } - return Buffer.from(signature).toString(profile.signatureEncoding); + return encoded; } From af81dca100f35f586d69c965fe8ce05e2a664ffa Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 5 Mar 2026 00:45:12 -0800 Subject: [PATCH 5/8] Use v2 payload for clawdbot-v1 profile (older gateway compat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Clawdbot marketplace image may run an older gateway that only supports v2 device auth payloads (no platform/deviceFamily fields). The current server tries v3→v2 fallback, but older versions only have v2. Changes: - clawdbot-v1 profile now uses v2 payload as primary - Added v2 variants to canonicalization matrix (v2-default-ms, v2-default-sec, v2-no-token-ms) - Default profile still uses v3 (unchanged for standard OpenClaw) Co-Authored-By: Claude Opus 4.6 --- packages/openclaw/src/gateway.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/openclaw/src/gateway.ts b/packages/openclaw/src/gateway.ts index c50c4caf2..1314d7344 100644 --- a/packages/openclaw/src/gateway.ts +++ b/packages/openclaw/src/gateway.ts @@ -300,6 +300,21 @@ function buildCanonicalVariants( name: 'v3-no-token-sec', payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.nonce, params.platform, params.deviceFamily].join('|'), }, + // V6: v2 format (no platform/deviceFamily) — used by older gateway versions + { + name: 'v2-default-ms', + payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.token || '', params.nonce].join('|'), + }, + // V7: v2 with signedAt in seconds + { + name: 'v2-default-sec', + payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.token || '', params.nonce].join('|'), + }, + // V8: v2 without token + { + name: 'v2-no-token-ms', + payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce].join('|'), + }, ]; } @@ -321,7 +336,13 @@ function signConnectPayload( // Build canonicalization variants for diagnostics const variants = buildCanonicalVariants(device, params); - const primary = variants[0]; // v3-default-ms is the primary + + // Select primary payload: clawdbot-v1 uses v2 format (no platform/deviceFamily) + // because the Clawdbot marketplace image may run an older gateway that only + // supports v2 payloads. The current server (openclaw/openclaw) tries v3 first + // then v2, but older versions may only have v2. + const primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms'; + const primary = variants.find(v => v.name === primaryName) ?? variants[0]; const payloadBytes = Buffer.from(primary.payload, 'utf-8'); From 1f620f45e0ad0d9e3e2256de405e4b679a3eef63 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 5 Mar 2026 01:01:06 -0800 Subject: [PATCH 6/8] =?UTF-8?q?Harden=20WS=20auth:=20v3=E2=86=94v2=20fallb?= =?UTF-8?q?ack,=20cleanup=20diagnostics,=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add safe v3↔v2 auth payload fallback: on signature rejection, retry once with alternate payload version before giving up - Clean up diagnostic logging: consolidate to single production line, gate verbose output behind OPENCLAW_WS_DEBUG=1 - Add auth reject/fallback counters for observability - Add fallback conformance tests (signature reject → retry → success, and double-reject → fail after one fallback) - Add WS auth version-compat matrix to SKILL.md Co-Authored-By: Claude Opus 4.6 --- packages/openclaw/skill/SKILL.md | 14 +++ .../openclaw/src/__tests__/ws-client.test.ts | 72 ++++++++++++++ packages/openclaw/src/gateway.ts | 95 ++++++++++++++----- 3 files changed, 158 insertions(+), 23 deletions(-) diff --git a/packages/openclaw/skill/SKILL.md b/packages/openclaw/skill/SKILL.md index 2d214e17b..59948e450 100644 --- a/packages/openclaw/skill/SKILL.md +++ b/packages/openclaw/skill/SKILL.md @@ -411,6 +411,20 @@ Confirm what appears auto-injected in your UI stream: - Prefer explicit env exports in hosted/sandbox deployments. - If available in your deployment, use a lockfile/PID strategy for relay gateway singleton enforcement. +### WS auth version-compat matrix + +The relay gateway automatically selects the right device auth payload version based on the detected environment. If the selected version is rejected, it falls back to the alternate version once before giving up. + +| Environment | Auth Profile | Primary Payload | Fallback | Notes | +|---|---|---|---|---| +| `~/.openclaw/` (standard) | `default` | v3 (with platform/deviceFamily) | v2 | Current OpenClaw server supports v3 natively | +| `~/.clawdbot/` (marketplace image) | `clawdbot-v1` | v2 (no platform/deviceFamily) | v3 | Older gateway only supports v2; v3↔v2 fallback handles upgrades | +| `OPENCLAW_WS_AUTH_COMPAT=clawdbot` | `clawdbot-v1` | v2 | v3 | Manual override for non-standard installations | + +**When upgrading a Clawdbot marketplace image** to a newer OpenClaw server that supports v3, the fallback mechanism handles the transition automatically — v2 is tried first, and if the new server rejects it (unlikely, since servers accept both), v3 is tried as fallback. + +**Debug logging**: Set `OPENCLAW_WS_DEBUG=1` to see the full canonicalization matrix, field hashes, and self-verification output during auth. + --- ## 11b) Advanced Troubleshooting: Execution Policy Lockdown diff --git a/packages/openclaw/src/__tests__/ws-client.test.ts b/packages/openclaw/src/__tests__/ws-client.test.ts index c7042975b..719a2223d 100644 --- a/packages/openclaw/src/__tests__/ws-client.test.ts +++ b/packages/openclaw/src/__tests__/ws-client.test.ts @@ -372,6 +372,78 @@ describe('OpenClawGatewayClient', () => { await client.disconnect(); }); + it('should fallback to alternate payload version on signature rejection', async () => { + let connectAttempts = 0; + server = new MockOpenClawServer({ sendChallenge: false }); + const origWss = (server as unknown as { wss: WebSocketServer }).wss; + origWss.removeAllListeners('connection'); + origWss.on('connection', (ws) => { + connectAttempts++; + // Send challenge + ws.send(JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: `nonce-fallback-${connectAttempts}`, ts: Date.now() }, + })); + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + if (msg.method === 'connect') { + if (connectAttempts === 1) { + // First attempt: reject with signature invalid + ws.send(JSON.stringify({ + type: 'res', + id: 'connect-1', + ok: false, + error: { code: 'auth_failed', message: 'device signature invalid' }, + })); + } else { + // Second attempt (fallback): accept + ws.send(JSON.stringify({ type: 'res', id: 'connect-1', ok: true })); + } + } + }); + }); + + const client = new OpenClawGatewayClient('test-token', server.port); + await client.connect(); + // Should have connected on the fallback attempt + expect(connectAttempts).toBe(2); + await client.disconnect(); + }); + + it('should not retry fallback more than once', async () => { + let connectAttempts = 0; + server = new MockOpenClawServer({ sendChallenge: false }); + const origWss = (server as unknown as { wss: WebSocketServer }).wss; + origWss.removeAllListeners('connection'); + origWss.on('connection', (ws) => { + connectAttempts++; + ws.send(JSON.stringify({ + type: 'event', + event: 'connect.challenge', + payload: { nonce: `nonce-nofallback-${connectAttempts}`, ts: Date.now() }, + })); + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as Record; + if (msg.method === 'connect') { + // Always reject with signature invalid + ws.send(JSON.stringify({ + type: 'res', + id: 'connect-1', + ok: false, + error: { code: 'auth_failed', message: 'device signature invalid' }, + })); + } + }); + }); + + const client = new OpenClawGatewayClient('test-token', server.port); + await expect(client.connect()).rejects.toThrow(/auth failed|signature invalid|closed before/i); + // Should have tried exactly 2 times: primary + one fallback + expect(connectAttempts).toBe(2); + await client.disconnect(); + }); + it('should silently ignore unrecognized event messages', async () => { server = new MockOpenClawServer(); const origWss = (server as unknown as { wss: WebSocketServer }).wss; diff --git a/packages/openclaw/src/gateway.ts b/packages/openclaw/src/gateway.ts index 1314d7344..d2ecbcfe3 100644 --- a/packages/openclaw/src/gateway.ts +++ b/packages/openclaw/src/gateway.ts @@ -318,6 +318,9 @@ function buildCanonicalVariants( ]; } +/** Payload version override for v3↔v2 fallback. */ +type PayloadVersionOverride = 'v2' | 'v3' | null; + function signConnectPayload( device: DeviceIdentity, params: { @@ -331,46 +334,51 @@ function signConnectPayload( token: string; nonce: string; }, + versionOverride?: PayloadVersionOverride, ): string { const profile = resolveAuthProfile(); // Build canonicalization variants for diagnostics const variants = buildCanonicalVariants(device, params); - // Select primary payload: clawdbot-v1 uses v2 format (no platform/deviceFamily) - // because the Clawdbot marketplace image may run an older gateway that only - // supports v2 payloads. The current server (openclaw/openclaw) tries v3 first - // then v2, but older versions may only have v2. - const primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms'; + // Select primary payload version: + // 1. If versionOverride is set (from fallback), use that directly + // 2. clawdbot-v1 defaults to v2 (older gateway compat) + // 3. default profile uses v3 + let primaryName: string; + if (versionOverride === 'v2') { + primaryName = 'v2-default-ms'; + } else if (versionOverride === 'v3') { + primaryName = 'v3-default-ms'; + } else { + primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms'; + } const primary = variants.find(v => v.name === primaryName) ?? variants[0]; const payloadBytes = Buffer.from(primary.payload, 'utf-8'); - // Diagnostic logging: selected profile + pre-auth fingerprint (no secrets). - console.log(`[ws-auth] profile=${profile.name} deviceId=${device.deviceId.slice(0, 16)}... keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding}`); - console.log(`[ws-auth] signedAt=${params.signedAt} (ms) signedAtSec=${Math.floor(params.signedAt / 1000)} nonce=${shortHash(params.nonce)}`); + const isDebug = process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1'; - // Log per-field hashes for debugging canonicalization mismatches - if (process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1') { - console.log(`[ws-auth-debug] field hashes: deviceId=${shortHash(device.deviceId)} clientId=${shortHash(params.clientId)} role=${shortHash(params.role)} scopes=${shortHash(params.scopes.join(','))} token=${shortHash(params.token || '')} nonce=${shortHash(params.nonce)}`); + // Concise production log — one line with essential info + console.log(`[ws-auth] profile=${profile.name} payload=${primary.name} device=${device.deviceId.slice(0, 12)}...${versionOverride ? ` override=${versionOverride}` : ''}`); - // Log all canonicalization variant hashes + // Verbose debug logging — field hashes and canonicalization matrix + if (isDebug) { + console.log(`[ws-auth-debug] signedAt=${params.signedAt}ms nonce=${shortHash(params.nonce)} keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding}`); + console.log(`[ws-auth-debug] field hashes: deviceId=${shortHash(device.deviceId)} clientId=${shortHash(params.clientId)} role=${shortHash(params.role)} scopes=${shortHash(params.scopes.join(','))} token=${shortHash(params.token || '')} nonce=${shortHash(params.nonce)}`); console.log('[ws-auth-debug] canonicalization matrix:'); for (const v of variants) { console.log(` ${v.name}: hash=${shortHash(v.payload)}`); } + console.log(`[ws-auth-debug] payloadHash=${shortHash(primary.payload)}`); } - // Primary payload hash always logged - console.log(`[ws-auth] primaryPayload=${primary.name} payloadHash=${shortHash(primary.payload)}`); - // Ed25519 sign — no hash algorithm needed (null), it's built into Ed25519 const signature = sign(null, payloadBytes, device.privateKeyObj); const encoded = Buffer.from(signature).toString(profile.signatureEncoding); - // Self-verification: replicate what the server does to confirm our signature - // is valid before sending. If this fails, the key/payload are mismatched locally. - if (process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1') { + // Self-verification (debug only): confirm our signature is valid locally. + if (isDebug) { try { // Derive public key from private key (same as server would use from our publicKey field) const pubKey = createPublicKey(device.privateKeyObj); @@ -424,6 +432,15 @@ export class OpenClawGatewayClient { private connectTimeout: ReturnType | null = null; private pairingRejected = false; private consecutiveFailures = 0; + /** Payload version override for v3↔v2 fallback (null = use profile default). */ + private payloadVersionOverride: PayloadVersionOverride = null; + /** Whether a fallback attempt has already been tried this connection cycle. */ + private fallbackAttempted = false; + /** Auth rejection counters for observability. */ + private authRejectCount = 0; + private authFallbackCount = 0; + /** True while a fallback reconnect is in progress — suppresses close handler rejection. */ + private fallbackInProgress = false; /** Default timeout for initial connection (30 seconds). */ private static readonly CONNECT_TIMEOUT_MS = 30_000; @@ -456,6 +473,10 @@ export class OpenClawGatewayClient { // Explicit connect() clears pairing rejection so users can retry after fixing their token this.pairingRejected = false; this.stopped = false; + // Reset fallback state for fresh connection attempts + this.payloadVersionOverride = null; + this.fallbackAttempted = false; + this.fallbackInProgress = false; // Cancel any pending reconnect timer to prevent orphaned WebSocket connections if (this.reconnectTimer) { @@ -530,14 +551,15 @@ export class OpenClawGatewayClient { this.pendingRpcs.delete(id); } // If we weren't authenticated yet, reject the connect promise - if (!wasAuthenticated && this.connectReject) { + // (unless a fallback reconnect is in progress — let it proceed) + if (!wasAuthenticated && this.connectReject && !this.fallbackInProgress) { this.clearConnectTimeout(); const err = new Error(`WebSocket closed before authentication (code=${code})`); this.connectReject(err); this.connectReject = null; this.connectResolve = null; } - if (!this.stopped) { + if (!this.stopped && !this.fallbackInProgress) { this.scheduleReconnect(); } }); @@ -589,7 +611,7 @@ export class OpenClawGatewayClient { signedAt, token: this.token, nonce: payload.nonce, - }); + }, this.payloadVersionOverride); // Select public key format based on resolved auth profile. const profile = resolveAuthProfile(); @@ -638,8 +660,11 @@ export class OpenClawGatewayClient { // Handle connect response if (msg.type === 'res' && msg.id === 'connect-1') { this.clearConnectTimeout(); + this.fallbackInProgress = false; // Clear on any connect response if (msg.ok) { - console.log('[openclaw-ws] Authenticated successfully'); + const versionUsed = this.payloadVersionOverride + ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3'); + console.log(`[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})`); this.authenticated = true; this.consecutiveFailures = 0; this.connectResolve?.(); @@ -648,6 +673,7 @@ export class OpenClawGatewayClient { } else { const errStr = msg.error ? JSON.stringify(msg.error) : 'Authentication rejected'; const isPairing = /pairing.required|not.paired/i.test(errStr); + const isSignatureInvalid = /signature.invalid|device.signature|invalid.signature/i.test(errStr); if (isPairing) { const errObj = msg.error as Record | undefined; @@ -662,8 +688,31 @@ export class OpenClawGatewayClient { : '~/.openclaw/openclaw.json'; console.error(`[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token`); this.pairingRejected = true; + } else if (isSignatureInvalid && !this.fallbackAttempted) { + // Signature rejected — try the alternate payload version once. + // If we were using v2 (clawdbot-v1 profile), try v3. If v3 (default), try v2. + this.authRejectCount++; + this.authFallbackCount++; + const profile = resolveAuthProfile(); + const currentVersion = this.payloadVersionOverride + ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3'); + const fallbackVersion: PayloadVersionOverride = currentVersion === 'v2' ? 'v3' : 'v2'; + + console.warn(`[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})`); + this.payloadVersionOverride = fallbackVersion; + this.fallbackAttempted = true; + this.fallbackInProgress = true; + + // Close current WS and reconnect with the alternate payload. + // fallbackInProgress stays true until the next auth response arrives, + // suppressing close-handler rejection from the old connection. + try { this.ws?.close(); } catch {} + this.ws = null; + setTimeout(() => this.doConnect(), 0); + return; // Don't reject the connect promise yet — fallback attempt in progress } else { - console.warn(`[openclaw-ws] Auth rejected: ${errStr}`); + this.authRejectCount++; + console.warn(`[openclaw-ws] Auth rejected (rejects=${this.authRejectCount}): ${errStr}`); } this.connectReject?.(new Error(`OpenClaw gateway auth failed: ${errStr}`)); From 9fcb24683ec8f05deb8a1a1518270a988800d0ea Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 5 Mar 2026 01:06:04 -0800 Subject: [PATCH 7/8] =?UTF-8?q?Fix=20connect=20timeout=20cleared=20prematu?= =?UTF-8?q?rely=20during=20v3=E2=86=94v2=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move clearConnectTimeout() from the unconditional connect-response path into each terminal branch (success, pairing rejection, final rejection). The fallback branch intentionally keeps the original 30s timeout alive so a hanging fallback connection still triggers the timeout instead of leaving the connect promise dangling forever. Fixes Devin review finding on PR #493. Co-Authored-By: Claude Opus 4.6 --- packages/openclaw/src/gateway.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/openclaw/src/gateway.ts b/packages/openclaw/src/gateway.ts index d2ecbcfe3..594f44804 100644 --- a/packages/openclaw/src/gateway.ts +++ b/packages/openclaw/src/gateway.ts @@ -659,9 +659,9 @@ export class OpenClawGatewayClient { // Handle connect response if (msg.type === 'res' && msg.id === 'connect-1') { - this.clearConnectTimeout(); this.fallbackInProgress = false; // Clear on any connect response if (msg.ok) { + this.clearConnectTimeout(); const versionUsed = this.payloadVersionOverride ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3'); console.log(`[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})`); @@ -676,6 +676,7 @@ export class OpenClawGatewayClient { const isSignatureInvalid = /signature.invalid|device.signature|invalid.signature/i.test(errStr); if (isPairing) { + this.clearConnectTimeout(); const errObj = msg.error as Record | undefined; const requestId = errObj?.requestId ?? errObj?.request_id ?? ''; console.error('[openclaw-ws] Pairing rejected — device is not paired with the OpenClaw gateway.'); @@ -690,7 +691,7 @@ export class OpenClawGatewayClient { this.pairingRejected = true; } else if (isSignatureInvalid && !this.fallbackAttempted) { // Signature rejected — try the alternate payload version once. - // If we were using v2 (clawdbot-v1 profile), try v3. If v3 (default), try v2. + // Do NOT clear connect timeout — it protects the fallback attempt too. this.authRejectCount++; this.authFallbackCount++; const profile = resolveAuthProfile(); @@ -711,6 +712,7 @@ export class OpenClawGatewayClient { setTimeout(() => this.doConnect(), 0); return; // Don't reject the connect promise yet — fallback attempt in progress } else { + this.clearConnectTimeout(); this.authRejectCount++; console.warn(`[openclaw-ws] Auth rejected (rejects=${this.authRejectCount}): ${errStr}`); } From 8033eb28396d49a25ca0215659aed9cac567ea78 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Thu, 5 Mar 2026 01:18:18 -0800 Subject: [PATCH 8/8] =?UTF-8?q?Fix=20stale=20WS=20event=20handlers=20durin?= =?UTF-8?q?g=20v3=E2=86=94v2=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard close/error handlers with `this.ws !== ws` to ignore events from superseded WebSocket instances. This fixes two Devin review findings: 1. Error handler could reject connect promise during fallback if the old WS emitted an error during close handshake. 2. Old WS close handler could stomp `authenticated = false` on the new connection if it fired after the fallback succeeded, plus trigger a spurious scheduleReconnect(). The `this.ws !== ws` pattern replaces the `fallbackInProgress` flag with a simpler, more robust guard. Also adds early `stopped` check in sendChatMessage to prevent reconnect after explicit disconnect. Co-Authored-By: Claude Opus 4.6 --- packages/openclaw/src/gateway.ts | 34 ++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/openclaw/src/gateway.ts b/packages/openclaw/src/gateway.ts index 594f44804..c9bedfcdb 100644 --- a/packages/openclaw/src/gateway.ts +++ b/packages/openclaw/src/gateway.ts @@ -439,8 +439,6 @@ export class OpenClawGatewayClient { /** Auth rejection counters for observability. */ private authRejectCount = 0; private authFallbackCount = 0; - /** True while a fallback reconnect is in progress — suppresses close handler rejection. */ - private fallbackInProgress = false; /** Default timeout for initial connection (30 seconds). */ private static readonly CONNECT_TIMEOUT_MS = 30_000; @@ -476,7 +474,6 @@ export class OpenClawGatewayClient { // Reset fallback state for fresh connection attempts this.payloadVersionOverride = null; this.fallbackAttempted = false; - this.fallbackInProgress = false; // Cancel any pending reconnect timer to prevent orphaned WebSocket connections if (this.reconnectTimer) { @@ -514,23 +511,29 @@ export class OpenClawGatewayClient { private doConnect(): void { if (this.stopped) return; + let ws: WebSocket; try { - this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`); + ws = new WebSocket(`ws://127.0.0.1:${this.port}`); } catch (err) { console.warn(`[openclaw-ws] Connection failed: ${err instanceof Error ? err.message : String(err)}`); this.scheduleReconnect(); return; } + this.ws = ws; - this.ws.on('open', () => { + ws.on('open', () => { console.log('[openclaw-ws] Connected to OpenClaw gateway'); }); - this.ws.on('message', (data) => { + ws.on('message', (data) => { this.handleMessage(data.toString()); }); - this.ws.on('close', (code, reason) => { + ws.on('close', (code, reason) => { + // Guard: ignore close events from superseded WebSocket instances. + // During v3↔v2 fallback, the old WS is replaced before its close fires. + if (this.ws !== ws) return; + const reasonStr = reason.toString(); console.warn(`[openclaw-ws] Disconnected: ${code} ${reasonStr}`); const wasAuthenticated = this.authenticated; @@ -551,20 +554,22 @@ export class OpenClawGatewayClient { this.pendingRpcs.delete(id); } // If we weren't authenticated yet, reject the connect promise - // (unless a fallback reconnect is in progress — let it proceed) - if (!wasAuthenticated && this.connectReject && !this.fallbackInProgress) { + if (!wasAuthenticated && this.connectReject) { this.clearConnectTimeout(); const err = new Error(`WebSocket closed before authentication (code=${code})`); this.connectReject(err); this.connectReject = null; this.connectResolve = null; } - if (!this.stopped && !this.fallbackInProgress) { + if (!this.stopped) { this.scheduleReconnect(); } }); - this.ws.on('error', (err) => { + ws.on('error', (err) => { + // Guard: ignore error events from superseded WebSocket instances. + if (this.ws !== ws) return; + console.warn(`[openclaw-ws] Error: ${err.message}`); // If we weren't authenticated yet, reject the connect promise if (!this.authenticated && this.connectReject) { @@ -659,7 +664,6 @@ export class OpenClawGatewayClient { // Handle connect response if (msg.type === 'res' && msg.id === 'connect-1') { - this.fallbackInProgress = false; // Clear on any connect response if (msg.ok) { this.clearConnectTimeout(); const versionUsed = this.payloadVersionOverride @@ -702,11 +706,10 @@ export class OpenClawGatewayClient { console.warn(`[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})`); this.payloadVersionOverride = fallbackVersion; this.fallbackAttempted = true; - this.fallbackInProgress = true; // Close current WS and reconnect with the alternate payload. - // fallbackInProgress stays true until the next auth response arrives, - // suppressing close-handler rejection from the old connection. + // Setting this.ws = null ensures the old WS's close/error handlers + // no-op via the `this.ws !== ws` guard in doConnect(). try { this.ws?.close(); } catch {} this.ws = null; setTimeout(() => this.doConnect(), 0); @@ -750,6 +753,7 @@ export class OpenClawGatewayClient { /** Send a chat.send RPC. Returns true if accepted. */ async sendChatMessage(text: string, idempotencyKey?: string): Promise { + if (this.stopped) return false; if (!this.authenticated || !this.ws || this.ws.readyState !== WebSocket.OPEN) { // Try to reconnect try {