@coclaw/openclaw-coclaw 0.8.2 → 0.9.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.8.2",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
package/src/config.js CHANGED
@@ -29,19 +29,24 @@ function toRecord(value) {
29
29
  }
30
30
 
31
31
  async function readJson(filePath) {
32
+ let raw;
32
33
  try {
33
- const raw = await fs.readFile(filePath, 'utf8');
34
- if (!String(raw).trim()) {
35
- return {};
36
- }
37
- return JSON.parse(raw);
34
+ raw = await fs.readFile(filePath, 'utf8');
38
35
  }
39
36
  catch (err) {
40
- if (err?.code === 'ENOENT') {
41
- return {};
42
- }
37
+ if (err?.code === 'ENOENT') return {};
38
+ /* c8 ignore next 2 */
43
39
  throw err;
44
40
  }
41
+ if (!String(raw).trim()) return {};
42
+ try {
43
+ return JSON.parse(raw);
44
+ }
45
+ catch {
46
+ // 文件损坏,删除后当空文件处理
47
+ await fs.unlink(filePath).catch(() => {});
48
+ return {};
49
+ }
45
50
  }
46
51
 
47
52
  const bindingsMutex = createMutex();
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import fsp from 'node:fs/promises';
3
3
  import nodePath from 'node:path';
4
- import { randomUUID } from 'node:crypto';
4
+ import { randomUUID, randomBytes } from 'node:crypto';
5
5
 
6
6
  // --- 常量 ---
7
7
 
@@ -93,6 +93,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
93
93
  const _readdir = deps.readdir ?? fsp.readdir;
94
94
  const _unlink = deps.unlink ?? fsp.unlink;
95
95
  const _rmdir = deps.rmdir ?? fsp.rmdir;
96
+ const _rm = deps.rm ?? fsp.rm;
96
97
  const _stat = deps.stat ?? fsp.stat;
97
98
  const _mkdir = deps.mkdir ?? fsp.mkdir;
98
99
  const _rename = deps.rename ?? fsp.rename;
@@ -102,22 +103,28 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
102
103
 
103
104
  const pathDeps = { lstat: _lstat, realpath: _realpath };
104
105
 
105
- // --- RPC 处理(rpc DC 上的 coclaw.file.list / coclaw.file.delete) ---
106
+ // --- RPC 处理(rpc DC 上的 coclaw.files.* 方法) ---
106
107
 
107
108
  /**
108
- * 处理 rpc DC 上的 coclaw.file.* 请求
109
+ * 处理 rpc DC 上的 coclaw.files.* 请求
109
110
  * @param {object} payload - { id, method, params }
110
111
  * @param {function} sendFn - (responseObj) => void
111
112
  */
112
113
  async function handleRpcRequest(payload, sendFn) {
113
114
  const { id, method, params } = payload;
114
115
  try {
115
- if (method === 'coclaw.file.list') {
116
+ if (method === 'coclaw.files.list') {
116
117
  const result = await listFiles(params);
117
118
  sendFn({ type: 'res', id, ok: true, payload: result });
118
- } else if (method === 'coclaw.file.delete') {
119
+ } else if (method === 'coclaw.files.delete') {
119
120
  const result = await deleteFile(params);
120
121
  sendFn({ type: 'res', id, ok: true, payload: result });
122
+ } else if (method === 'coclaw.files.mkdir') {
123
+ const result = await mkdirOp(params);
124
+ sendFn({ type: 'res', id, ok: true, payload: result });
125
+ } else if (method === 'coclaw.files.create') {
126
+ const result = await createFile(params);
127
+ sendFn({ type: 'res', id, ok: true, payload: result });
121
128
  } else {
122
129
  sendFn({
123
130
  type: 'res', id, ok: false,
@@ -207,15 +214,19 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
207
214
  throw e;
208
215
  }
209
216
  if (stat.isDirectory()) {
210
- try {
211
- await _rmdir(resolved);
212
- } catch (e) {
213
- if (e.code === 'ENOTEMPTY') {
214
- const err = new Error(`Directory not empty: ${userPath}`);
215
- err.code = 'NOT_EMPTY';
216
- throw err;
217
+ if (params?.force) {
218
+ await _rm(resolved, { recursive: true, force: true });
219
+ } else {
220
+ try {
221
+ await _rmdir(resolved);
222
+ } catch (e) {
223
+ if (e.code === 'ENOTEMPTY') {
224
+ const err = new Error(`Directory not empty: ${userPath}`);
225
+ err.code = 'NOT_EMPTY';
226
+ throw err;
227
+ }
228
+ throw e;
217
229
  }
218
- throw e;
219
230
  }
220
231
  } else {
221
232
  await _unlink(resolved);
@@ -223,6 +234,79 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
223
234
  return {};
224
235
  }
225
236
 
237
+ async function mkdirOp(params) {
238
+ const agentId = params?.agentId?.trim?.() || 'main';
239
+ const userPath = params?.path;
240
+ if (!userPath) {
241
+ const err = new Error('path is required');
242
+ err.code = 'PATH_DENIED';
243
+ throw err;
244
+ }
245
+ const workspaceDir = await resolveWorkspace(agentId);
246
+ const resolved = await validatePath(workspaceDir, userPath, pathDeps);
247
+ await _mkdir(resolved, { recursive: true });
248
+ return {};
249
+ }
250
+
251
+ async function createFile(params) {
252
+ const agentId = params?.agentId?.trim?.() || 'main';
253
+ const userPath = params?.path;
254
+ if (!userPath) {
255
+ const err = new Error('path is required');
256
+ err.code = 'PATH_DENIED';
257
+ throw err;
258
+ }
259
+ const workspaceDir = await resolveWorkspace(agentId);
260
+ const resolved = await validatePath(workspaceDir, userPath, pathDeps);
261
+
262
+ // 检查文件是否已存在
263
+ try {
264
+ await _lstat(resolved);
265
+ // 没抛异常说明存在
266
+ const err = new Error(`File already exists: ${userPath}`);
267
+ err.code = 'ALREADY_EXISTS';
268
+ throw err;
269
+ } catch (e) {
270
+ if (e.code === 'ALREADY_EXISTS') throw e;
271
+ if (e.code !== 'ENOENT') throw e;
272
+ // ENOENT — 不存在,继续创建
273
+ }
274
+
275
+ // 确保父目录存在
276
+ await _mkdir(nodePath.dirname(resolved), { recursive: true });
277
+ await (deps.writeFile ?? fsp.writeFile)(resolved, '');
278
+ return {};
279
+ }
280
+
281
+ /**
282
+ * 生成唯一文件名:<name>-<4hex>.<ext>,碰撞时重试
283
+ * @param {string} dir - 目标目录绝对路径
284
+ * @param {string} fileName - 原始文件名
285
+ * @returns {Promise<string>} 唯一文件名(仅文件名,非完整路径)
286
+ */
287
+ async function generateUniqueName(dir, fileName) {
288
+ const ext = nodePath.extname(fileName);
289
+ const base = nodePath.basename(fileName, ext);
290
+ const maxAttempts = 20;
291
+ for (let i = 0; i < maxAttempts; i++) {
292
+ const hex = randomBytes(2).toString('hex');
293
+ const candidate = `${base}-${hex}${ext}`;
294
+ try {
295
+ await _lstat(nodePath.join(dir, candidate));
296
+ // 存在 → 碰撞,重试
297
+ } catch (e) {
298
+ if (e.code === 'ENOENT') return candidate;
299
+ /* c8 ignore next -- lstat 非 ENOENT 属罕见 IO 异常 */
300
+ throw e;
301
+ } /* c8 ignore next */
302
+ }
303
+ /* c8 ignore start -- 20 次均碰撞几乎不可能 */
304
+ const err = new Error(`Cannot generate unique name for: ${fileName}`);
305
+ err.code = 'WRITE_FAILED';
306
+ throw err;
307
+ /* c8 ignore stop */
308
+ }
309
+
226
310
  // --- File DataChannel 处理 ---
227
311
 
228
312
  /**
@@ -262,15 +346,20 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
262
346
  return;
263
347
  }
264
348
 
265
- if (req.method === 'read') {
266
- /* c8 ignore next 3 -- handleRead 内部已完整处理异常,此 catch 纯防御 */
267
- handleRead(dc, req).catch((err) => {
268
- log.warn?.(`[coclaw/file] read error: ${err.message}`);
349
+ if (req.method === 'GET') {
350
+ /* c8 ignore next 3 -- handleGet 内部已完整处理异常,此 catch 纯防御 */
351
+ handleGet(dc, req).catch((err) => {
352
+ log.warn?.(`[coclaw/file] GET error: ${err.message}`);
353
+ });
354
+ } else if (req.method === 'PUT') {
355
+ /* c8 ignore next 3 -- handlePut 内部已完整处理异常,此 catch 纯防御 */
356
+ handlePut(dc, req).catch((err) => {
357
+ log.warn?.(`[coclaw/file] PUT error: ${err.message}`);
269
358
  });
270
- } else if (req.method === 'write') {
271
- /* c8 ignore next 3 -- handleWrite 内部已完整处理异常,此 catch 纯防御 */
272
- handleWrite(dc, req).catch((err) => {
273
- log.warn?.(`[coclaw/file] write error: ${err.message}`);
359
+ } else if (req.method === 'POST') {
360
+ /* c8 ignore next 3 -- handlePost 内部已完整处理异常,此 catch 纯防御 */
361
+ handlePost(dc, req).catch((err) => {
362
+ log.warn?.(`[coclaw/file] POST error: ${err.message}`);
274
363
  });
275
364
  } else {
276
365
  sendError(dc, 'UNKNOWN_METHOD', `Unknown method: ${req.method}`);
@@ -278,7 +367,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
278
367
  };
279
368
  }
280
369
 
281
- async function handleRead(dc, req) {
370
+ async function handleGet(dc, req) {
282
371
  let workspaceDir, resolved;
283
372
  try {
284
373
  const agentId = req.agentId?.trim?.() || 'main'; /* c8 ignore next -- ?./?? fallback */
@@ -367,7 +456,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
367
456
  });
368
457
  }
369
458
 
370
- async function handleWrite(dc, req) {
459
+ async function handlePut(dc, req) {
371
460
  let workspaceDir, resolved;
372
461
  try {
373
462
  const agentId = req.agentId?.trim?.() || 'main';
@@ -377,7 +466,59 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
377
466
  sendError(dc, err.code ?? 'INTERNAL_ERROR', err.message);
378
467
  return;
379
468
  }
469
+ await receiveUpload(dc, req, resolved);
470
+ }
471
+
472
+ async function handlePost(dc, req) {
473
+ let workspaceDir, dirResolved;
474
+ try {
475
+ const agentId = req.agentId?.trim?.() || 'main';
476
+ workspaceDir = await resolveWorkspace(agentId);
477
+ dirResolved = await validatePath(workspaceDir, req.path || '.', pathDeps);
478
+ } catch (err) {
479
+ sendError(dc, err.code ?? 'INTERNAL_ERROR', err.message);
480
+ return;
481
+ }
380
482
 
483
+ const fileName = req.fileName;
484
+ if (!fileName || typeof fileName !== 'string') {
485
+ sendError(dc, 'INVALID_INPUT', 'fileName is required for POST');
486
+ return;
487
+ }
488
+
489
+ // 确保集合目录存在
490
+ try {
491
+ await _mkdir(dirResolved, { recursive: true });
492
+ } catch (err) {
493
+ sendError(dc, 'WRITE_FAILED', `Cannot create directory: ${err.message}`);
494
+ return;
495
+ }
496
+
497
+ // 生成唯一文件名
498
+ let uniqueName;
499
+ try {
500
+ uniqueName = await generateUniqueName(dirResolved, fileName);
501
+ /* c8 ignore start -- generateUniqueName 内部已处理,此为防御 */
502
+ } catch (err) {
503
+ sendError(dc, err.code ?? 'WRITE_FAILED', err.message);
504
+ return;
505
+ }
506
+ /* c8 ignore stop */
507
+
508
+ const resolved = nodePath.join(dirResolved, uniqueName);
509
+ // 计算相对于 workspace 的路径,作为响应中的 path
510
+ const relativePath = nodePath.relative(workspaceDir, resolved);
511
+ await receiveUpload(dc, req, resolved, relativePath);
512
+ }
513
+
514
+ /**
515
+ * 共享上传接收逻辑(PUT/POST 复用)
516
+ * @param {object} dc - DataChannel
517
+ * @param {object} req - 请求对象(含 size)
518
+ * @param {string} resolved - 目标文件绝对路径
519
+ * @param {string} [relativePath] - POST 时附带的相对路径(响应中返回)
520
+ */
521
+ async function receiveUpload(dc, req, resolved, relativePath) {
381
522
  const declaredSize = req.size;
382
523
  if (!Number.isFinite(declaredSize) || declaredSize < 0) {
383
524
  sendError(dc, 'INVALID_INPUT', 'size must be a non-negative number');
@@ -388,7 +529,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
388
529
  return;
389
530
  }
390
531
 
391
- // 确保目标目录存在
532
+ // 确保目标目录存在(PUT 场景需要;POST 已在上层创建,幂等无害)
392
533
  const targetDir = nodePath.dirname(resolved);
393
534
  try {
394
535
  await _mkdir(targetDir, { recursive: true });
@@ -460,8 +601,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
460
601
  try { dc.close(); } catch { /* ignore */ }
461
602
  return;
462
603
  }
604
+ const result = { ok: true, bytes: receivedBytes };
605
+ if (relativePath) result.path = relativePath;
463
606
  try {
464
- dc.send(JSON.stringify({ ok: true, bytes: receivedBytes }));
607
+ dc.send(JSON.stringify(result));
465
608
  /* c8 ignore next */
466
609
  } catch { /* ignore */ }
467
610
  /* c8 ignore next */
@@ -580,8 +723,12 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
580
723
  // 暴露内部方法便于测试
581
724
  __listFiles: listFiles,
582
725
  __deleteFile: deleteFile,
583
- __handleRead: handleRead,
584
- __handleWrite: handleWrite,
726
+ __mkdirOp: mkdirOp,
727
+ __createFile: createFile,
728
+ __handleGet: handleGet,
729
+ __handlePut: handlePut,
730
+ __handlePost: handlePost,
731
+ __generateUniqueName: generateUniqueName,
585
732
  __cleanupTmpFilesInDir: cleanupTmpFilesInDir,
586
733
  };
587
734
  }
@@ -10,7 +10,7 @@ export class WebRtcPeer {
10
10
  * @param {object} opts
11
11
  * @param {function} opts.onSend - 将信令消息交给 RealtimeBridge 发送
12
12
  * @param {function} [opts.onRequest] - DataChannel 收到 req 消息时的回调 (payload, connId) => void
13
- * @param {function} [opts.onFileRpc] - rpc DC 上 coclaw.file.* 请求的回调 (payload, sendFn, connId) => void
13
+ * @param {function} [opts.onFileRpc] - rpc DC 上 coclaw.files.* 请求的回调 (payload, sendFn, connId) => void
14
14
  * @param {function} [opts.onFileChannel] - file:<transferId> DataChannel 的回调 (dc, connId) => void
15
15
  * @param {object} [opts.logger] - pino 风格 logger
16
16
  * @param {function} [opts.PeerConnection] - 可替换的构造函数(测试用)
@@ -218,8 +218,8 @@ export class WebRtcPeer {
218
218
  const reassembler = createReassembler((jsonStr) => {
219
219
  const payload = JSON.parse(jsonStr);
220
220
  if (payload.type === 'req') {
221
- // coclaw.file.* 方法本地处理,不转发 gateway
222
- if (payload.method?.startsWith('coclaw.file.') && this.__onFileRpc) {
221
+ // coclaw.files.* 方法本地处理,不转发 gateway
222
+ if (payload.method?.startsWith('coclaw.files.') && this.__onFileRpc) {
223
223
  const session = this.__sessions.get(connId);
224
224
  const sendFn = (response) => {
225
225
  try {