@coclaw/openclaw-coclaw 0.9.2 → 0.11.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
@@ -88,15 +88,6 @@ openclaw coclaw enroll [--server <url>]
88
88
 
89
89
  需要 gateway 运行中。
90
90
 
91
- ### 方式三:独立 CLI(遗留)
92
-
93
- ```bash
94
- node ~/.openclaw/extensions/coclaw/src/cli.js bind <binding-code> --server <url>
95
- node ~/.openclaw/extensions/coclaw/src/cli.js unbind --server <url>
96
- ```
97
-
98
- > 注意:独立 CLI 不走 gateway RPC,直接在 CLI 进程中执行 bind/unbind 并通过 `coclaw.refreshBridge`/`coclaw.stopBridge` 通知 gateway。此路径不具备瘦 CLI 的架构保证(所有 config 操作在同一进程内)。推荐使用方式一。
99
-
100
91
  ## 配置存储
101
92
 
102
93
  绑定信息存储在 `~/.openclaw/coclaw/bindings.json`(通过 `resolveStateDir()` + channel ID 组合路径),**不存储在 `openclaw.json` 中**。
@@ -137,6 +128,25 @@ openclaw gateway call coclaw.upgradeHealth --json
137
128
 
138
129
  详见设计文档 `docs/auto-upgrade.md`。
139
130
 
131
+ ## WebRTC 实现
132
+
133
+ 插件支持两个 WebRTC 实现,运行时自动选择:
134
+
135
+ 1. **node-datachannel**(首选)— 基于 libdatachannel 的工业级实现,通过 vendor 预编译 native binary 部署。
136
+ 2. **werift**(回退)— 纯 JavaScript 实现,作为 node-datachannel 加载失败时的兜底。
137
+
138
+ 选择结果通过 remoteLog 上报(`ndc.loaded` 或 `ndc.using-werift`)。
139
+
140
+ ### vendor 预编译包
141
+
142
+ 由于 OpenClaw 使用 `--ignore-scripts` 安装插件,node-datachannel 的 native binary 需通过 vendor 预编译包提供:
143
+
144
+ ```bash
145
+ bash scripts/download-ndc-prebuilds.sh # 下载 5 平台预编译包到 vendor/ndc-prebuilds/
146
+ ```
147
+
148
+ 支持的平台:linux-x64、linux-arm64、darwin-x64、darwin-arm64、win32-x64。vendor 目录不入 git,通过 npm publish 的 `files` 字段包含在发布包中。
149
+
140
150
  ## 运行与排障日志
141
151
 
142
152
  ### 日志级别建议
@@ -176,10 +186,8 @@ openclaw logs --limit 300 --plain | rg -n "ui->server req|bot->server res|bot->s
176
186
  ## 测试门禁
177
187
 
178
188
  ```bash
179
- pnpm check # lint + typecheck
180
- pnpm test # 全部单测
181
- pnpm coverage # 覆盖率检查
182
- pnpm verify # 完整验证(check → test:standalone → test:plugin → test → coverage)
189
+ pnpm check # lint + typecheck
190
+ pnpm test # 测试 + 覆盖率检查
183
191
  ```
184
192
 
185
193
  覆盖率阈值:lines/statements/functions 100%,branches ≥ 95%。未通过禁止接入 gateway。
package/index.js CHANGED
@@ -1,12 +1,10 @@
1
- import fs from 'node:fs/promises';
2
- import nodePath from 'node:path';
3
-
4
1
  import { bindBot, unbindBot, enrollBot, waitForClaimAndSave } from './src/common/bot-binding.js';
5
2
  import { registerCoclawCli } from './src/cli-registrar.js';
6
3
  import { resolveErrorMessage } from './src/common/errors.js';
7
4
  import { notBound, bindOk, unbindOk, claimCodeCreated } from './src/common/messages.js';
8
5
  import { coclawChannelPlugin } from './src/channel-plugin.js';
9
- import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge, waitForSessionsReady } from './src/realtime-bridge.js';
6
+ import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge, waitForSessionsReady, broadcastPluginEvent } from './src/realtime-bridge.js';
7
+ import { getHostName, readSettings, writeName, MAX_NAME_LENGTH } from './src/settings.js';
10
8
  import { setRuntime } from './src/runtime.js';
11
9
  import { createSessionManager } from './src/session-manager/manager.js';
12
10
  import { TopicManager } from './src/topic-manager/manager.js';
@@ -16,27 +14,12 @@ import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
16
14
  import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
17
15
  import { createFileHandler } from './src/file-manager/handler.js';
18
16
 
19
- // 延迟读取 + 缓存:避免模块加载时 package.json 损坏导致插件整体无法注册
20
- let __pluginVersion = null;
21
- export async function getPluginVersion() {
22
- if (__pluginVersion) return __pluginVersion;
23
- try {
24
- const pkgPath = nodePath.resolve(import.meta.dirname, 'package.json');
25
- const raw = await fs.readFile(pkgPath, 'utf8');
26
- __pluginVersion = JSON.parse(raw).version ?? 'unknown';
27
- } catch {
28
- return 'unknown';
29
- }
30
- return __pluginVersion;
31
- }
32
- // 测试用:重置缓存
33
- export function __resetPluginVersion() { __pluginVersion = null; }
34
-
17
+ import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
18
+ export { getPluginVersion, __resetPluginVersion };
35
19
 
20
+ /* c8 ignore start */
36
21
  function parseCommandArgs(args) {
37
- /* c8 ignore next */
38
22
  const tokens = (args ?? '').split(/\s+/).filter(Boolean);
39
- /* c8 ignore next */
40
23
  const action = tokens[0] ?? 'help';
41
24
  const options = {};
42
25
  const positionals = [];
@@ -67,7 +50,6 @@ function buildHelpText() {
67
50
  function respondError(respond, err) {
68
51
  respond(false, undefined, {
69
52
  code: err?.code ?? 'INTERNAL_ERROR',
70
- /* c8 ignore next */
71
53
  message: String(err?.message ?? err),
72
54
  });
73
55
  }
@@ -75,8 +57,6 @@ function respondError(respond, err) {
75
57
  function respondInvalid(respond, message) {
76
58
  respond(false, undefined, { code: 'INVALID_INPUT', message });
77
59
  }
78
-
79
- /* c8 ignore start */
80
60
  const plugin = {
81
61
  id: 'openclaw-coclaw',
82
62
  name: 'CoClaw',
@@ -130,26 +110,6 @@ const plugin = {
130
110
  },
131
111
  });
132
112
 
133
- api.registerGatewayMethod('coclaw.refreshBridge', async ({ respond }) => {
134
- try {
135
- await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
136
- respond(true, { status: 'refreshed' });
137
- }
138
- catch (err) {
139
- respondError(respond, err);
140
- }
141
- });
142
-
143
- api.registerGatewayMethod('coclaw.stopBridge', async ({ respond }) => {
144
- try {
145
- await stopRealtimeBridge();
146
- respond(true, { status: 'stopped' });
147
- }
148
- catch (err) {
149
- respondError(respond, err);
150
- }
151
- });
152
-
153
113
  // --- bind/unbind 共享逻辑(RPC handler + 斜杠命令共用) ---
154
114
 
155
115
  async function doBind({ code, serverUrl }) {
@@ -290,14 +250,48 @@ const plugin = {
290
250
  }
291
251
  });
292
252
 
293
- api.registerGatewayMethod('coclaw.info', async ({ respond }) => {
253
+ async function handleInfoGet({ respond }) {
294
254
  try {
295
255
  await waitForSessionsReady();
296
256
  const version = await getPluginVersion();
297
257
  const rawClawVersion = api.runtime?.version;
298
258
  // OpenClaw 打包后 resolveVersion() 路径失配导致返回 'unknown',此时不传该字段
299
259
  const clawVersion = (rawClawVersion && rawClawVersion !== 'unknown') ? rawClawVersion : undefined;
300
- respond(true, { version, clawVersion, capabilities: ['topics', 'chatHistory'] });
260
+ const settings = await readSettings();
261
+ const name = settings.name ?? null;
262
+ const hostName = getHostName();
263
+ respond(true, { version, clawVersion, capabilities: ['topics', 'chatHistory'], name, hostName });
264
+ }
265
+ catch (err) {
266
+ respondError(respond, err);
267
+ }
268
+ }
269
+
270
+ api.registerGatewayMethod('coclaw.info', handleInfoGet);
271
+ api.registerGatewayMethod('coclaw.info.get', handleInfoGet);
272
+
273
+ api.registerGatewayMethod('coclaw.info.patch', async ({ params, respond }) => {
274
+ try {
275
+ const rawName = params?.name;
276
+ if (rawName === undefined) {
277
+ respondInvalid(respond, 'name field is required');
278
+ return;
279
+ }
280
+ if (rawName !== null && typeof rawName !== 'string') {
281
+ respondInvalid(respond, 'name must be a string or null');
282
+ return;
283
+ }
284
+ const trimmed = typeof rawName === 'string' ? rawName.trim() : '';
285
+ if (trimmed.length > MAX_NAME_LENGTH) {
286
+ respondInvalid(respond, `name exceeds maximum length of ${MAX_NAME_LENGTH} characters`);
287
+ return;
288
+ }
289
+ const nameToSave = trimmed || null;
290
+ await writeName(nameToSave);
291
+ const hostName = getHostName();
292
+ respond(true, { name: nameToSave, hostName });
293
+ // 异步广播变更事件到 server 和其他 UI 实例
294
+ broadcastPluginEvent('coclaw.info.updated', { name: nameToSave, hostName });
301
295
  }
302
296
  catch (err) {
303
297
  respondError(respond, err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.9.2",
3
+ "version": "0.11.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",
@@ -40,20 +41,14 @@
40
41
  "./index.js"
41
42
  ]
42
43
  },
43
- "bin": {
44
- "coclaw": "src/cli.js"
45
- },
46
44
  "scripts": {
47
- "dev": "node src/cli.js --help",
48
45
  "build": "echo 'No build step needed (pure ES modules)'",
49
46
  "lint": "eslint \"**/*.{js,mjs,cjs}\" --no-error-on-unmatched-pattern",
50
47
  "typecheck": "echo \"No typecheck configured yet (coclaw openclaw plugin)\"",
51
48
  "check": "pnpm lint && pnpm typecheck",
52
- "test:standalone": "node --test src/standalone-mode.test.js",
53
49
  "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",
50
+ "test": "c8 --check-coverage --lines 100 --functions 100 --branches 94 --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'",
51
+ "verify": "pnpm check && pnpm test",
57
52
  "link": "bash ./scripts/link.sh",
58
53
  "unlink": "bash ./scripts/unlink.sh",
59
54
  "install:npm": "bash ./scripts/install-npm.sh",
@@ -64,6 +59,7 @@
64
59
  "release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
65
60
  },
66
61
  "dependencies": {
62
+ "node-datachannel": "0.32.1",
67
63
  "werift": "^0.19.0"
68
64
  },
69
65
  "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 {
@@ -15,16 +15,33 @@ export function escapeJsonForCmd(json) {
15
15
  /**
16
16
  * 通过 spawn 调用 `openclaw gateway call <method> --json`
17
17
  *
18
- * 背景:openclaw CLI 在完成 gateway RPC 后,因 WebSocket handle 未清理,
19
- * 进程不会自然退出。execSync 会一直阻塞直到超时,导致误报失败。
18
+ * ## 设计背景
20
19
  *
21
- * 策略:
22
- * 1. spawn 子进程,监听 stdout
23
- * 2. 解析 JSON 输出判断 RPC 是否成功
24
- * 3. 检测到输出后延迟 KILL_DELAY_MS 再 kill(给进程自然退出的机会)
25
- * 4. 无论成功失败,最终都主动 kill 子进程
20
+ * openclaw CLI 完成 gateway RPC 后,因 GatewayClient(WebSocket)handle 未完全销毁,
21
+ * 事件循环仍活跃,进程不会自然退出。早期使用 execSync 会阻塞等待进程退出而非输出完成,
22
+ * 导致 RPC 实际在 ~2s 内成功,但 10s 超时后误报 100% 失败。
26
23
  *
27
- * @param {string} method - gateway method 名(如 coclaw.refreshBridge)
24
+ * ## 策略
25
+ *
26
+ * 1. spawn 子进程执行 `openclaw gateway call <method> --json`
27
+ * 2. 监听 stdout,解析 JSON 输出判断 RPC 成功/失败
28
+ * 3. 检测到完整 JSON 后启动 KILL_DELAY_MS grace period 等待自然退出
29
+ * 4. 总超时默认 NOTIFY_TIMEOUT_MS(注册 CLI 路径覆盖为 30s)
30
+ * 5. 无论成功失败,最终都主动 kill 子进程
31
+ *
32
+ * grace period 设计:openclaw 进程因 WS handle 滞留可能 10s+ 才退出,
33
+ * 延迟 kill 是为兼容未来 OpenClaw 修复 WS 清理后进程能优雅退出的场景。
34
+ *
35
+ * ## stdout 判断策略
36
+ *
37
+ * `openclaw gateway call --json` 直接输出 method 的 result payload(respond 第二参数),
38
+ * 而非 gateway 协议层 { ok, result, error } 包装:
39
+ * - 有 stdout + 可解析 JSON → RPC 成功
40
+ * - 有 stdout + 非 JSON → 也视为成功(兜底)
41
+ * - 无 stdout + 非零退出码 → RPC 失败
42
+ * - 无 stdout + 超时 → RPC 失败
43
+ *
44
+ * @param {string} method - gateway method 名(如 coclaw.bind)
28
45
  * @param {Function} [spawnFn] - 可注入的 spawn 函数(测试用)
29
46
  * @param {object} [opts] - 可选配置(测试用)
30
47
  * @param {number} [opts.timeoutMs] - 总超时毫秒数
@@ -17,16 +17,6 @@ export function notBound() {
17
17
  return 'Not bound. Nothing to unbind.';
18
18
  }
19
19
 
20
- export function gatewayNotified(action) {
21
- return action === 'refresh'
22
- ? 'Bridge connection refreshed.'
23
- : 'Bridge connection stopped.';
24
- }
25
-
26
- export function gatewayNotifyFailed() {
27
- return 'Note: could not notify the running gateway. If it is running, restart it manually.';
28
- }
29
-
30
20
  export function claimCodeCreated({ code, appUrl, expiresMinutes }) {
31
21
  return [
32
22
  `Claim code: ${code}`,
package/src/config.js CHANGED
@@ -7,10 +7,10 @@ import { atomicWriteJsonFile } from './utils/atomic-write.js';
7
7
  import { createMutex } from './utils/mutex.js';
8
8
 
9
9
  export const DEFAULT_ACCOUNT_ID = 'default';
10
- const CHANNEL_ID = 'coclaw';
10
+ export const CHANNEL_ID = 'coclaw';
11
11
  const BINDINGS_FILENAME = 'bindings.json';
12
12
 
13
- function resolveStateDir() {
13
+ export function resolveStateDir() {
14
14
  const rt = getRuntime();
15
15
  if (rt?.state?.resolveStateDir) {
16
16
  return rt.state.resolveStateDir();
@@ -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,18 @@
1
+ import fs from 'node:fs/promises';
2
+ import nodePath from 'node:path';
3
+
4
+ // 延迟读取 + 缓存:避免模块加载时 package.json 损坏导致插件整体无法注册
5
+ let __pluginVersion = null;
6
+ export async function getPluginVersion() {
7
+ if (__pluginVersion) return __pluginVersion;
8
+ try {
9
+ const pkgPath = nodePath.resolve(import.meta.dirname, '..', 'package.json');
10
+ const raw = await fs.readFile(pkgPath, 'utf8');
11
+ __pluginVersion = JSON.parse(raw).version ?? 'unknown';
12
+ } catch {
13
+ return 'unknown';
14
+ }
15
+ return __pluginVersion;
16
+ }
17
+ // 测试用:重置缓存
18
+ export function __resetPluginVersion() { __pluginVersion = null; }