From 0f8e6f3f9e8eeabe983847cf4d3bfe6ebecf719a Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Fri, 6 Feb 2026 02:17:43 +0800 Subject: [PATCH] fix(gateway): implement proper OpenClaw WebSocket handshake protocol - Send JSON-RPC connect request after WebSocket opens (required by OpenClaw Gateway) - Use OpenClaw protocol format: { type: "req" } instead of { jsonrpc: "2.0" } - Include proper ConnectParams: client info, auth token, protocol version - Handle OpenClaw response format: { type: "res", ok: true/false } - Handle OpenClaw event format: { type: "event", event: "..." } - Wait for handshake completion before marking connection as running - Improve error handling for connection failures The Gateway was rejecting connections because: 1. ClawX wasn't sending the required "connect" handshake message 2. The protocol format was incorrect (standard JSON-RPC vs OpenClaw format) --- .../commit_15_gateway_ws_handshake.md | 92 +++++++++++ build_process/process.md | 2 + electron/gateway/manager.ts | 147 ++++++++++++++++-- 3 files changed, 224 insertions(+), 17 deletions(-) create mode 100644 build_process/commit_15_gateway_ws_handshake.md diff --git a/build_process/commit_15_gateway_ws_handshake.md b/build_process/commit_15_gateway_ws_handshake.md new file mode 100644 index 000000000..daaa1a873 --- /dev/null +++ b/build_process/commit_15_gateway_ws_handshake.md @@ -0,0 +1,92 @@ +# Commit 15: Fix Gateway WebSocket Handshake + +## Summary + +Fixed the Gateway WebSocket connection instability by implementing the proper OpenClaw WebSocket handshake protocol. + +## Problem + +The Gateway connection was failing with errors like: +- `token_mismatch` - Authentication failing despite correct tokens +- `invalid handshake: first request must be connect` - Missing handshake +- Repeated connect/disconnect cycles + +Root causes: +1. ClawX was not sending the required `connect` handshake message after WebSocket opens +2. Using standard JSON-RPC 2.0 format instead of OpenClaw's custom protocol format +3. Config file had hardcoded token overriding CLI arguments + +## Solution + +### 1. Implement Proper Connect Handshake + +After WebSocket opens, send a proper connect request: + +```typescript +const connectFrame = { + type: 'req', // OpenClaw uses 'req' not 'jsonrpc: 2.0' + id: connectId, + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: 'gateway-client', + displayName: 'ClawX', + version: '0.1.0', + platform: process.platform, + mode: 'ui', + }, + auth: { + token: gatewayToken, + }, + caps: [], + role: 'operator', + scopes: [], + }, +}; +``` + +### 2. Handle OpenClaw Protocol Format + +OpenClaw Gateway uses a custom protocol format: +- Request: `{ type: "req", id, method, params }` +- Response: `{ type: "res", id, ok, payload, error }` +- Event: `{ type: "event", event, payload }` + +Updated `handleMessage` to parse these formats correctly. + +### 3. Remove Hardcoded Config Token + +The `~/.openclaw/openclaw.json` file had a hardcoded token that was overriding the CLI token. Updated to remove the auth.token field so the environment variable takes precedence. + +## Files Changed + +### `electron/gateway/manager.ts` +- **connect()**: Added proper handshake flow with connect request +- **handleMessage()**: Parse OpenClaw protocol response/event formats +- **handleProtocolEvent()**: New method to handle OpenClaw events +- **rpc()**: Use OpenClaw request format `{ type: "req" }` + +## Protocol Flow + +``` +Before (broken): + 1. Open WebSocket + 2. Immediately mark as "running" ❌ + 3. Send RPC requests (rejected - no handshake) + +After (fixed): + 1. Open WebSocket + 2. Send connect handshake with auth token + 3. Wait for response { type: "res", ok: true } + 4. Mark as "running" ✓ + 5. Send RPC requests (accepted) +``` + +## Testing + +After this fix: +- Gateway connects successfully +- WebSocket stays connected without constant reconnection +- RPC calls work correctly diff --git a/build_process/process.md b/build_process/process.md index a71987832..f152d7e5c 100644 --- a/build_process/process.md +++ b/build_process/process.md @@ -20,6 +20,7 @@ * [commit_12] Real API key validation - OpenRouter support, actual API calls to verify keys * [commit_13] Remove channel setup step - Simplified onboarding, channels moved to Settings * [commit_14] Auto-install skills step - Replace skill selection with auto-installation progress UI +* [commit_15] Fix Gateway WebSocket handshake - Implement proper OpenClaw protocol ### Plan: 1. ~~Initialize project structure~~ ✅ @@ -50,6 +51,7 @@ All core features have been implemented: - Real API key validation via actual API calls (Anthropic, OpenAI, Google, OpenRouter) - Simplified setup wizard (channel connection deferred to Settings page) - Auto-install skills step with real-time progress UI (no manual skill selection) +- Fixed Gateway WebSocket connection with proper OpenClaw handshake protocol ## Version Milestones diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index aee1cacb7..0419bc214 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -208,6 +208,7 @@ export class GatewayManager extends EventEmitter { /** * Make an RPC call to the Gateway + * Uses OpenClaw protocol format: { type: "req", id: "...", method: "...", params: {...} } */ async rpc(method: string, params?: unknown, timeoutMs = 30000): Promise { return new Promise((resolve, reject) => { @@ -231,9 +232,9 @@ export class GatewayManager extends EventEmitter { timeout, }); - // Send request + // Send request using OpenClaw protocol format const request = { - jsonrpc: '2.0', + type: 'req', id, method, params, @@ -442,20 +443,73 @@ export class GatewayManager extends EventEmitter { const gatewayToken = await getSetting('gatewayToken'); return new Promise((resolve, reject) => { - // Include token in WebSocket URL for authentication - const wsUrl = `ws://localhost:${port}/ws?auth=${encodeURIComponent(gatewayToken)}`; + // WebSocket URL (token will be sent in connect handshake, not URL) + const wsUrl = `ws://localhost:${port}/ws`; this.ws = new WebSocket(wsUrl); + let handshakeComplete = false; - this.ws.on('open', () => { - console.log('WebSocket connected to Gateway'); - this.setStatus({ - state: 'running', - port, - connectedAt: Date.now(), + this.ws.on('open', async () => { + console.log('WebSocket opened, sending connect handshake...'); + + // Send proper connect handshake as required by OpenClaw Gateway protocol + // The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams } + const connectId = `connect-${Date.now()}`; + const connectFrame = { + type: 'req', + id: connectId, + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: 'gateway-client', + displayName: 'ClawX', + version: '0.1.0', + platform: process.platform, + mode: 'ui', + }, + auth: { + token: gatewayToken, + }, + caps: [], + role: 'operator', + scopes: [], + }, + }; + + console.log('Sending connect handshake:', JSON.stringify(connectFrame)); + this.ws?.send(JSON.stringify(connectFrame)); + + // Store pending connect request + const connectTimeout = setTimeout(() => { + if (!handshakeComplete) { + console.error('Connect handshake timeout'); + reject(new Error('Connect handshake timeout')); + this.ws?.close(); + } + }, 10000); + + this.pendingRequests.set(connectId, { + resolve: (result) => { + clearTimeout(connectTimeout); + handshakeComplete = true; + console.log('WebSocket handshake complete, gateway connected'); + this.setStatus({ + state: 'running', + port, + connectedAt: Date.now(), + }); + this.startPing(); + resolve(); + }, + reject: (error) => { + clearTimeout(connectTimeout); + console.error('Connect handshake failed:', error); + reject(error); + }, + timeout: connectTimeout, }); - this.startPing(); - resolve(); }); this.ws.on('message', (data) => { @@ -467,8 +521,13 @@ export class GatewayManager extends EventEmitter { } }); - this.ws.on('close', () => { - console.log('WebSocket disconnected'); + this.ws.on('close', (code, reason) => { + const reasonStr = reason?.toString() || 'unknown'; + console.log(`WebSocket disconnected: code=${code}, reason=${reasonStr}`); + if (!handshakeComplete) { + reject(new Error(`WebSocket closed before handshake: ${reasonStr}`)); + return; + } if (this.status.state === 'running') { this.setStatus({ state: 'stopped' }); this.scheduleReconnect(); @@ -477,7 +536,9 @@ export class GatewayManager extends EventEmitter { this.ws.on('error', (error) => { console.error('WebSocket error:', error); - reject(error); + if (!handshakeComplete) { + reject(error); + } }); }); } @@ -486,7 +547,38 @@ export class GatewayManager extends EventEmitter { * Handle incoming WebSocket message */ private handleMessage(message: unknown): void { - // Check if this is a JSON-RPC response + if (typeof message !== 'object' || message === null) { + console.warn('Received non-object message:', message); + return; + } + + const msg = message as Record; + + // Handle OpenClaw protocol response format: { type: "res", id: "...", ok: true/false, ... } + if (msg.type === 'res' && typeof msg.id === 'string') { + if (this.pendingRequests.has(msg.id)) { + const request = this.pendingRequests.get(msg.id)!; + clearTimeout(request.timeout); + this.pendingRequests.delete(msg.id); + + if (msg.ok === false || msg.error) { + const errorObj = msg.error as { message?: string; code?: number } | undefined; + const errorMsg = errorObj?.message || JSON.stringify(msg.error) || 'Unknown error'; + request.reject(new Error(errorMsg)); + } else { + request.resolve(msg.payload ?? msg); + } + return; + } + } + + // Handle OpenClaw protocol event format: { type: "event", event: "...", payload: {...} } + if (msg.type === 'event' && typeof msg.event === 'string') { + this.handleProtocolEvent(msg.event, msg.payload); + return; + } + + // Fallback: Check if this is a JSON-RPC 2.0 response (legacy support) if (isResponse(message) && message.id && this.pendingRequests.has(String(message.id))) { const request = this.pendingRequests.get(String(message.id))!; clearTimeout(request.timeout); @@ -503,7 +595,7 @@ export class GatewayManager extends EventEmitter { return; } - // Check if this is a notification (server-initiated event) + // Check if this is a JSON-RPC notification (server-initiated event) if (isNotification(message)) { this.handleNotification(message); return; @@ -513,6 +605,27 @@ export class GatewayManager extends EventEmitter { this.emit('message', message); } + /** + * Handle OpenClaw protocol events + */ + private handleProtocolEvent(event: string, payload: unknown): void { + // Map OpenClaw events to our internal event types + switch (event) { + case 'tick': + // Heartbeat tick, ignore + break; + case 'chat': + this.emit('chat:message', { message: payload }); + break; + case 'channel.status': + this.emit('channel:status', payload as { channelId: string; status: string }); + break; + default: + // Forward unknown events as generic notifications + this.emit('notification', { method: event, params: payload }); + } + } + /** * Handle server-initiated notifications */