@coclaw/openclaw-coclaw 0.17.8 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.17.8",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -35,6 +35,13 @@ const LAG_PROBE_PERIOD_MS = 200;
35
35
  const LAG_PROBE_THRESHOLD_MS = 100;
36
36
  const LAG_PROBE_MAX_DURATION_MS = 60_000;
37
37
 
38
+ // UI 转发 RPC 路由表条目的最大存活时间(24h)。
39
+ // 选 24h 的理由:agent run 极端可达数小时甚至更久;正常 RPC 在终态触达前已自然清除;
40
+ // 24h 足够覆盖几乎所有真实场景,且条目内存压力可忽略(百量级 × 几十字节)。
41
+ const DC_REQ_TTL_MS = 24 * 60 * 60 * 1000;
42
+ // 整表周期扫描间隔(1h)。条目存留误差 0~1h,对内存压力毫无影响。
43
+ const DC_REQ_SCAN_MS = 60 * 60 * 1000;
44
+
38
45
  /**
39
46
  * 判断一个出方向 res payload 是否表示 agent RPC 进入 phase-2 终态。
40
47
  * 终态 = res 帧 + status !== 'accepted'。覆盖三种情形:
@@ -52,6 +59,19 @@ export function classifyAgentLagStop(payload) {
52
59
  return status ?? (payload.ok === false ? 'error' : 'ok');
53
60
  }
54
61
 
62
+ /**
63
+ * 判断一个 res 帧是否为终态(不会再有后续同 id 帧跟随)。
64
+ * 与 OpenClaw 上游 gateway/client.ts 的 `expectFinal && status === "accepted"` 判据严格镜像:
65
+ * 仅当 payload.status==='accepted' 时为中间态,其他一切(含无 status 字段)均为终态。
66
+ * 见 docs/designs/dc-rpc-response-unicast.md §2.8。
67
+ *
68
+ * @param {object} frame - 待判断的 gateway res 帧
69
+ * @returns {boolean}
70
+ */
71
+ export function isFinalResMsg(frame) {
72
+ return frame?.type === 'res' && frame?.payload?.status !== 'accepted';
73
+ }
74
+
55
75
  function toServerWsUrl(baseUrl, token) {
56
76
  const url = new URL(baseUrl);
57
77
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -108,6 +128,8 @@ export class RealtimeBridge {
108
128
  * @param {Function} [deps.resolveGatewayAuthToken] - 获取 gateway 认证 token
109
129
  * @param {Function} [deps.loadDeviceIdentity] - 加载设备身份
110
130
  * @param {number} [deps.gatewayReadyTimeoutMs] - __waitGatewayReady 默认超时(测试可注入短值)
131
+ * @param {number} [deps.dcReqTtlMs] - UI 转发 RPC 路由表条目 TTL(测试可注入短值)
132
+ * @param {number} [deps.dcReqScanMs] - UI 转发 RPC 路由表周期扫描间隔(测试可注入短值)
111
133
  */
112
134
  constructor(deps = {}) {
113
135
  this.__readConfig = deps.readConfig ?? readConfig;
@@ -119,6 +141,8 @@ export class RealtimeBridge {
119
141
  this.__preloadNdc = deps.preloadNdc ?? null;
120
142
  this.__WebSocket = deps.WebSocket; // undefined=使用 ws 包, null=禁用(测试用), 其他=自定义实现
121
143
  this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
144
+ this.__dcReqTtlMs = deps.dcReqTtlMs ?? DC_REQ_TTL_MS;
145
+ this.__dcReqScanMs = deps.dcReqScanMs ?? DC_REQ_SCAN_MS;
122
146
 
123
147
  this.serverWs = null;
124
148
  this.gatewayWs = null;
@@ -149,6 +173,10 @@ export class RealtimeBridge {
149
173
  this.__gatewayLastReason = null; // 最近一次失败原因(用于 gave-up 上报)
150
174
  // agent RPC 进 in-flight 时建探针、phase-2 终态时移除:id -> { interval, timeout, stats }
151
175
  this.__agentLagProbes = new Map();
176
+ // UI 转发 RPC 路由表:reqId -> { connId, expireAt }
177
+ // 用于 res 帧按发起方单播;查不到时回退广播兜底(兼容旧 UI / 撞号 / 上游新增中间态字符串等)
178
+ this.__dcPendingRequests = new Map();
179
+ this.__dcPendingScanTimer = null;
152
180
  }
153
181
 
154
182
  __resolveWebSocket() {
@@ -253,6 +281,8 @@ export class RealtimeBridge {
253
281
  settle({ ok: false, error: 'gateway_closed' });
254
282
  }
255
283
  this.gatewayPendingRequests.clear();
284
+ // 清空 UI 转发 RPC 路由表:gateway 已断,不会再有响应回来;不主动通知 UI,由 UI 30/60s 超时兜底
285
+ this.__dcPendingRequests.clear();
256
286
  }
257
287
 
258
288
  /** 懒加载 WebRtcPeer(promise 锁防并发重复创建) */
@@ -273,8 +303,8 @@ export class RealtimeBridge {
273
303
  this.__fileHandler.scheduleTmpCleanup(() => this.__listAgentWorkspaces());
274
304
  this.webrtcPeer = new WebRtcPeer({
275
305
  onSend: (msg) => this.__forwardToServer(msg),
276
- onRequest: (dcPayload) => {
277
- this.__handleGatewayRequestFromDc(dcPayload)
306
+ onRequest: (dcPayload, connId) => {
307
+ this.__handleGatewayRequestFromDc(dcPayload, connId)
278
308
  .catch((err) => this.logger.warn?.(`[coclaw] dc request handler error: ${err?.message}`));
279
309
  },
280
310
  onFileRpc: (payload, sendFn) => {
@@ -778,7 +808,7 @@ export class RealtimeBridge {
778
808
  return;
779
809
  }
780
810
  if (payload.type === 'res' || payload.type === 'event') {
781
- // 过滤 gateway 的管理层广播事件,这些对 WebChat / plugin 客户端无意义:
811
+ // (a) 过滤 gateway 的管理层广播事件,这些对 WebChat / plugin 客户端无意义:
782
812
  // - health: 全量状态快照(~3KB, ~60s 一次 + RPC 触发),给 Admin UI 的监控仪表盘用
783
813
  // - tick: gateway WS 保活心跳(30s 一次),UI 隔着 DC 不需要,DC 自己有 probe 机制
784
814
  // 不转发可避免后台时 rpc DC 队列被灌满。上游支持按需订阅前先在插件侧拦截。
@@ -786,11 +816,31 @@ export class RealtimeBridge {
786
816
  && (payload.event === 'health' || payload.event === 'tick')) {
787
817
  return;
788
818
  }
789
- // agent RPC 进入 phase-2 终态时停 lag 探针(详见 classifyAgentLagStop)
819
+ // (b) agent RPC 进入 phase-2 终态时停 lag 探针(必须放在 (c) 单播分支之前,
820
+ // 避免命中后探针不停导致 60s 兜底 + 噪声日志)
790
821
  const lagReason = classifyAgentLagStop(payload);
791
822
  if (lagReason !== null) {
792
823
  this.__stopLagProbe(payload.id, lagReason);
793
824
  }
825
+ // (c) UI 转发 RPC 的 res 单播:按 reqId 查路由表,命中则定向 sendTo
826
+ if (payload.type === 'res' && typeof payload.id === 'string') {
827
+ const info = this.__dcPendingRequests.get(payload.id);
828
+ if (info) {
829
+ // 终态才清条目;accepted 类中间态保留等下一帧
830
+ if (isFinalResMsg(payload)) {
831
+ this.__dcPendingRequests.delete(payload.id);
832
+ }
833
+ const delivered = this.webrtcPeer?.sendTo(info.connId, payload);
834
+ if (!delivered) {
835
+ // PC 已断 / DC 未 open / 队列拒收:本地 log 丢弃,不退回广播
836
+ this.__logDebug(
837
+ `dc res undeliverable: id=${payload.id} connId=${info.connId}`
838
+ );
839
+ }
840
+ return;
841
+ }
842
+ }
843
+ // (d) 兜底广播:覆盖 event 类型 / 映射未命中场景
794
844
  this.webrtcPeer?.broadcast(payload);
795
845
  }
796
846
  });
@@ -818,6 +868,8 @@ export class RealtimeBridge {
818
868
  settle({ ok: false, error: 'gateway_closed' });
819
869
  }
820
870
  this.gatewayPendingRequests.clear();
871
+ // 同步清空 UI 转发 RPC 路由表(同 __closeGatewayWs 语义)
872
+ this.__dcPendingRequests.clear();
821
873
  // 调度下一次尝试:仅在 bridge 仍活着、未 gave-up、server WS 健康时;
822
874
  // 其他场景(如 bridge stop、server WS 已断)由上游流程兜底,不参与 gateway 重试。
823
875
  if (this.started && !this.__gatewayGaveUp
@@ -890,9 +942,10 @@ export class RealtimeBridge {
890
942
  });
891
943
  }
892
944
 
893
- async __handleGatewayRequestFromDc(payload) {
945
+ async __handleGatewayRequestFromDc(payload, connId) {
894
946
  const ready = await this.__waitGatewayReady();
895
947
  if (!ready || !this.gatewayWs || this.gatewayWs.readyState !== 1) {
948
+ // OFFLINE 路径在写映射前触发,无脏映射;保留广播语义(属系统状态公告)
896
949
  this.__logDebug(`gateway req drop (offline): id=${payload.id} method=${payload.method}`);
897
950
  this.webrtcPeer?.broadcast({
898
951
  type: 'res',
@@ -905,23 +958,41 @@ export class RealtimeBridge {
905
958
  });
906
959
  return;
907
960
  }
961
+ // 撞号检测:UUID 全唯一时极小概率,但旧 UI 跨 tab 或 UI bug 可能触发。
962
+ // 删旧条目让旧响应未来走广播兜底,不主动回错给旧发起方(可能已断)
963
+ const id = payload.id;
964
+ if (typeof id === 'string' && this.__dcPendingRequests.has(id)) {
965
+ this.logger.warn?.(`[coclaw] duplicate dc reqId, dropping previous mapping: id=${id}`);
966
+ this.__dcPendingRequests.delete(id);
967
+ }
968
+ // 写映射:必须在 ready 通过后、send 之前;缺 connId 时退化为旧广播行为
969
+ if (typeof id === 'string' && connId) {
970
+ this.__dcPendingRequests.set(id, {
971
+ connId,
972
+ expireAt: Date.now() + this.__dcReqTtlMs,
973
+ });
974
+ }
908
975
  try {
909
- this.__logDebug(`gateway req -> id=${payload.id} method=${payload.method}`);
976
+ this.__logDebug(`gateway req -> id=${id} method=${payload.method}`);
910
977
  this.gatewayWs.send(JSON.stringify({
911
978
  type: 'req',
912
- id: payload.id,
979
+ id,
913
980
  method: payload.method,
914
981
  params: payload.params ?? {},
915
982
  }));
916
983
  // 仅 agent RPC 启动 lag 探针(覆盖发送 → phase-2 终态全程)。
917
984
  if (payload.method === 'agent') {
918
- this.__startLagProbe(payload.id);
985
+ this.__startLagProbe(id);
919
986
  }
920
987
  }
921
988
  catch {
989
+ // SEND_FAILED:撤回映射后广播错误响应
990
+ if (typeof id === 'string') {
991
+ this.__dcPendingRequests.delete(id);
992
+ }
922
993
  this.webrtcPeer?.broadcast({
923
994
  type: 'res',
924
- id: payload.id,
995
+ id,
925
996
  ok: false,
926
997
  error: {
927
998
  code: 'GATEWAY_SEND_FAILED',
@@ -1231,6 +1302,29 @@ export class RealtimeBridge {
1231
1302
  this.logger.info?.(`[coclaw] WebRTC impl: ${implLabel}`);
1232
1303
  this.logger.info?.(`[coclaw] ${this.__buildEnvLine()}`);
1233
1304
  remoteLog('bridge.started');
1305
+ // 启动 UI 转发 RPC 路由表周期扫描:1h 间隔扫描 24h 过期条目,避免长程 RPC 残留。
1306
+ // try/catch 兜底:插件运行在 gateway 进程内,timer 回调任何同步抛出都会让进程崩溃
1307
+ // (CLAUDE.md 禁止全局异常兜底),与 __startLagProbe 的实现保持一致。
1308
+ this.__dcPendingScanTimer = setInterval(() => {
1309
+ try {
1310
+ const now = Date.now();
1311
+ let cleaned = 0;
1312
+ for (const [id, info] of this.__dcPendingRequests) {
1313
+ if (info.expireAt <= now) {
1314
+ this.__dcPendingRequests.delete(id);
1315
+ cleaned++;
1316
+ }
1317
+ }
1318
+ if (cleaned > 0) {
1319
+ this.logger.warn?.(`[coclaw] dc pending entries expired: count=${cleaned}`);
1320
+ }
1321
+ }
1322
+ /* c8 ignore next 3 -- 防御性兜底,正常路径下 Map ops + logger.warn 不抛 */
1323
+ catch {
1324
+ // 扫描器自身异常静默吞掉,避免拖垮 gateway。
1325
+ }
1326
+ }, this.__dcReqScanMs);
1327
+ this.__dcPendingScanTimer.unref?.();
1234
1328
  await this.__connectIfNeeded();
1235
1329
  }
1236
1330
 
@@ -1276,6 +1370,11 @@ export class RealtimeBridge {
1276
1370
  this.__gatewayGaveUp = false;
1277
1371
  this.__gatewayLegacyMode = false;
1278
1372
  this.__gatewayLastReason = null;
1373
+ // 停止 UI 转发 RPC 路由表的周期扫描
1374
+ if (this.__dcPendingScanTimer) {
1375
+ clearInterval(this.__dcPendingScanTimer);
1376
+ this.__dcPendingScanTimer = null;
1377
+ }
1279
1378
  this.__closeGatewayWs();
1280
1379
  if (this.webrtcPeer) {
1281
1380
  await this.webrtcPeer.closeAll().catch(() => {});
@@ -49,13 +49,14 @@ export async function preloadPion(deps = {}) {
49
49
  // 启动 IPC 进程(内部会 ping 验证就绪,binary 由 pion-node 自动解析)
50
50
  // logger 回调双打:始终走 remoteLog;同时送本地 logger,严重事件(IPC 超时、orphan 响应)
51
51
  // 升级到 error 级别,便于本地调试时一眼可见;其他运维类消息走 info。
52
+ // pion-node SDK 已在 msg 中加 [pion-ipc] 前缀,此处不再重复
52
53
  ipc = new PionIpc({
53
54
  logger: (msg) => {
54
55
  log(`pion.ipc ${msg}`);
55
56
  if (SEVERE_LOG_PATTERN.test(msg)) {
56
- localLogger?.error?.(`[pion-ipc] ${msg}`);
57
+ localLogger?.error?.(msg);
57
58
  } else {
58
- localLogger?.info?.(`[pion-ipc] ${msg}`);
59
+ localLogger?.info?.(msg);
59
60
  }
60
61
  },
61
62
  timeout: ipcRequestTimeout,
@@ -10,6 +10,15 @@
10
10
  * - close():DC 关闭时调用,清空队列并汇总 drop 统计
11
11
  *
12
12
  * 不做:Promise 送达保证;单条消息硬上限内的背压;自动重试。
13
+ *
14
+ * 契约(重要,修改 send/__drain/onBufferedAmountLow 时务必维持):
15
+ * send/__drain/onBufferedAmountLow 必须是「总函数」——任何分支都不得抛异常给调用方。
16
+ * 这是上游(webrtc-peer 的 broadcast/sendTo/files sendFn)能去掉 try/catch 的前提。
17
+ * 队列内部所有外部调用(buildChunks、dc.send、logger.*、remoteLog 等)都必须就地保护,
18
+ * 失败转化为 dropped 计数 / 静默吞掉,返回 false。日志输出统一走 __safeWarn / __safeInfo /
19
+ * __safeRemoteLog,避免外部传入的 logger 实现自身抛异常时破坏契约。
20
+ * 入参防御:jsonStr 必须是 string;非 string 直接 drop,避免 Buffer.byteLength 抛 TypeError。
21
+ * 仅构造器允许抛(参数校验,初始化期),运行期入口都不允许。
13
22
  */
14
23
 
15
24
  import { buildChunks } from './dc-chunking.js';
@@ -64,33 +73,60 @@ export class RpcSendQueue {
64
73
  send(jsonStr) {
65
74
  if (this.closed || this.dc.readyState !== 'open') return false;
66
75
 
76
+ // 入参防御:契约要求 jsonStr 是 string;非 string 直接 drop(避免 Buffer.byteLength 抛 TypeError)
77
+ if (typeof jsonStr !== 'string') {
78
+ this.droppedCount += 1;
79
+ this.__safeWarn(`drop reason=non-string-input type=${typeof jsonStr}`);
80
+ return false;
81
+ }
82
+
67
83
  // 诊断日志:打印每次入队的事件,跟踪 gateway 还会推哪些事件
68
84
  // 需要时临时打开,平时保持注释避免日志噪音
69
- // this.logger.info?.(`[rpc-queue${this.__tagSuffix()}] send-payload ${jsonStr}`);
85
+ // this.__safeInfo(`send-payload ${jsonStr}`);
86
+
87
+ // payload 字节:UTF-8 实际字节数,与对端 reassembly 上限同口径
88
+ const payloadBytes = Buffer.byteLength(jsonStr, 'utf8');
89
+
90
+ // 分片:异常需在 send 内吃掉,避免抛回 gateway 主循环(plugin 硬约束)
91
+ let chunks;
92
+ try {
93
+ chunks = buildChunks(jsonStr, this.maxMessageSize, this.getNextMsgId);
94
+ } catch (err) {
95
+ this.droppedCount += 1;
96
+ this.droppedBytes += payloadBytes;
97
+ const errMsg = err?.message ?? String(err);
98
+ this.__safeWarn(`drop reason=build-chunks-failed size=${payloadBytes} maxMessageSize=${this.maxMessageSize} err=${errMsg}`);
99
+ this.__safeRemoteLog(`rpc-queue.build-chunks-failed${this.__tagSuffix()} size=${payloadBytes} maxMessageSize=${this.maxMessageSize} err=${errMsg}`);
100
+ return false;
101
+ }
70
102
 
71
- const chunks = buildChunks(jsonStr, this.maxMessageSize, this.getNextMsgId);
72
- const totalBytes = chunks
103
+ // 帧字节:含 5 字节 header 的实际网络字节,用于队列核算
104
+ const frameBytes = chunks
73
105
  ? chunks.reduce((n, c) => n + c.length, 0)
74
- : Buffer.byteLength(jsonStr, 'utf8');
106
+ : payloadBytes;
75
107
 
76
- // 硬上限:单条超限
77
- if (totalBytes > MAX_SINGLE_MSG_BYTES) {
108
+ // 硬上限:单条超限——按 payload 字节判断,对齐对端 reassembly payload 上限
109
+ // (帧字节因 header 累计可能在 payload 恰好不超时被误判 drop)
110
+ if (payloadBytes > MAX_SINGLE_MSG_BYTES) {
78
111
  this.droppedCount += 1;
79
- this.droppedBytes += totalBytes;
80
- this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] drop reason=single-msg-oversize size=${totalBytes} cap=${MAX_SINGLE_MSG_BYTES}`);
112
+ this.droppedBytes += frameBytes;
113
+ this.__safeWarn(`drop reason=single-msg-oversize size=${payloadBytes} cap=${MAX_SINGLE_MSG_BYTES}`);
81
114
  return false;
82
115
  }
83
116
 
84
117
  // 软上限:队列已积压到 MAX(允许之前单条溢出,但新消息从此开始拒绝)
85
- if (this.queueBytes >= MAX_QUEUE_BYTES) {
118
+ // 白名单豁免:agent run RPC 响应(顶层 type=res + payload.runId 顶层存在)
119
+ // 即使队列已满也强行入队,避免 UI 端因 phase-2 res 被 drop 而无法收到 run 终态。
120
+ // 仍受 50MB 单条硬上限约束(接收端重组上限,超过也无意义)。
121
+ if (this.queueBytes >= MAX_QUEUE_BYTES && !isAgentRunResponse(jsonStr)) {
86
122
  this.droppedCount += 1;
87
- this.droppedBytes += totalBytes;
123
+ this.droppedBytes += frameBytes;
88
124
  // 仅状态翻转点打 log(warn + remoteLog 各一次);overflow 持续期间所有 drop 静默累加,
89
125
  // 避免 UI 离线 + ICE 失败导致 DC 永远不 drain 时的日志刷屏
90
126
  if (!this.queueOverflowActive) {
91
127
  this.queueOverflowActive = true;
92
- this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] overflow-start queueBytes=${this.queueBytes}`);
93
- remoteLog(`rpc-queue.overflow-start${this.__tagSuffix()} queueBytes=${this.queueBytes}`);
128
+ this.__safeWarn(`overflow-start queueBytes=${this.queueBytes}`);
129
+ this.__safeRemoteLog(`rpc-queue.overflow-start${this.__tagSuffix()} queueBytes=${this.queueBytes}`);
94
130
  }
95
131
  return false;
96
132
  }
@@ -104,7 +140,7 @@ export class RpcSendQueue {
104
140
  this.dc.send(jsonStr);
105
141
  return true;
106
142
  } catch (err) {
107
- this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] fast-path send failed: ${err?.message}`);
143
+ this.__safeWarn(`fast-path send failed: ${err?.message}`);
108
144
  return false;
109
145
  }
110
146
  }
@@ -125,7 +161,7 @@ export class RpcSendQueue {
125
161
  this.dc.send(chunks[i]);
126
162
  i += 1;
127
163
  } catch (err) {
128
- this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] fast-path send failed at chunk ${i}/${chunks.length}: ${err?.message}`);
164
+ this.__safeWarn(`fast-path send failed at chunk ${i}/${chunks.length}: ${err?.message}`);
129
165
  return false;
130
166
  }
131
167
  }
@@ -155,7 +191,7 @@ export class RpcSendQueue {
155
191
  this.queueBytes = 0;
156
192
  this.queueOverflowActive = false;
157
193
  if (this.droppedCount > 0 || residual > 0) {
158
- remoteLog(`rpc-queue.close${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes} residualChunks=${residual} residualBytes=${residualBytes}`);
194
+ this.__safeRemoteLog(`rpc-queue.close${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes} residualChunks=${residual} residualBytes=${residualBytes}`);
159
195
  }
160
196
  }
161
197
 
@@ -170,7 +206,7 @@ export class RpcSendQueue {
170
206
  try {
171
207
  dc.send(item.data);
172
208
  } catch (err) {
173
- this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] drain send failed: ${err?.message}`);
209
+ this.__safeWarn(`drain send failed: ${err?.message}`);
174
210
  return; // 保留队列,等 onclose 统一清理
175
211
  }
176
212
  this.queue.shift();
@@ -178,8 +214,8 @@ export class RpcSendQueue {
178
214
  // 满 → 未满 状态转换:打一条带累计数的 log,与 overflow-start 对称
179
215
  if (this.queueOverflowActive && this.queueBytes < MAX_QUEUE_BYTES) {
180
216
  this.queueOverflowActive = false;
181
- this.logger.info?.(`[rpc-queue${this.__tagSuffix()}] overflow-end dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`);
182
- remoteLog(`rpc-queue.overflow-end${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`);
217
+ this.__safeInfo(`overflow-end dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`);
218
+ this.__safeRemoteLog(`rpc-queue.overflow-end${this.__tagSuffix()} dropped=${this.droppedCount} droppedBytes=${this.droppedBytes}`);
183
219
  }
184
220
  }
185
221
  }
@@ -188,4 +224,48 @@ export class RpcSendQueue {
188
224
  __tagSuffix() {
189
225
  return this.tag ? ` ${this.tag}` : '';
190
226
  }
227
+
228
+ /**
229
+ * @private logger.warn 安全包装:吃掉 logger 自身抛的异常,保护 send/__drain 的 no-throw 契约
230
+ * @param {string} msg
231
+ */
232
+ __safeWarn(msg) {
233
+ try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 send 抛 */ }
234
+ }
235
+
236
+ /** @private 同 __safeWarn,info 级别 */
237
+ __safeInfo(msg) {
238
+ try { this.logger.info?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 send 抛 */ }
239
+ }
240
+
241
+ /**
242
+ * @private remoteLog 安全包装。
243
+ * 当前 `remoteLog` 实现同步路径仅做数组操作 + flush().catch(),不抛异常,本 catch 块覆盖率因此为 0。
244
+ * 保留 try/catch 是为了让"send 不抛"契约不依赖 remoteLog 模块的具体实现——如果未来 remoteLog
245
+ * 内部加入可能抛的同步路径(如序列化、外部 sink 注入),此 wrapper 仍能兜底。
246
+ */
247
+ __safeRemoteLog(text) {
248
+ try { remoteLog(text); } catch { /* 未来防御:见上方注释 */ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * 判断一条 JSON 字符串是否为带 runId 的 RPC 响应(用于队列满时白名单豁免)。
254
+ *
255
+ * 命中条件(仅看顶层):`type === 'res'` 且 `payload.runId` 为 truthy。
256
+ * 设计取舍:硬编码识别、不维护方法白名单表。该条件主要为覆盖 OpenClaw `agent` 二阶段 res
257
+ * 与 `agent.wait` 全部分支(accepted/ok/error/timeout/race/dedupe);同时也会顺带豁免
258
+ * `chat.send` 等其他顶层带 `runId` 的响应——这类 rsp 极小,加白无副作用。
259
+ * 解析失败或不命中按非白名单处理。
260
+ *
261
+ * @param {string} jsonStr - 待发送的 RPC 帧 JSON 字符串
262
+ * @returns {boolean} 命中白名单返回 true;解析失败或不命中返回 false
263
+ */
264
+ function isAgentRunResponse(jsonStr) {
265
+ try {
266
+ const parsed = JSON.parse(jsonStr);
267
+ return parsed?.type === 'res' && Boolean(parsed?.payload?.runId);
268
+ } catch {
269
+ return false;
270
+ }
191
271
  }
@@ -110,39 +110,44 @@ export class WebRtcPeer {
110
110
 
111
111
  /** 向所有已打开的 rpcChannel 广播(大消息自动分片,经由 RpcSendQueue 流控) */
112
112
  broadcast(payload) {
113
- const jsonStr = JSON.stringify(payload);
114
- for (const [connId, session] of this.__sessions) {
113
+ let jsonStr;
114
+ try {
115
+ jsonStr = JSON.stringify(payload);
116
+ } catch (err) {
117
+ // 循环引用 / BigInt 等导致 stringify 抛——记日志后整条丢弃,不冒到 gateway
118
+ this.__logDebug(`broadcast stringify failed: ${err?.message}`);
119
+ return;
120
+ }
121
+ if (typeof jsonStr !== 'string') return; // payload 是 undefined/symbol 时 stringify 返回 undefined
122
+ for (const session of this.__sessions.values()) {
115
123
  const q = session.rpcSendQueue;
116
124
  if (q && session.rpcChannel?.readyState === 'open') {
117
- try {
118
- q.send(jsonStr);
119
- } catch (err) {
120
- // buildChunks 抛(maxMessageSize 配置错)等罕见情况
121
- this.__logDebug(`[${connId}] broadcast send failed: ${err.message}`);
122
- }
125
+ q.send(jsonStr);
123
126
  }
124
127
  }
125
128
  }
126
129
 
127
130
  /**
128
131
  * 向指定 connId 的 rpc DC 单播一个 JSON 帧(不走 server 中转)。
129
- * 若 session/DC 未就绪返回 false,由调用方决定是否重试。
132
+ * 若 session/DC 未就绪或被发送队列拒收(队列满等)返回 false,由调用方决定是否重试。
130
133
  * @param {string} connId
131
134
  * @param {object} payload - 完整的 JSON 帧(通常是 { type: 'event', event, payload })
132
- * @returns {boolean} true=已入队发送,false=未能发送(session 不存在 / DC 未 open
135
+ * @returns {boolean} true=已入队发送;false=session 不存在 / DC 未 open / payload 不可序列化 / 发送队列拒收
133
136
  */
134
137
  sendTo(connId, payload) {
135
138
  const session = this.__sessions.get(connId);
136
139
  if (!session) return false;
137
140
  const q = session.rpcSendQueue;
138
141
  if (!q || session.rpcChannel?.readyState !== 'open') return false;
142
+ let jsonStr;
139
143
  try {
140
- q.send(JSON.stringify(payload));
141
- return true;
144
+ jsonStr = JSON.stringify(payload);
142
145
  } catch (err) {
143
- this.__logDebug(`[${connId}] sendTo failed: ${err.message}`);
146
+ this.__logDebug(`[${connId}] sendTo stringify failed: ${err?.message}`);
144
147
  return false;
145
148
  }
149
+ if (typeof jsonStr !== 'string') return false;
150
+ return q.send(jsonStr);
146
151
  }
147
152
 
148
153
  async __handleOffer(msg) {
@@ -520,11 +525,15 @@ export class WebRtcPeer {
520
525
  if (payload.method?.startsWith('coclaw.files.') && this.__onFileRpc) {
521
526
  const sess = this.__sessions.get(connId);
522
527
  const sendFn = (response) => {
528
+ let jsonStr;
523
529
  try {
524
- sess?.rpcSendQueue?.send(JSON.stringify(response));
530
+ jsonStr = JSON.stringify(response);
525
531
  } catch (err) {
526
- this.__logDebug(`[${connId}] sendFn failed: ${err.message}`);
532
+ this.__logDebug(`[${connId}] file sendFn stringify failed: ${err?.message}`);
533
+ return;
527
534
  }
535
+ if (typeof jsonStr !== 'string') return;
536
+ sess?.rpcSendQueue?.send(jsonStr);
528
537
  };
529
538
  this.__onFileRpc(payload, sendFn, connId);
530
539
  } else {
@@ -769,9 +778,9 @@ export class WebRtcPeer {
769
778
  }
770
779
 
771
780
  __logDebug(message) {
772
- if (typeof this.logger?.debug === 'function') {
773
- this.logger.debug(`${this.__rtcTag} ${message}`);
774
- }
781
+ if (typeof this.logger?.debug !== 'function') return;
782
+ try { this.logger.debug(`${this.__rtcTag} ${message}`); }
783
+ catch { /* 上层(broadcast / sendTo / sendFn 等的 stringify catch)依赖 __logDebug 不抛 */ }
775
784
  }
776
785
  }
777
786