/** * OpenClaw Gateway WebSocket client — Protocol v3 * * Correct wire format (verified from openclaw/openclaw source): * Request: { type:"req", id, method, params?, idempotencyKey? } * Response: { type:"res", id, ok:boolean, payload?, error? } * Event: { type:"event", event, payload?, seq? } * * Authentication: * 1. Server sends "connect.challenge" event with { nonce, timestamp } * 2. Client signs the nonce with an ephemeral Ed25519 key * 3. Client sends "connect" req with auth.token + device.{id,publicKey,signature,signedAt} * 4. Server responds with ok:true → connection is ready */ import WebSocket from 'ws'; import * as crypto from 'crypto'; // ── Wire types ──────────────────────────────────────────────────────────────── interface ReqFrame { type: 'req'; id: string; method: string; params?: unknown; idempotencyKey?: string; } interface ResFrame { type: 'res'; id: string; ok: boolean; payload?: unknown; error?: { code: string; message: string; retryable?: boolean; retryAfterMs?: number }; } interface EventFrame { type: 'event'; event: string; payload?: unknown; seq?: number; } type Frame = ReqFrame | ResFrame | EventFrame; // ── Client ──────────────────────────────────────────────────────────────────── export class OpenClawClient { private ws: WebSocket | null = null; private pending = new Map< string, { resolve: (v: unknown) => void; reject: (e: Error) => void } >(); private connected = false; private msgCounter = 0; // Ephemeral Ed25519 key pair — generated once per client instance private readonly deviceId = crypto.randomUUID(); private readonly keyPair = crypto.generateKeyPairSync('ed25519'); constructor( private readonly gatewayUrl: string, private readonly token: string, ) {} connect(): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(this.gatewayUrl); this.ws = ws; // Whether the handshake promise has settled (prevents double-settle) let settled = false; const settle = (err?: Error) => { if (settled) return; settled = true; if (err) reject(err); else resolve(); }; ws.on('message', (raw) => { let frame: Frame; try { frame = JSON.parse(raw.toString()); } catch { return; } this.handleFrame(frame, settle); }); ws.once('error', (err) => { if (!this.connected) settle(err); }); ws.on('close', () => { this.connected = false; for (const [, p] of this.pending) { p.reject(new Error('OpenClaw gateway disconnected')); } this.pending.clear(); }); // Safety timeout for handshake (gateway has 10s timeout itself) setTimeout(() => settle(new Error('Handshake timeout')), 12_000); }); } private handleFrame( frame: Frame, settle: (err?: Error) => void, ): void { // Step 1: Server sends challenge if (frame.type === 'event' && frame.event === 'connect.challenge') { const { nonce } = frame.payload as { nonce: string; timestamp: number }; this.sendHandshake(nonce).catch((e) => settle(e)); return; } // Step 2: Server acknowledges handshake if (frame.type === 'res' && frame.id === '__connect__') { if (frame.ok) { this.connected = true; settle(); } else { settle(new Error(`OpenClaw handshake rejected: ${frame.error?.message ?? frame.error?.code}`)); } return; } // Regular RPC responses if (frame.type === 'res') { const p = this.pending.get(frame.id); if (!p) return; this.pending.delete(frame.id); if (frame.ok) { p.resolve(frame.payload ?? null); } else { p.reject(new Error(frame.error?.message ?? frame.error?.code ?? 'RPC error')); } } // Events are ignored by default (not needed for the bridge use case) } private async sendHandshake(nonce: string): Promise { // Sign the nonce (hex string → Buffer) with our ephemeral private key const nonceBuffer = Buffer.from(nonce, 'hex'); const signature = crypto.sign(null, nonceBuffer, this.keyPair.privateKey); const pubKeyDer = this.keyPair.publicKey.export({ type: 'spki', format: 'der' }); const req: ReqFrame = { type: 'req', id: '__connect__', method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: this.deviceId, version: '1.0.0', platform: 'node', mode: 'channel', }, role: 'operator', scopes: ['operator.read', 'operator.write'], auth: { token: this.token }, device: { id: this.deviceId, publicKey: pubKeyDer.toString('base64'), signature: signature.toString('base64'), signedAt: Date.now(), }, }, }; this.ws!.send(JSON.stringify(req)); } isConnected(): boolean { return this.connected && this.ws?.readyState === WebSocket.OPEN; } rpc(method: string, params?: unknown, timeoutMs = 30_000): Promise { return new Promise((resolve, reject) => { if (!this.isConnected()) { reject(new Error('Not connected to OpenClaw gateway')); return; } const id = String(++this.msgCounter); const frame: ReqFrame = { type: 'req', id, method, params }; this.pending.set(id, { resolve, reject }); this.ws!.send(JSON.stringify(frame)); const timer = setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`RPC timeout: ${method}`)); } }, timeoutMs); // Don't keep process alive just for the timer if (timer.unref) timer.unref(); }); } close(): void { this.ws?.close(); this.connected = false; } }