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:
92
build_process/commit_15_gateway_ws_handshake.md
Normal file
92
build_process/commit_15_gateway_ws_handshake.md
Normal 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
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
* [commit_12] Real API key validation - OpenRouter support, actual API calls to verify keys
|
* [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_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_14] Auto-install skills step - Replace skill selection with auto-installation progress UI
|
||||||
|
* [commit_15] Fix Gateway WebSocket handshake - Implement proper OpenClaw protocol
|
||||||
|
|
||||||
### Plan:
|
### Plan:
|
||||||
1. ~~Initialize project structure~~ ✅
|
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)
|
- Real API key validation via actual API calls (Anthropic, OpenAI, Google, OpenRouter)
|
||||||
- Simplified setup wizard (channel connection deferred to Settings page)
|
- Simplified setup wizard (channel connection deferred to Settings page)
|
||||||
- Auto-install skills step with real-time progress UI (no manual skill selection)
|
- Auto-install skills step with real-time progress UI (no manual skill selection)
|
||||||
|
- Fixed Gateway WebSocket connection with proper OpenClaw handshake protocol
|
||||||
|
|
||||||
## Version Milestones
|
## Version Milestones
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an RPC call to the Gateway
|
* 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> {
|
async rpc<T>(method: string, params?: unknown, timeoutMs = 30000): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -231,9 +232,9 @@ export class GatewayManager extends EventEmitter {
|
|||||||
timeout,
|
timeout,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send request
|
// Send request using OpenClaw protocol format
|
||||||
const request = {
|
const request = {
|
||||||
jsonrpc: '2.0',
|
type: 'req',
|
||||||
id,
|
id,
|
||||||
method,
|
method,
|
||||||
params,
|
params,
|
||||||
@@ -442,20 +443,73 @@ export class GatewayManager extends EventEmitter {
|
|||||||
const gatewayToken = await getSetting('gatewayToken');
|
const gatewayToken = await getSetting('gatewayToken');
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Include token in WebSocket URL for authentication
|
// WebSocket URL (token will be sent in connect handshake, not URL)
|
||||||
const wsUrl = `ws://localhost:${port}/ws?auth=${encodeURIComponent(gatewayToken)}`;
|
const wsUrl = `ws://localhost:${port}/ws`;
|
||||||
|
|
||||||
this.ws = new WebSocket(wsUrl);
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
let handshakeComplete = false;
|
||||||
|
|
||||||
this.ws.on('open', () => {
|
this.ws.on('open', async () => {
|
||||||
console.log('WebSocket connected to Gateway');
|
console.log('WebSocket opened, sending connect handshake...');
|
||||||
this.setStatus({
|
|
||||||
state: 'running',
|
// Send proper connect handshake as required by OpenClaw Gateway protocol
|
||||||
port,
|
// The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams }
|
||||||
connectedAt: Date.now(),
|
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) => {
|
this.ws.on('message', (data) => {
|
||||||
@@ -467,8 +521,13 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ws.on('close', () => {
|
this.ws.on('close', (code, reason) => {
|
||||||
console.log('WebSocket disconnected');
|
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') {
|
if (this.status.state === 'running') {
|
||||||
this.setStatus({ state: 'stopped' });
|
this.setStatus({ state: 'stopped' });
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
@@ -477,7 +536,9 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
this.ws.on('error', (error) => {
|
this.ws.on('error', (error) => {
|
||||||
console.error('WebSocket 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
|
* Handle incoming WebSocket message
|
||||||
*/
|
*/
|
||||||
private handleMessage(message: unknown): void {
|
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))) {
|
if (isResponse(message) && message.id && this.pendingRequests.has(String(message.id))) {
|
||||||
const request = this.pendingRequests.get(String(message.id))!;
|
const request = this.pendingRequests.get(String(message.id))!;
|
||||||
clearTimeout(request.timeout);
|
clearTimeout(request.timeout);
|
||||||
@@ -503,7 +595,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a notification (server-initiated event)
|
// Check if this is a JSON-RPC notification (server-initiated event)
|
||||||
if (isNotification(message)) {
|
if (isNotification(message)) {
|
||||||
this.handleNotification(message);
|
this.handleNotification(message);
|
||||||
return;
|
return;
|
||||||
@@ -513,6 +605,27 @@ export class GatewayManager extends EventEmitter {
|
|||||||
this.emit('message', message);
|
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
|
* Handle server-initiated notifications
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user