@coclaw/openclaw-coclaw 0.6.0 → 0.6.2

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
@@ -72,7 +72,8 @@ openclaw coclaw unbind [--server <url>]
72
72
  openclaw coclaw enroll [--server <url>]
73
73
  ```
74
74
 
75
- - bind/unbind 成功后会通过 gateway RPC 通知插件刷新/停止 bridge 连接(无需重启 gateway)。若 gateway 未运行,通知会失败但不影响绑定结果。
75
+ - bind/unbind/enroll 均为瘦 CLI,通过 gateway RPC(`coclaw.bind`/`coclaw.unbind`/`coclaw.enroll`)在 gateway 内执行,由 gateway 内部管理 bridge 生命周期。若 gateway 未运行,CLI 会自动尝试重启一次再重试;若仍不可用,操作失败。
76
+ - unbind 是强制操作:server 不可达时操作失败(不清理本地 config,避免产生孤儿 bot)。server 返回 401/404/410 视为 bot 已不存在,允许继续。
76
77
  - enroll 由 OpenClaw 侧主动发起,生成认领码和链接供用户点击完成绑定。已绑定时需先 unbind 再发起。
77
78
 
78
79
  ### 方式二:IM 渠道命令
@@ -87,13 +88,15 @@ openclaw coclaw enroll [--server <url>]
87
88
 
88
89
  需要 gateway 运行中。
89
90
 
90
- ### 方式三:独立 CLI(兼容)
91
+ ### 方式三:独立 CLI(遗留)
91
92
 
92
93
  ```bash
93
94
  node ~/.openclaw/extensions/coclaw/src/cli.js bind <binding-code> --server <url>
94
95
  node ~/.openclaw/extensions/coclaw/src/cli.js unbind --server <url>
95
96
  ```
96
97
 
98
+ > 注意:独立 CLI 不走 gateway RPC,直接在 CLI 进程中执行 bind/unbind 并通过 `coclaw.refreshBridge`/`coclaw.stopBridge` 通知 gateway。此路径不具备瘦 CLI 的架构保证(所有 config 操作在同一进程内)。推荐使用方式一。
99
+
97
100
  ## 配置存储
98
101
 
99
102
  绑定信息存储在 `~/.openclaw/coclaw/bindings.json`(通过 `resolveStateDir()` + channel ID 组合路径),**不存储在 `openclaw.json` 中**。
package/index.js CHANGED
@@ -149,6 +149,70 @@ const plugin = {
149
149
  }
150
150
  });
151
151
 
152
+ // --- bind/unbind 共享逻辑(RPC handler + 斜杠命令共用) ---
153
+
154
+ async function doBind({ code, serverUrl }) {
155
+ await stopRealtimeBridge();
156
+ let result;
157
+ try {
158
+ result = await bindBot({
159
+ code,
160
+ serverUrl: serverUrl ?? api.pluginConfig?.serverUrl,
161
+ });
162
+ } catch (err) {
163
+ // bind 失败时恢复 bridge(best-effort,不覆盖原始错误)
164
+ await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig }).catch(() => {});
165
+ throw err;
166
+ }
167
+ // bind 已持久化,restart 失败不影响结果
168
+ await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig }).catch((err) => {
169
+ logger.warn?.(`[coclaw] bridge restart failed after bind: ${err?.message ?? err}`);
170
+ });
171
+ return result;
172
+ }
173
+
174
+ async function doUnbind({ serverUrl }) {
175
+ const result = await unbindBot({
176
+ serverUrl: serverUrl ?? api.pluginConfig?.serverUrl,
177
+ });
178
+ await stopRealtimeBridge();
179
+ return result;
180
+ }
181
+
182
+ api.registerGatewayMethod('coclaw.bind', async ({ params, respond }) => {
183
+ try {
184
+ const code = params?.code;
185
+ if (!code) {
186
+ respondInvalid(respond, 'code is required');
187
+ return;
188
+ }
189
+ const result = await doBind({
190
+ code,
191
+ serverUrl: params?.serverUrl,
192
+ });
193
+ respond(true, {
194
+ status: {
195
+ botId: result.botId,
196
+ rebound: result.rebound,
197
+ previousBotId: result.previousBotId,
198
+ },
199
+ });
200
+ }
201
+ catch (err) {
202
+ respondError(respond, err);
203
+ }
204
+ });
205
+
206
+ api.registerGatewayMethod('coclaw.unbind', async ({ params, respond }) => {
207
+ try {
208
+ const result = await doUnbind({ serverUrl: params?.serverUrl });
209
+ respond(true, { status: { botId: result.botId } });
210
+ }
211
+ catch (err) {
212
+ respondError(respond, err);
213
+ }
214
+ });
215
+
152
216
  // enroll 并发控制:同一时刻只允许一个活跃 enroll
153
217
  let activeEnrollAbort = null;
154
218
 
@@ -229,7 +293,10 @@ const plugin = {
229
293
  try {
230
294
  await waitForSessionsReady();
231
295
  const version = await getPluginVersion();
232
- respond(true, { version, capabilities: ['topics', 'chatHistory'] });
296
+ const rawClawVersion = api.runtime?.version;
297
+ // OpenClaw 打包后 resolveVersion() 路径失配导致返回 'unknown',此时不传该字段
298
+ const clawVersion = (rawClawVersion && rawClawVersion !== 'unknown') ? rawClawVersion : undefined;
299
+ respond(true, { version, clawVersion, capabilities: ['topics', 'chatHistory'] });
233
300
  }
234
301
  catch (err) {
235
302
  respondError(respond, err);
@@ -426,13 +493,10 @@ const plugin = {
426
493
 
427
494
  try {
428
495
  if (action === 'bind') {
429
- await stopRealtimeBridge(); // 先断开,避免 bindBot 内 unbind 触发 bot.unbound 竞态
430
- const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
431
- const result = await bindBot({
496
+ const result = await doBind({
432
497
  code: positionals[0],
433
- serverUrl,
498
+ serverUrl: options.server,
434
499
  });
435
- await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
436
500
  return { text: bindOk(result) };
437
501
  }
438
502
 
@@ -480,8 +544,7 @@ const plugin = {
480
544
  }
481
545
 
482
546
  if (action === 'unbind') {
483
- const result = await unbindBot({ serverUrl: options.server });
484
- await stopRealtimeBridge();
547
+ const result = await doUnbind({ serverUrl: options.server });
485
548
  return { text: unbindOk(result) };
486
549
  }
487
550
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -1,9 +1,7 @@
1
- import { bindBot, unbindBot } from './common/bot-binding.js';
2
1
  import { resolveErrorMessage } from './common/errors.js';
3
2
  import { callGatewayMethod } from './common/gateway-notify.js';
4
3
  import {
5
4
  notBound, bindOk, unbindOk,
6
- gatewayNotified, gatewayNotifyFailed,
7
5
  claimCodeCreated,
8
6
  } from './common/messages.js';
9
7
 
@@ -43,55 +41,82 @@ async function restartGatewayProcess(spawnFn) {
43
41
  }
44
42
  /* c8 ignore stop */
45
43
 
46
- function resolveServerUrl(opts, config) {
47
- return opts?.server
48
- ?? config?.plugins?.entries?.['openclaw-coclaw']?.config?.serverUrl
49
- ?? process.env.COCLAW_SERVER_URL;
44
+ // bind/unbind/enroll RPC 超时(覆盖默认 10s)
45
+ // 卡点是 gateway ↔ server 的网络通信,bind 最多两次(先解绑再绑定)
46
+ const RPC_TIMEOUT_MS = 30_000;
47
+
48
+ /**
49
+ * 通用 RPC 调用:gateway 不可用时重启重试
50
+ */
51
+ async function callWithRetry(method, deps, rpcOpts) {
52
+ const callRpc = () => callGatewayMethod(method, deps.spawn, rpcOpts);
53
+
54
+ let result = await callRpc();
55
+
56
+ if (isGatewayUnavailable(result)) {
57
+ const restartFn = deps.restartGateway ?? restartGatewayProcess;
58
+ try {
59
+ await restartFn(deps.spawn);
60
+ } catch {
61
+ // 重启失败,仍然尝试再次 RPC
62
+ }
63
+ result = await callRpc();
64
+ }
65
+
66
+ return result;
50
67
  }
51
68
 
52
69
  /**
53
- * 注册 `openclaw coclaw bind/unbind` CLI 子命令
70
+ * 通用 RPC 错误输出
71
+ */
72
+ function handleRpcError(result, fallbackMsg) {
73
+ if (isGatewayUnavailable(result)) {
74
+ console.error('Error: Could not reach gateway. Ensure OpenClaw gateway is running.');
75
+ console.error(' Try: openclaw gateway start');
76
+ } else {
77
+ console.error(`Error: ${extractRpcErrorMessage(result.message) || fallbackMsg}`);
78
+ }
79
+ process.exitCode = 1;
80
+ }
81
+
82
+ /**
83
+ * 注册 `openclaw coclaw bind/unbind/enroll` CLI 子命令
84
+ * bind/unbind/enroll 均为瘦 CLI,通过 gateway RPC 执行
54
85
  * @param {object} ctx - OpenClaw CLI 注册上下文
55
86
  * @param {import('commander').Command} ctx.program - Commander.js Command 实例
56
- * @param {object} ctx.config - OpenClaw 配置
57
87
  * @param {object} ctx.logger - 日志实例
58
88
  * @param {object} [deps] - 可注入依赖(测试用)
59
89
  */
60
- export function registerCoclawCli({ program, config, logger }, deps = {}) {
61
- const notifyGateway = async (method) => {
62
- const action = method.endsWith('refreshBridge') ? 'refresh' : 'stop';
63
- try {
64
- const result = await callGatewayMethod(method, deps.spawn);
65
- if (result.ok) {
66
- logger.info?.(`[coclaw] ${gatewayNotified(action)}`);
67
- } else {
68
- logger.warn?.(`[coclaw] ${gatewayNotifyFailed()}`);
69
- }
70
- }
71
- /* c8 ignore next 3 -- callGatewayMethod 已内部兜底,此处纯防御 */
72
- catch {
73
- logger.warn?.(`[coclaw] ${gatewayNotifyFailed()}`);
74
- }
75
- };
76
-
90
+ export function registerCoclawCli({ program, logger }, deps = {}) {
77
91
  const coclaw = program
78
92
  .command('coclaw')
79
93
  .description('CoClaw bind/unbind commands');
80
94
 
81
95
  coclaw
82
96
  .command('bind <code>')
83
- .description('Bind this OpenClaw instance to CoClaw')
97
+ .description('Bind this Claw to CoClaw')
84
98
  .option('--server <url>', 'CoClaw server URL')
85
99
  .action(async (code, opts) => {
86
100
  try {
87
- // 先断开 bridge,避免 unbindWithServer 触发的 bot.unbound 竞态
88
- await notifyGateway('coclaw.stopBridge');
89
- const serverUrl = resolveServerUrl(opts, config);
90
- const result = await bindBot({ code, serverUrl });
91
- /* c8 ignore next */
92
- console.log(bindOk(result));
93
- await notifyGateway('coclaw.refreshBridge');
94
- } catch (err) {
101
+ const params = { code };
102
+ if (opts?.server) params.serverUrl = opts.server;
103
+ const result = await callWithRetry('coclaw.bind', deps, { params, timeoutMs: RPC_TIMEOUT_MS });
104
+
105
+ if (!result.ok) {
106
+ if (result.message && /NOT_BOUND|UNBIND_FAILED/.test(result.message)) {
107
+ console.error(`Error: ${extractRpcErrorMessage(result.message) || 'bind failed'}`);
108
+ process.exitCode = 1;
109
+ return;
110
+ }
111
+ handleRpcError(result, 'bind failed');
112
+ return;
113
+ }
114
+
115
+ const data = result.status;
116
+ console.log(bindOk(data));
117
+ }
118
+ /* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
119
+ catch (err) {
95
120
  console.error(`Error: ${resolveErrorMessage(err)}`);
96
121
  process.exitCode = 1;
97
122
  }
@@ -99,36 +124,16 @@ export function registerCoclawCli({ program, config, logger }, deps = {}) {
99
124
 
100
125
  coclaw
101
126
  .command('enroll')
102
- .description('Enroll this OpenClaw instance with CoClaw (generate a claim code for the user)')
127
+ .description('Enroll this Claw with CoClaw (generate a claim code for the user)')
103
128
  .option('--server <url>', 'CoClaw server URL')
104
129
  .action(async (opts) => {
105
130
  try {
106
- const rpcOpts = opts?.server ? { params: { serverUrl: opts.server } } : undefined;
107
- const callRpc = () => callGatewayMethod('coclaw.enroll', deps.spawn, rpcOpts);
108
-
109
- let result = await callRpc();
110
-
111
- // 仅在 gateway 不可用时重启重试,业务错误不重启
112
- if (isGatewayUnavailable(result)) {
113
- logger.info?.('[coclaw] enroll RPC failed, attempting gateway restart...');
114
- const restartFn = deps.restartGateway ?? restartGatewayProcess;
115
- try {
116
- await restartFn(deps.spawn);
117
- } catch {
118
- // 重启失败,仍然尝试再次 RPC
119
- }
120
- result = await callRpc();
121
- }
131
+ const rpcOpts = { timeoutMs: RPC_TIMEOUT_MS };
132
+ if (opts?.server) rpcOpts.params = { serverUrl: opts.server };
133
+ const result = await callWithRetry('coclaw.enroll', deps, rpcOpts);
122
134
 
123
135
  if (!result.ok) {
124
- if (isGatewayUnavailable(result)) {
125
- console.error('Error: Could not reach gateway. Ensure OpenClaw gateway is running.');
126
- console.error(' Try: openclaw gateway start');
127
- } else {
128
- // 业务错误(如已绑定):输出 gateway 返回的错误信息
129
- console.error(`Error: ${extractRpcErrorMessage(result.message) || 'enroll failed'}`);
130
- }
131
- process.exitCode = 1;
136
+ handleRpcError(result, 'enroll failed');
132
137
  return;
133
138
  }
134
139
 
@@ -154,24 +159,31 @@ export function registerCoclawCli({ program, config, logger }, deps = {}) {
154
159
 
155
160
  coclaw
156
161
  .command('unbind')
157
- .description('Unbind this OpenClaw instance from CoClaw')
162
+ .description('Unbind this Claw from CoClaw')
158
163
  .option('--server <url>', 'CoClaw server URL')
159
164
  .action(async (opts) => {
160
165
  try {
161
- const result = await unbindBot({ serverUrl: opts?.server });
162
- /* c8 ignore next */
163
- console.log(unbindOk(result));
164
- await notifyGateway('coclaw.stopBridge');
165
- } catch (err) {
166
- if (err.code === 'NOT_BOUND') {
167
- console.error(notBound());
166
+ const rpcOpts = { timeoutMs: RPC_TIMEOUT_MS };
167
+ if (opts?.server) rpcOpts.params = { serverUrl: opts.server };
168
+ const result = await callWithRetry('coclaw.unbind', deps, rpcOpts);
169
+
170
+ if (!result.ok) {
171
+ if (result.message && /NOT_BOUND/.test(result.message)) {
172
+ console.error(notBound());
173
+ } else {
174
+ handleRpcError(result, 'unbind failed');
175
+ }
168
176
  process.exitCode = 1;
169
177
  return;
170
178
  }
171
- /* c8 ignore start -- 防御性兜底,unbindBot 当前仅抛 NOT_BOUND */
179
+
180
+ const data = result.status;
181
+ console.log(unbindOk(data));
182
+ }
183
+ /* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
184
+ catch (err) {
172
185
  console.error(`Error: ${resolveErrorMessage(err)}`);
173
186
  process.exitCode = 1;
174
187
  }
175
- /* c8 ignore stop */
176
188
  });
177
189
  }
package/src/cli.js CHANGED
@@ -92,7 +92,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
92
92
  console.error(notBound());
93
93
  return 1;
94
94
  }
95
- /* c8 ignore start -- 防御性兜底,unbindBot 当前仅抛 NOT_BOUND */
95
+ /* c8 ignore start -- 防御性兜底,unbindBot 主要抛 NOT_BOUND,也可能抛网络/HTTP 错误 */
96
96
  throw err;
97
97
  }
98
98
  /* c8 ignore stop */
@@ -8,31 +8,53 @@ function resolveServerUrl(serverUrl) {
8
8
  return serverUrl ?? process.env.COCLAW_SERVER_URL ?? DEFAULT_SERVER_URL;
9
9
  }
10
10
 
11
- export async function bindBot({ code, serverUrl }) {
11
+ // 这些 HTTP 状态码表示 bot server 端已不存在,视为解绑成功
12
+ const ALREADY_UNBOUND_STATUSES = new Set([401, 404, 410]);
13
+
14
+ function isAlreadyUnbound(err) {
15
+ return ALREADY_UNBOUND_STATUSES.has(err?.response?.status);
16
+ }
17
+
18
+ export async function bindBot({ code, serverUrl }, deps = {}) {
19
+ const {
20
+ readCfg = readConfig,
21
+ clearCfg = clearConfig,
22
+ writeCfg = writeConfig,
23
+ unbindServer = unbindWithServer,
24
+ bindServer = bindWithServer,
25
+ } = deps;
26
+
12
27
  if (!code) {
13
28
  throw new Error('binding code is required');
14
29
  }
15
30
 
16
- const config = await readConfig();
31
+ const config = await readCfg();
17
32
 
18
- // 已绑定时自动解绑再重绑(解绑尽力而为,不阻塞新绑定)
33
+ // 已绑定时必须先解绑旧 bot,避免产生孤儿记录
19
34
  let previousBotId;
20
35
  if (config?.token) {
21
36
  previousBotId = config.botId || 'unknown';
22
37
  const oldBaseUrl = config.serverUrl;
23
38
  if (oldBaseUrl) {
24
39
  try {
25
- await unbindWithServer({ baseUrl: oldBaseUrl, token: config.token });
26
- } catch {
27
- // 尽力而为,忽略解绑错误
40
+ await unbindServer({ baseUrl: oldBaseUrl, token: config.token });
41
+ } catch (err) {
42
+ if (!isAlreadyUnbound(err)) {
43
+ const rebindErr = new Error(
44
+ `Failed to unbind previous bot (${previousBotId}): ${err.message}. ` +
45
+ 'Unbind manually first, then retry.',
46
+ );
47
+ rebindErr.code = 'UNBIND_FAILED';
48
+ throw rebindErr;
49
+ }
28
50
  }
29
51
  }
30
- await clearConfig();
52
+ await clearCfg();
31
53
  }
32
54
 
33
55
  /* c8 ignore next */
34
56
  const baseUrl = serverUrl ?? process.env.COCLAW_SERVER_URL ?? DEFAULT_SERVER_URL;
35
- const data = await bindWithServer({
57
+ const data = await bindServer({
36
58
  baseUrl,
37
59
  code,
38
60
  });
@@ -41,7 +63,7 @@ export async function bindBot({ code, serverUrl }) {
41
63
  throw new Error('invalid bind response');
42
64
  }
43
65
 
44
- await writeConfig({
66
+ await writeCfg({
45
67
  serverUrl: baseUrl,
46
68
  botId: data.botId,
47
69
  token: data.token,
@@ -128,8 +150,14 @@ export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }
128
150
  }
129
151
  }
130
152
 
131
- export async function unbindBot({ serverUrl }) {
132
- const config = await readConfig();
153
+ export async function unbindBot({ serverUrl }, deps = {}) {
154
+ const {
155
+ readCfg = readConfig,
156
+ clearCfg = clearConfig,
157
+ unbindServer = unbindWithServer,
158
+ } = deps;
159
+
160
+ const config = await readCfg();
133
161
  if (!config?.token) {
134
162
  const err = new Error('not bound, nothing to unbind');
135
163
  err.code = 'NOT_BOUND';
@@ -138,25 +166,18 @@ export async function unbindBot({ serverUrl }) {
138
166
 
139
167
  const baseUrl = serverUrl ?? config.serverUrl;
140
168
 
141
- // 用户主动解绑:无论 server 通知成功与否,都清理本地绑定
142
- let data = null;
143
- let serverError = null;
144
169
  if (baseUrl) {
145
170
  try {
146
- data = await unbindWithServer({
147
- baseUrl,
148
- token: config.token,
149
- });
150
- }
151
- catch (err) {
152
- serverError = err;
171
+ await unbindServer({ baseUrl, token: config.token });
172
+ } catch (err) {
173
+ // bot 在 server 已不存在 — 视为解绑成功,继续清理本地
174
+ if (!isAlreadyUnbound(err)) {
175
+ throw err;
176
+ }
153
177
  }
154
178
  }
155
179
 
156
- await clearConfig();
180
+ await clearCfg();
157
181
 
158
- return {
159
- botId: data?.botId ?? config.botId,
160
- serverError,
161
- };
182
+ return { botId: config.botId };
162
183
  }
@@ -3,7 +3,7 @@ const ERROR_TEXT_MAP = {
3
3
  BINDING_CODE_INVALID: 'Binding code is invalid',
4
4
  BINDING_CODE_EXPIRED: 'Binding code has expired, please get a new one',
5
5
  BINDING_CODE_EXHAUSTED: 'Server cannot generate binding code right now, please try again later',
6
- BOT_BLOCKED: 'Bot is blocked, please contact the admin',
6
+ BOT_BLOCKED: 'Claw is blocked, please contact the admin',
7
7
  UNAUTHORIZED: 'Auth failed, please check token or re-bind',
8
8
  INTERNAL_SERVER_ERROR: 'Server error, please try again later',
9
9
  };
@@ -3,17 +3,14 @@
3
3
  export function bindOk({ botId, rebound, previousBotId }) {
4
4
  const action = rebound ? 're-bound' : 'bound';
5
5
  const prev = previousBotId
6
- ? ` (previous binding to bot ${previousBotId} was auto-removed)`
6
+ ? ` (previous Claw ${previousBotId} was auto-unbound)`
7
7
  : '';
8
- return `OK. Bot (${botId}) ${action} to CoClaw.${prev}`;
8
+ return `OK. Claw (${botId}) ${action} to CoClaw.${prev}`;
9
9
  }
10
10
 
11
- export function unbindOk({ botId, serverError }) {
11
+ export function unbindOk({ botId }) {
12
12
  const id = botId ?? 'unknown';
13
- const tag = serverError
14
- ? ' (server notification failed; you can unbind the orphan bot in the CoClaw app)'
15
- : '';
16
- return `OK. Bot (${id}) unbound from CoClaw.${tag}`;
13
+ return `OK. Claw (${id}) unbound from CoClaw.`;
17
14
  }
18
15
 
19
16
  export function notBound() {
package/src/config.js CHANGED
@@ -3,6 +3,8 @@ import os from 'node:os';
3
3
  import nodePath from 'node:path';
4
4
 
5
5
  import { getRuntime } from './runtime.js';
6
+ import { atomicWriteJsonFile } from './utils/atomic-write.js';
7
+ import { createMutex } from './utils/mutex.js';
6
8
 
7
9
  export const DEFAULT_ACCOUNT_ID = 'default';
8
10
  const CHANNEL_ID = 'coclaw';
@@ -42,11 +44,7 @@ async function readJson(filePath) {
42
44
  }
43
45
  }
44
46
 
45
- async function writeJson(filePath, value) {
46
- const dirPath = nodePath.dirname(filePath);
47
- await fs.mkdir(dirPath, { recursive: true });
48
- await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
49
- }
47
+ const bindingsMutex = createMutex();
50
48
 
51
49
  // --- 公共 API ---
52
50
 
@@ -57,35 +55,39 @@ export async function readConfig(accountId = DEFAULT_ACCOUNT_ID) {
57
55
  }
58
56
 
59
57
  export async function writeConfig(nextConfig, accountId = DEFAULT_ACCOUNT_ID) {
60
- const bindingsPath = getBindingsPath();
61
- const bindings = toRecord(await readJson(bindingsPath));
62
- const current = toRecord(bindings[accountId]);
58
+ return bindingsMutex.withLock(async () => {
59
+ const bindingsPath = getBindingsPath();
60
+ const bindings = toRecord(await readJson(bindingsPath));
61
+ const current = toRecord(bindings[accountId]);
63
62
 
64
- const next = { ...current };
65
- if (nextConfig.serverUrl !== undefined) next.serverUrl = nextConfig.serverUrl;
66
- if (nextConfig.botId !== undefined) next.botId = nextConfig.botId;
67
- if (nextConfig.token !== undefined) next.token = nextConfig.token;
68
- if (nextConfig.boundAt !== undefined) next.boundAt = nextConfig.boundAt;
63
+ const next = { ...current };
64
+ if (nextConfig.serverUrl !== undefined) next.serverUrl = nextConfig.serverUrl;
65
+ if (nextConfig.botId !== undefined) next.botId = nextConfig.botId;
66
+ if (nextConfig.token !== undefined) next.token = nextConfig.token;
67
+ if (nextConfig.boundAt !== undefined) next.boundAt = nextConfig.boundAt;
69
68
 
70
- bindings[accountId] = next;
71
- await writeJson(bindingsPath, bindings);
69
+ bindings[accountId] = next;
70
+ await atomicWriteJsonFile(bindingsPath, bindings);
71
+ });
72
72
  }
73
73
 
74
74
  export async function clearConfig(accountId = DEFAULT_ACCOUNT_ID) {
75
- const bindingsPath = getBindingsPath();
76
- const bindings = toRecord(await readJson(bindingsPath));
77
- delete bindings[accountId];
75
+ return bindingsMutex.withLock(async () => {
76
+ const bindingsPath = getBindingsPath();
77
+ const bindings = toRecord(await readJson(bindingsPath));
78
+ delete bindings[accountId];
78
79
 
79
- const remaining = Object.keys(bindings).length;
80
- if (remaining === 0) {
81
- try {
82
- await fs.unlink(bindingsPath);
80
+ const remaining = Object.keys(bindings).length;
81
+ if (remaining === 0) {
82
+ try {
83
+ await fs.unlink(bindingsPath);
84
+ }
85
+ /* c8 ignore next 4 */
86
+ catch (err) {
87
+ if (err?.code !== 'ENOENT') throw err;
88
+ }
89
+ } else {
90
+ await atomicWriteJsonFile(bindingsPath, bindings);
83
91
  }
84
- /* c8 ignore next 4 */
85
- catch (err) {
86
- if (err?.code !== 'ENOENT') throw err;
87
- }
88
- } else {
89
- await writeJson(bindingsPath, bindings);
90
- }
92
+ });
91
93
  }
@@ -163,11 +163,15 @@ export class RealtimeBridge {
163
163
  ?? DEFAULT_GATEWAY_WS_URL;
164
164
  }
165
165
 
166
- async __clearTokenLocal() {
166
+ async __clearTokenLocal(unboundBotId) {
167
167
  const cfg = await this.__readConfig();
168
168
  if (!cfg?.token) {
169
169
  return;
170
170
  }
171
+ // 只清除匹配的 bot,避免新绑定被误清
172
+ if (unboundBotId && cfg.botId && cfg.botId !== unboundBotId) {
173
+ return;
174
+ }
171
175
  await this.__clearConfig();
172
176
  }
173
177
 
@@ -518,6 +522,11 @@ export class RealtimeBridge {
518
522
  this.gatewayWs = null;
519
523
  this.gatewayReady = false;
520
524
  this.gatewayConnectReqId = null;
525
+ /* c8 ignore next 3 -- gateway 意外断开时结算未完成 RPC,避免等超时 */
526
+ for (const [, settle] of this.gatewayPendingRequests) {
527
+ settle({ ok: false, error: 'gateway_closed' });
528
+ }
529
+ this.gatewayPendingRequests.clear();
521
530
  });
522
531
  ws.addEventListener('error', () => {});
523
532
  }
@@ -681,7 +690,7 @@ export class RealtimeBridge {
681
690
  try {
682
691
  const payload = JSON.parse(String(event.data ?? '{}'));
683
692
  if (payload?.type === 'bot.unbound') {
684
- await this.__clearTokenLocal();
693
+ await this.__clearTokenLocal(payload.botId);
685
694
  try { sock.close(4001, 'bot_unbound'); }
686
695
  /* c8 ignore next */
687
696
  catch {}
@@ -713,7 +722,13 @@ export class RealtimeBridge {
713
722
  this.__closeGatewayWs();
714
723
 
715
724
  if (event?.code === 4001 || event?.code === 4003) {
716
- await this.__clearTokenLocal();
725
+ try {
726
+ await this.__clearTokenLocal();
727
+ }
728
+ /* c8 ignore next 3 -- 防御性兜底,磁盘 I/O 异常时不可崩溃 gateway */
729
+ catch (e) {
730
+ this.logger.error?.('[coclaw] clearTokenLocal failed on auth-close', e);
731
+ }
717
732
  return;
718
733
  }
719
734