agent-relay 3.1.7 → 3.1.9

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.
Files changed (38) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/package.json +8 -8
  6. package/packages/acp-bridge/package.json +2 -2
  7. package/packages/config/package.json +1 -1
  8. package/packages/hooks/package.json +4 -4
  9. package/packages/memory/package.json +2 -2
  10. package/packages/openclaw/dist/__tests__/ws-client.test.js +69 -0
  11. package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -1
  12. package/packages/openclaw/dist/cli.js +8 -1
  13. package/packages/openclaw/dist/cli.js.map +1 -1
  14. package/packages/openclaw/dist/config.d.ts +6 -2
  15. package/packages/openclaw/dist/config.d.ts.map +1 -1
  16. package/packages/openclaw/dist/config.js +95 -7
  17. package/packages/openclaw/dist/config.js.map +1 -1
  18. package/packages/openclaw/dist/gateway.d.ts +8 -0
  19. package/packages/openclaw/dist/gateway.d.ts.map +1 -1
  20. package/packages/openclaw/dist/gateway.js +269 -34
  21. package/packages/openclaw/dist/gateway.js.map +1 -1
  22. package/packages/openclaw/dist/setup.d.ts.map +1 -1
  23. package/packages/openclaw/dist/setup.js +117 -15
  24. package/packages/openclaw/dist/setup.js.map +1 -1
  25. package/packages/openclaw/package.json +2 -2
  26. package/packages/openclaw/skill/SKILL.md +103 -0
  27. package/packages/openclaw/src/__tests__/ws-client.test.ts +72 -0
  28. package/packages/openclaw/src/cli.ts +7 -1
  29. package/packages/openclaw/src/config.ts +94 -8
  30. package/packages/openclaw/src/gateway.ts +328 -36
  31. package/packages/openclaw/src/setup.ts +120 -13
  32. package/packages/policy/package.json +2 -2
  33. package/packages/sdk/package.json +2 -2
  34. package/packages/sdk-py/pyproject.toml +1 -1
  35. package/packages/telemetry/package.json +1 -1
  36. package/packages/trajectory/package.json +2 -2
  37. package/packages/user-directory/package.json +2 -2
  38. package/packages/utils/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { createHash, createPrivateKey, generateKeyPairSync, sign, type KeyObject } from 'node:crypto';
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify, type KeyObject } from 'node:crypto';
2
2
  import { chmod, readFile, rename, writeFile, mkdir } from 'node:fs/promises';
3
3
  import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
4
4
  import { join } from 'node:path';
@@ -16,7 +16,7 @@ import type {
16
16
  } from '@relaycast/sdk';
17
17
  import WebSocket from 'ws';
18
18
 
19
- import { openclawHome } from './config.js';
19
+ import { openclawHome, detectOpenClaw } from './config.js';
20
20
  import { DEFAULT_OPENCLAW_GATEWAY_PORT, type GatewayConfig, type InboundMessage, type DeliveryResult } from './types.js';
21
21
  import { SpawnManager } from './spawn/manager.js';
22
22
  import type { SpawnOptions } from './spawn/types.js';
@@ -49,13 +49,81 @@ function normalizeChannelName(channel: string): string {
49
49
  // Ed25519 device identity for OpenClaw gateway WebSocket auth
50
50
  // ---------------------------------------------------------------------------
51
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // Auth profile system — deterministic profile selection for WS auth across
54
+ // OpenClaw/Clawdbot versions. Profiles define key encoding, signature format,
55
+ // and payload canonicalization for the device auth handshake.
56
+ // ---------------------------------------------------------------------------
57
+
58
+ interface AuthProfile {
59
+ /** Human-readable profile name (logged on each auth attempt). */
60
+ name: string;
61
+ /** Encoding for the public key sent in the connect message. */
62
+ publicKeyFormat: 'raw-base64url' | 'spki-pem';
63
+ /** Encoding for the Ed25519 signature. */
64
+ signatureEncoding: 'base64url' | 'base64';
65
+ }
66
+
67
+ const AUTH_PROFILES: Record<string, AuthProfile> = {
68
+ default: {
69
+ name: 'default',
70
+ publicKeyFormat: 'raw-base64url',
71
+ signatureEncoding: 'base64url',
72
+ },
73
+ 'clawdbot-v1': {
74
+ // Server (openclaw/openclaw device-identity.ts) accepts both PEM and raw-base64url
75
+ // public keys, and decodes signatures in both base64url and base64. Use base64url
76
+ // for consistency — matches the server's own signDevicePayload() output.
77
+ name: 'clawdbot-v1',
78
+ publicKeyFormat: 'raw-base64url',
79
+ signatureEncoding: 'base64url',
80
+ },
81
+ };
82
+
83
+ /**
84
+ * Resolve the auth profile to use. Selection priority:
85
+ * 1. Explicit env var `OPENCLAW_WS_AUTH_COMPAT` (manual override, highest priority)
86
+ * 2. Variant detection: `~/.clawdbot/` detected → clawdbot-v1
87
+ * 3. Default profile (standard OpenClaw, unchanged)
88
+ */
89
+ function resolveAuthProfile(): AuthProfile {
90
+ // 1. Manual override (highest priority)
91
+ const envVal = process.env.OPENCLAW_WS_AUTH_COMPAT;
92
+ if (envVal === 'clawdbot' || envVal === 'clawdbot-v1') {
93
+ return AUTH_PROFILES['clawdbot-v1'];
94
+ }
95
+ if (envVal && AUTH_PROFILES[envVal]) {
96
+ return AUTH_PROFILES[envVal];
97
+ }
98
+
99
+ // 2. Variant detection via filesystem probing — delegates to openclawHome()
100
+ // which checks valid parseable config files, not just directory existence.
101
+ // Strict suffix check avoids false positives from substring matching.
102
+ const home = openclawHome();
103
+ const homeSuffix = home.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '';
104
+ if (homeSuffix === '.clawdbot' || homeSuffix === 'clawdbot') {
105
+ return AUTH_PROFILES['clawdbot-v1'];
106
+ }
107
+
108
+ // 3. Default
109
+ return AUTH_PROFILES['default'];
110
+ }
111
+
112
+ /** Backward-compat helper — returns 'clawdbot' when using clawdbot profile. */
113
+ type WsAuthCompat = 'clawdbot' | undefined;
114
+ function getWsAuthCompat(): WsAuthCompat {
115
+ const profile = resolveAuthProfile();
116
+ return profile.name === 'clawdbot-v1' ? 'clawdbot' : undefined;
117
+ }
118
+
52
119
  interface DeviceIdentity {
53
- publicKeyB64: string; // base64url-encoded raw Ed25519 public key
120
+ publicKeyB64: string; // base64url-encoded raw Ed25519 public key (default mode)
121
+ publicKeyPem?: string; // PEM-encoded SPKI public key (clawdbot compat mode)
54
122
  privateKeyObj: KeyObject; // Node.js KeyObject for signing
55
123
  deviceId: string; // SHA-256 hex of the raw public key
56
124
  }
57
125
 
58
- function generateDeviceIdentity(): DeviceIdentity {
126
+ function generateDeviceIdentity(compat?: WsAuthCompat): DeviceIdentity {
59
127
  const { publicKey, privateKey } = generateKeyPairSync('ed25519');
60
128
 
61
129
  // Extract raw 32-byte public key from SPKI DER (12-byte header for Ed25519)
@@ -64,11 +132,17 @@ function generateDeviceIdentity(): DeviceIdentity {
64
132
  const deviceId = createHash('sha256').update(rawPublicBytes).digest('hex');
65
133
  const publicKeyB64 = Buffer.from(rawPublicBytes).toString('base64url');
66
134
 
67
- return {
135
+ const identity: DeviceIdentity = {
68
136
  publicKeyB64,
69
137
  privateKeyObj: privateKey,
70
138
  deviceId,
71
139
  };
140
+
141
+ if (compat === 'clawdbot') {
142
+ identity.publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
143
+ }
144
+
145
+ return identity;
72
146
  }
73
147
 
74
148
  /** Path to persisted device identity file. */
@@ -80,6 +154,10 @@ interface PersistedDevice {
80
154
  publicKeyB64: string;
81
155
  privateKeyPkcs8B64: string; // base64-encoded PKCS#8 DER
82
156
  deviceId: string;
157
+ /** PEM-encoded SPKI public key — present when generated with clawdbot compat mode. */
158
+ publicKeyPem?: string;
159
+ /** PEM-encoded PKCS#8 private key — present when generated with clawdbot compat mode. */
160
+ privateKeyPem?: string;
83
161
  }
84
162
 
85
163
  /**
@@ -89,6 +167,7 @@ interface PersistedDevice {
89
167
  */
90
168
  async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
91
169
  const filePath = deviceIdentityPath();
170
+ const compat = getWsAuthCompat();
92
171
 
93
172
  // Attempt to load existing identity (no existsSync — just try the read)
94
173
  try {
@@ -102,11 +181,31 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
102
181
  // Ensure permissions are tight even if file was created with looser perms
103
182
  await chmod(filePath, 0o600).catch(() => {});
104
183
  console.log(`[openclaw-ws] Loaded persisted device identity (deviceId=${persisted.deviceId.slice(0, 12)}...)`);
105
- return {
184
+
185
+ const identity: DeviceIdentity = {
106
186
  publicKeyB64: persisted.publicKeyB64,
107
187
  privateKeyObj,
108
188
  deviceId: persisted.deviceId,
109
189
  };
190
+
191
+ // If compat mode is clawdbot but the persisted device has no PEM keys,
192
+ // derive them on-the-fly from the existing DER key material.
193
+ if (compat === 'clawdbot') {
194
+ if (persisted.publicKeyPem) {
195
+ identity.publicKeyPem = persisted.publicKeyPem;
196
+ } else {
197
+ // Reconstruct SPKI public key from the stored base64url raw bytes
198
+ const rawPublicBytes = Buffer.from(persisted.publicKeyB64, 'base64url');
199
+ // Ed25519 SPKI DER = 12-byte header + 32-byte raw key
200
+ const spkiHeader = Buffer.from('302a300506032b6570032100', 'hex');
201
+ const spkiDer = Buffer.concat([spkiHeader, rawPublicBytes]);
202
+ const publicKeyObj = createPublicKey({ key: spkiDer, format: 'der', type: 'spki' });
203
+ identity.publicKeyPem = publicKeyObj.export({ type: 'spki', format: 'pem' }) as string;
204
+ console.log('[openclaw-ws] Derived PEM public key from existing DER key for clawdbot compat mode');
205
+ }
206
+ }
207
+
208
+ return identity;
110
209
  } catch (err) {
111
210
  // ENOENT is expected on first run; other errors mean corruption
112
211
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
@@ -115,7 +214,7 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
115
214
  }
116
215
 
117
216
  // Generate fresh and persist via atomic write-then-rename
118
- const identity = generateDeviceIdentity();
217
+ const identity = generateDeviceIdentity(compat);
119
218
  const pkcs8Der = identity.privateKeyObj.export({ type: 'pkcs8', format: 'der' });
120
219
  const persisted: PersistedDevice = {
121
220
  publicKeyB64: identity.publicKeyB64,
@@ -123,6 +222,11 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
123
222
  deviceId: identity.deviceId,
124
223
  };
125
224
 
225
+ if (compat === 'clawdbot' && identity.publicKeyPem) {
226
+ persisted.publicKeyPem = identity.publicKeyPem;
227
+ persisted.privateKeyPem = identity.privateKeyObj.export({ type: 'pkcs8', format: 'pem' }) as string;
228
+ }
229
+
126
230
  try {
127
231
  const dir = join(openclawHome(), 'workspace', 'relaycast');
128
232
  await mkdir(dir, { recursive: true });
@@ -137,6 +241,86 @@ async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
137
241
  return identity;
138
242
  }
139
243
 
244
+ /** Hash helper for diagnostics (no secrets leaked — just truncated SHA-256). */
245
+ function shortHash(data: string | Buffer): string {
246
+ const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
247
+ return createHash('sha256').update(buf).digest('hex').slice(0, 16);
248
+ }
249
+
250
+ /**
251
+ * Canonicalization variants to try for debugging. Each produces a different
252
+ * pipe-delimited payload string. The server should match exactly one.
253
+ */
254
+ function buildCanonicalVariants(
255
+ device: DeviceIdentity,
256
+ params: {
257
+ clientId: string;
258
+ clientMode: string;
259
+ platform: string;
260
+ deviceFamily: string;
261
+ role: string;
262
+ scopes: string[];
263
+ signedAt: number;
264
+ token: string;
265
+ nonce: string;
266
+ },
267
+ ): Array<{ name: string; payload: string }> {
268
+ const signedAtMs = String(params.signedAt);
269
+ const signedAtSec = String(Math.floor(params.signedAt / 1000));
270
+ const scopesCsv = params.scopes.join(',');
271
+
272
+ return [
273
+ // V0: current default order (v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily)
274
+ {
275
+ name: 'v3-default-ms',
276
+ payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.token || '', params.nonce, params.platform, params.deviceFamily].join('|'),
277
+ },
278
+ // V1: signedAt in seconds instead of milliseconds
279
+ {
280
+ name: 'v3-default-sec',
281
+ payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.token || '', params.nonce, params.platform, params.deviceFamily].join('|'),
282
+ },
283
+ // V2: no token in payload (token omitted entirely)
284
+ {
285
+ name: 'v3-no-token-ms',
286
+ payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce, params.platform, params.deviceFamily].join('|'),
287
+ },
288
+ // V3: nonce before token (swapped positions)
289
+ {
290
+ name: 'v3-nonce-first-ms',
291
+ payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce, params.token || '', params.platform, params.deviceFamily].join('|'),
292
+ },
293
+ // V4: fewer fields — just core identity + nonce + signedAt (minimal)
294
+ {
295
+ name: 'v3-minimal',
296
+ payload: ['v3', device.deviceId, signedAtMs, params.nonce].join('|'),
297
+ },
298
+ // V5: signedAt seconds + no token
299
+ {
300
+ name: 'v3-no-token-sec',
301
+ payload: ['v3', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.nonce, params.platform, params.deviceFamily].join('|'),
302
+ },
303
+ // V6: v2 format (no platform/deviceFamily) — used by older gateway versions
304
+ {
305
+ name: 'v2-default-ms',
306
+ payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.token || '', params.nonce].join('|'),
307
+ },
308
+ // V7: v2 with signedAt in seconds
309
+ {
310
+ name: 'v2-default-sec',
311
+ payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtSec, params.token || '', params.nonce].join('|'),
312
+ },
313
+ // V8: v2 without token
314
+ {
315
+ name: 'v2-no-token-ms',
316
+ payload: ['v2', device.deviceId, params.clientId, params.clientMode, params.role, scopesCsv, signedAtMs, params.nonce].join('|'),
317
+ },
318
+ ];
319
+ }
320
+
321
+ /** Payload version override for v3↔v2 fallback. */
322
+ type PayloadVersionOverride = 'v2' | 'v3' | null;
323
+
140
324
  function signConnectPayload(
141
325
  device: DeviceIdentity,
142
326
  params: {
@@ -150,27 +334,75 @@ function signConnectPayload(
150
334
  token: string;
151
335
  nonce: string;
152
336
  },
337
+ versionOverride?: PayloadVersionOverride,
153
338
  ): string {
154
- // v3 payload format: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily
155
- const payload = [
156
- 'v3',
157
- device.deviceId,
158
- params.clientId,
159
- params.clientMode,
160
- params.role,
161
- params.scopes.join(','),
162
- String(params.signedAt),
163
- params.token || '',
164
- params.nonce,
165
- params.platform,
166
- params.deviceFamily,
167
- ].join('|');
168
-
169
- const payloadBytes = Buffer.from(payload, 'utf-8');
339
+ const profile = resolveAuthProfile();
340
+
341
+ // Build canonicalization variants for diagnostics
342
+ const variants = buildCanonicalVariants(device, params);
343
+
344
+ // Select primary payload version:
345
+ // 1. If versionOverride is set (from fallback), use that directly
346
+ // 2. clawdbot-v1 defaults to v2 (older gateway compat)
347
+ // 3. default profile uses v3
348
+ let primaryName: string;
349
+ if (versionOverride === 'v2') {
350
+ primaryName = 'v2-default-ms';
351
+ } else if (versionOverride === 'v3') {
352
+ primaryName = 'v3-default-ms';
353
+ } else {
354
+ primaryName = profile.name === 'clawdbot-v1' ? 'v2-default-ms' : 'v3-default-ms';
355
+ }
356
+ const primary = variants.find(v => v.name === primaryName) ?? variants[0];
357
+
358
+ const payloadBytes = Buffer.from(primary.payload, 'utf-8');
359
+
360
+ const isDebug = process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1';
361
+
362
+ // Concise production log — one line with essential info
363
+ console.log(`[ws-auth] profile=${profile.name} payload=${primary.name} device=${device.deviceId.slice(0, 12)}...${versionOverride ? ` override=${versionOverride}` : ''}`);
364
+
365
+ // Verbose debug logging — field hashes and canonicalization matrix
366
+ if (isDebug) {
367
+ console.log(`[ws-auth-debug] signedAt=${params.signedAt}ms nonce=${shortHash(params.nonce)} keyFormat=${profile.publicKeyFormat} sigEncoding=${profile.signatureEncoding}`);
368
+ console.log(`[ws-auth-debug] field hashes: deviceId=${shortHash(device.deviceId)} clientId=${shortHash(params.clientId)} role=${shortHash(params.role)} scopes=${shortHash(params.scopes.join(','))} token=${shortHash(params.token || '')} nonce=${shortHash(params.nonce)}`);
369
+ console.log('[ws-auth-debug] canonicalization matrix:');
370
+ for (const v of variants) {
371
+ console.log(` ${v.name}: hash=${shortHash(v.payload)}`);
372
+ }
373
+ console.log(`[ws-auth-debug] payloadHash=${shortHash(primary.payload)}`);
374
+ }
170
375
 
171
376
  // Ed25519 sign — no hash algorithm needed (null), it's built into Ed25519
172
377
  const signature = sign(null, payloadBytes, device.privateKeyObj);
173
- return Buffer.from(signature).toString('base64url');
378
+ const encoded = Buffer.from(signature).toString(profile.signatureEncoding);
379
+
380
+ // Self-verification (debug only): confirm our signature is valid locally.
381
+ if (isDebug) {
382
+ try {
383
+ // Derive public key from private key (same as server would use from our publicKey field)
384
+ const pubKey = createPublicKey(device.privateKeyObj);
385
+ const selfVerifyRaw = verify(null, payloadBytes, pubKey, signature);
386
+
387
+ // Also verify the round-trip: decode our encoded signature like the server would
388
+ const decodedSig = Buffer.from(encoded, profile.signatureEncoding === 'base64url' ? 'base64url' : 'base64');
389
+ const selfVerifyEncoded = verify(null, payloadBytes, pubKey, decodedSig);
390
+
391
+ // Verify deviceId matches public key
392
+ const rawPubBytes = pubKey.export({ type: 'spki', format: 'der' }).subarray(12);
393
+ const derivedDeviceId = createHash('sha256').update(rawPubBytes).digest('hex');
394
+ const deviceIdMatch = derivedDeviceId === device.deviceId;
395
+
396
+ console.log(`[ws-auth-debug] self-verify: raw=${selfVerifyRaw} encoded=${selfVerifyEncoded} deviceIdMatch=${deviceIdMatch} derivedId=${derivedDeviceId.slice(0, 16)}...`);
397
+ if (!deviceIdMatch) {
398
+ console.error(`[ws-auth-debug] DEVICE ID MISMATCH: derived=${derivedDeviceId} sent=${device.deviceId}`);
399
+ }
400
+ } catch (err) {
401
+ console.error(`[ws-auth-debug] self-verify error: ${err instanceof Error ? err.message : String(err)}`);
402
+ }
403
+ }
404
+
405
+ return encoded;
174
406
  }
175
407
 
176
408
 
@@ -200,6 +432,13 @@ export class OpenClawGatewayClient {
200
432
  private connectTimeout: ReturnType<typeof setTimeout> | null = null;
201
433
  private pairingRejected = false;
202
434
  private consecutiveFailures = 0;
435
+ /** Payload version override for v3↔v2 fallback (null = use profile default). */
436
+ private payloadVersionOverride: PayloadVersionOverride = null;
437
+ /** Whether a fallback attempt has already been tried this connection cycle. */
438
+ private fallbackAttempted = false;
439
+ /** Auth rejection counters for observability. */
440
+ private authRejectCount = 0;
441
+ private authFallbackCount = 0;
203
442
 
204
443
  /** Default timeout for initial connection (30 seconds). */
205
444
  private static readonly CONNECT_TIMEOUT_MS = 30_000;
@@ -212,7 +451,7 @@ export class OpenClawGatewayClient {
212
451
  constructor(token: string, port: number, device?: DeviceIdentity) {
213
452
  this.token = token;
214
453
  this.port = port;
215
- this.device = device ?? generateDeviceIdentity();
454
+ this.device = device ?? generateDeviceIdentity(getWsAuthCompat());
216
455
  }
217
456
 
218
457
  /**
@@ -232,6 +471,9 @@ export class OpenClawGatewayClient {
232
471
  // Explicit connect() clears pairing rejection so users can retry after fixing their token
233
472
  this.pairingRejected = false;
234
473
  this.stopped = false;
474
+ // Reset fallback state for fresh connection attempts
475
+ this.payloadVersionOverride = null;
476
+ this.fallbackAttempted = false;
235
477
 
236
478
  // Cancel any pending reconnect timer to prevent orphaned WebSocket connections
237
479
  if (this.reconnectTimer) {
@@ -269,23 +511,29 @@ export class OpenClawGatewayClient {
269
511
  private doConnect(): void {
270
512
  if (this.stopped) return;
271
513
 
514
+ let ws: WebSocket;
272
515
  try {
273
- this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`);
516
+ ws = new WebSocket(`ws://127.0.0.1:${this.port}`);
274
517
  } catch (err) {
275
518
  console.warn(`[openclaw-ws] Connection failed: ${err instanceof Error ? err.message : String(err)}`);
276
519
  this.scheduleReconnect();
277
520
  return;
278
521
  }
522
+ this.ws = ws;
279
523
 
280
- this.ws.on('open', () => {
524
+ ws.on('open', () => {
281
525
  console.log('[openclaw-ws] Connected to OpenClaw gateway');
282
526
  });
283
527
 
284
- this.ws.on('message', (data) => {
528
+ ws.on('message', (data) => {
285
529
  this.handleMessage(data.toString());
286
530
  });
287
531
 
288
- this.ws.on('close', (code, reason) => {
532
+ ws.on('close', (code, reason) => {
533
+ // Guard: ignore close events from superseded WebSocket instances.
534
+ // During v3↔v2 fallback, the old WS is replaced before its close fires.
535
+ if (this.ws !== ws) return;
536
+
289
537
  const reasonStr = reason.toString();
290
538
  console.warn(`[openclaw-ws] Disconnected: ${code} ${reasonStr}`);
291
539
  const wasAuthenticated = this.authenticated;
@@ -318,7 +566,10 @@ export class OpenClawGatewayClient {
318
566
  }
319
567
  });
320
568
 
321
- this.ws.on('error', (err) => {
569
+ ws.on('error', (err) => {
570
+ // Guard: ignore error events from superseded WebSocket instances.
571
+ if (this.ws !== ws) return;
572
+
322
573
  console.warn(`[openclaw-ws] Error: ${err.message}`);
323
574
  // If we weren't authenticated yet, reject the connect promise
324
575
  if (!this.authenticated && this.connectReject) {
@@ -342,6 +593,10 @@ export class OpenClawGatewayClient {
342
593
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
343
594
  const payload = msg.payload as { nonce: string; ts: number };
344
595
  console.log('[openclaw-ws] Received connect.challenge, signing...');
596
+ // Log raw challenge payload for debugging canonicalization issues
597
+ if (process.env.RELAY_LOG_LEVEL === 'DEBUG' || process.env.OPENCLAW_WS_DEBUG === '1') {
598
+ console.log(`[ws-auth-debug] challenge payload: ${JSON.stringify(payload)}`);
599
+ }
345
600
 
346
601
  const signedAt = Date.now();
347
602
  const clientId = 'cli';
@@ -361,7 +616,13 @@ export class OpenClawGatewayClient {
361
616
  signedAt,
362
617
  token: this.token,
363
618
  nonce: payload.nonce,
364
- });
619
+ }, this.payloadVersionOverride);
620
+
621
+ // Select public key format based on resolved auth profile.
622
+ const profile = resolveAuthProfile();
623
+ const publicKeyField = profile.publicKeyFormat === 'spki-pem' && this.device.publicKeyPem
624
+ ? this.device.publicKeyPem
625
+ : this.device.publicKeyB64;
365
626
 
366
627
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
367
628
  console.warn('[openclaw-ws] WebSocket not open when trying to send connect');
@@ -391,7 +652,7 @@ export class OpenClawGatewayClient {
391
652
  userAgent: 'relaycast-gateway/1.0.0',
392
653
  device: {
393
654
  id: this.device.deviceId,
394
- publicKey: this.device.publicKeyB64,
655
+ publicKey: publicKeyField,
395
656
  signature,
396
657
  signedAt,
397
658
  nonce: payload.nonce,
@@ -403,9 +664,11 @@ export class OpenClawGatewayClient {
403
664
 
404
665
  // Handle connect response
405
666
  if (msg.type === 'res' && msg.id === 'connect-1') {
406
- this.clearConnectTimeout();
407
667
  if (msg.ok) {
408
- console.log('[openclaw-ws] Authenticated successfully');
668
+ this.clearConnectTimeout();
669
+ const versionUsed = this.payloadVersionOverride
670
+ ?? (resolveAuthProfile().name === 'clawdbot-v1' ? 'v2' : 'v3');
671
+ console.log(`[openclaw-ws] Authenticated successfully (payload=${versionUsed}${this.fallbackAttempted ? ', via fallback' : ''})`);
409
672
  this.authenticated = true;
410
673
  this.consecutiveFailures = 0;
411
674
  this.connectResolve?.();
@@ -414,8 +677,10 @@ export class OpenClawGatewayClient {
414
677
  } else {
415
678
  const errStr = msg.error ? JSON.stringify(msg.error) : 'Authentication rejected';
416
679
  const isPairing = /pairing.required|not.paired/i.test(errStr);
680
+ const isSignatureInvalid = /signature.invalid|device.signature|invalid.signature/i.test(errStr);
417
681
 
418
682
  if (isPairing) {
683
+ this.clearConnectTimeout();
419
684
  const errObj = msg.error as Record<string, unknown> | undefined;
420
685
  const requestId = errObj?.requestId ?? errObj?.request_id ?? '';
421
686
  console.error('[openclaw-ws] Pairing rejected — device is not paired with the OpenClaw gateway.');
@@ -423,10 +688,36 @@ export class OpenClawGatewayClient {
423
688
  console.error(`[openclaw-ws] Approve this device: openclaw devices approve ${requestId}`);
424
689
  }
425
690
  console.error(`[openclaw-ws] Device ID: ${this.device.deviceId.slice(0, 16)}...`);
426
- console.error('[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ~/.openclaw/openclaw.json gateway.auth.token');
691
+ const configHint = getWsAuthCompat() === 'clawdbot'
692
+ ? '~/.clawdbot/clawdbot.json'
693
+ : '~/.openclaw/openclaw.json';
694
+ console.error(`[openclaw-ws] Ensure OPENCLAW_GATEWAY_TOKEN matches ${configHint} gateway.auth.token`);
427
695
  this.pairingRejected = true;
696
+ } else if (isSignatureInvalid && !this.fallbackAttempted) {
697
+ // Signature rejected — try the alternate payload version once.
698
+ // Do NOT clear connect timeout — it protects the fallback attempt too.
699
+ this.authRejectCount++;
700
+ this.authFallbackCount++;
701
+ const profile = resolveAuthProfile();
702
+ const currentVersion = this.payloadVersionOverride
703
+ ?? (profile.name === 'clawdbot-v1' ? 'v2' : 'v3');
704
+ const fallbackVersion: PayloadVersionOverride = currentVersion === 'v2' ? 'v3' : 'v2';
705
+
706
+ console.warn(`[ws-auth] Signature rejected with ${currentVersion} payload — retrying with ${fallbackVersion} fallback (rejects=${this.authRejectCount} fallbacks=${this.authFallbackCount})`);
707
+ this.payloadVersionOverride = fallbackVersion;
708
+ this.fallbackAttempted = true;
709
+
710
+ // Close current WS and reconnect with the alternate payload.
711
+ // Setting this.ws = null ensures the old WS's close/error handlers
712
+ // no-op via the `this.ws !== ws` guard in doConnect().
713
+ try { this.ws?.close(); } catch {}
714
+ this.ws = null;
715
+ setTimeout(() => this.doConnect(), 0);
716
+ return; // Don't reject the connect promise yet — fallback attempt in progress
428
717
  } else {
429
- console.warn(`[openclaw-ws] Auth rejected: ${errStr}`);
718
+ this.clearConnectTimeout();
719
+ this.authRejectCount++;
720
+ console.warn(`[openclaw-ws] Auth rejected (rejects=${this.authRejectCount}): ${errStr}`);
430
721
  }
431
722
 
432
723
  this.connectReject?.(new Error(`OpenClaw gateway auth failed: ${errStr}`));
@@ -462,6 +753,7 @@ export class OpenClawGatewayClient {
462
753
 
463
754
  /** Send a chat.send RPC. Returns true if accepted. */
464
755
  async sendChatMessage(text: string, idempotencyKey?: string): Promise<boolean> {
756
+ if (this.stopped) return false;
465
757
  if (!this.authenticated || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
466
758
  // Try to reconnect
467
759
  try {