@coclaw/openclaw-coclaw 0.9.2 → 0.10.0

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/README.md CHANGED
@@ -137,6 +137,25 @@ openclaw gateway call coclaw.upgradeHealth --json
137
137
 
138
138
  详见设计文档 `docs/auto-upgrade.md`。
139
139
 
140
+ ## WebRTC 实现
141
+
142
+ 插件支持两个 WebRTC 实现,运行时自动选择:
143
+
144
+ 1. **node-datachannel**(首选)— 基于 libdatachannel 的工业级实现,通过 vendor 预编译 native binary 部署。
145
+ 2. **werift**(回退)— 纯 JavaScript 实现,作为 node-datachannel 加载失败时的兜底。
146
+
147
+ 选择结果通过 remoteLog 上报(`ndc.loaded` 或 `ndc.using-werift`)。
148
+
149
+ ### vendor 预编译包
150
+
151
+ 由于 OpenClaw 使用 `--ignore-scripts` 安装插件,node-datachannel 的 native binary 需通过 vendor 预编译包提供:
152
+
153
+ ```bash
154
+ bash scripts/download-ndc-prebuilds.sh # 下载 5 平台预编译包到 vendor/ndc-prebuilds/
155
+ ```
156
+
157
+ 支持的平台:linux-x64、linux-arm64、darwin-x64、darwin-arm64、win32-x64。vendor 目录不入 git,通过 npm publish 的 `files` 字段包含在发布包中。
158
+
140
159
  ## 运行与排障日志
141
160
 
142
161
  ### 日志级别建议
@@ -176,10 +195,8 @@ openclaw logs --limit 300 --plain | rg -n "ui->server req|bot->server res|bot->s
176
195
  ## 测试门禁
177
196
 
178
197
  ```bash
179
- pnpm check # lint + typecheck
180
- pnpm test # 全部单测
181
- pnpm coverage # 覆盖率检查
182
- pnpm verify # 完整验证(check → test:standalone → test:plugin → test → coverage)
198
+ pnpm check # lint + typecheck
199
+ pnpm test # 测试 + 覆盖率检查
183
200
  ```
184
201
 
185
202
  覆盖率阈值:lines/statements/functions 100%,branches ≥ 95%。未通过禁止接入 gateway。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -32,6 +32,7 @@
32
32
  "!src/**/*.test.js",
33
33
  "!src/mock-server.helper.js",
34
34
  "openclaw.plugin.json",
35
+ "vendor/ndc-prebuilds/**",
35
36
  "LICENSE"
36
37
  ],
37
38
  "main": "index.js",
@@ -49,11 +50,9 @@
49
50
  "lint": "eslint \"**/*.{js,mjs,cjs}\" --no-error-on-unmatched-pattern",
50
51
  "typecheck": "echo \"No typecheck configured yet (coclaw openclaw plugin)\"",
51
52
  "check": "pnpm lint && pnpm typecheck",
52
- "test:standalone": "node --test src/standalone-mode.test.js",
53
53
  "test:plugin": "node --test src/plugin-mode.test.js",
54
- "test": "node --test",
55
- "coverage": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov node --test",
56
- "verify": "pnpm check && pnpm coverage",
54
+ "test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c 'for f in src/**/*.test.js src/*.test.js index.test.js; do node --test \"$f\" || exit 1; done'",
55
+ "verify": "pnpm check && pnpm test",
57
56
  "link": "bash ./scripts/link.sh",
58
57
  "unlink": "bash ./scripts/unlink.sh",
59
58
  "install:npm": "bash ./scripts/install-npm.sh",
@@ -64,6 +63,7 @@
64
63
  "release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
65
64
  },
66
65
  "dependencies": {
66
+ "node-datachannel": "0.32.1",
67
67
  "werift": "^0.19.0"
68
68
  },
69
69
  "devDependencies": {
@@ -5,6 +5,7 @@ import { checkForUpdate } from './updater-check.js';
5
5
  import { spawnUpgradeWorker } from './updater-spawn.js';
6
6
  import { resolveStateDir } from './state.js';
7
7
  import { getRuntime } from '../runtime.js';
8
+ import { remoteLog } from '../remote-log.js';
8
9
 
9
10
  const INITIAL_DELAY_MS = 5 * 60 * 1000; // 5 分钟
10
11
  const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 小时
@@ -203,6 +204,7 @@ export class AutoUpgradeScheduler {
203
204
  // 若上一次 spawn 的 worker 仍在运行,跳过本次检查
204
205
  const isLocked = this.__opts.isUpgradeLockedFn ?? isUpgradeLocked;
205
206
  if (await isLocked({ logger: this.__logger })) {
207
+ remoteLog('upgrade.worker-locked');
206
208
  this.__logger.info?.('[auto-upgrade] Upgrade worker still running, skipping check');
207
209
  return;
208
210
  }
@@ -221,11 +223,13 @@ export class AutoUpgradeScheduler {
221
223
  return;
222
224
  }
223
225
 
226
+ remoteLog(`upgrade.available from=${result.currentVersion} to=${result.latestVersion}`);
224
227
  this.__logger.info?.(`[auto-upgrade] Update available: ${result.currentVersion} → ${result.latestVersion}`);
225
228
 
226
229
  const getInstallPath = this.__opts.getPluginInstallPathFn ?? getPluginInstallPath;
227
230
  const pluginDir = getInstallPath(this.__pluginId);
228
231
  if (!pluginDir) {
232
+ remoteLog('upgrade.no-install-path');
229
233
  this.__logger.warn?.('[auto-upgrade] Cannot determine plugin install path');
230
234
  return;
231
235
  }
@@ -245,6 +249,7 @@ export class AutoUpgradeScheduler {
245
249
  await writeLock(child.pid);
246
250
  }
247
251
  catch (err) {
252
+ remoteLog(`upgrade.check-failed msg=${err.message}`);
248
253
  this.__logger.warn?.(`[auto-upgrade] Check failed: ${err.message}`);
249
254
  }
250
255
  finally {
package/src/config.js CHANGED
@@ -42,8 +42,10 @@ async function readJson(filePath) {
42
42
  try {
43
43
  return JSON.parse(raw);
44
44
  }
45
- catch {
45
+ catch (err) {
46
46
  // 文件损坏,删除后当空文件处理
47
+ /* c8 ignore next -- ?./?? fallback */
48
+ console.warn?.(`[coclaw] corrupt bindings file deleted: ${filePath} (${String(err?.message ?? err)})`);
47
49
  await fs.unlink(filePath).catch(() => {});
48
50
  return {};
49
51
  }
@@ -115,8 +115,10 @@ export function loadOrCreateDeviceIdentity(filePath) {
115
115
  }
116
116
  }
117
117
  }
118
- catch {
119
- // 读取/解析失败时重新生成
118
+ catch (err) {
119
+ // 读取/解析失败时重新生成(将产生新 deviceId,需重新 enroll)
120
+ /* c8 ignore next -- ?./?? fallback */
121
+ console.warn?.(`[coclaw] device identity read failed, regenerating: ${String(err?.message ?? err)}`);
120
122
  }
121
123
 
122
124
  const identity = generateIdentity();
@@ -0,0 +1,191 @@
1
+ import { createRequire } from 'module';
2
+ import nodePath from 'path';
3
+ import fsSync from 'fs';
4
+ import fsPromises from 'fs/promises';
5
+ import { remoteLog as defaultRemoteLog } from './remote-log.js';
6
+
7
+ const SUPPORTED_PLATFORMS = new Set([
8
+ 'linux-x64',
9
+ 'linux-arm64',
10
+ 'darwin-x64',
11
+ 'darwin-arm64',
12
+ 'win32-x64',
13
+ ]);
14
+
15
+ const DEFAULT_IMPORT_TIMEOUT_MS = 10_000;
16
+
17
+ /**
18
+ * 给 promise 加超时保护。超时后 reject,但原 promise 仍在后台执行——
19
+ * JS 无法取消 pending 的 import(),超时只是让调用方不再等待。
20
+ * @param {Promise} promise
21
+ * @param {number} ms
22
+ * @param {string} label - 用于错误信息
23
+ */
24
+ function withTimeout(promise, ms, label) {
25
+ // 超时后原 promise 仍在后台执行(JS 无法取消 pending 的 import())。
26
+ // 必须 .catch 兜住原 promise 的潜在 rejection,否则超时场景下
27
+ // 原 promise 最终 reject 会成为 unhandled rejection,导致进程终止。
28
+ promise.catch(() => {});
29
+ let timer;
30
+ const timeout = new Promise((_, reject) => {
31
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
32
+ timer.unref?.();
33
+ });
34
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
35
+ }
36
+
37
+ /**
38
+ * 解析 vendor 源和部署目标路径。
39
+ * @param {string} platformKey - 如 'linux-x64'
40
+ * @param {string} pluginRoot - 插件根目录
41
+ * @returns {{ src: string, dest: string, destDir: string }}
42
+ */
43
+ export function defaultResolvePaths(platformKey, pluginRoot) {
44
+ const src = nodePath.join(pluginRoot, 'vendor', 'ndc-prebuilds', platformKey, 'node_datachannel.node');
45
+
46
+ // 定位 node-datachannel 包根:从入口路径向上查找 package.json
47
+ const require = createRequire(nodePath.join(pluginRoot, 'package.json'));
48
+ const entryPath = require.resolve('node-datachannel');
49
+ let pkgRoot = nodePath.dirname(entryPath);
50
+ while (pkgRoot !== nodePath.dirname(pkgRoot)) {
51
+ try {
52
+ const pkg = JSON.parse(fsSync.readFileSync(nodePath.join(pkgRoot, 'package.json'), 'utf8'));
53
+ if (pkg.name === 'node-datachannel') break;
54
+ } catch { /* 继续向上 */ }
55
+ pkgRoot = nodePath.dirname(pkgRoot);
56
+ }
57
+ const destDir = nodePath.join(pkgRoot, 'build', 'Release');
58
+ const dest = nodePath.join(destDir, 'node_datachannel.node');
59
+
60
+ return { src, dest, destDir };
61
+ }
62
+
63
+ /**
64
+ * 预加载 WebRTC 实现:优先 node-datachannel,失败回退 werift,全部失败返回 null。
65
+ *
66
+ * **此函数永不 throw**——所有异常内部捕获,通过 remoteLog 报告。
67
+ * 返回值始终为 { PeerConnection, cleanup, impl } 结构。
68
+ *
69
+ * @param {object} [deps] - 可注入依赖(测试用)
70
+ * @param {object} [deps.fs] - { access, copyFile, mkdir }
71
+ * @param {Function} [deps.dynamicImport] - (specifier) => import(specifier)
72
+ * @param {Function} [deps.remoteLog] - (text) => void
73
+ * @param {string} [deps.platform] - 覆盖 process.platform
74
+ * @param {string} [deps.arch] - 覆盖 process.arch
75
+ * @param {string} [deps.pluginRoot] - 覆盖插件根目录
76
+ * @param {Function} [deps.resolvePaths] - (platformKey, pluginRoot) => { src, dest, destDir }
77
+ * @param {number} [deps.importTimeout] - 动态 import 超时(ms),默认 10s
78
+ * @returns {Promise<{ PeerConnection: Function|null, cleanup: Function|null, impl: string }>}
79
+ */
80
+ export async function preloadNdc(deps = {}) {
81
+ const fs = deps.fs ?? fsPromises;
82
+ const dynamicImport = deps.dynamicImport ?? ((spec) => import(spec));
83
+ const log = deps.remoteLog ?? defaultRemoteLog;
84
+ const platform = deps.platform ?? process.platform;
85
+ const arch = deps.arch ?? process.arch;
86
+ const pluginRoot = deps.pluginRoot ?? nodePath.resolve(import.meta.dirname, '..');
87
+ const resolvePaths = deps.resolvePaths ?? defaultResolvePaths;
88
+ const importTimeout = deps.importTimeout ?? DEFAULT_IMPORT_TIMEOUT_MS;
89
+
90
+ const platformKey = `${platform}-${arch}`;
91
+ log(`ndc.preload platform=${platformKey}`);
92
+
93
+ try {
94
+ // 平台检查
95
+ if (!SUPPORTED_PLATFORMS.has(platformKey)) {
96
+ log(`ndc.skip reason=unsupported-platform platform=${platformKey}`);
97
+ return weriftFallback(dynamicImport, log, importTimeout);
98
+ }
99
+ const { src, dest, destDir } = resolvePaths(platformKey, pluginRoot);
100
+
101
+ // 检查目标 binary 是否已存在(正常 pnpm install 或已执行过 bootstrap)
102
+ let needCopy = false;
103
+ try {
104
+ await fs.access(dest);
105
+ log('ndc.binary-exists');
106
+ } catch {
107
+ needCopy = true;
108
+ }
109
+
110
+ if (needCopy) {
111
+ // 检查 vendor 源 binary
112
+ try {
113
+ await fs.access(src);
114
+ } catch {
115
+ log('ndc.fallback reason=binary-missing');
116
+ return weriftFallback(dynamicImport, log, importTimeout);
117
+ }
118
+
119
+ // 部署 binary
120
+ try {
121
+ await fs.mkdir(destDir, { recursive: true });
122
+ await fs.copyFile(src, dest);
123
+ log('ndc.binary-deployed');
124
+ } catch (err) {
125
+ log(`ndc.fallback reason=copy-failed error=${err.message}`);
126
+ return weriftFallback(dynamicImport, log, importTimeout);
127
+ }
128
+ }
129
+
130
+ // 加载模块(带超时保护,防止 native binding dlopen 卡住)
131
+ let polyfill, ndc;
132
+ try {
133
+ polyfill = await withTimeout(
134
+ dynamicImport('node-datachannel/polyfill'),
135
+ importTimeout,
136
+ 'import(node-datachannel/polyfill)',
137
+ );
138
+ ndc = await withTimeout(
139
+ dynamicImport('node-datachannel'),
140
+ importTimeout,
141
+ 'import(node-datachannel)',
142
+ );
143
+ } catch (err) {
144
+ log(`ndc.fallback reason=import-failed error=${err.message}`);
145
+ return weriftFallback(dynamicImport, log, importTimeout);
146
+ }
147
+
148
+ const { RTCPeerConnection } = polyfill;
149
+ const cleanup = ndc.cleanup ?? ndc.default?.cleanup ?? null;
150
+
151
+ // 验证 RTCPeerConnection 可用(不创建实例,避免 native thread 阻止进程退出)
152
+ if (typeof RTCPeerConnection !== 'function') {
153
+ log('ndc.fallback reason=smoke-failed error=RTCPeerConnection is not a function');
154
+ return weriftFallback(dynamicImport, log, importTimeout);
155
+ }
156
+
157
+ // 重要:调用方在不再需要 node-datachannel 时(如 bridge stop),必须调用 cleanup()。
158
+ // node-datachannel 内部使用 ThreadSafeCallback 维持 native threads,不调用 cleanup()
159
+ // 会阻止 Node 进程正常退出(上游 issue #366)。
160
+ // 当前由 RealtimeBridge.stop() 负责调用。若 gateway 被 SIGKILL 强杀则无法执行,
161
+ // 但 OS 会回收所有资源。若 OpenClaw 提供了优雅终止钩子,应在钩子中也调用 cleanup。
162
+ log(`ndc.loaded platform=${platformKey}`);
163
+ return { PeerConnection: RTCPeerConnection, cleanup, impl: 'ndc' };
164
+ } catch (err) {
165
+ // resolvePaths 或其他未预期异常的兜底
166
+ log(`ndc.fallback reason=unexpected error=${err.message}`);
167
+ return weriftFallback(dynamicImport, log, importTimeout);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * 回退到 werift。加载也带超时保护。
173
+ * werift 也失败时返回 PeerConnection: null(WebRTC 不可用但不影响 gateway)。
174
+ * @param {Function} dynamicImport
175
+ * @param {Function} log
176
+ * @param {number} importTimeout
177
+ */
178
+ async function weriftFallback(dynamicImport, log, importTimeout) {
179
+ try {
180
+ const { RTCPeerConnection } = await withTimeout(
181
+ dynamicImport('werift'),
182
+ importTimeout,
183
+ 'import(werift)',
184
+ );
185
+ log('webrtc.fallback-to-werift');
186
+ return { PeerConnection: RTCPeerConnection, cleanup: null, impl: 'werift' };
187
+ } catch (err) {
188
+ log(`webrtc.all-unavailable error=${err.message}`);
189
+ return { PeerConnection: null, cleanup: null, impl: 'none' };
190
+ }
191
+ }
@@ -10,6 +10,7 @@ import {
10
10
  buildDeviceAuthPayloadV3,
11
11
  } from './device-identity.js';
12
12
  import { getRuntime } from './runtime.js';
13
+ import { setSender as setRemoteLogSender, remoteLog } from './remote-log.js';
13
14
 
14
15
  const DEFAULT_GATEWAY_WS_URL = `ws://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT || '18789'}`;
15
16
  const RECONNECT_MS = 10_000;
@@ -52,7 +53,8 @@ function defaultResolveGatewayAuthToken() {
52
53
  const token = cfg?.gateway?.auth?.token;
53
54
  return typeof token === 'string' && token.trim() ? token.trim() : '';
54
55
  }
55
- catch {
56
+ catch (err) {
57
+ console.warn?.(`[coclaw] resolve gateway auth token failed: ${String(err?.message ?? err)}`);
56
58
  return '';
57
59
  }
58
60
  }
@@ -72,6 +74,7 @@ export class RealtimeBridge {
72
74
  * @param {Function} [deps.getBindingsPath] - 获取绑定文件路径
73
75
  * @param {Function} [deps.resolveGatewayAuthToken] - 获取 gateway 认证 token
74
76
  * @param {Function} [deps.loadDeviceIdentity] - 加载设备身份
77
+ * @param {number} [deps.gatewayReadyTimeoutMs] - __waitGatewayReady 默认超时(测试可注入短值)
75
78
  */
76
79
  constructor(deps = {}) {
77
80
  this.__readConfig = deps.readConfig ?? readConfig;
@@ -79,7 +82,9 @@ export class RealtimeBridge {
79
82
  this.__getBindingsPath = deps.getBindingsPath ?? getBindingsPath;
80
83
  this.__resolveGatewayAuthToken = deps.resolveGatewayAuthToken ?? defaultResolveGatewayAuthToken;
81
84
  this.__loadDeviceIdentity = deps.loadDeviceIdentity ?? loadOrCreateDeviceIdentity;
85
+ this.__preloadNdc = deps.preloadNdc ?? null;
82
86
  this.__WebSocket = deps.WebSocket ?? null;
87
+ this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
83
88
 
84
89
  this.serverWs = null;
85
90
  this.gatewayWs = null;
@@ -100,6 +105,8 @@ export class RealtimeBridge {
100
105
  this.webrtcPeer = null;
101
106
  this.__webrtcPeerReady = null;
102
107
  this.__fileHandler = null;
108
+ this.__ndcPreloadResult = null;
109
+ this.__ndcCleanup = null;
103
110
  }
104
111
 
105
112
  __resolveWebSocket() {
@@ -149,6 +156,7 @@ export class RealtimeBridge {
149
156
  this.serverHbTimer.unref?.();
150
157
  return;
151
158
  }
159
+ remoteLog(`ws.hb-timeout peer=server misses=${this.__serverHbMissCount}`);
152
160
  this.logger.warn?.(
153
161
  `[coclaw] server ws heartbeat timeout after ${this.__serverHbMissCount} consecutive misses (~${this.__serverHbMissCount * SERVER_HB_TIMEOUT_MS / 1000}s), closing`
154
162
  );
@@ -198,22 +206,26 @@ export class RealtimeBridge {
198
206
  }
199
207
 
200
208
  /** 懒加载 WebRtcPeer(promise 锁防并发重复创建) */
209
+ /* c8 ignore start -- 仅通过 WebRTC 路径触发,集成测试覆盖 */
201
210
  async __initWebrtcPeer() {
211
+ const PeerConnection = this.__ndcPreloadResult?.PeerConnection;
212
+ if (!PeerConnection) {
213
+ remoteLog('rtc.unavailable reason=no-webrtc-impl');
214
+ throw new Error('No WebRTC implementation available');
215
+ }
216
+
202
217
  const { WebRtcPeer } = await import('./webrtc-peer.js');
203
218
  const { createFileHandler } = await import('./file-manager/handler.js');
204
- /* c8 ignore start -- 仅通过 WebRTC 路径触发,集成测试覆盖 */
205
219
  this.__fileHandler = createFileHandler({
206
220
  resolveWorkspace: (agentId) => this.__resolveWorkspace(agentId),
207
221
  logger: this.logger,
208
222
  });
209
223
  this.__fileHandler.scheduleTmpCleanup(() => this.__listAgentWorkspaces());
210
- /* c8 ignore stop */
211
224
  this.webrtcPeer = new WebRtcPeer({
212
225
  onSend: (msg) => this.__forwardToServer(msg),
213
226
  onRequest: (dcPayload) => {
214
227
  void this.__handleGatewayRequestFromServer(dcPayload);
215
228
  },
216
- /* c8 ignore start -- 仅通过 WebRTC 路径触发,集成测试覆盖 */
217
229
  onFileRpc: (payload, sendFn) => {
218
230
  this.__fileHandler.handleRpcRequest(payload, sendFn)
219
231
  .catch((err) => this.logger.warn?.(`[coclaw/file] rpc error: ${err.message}`));
@@ -221,10 +233,11 @@ export class RealtimeBridge {
221
233
  onFileChannel: (dc) => {
222
234
  this.__fileHandler.handleFileChannel(dc);
223
235
  },
224
- /* c8 ignore stop */
236
+ PeerConnection,
225
237
  logger: this.logger,
226
238
  });
227
239
  }
240
+ /* c8 ignore stop */
228
241
 
229
242
  /* c8 ignore next 7 -- 防御性检查,serverWs 通常在调用时可用 */
230
243
  __forwardToServer(payload) {
@@ -450,8 +463,8 @@ export class RealtimeBridge {
450
463
  try {
451
464
  const ws = await this.__resolveWorkspace(id);
452
465
  workspaces.push(ws);
453
- } catch {
454
- // 个别 agent 解析失败不阻断
466
+ } catch (err) {
467
+ this.__logDebug(`workspace resolve failed for agent=${id}: ${err?.message}`);
455
468
  }
456
469
  }
457
470
  return workspaces;
@@ -523,7 +536,8 @@ export class RealtimeBridge {
523
536
  params,
524
537
  }));
525
538
  }
526
- catch {
539
+ catch (err) {
540
+ this.logger.warn?.(`[coclaw] gateway connect request failed: ${String(err?.message ?? err)}`);
527
541
  this.gatewayConnectReqId = null;
528
542
  }
529
543
  }
@@ -562,6 +576,7 @@ export class RealtimeBridge {
562
576
  if (payload.type === 'res' && this.gatewayConnectReqId && payload.id === this.gatewayConnectReqId) {
563
577
  if (payload.ok === true) {
564
578
  this.gatewayReady = true;
579
+ remoteLog('ws.connected peer=gateway');
565
580
  this.__logDebug(`gateway connect ok <- id=${payload.id}`);
566
581
  this.gatewayConnectReqId = null;
567
582
  this.__ensureSessionsPromise = this.__ensureAllAgentSessions();
@@ -569,6 +584,7 @@ export class RealtimeBridge {
569
584
  else {
570
585
  this.gatewayReady = false;
571
586
  this.gatewayConnectReqId = null;
587
+ remoteLog(`ws.connect-failed peer=gateway msg=${payload?.error?.message ?? 'unknown'}`);
572
588
  this.logger.warn?.(`[coclaw] gateway connect failed: ${payload?.error?.message ?? 'unknown'}`);
573
589
  try { ws.close(1008, 'gateway_connect_failed'); }
574
590
  /* c8 ignore next */
@@ -592,15 +608,18 @@ export class RealtimeBridge {
592
608
  return;
593
609
  }
594
610
  if (payload.type === 'res' || payload.type === 'event') {
611
+ // TODO: UI 已通过 DataChannel 接收业务消息,待旧版 UI 全部更新后移除此转发
595
612
  this.__forwardToServer(payload);
596
613
  this.webrtcPeer?.broadcast(payload);
597
614
  }
598
615
  });
599
616
 
600
617
  ws.addEventListener('open', () => {
601
- // wait for connect.challenge
618
+ this.__logDebug('gateway ws open, waiting for connect.challenge');
602
619
  });
603
- ws.addEventListener('close', () => {
620
+ ws.addEventListener('close', (ev) => {
621
+ remoteLog(`ws.disconnected peer=gateway code=${ev?.code ?? '?'}`);
622
+ this.logger.info?.(`[coclaw] gateway ws closed (code=${ev?.code ?? '?'} reason=${ev?.reason ?? 'n/a'})`);
604
623
  this.gatewayWs = null;
605
624
  this.gatewayReady = false;
606
625
  this.gatewayConnectReqId = null;
@@ -610,10 +629,14 @@ export class RealtimeBridge {
610
629
  }
611
630
  this.gatewayPendingRequests.clear();
612
631
  });
613
- ws.addEventListener('error', () => {});
632
+ ws.addEventListener('error', (err) => {
633
+ /* c8 ignore next -- ?./?? fallback */
634
+ remoteLog(`ws.error peer=gateway msg=${String(err?.message ?? err)}`);
635
+ this.logger.warn?.(`[coclaw] gateway ws error: ${String(err?.message ?? err)}`);
636
+ });
614
637
  }
615
638
 
616
- async __waitGatewayReady(timeoutMs = 1500) {
639
+ async __waitGatewayReady(timeoutMs = this.__gatewayReadyTimeoutMs) {
617
640
  this.__ensureGatewayConnection();
618
641
  if (this.gatewayWs && this.gatewayWs.readyState === 1 && this.gatewayReady) {
619
642
  return true;
@@ -711,6 +734,7 @@ export class RealtimeBridge {
711
734
  if (!this.started || this.reconnectTimer) {
712
735
  return;
713
736
  }
737
+ remoteLog(`ws.reconnecting peer=server delay=${RECONNECT_MS}ms`);
714
738
  this.reconnectTimer = setTimeout(() => {
715
739
  this.reconnectTimer = null;
716
740
  this.__connectIfNeeded().catch((err) => {
@@ -758,6 +782,7 @@ export class RealtimeBridge {
758
782
  return;
759
783
  }
760
784
  this.logger.warn?.(`[coclaw] realtime bridge connect timeout, will retry: ${maskedTarget}`);
785
+ remoteLog('ws.connect-timeout peer=server');
761
786
  this.serverWs = null;
762
787
  this.__closeGatewayWs();
763
788
  this.__scheduleReconnect();
@@ -770,6 +795,10 @@ export class RealtimeBridge {
770
795
  sock.addEventListener('open', () => {
771
796
  this.__clearConnectTimer();
772
797
  this.logger.info?.(`[coclaw] realtime bridge connected: ${maskedTarget}`);
798
+ remoteLog('ws.connected peer=server');
799
+ setRemoteLogSender((msg) => {
800
+ if (sock.readyState === 1) sock.send(JSON.stringify(msg));
801
+ });
773
802
  this.__startServerHeartbeat(sock);
774
803
  this.__ensureGatewayConnection();
775
804
  });
@@ -779,6 +808,7 @@ export class RealtimeBridge {
779
808
  try {
780
809
  const payload = JSON.parse(String(event.data ?? '{}'));
781
810
  if (payload?.type === 'bot.unbound') {
811
+ remoteLog('ws.bot-unbound');
782
812
  await this.__clearTokenLocal(payload.botId);
783
813
  try { sock.close(4001, 'bot_unbound'); }
784
814
  /* c8 ignore next */
@@ -797,6 +827,7 @@ export class RealtimeBridge {
797
827
  await this.webrtcPeer.handleSignaling(payload);
798
828
  } catch (err) {
799
829
  this.logger.warn?.(`[coclaw/rtc] signaling error (or werift not found): ${err?.message}`);
830
+ remoteLog(`rtc.signaling-error msg=${err?.message}`);
800
831
  }
801
832
  return;
802
833
  }
@@ -820,6 +851,7 @@ export class RealtimeBridge {
820
851
  if (this.serverWs !== null && this.serverWs !== sock) {
821
852
  return;
822
853
  }
854
+ setRemoteLogSender(null);
823
855
  const wasIntentional = this.intentionallyClosed;
824
856
  this.serverWs = null;
825
857
  this.intentionallyClosed = false;
@@ -837,6 +869,8 @@ export class RealtimeBridge {
837
869
  }
838
870
 
839
871
  if (event?.code === 4001 || event?.code === 4003) {
872
+ remoteLog(`ws.auth-close peer=server code=${event.code}`);
873
+ this.logger.warn?.(`[coclaw] server ws auth-close (code=${event.code}), clearing local token`);
840
874
  try {
841
875
  await this.__clearTokenLocal();
842
876
  }
@@ -848,6 +882,7 @@ export class RealtimeBridge {
848
882
  }
849
883
 
850
884
  if (!wasIntentional) {
885
+ remoteLog(`ws.disconnected peer=server code=${event?.code ?? 'unknown'} reason=${event?.reason ?? 'n/a'}`);
851
886
  this.logger.warn?.(`[coclaw] realtime bridge closed (${event?.code ?? 'unknown'}: ${event?.reason ?? 'n/a'}), will retry in ${RECONNECT_MS}ms`);
852
887
  this.__scheduleReconnect();
853
888
  }
@@ -859,6 +894,8 @@ export class RealtimeBridge {
859
894
  }
860
895
  this.__clearServerHeartbeat();
861
896
  this.__clearConnectTimer();
897
+ setRemoteLogSender(null);
898
+ remoteLog(`ws.error peer=server msg=${String(err?.message ?? err)}`);
862
899
  this.logger.warn?.(`[coclaw] realtime bridge error, will retry in ${RECONNECT_MS}ms: ${String(err?.message ?? err)}`);
863
900
  this.serverWs = null;
864
901
  this.__closeGatewayWs();
@@ -873,6 +910,27 @@ export class RealtimeBridge {
873
910
  this.logger = logger ?? console;
874
911
  this.pluginConfig = pluginConfig ?? {};
875
912
  this.started = true;
913
+ // 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
914
+ const preloadFn = this.__preloadNdc
915
+ ?? (await import('./ndc-preloader.js')).preloadNdc;
916
+ const preloadResult = await preloadFn()
917
+ .catch((err) => {
918
+ // preloadNdc 设计上永不 throw,此 catch 为纯防御性兜底
919
+ this.logger.warn?.(`[coclaw] ndc preload unexpected failure: ${err?.message}`);
920
+ return { PeerConnection: null, cleanup: null, impl: 'none' };
921
+ });
922
+ // 竞态保护:若 preload 期间 stop() 已执行,不再赋值,立即释放 cleanup
923
+ if (!this.started) {
924
+ if (preloadResult.cleanup) {
925
+ try { preloadResult.cleanup(); } catch {}
926
+ }
927
+ return;
928
+ }
929
+ this.__ndcPreloadResult = preloadResult;
930
+ this.__ndcCleanup = preloadResult.cleanup;
931
+ const implLabel = preloadResult.impl === 'ndc' ? 'node-datachannel(ndc)' : preloadResult.impl;
932
+ this.logger.info?.(`[coclaw] WebRTC impl: ${implLabel}`);
933
+ remoteLog(`bridge.webrtc-impl impl=${implLabel}`);
876
934
  await this.__connectIfNeeded();
877
935
  }
878
936
 
@@ -886,6 +944,7 @@ export class RealtimeBridge {
886
944
 
887
945
  async stop() {
888
946
  this.started = false;
947
+ setRemoteLogSender(null);
889
948
  this.__clearServerHeartbeat();
890
949
  this.__clearConnectTimer();
891
950
  if (this.reconnectTimer) {
@@ -898,6 +957,17 @@ export class RealtimeBridge {
898
957
  this.webrtcPeer = null;
899
958
  this.__webrtcPeerReady = null;
900
959
  }
960
+ // ndc cleanup:node-datachannel 的 native threads 必须通过 cleanup() 释放,
961
+ // 否则会阻止进程退出(issue #366)。
962
+ // start() 已 await preload 完成并缓存 cleanup 引用,此处直接使用。
963
+ // 注意:若进程被 SIGKILL 强杀,此处不会执行,OS 会回收资源。
964
+ // TODO: 若 OpenClaw 未来提供 graceful shutdown 钩子,应在钩子中也调用 cleanup。
965
+ if (this.__ndcCleanup) {
966
+ try { this.__ndcCleanup(); }
967
+ catch (err) { remoteLog(`ndc.cleanup-failed error=${err?.message}`); }
968
+ }
969
+ this.__ndcCleanup = null;
970
+ this.__ndcPreloadResult = null;
901
971
  if (this.__fileHandler) {
902
972
  this.__fileHandler.cancelCleanup();
903
973
  this.__fileHandler = null;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 远程日志推送模块
3
+ *
4
+ * 将诊断日志缓冲并通过 WS 通道推送到 CoClaw server。
5
+ * 单例模式——各模块直接 import { remoteLog } 使用。
6
+ */
7
+
8
+ const MAX_BUFFER = 1000;
9
+ const BATCH_SIZE = 20;
10
+
11
+ /** @type {{ ts: number, text: string }[]} */
12
+ const buffer = [];
13
+
14
+ /** @type {((msg: object) => void) | null} */
15
+ let sender = null;
16
+
17
+ let flushing = false;
18
+
19
+ /**
20
+ * 注入/移除发送函数。由 RealtimeBridge 在 WS 连接/断开时调用。
21
+ * @param {((msg: object) => void) | null} fn
22
+ */
23
+ export function setSender(fn) {
24
+ sender = fn;
25
+ if (fn && buffer.length > 0) {
26
+ flush().catch(() => {});
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 推送一条远程诊断日志。
32
+ * @param {string} text - 可读文本描述(不含时间戳,内部自动附加)
33
+ */
34
+ export function remoteLog(text) {
35
+ if (buffer.length >= MAX_BUFFER) {
36
+ buffer.shift();
37
+ }
38
+ buffer.push({ ts: Date.now(), text });
39
+ if (sender && !flushing) {
40
+ flush().catch(() => {});
41
+ }
42
+ }
43
+
44
+ async function flush() {
45
+ if (flushing) return;
46
+ flushing = true;
47
+ try {
48
+ while (buffer.length > 0 && sender) {
49
+ const batch = buffer.slice(0, BATCH_SIZE);
50
+ try {
51
+ sender({ type: 'log', logs: batch });
52
+ buffer.splice(0, batch.length);
53
+ } catch {
54
+ break;
55
+ }
56
+ await new Promise(r => setTimeout(r, 0));
57
+ }
58
+ } finally {
59
+ flushing = false;
60
+ }
61
+ }
62
+
63
+ // 测试用:重置内部状态
64
+ export function __reset() {
65
+ buffer.length = 0;
66
+ sender = null;
67
+ flushing = false;
68
+ }
69
+
70
+ export { buffer as __buffer, BATCH_SIZE as __BATCH_SIZE, MAX_BUFFER as __MAX_BUFFER };
@@ -1,5 +1,5 @@
1
- import { RTCPeerConnection as WeriftRTCPeerConnection } from 'werift';
2
1
  import { chunkAndSend, createReassembler } from './utils/dc-chunking.js';
2
+ import { remoteLog } from './remote-log.js';
3
3
 
4
4
  /**
5
5
  * 管理多个 WebRTC PeerConnection(以 connId 为粒度)。
@@ -13,15 +13,18 @@ export class WebRtcPeer {
13
13
  * @param {function} [opts.onFileRpc] - rpc DC 上 coclaw.files.* 请求的回调 (payload, sendFn, connId) => void
14
14
  * @param {function} [opts.onFileChannel] - file:<transferId> DataChannel 的回调 (dc, connId) => void
15
15
  * @param {object} [opts.logger] - pino 风格 logger
16
- * @param {function} [opts.PeerConnection] - 可替换的构造函数(测试用)
16
+ * @param {function} opts.PeerConnection - RTCPeerConnection 构造函数(由 ndc-preloader 提供)
17
17
  */
18
18
  constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection }) {
19
+ if (!PeerConnection) {
20
+ throw new Error('PeerConnection constructor is required');
21
+ }
19
22
  this.__onSend = onSend;
20
23
  this.__onRequest = onRequest;
21
24
  this.__onFileRpc = onFileRpc;
22
25
  this.__onFileChannel = onFileChannel;
23
26
  this.logger = logger ?? console;
24
- this.__PeerConnection = PeerConnection ?? WeriftRTCPeerConnection;
27
+ this.__PeerConnection = PeerConnection;
25
28
  /** @type {Map<string, { pc: object, rpcChannel: object|null, remoteMaxMessageSize: number, nextMsgId: number }>} */
26
29
  this.__sessions = new Map();
27
30
  }
@@ -50,6 +53,7 @@ export class WebRtcPeer {
50
53
  session.pc.onconnectionstatechange = null;
51
54
  session.pc.onicecandidate = null;
52
55
  await session.pc.close();
56
+ remoteLog(`rtc.closed conn=${connId}`);
53
57
  this.logger.info?.(`[coclaw/rtc] [${connId}] closed`);
54
58
  }
55
59
 
@@ -82,6 +86,7 @@ export class WebRtcPeer {
82
86
  if (isIceRestart) {
83
87
  const existing = this.__sessions.get(connId);
84
88
  if (existing) {
89
+ remoteLog(`rtc.ice-restart conn=${connId}`);
85
90
  this.logger.info?.(`[coclaw/rtc] ICE restart offer from ${connId}, renegotiating`);
86
91
  try {
87
92
  await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
@@ -96,6 +101,7 @@ export class WebRtcPeer {
96
101
  return;
97
102
  } catch (err) {
98
103
  // ICE restart 协商失败 → 回退到 full rebuild
104
+ remoteLog(`rtc.ice-restart-failed conn=${connId}`);
99
105
  this.logger.warn?.(`[coclaw/rtc] ICE restart failed for ${connId}, falling back to rebuild: ${err?.message}`);
100
106
  await this.closeByConnId(connId);
101
107
  }
@@ -103,6 +109,7 @@ export class WebRtcPeer {
103
109
  // 无现有 session 或 ICE restart 失败 → 按 full rebuild 继续
104
110
  }
105
111
 
112
+ remoteLog(`rtc.offer conn=${connId}`);
106
113
  this.logger.info?.(`[coclaw/rtc] offer received from ${connId}, creating answer`);
107
114
 
108
115
  // 同一 connId 重复 offer → 先关闭旧连接
@@ -125,6 +132,11 @@ export class WebRtcPeer {
125
132
  }
126
133
  }
127
134
 
135
+ // 记录 ICE 服务器配置(脱敏,不含 credential)
136
+ const stunUrl = iceServers.find((s) => s.urls?.startsWith('stun:'))?.urls ?? 'none';
137
+ const turnUrl = iceServers.find((s) => s.urls?.startsWith('turn:'))?.urls ?? 'none';
138
+ remoteLog(`rtc.ice-config conn=${connId} stun=${stunUrl} turn=${turnUrl}`);
139
+
128
140
  const pc = new this.__PeerConnection({ iceServers });
129
141
 
130
142
  // 从 SDP 解析对端 maxMessageSize(用于分片决策)
@@ -134,9 +146,19 @@ export class WebRtcPeer {
134
146
  const session = { pc, rpcChannel: null, remoteMaxMessageSize, nextMsgId: 1 };
135
147
  this.__sessions.set(connId, session);
136
148
 
137
- // ICE candidate → 发给 UI
149
+ // ICE candidate → 发给 UI,并统计各类型 candidate 数量
150
+ const candidateCounts = { host: 0, srflx: 0, relay: 0 };
138
151
  pc.onicecandidate = ({ candidate }) => {
139
- if (!candidate) return;
152
+ if (!candidate) {
153
+ // gathering 完成,输出汇总
154
+ remoteLog(`rtc.ice-gathered conn=${connId} host=${candidateCounts.host} srflx=${candidateCounts.srflx} relay=${candidateCounts.relay}`);
155
+ return;
156
+ }
157
+ // 从 candidate 字符串中提取类型(typ host / typ srflx / typ relay)
158
+ const typMatch = candidate.candidate?.match(/typ (\w+)/);
159
+ if (typMatch && candidateCounts[typMatch[1]] !== undefined) {
160
+ candidateCounts[typMatch[1]]++;
161
+ }
140
162
  this.__onSend({
141
163
  type: 'rtc:ice',
142
164
  toConnId: connId,
@@ -151,6 +173,7 @@ export class WebRtcPeer {
151
173
  // 连接状态变更(校验 pc 归属,防止旧 PC 异步回调删除新 session)
152
174
  pc.onconnectionstatechange = () => {
153
175
  const state = pc.connectionState;
176
+ remoteLog(`rtc.state conn=${connId} ${state}`);
154
177
  this.logger.info?.(`[coclaw/rtc] [${connId}] connectionState: ${state}`);
155
178
  if (state === 'connected') {
156
179
  const nominated = pc.iceTransports?.[0]?.connection?.nominated;
@@ -159,6 +182,7 @@ export class WebRtcPeer {
159
182
  const remoteC = nominated.remoteCandidate;
160
183
  const localInfo = `${localC?.type ?? '?'} ${localC?.host ?? '?'}:${localC?.port ?? '?'}`;
161
184
  const remoteInfo = `${remoteC?.type ?? '?'} ${remoteC?.host ?? '?'}:${remoteC?.port ?? '?'}`;
185
+ remoteLog(`rtc.ice-nominated conn=${connId} local=${localInfo} remote=${remoteInfo}`);
162
186
  this.logger.info?.(`[coclaw/rtc] [${connId}] ICE nominated: local=${localInfo} remote=${remoteInfo}`);
163
187
  }
164
188
  } else if (state === 'failed' || state === 'closed') {
@@ -171,6 +195,7 @@ export class WebRtcPeer {
171
195
 
172
196
  // 监听 UI 创建的 DataChannel
173
197
  pc.ondatachannel = ({ channel }) => {
198
+ remoteLog(`dc.received conn=${connId} label=${channel.label}`);
174
199
  this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${channel.label}" received`);
175
200
  if (channel.label === 'rpc') {
176
201
  session.rpcChannel = channel;
@@ -191,6 +216,7 @@ export class WebRtcPeer {
191
216
  toConnId: connId,
192
217
  payload: { sdp: answer.sdp },
193
218
  });
219
+ remoteLog(`rtc.answer conn=${connId}`);
194
220
  this.logger.info?.(`[coclaw/rtc] answer sent to ${connId}`);
195
221
  } catch (err) {
196
222
  // SDP 协商失败 → 清理已入 Map 的 session,避免泄漏
@@ -217,6 +243,12 @@ export class WebRtcPeer {
217
243
  __setupDataChannel(connId, dc) {
218
244
  const reassembler = createReassembler((jsonStr) => {
219
245
  const payload = JSON.parse(jsonStr);
246
+ // DC 探测:立即回复,不走 gateway
247
+ if (payload.type === 'probe') {
248
+ try { dc.send(JSON.stringify({ type: 'probe-ack' })); }
249
+ catch { /* DC 已关闭,忽略 */ }
250
+ return;
251
+ }
220
252
  if (payload.type === 'req') {
221
253
  // coclaw.files.* 方法本地处理,不转发 gateway
222
254
  if (payload.method?.startsWith('coclaw.files.') && this.__onFileRpc) {
@@ -243,14 +275,21 @@ export class WebRtcPeer {
243
275
  }, { logger: this.logger });
244
276
 
245
277
  dc.onopen = () => {
278
+ remoteLog(`dc.open conn=${connId} label=${dc.label}`);
246
279
  this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" opened`);
247
280
  };
248
281
  dc.onclose = () => {
282
+ remoteLog(`dc.closed conn=${connId} label=${dc.label}`);
249
283
  this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" closed`);
250
284
  reassembler.reset();
251
285
  const session = this.__sessions.get(connId);
252
286
  if (session && dc.label === 'rpc') session.rpcChannel = null;
253
287
  };
288
+ dc.onerror = (err) => {
289
+ remoteLog(`dc.error conn=${connId} label=${dc.label}`);
290
+ /* c8 ignore next -- ?./?? fallback */
291
+ this.logger.warn?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" error: ${String(err?.message ?? err)}`);
292
+ };
254
293
  dc.onmessage = (event) => {
255
294
  try {
256
295
  reassembler.feed(event.data);