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.
- package/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
- package/bin/agent-relay-broker-linux-x64 +0 -0
- package/package.json +8 -8
- package/packages/acp-bridge/package.json +2 -2
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/dist/__tests__/ws-client.test.js +69 -0
- package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -1
- package/packages/openclaw/dist/cli.js +8 -1
- package/packages/openclaw/dist/cli.js.map +1 -1
- package/packages/openclaw/dist/config.d.ts +6 -2
- package/packages/openclaw/dist/config.d.ts.map +1 -1
- package/packages/openclaw/dist/config.js +95 -7
- package/packages/openclaw/dist/config.js.map +1 -1
- package/packages/openclaw/dist/gateway.d.ts +8 -0
- package/packages/openclaw/dist/gateway.d.ts.map +1 -1
- package/packages/openclaw/dist/gateway.js +269 -34
- package/packages/openclaw/dist/gateway.js.map +1 -1
- package/packages/openclaw/dist/setup.d.ts.map +1 -1
- package/packages/openclaw/dist/setup.js +117 -15
- package/packages/openclaw/dist/setup.js.map +1 -1
- package/packages/openclaw/package.json +2 -2
- package/packages/openclaw/skill/SKILL.md +103 -0
- package/packages/openclaw/src/__tests__/ws-client.test.ts +72 -0
- package/packages/openclaw/src/cli.ts +7 -1
- package/packages/openclaw/src/config.ts +94 -8
- package/packages/openclaw/src/gateway.ts +328 -36
- package/packages/openclaw/src/setup.ts +120 -13
- package/packages/policy/package.json +2 -2
- package/packages/sdk/package.json +2 -2
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
+
ws.on('open', () => {
|
|
281
525
|
console.log('[openclaw-ws] Connected to OpenClaw gateway');
|
|
282
526
|
});
|
|
283
527
|
|
|
284
|
-
|
|
528
|
+
ws.on('message', (data) => {
|
|
285
529
|
this.handleMessage(data.toString());
|
|
286
530
|
});
|
|
287
531
|
|
|
288
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|