@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.
- package/dist/gateway-bridge.d.ts +1 -0
- package/dist/gateway-bridge.js +75 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -4
- package/dist/signaling-client.d.ts +1 -2
- package/dist/signaling-client.js +3 -11
- package/package.json +1 -1
- package/server.js +62 -1
package/dist/gateway-bridge.d.ts
CHANGED
package/dist/gateway-bridge.js
CHANGED
|
@@ -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
|
|
183
|
+
// connect.challenge → send signed connect request with device identity
|
|
125
184
|
if (type === 'event' && event === 'connect.challenge') {
|
|
126
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
/**
|
package/dist/signaling-client.js
CHANGED
|
@@ -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
|
-
|
|
202
|
-
|
|
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
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
|
-
|
|
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); }
|