208 lines
6.1 KiB
TypeScript
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;
|
|
}
|
|
}
|