openclaw-client 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import type { DeviceIdentityStore, DeviceTokenStore } from './device-identity';
2
+ import { DeviceIdentityManager } from './device-identity';
3
+ import type { ReconnectConfig } from './reconnect';
4
+ import { ReconnectController } from './reconnect';
1
5
  import type {
2
6
  AgentIdentityParams,
3
7
  AgentIdentityResult,
@@ -120,8 +124,22 @@ export interface OpenClawClientConfig {
120
124
  * Can be a static object or a function that receives the challenge
121
125
  * (useful for signing the nonce into `device.nonce`). */
122
126
  connectParams?:
123
- | Partial<ConnectParams>
124
- | ((challenge: { nonce: string; ts: number }) => Partial<ConnectParams> | Promise<Partial<ConnectParams>>);
127
+ | Partial<ConnectParams>
128
+ | ((challenge: { nonce: string; ts: number }) => Partial<ConnectParams> | Promise<Partial<ConnectParams>>);
129
+ /** Provide a DeviceIdentityStore to enable built-in Ed25519 device signing.
130
+ * When set, the library generates/loads a keypair and signs the connect
131
+ * request automatically (takes precedence over connectParams function). */
132
+ deviceIdentity?: DeviceIdentityStore;
133
+ /** Optional store for persisting the device token returned by the gateway
134
+ * after pairing approval. The stored token is preferred over config.token
135
+ * for subsequent connections. */
136
+ deviceToken?: DeviceTokenStore;
137
+ /** Enable automatic reconnection with exponential backoff. */
138
+ reconnect?: ReconnectConfig;
139
+ /** Called when connection state changes. */
140
+ onConnection?: (connected: boolean) => void;
141
+ /** Called when the gateway indicates device pairing is required. */
142
+ onPairingRequired?: (required: boolean) => void;
125
143
  }
126
144
 
127
145
  export type EventListener = (event: EventFrame) => void;
@@ -148,9 +166,22 @@ export class OpenClawClient {
148
166
  private eventListeners: EventListener[] = [];
149
167
  private connected = false;
150
168
  private connectionId: string | null = null;
169
+ private deviceManager: DeviceIdentityManager | null = null;
170
+ private reconnectController: ReconnectController | null = null;
171
+ private reconnecting = false;
172
+ private intentionalDisconnect = false;
151
173
 
152
174
  constructor(config: OpenClawClientConfig) {
153
175
  this.config = config;
176
+ if (config.deviceIdentity) {
177
+ this.deviceManager = new DeviceIdentityManager(
178
+ config.deviceIdentity,
179
+ config.deviceToken,
180
+ );
181
+ }
182
+ if (config.reconnect?.enabled) {
183
+ this.reconnectController = new ReconnectController(config.reconnect);
184
+ }
154
185
  }
155
186
 
156
187
  /**
@@ -167,6 +198,8 @@ export class OpenClawClient {
167
198
  throw new Error('Already connected');
168
199
  }
169
200
 
201
+ this.intentionalDisconnect = false;
202
+
170
203
  // Create WebSocket connection
171
204
  const WS = getWebSocketConstructor();
172
205
  this.ws = new WS(this.config.gatewayUrl);
@@ -216,6 +249,14 @@ export class OpenClawClient {
216
249
  const result = await this.handshake(challenge);
217
250
  this.connected = true;
218
251
  this.connectionId = result.server.connId;
252
+
253
+ // Save device token if returned and store is configured
254
+ if (result.auth?.deviceToken && this.deviceManager) {
255
+ await this.deviceManager.saveDeviceToken(result.auth.deviceToken);
256
+ }
257
+
258
+ this.reconnectController?.reset();
259
+ this.config.onConnection?.(true);
219
260
  return result;
220
261
  }
221
262
 
@@ -223,12 +264,16 @@ export class OpenClawClient {
223
264
  * Disconnect from the Gateway
224
265
  */
225
266
  disconnect(): void {
267
+ this.intentionalDisconnect = true;
268
+ this.reconnectController?.abort();
269
+
226
270
  if (this.ws) {
227
271
  this.ws.close();
228
272
  this.ws = null;
229
273
  }
230
274
  this.connected = false;
231
275
  this.connectionId = null;
276
+ this.config.onConnection?.(false);
232
277
 
233
278
  // Reject all pending requests
234
279
  for (const [id, { reject }] of this.pending.entries()) {
@@ -269,21 +314,55 @@ export class OpenClawClient {
269
314
  * Uses connectTimeoutMs (default 120s) since device approval may take a while.
270
315
  */
271
316
  private async handshake(challenge: { nonce: string; ts: number }): Promise<HelloOk> {
272
- const connectParams = typeof this.config.connectParams === 'function'
273
- ? await this.config.connectParams(challenge)
274
- : (this.config.connectParams ?? {});
317
+ const clientId = this.config.clientId || 'webchat-ui';
318
+ const clientMode = this.config.mode || 'ui';
319
+ const role = 'operator';
320
+ const scopes = ['operator.read', 'operator.write', 'operator.admin'];
321
+
322
+ let connectParams: Partial<ConnectParams> = {};
323
+
324
+ if (this.deviceManager) {
325
+ // Built-in device identity takes precedence over connectParams function
326
+ const storedToken = await this.deviceManager.getDeviceToken();
327
+ const token = storedToken || this.config.token;
328
+
329
+ const device = await this.deviceManager.buildConnectDevice({
330
+ clientId,
331
+ clientMode,
332
+ role,
333
+ scopes,
334
+ token,
335
+ nonce: challenge.nonce,
336
+ });
337
+
338
+ // Merge with static connectParams (for caps, etc.) but not function form
339
+ const staticParams =
340
+ typeof this.config.connectParams === 'function'
341
+ ? {}
342
+ : (this.config.connectParams ?? {});
343
+
344
+ connectParams = { ...staticParams, device };
345
+ // Override auth token if we have a stored device token
346
+ if (storedToken) {
347
+ connectParams.auth = { token: storedToken };
348
+ }
349
+ } else if (typeof this.config.connectParams === 'function') {
350
+ connectParams = await this.config.connectParams(challenge);
351
+ } else {
352
+ connectParams = this.config.connectParams ?? {};
353
+ }
275
354
 
276
355
  const params: ConnectParams = {
277
356
  minProtocol: 3,
278
357
  maxProtocol: 3,
279
358
  client: {
280
- id: this.config.clientId || 'webchat-ui',
359
+ id: clientId,
281
360
  version: this.config.clientVersion || '1.0.0',
282
361
  platform: this.config.platform || 'web',
283
- mode: this.config.mode || 'ui',
362
+ mode: clientMode,
284
363
  },
285
- role: 'operator',
286
- scopes: ['operator.read', 'operator.write', 'operator.admin'],
364
+ role,
365
+ scopes,
287
366
  auth: {
288
367
  token: this.config.token,
289
368
  },
@@ -382,6 +461,15 @@ export class OpenClawClient {
382
461
  * Handle event frame
383
462
  */
384
463
  private handleEvent(frame: EventFrame): void {
464
+ // Fire onPairingRequired for device pairing events
465
+ if (this.config.onPairingRequired) {
466
+ if (frame.event === 'device.pair.required') {
467
+ this.config.onPairingRequired(true);
468
+ } else if (frame.event === 'device.pair.approved') {
469
+ this.config.onPairingRequired(false);
470
+ }
471
+ }
472
+
385
473
  for (const listener of this.eventListeners) {
386
474
  try {
387
475
  listener(frame);
@@ -391,12 +479,63 @@ export class OpenClawClient {
391
479
  }
392
480
  }
393
481
 
482
+ private attemptReconnect(): void {
483
+ if (this.reconnecting) return;
484
+ this.reconnecting = true;
485
+
486
+ const loop = async () => {
487
+ while (
488
+ !this.intentionalDisconnect &&
489
+ this.reconnectController &&
490
+ this.reconnectController.canRetry()
491
+ ) {
492
+ try {
493
+ await this.reconnectController.schedule();
494
+ } catch {
495
+ // Aborted or max attempts exceeded
496
+ break;
497
+ }
498
+
499
+ if (this.intentionalDisconnect) break;
500
+
501
+ try {
502
+ await this.connect();
503
+ break; // Success
504
+ } catch {
505
+ // Will retry on next iteration
506
+ }
507
+ }
508
+ this.reconnecting = false;
509
+ };
510
+
511
+ loop();
512
+ }
513
+
394
514
  /**
395
515
  * Handle connection close
396
516
  */
397
517
  private handleClose(): void {
518
+ const wasConnected = this.connected;
398
519
  this.connected = false;
399
- console.log('WebSocket connection closed');
520
+ this.connectionId = null;
521
+
522
+ if (wasConnected) {
523
+ this.config.onConnection?.(false);
524
+ }
525
+
526
+ // Reject all pending requests
527
+ for (const [id, { reject }] of this.pending.entries()) {
528
+ reject(new Error('Connection closed'));
529
+ this.pending.delete(id);
530
+ }
531
+
532
+ if (
533
+ !this.intentionalDisconnect &&
534
+ this.reconnectController &&
535
+ this.reconnectController.canRetry()
536
+ ) {
537
+ this.attemptReconnect();
538
+ }
400
539
  }
401
540
 
402
541
  /**
@@ -498,7 +637,7 @@ export class OpenClawClient {
498
637
  * Get agent identity
499
638
  */
500
639
  async getAgentIdentity(params: AgentIdentityParams = {}): Promise<AgentIdentityResult> {
501
- return this.request<AgentIdentityResult>('agent.identity', params);
640
+ return this.request<AgentIdentityResult>('agent.identity.get', params);
502
641
  }
503
642
 
504
643
  /**
@@ -0,0 +1,131 @@
1
+ export interface DeviceIdentityRecord {
2
+ /** Hex-encoded SHA-256 of the raw 32-byte public key */
3
+ id: string;
4
+ /** Base64url-encoded (no padding) raw 32-byte public key */
5
+ publicKey: string;
6
+ /** JWK of the Ed25519 private key (for re-import) */
7
+ privateKeyJwk: JsonWebKey;
8
+ }
9
+
10
+ export interface DeviceIdentityStore {
11
+ load(): Promise<DeviceIdentityRecord | null>;
12
+ save(record: DeviceIdentityRecord): Promise<void>;
13
+ }
14
+
15
+ export interface DeviceTokenStore {
16
+ load(): Promise<string | null>;
17
+ save(token: string): Promise<void>;
18
+ }
19
+
20
+ export interface BuildConnectDeviceOptions {
21
+ clientId: string;
22
+ clientMode: string;
23
+ role: string;
24
+ scopes: string[];
25
+ token: string;
26
+ nonce: string;
27
+ }
28
+
29
+ function base64urlEncode(bytes: Uint8Array): string {
30
+ let binary = '';
31
+ for (let i = 0; i < bytes.length; i++) {
32
+ binary += String.fromCharCode(bytes[i]);
33
+ }
34
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
35
+ }
36
+
37
+ function hexEncode(bytes: Uint8Array): string {
38
+ return Array.from(bytes)
39
+ .map((b) => b.toString(16).padStart(2, '0'))
40
+ .join('');
41
+ }
42
+
43
+ async function generateEd25519Keypair(): Promise<CryptoKeyPair> {
44
+ return crypto.subtle.generateKey('Ed25519', true, [
45
+ 'sign',
46
+ 'verify',
47
+ ]) as Promise<CryptoKeyPair>;
48
+ }
49
+
50
+ async function deriveDeviceId(rawPublicKey: ArrayBuffer): Promise<string> {
51
+ const hash = await crypto.subtle.digest('SHA-256', rawPublicKey);
52
+ return hexEncode(new Uint8Array(hash));
53
+ }
54
+
55
+ async function signV2Payload(
56
+ privateKeyJwk: JsonWebKey,
57
+ payload: string,
58
+ ): Promise<string> {
59
+ const key = await crypto.subtle.importKey(
60
+ 'jwk',
61
+ privateKeyJwk,
62
+ 'Ed25519',
63
+ false,
64
+ ['sign'],
65
+ );
66
+ const data = new TextEncoder().encode(payload);
67
+ const signature = await crypto.subtle.sign('Ed25519', key, data);
68
+ return base64urlEncode(new Uint8Array(signature));
69
+ }
70
+
71
+ export class DeviceIdentityManager {
72
+ private identityStore: DeviceIdentityStore;
73
+ private tokenStore: DeviceTokenStore | null;
74
+
75
+ constructor(
76
+ identityStore: DeviceIdentityStore,
77
+ tokenStore?: DeviceTokenStore,
78
+ ) {
79
+ this.identityStore = identityStore;
80
+ this.tokenStore = tokenStore ?? null;
81
+ }
82
+
83
+ async getOrCreateIdentity(): Promise<DeviceIdentityRecord> {
84
+ const existing = await this.identityStore.load();
85
+ if (existing) return existing;
86
+
87
+ const keypair = await generateEd25519Keypair();
88
+ const rawPublicKey = await crypto.subtle.exportKey('raw', keypair.publicKey);
89
+ const privateKeyJwk = await crypto.subtle.exportKey('jwk', keypair.privateKey);
90
+ const id = await deriveDeviceId(rawPublicKey);
91
+ const publicKey = base64urlEncode(new Uint8Array(rawPublicKey));
92
+
93
+ const record: DeviceIdentityRecord = { id, publicKey, privateKeyJwk };
94
+ await this.identityStore.save(record);
95
+ return record;
96
+ }
97
+
98
+ async buildConnectDevice(
99
+ opts: BuildConnectDeviceOptions,
100
+ ): Promise<{
101
+ id: string;
102
+ publicKey: string;
103
+ signature: string;
104
+ signedAt: number;
105
+ nonce: string;
106
+ }> {
107
+ const identity = await this.getOrCreateIdentity();
108
+ const signedAt = Date.now();
109
+ const scopeStr = opts.scopes.join(',');
110
+ const payload = `v2|${identity.id}|${opts.clientId}|${opts.clientMode}|${opts.role}|${scopeStr}|${signedAt}|${opts.token}|${opts.nonce}`;
111
+ const signature = await signV2Payload(identity.privateKeyJwk, payload);
112
+
113
+ return {
114
+ id: identity.id,
115
+ publicKey: identity.publicKey,
116
+ signature,
117
+ signedAt,
118
+ nonce: opts.nonce,
119
+ };
120
+ }
121
+
122
+ async getDeviceToken(): Promise<string | null> {
123
+ if (!this.tokenStore) return null;
124
+ return this.tokenStore.load();
125
+ }
126
+
127
+ async saveDeviceToken(token: string): Promise<void> {
128
+ if (!this.tokenStore) return;
129
+ await this.tokenStore.save(token);
130
+ }
131
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from './client';
2
+ export * from './device-identity';
3
+ export * from './reconnect';
2
4
  export * from './server-client';
3
5
  export * from './types';
@@ -0,0 +1,68 @@
1
+ export interface ReconnectConfig {
2
+ enabled: boolean;
3
+ /** Base delay in ms (default: 1000) */
4
+ baseDelay?: number;
5
+ /** Maximum delay in ms (default: 30000) */
6
+ maxDelay?: number;
7
+ /** Maximum number of reconnect attempts (default: Infinity) */
8
+ maxAttempts?: number;
9
+ }
10
+
11
+ export class ReconnectController {
12
+ private baseDelay: number;
13
+ private maxDelay: number;
14
+ private maxAttempts: number;
15
+ private attempt = 0;
16
+ private aborted = false;
17
+ private pendingTimer: ReturnType<typeof setTimeout> | null = null;
18
+
19
+ constructor(config: ReconnectConfig) {
20
+ this.baseDelay = config.baseDelay ?? 1000;
21
+ this.maxDelay = config.maxDelay ?? 30000;
22
+ this.maxAttempts = config.maxAttempts ?? Infinity;
23
+ }
24
+
25
+ nextDelay(): number | null {
26
+ if (this.aborted || this.attempt >= this.maxAttempts) return null;
27
+ const exponential = Math.min(
28
+ this.baseDelay * Math.pow(2, this.attempt),
29
+ this.maxDelay,
30
+ );
31
+ // Jitter: random between 50%-100% of the exponential value
32
+ const jitter = 0.5 + Math.random() * 0.5;
33
+ this.attempt++;
34
+ return Math.round(exponential * jitter);
35
+ }
36
+
37
+ schedule(): Promise<void> {
38
+ const delay = this.nextDelay();
39
+ if (delay === null) return Promise.reject(new Error('Max reconnect attempts exceeded'));
40
+ return new Promise((resolve, reject) => {
41
+ this.pendingTimer = setTimeout(() => {
42
+ this.pendingTimer = null;
43
+ if (this.aborted) {
44
+ reject(new Error('Reconnect aborted'));
45
+ } else {
46
+ resolve();
47
+ }
48
+ }, delay);
49
+ });
50
+ }
51
+
52
+ reset(): void {
53
+ this.attempt = 0;
54
+ this.aborted = false;
55
+ }
56
+
57
+ abort(): void {
58
+ this.aborted = true;
59
+ if (this.pendingTimer !== null) {
60
+ clearTimeout(this.pendingTimer);
61
+ this.pendingTimer = null;
62
+ }
63
+ }
64
+
65
+ canRetry(): boolean {
66
+ return !this.aborted && this.attempt < this.maxAttempts;
67
+ }
68
+ }