@coclaw/openclaw-coclaw 0.21.2 → 0.21.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/index.js CHANGED
@@ -5,6 +5,7 @@ import { notBound, bindOk, unbindOk, claimCodeCreated } from './src/common/messa
5
5
  import { coclawChannelPlugin } from './src/channel-plugin.js';
6
6
  import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge, waitForSessionsReady, broadcastPluginEvent } from './src/realtime-bridge.js';
7
7
  import { getHostName, readSettings, writeName, MAX_NAME_LENGTH } from './src/settings.js';
8
+ import { readConfig } from './src/config.js';
8
9
  import { setRuntime } from './src/runtime.js';
9
10
  import { createSessionManager } from './src/session-manager/manager.js';
10
11
  import { TopicManager } from './src/topic-manager/manager.js';
@@ -157,6 +158,13 @@ const plugin = {
157
158
  setRuntime(api.runtime);
158
159
  const logger = api?.logger ?? console;
159
160
  installAbortRegistryDiag(logger);
161
+
162
+ // 未 bind 时打条提示,便于 hub 装机用户看到下一步动作
163
+ readConfig().then((cfg) => {
164
+ if (!cfg?.token) {
165
+ logger.info?.('[coclaw] not bound — run `openclaw coclaw enroll` to connect to CoClaw');
166
+ }
167
+ }).catch(() => {});
160
168
  const manager = createSessionManager({ logger });
161
169
  const topicManager = new TopicManager({ logger });
162
170
  const chatHistoryManager = new ChatHistoryManager({ logger });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-coclaw",
3
3
  "name": "CoClaw",
4
- "description": "OpenClaw CoClaw channel plugin for remote chat",
4
+ "description": "OpenClaw CoClaw channel plugin for remote chat. Run `openclaw coclaw enroll` after install to connect to CoClaw.",
5
5
  "activation": {
6
6
  "onStartup": true
7
7
  },
@@ -11,9 +11,6 @@
11
11
  "properties": {
12
12
  "serverUrl": {
13
13
  "type": "string"
14
- },
15
- "gatewayWsUrl": {
16
- "type": "string"
17
14
  }
18
15
  }
19
16
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
- "description": "OpenClaw CoClaw channel plugin for remote chat",
6
+ "description": "OpenClaw CoClaw channel plugin for remote chat. Run `openclaw coclaw enroll` after install to connect to CoClaw.",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/coclaw/coclaw.git",
@@ -25,8 +25,17 @@ const SERVER_HB_PING_MS = 25_000;
25
25
  const SERVER_HB_TIMEOUT_MS = 45_000;
26
26
  const SERVER_HB_MAX_MISS = 3; // 连续 3 次无响应才断连(~135s)。上游主线程 spike 实测最坏 ~89.5s(issue #75069),余量 ~1.5x
27
27
  // gateway 握手失败的指数退避表:每个元素是"上一次失败"之后、"下一次尝试"之前的等待时间。
28
- // 最多 5 次重试(加上首次尝试共 6 次),全部失败后进入 gave-up 终态,不再自动尝试。
29
- const GATEWAY_RETRY_DELAYS_MS = [5_000, 10_000, 20_000, 20_000, 20_000];
28
+ // 最多 N 次重试(N=length;加上首次尝试共 N+1 次),全部失败后进入 gave-up 终态,
29
+ // 终态后必须 stop+start 才能恢复(无运行期自动复活)——所以预算要够覆盖慢启动场景。
30
+ // 表前置 4 档(1s + 3×1.5s)专门压低"server 启动期 sidecars 还没就绪"窗口(推荐
31
+ // retryAfterMs=500);尾段 9 × 20s 把总预算拉到 ~200s(>3 分钟),覆盖 profile 初始化、
32
+ // 笔记本刚开机磁盘忙、pion 子进程首次 spawn 等慢启动场景,避免误判"放弃"。
33
+ // export 是为了让测试 helper 通过同一份常量识别 retry timer,避免硬编码副本与生产代码漂移。
34
+ export const GATEWAY_RETRY_DELAYS_MS = [
35
+ 1_000, 1_500, 1_500, 1_500,
36
+ 5_000, 10_000,
37
+ 20_000, 20_000, 20_000, 20_000, 20_000, 20_000, 20_000, 20_000, 20_000,
38
+ ];
30
39
  // v3 握手失败时,只有错误消息匹配此正则才回退到不带 device 的 legacy 握手。
31
40
  // 严格限定在"签名/设备/scope/协议"相关错误,避免对网络/内部错误做无意义的降级尝试。
32
41
  const GATEWAY_HANDSHAKE_FALLBACK_PATTERN = /signature|device|scope|protocol/i;
@@ -90,20 +99,26 @@ function maskUrlToken(url) {
90
99
  }
91
100
 
92
101
  // 仅在未注入 resolveGatewayAuthToken 时使用,依赖 runtime / 环境变量
102
+ // 优先级:config.gateway.auth.token > env OPENCLAW_GATEWAY_TOKEN
103
+ // 与上游 server 的 auth-surface-resolution 同方向:config 主、env 兜底。
104
+ // env 路径保留是为 ensureGatewayStartupAuth 的 env-only 边角(token 由 env 提供时
105
+ // 上游不会写回 cfg),cfg 读不到时仍能拿到正确 token。
93
106
  export function defaultResolveGatewayAuthToken() {
94
- const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
95
- if (envToken) {
96
- return envToken;
97
- }
98
107
  try {
99
108
  const cfg = getClawConfig();
100
109
  const token = cfg?.gateway?.auth?.token;
101
- return typeof token === 'string' && token.trim() ? token.trim() : '';
110
+ if (typeof token === 'string' && token.trim()) {
111
+ return token.trim();
112
+ }
102
113
  }
103
114
  catch (err) {
104
115
  console.warn?.(`[coclaw] resolve gateway auth token failed: ${String(err?.message ?? err)}`);
105
- return '';
106
116
  }
117
+ const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
118
+ if (envToken) {
119
+ return envToken;
120
+ }
121
+ return '';
107
122
  }
108
123
 
109
124
  /**
@@ -247,9 +262,7 @@ export class RealtimeBridge {
247
262
  }
248
263
 
249
264
  __resolveGatewayWsUrl() {
250
- return this.pluginConfig?.gatewayWsUrl
251
- ?? process.env.COCLAW_GATEWAY_WS_URL
252
- ?? DEFAULT_GATEWAY_WS_URL;
265
+ return process.env.COCLAW_GATEWAY_WS_URL ?? DEFAULT_GATEWAY_WS_URL;
253
266
  }
254
267
 
255
268
  async __clearTokenLocal(unboundClawId) {
@@ -7,6 +7,7 @@ import { RpcDcSender, DC_LOW_WATER_MARK, MAX_SINGLE_MSG_BYTES } from './rpc-dc-s
7
7
  import { createRpcDropMonitor } from './rpc-drop-monitor.js';
8
8
  import { isAgentRunResponse } from './agent-run-response.js';
9
9
  import { remoteLog } from '../remote-log.js';
10
+ import { createMutex } from '../utils/mutex.js';
10
11
 
11
12
  // rpc DC 发送队列实现选择(B-stage2 B9b)。
12
13
  // - 'fbq':FileBackedQueue(当前生产默认)—— 长时间后台 / ICE 恢复等慢消化场景溢出到磁盘
@@ -71,6 +72,11 @@ export class WebRtcPeer {
71
72
  this.__rtcTag = impl ? `[coclaw/rtc:${impl}]` : '[coclaw/rtc]';
72
73
  /** @type {Map<string, { pc: object, rpcChannel: object|null, rpcQueue: MemoryQueue|null, rpcDcSender: RpcDcSender|null, rpcConsumeLoop: Promise<void>|null, rpcDropMonitor: object|null, fileChannels: Set, remoteMaxMessageSize: number, nextMsgId: number }>} */
73
74
  this.__sessions = new Map();
75
+ // per-connId offer mutex:串行化同 connId 的 __handleOffer 调用,防止两条 ICE restart
76
+ // offer 几乎同时到达时三连 await 中间被并发 offer 重入触发 pion 状态机
77
+ // InvalidModificationError。lazy 创建,closeByConnId 末尾 delete 释放。
78
+ /** @type {Map<string, ReturnType<typeof createMutex>>} */
79
+ this.__offerMutexes = new Map();
74
80
  }
75
81
 
76
82
  /** 处理来自 Server 转发的信令消息 */
@@ -142,6 +148,15 @@ export class WebRtcPeer {
142
148
  session.rpcChannel = null;
143
149
  }
144
150
  await session.pc.close();
151
+ // 清理 per-connId offer mutex 键:mutex 生命期与 session 生命期一一对应,
152
+ // session 销毁时 mutex 同步销毁(详见 __handleOffer 注释)。
153
+ // 注意:__handleOffer 内部 mutex 引用是闭包已在 mu.withLock 内拿到;
154
+ // 此处 delete 只影响 Map 键,不影响排队中的 fn 执行;后续同 connId 新 offer
155
+ // 走 lazy create 路径建新 mutex(旧 mutex 队列若仍有 fn 也能跑完)。
156
+ // 已知边界:当 closeByConnId 是从 __handleOfferLocked 内部触发时(restart-failed catch
157
+ // 或首次 offer 替换旧 session),fn 仍持有 mutex;此期间新 offer 进来会创建 M2
158
+ // 与 M1 并发——split race 在 TODO.md 追踪,不在本修法范围闭合。
159
+ this.__offerMutexes.delete(connId);
145
160
  this.__remoteLog(`rtc.closed conn=${connId}`);
146
161
  this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
147
162
  }
@@ -212,6 +227,46 @@ export class WebRtcPeer {
212
227
  async __handleOffer(msg) {
213
228
  const connId = msg.fromConnId;
214
229
  const isIceRestart = !!msg.payload?.iceRestart;
230
+
231
+ // no-session ICE restart 不需要串行化:直接 reject,不进 mutex。
232
+ // 设计意图:mutex 生命期严格对齐 session(仅在有 session 时存在),
233
+ // 短命路径不为它建/拆 mutex。同时该路径本身就是"无 session 立即 reject"的语义,
234
+ // 没有任何可序列化的状态。
235
+ if (isIceRestart && !this.__sessions.has(connId)) {
236
+ const credRemain = this.__credRemainSec(msg.turnCreds);
237
+ const credRemainStr = credRemain ?? 'none';
238
+ this.__remoteLog(`rtc.ice-restart-no-session conn=${connId} credRemain=${credRemainStr}`);
239
+ this.logger.warn?.(`${this.__rtcTag} ICE restart from ${connId} but no session, rejecting`);
240
+ this.__onSend({
241
+ type: 'rtc:restart-rejected',
242
+ toConnId: connId,
243
+ payload: { reason: 'no_session' },
244
+ });
245
+ return;
246
+ }
247
+
248
+ // per-connId mutex 串行化:同 connId 几乎同时到达多条 offer(典型场景:UI 多入口
249
+ // nudgeRestart 在数百毫秒内连发两条 ICE restart)时,把整段三连 await + sendSignaling
250
+ // 串成 N 轮,最后一条胜出(plugin PC 凭据 = 最后一条 offer 凭据 = UI 当前凭据)。
251
+ // 不串行化时三连 await 中间被并发 offer 重入会触发 pion InvalidModificationError。
252
+ // 包**整个** __handleOfferLocked:既覆盖 ICE restart 路径(复用现有 PC)也覆盖首次
253
+ // offer 路径(创建新 PC + 触发 closeByConnId 清场),后者若不在锁内会与紧随的 ICE
254
+ // restart 踩。
255
+ // mutex 生命期 = session 生命期:与 session 一一成对管理。删除点在两处(且仅两处):
256
+ // 1) closeByConnId(覆盖所有正常 close 路径:rtc:closed / state-change / TTL / closeAll)
257
+ // 2) __handleOfferLocked 首次 offer 的 catch(catch 手工删 session 时同步删 mutex)
258
+ // 任何新增 session 删除点必须配套删除 mutex 键,否则会泄漏。
259
+ let mu = this.__offerMutexes.get(connId);
260
+ if (!mu) {
261
+ mu = createMutex();
262
+ this.__offerMutexes.set(connId, mu);
263
+ }
264
+ return mu.withLock(() => this.__handleOfferLocked(msg));
265
+ }
266
+
267
+ async __handleOfferLocked(msg) {
268
+ const connId = msg.fromConnId;
269
+ const isIceRestart = !!msg.payload?.iceRestart;
215
270
  const credRemain = this.__credRemainSec(msg.turnCreds);
216
271
  const credRemainStr = credRemain ?? 'none';
217
272
 
@@ -239,6 +294,14 @@ export class WebRtcPeer {
239
294
  this.logger.info?.(`${this.__rtcTag} ICE restart offer from ${connId}, renegotiating`);
240
295
  try {
241
296
  await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
297
+ // 身份重核:close-during-lock 路径(rtc:closed / connectionState=closed|failed-TTL /
298
+ // closeAll)不走 offer mutex,可能在三连 await 中间删掉 session。命中即丢弃后续动作,
299
+ // 不发 stale rtc:answer。logger.info 仅本地诊断,不上 remoteLog(closeByConnId 触发方
300
+ // 自带 rtc.closed / rtc.state 等远程日志,server 端能从那侧还原"PC 哪一刻死了")。
301
+ if (this.__sessions.get(connId) !== existing) {
302
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart aborted: session changed after setRemoteDescription`);
303
+ return;
304
+ }
242
305
  // 重协商 SDP 可能变更 a=max-message-size,同步刷新 sender 分片阈值;
243
306
  // queue 存的是完整字符串(buildChunks 在 sender.send 内同步完成),
244
307
  // 已开始分片的当前消息用旧 size,下一条消息用新 size
@@ -248,7 +311,15 @@ export class WebRtcPeer {
248
311
  if (existing.rpcDcSender) existing.rpcDcSender.maxMessageSize = newMMS;
249
312
  }
250
313
  const answer = await existing.pc.createAnswer();
314
+ if (this.__sessions.get(connId) !== existing) {
315
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart aborted: session changed after createAnswer`);
316
+ return;
317
+ }
251
318
  await existing.pc.setLocalDescription(answer);
319
+ if (this.__sessions.get(connId) !== existing) {
320
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart aborted: session changed after setLocalDescription`);
321
+ return;
322
+ }
252
323
  this.__onSend({
253
324
  type: 'rtc:answer',
254
325
  toConnId: connId,
@@ -258,6 +329,13 @@ export class WebRtcPeer {
258
329
  this.logger.info?.(`${this.__rtcTag} ICE restart answer sent to ${connId}`);
259
330
  return;
260
331
  } catch (err) {
332
+ // 身份重核:session 已被中途关掉时 catch 收到的 err 是"PC 已 close"残响,
333
+ // 不应再发 stale restart-rejected(用户会看到误报失败),也不重复调 closeByConnId
334
+ // (session 已删,再调是 no-op 但徒增 rtc.closed 日志噪声)。
335
+ if (this.__sessions.get(connId) !== existing) {
336
+ this.logger.info?.(`${this.__rtcTag} [${connId}] ICE restart error after session change (suppressed): ${err?.message}`);
337
+ return;
338
+ }
261
339
  // ICE restart 协商失败 → reject,不 fall through
262
340
  this.__remoteLog(`rtc.ice-restart-failed conn=${connId} credRemain=${credRemainStr}`);
263
341
  this.logger.warn?.(`${this.__rtcTag} ICE restart failed for ${connId}: ${err?.message}`);
@@ -546,7 +624,7 @@ export class WebRtcPeer {
546
624
  this.__remoteLog(`rtc.answer conn=${connId}`);
547
625
  this.logger.info?.(`${this.__rtcTag} answer sent to ${connId}`);
548
626
  } catch (err) {
549
- // SDP 协商失败 → 清理已入 Map 的 session,避免泄漏
627
+ // SDP 协商失败 → 清理已入 Map 的 session + mutex,避免泄漏
550
628
  const cur = this.__sessions.get(connId);
551
629
  if (cur && cur.pc === pc) {
552
630
  if (cur.__failedTimer) {
@@ -555,6 +633,10 @@ export class WebRtcPeer {
555
633
  }
556
634
  this.__sessions.delete(connId);
557
635
  }
636
+ // 同步清 mutex 键,保持 mutex 与 session 生命期一致。本 fn 即将 throw 退出,
637
+ // fn 仍持有的 mutex 实例引用会自然 GC;删 map 键防后续 offer 误用残留键
638
+ // (split race 边界仍在 TODO 中追踪,不在此修法范围)。
639
+ this.__offerMutexes.delete(connId);
558
640
  await pc.close().catch(() => {});
559
641
  throw err;
560
642
  }