@coclaw/openclaw-coclaw 0.17.2 → 0.17.3
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 +2 -1
- package/src/realtime-bridge.js +141 -13
- package/src/homedir-mock.helper.js +0 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw CoClaw channel plugin for remote chat",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"src/**/*.js",
|
|
32
32
|
"!src/**/*.test.js",
|
|
33
33
|
"!src/mock-server.helper.js",
|
|
34
|
+
"!src/homedir-mock.helper.js",
|
|
34
35
|
"openclaw.plugin.json",
|
|
35
36
|
"LICENSE"
|
|
36
37
|
],
|
package/src/realtime-bridge.js
CHANGED
|
@@ -22,6 +22,12 @@ const CONNECT_TIMEOUT_MS = 10_000;
|
|
|
22
22
|
const SERVER_HB_PING_MS = 25_000;
|
|
23
23
|
const SERVER_HB_TIMEOUT_MS = 45_000;
|
|
24
24
|
const SERVER_HB_MAX_MISS = 4; // 连续 4 次无响应才断连(~3 分钟)
|
|
25
|
+
// gateway 握手失败的指数退避表:每个元素是"上一次失败"之后、"下一次尝试"之前的等待时间。
|
|
26
|
+
// 最多 5 次重试(加上首次尝试共 6 次),全部失败后进入 gave-up 终态,不再自动尝试。
|
|
27
|
+
const GATEWAY_RETRY_DELAYS_MS = [5_000, 10_000, 20_000, 20_000, 20_000];
|
|
28
|
+
// v3 握手失败时,只有错误消息匹配此正则才回退到不带 device 的 legacy 握手。
|
|
29
|
+
// 严格限定在"签名/设备/scope/协议"相关错误,避免对网络/内部错误做无意义的降级尝试。
|
|
30
|
+
const GATEWAY_HANDSHAKE_FALLBACK_PATTERN = /signature|device|scope|protocol/i;
|
|
25
31
|
|
|
26
32
|
function toServerWsUrl(baseUrl, token) {
|
|
27
33
|
const url = new URL(baseUrl);
|
|
@@ -112,6 +118,12 @@ export class RealtimeBridge {
|
|
|
112
118
|
this.__fileHandler = null;
|
|
113
119
|
this.__ndcPreloadResult = null;
|
|
114
120
|
this.__ndcCleanup = null;
|
|
121
|
+
// gateway 握手重试状态(刷屏治理 + 兼容性回退)
|
|
122
|
+
this.__gatewayAttempts = 0; // 已失败的连续握手次数(握手成功时归零)
|
|
123
|
+
this.__gatewayRetryTimer = null; // 下一次尝试的 setTimeout 句柄
|
|
124
|
+
this.__gatewayGaveUp = false; // 重试次数耗尽 → 终态,不再自动尝试
|
|
125
|
+
this.__gatewayLegacyMode = false; // 学到"本 gateway 不接受带 device 的 v3"
|
|
126
|
+
this.__gatewayLastReason = null; // 最近一次失败原因(用于 gave-up 上报)
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
__resolveWebSocket() {
|
|
@@ -192,6 +204,14 @@ export class RealtimeBridge {
|
|
|
192
204
|
}
|
|
193
205
|
|
|
194
206
|
__closeGatewayWs() {
|
|
207
|
+
// 当 server WS 失效主动关闭 gateway 时,取消任何 pending 重试定时器、把连续失败计数归零:
|
|
208
|
+
// 新 server 会话应从新预算开始重试 gateway,避免旧会话的零散失败累计吞掉未来的重试机会。
|
|
209
|
+
// 不清 __gatewayGaveUp / __gatewayLegacyMode —— 那是跨会话的终态/学习,只由 stop() 复位。
|
|
210
|
+
if (this.__gatewayRetryTimer) {
|
|
211
|
+
clearTimeout(this.__gatewayRetryTimer);
|
|
212
|
+
this.__gatewayRetryTimer = null;
|
|
213
|
+
}
|
|
214
|
+
this.__gatewayAttempts = 0;
|
|
195
215
|
if (!this.gatewayWs) {
|
|
196
216
|
return;
|
|
197
217
|
}
|
|
@@ -558,12 +578,13 @@ export class RealtimeBridge {
|
|
|
558
578
|
};
|
|
559
579
|
}
|
|
560
580
|
|
|
561
|
-
__sendGatewayConnectRequest(ws, nonce) {
|
|
562
|
-
|
|
563
|
-
this.
|
|
581
|
+
__sendGatewayConnectRequest(ws, nonce, { legacy = false } = {}) {
|
|
582
|
+
// 用 rpcSeq 保证 ID 唯一,避免 v3→legacy 同毫秒内两次调用产生相同 id
|
|
583
|
+
this.gatewayRpcSeq += 1;
|
|
584
|
+
this.gatewayConnectReqId = `coclaw-connect-${Date.now()}-${this.gatewayRpcSeq}`;
|
|
585
|
+
this.__logDebug(`gateway connect request -> id=${this.gatewayConnectReqId} legacy=${legacy}`);
|
|
564
586
|
try {
|
|
565
587
|
const authToken = this.__resolveGatewayAuthToken();
|
|
566
|
-
const device = this.__buildDeviceField(nonce, authToken);
|
|
567
588
|
const params = {
|
|
568
589
|
minProtocol: 3,
|
|
569
590
|
maxProtocol: 3,
|
|
@@ -577,8 +598,12 @@ export class RealtimeBridge {
|
|
|
577
598
|
role: 'operator',
|
|
578
599
|
scopes: ['operator.admin'],
|
|
579
600
|
auth: authToken ? { token: authToken } : undefined,
|
|
580
|
-
device,
|
|
581
601
|
};
|
|
602
|
+
// legacy 回退仅省略 device 字段;其他字段保持与 v3 一致。
|
|
603
|
+
// 当 gateway 不支持/不接受 device 字段时,auth.token 足以完成旧版握手。
|
|
604
|
+
if (!legacy) {
|
|
605
|
+
params.device = this.__buildDeviceField(nonce, authToken);
|
|
606
|
+
}
|
|
582
607
|
ws.send(JSON.stringify({
|
|
583
608
|
type: 'req',
|
|
584
609
|
id: this.gatewayConnectReqId,
|
|
@@ -592,7 +617,42 @@ export class RealtimeBridge {
|
|
|
592
617
|
}
|
|
593
618
|
}
|
|
594
619
|
|
|
620
|
+
/**
|
|
621
|
+
* 握手失败一次:累加计数;未耗尽则按退避表调度下次尝试,耗尽则进入 gave-up 终态。
|
|
622
|
+
* 调度 / 尝试 / 终态 guard 由 __ensureGatewayConnection 一致执行。
|
|
623
|
+
* @param {string} reason - 本次失败原因,用于 gave-up 时汇总上报
|
|
624
|
+
*/
|
|
625
|
+
__onGatewayAttemptFailed(reason) {
|
|
626
|
+
if (!this.started || this.__gatewayGaveUp || this.__gatewayRetryTimer) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this.__gatewayLastReason = reason;
|
|
630
|
+
this.__gatewayAttempts += 1;
|
|
631
|
+
if (this.__gatewayAttempts > GATEWAY_RETRY_DELAYS_MS.length) {
|
|
632
|
+
this.__gatewayGaveUp = true;
|
|
633
|
+
remoteLog(`gateway.handshake.gave-up attempts=${this.__gatewayAttempts} lastReason=${reason}`);
|
|
634
|
+
this.logger.warn?.(`[coclaw] gateway handshake gave up after ${this.__gatewayAttempts} attempts (last reason: ${reason})`);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const delay = GATEWAY_RETRY_DELAYS_MS[this.__gatewayAttempts - 1];
|
|
638
|
+
this.__gatewayRetryTimer = setTimeout(() => {
|
|
639
|
+
this.__gatewayRetryTimer = null;
|
|
640
|
+
this.__ensureGatewayConnection();
|
|
641
|
+
}, delay);
|
|
642
|
+
this.__gatewayRetryTimer.unref?.();
|
|
643
|
+
}
|
|
644
|
+
|
|
595
645
|
__ensureGatewayConnection() {
|
|
646
|
+
// 停机守卫:防止 stop() 之后某个已进入调度队列的 retry timer callback 再触发新 WS
|
|
647
|
+
if (!this.started) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
// 刷屏治理:已进入终态 / 已调度下次尝试 → 不启动新 WS。
|
|
651
|
+
// 这两个 guard 保证在 __waitGatewayReady 或 server WS 重连的连续触发下
|
|
652
|
+
// 只会按退避表节奏新建连接。
|
|
653
|
+
if (this.__gatewayGaveUp || this.__gatewayRetryTimer) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
596
656
|
if (this.gatewayWs || !this.serverWs || this.serverWs.readyState !== 1) {
|
|
597
657
|
return;
|
|
598
658
|
}
|
|
@@ -606,6 +666,12 @@ export class RealtimeBridge {
|
|
|
606
666
|
this.gatewayReady = false;
|
|
607
667
|
this.gatewayConnectReqId = null;
|
|
608
668
|
|
|
669
|
+
// per-WS 闭包状态,只在本条 WS 的生命周期内有效。
|
|
670
|
+
let connectFailReported = false; // 已经打过 ws.connect-failed;close 时抑制重复的 ws.disconnected
|
|
671
|
+
let pendingLegacyAttempted = false; // 本 WS 已尝试过 legacy 握手,避免重复降级
|
|
672
|
+
let wasReady = false; // 本 WS 曾经握手成功(区分"握手失败"与"成功后断开")
|
|
673
|
+
let lastChallengeNonce = ''; // 最近一次 challenge 的 nonce,legacy 回退时复用
|
|
674
|
+
|
|
609
675
|
ws.addEventListener('message', (event) => {
|
|
610
676
|
let payload = null;
|
|
611
677
|
try {
|
|
@@ -619,13 +685,23 @@ export class RealtimeBridge {
|
|
|
619
685
|
}
|
|
620
686
|
if (payload.type === 'event' && payload.event === 'connect.challenge') {
|
|
621
687
|
const nonce = payload?.payload?.nonce ?? '';
|
|
622
|
-
|
|
623
|
-
this.
|
|
688
|
+
lastChallengeNonce = nonce;
|
|
689
|
+
this.__logDebug(`gateway event <- connect.challenge legacyMode=${this.__gatewayLegacyMode}`);
|
|
690
|
+
// 已经学到此 gateway 是 legacy(上一条 WS 回退过)→ 直接发 legacy 握手
|
|
691
|
+
if (this.__gatewayLegacyMode) {
|
|
692
|
+
pendingLegacyAttempted = true;
|
|
693
|
+
this.__sendGatewayConnectRequest(ws, nonce, { legacy: true });
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
this.__sendGatewayConnectRequest(ws, nonce);
|
|
697
|
+
}
|
|
624
698
|
return;
|
|
625
699
|
}
|
|
626
700
|
if (payload.type === 'res' && this.gatewayConnectReqId && payload.id === this.gatewayConnectReqId) {
|
|
627
701
|
if (payload.ok === true) {
|
|
628
702
|
this.gatewayReady = true;
|
|
703
|
+
wasReady = true;
|
|
704
|
+
this.__gatewayAttempts = 0; // 成功握手 → 重置失败计数,让后续瞬态断开有完整重试预算
|
|
629
705
|
remoteLog('ws.connected peer=gateway');
|
|
630
706
|
this.__logDebug(`gateway connect ok <- id=${payload.id}`);
|
|
631
707
|
this.gatewayConnectReqId = null;
|
|
@@ -633,10 +709,28 @@ export class RealtimeBridge {
|
|
|
633
709
|
this.__pushInstanceInfo();
|
|
634
710
|
}
|
|
635
711
|
else {
|
|
712
|
+
const reason = payload?.error?.message ?? 'unknown';
|
|
713
|
+
// v3 → legacy 同 WS 回退:仅在签名/协议相关错误、且本 WS 尚未尝试 legacy 时触发
|
|
714
|
+
const shouldFallback =
|
|
715
|
+
!pendingLegacyAttempted
|
|
716
|
+
&& !this.__gatewayLegacyMode
|
|
717
|
+
&& GATEWAY_HANDSHAKE_FALLBACK_PATTERN.test(reason);
|
|
718
|
+
if (shouldFallback) {
|
|
719
|
+
pendingLegacyAttempted = true;
|
|
720
|
+
this.__gatewayLegacyMode = true;
|
|
721
|
+
// v3 的失败原因已由这条 remoteLog 单独上报,不写入 __gatewayLastReason;
|
|
722
|
+
// 后者保持"最后一次真正失败的原因"语义,供 gave-up 时使用。
|
|
723
|
+
remoteLog(`gateway.handshake.fallback v3→legacy reason=${reason}`);
|
|
724
|
+
this.logger.info?.(`[coclaw] gateway v3 handshake failed (${reason}), falling back to legacy`);
|
|
725
|
+
this.__sendGatewayConnectRequest(ws, lastChallengeNonce, { legacy: true });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
636
728
|
this.gatewayReady = false;
|
|
637
729
|
this.gatewayConnectReqId = null;
|
|
638
|
-
|
|
639
|
-
this.
|
|
730
|
+
connectFailReported = true;
|
|
731
|
+
this.__gatewayLastReason = reason;
|
|
732
|
+
remoteLog(`ws.connect-failed peer=gateway msg=${reason}`);
|
|
733
|
+
this.logger.warn?.(`[coclaw] gateway connect failed: ${reason}`);
|
|
640
734
|
try { ws.close(1008, 'gateway_connect_failed'); }
|
|
641
735
|
/* c8 ignore next */
|
|
642
736
|
catch {}
|
|
@@ -675,21 +769,46 @@ export class RealtimeBridge {
|
|
|
675
769
|
this.__logDebug('gateway ws open, waiting for connect.challenge');
|
|
676
770
|
});
|
|
677
771
|
ws.addEventListener('close', (ev) => {
|
|
678
|
-
|
|
772
|
+
// 握手失败路径已经打过 ws.connect-failed,这里抑制重复的 disconnected 日志;
|
|
773
|
+
// 成功后的意外断开、握手途中的异常断开仍按原样上报。
|
|
774
|
+
if (!connectFailReported) {
|
|
775
|
+
remoteLog(`ws.disconnected peer=gateway code=${ev?.code ?? '?'}`);
|
|
776
|
+
}
|
|
679
777
|
this.logger.info?.(`[coclaw] gateway ws closed (code=${ev?.code ?? '?'} reason=${ev?.reason ?? 'n/a'})`);
|
|
680
|
-
this.gatewayWs
|
|
681
|
-
|
|
682
|
-
|
|
778
|
+
if (this.gatewayWs === ws) {
|
|
779
|
+
this.gatewayWs = null;
|
|
780
|
+
this.gatewayReady = false;
|
|
781
|
+
this.gatewayConnectReqId = null;
|
|
782
|
+
}
|
|
683
783
|
/* c8 ignore next 3 -- gateway 意外断开时结算未完成 RPC,避免等超时 */
|
|
684
784
|
for (const [, settle] of this.gatewayPendingRequests) {
|
|
685
785
|
settle({ ok: false, error: 'gateway_closed' });
|
|
686
786
|
}
|
|
687
787
|
this.gatewayPendingRequests.clear();
|
|
788
|
+
// 调度下一次尝试:仅在 bridge 仍活着、未 gave-up、server WS 健康时;
|
|
789
|
+
// 其他场景(如 bridge stop、server WS 已断)由上游流程兜底,不参与 gateway 重试。
|
|
790
|
+
if (this.started && !this.__gatewayGaveUp
|
|
791
|
+
&& this.serverWs && this.serverWs.readyState === 1
|
|
792
|
+
&& (wasReady || connectFailReported)) {
|
|
793
|
+
if (wasReady) {
|
|
794
|
+
// 之前握成功过,视为瞬态掉线 → 重置计数,让新一轮拿到完整重试预算
|
|
795
|
+
this.__gatewayAttempts = 0;
|
|
796
|
+
}
|
|
797
|
+
this.__onGatewayAttemptFailed(
|
|
798
|
+
/* c8 ignore next -- connectFailReported 路径必然已设 __gatewayLastReason */
|
|
799
|
+
wasReady ? 'disconnected' : (this.__gatewayLastReason ?? 'connect-failed')
|
|
800
|
+
);
|
|
801
|
+
}
|
|
688
802
|
});
|
|
689
803
|
ws.addEventListener('error', (err) => {
|
|
690
804
|
/* c8 ignore next -- ?./?? fallback */
|
|
691
805
|
remoteLog(`ws.error peer=gateway msg=${String(err?.message ?? err)}`);
|
|
692
806
|
this.logger.warn?.(`[coclaw] gateway ws error: ${String(err?.message ?? err)}`);
|
|
807
|
+
// 防御 ws 库在某些错误下只 emit error 不跟随 close 的情况:主动关闭让 close handler
|
|
808
|
+
// 接管清理和重试调度,避免 gatewayWs 引用卡在僵尸状态阻塞后续 __ensureGatewayConnection。
|
|
809
|
+
try { ws.close(1011, 'ws_error'); }
|
|
810
|
+
/* c8 ignore next */
|
|
811
|
+
catch {}
|
|
693
812
|
});
|
|
694
813
|
}
|
|
695
814
|
|
|
@@ -1049,6 +1168,15 @@ export class RealtimeBridge {
|
|
|
1049
1168
|
clearTimeout(this.reconnectTimer);
|
|
1050
1169
|
this.reconnectTimer = null;
|
|
1051
1170
|
}
|
|
1171
|
+
// 清理 gateway 重试状态:refresh()(stop+start 同一实例)后应以全新状态启动
|
|
1172
|
+
if (this.__gatewayRetryTimer) {
|
|
1173
|
+
clearTimeout(this.__gatewayRetryTimer);
|
|
1174
|
+
this.__gatewayRetryTimer = null;
|
|
1175
|
+
}
|
|
1176
|
+
this.__gatewayAttempts = 0;
|
|
1177
|
+
this.__gatewayGaveUp = false;
|
|
1178
|
+
this.__gatewayLegacyMode = false;
|
|
1179
|
+
this.__gatewayLastReason = null;
|
|
1052
1180
|
this.__closeGatewayWs();
|
|
1053
1181
|
if (this.webrtcPeer) {
|
|
1054
1182
|
await this.webrtcPeer.closeAll().catch(() => {});
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 跨平台 mock os.homedir()
|
|
3
|
-
*
|
|
4
|
-
* Node.js os.homedir() 在不同平台读取不同环境变量:
|
|
5
|
-
* - POSIX: HOME
|
|
6
|
-
* - Windows: USERPROFILE(优先)、HOMEDRIVE+HOMEPATH
|
|
7
|
-
*
|
|
8
|
-
* 测试中需同时设置两端变量,确保 os.homedir() 返回期望路径。
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const HOME_VARS = ['HOME', 'USERPROFILE'];
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* 保存当前 home 相关环境变量
|
|
15
|
-
* @returns {Record<string, string | undefined>}
|
|
16
|
-
*/
|
|
17
|
-
export function saveHomedir() {
|
|
18
|
-
const saved = {};
|
|
19
|
-
for (const key of HOME_VARS) {
|
|
20
|
-
saved[key] = process.env[key];
|
|
21
|
-
}
|
|
22
|
-
return saved;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* 将 home 相关环境变量统一设置为指定路径
|
|
27
|
-
* @param {string} dir - 目标路径
|
|
28
|
-
*/
|
|
29
|
-
export function setHomedir(dir) {
|
|
30
|
-
for (const key of HOME_VARS) {
|
|
31
|
-
process.env[key] = dir;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 恢复之前保存的 home 相关环境变量
|
|
37
|
-
* @param {Record<string, string | undefined>} saved
|
|
38
|
-
*/
|
|
39
|
-
export function restoreHomedir(saved) {
|
|
40
|
-
for (const key of HOME_VARS) {
|
|
41
|
-
if (saved[key] === undefined) {
|
|
42
|
-
delete process.env[key];
|
|
43
|
-
} else {
|
|
44
|
-
process.env[key] = saved[key];
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|