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)
This commit is contained in:
Haze
2026-02-06 02:17:43 +08:00
Unverified
parent 931fea3735
commit 0f8e6f3f9e
3 changed files with 224 additions and 17 deletions

View File

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

View File

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

View File

@@ -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<T>(method: string, params?: unknown, timeoutMs = 30000): Promise<T> {
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<string, unknown>;
// 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
*/