@coclaw/openclaw-coclaw 0.2.2 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -0,0 +1,190 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import nodePath from 'node:path';
5
+
6
+ import { getRuntime } from './runtime.js';
7
+
8
+ const CHANNEL_ID = 'coclaw';
9
+ const IDENTITY_FILENAME = 'device-identity.json';
10
+
11
+ // Ed25519 SPKI 前缀(固定 12 字节),公钥裸字节从 SPKI DER 中截取
12
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
13
+
14
+ function base64UrlEncode(buf) {
15
+ return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
16
+ }
17
+
18
+ // 仅处理 ASCII 范围的大写→小写,保持跨运行时确定性
19
+ function toLowerAscii(input) {
20
+ return input.replace(/[A-Z]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) + 32));
21
+ }
22
+
23
+ function normalizeMetadataForAuth(value) {
24
+ if (typeof value !== 'string') return '';
25
+ const trimmed = value.trim();
26
+ return trimmed ? toLowerAscii(trimmed) : '';
27
+ }
28
+
29
+ function resolveStateDir() {
30
+ const rt = getRuntime();
31
+ if (rt?.state?.resolveStateDir) {
32
+ return rt.state.resolveStateDir();
33
+ }
34
+ return process.env.OPENCLAW_STATE_DIR
35
+ ? nodePath.resolve(process.env.OPENCLAW_STATE_DIR)
36
+ : nodePath.join(os.homedir(), '.openclaw');
37
+ }
38
+
39
+ /**
40
+ * 获取身份文件路径
41
+ * @returns {string}
42
+ */
43
+ export function getIdentityPath() {
44
+ return nodePath.join(resolveStateDir(), CHANNEL_ID, IDENTITY_FILENAME);
45
+ }
46
+
47
+ /**
48
+ * 从 PEM 公钥提取裸字节
49
+ * @param {string} publicKeyPem
50
+ * @returns {Buffer}
51
+ */
52
+ function derivePublicKeyRaw(publicKeyPem) {
53
+ const key = crypto.createPublicKey(publicKeyPem);
54
+ const spki = key.export({ type: 'spki', format: 'der' });
55
+ if (
56
+ spki.length === ED25519_SPKI_PREFIX.length + 32
57
+ && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
58
+ ) {
59
+ return spki.subarray(ED25519_SPKI_PREFIX.length);
60
+ }
61
+ /* c8 ignore next -- Ed25519 密钥 SPKI 格式固定,此分支仅防御未知密钥格式 */
62
+ return spki;
63
+ }
64
+
65
+ /**
66
+ * 公钥指纹 = SHA256(裸公钥字节) 的十六进制
67
+ * @param {string} publicKeyPem
68
+ * @returns {string}
69
+ */
70
+ function fingerprintPublicKey(publicKeyPem) {
71
+ const raw = derivePublicKeyRaw(publicKeyPem);
72
+ return crypto.createHash('sha256').update(raw).digest('hex');
73
+ }
74
+
75
+ /**
76
+ * 生成新的 Ed25519 密钥对
77
+ * @returns {{ deviceId: string, publicKeyPem: string, privateKeyPem: string }}
78
+ */
79
+ function generateIdentity() {
80
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
81
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
82
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
83
+ const deviceId = fingerprintPublicKey(publicKeyPem);
84
+ return { deviceId, publicKeyPem, privateKeyPem };
85
+ }
86
+
87
+ /**
88
+ * 加载或创建设备身份(Ed25519 密钥对)
89
+ *
90
+ * 存储格式与 OpenClaw device-identity.ts 保持一致。
91
+ * @param {string} [filePath] - 自定义路径,默认 ~/.openclaw/coclaw/device-identity.json
92
+ * @returns {{ deviceId: string, publicKeyPem: string, privateKeyPem: string }}
93
+ */
94
+ export function loadOrCreateDeviceIdentity(filePath) {
95
+ const fp = filePath ?? getIdentityPath();
96
+ try {
97
+ if (fs.existsSync(fp)) {
98
+ const raw = fs.readFileSync(fp, 'utf8');
99
+ const parsed = JSON.parse(raw);
100
+ if (
101
+ parsed?.version === 1
102
+ && typeof parsed.deviceId === 'string'
103
+ && typeof parsed.publicKeyPem === 'string'
104
+ && typeof parsed.privateKeyPem === 'string'
105
+ ) {
106
+ // 校验 deviceId 一致性
107
+ const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
108
+ if (derivedId && derivedId !== parsed.deviceId) {
109
+ const updated = { ...parsed, deviceId: derivedId };
110
+ fs.writeFileSync(fp, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
111
+ try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
112
+ return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
113
+ }
114
+ return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
115
+ }
116
+ }
117
+ }
118
+ catch {
119
+ // 读取/解析失败时重新生成
120
+ }
121
+
122
+ const identity = generateIdentity();
123
+ fs.mkdirSync(nodePath.dirname(fp), { recursive: true });
124
+ const stored = {
125
+ version: 1,
126
+ deviceId: identity.deviceId,
127
+ publicKeyPem: identity.publicKeyPem,
128
+ privateKeyPem: identity.privateKeyPem,
129
+ createdAtMs: Date.now(),
130
+ };
131
+ fs.writeFileSync(fp, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
132
+ try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
133
+ return identity;
134
+ }
135
+
136
+ /**
137
+ * 签名设备认证载荷
138
+ * @param {string} privateKeyPem
139
+ * @param {string} payload
140
+ * @returns {string} base64url 编码签名
141
+ */
142
+ export function signDevicePayload(privateKeyPem, payload) {
143
+ const key = crypto.createPrivateKey(privateKeyPem);
144
+ const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), key);
145
+ return base64UrlEncode(sig);
146
+ }
147
+
148
+ /**
149
+ * 公钥 PEM → base64url 裸字节
150
+ * @param {string} publicKeyPem
151
+ * @returns {string}
152
+ */
153
+ export function publicKeyRawBase64Url(publicKeyPem) {
154
+ return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
155
+ }
156
+
157
+ /**
158
+ * 构建 v3 版本的设备认证载荷字符串
159
+ * @param {object} params
160
+ * @param {string} params.deviceId
161
+ * @param {string} params.clientId
162
+ * @param {string} params.clientMode
163
+ * @param {string} params.role
164
+ * @param {string[]} params.scopes
165
+ * @param {number} params.signedAtMs
166
+ * @param {string} [params.token]
167
+ * @param {string} params.nonce
168
+ * @param {string} [params.platform]
169
+ * @param {string} [params.deviceFamily]
170
+ * @returns {string}
171
+ */
172
+ export function buildDeviceAuthPayloadV3(params) {
173
+ const scopes = params.scopes.join(',');
174
+ const token = params.token ?? '';
175
+ const platform = normalizeMetadataForAuth(params.platform);
176
+ const deviceFamily = normalizeMetadataForAuth(params.deviceFamily);
177
+ return [
178
+ 'v3',
179
+ params.deviceId,
180
+ params.clientId,
181
+ params.clientMode,
182
+ params.role,
183
+ scopes,
184
+ String(params.signedAtMs),
185
+ token,
186
+ params.nonce,
187
+ platform,
188
+ deviceFamily,
189
+ ].join('|');
190
+ }
@@ -3,6 +3,12 @@ import os from 'node:os';
3
3
  import nodePath from 'node:path';
4
4
 
5
5
  import { clearConfig, getBindingsPath, readConfig } from './config.js';
6
+ import {
7
+ loadOrCreateDeviceIdentity,
8
+ signDevicePayload,
9
+ publicKeyRawBase64Url,
10
+ buildDeviceAuthPayloadV3,
11
+ } from './device-identity.js';
6
12
  import { getRuntime } from './runtime.js';
7
13
 
8
14
  const DEFAULT_GATEWAY_WS_URL = 'ws://127.0.0.1:18789';
@@ -10,6 +16,7 @@ const RECONNECT_MS = 10_000;
10
16
  const CONNECT_TIMEOUT_MS = 10_000;
11
17
  const SERVER_HB_PING_MS = 25_000;
12
18
  const SERVER_HB_TIMEOUT_MS = 45_000;
19
+ const SERVER_HB_MAX_MISS = 4; // 连续 4 次无响应才断连(~3 分钟)
13
20
 
14
21
  function toServerWsUrl(baseUrl, token) {
15
22
  const url = new URL(baseUrl);
@@ -64,12 +71,14 @@ export class RealtimeBridge {
64
71
  * @param {Function} [deps.clearConfig] - 清除绑定配置
65
72
  * @param {Function} [deps.getBindingsPath] - 获取绑定文件路径
66
73
  * @param {Function} [deps.resolveGatewayAuthToken] - 获取 gateway 认证 token
74
+ * @param {Function} [deps.loadDeviceIdentity] - 加载设备身份
67
75
  */
68
76
  constructor(deps = {}) {
69
77
  this.__readConfig = deps.readConfig ?? readConfig;
70
78
  this.__clearConfig = deps.clearConfig ?? clearConfig;
71
79
  this.__getBindingsPath = deps.getBindingsPath ?? getBindingsPath;
72
80
  this.__resolveGatewayAuthToken = deps.resolveGatewayAuthToken ?? defaultResolveGatewayAuthToken;
81
+ this.__loadDeviceIdentity = deps.loadDeviceIdentity ?? loadOrCreateDeviceIdentity;
73
82
  this.__WebSocket = deps.WebSocket ?? null;
74
83
 
75
84
  this.serverWs = null;
@@ -88,6 +97,8 @@ export class RealtimeBridge {
88
97
  this.intentionallyClosed = false;
89
98
  this.serverHbInterval = null;
90
99
  this.serverHbTimer = null;
100
+ this.__serverHbMissCount = 0;
101
+ this.__deviceIdentity = null;
91
102
  }
92
103
 
93
104
  __resolveWebSocket() {
@@ -102,6 +113,7 @@ export class RealtimeBridge {
102
113
 
103
114
  __startServerHeartbeat(sock) {
104
115
  this.__clearServerHeartbeat();
116
+ this.__serverHbMissCount = 0;
105
117
  this.serverHbInterval = setInterval(() => {
106
118
  if (sock.readyState === 1) {
107
119
  try { sock.send(JSON.stringify({ type: 'ping' })); } catch {}
@@ -112,14 +124,36 @@ export class RealtimeBridge {
112
124
  }
113
125
 
114
126
  __resetServerHbTimeout(sock) {
127
+ this.__serverHbMissCount = 0;
115
128
  if (this.serverHbTimer) clearTimeout(this.serverHbTimer);
116
129
  this.serverHbTimer = setTimeout(() => {
117
- this.logger.warn?.(`[coclaw] server ws heartbeat timeout (${SERVER_HB_TIMEOUT_MS / 1000}s), closing`);
118
- try { sock.close(4000, 'heartbeat_timeout'); } catch {}
130
+ this.__onServerHbMiss(sock);
119
131
  }, SERVER_HB_TIMEOUT_MS);
120
132
  this.serverHbTimer.unref?.();
121
133
  }
122
134
 
135
+ __onServerHbMiss(sock) {
136
+ this.__serverHbMissCount++;
137
+ if (this.__serverHbMissCount < SERVER_HB_MAX_MISS) {
138
+ this.__logDebug(
139
+ `server heartbeat miss ${this.__serverHbMissCount}/${SERVER_HB_MAX_MISS}, will retry`
140
+ );
141
+ // 补发 ping,继续等下一轮
142
+ if (sock.readyState === 1) {
143
+ try { sock.send(JSON.stringify({ type: 'ping' })); } catch {}
144
+ }
145
+ this.serverHbTimer = setTimeout(() => {
146
+ this.__onServerHbMiss(sock);
147
+ }, SERVER_HB_TIMEOUT_MS);
148
+ this.serverHbTimer.unref?.();
149
+ return;
150
+ }
151
+ this.logger.warn?.(
152
+ `[coclaw] server ws heartbeat timeout after ${this.__serverHbMissCount} consecutive misses (~${this.__serverHbMissCount * SERVER_HB_TIMEOUT_MS / 1000}s), closing`
153
+ );
154
+ try { sock.close(4000, 'heartbeat_timeout'); } catch {}
155
+ }
156
+
123
157
  __clearServerHeartbeat() {
124
158
  if (this.serverHbInterval) { clearInterval(this.serverHbInterval); this.serverHbInterval = null; }
125
159
  if (this.serverHbTimer) { clearTimeout(this.serverHbTimer); this.serverHbTimer = null; }
@@ -256,25 +290,63 @@ export class RealtimeBridge {
256
290
  }
257
291
  }
258
292
 
259
- __sendGatewayConnectRequest(ws) {
293
+ __ensureDeviceIdentity() {
294
+ if (!this.__deviceIdentity) {
295
+ this.__deviceIdentity = this.__loadDeviceIdentity();
296
+ }
297
+ return this.__deviceIdentity;
298
+ }
299
+
300
+ __buildDeviceField(nonce, authToken) {
301
+ const identity = this.__ensureDeviceIdentity();
302
+ const clientId = 'gateway-client';
303
+ const clientMode = 'backend';
304
+ const role = 'operator';
305
+ const scopes = ['operator.admin'];
306
+ const signedAtMs = Date.now();
307
+ const payload = buildDeviceAuthPayloadV3({
308
+ deviceId: identity.deviceId,
309
+ clientId,
310
+ clientMode,
311
+ role,
312
+ scopes,
313
+ signedAtMs,
314
+ token: authToken ?? '',
315
+ nonce: nonce ?? '',
316
+ platform: process.platform,
317
+ deviceFamily: '',
318
+ });
319
+ const signature = signDevicePayload(identity.privateKeyPem, payload);
320
+ return {
321
+ id: identity.deviceId,
322
+ publicKey: publicKeyRawBase64Url(identity.publicKeyPem),
323
+ signature,
324
+ signedAt: signedAtMs,
325
+ nonce: nonce ?? '',
326
+ };
327
+ }
328
+
329
+ __sendGatewayConnectRequest(ws, nonce) {
260
330
  this.gatewayConnectReqId = `coclaw-connect-${Date.now()}`;
261
331
  this.__logDebug(`gateway connect request -> id=${this.gatewayConnectReqId}`);
262
- const authToken = this.__resolveGatewayAuthToken();
263
- const params = {
264
- minProtocol: 3,
265
- maxProtocol: 3,
266
- client: {
267
- id: 'gateway-client',
268
- version: 'dev',
269
- platform: process.platform,
270
- mode: 'backend',
271
- },
272
- caps: [],
273
- role: 'operator',
274
- scopes: ['operator.admin'],
275
- auth: authToken ? { token: authToken } : undefined,
276
- };
277
332
  try {
333
+ const authToken = this.__resolveGatewayAuthToken();
334
+ const device = this.__buildDeviceField(nonce, authToken);
335
+ const params = {
336
+ minProtocol: 3,
337
+ maxProtocol: 3,
338
+ client: {
339
+ id: 'gateway-client',
340
+ version: 'dev',
341
+ platform: process.platform,
342
+ mode: 'backend',
343
+ },
344
+ caps: [],
345
+ role: 'operator',
346
+ scopes: ['operator.admin'],
347
+ auth: authToken ? { token: authToken } : undefined,
348
+ device,
349
+ };
278
350
  ws.send(JSON.stringify({
279
351
  type: 'req',
280
352
  id: this.gatewayConnectReqId,
@@ -313,8 +385,9 @@ export class RealtimeBridge {
313
385
  return;
314
386
  }
315
387
  if (payload.type === 'event' && payload.event === 'connect.challenge') {
388
+ const nonce = payload?.payload?.nonce ?? '';
316
389
  this.__logDebug('gateway event <- connect.challenge');
317
- this.__sendGatewayConnectRequest(ws);
390
+ this.__sendGatewayConnectRequest(ws, nonce);
318
391
  return;
319
392
  }
320
393
  if (payload.type === 'res' && this.gatewayConnectReqId && payload.id === this.gatewayConnectReqId) {