@coclaw/openclaw-coclaw 0.18.0 → 0.19.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/index.js CHANGED
@@ -14,6 +14,7 @@ import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
14
14
  import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
15
15
  import { createFileHandler } from './src/file-manager/handler.js';
16
16
  import { abortAgentRun } from './src/agent-abort.js';
17
+ import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
17
18
  import { remoteLog } from './src/remote-log.js';
18
19
 
19
20
  import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
@@ -200,9 +201,27 @@ const plugin = {
200
201
  },
201
202
  });
202
203
 
204
+ // enroll 并发控制:同一时刻只允许一个活跃 enroll。
205
+ // 声明前置以便 doBind/doUnbind 入口可调用 cancelActiveEnroll;
206
+ // .finally 仍在 enroll 自身的回调里把 activeEnrollAbort 置 null。
207
+ let activeEnrollAbort = null;
208
+
209
+ function cancelActiveEnroll() {
210
+ if (activeEnrollAbort) {
211
+ logger.info?.('[coclaw] cancelling active enroll');
212
+ activeEnrollAbort.abort();
213
+ // 立即清 ref:避免后续 cancelActiveEnroll 对同一已 abort 的 controller 重复 log;
214
+ // 原 enroll 自己的 .finally 仍负责本身分支的兜底清理(按 ref 相等判断)
215
+ activeEnrollAbort = null;
216
+ }
217
+ }
218
+
203
219
  // --- bind/unbind 共享逻辑(RPC handler + 斜杠命令共用) ---
204
220
 
205
221
  async function doBind({ code, serverUrl }) {
222
+ // 显式 bind 必须取消进行中的 enroll:否则 enroll 后到的 token 走 partial-failure
223
+ // rollback 路径前可能仍写入旧 config,污染刚 bind 完的本地状态
224
+ cancelActiveEnroll();
206
225
  await stopRealtimeBridge();
207
226
  let result;
208
227
  try {
@@ -223,6 +242,8 @@ const plugin = {
223
242
  }
224
243
 
225
244
  async function doUnbind({ serverUrl }) {
245
+ // unbind 同样取消进行中的 enroll,避免 server 解绑后 enroll token 又写回本地
246
+ cancelActiveEnroll();
226
247
  const result = await unbindClaw({
227
248
  serverUrl: serverUrl ?? api.pluginConfig?.serverUrl,
228
249
  });
@@ -233,8 +254,13 @@ const plugin = {
233
254
  api.registerGatewayMethod('coclaw.bind', async ({ params, respond }) => {
234
255
  try {
235
256
  const code = params?.code;
236
- if (!code) {
237
- respondInvalid(respond, 'code is required');
257
+ if (typeof code !== 'string' || code.length === 0) {
258
+ respondInvalid(respond, 'code must be a non-empty string');
259
+ return;
260
+ }
261
+ if (params?.serverUrl !== undefined
262
+ && (typeof params.serverUrl !== 'string' || params.serverUrl.trim().length === 0)) {
263
+ respondInvalid(respond, 'serverUrl must be a non-empty string');
238
264
  return;
239
265
  }
240
266
  const result = await doBind({
@@ -256,6 +282,11 @@ const plugin = {
256
282
 
257
283
  api.registerGatewayMethod('coclaw.unbind', async ({ params, respond }) => {
258
284
  try {
285
+ if (params?.serverUrl !== undefined
286
+ && (typeof params.serverUrl !== 'string' || params.serverUrl.trim().length === 0)) {
287
+ respondInvalid(respond, 'serverUrl must be a non-empty string');
288
+ return;
289
+ }
259
290
  const result = await doUnbind({ serverUrl: params?.serverUrl });
260
291
  respond(true, { status: { clawId: result.clawId } });
261
292
  }
@@ -264,15 +295,15 @@ const plugin = {
264
295
  }
265
296
  });
266
297
 
267
- // enroll 并发控制:同一时刻只允许一个活跃 enroll
268
- let activeEnrollAbort = null;
269
-
270
298
  api.registerGatewayMethod('coclaw.enroll', async ({ params, respond }) => {
271
299
  try {
272
- // 取消前一个 enroll
273
- if (activeEnrollAbort) {
274
- activeEnrollAbort.abort();
300
+ if (params?.serverUrl !== undefined
301
+ && (typeof params.serverUrl !== 'string' || params.serverUrl.trim().length === 0)) {
302
+ respondInvalid(respond, 'serverUrl must be a non-empty string');
303
+ return;
275
304
  }
305
+ // 取消前一个 enroll(与 doBind/doUnbind 共享 helper)
306
+ cancelActiveEnroll();
276
307
  const abortController = new AbortController();
277
308
  activeEnrollAbort = abortController;
278
309
 
@@ -333,6 +364,11 @@ const plugin = {
333
364
 
334
365
  api.registerGatewayMethod('nativeui.sessions.get', ({ params, respond }) => {
335
366
  try {
367
+ const sessionId = params?.sessionId;
368
+ if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
369
+ respondInvalid(respond, 'sessionId required');
370
+ return;
371
+ }
336
372
  respond(true, manager.get(params ?? {}));
337
373
  }
338
374
  catch (err) {
@@ -463,12 +499,14 @@ const plugin = {
463
499
  respondInvalid(respond, 'No valid change field provided (supported: title)');
464
500
  return;
465
501
  }
466
- await topicManager.updateTitle({ topicId, title: changes.title });
467
- const { topic } = topicManager.get({ topicId });
468
- if (!topic) {
469
- respondInvalid(respond, `Topic not found: ${topicId}`);
502
+ // 先检查 topic 是否存在:避免 updateTitle 内部 throw 后被 respondError 错报为 INTERNAL_ERROR
503
+ const existing = topicManager.get({ topicId })?.topic;
504
+ if (!existing) {
505
+ respond(false, undefined, { code: 'NOT_FOUND', message: `Topic not found: ${topicId}` });
470
506
  return;
471
507
  }
508
+ await topicManager.updateTitle({ topicId, title: changes.title });
509
+ const { topic } = topicManager.get({ topicId });
472
510
  respond(true, { topic });
473
511
  }
474
512
  catch (err) {
@@ -550,6 +588,8 @@ const plugin = {
550
588
  // 取消正在执行的 embedded agent run(通过 OpenClaw 全局 symbol 侧门)
551
589
  // 侧门不存在 / sessionId 未注册 / handle.abort 抛异常时返回 { ok:false, reason } —— UI 静默降级
552
590
  // UI 可能在 OpenClaw 注册 sessionId 前点 STOP(注册空窗期),此时返回 not-found;UI 会按 500ms 间隔重试。
591
+ // UI 自 v0.20 起额外透传 runDuration / abortDuration(墙钟差,毫秒)供启发判定:
592
+ // 侧门返 not-found 但双闸都达阈值时升格为 gone,告知 UI 主动收尾。旧 UI 不传时退化为透传 not-found。
553
593
  api.registerGatewayMethod('coclaw.agent.abort', ({ params, respond }) => {
554
594
  try {
555
595
  const sessionId = params?.sessionId;
@@ -558,7 +598,10 @@ const plugin = {
558
598
  respondInvalid(respond, 'sessionId is required');
559
599
  return;
560
600
  }
561
- const result = abortAgentRun(sessionId);
601
+ const abortResult = abortAgentRun(sessionId);
602
+ const runDuration = typeof params?.runDuration === 'number' ? params.runDuration : undefined;
603
+ const abortDuration = typeof params?.abortDuration === 'number' ? params.abortDuration : undefined;
604
+ const result = decideCancelResponse(abortResult, { runDuration, abortDuration });
562
605
  // not-found 是 UI 重试期常态(注册空窗),不打日志避免噪音;其余分支保留 info
563
606
  if (result.reason !== 'not-found') {
564
607
  logger.info?.(`[coclaw.agent.abort] result sessionId=${sessionId} ok=${result.ok}${result.reason ? ` reason=${result.reason}` : ''}${result.error ? ` error=${result.error}` : ''}`);
@@ -570,6 +613,10 @@ const plugin = {
570
613
  // 侧门缺失或 handle shape 变化:OpenClaw 升级契约变更的早期信号
571
614
  remoteLog(`abort.not-supported sid=${sessionId}`);
572
615
  }
616
+ else if (result.reason === 'gone') {
617
+ // 启发升格:双闸均达阈值,把 not-found 升格为 gone,让 UI 主动 settleByCancel
618
+ remoteLog(`abort.gone sid=${sessionId} runDur=${runDuration} abortDur=${abortDuration}`);
619
+ }
573
620
  respond(true, result);
574
621
  }
575
622
  catch (err) {
@@ -663,10 +710,8 @@ const plugin = {
663
710
  }
664
711
 
665
712
  if (action === 'enroll') {
666
- // 并发控制:取消前一个 enroll(与 RPC 路径共享)
667
- if (activeEnrollAbort) {
668
- activeEnrollAbort.abort();
669
- }
713
+ // 并发控制:取消前一个 enroll(与 RPC 路径共享 helper)
714
+ cancelActiveEnroll();
670
715
  const abortController = new AbortController();
671
716
  activeEnrollAbort = abortController;
672
717
 
@@ -2,6 +2,9 @@
2
2
  "id": "openclaw-coclaw",
3
3
  "name": "CoClaw",
4
4
  "description": "OpenClaw CoClaw channel plugin for remote chat",
5
+ "activation": {
6
+ "onStartup": true
7
+ },
5
8
  "configSchema": {
6
9
  "type": "object",
7
10
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.18.0",
3
+ "version": "0.19.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * agent-cancel-heuristic:取消请求的启发式判定
3
+ *
4
+ * OpenClaw 侧门 abort 返回 not-found 时,单凭这个信号无法区分两种状态:
5
+ * 1. 注册空窗:UI 已发 send,但 OpenClaw 还没把 run 注册到 activeRuns
6
+ * → UI 应继续 tick 重试,等注册完成
7
+ * 2. run 已实际结束/丢失但终态信号未送达 UI(compaction-retry 边界、
8
+ * 上游 lifecycle:end 漏发、网络丢包等)
9
+ * → UI 应主动收尾,避免无限 tick
10
+ *
11
+ * 用 UI 透传的两个墙钟时长做"双闸"启发:
12
+ * - runDuration ≥ 3min:从 onAccepted 到现在;正常 run 不会这么久仍在跑
13
+ * - abortDuration ≥ 1min:从首次 STOP 到现在;正常 run 在 1min 内能响应取消
14
+ * 双闸都满足才升格为 'gone',告知 UI 主动 settleByCancel + 提示用户。
15
+ *
16
+ * 阈值偏保守,宁可让 UI 多 tick 几次也不误升格。
17
+ *
18
+ * 兼容旧 UI:旧 UI 不传 runDuration/abortDuration,ctx 字段为 undefined,
19
+ * 双闸永远不命中,行为退化为原样透传 not-found(与无启发时一致)。
20
+ */
21
+
22
+ export const RUN_DURATION_GONE_THRESHOLD_MS = 3 * 60 * 1000;
23
+ export const ABORT_DURATION_GONE_THRESHOLD_MS = 60 * 1000;
24
+
25
+ /**
26
+ * 根据侧门 abort 结果 + UI 上下文决定最终响应
27
+ * @param {object} abortResult - abortAgentRun 的返回值
28
+ * @param {object} [ctx] - { runDuration, abortDuration }(毫秒),旧 UI 不传时为 undefined
29
+ * @returns {object} 透传或升格后的响应(保持 abortResult 同形 shape)
30
+ */
31
+ export function decideCancelResponse(abortResult, ctx) {
32
+ // ok 与非 not-found 原因(not-supported / abort-threw 等)原样透传
33
+ if (abortResult.ok) return abortResult;
34
+ if (abortResult.reason !== 'not-found') return abortResult;
35
+
36
+ const runDur = ctx?.runDuration;
37
+ const abortDur = ctx?.abortDuration;
38
+ const runHit = typeof runDur === 'number' && Number.isFinite(runDur) && runDur >= RUN_DURATION_GONE_THRESHOLD_MS;
39
+ const abortHit = typeof abortDur === 'number' && Number.isFinite(abortDur) && abortDur >= ABORT_DURATION_GONE_THRESHOLD_MS;
40
+ if (runHit && abortHit) return { ok: false, reason: 'gone' };
41
+ return abortResult;
42
+ }
@@ -12,6 +12,7 @@ import nodePath from 'node:path';
12
12
  import os from 'node:os';
13
13
 
14
14
  import { getRuntime } from '../runtime.js';
15
+ import { atomicWriteFile } from '../utils/atomic-write.js';
15
16
 
16
17
  const CHANNEL_ID = 'coclaw';
17
18
  const STATE_FILENAME = 'upgrade-state.json';
@@ -61,8 +62,7 @@ export async function readState() {
61
62
  */
62
63
  export async function writeState(state) {
63
64
  const filePath = getStatePath();
64
- await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
65
- await fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
65
+ await atomicWriteFile(filePath, `${JSON.stringify(state, null, 2)}\n`);
66
66
  }
67
67
 
68
68
  /**
@@ -119,7 +119,8 @@ async function trimLog(filePath) {
119
119
  const lines = content.split('\n').filter(Boolean);
120
120
  if (lines.length <= LOG_MAX_LINES) return;
121
121
  const kept = lines.slice(-LOG_KEEP_LINES);
122
- await fs.writeFile(filePath, `${kept.join('\n')}\n`, 'utf8');
122
+ // 整文件覆写走 atomic:truncate-then-write 中途崩溃会清空整个 log
123
+ await atomicWriteFile(filePath, `${kept.join('\n')}\n`);
123
124
  }
124
125
  catch {
125
126
  // 截断失败不影响主流程
@@ -6,6 +6,7 @@ import { spawnUpgradeWorker } from './updater-spawn.js';
6
6
  import { readState, resolveStateDir, writeState } from './state.js';
7
7
  import { getRuntime } from '../runtime.js';
8
8
  import { remoteLog } from '../remote-log.js';
9
+ import { atomicWriteFile } from '../utils/atomic-write.js';
9
10
 
10
11
  // 首次检查延迟较长:失败时由 worker 触发 gateway restart,scheduler 重启后会重新计时;
11
12
  // 60 分钟基线(实际随机 60-120 分钟)能把"失败→重启→再次检查"的循环周期拉长,
@@ -102,11 +103,9 @@ export async function isUpgradeLocked(opts) {
102
103
  */
103
104
  export async function writeUpgradeLock(pid) {
104
105
  const lockPath = getLockPath();
105
- await fs.mkdir(nodePath.dirname(lockPath), { recursive: true });
106
- await fs.writeFile(
106
+ await atomicWriteFile(
107
107
  lockPath,
108
108
  `${JSON.stringify({ pid, ts: new Date().toISOString() })}\n`,
109
- 'utf8',
110
109
  );
111
110
  }
112
111
 
@@ -63,12 +63,25 @@ export async function bindClaw({ code, serverUrl }, deps = {}) {
63
63
  throw new Error('invalid bind response');
64
64
  }
65
65
 
66
- await writeCfg({
67
- serverUrl: baseUrl,
68
- clawId: data.clawId,
69
- token: data.token,
70
- boundAt: new Date().toISOString(),
71
- });
66
+ try {
67
+ await writeCfg({
68
+ serverUrl: baseUrl,
69
+ clawId: data.clawId,
70
+ token: data.token,
71
+ boundAt: new Date().toISOString(),
72
+ });
73
+ }
74
+ catch (writeErr) {
75
+ // 本地 writeCfg 失败 → 回滚 server 端,避免产生孤儿 claw(与 unbind 强制不容错的红线对称)
76
+ await unbindServer({ baseUrl, token: data.token }).catch(() => {
77
+ // 回滚失败不掩盖原因;用户根据原始 writeErr.message 排查,
78
+ // server 端孤儿可通过下次 enroll/bind 时 401/404/410 再清理
79
+ });
80
+ const wrapped = new Error(`bind succeeded on server but local write failed: ${writeErr.message}`);
81
+ wrapped.code = 'BIND_LOCAL_WRITE_FAILED';
82
+ wrapped.cause = writeErr;
83
+ throw wrapped;
84
+ }
72
85
 
73
86
  return {
74
87
  clawId: data.clawId,
@@ -105,7 +118,12 @@ export async function enrollClaw({ serverUrl }, deps = {}) {
105
118
  }
106
119
 
107
120
  export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }, deps = {}) {
108
- const { waitClaimCode = waitClaimCodeOnServer, writeCfg = writeConfig, retryDelayMs = 2000 } = deps;
121
+ const {
122
+ waitClaimCode = waitClaimCodeOnServer,
123
+ writeCfg = writeConfig,
124
+ unbindServer = unbindWithServer,
125
+ retryDelayMs = 2000,
126
+ } = deps;
109
127
  const baseUrl = resolveServerUrl(serverUrl);
110
128
 
111
129
  // 循环长轮询,直到成功或超时
@@ -122,7 +140,12 @@ export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }
122
140
  if (err?.response?.status === 404) {
123
141
  throw new Error('claim code not found or expired');
124
142
  }
125
- // 其他所有错误(HTTP 408/500、网络超时、TimeoutError 等)延迟后重试,
143
+ // server 408 + CLAIM_TIMEOUT body 表示 claim 在 server 端已过期,与 404 同列终态;
144
+ // 没有 CLAIM_TIMEOUT body 的 408(极少见)保持瞬态错误重试
145
+ if (err?.response?.status === 408 && err.response?.data?.code === 'CLAIM_TIMEOUT') {
146
+ throw new Error('claim code expired');
147
+ }
148
+ // 其他所有错误(HTTP 500、网络超时、TimeoutError 等)延迟后重试,
126
149
  // 确保后台等待不会因瞬时故障而终止
127
150
  await new Promise((r) => setTimeout(r, retryDelayMs));
128
151
  continue;
@@ -130,12 +153,28 @@ export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }
130
153
 
131
154
  // 已认领
132
155
  if (data?.clawId && data?.token) {
133
- await writeCfg({
134
- serverUrl: baseUrl,
135
- clawId: data.clawId,
136
- token: data.token,
137
- boundAt: new Date().toISOString(),
138
- });
156
+ // long-poll 期间 abort 到达 → 此时 server 已发 token,与 D bug D-3 #4 同模式:
157
+ // 不能落盘旧 token(会污染新 enroll/bind 的本地状态),同时回滚 server 端避免孤儿
158
+ if (signal?.aborted) {
159
+ await unbindServer({ baseUrl, token: data.token }).catch(() => {});
160
+ throw new Error('enroll cancelled');
161
+ }
162
+ try {
163
+ await writeCfg({
164
+ serverUrl: baseUrl,
165
+ clawId: data.clawId,
166
+ token: data.token,
167
+ boundAt: new Date().toISOString(),
168
+ });
169
+ }
170
+ catch (writeErr) {
171
+ // 与 bindClaw 对称:本地写失败 → 回滚 server 端,避免孤儿 claw
172
+ await unbindServer({ baseUrl, token: data.token }).catch(() => {});
173
+ const wrapped = new Error(`enroll succeeded on server but local write failed: ${writeErr.message}`);
174
+ wrapped.code = 'BIND_LOCAL_WRITE_FAILED';
175
+ wrapped.cause = writeErr;
176
+ throw wrapped;
177
+ }
139
178
  return { clawId: data.clawId };
140
179
  }
141
180
 
@@ -4,6 +4,7 @@ import os from 'node:os';
4
4
  import nodePath from 'node:path';
5
5
 
6
6
  import { getRuntime } from './runtime.js';
7
+ import { atomicWriteFileSync } from './utils/atomic-write.js';
7
8
 
8
9
  const CHANNEL_ID = 'coclaw';
9
10
  const IDENTITY_FILENAME = 'device-identity.json';
@@ -107,9 +108,7 @@ export function loadOrCreateDeviceIdentity(filePath) {
107
108
  const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
108
109
  if (derivedId && derivedId !== parsed.deviceId) {
109
110
  const updated = { ...parsed, deviceId: derivedId };
110
- fs.writeFileSync(fp, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
111
- /* c8 ignore next -- best-effort chmod */
112
- try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
111
+ atomicWriteFileSync(fp, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
113
112
  return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
114
113
  }
115
114
  return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
@@ -123,7 +122,6 @@ export function loadOrCreateDeviceIdentity(filePath) {
123
122
  }
124
123
 
125
124
  const identity = generateIdentity();
126
- fs.mkdirSync(nodePath.dirname(fp), { recursive: true });
127
125
  const stored = {
128
126
  version: 1,
129
127
  deviceId: identity.deviceId,
@@ -131,9 +129,7 @@ export function loadOrCreateDeviceIdentity(filePath) {
131
129
  privateKeyPem: identity.privateKeyPem,
132
130
  createdAtMs: Date.now(),
133
131
  };
134
- fs.writeFileSync(fp, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
135
- /* c8 ignore next -- best-effort chmod */
136
- try { fs.chmodSync(fp, 0o600); } catch { /* best-effort */ }
132
+ atomicWriteFileSync(fp, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
137
133
  return identity;
138
134
  }
139
135
 
@@ -216,7 +216,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
216
216
  throw e;
217
217
  }
218
218
  if (stat.isDirectory()) {
219
- if (params?.force) {
219
+ if (params?.force === true) {
220
220
  await _rm(resolved, { recursive: true, force: true });
221
221
  } else {
222
222
  try {