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/README.md +301 -232
- package/dist/client.d.ts +21 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +113 -9
- package/dist/device-identity.d.ts +40 -0
- package/dist/device-identity.d.ts.map +1 -0
- package/dist/device-identity.js +73 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/reconnect.d.ts +24 -0
- package/dist/reconnect.d.ts.map +1 -0
- package/dist/reconnect.js +52 -0
- package/package.json +1 -1
- package/src/client.ts +150 -11
- package/src/device-identity.ts +131 -0
- package/src/index.ts +2 -0
- package/src/reconnect.ts +68 -0
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
|
-
|
|
124
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
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:
|
|
359
|
+
id: clientId,
|
|
281
360
|
version: this.config.clientVersion || '1.0.0',
|
|
282
361
|
platform: this.config.platform || 'web',
|
|
283
|
-
mode:
|
|
362
|
+
mode: clientMode,
|
|
284
363
|
},
|
|
285
|
-
role
|
|
286
|
-
scopes
|
|
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
|
-
|
|
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
package/src/reconnect.ts
ADDED
|
@@ -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
|
+
}
|