@clawchatsai/connector 0.0.11 → 0.0.13

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.
@@ -20,6 +20,7 @@ export interface PluginConfig {
20
20
  }
21
21
  export interface BridgeConfig {
22
22
  gatewayToken: string;
23
+ dataDir: string;
23
24
  }
24
25
  export declare class GatewayBridge extends EventEmitter {
25
26
  private readonly gatewayUrl;
@@ -9,6 +9,9 @@
9
9
  * 3. Reconnect with exponential backoff on disconnect.
10
10
  */
11
11
  import { EventEmitter } from 'node:events';
12
+ import * as crypto from 'node:crypto';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
12
15
  import { WebSocket } from 'ws';
13
16
  import { PLUGIN_VERSION } from './index.js';
14
17
  // Reconnect backoff constants (milliseconds)
@@ -19,6 +22,62 @@ const CLIENT_ID = 'gateway-client';
19
22
  const CLIENT_MODE = 'backend';
20
23
  const ROLE = 'operator';
21
24
  const SCOPES = ['operator.read', 'operator.write', 'operator.admin'];
25
+ // ---------------------------------------------------------------------------
26
+ // Device Identity — ed25519 key generation + connect payload signing
27
+ // Required by OpenClaw ≥2.15 to prevent scope clearing on connect.
28
+ // ---------------------------------------------------------------------------
29
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
30
+ function _derivePublicKeyRaw(publicKeyPem) {
31
+ const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
32
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
33
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
34
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
35
+ }
36
+ return spki;
37
+ }
38
+ function _fingerprintPublicKey(publicKeyPem) {
39
+ return crypto.createHash('sha256').update(_derivePublicKeyRaw(publicKeyPem)).digest('hex');
40
+ }
41
+ function _base64UrlEncode(buf) {
42
+ return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
43
+ }
44
+ function _loadOrCreateDeviceIdentity(identityPath) {
45
+ try {
46
+ if (fs.existsSync(identityPath)) {
47
+ const parsed = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
48
+ if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) {
49
+ return parsed;
50
+ }
51
+ }
52
+ }
53
+ catch { /* regenerate */ }
54
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
55
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
56
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
57
+ const identity = {
58
+ version: 1,
59
+ deviceId: _fingerprintPublicKey(publicKeyPem),
60
+ publicKeyPem,
61
+ privateKeyPem,
62
+ createdAtMs: Date.now(),
63
+ };
64
+ fs.mkdirSync(path.dirname(identityPath), { recursive: true });
65
+ fs.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
66
+ return identity;
67
+ }
68
+ function _buildDeviceAuth(identity, params) {
69
+ const signedAt = Date.now();
70
+ const payload = [
71
+ 'v2', identity.deviceId, params.clientId, params.clientMode,
72
+ params.role, params.scopes.join(','), String(signedAt),
73
+ params.token || '', params.nonce,
74
+ ].join('|');
75
+ const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
76
+ const signature = _base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), privateKey));
77
+ const publicKeyB64Url = _base64UrlEncode(_derivePublicKeyRaw(identity.publicKeyPem));
78
+ return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce: params.nonce };
79
+ }
80
+ // ---------------------------------------------------------------------------
22
81
  export class GatewayBridge extends EventEmitter {
23
82
  gatewayUrl;
24
83
  config;
@@ -121,9 +180,11 @@ export class GatewayBridge extends EventEmitter {
121
180
  }
122
181
  const type = msg['type'];
123
182
  const event = msg['event'];
124
- // connect.challenge → send token-only connect request (loopback skips nonce)
183
+ // connect.challenge → send signed connect request with device identity
125
184
  if (type === 'event' && event === 'connect.challenge') {
126
- this._sendConnect();
185
+ const payload = msg['payload'];
186
+ const nonce = payload?.['nonce'] ?? '';
187
+ this._sendConnect(nonce);
127
188
  return;
128
189
  }
129
190
  // hello-ok → handshake complete
@@ -138,7 +199,17 @@ export class GatewayBridge extends EventEmitter {
138
199
  }
139
200
  }
140
201
  }
141
- _sendConnect() {
202
+ _sendConnect(nonce) {
203
+ const identityPath = path.join(this.config.dataDir, 'device-identity.json');
204
+ const identity = _loadOrCreateDeviceIdentity(identityPath);
205
+ const device = _buildDeviceAuth(identity, {
206
+ clientId: CLIENT_ID,
207
+ clientMode: CLIENT_MODE,
208
+ role: ROLE,
209
+ scopes: SCOPES,
210
+ token: this.config.gatewayToken,
211
+ nonce,
212
+ });
142
213
  this.send(JSON.stringify({
143
214
  type: 'req',
144
215
  id: 'gw-connect-1',
@@ -154,6 +225,7 @@ export class GatewayBridge extends EventEmitter {
154
225
  },
155
226
  role: ROLE,
156
227
  scopes: SCOPES,
228
+ device,
157
229
  auth: { token: this.config.gatewayToken },
158
230
  caps: ['tool-events'],
159
231
  },
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * Spec: specs/multitenant-p2p.md sections 6.1-6.2
11
11
  */
12
12
  export declare const PLUGIN_ID = "connector";
13
- export declare const PLUGIN_VERSION = "0.0.1";
13
+ export declare const PLUGIN_VERSION = "0.0.13";
14
14
  interface PluginServiceContext {
15
15
  stateDir: string;
16
16
  logger: {
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import { checkForUpdates, performUpdate } from './updater.js';
19
19
  // Inline from shared/api-version.ts to avoid rootDir conflict
20
20
  const CURRENT_API_VERSION = 1;
21
21
  export const PLUGIN_ID = 'connector';
22
- export const PLUGIN_VERSION = '0.0.1';
22
+ export const PLUGIN_VERSION = '0.0.13';
23
23
  /** Max DataChannel message size (~256KB, leave room for envelope) */
24
24
  const MAX_DC_MESSAGE_SIZE = 256 * 1024;
25
25
  /** Active DataChannel connections: connectionId → send function */
@@ -147,9 +147,7 @@ async function startShellChat(ctx, api) {
147
147
  signaling.on('auth-rejected', (reason) => {
148
148
  ctx.logger.error(`Signaling auth rejected: ${reason}`);
149
149
  });
150
- signaling.on('version-rejected', (current, minimum) => {
151
- ctx.logger.error(`Plugin version ${current} rejected, minimum: ${minimum}`);
152
- });
150
+ // version-rejected listener removed version check is now client-side
153
151
  signaling.on('force-update', async (targetVersion) => {
154
152
  ctx.logger.info(`Force update to ${targetVersion} requested`);
155
153
  try {
@@ -37,8 +37,7 @@ export declare class SignalingClient extends EventEmitter {
37
37
  /**
38
38
  * Open the WebSocket connection and perform the gateway-auth handshake.
39
39
  * Resolves once the socket is open (not necessarily authenticated yet).
40
- * Authentication outcome is signalled via 'connected' / 'auth-rejected' /
41
- * 'version-rejected' events.
40
+ * Authentication outcome is signalled via 'connected' / 'auth-rejected' events.
42
41
  */
43
42
  connect(): Promise<void>;
44
43
  /**
@@ -63,8 +63,7 @@ export class SignalingClient extends EventEmitter {
63
63
  /**
64
64
  * Open the WebSocket connection and perform the gateway-auth handshake.
65
65
  * Resolves once the socket is open (not necessarily authenticated yet).
66
- * Authentication outcome is signalled via 'connected' / 'auth-rejected' /
67
- * 'version-rejected' events.
66
+ * Authentication outcome is signalled via 'connected' / 'auth-rejected' events.
68
67
  */
69
68
  connect() {
70
69
  this.intentionalClose = false;
@@ -198,15 +197,8 @@ export class SignalingClient extends EventEmitter {
198
197
  this.emit('auth-rejected', reason);
199
198
  break;
200
199
  }
201
- case 'version-rejected': {
202
- const current = msg['current'] ?? PLUGIN_VERSION;
203
- const minimum = msg['minimum'] ?? '';
204
- console.error(`[SignalingClient] Version rejected: current=${current}, minimum=${minimum}`);
205
- // Do not reconnect — must upgrade first; auto-update logic is in updater.ts
206
- this.intentionalClose = true;
207
- this.emit('version-rejected', current, minimum);
208
- break;
209
- }
200
+ // version-rejected removed — version compatibility is now checked
201
+ // client-side via pluginVersion in connect-ready
210
202
  case 'ice-offer': {
211
203
  const offer = {
212
204
  connectionId: msg['connectionId'] ?? '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "description": "ShellChat OpenClaw plugin — P2P tunnel + local API bridge",
6
6
  "main": "dist/index.js",
package/server.js CHANGED
@@ -16,6 +16,52 @@ import { WebSocket as WS, WebSocketServer } from 'ws';
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = path.dirname(__filename);
18
18
 
19
+ // ─── Device Identity (ed25519 signing for OpenClaw ≥2.15 scope preservation) ─
20
+
21
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
22
+
23
+ function _derivePublicKeyRaw(publicKeyPem) {
24
+ const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
25
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
26
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
27
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
28
+ }
29
+ return spki;
30
+ }
31
+
32
+ function _fingerprintPublicKey(publicKeyPem) {
33
+ return crypto.createHash('sha256').update(_derivePublicKeyRaw(publicKeyPem)).digest('hex');
34
+ }
35
+
36
+ function _base64UrlEncode(buf) {
37
+ return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
38
+ }
39
+
40
+ function _loadOrCreateDeviceIdentity(identityPath) {
41
+ try {
42
+ if (fs.existsSync(identityPath)) {
43
+ const parsed = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
44
+ if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) return parsed;
45
+ }
46
+ } catch { /* regenerate */ }
47
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
48
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
49
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
50
+ const identity = { version: 1, deviceId: _fingerprintPublicKey(publicKeyPem), publicKeyPem, privateKeyPem, createdAtMs: Date.now() };
51
+ fs.mkdirSync(path.dirname(identityPath), { recursive: true });
52
+ fs.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
53
+ return identity;
54
+ }
55
+
56
+ function _buildDeviceAuth(identity, { clientId, clientMode, role, scopes, token, nonce }) {
57
+ const signedAt = Date.now();
58
+ const payload = ['v2', identity.deviceId, clientId, clientMode, role, scopes.join(','), String(signedAt), token || '', nonce].join('|');
59
+ const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
60
+ const signature = _base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), privateKey));
61
+ const publicKeyB64Url = _base64UrlEncode(_derivePublicKeyRaw(identity.publicKeyPem));
62
+ return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce };
63
+ }
64
+
19
65
  // ─── Configuration ──────────────────────────────────────────────────────────
20
66
 
21
67
  const PORT = parseInt(process.env.SHELLCHAT_PORT || '3001', 10);
@@ -2233,6 +2279,13 @@ class GatewayClient {
2233
2279
  // Handle connect.challenge (handshake)
2234
2280
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
2235
2281
  console.log('Received connect.challenge, sending auth...');
2282
+ const nonce = msg.payload?.nonce || '';
2283
+ const identityPath = path.join(DATA_DIR, 'device-identity.json');
2284
+ const identity = _loadOrCreateDeviceIdentity(identityPath);
2285
+ const device = _buildDeviceAuth(identity, {
2286
+ clientId: 'gateway-client', clientMode: 'backend', role: 'operator',
2287
+ scopes: ['operator.read', 'operator.write', 'operator.admin'], token: AUTH_TOKEN, nonce
2288
+ });
2236
2289
  this.ws.send(JSON.stringify({
2237
2290
  type: 'req',
2238
2291
  id: 'gw-connect-1',
@@ -2243,6 +2296,7 @@ class GatewayClient {
2243
2296
  client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' },
2244
2297
  role: 'operator',
2245
2298
  scopes: ['operator.read', 'operator.write', 'operator.admin'],
2299
+ device,
2246
2300
  auth: { token: AUTH_TOKEN },
2247
2301
  caps: ['tool-events']
2248
2302
  }
@@ -3567,7 +3621,14 @@ export function createApp(config = {}) {
3567
3621
  try { msg = JSON.parse(data); } catch { console.error('Invalid JSON from gateway:', data); return; }
3568
3622
  if (msg.type === 'event' && msg.event === 'connect.challenge') {
3569
3623
  console.log('Received connect.challenge, sending auth...');
3570
- this.ws.send(JSON.stringify({ type: 'req', id: 'gw-connect-1', method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' }, role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.admin'], auth: { token: _GATEWAY_TOKEN }, caps: ['tool-events'] } }));
3624
+ const _nonce = msg.payload?.nonce || '';
3625
+ const _identityPath = path.join(_DATA_DIR, 'device-identity.json');
3626
+ const _identity = _loadOrCreateDeviceIdentity(_identityPath);
3627
+ const _device = _buildDeviceAuth(_identity, {
3628
+ clientId: 'gateway-client', clientMode: 'backend', role: 'operator',
3629
+ scopes: ['operator.read', 'operator.write', 'operator.admin'], token: _GATEWAY_TOKEN, nonce: _nonce
3630
+ });
3631
+ this.ws.send(JSON.stringify({ type: 'req', id: 'gw-connect-1', method: 'connect', params: { minProtocol: 3, maxProtocol: 3, client: { id: 'gateway-client', version: '0.1.0', platform: 'node', mode: 'backend' }, role: 'operator', scopes: ['operator.read', 'operator.write', 'operator.admin'], device: _device, auth: { token: _GATEWAY_TOKEN }, caps: ['tool-events'] } }));
3571
3632
  return;
3572
3633
  }
3573
3634
  if (msg.type === 'res' && msg.payload?.type === 'hello-ok') { console.log('Gateway handshake complete'); this.connected = true; this.broadcastGatewayStatus(true); }