it0/packages/openclaw-bridge/src/openclaw-client.ts

208 lines
6.1 KiB
TypeScript

/**
* 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<void> {
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<void> {
// 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<unknown> {
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;
}
}