@coclaw/openclaw-coclaw 0.7.1 → 0.8.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.7.1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -53,7 +53,7 @@
53
53
  "test:plugin": "node --test src/plugin-mode.test.js",
54
54
  "test": "node --test",
55
55
  "coverage": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov node --test",
56
- "verify": "pnpm check && pnpm test:plugin && pnpm test && pnpm coverage",
56
+ "verify": "pnpm check && pnpm coverage",
57
57
  "link": "bash ./scripts/link.sh",
58
58
  "unlink": "bash ./scripts/unlink.sh",
59
59
  "install:npm": "bash ./scripts/install-npm.sh",
@@ -0,0 +1,587 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import nodePath from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ // --- 常量 ---
7
+
8
+ const CHUNK_SIZE = 16_384; // 16KB
9
+ const HIGH_WATER_MARK = 262_144; // 256KB
10
+ const LOW_WATER_MARK = 65_536; // 64KB
11
+ const MAX_UPLOAD_SIZE = 1_073_741_824; // 1GB
12
+ const FILE_DC_TIMEOUT_MS = 30_000; // DC 打开后 30s 内需收到请求
13
+ const TMP_CLEANUP_DELAY_MS = 60_000; // 启动后 60s 延迟清理
14
+ const TMP_FILE_PATTERN = /\.tmp\.[0-9a-f-]{36}$/;
15
+
16
+ // --- 路径安全校验 ---
17
+
18
+ /**
19
+ * 校验 userPath 是否在 workspaceDir 沙箱内,返回解析后的绝对路径。
20
+ * 校验内容:路径穿越、符号链接指向沙箱外、特殊文件类型。
21
+ * @param {string} workspaceDir - workspace 绝对路径
22
+ * @param {string} userPath - 用户提供的相对路径
23
+ * @param {object} [deps] - 可注入依赖(测试用)
24
+ * @returns {Promise<string>} 解析后的绝对路径
25
+ */
26
+ export async function validatePath(workspaceDir, userPath, deps = {}) {
27
+ const _lstat = deps.lstat ?? fsp.lstat;
28
+
29
+ if (!userPath || typeof userPath !== 'string') {
30
+ const err = new Error('Path is required');
31
+ err.code = 'PATH_DENIED';
32
+ throw err;
33
+ }
34
+
35
+ const resolved = nodePath.resolve(workspaceDir, userPath);
36
+
37
+ // 路径穿越检查
38
+ if (resolved !== workspaceDir && !resolved.startsWith(workspaceDir + nodePath.sep)) {
39
+ const err = new Error(`Path traversal denied: ${userPath}`);
40
+ err.code = 'PATH_DENIED';
41
+ throw err;
42
+ }
43
+
44
+ // 符号链接 & 文件类型检查(仅对已存在的路径)
45
+ let stat;
46
+ try {
47
+ stat = await _lstat(resolved);
48
+ } catch (e) {
49
+ // 路径不存在 — 对 write 场景合法,后续操作自行判断
50
+ if (e.code === 'ENOENT') return resolved;
51
+ throw e;
52
+ }
53
+
54
+ // 符号链接:检查实际目标是否在 workspace 内
55
+ if (stat.isSymbolicLink()) {
56
+ let realTarget;
57
+ try {
58
+ realTarget = await (deps.realpath ?? fsp.realpath)(resolved);
59
+ } catch {
60
+ const err = new Error(`Cannot resolve symlink: ${userPath}`);
61
+ err.code = 'PATH_DENIED';
62
+ throw err;
63
+ }
64
+ if (realTarget !== workspaceDir && !realTarget.startsWith(workspaceDir + nodePath.sep)) {
65
+ const err = new Error(`Symlink target outside workspace: ${userPath}`);
66
+ err.code = 'PATH_DENIED';
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ // 仅允许普通文件和目录
72
+ if (!stat.isFile() && !stat.isDirectory() && !stat.isSymbolicLink()) {
73
+ const err = new Error(`Special file type denied: ${userPath}`);
74
+ err.code = 'PATH_DENIED';
75
+ throw err;
76
+ }
77
+
78
+ return resolved;
79
+ }
80
+
81
+ // --- File Handler 工厂 ---
82
+
83
+ /**
84
+ * @param {object} opts
85
+ * @param {function} opts.resolveWorkspace - (agentId) => Promise<string> 返回 workspace 绝对路径
86
+ * @param {object} [opts.logger]
87
+ * @param {object} [opts.deps] - 可注入依赖(测试用)
88
+ */
89
+ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
90
+ /* c8 ignore next -- ?? fallback */
91
+ const log = logger ?? console;
92
+ const _lstat = deps.lstat ?? fsp.lstat;
93
+ const _readdir = deps.readdir ?? fsp.readdir;
94
+ const _unlink = deps.unlink ?? fsp.unlink;
95
+ const _rmdir = deps.rmdir ?? fsp.rmdir;
96
+ const _stat = deps.stat ?? fsp.stat;
97
+ const _mkdir = deps.mkdir ?? fsp.mkdir;
98
+ const _rename = deps.rename ?? fsp.rename;
99
+ const _createReadStream = deps.createReadStream ?? fs.createReadStream;
100
+ const _createWriteStream = deps.createWriteStream ?? fs.createWriteStream;
101
+ const _realpath = deps.realpath ?? fsp.realpath;
102
+
103
+ const pathDeps = { lstat: _lstat, realpath: _realpath };
104
+
105
+ // --- RPC 处理(rpc DC 上的 coclaw.file.list / coclaw.file.delete) ---
106
+
107
+ /**
108
+ * 处理 rpc DC 上的 coclaw.file.* 请求
109
+ * @param {object} payload - { id, method, params }
110
+ * @param {function} sendFn - (responseObj) => void
111
+ */
112
+ async function handleRpcRequest(payload, sendFn) {
113
+ const { id, method, params } = payload;
114
+ try {
115
+ if (method === 'coclaw.file.list') {
116
+ const result = await listFiles(params);
117
+ sendFn({ type: 'res', id, ok: true, payload: result });
118
+ } else if (method === 'coclaw.file.delete') {
119
+ const result = await deleteFile(params);
120
+ sendFn({ type: 'res', id, ok: true, payload: result });
121
+ } else {
122
+ sendFn({
123
+ type: 'res', id, ok: false,
124
+ error: { code: 'UNKNOWN_METHOD', message: `Unknown file method: ${method}` },
125
+ });
126
+ }
127
+ } catch (err) {
128
+ sendFn({
129
+ type: 'res', id, ok: false,
130
+ error: { code: err.code ?? 'INTERNAL_ERROR', message: err.message },
131
+ });
132
+ }
133
+ }
134
+
135
+ async function listFiles(params) {
136
+ const agentId = params?.agentId?.trim?.() || 'main';
137
+ const userPath = params?.path ?? ''; /* c8 ignore next -- ?? fallback */
138
+ const workspaceDir = await resolveWorkspace(agentId);
139
+ const resolved = await validatePath(workspaceDir, userPath || '.', pathDeps);
140
+
141
+ let stat;
142
+ try {
143
+ stat = await _stat(resolved);
144
+ } catch (e) {
145
+ if (e.code === 'ENOENT') {
146
+ const err = new Error(`Directory not found: ${userPath || '.'}`);
147
+ err.code = 'NOT_FOUND';
148
+ throw err;
149
+ }
150
+ /* c8 ignore next 2 */
151
+ throw e;
152
+ }
153
+ if (!stat.isDirectory()) {
154
+ const err = new Error(`Not a directory: ${userPath}`);
155
+ err.code = 'IS_DIRECTORY';
156
+ throw err;
157
+ }
158
+
159
+ const entries = await _readdir(resolved, { withFileTypes: true });
160
+ const files = [];
161
+ for (const entry of entries) {
162
+ // 跳过临时文件
163
+ if (TMP_FILE_PATTERN.test(entry.name)) continue;
164
+
165
+ let type;
166
+ if (entry.isSymbolicLink()) type = 'symlink';
167
+ else if (entry.isDirectory()) type = 'dir';
168
+ else if (entry.isFile()) type = 'file';
169
+ /* c8 ignore next */
170
+ else continue; // 跳过特殊文件
171
+
172
+ let size = 0;
173
+ let mtime = 0;
174
+ try {
175
+ const s = await _lstat(nodePath.join(resolved, entry.name));
176
+ size = s.size;
177
+ mtime = s.mtimeMs;
178
+ } catch {
179
+ // 获取 stat 失败时,条目仍然返回但没有 size/mtime
180
+ }
181
+ files.push({ name: entry.name, type, size, mtime: Math.floor(mtime) });
182
+ }
183
+
184
+ return { files };
185
+ }
186
+
187
+ async function deleteFile(params) {
188
+ const agentId = params?.agentId?.trim?.() || 'main';
189
+ const userPath = params?.path;
190
+ if (!userPath) {
191
+ const err = new Error('path is required');
192
+ err.code = 'PATH_DENIED';
193
+ throw err;
194
+ }
195
+ const workspaceDir = await resolveWorkspace(agentId);
196
+ const resolved = await validatePath(workspaceDir, userPath, pathDeps);
197
+
198
+ let stat;
199
+ try {
200
+ stat = await _lstat(resolved);
201
+ } catch (e) {
202
+ if (e.code === 'ENOENT') {
203
+ const err = new Error(`File not found: ${userPath}`);
204
+ err.code = 'NOT_FOUND';
205
+ throw err;
206
+ }
207
+ throw e;
208
+ }
209
+ 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
+ }
218
+ throw e;
219
+ }
220
+ } else {
221
+ await _unlink(resolved);
222
+ }
223
+ return {};
224
+ }
225
+
226
+ // --- File DataChannel 处理 ---
227
+
228
+ /**
229
+ * 处理 file:<transferId> DataChannel
230
+ * @param {object} dc - werift DataChannel
231
+ */
232
+ function handleFileChannel(dc) {
233
+ let requestTimer = setTimeout(() => {
234
+ try {
235
+ dc.send(JSON.stringify({
236
+ ok: false,
237
+ error: { code: 'TIMEOUT', message: 'No request received within 30s' },
238
+ }));
239
+ /* c8 ignore next */
240
+ } catch { /* ignore */ }
241
+ /* c8 ignore next */
242
+ try { dc.close(); } catch { /* ignore */ }
243
+ }, FILE_DC_TIMEOUT_MS);
244
+ requestTimer.unref?.();
245
+
246
+ let requestReceived = false;
247
+
248
+ dc.onmessage = (event) => {
249
+ // 只处理第一条 string 消息作为请求
250
+ if (requestReceived) return;
251
+ if (typeof event.data !== 'string') return;
252
+
253
+ requestReceived = true;
254
+ clearTimeout(requestTimer);
255
+ requestTimer = null;
256
+
257
+ let req;
258
+ try {
259
+ req = JSON.parse(event.data);
260
+ } catch {
261
+ sendError(dc, 'INVALID_INPUT', 'Invalid JSON request');
262
+ return;
263
+ }
264
+
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}`);
269
+ });
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}`);
274
+ });
275
+ } else {
276
+ sendError(dc, 'UNKNOWN_METHOD', `Unknown method: ${req.method}`);
277
+ }
278
+ };
279
+ }
280
+
281
+ async function handleRead(dc, req) {
282
+ let workspaceDir, resolved;
283
+ try {
284
+ const agentId = req.agentId?.trim?.() || 'main'; /* c8 ignore next -- ?./?? fallback */
285
+ workspaceDir = await resolveWorkspace(agentId);
286
+ resolved = await validatePath(workspaceDir, req.path, pathDeps);
287
+ } catch (err) {
288
+ sendError(dc, err.code ?? 'INTERNAL_ERROR', err.message); /* c8 ignore next -- ?? fallback */
289
+ return;
290
+ }
291
+
292
+ let stat;
293
+ try {
294
+ stat = await _stat(resolved);
295
+ } catch (e) {
296
+ if (e.code === 'ENOENT') {
297
+ sendError(dc, 'NOT_FOUND', `File not found: ${req.path}`);
298
+ } else {
299
+ sendError(dc, 'READ_FAILED', e.message);
300
+ }
301
+ return;
302
+ }
303
+ if (stat.isDirectory()) {
304
+ sendError(dc, 'IS_DIRECTORY', `Cannot read a directory: ${req.path}`);
305
+ return;
306
+ }
307
+ if (!stat.isFile()) {
308
+ sendError(dc, 'PATH_DENIED', `Not a regular file: ${req.path}`);
309
+ return;
310
+ }
311
+
312
+ // 发送响应头
313
+ try {
314
+ dc.send(JSON.stringify({
315
+ ok: true,
316
+ size: stat.size,
317
+ name: nodePath.basename(resolved),
318
+ }));
319
+ } catch {
320
+ return; // DC 已关闭
321
+ }
322
+
323
+ // 流式发送文件内容
324
+ const stream = _createReadStream(resolved, { highWaterMark: CHUNK_SIZE });
325
+ let sentBytes = 0;
326
+ let dcClosed = false;
327
+
328
+ dc.onclose = () => { dcClosed = true; stream.destroy(); };
329
+
330
+ if (dc.bufferedAmountLowThreshold !== undefined) {
331
+ dc.bufferedAmountLowThreshold = LOW_WATER_MARK;
332
+ }
333
+ dc.onbufferedamountlow = () => stream.resume();
334
+
335
+ await new Promise((resolve, reject) => {
336
+ stream.on('data', (chunk) => {
337
+ if (dcClosed) { stream.destroy(); return; }
338
+ try {
339
+ dc.send(chunk);
340
+ sentBytes += chunk.length;
341
+ if (dc.bufferedAmount > HIGH_WATER_MARK) {
342
+ stream.pause();
343
+ }
344
+ /* c8 ignore start -- dc.send 抛异常属罕见竞态 */
345
+ } catch {
346
+ dcClosed = true;
347
+ stream.destroy();
348
+ }
349
+ /* c8 ignore stop */
350
+ });
351
+ stream.on('end', () => {
352
+ if (dcClosed) { resolve(); return; }
353
+ try {
354
+ dc.send(JSON.stringify({ ok: true, bytes: sentBytes }));
355
+ dc.close();
356
+ } catch { /* ignore */ }
357
+ resolve();
358
+ });
359
+ stream.on('error', (err) => {
360
+ if (!dcClosed) {
361
+ sendError(dc, 'READ_FAILED', err.message);
362
+ }
363
+ reject(err);
364
+ });
365
+ }).catch((err) => {
366
+ log.warn?.(`[coclaw/file] read stream error: ${err.message}`);
367
+ });
368
+ }
369
+
370
+ async function handleWrite(dc, req) {
371
+ let workspaceDir, resolved;
372
+ try {
373
+ const agentId = req.agentId?.trim?.() || 'main';
374
+ workspaceDir = await resolveWorkspace(agentId);
375
+ resolved = await validatePath(workspaceDir, req.path, pathDeps);
376
+ } catch (err) {
377
+ sendError(dc, err.code ?? 'INTERNAL_ERROR', err.message);
378
+ return;
379
+ }
380
+
381
+ const declaredSize = req.size;
382
+ if (!Number.isFinite(declaredSize) || declaredSize < 0) {
383
+ sendError(dc, 'INVALID_INPUT', 'size must be a non-negative number');
384
+ return;
385
+ }
386
+ if (declaredSize > MAX_UPLOAD_SIZE) {
387
+ sendError(dc, 'SIZE_EXCEEDED', `File size ${declaredSize} exceeds 1GB limit`);
388
+ return;
389
+ }
390
+
391
+ // 确保目标目录存在
392
+ const targetDir = nodePath.dirname(resolved);
393
+ try {
394
+ await _mkdir(targetDir, { recursive: true });
395
+ } catch (err) {
396
+ sendError(dc, 'WRITE_FAILED', `Cannot create directory: ${err.message}`);
397
+ return;
398
+ }
399
+
400
+ // 临时文件与目标在同一目录(避免 EXDEV)
401
+ const transferId = dc.label?.split(':')?.[1] ?? randomUUID();
402
+ const tmpPath = `${resolved}.tmp.${transferId}`;
403
+
404
+ let ws;
405
+ try {
406
+ ws = _createWriteStream(tmpPath, { highWaterMark: CHUNK_SIZE });
407
+ } catch (err) {
408
+ sendError(dc, 'WRITE_FAILED', `Cannot create temp file: ${err.message}`);
409
+ return;
410
+ }
411
+
412
+ // 发送就绪信号
413
+ try {
414
+ dc.send(JSON.stringify({ ok: true }));
415
+ } catch {
416
+ // WriteStream 可能尚未完成文件创建,等 close 后再清理
417
+ ws.on('close', () => safeUnlink(tmpPath));
418
+ ws.destroy();
419
+ return;
420
+ }
421
+
422
+ let receivedBytes = 0;
423
+ let doneReceived = false;
424
+ let dcClosed = false;
425
+
426
+ // 替换原始 onmessage(文件传输模式)
427
+ dc.onmessage = (event) => {
428
+ if (typeof event.data === 'string') {
429
+ let msg;
430
+ try { msg = JSON.parse(event.data); } catch { return; }
431
+ if (msg.done) {
432
+ doneReceived = true;
433
+ ws.end(async () => {
434
+ if (dcClosed) {
435
+ safeUnlink(tmpPath);
436
+ return;
437
+ }
438
+ const valid = receivedBytes === declaredSize;
439
+ if (!valid) {
440
+ try {
441
+ dc.send(JSON.stringify({ ok: false, error: { code: 'WRITE_FAILED', message: `Size mismatch: expected ${declaredSize}, got ${receivedBytes}` } }));
442
+ /* c8 ignore next */
443
+ } catch { /* ignore */ }
444
+ safeUnlink(tmpPath);
445
+ /* c8 ignore next */
446
+ try { dc.close(); } catch { /* ignore */ }
447
+ return;
448
+ }
449
+ // 先 rename,再发成功响应(避免 rename 失败时 UI 误认为成功)
450
+ try {
451
+ await _rename(tmpPath, resolved);
452
+ } catch (renameErr) {
453
+ log.warn?.(`[coclaw/file] rename failed: ${renameErr.message}`);
454
+ /* c8 ignore next 3 -- dc.send/close 失败属罕见竞态 */
455
+ try {
456
+ dc.send(JSON.stringify({ ok: false, error: { code: 'WRITE_FAILED', message: `rename failed: ${renameErr.message}` } }));
457
+ } catch { /* ignore */ }
458
+ safeUnlink(tmpPath);
459
+ /* c8 ignore next */
460
+ try { dc.close(); } catch { /* ignore */ }
461
+ return;
462
+ }
463
+ try {
464
+ dc.send(JSON.stringify({ ok: true, bytes: receivedBytes }));
465
+ /* c8 ignore next */
466
+ } catch { /* ignore */ }
467
+ /* c8 ignore next */
468
+ try { dc.close(); } catch { /* ignore */ }
469
+ });
470
+ }
471
+ } else {
472
+ // binary 数据帧
473
+ const chunk = event.data;
474
+ const len = chunk.byteLength ?? chunk.length ?? 0;
475
+ receivedBytes += len;
476
+
477
+ // 接收端超限防护
478
+ if (receivedBytes > MAX_UPLOAD_SIZE || receivedBytes > declaredSize) {
479
+ ws.destroy();
480
+ safeUnlink(tmpPath);
481
+ try {
482
+ dc.send(JSON.stringify({
483
+ ok: false,
484
+ error: { code: 'SIZE_EXCEEDED', message: 'Received bytes exceed declared size or 1GB limit' },
485
+ }));
486
+ } catch { /* ignore */ }
487
+ try { dc.close(); } catch { /* ignore */ }
488
+ return;
489
+ }
490
+ ws.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
491
+ }
492
+ };
493
+
494
+ dc.onclose = () => {
495
+ dcClosed = true;
496
+ if (!doneReceived) {
497
+ ws.destroy();
498
+ safeUnlink(tmpPath);
499
+ }
500
+ };
501
+
502
+ // WriteStream 错误处理
503
+ ws.on('error', (err) => {
504
+ log.warn?.(`[coclaw/file] write stream error: ${err.message}`);
505
+ if (!dcClosed) {
506
+ const code = err.code === 'ENOSPC' ? 'DISK_FULL' : 'WRITE_FAILED';
507
+ sendError(dc, code, err.message);
508
+ }
509
+ safeUnlink(tmpPath);
510
+ });
511
+ }
512
+
513
+ // --- 临时文件清理 ---
514
+
515
+ let cleanupTimer = null;
516
+
517
+ /**
518
+ * 延迟启动临时文件清理任务
519
+ * @param {function} listAgentWorkspaces - () => Promise<string[]> 返回所有 workspace 路径
520
+ */
521
+ function scheduleTmpCleanup(listAgentWorkspaces) {
522
+ if (cleanupTimer) return;
523
+ cleanupTimer = setTimeout(async () => {
524
+ cleanupTimer = null;
525
+ try {
526
+ const workspaces = await listAgentWorkspaces();
527
+ for (const dir of workspaces) {
528
+ await cleanupTmpFilesInDir(dir);
529
+ }
530
+ } catch (err) {
531
+ log.warn?.(`[coclaw/file] tmp cleanup failed: ${err.message}`);
532
+ }
533
+ }, TMP_CLEANUP_DELAY_MS);
534
+ cleanupTimer.unref?.();
535
+ }
536
+
537
+ async function cleanupTmpFilesInDir(dir) {
538
+ let entries;
539
+ try {
540
+ entries = await _readdir(dir, { withFileTypes: true });
541
+ } catch {
542
+ return;
543
+ }
544
+ for (const entry of entries) {
545
+ if (entry.isFile() && TMP_FILE_PATTERN.test(entry.name)) {
546
+ safeUnlink(nodePath.join(dir, entry.name));
547
+ }
548
+ // 递归进入子目录
549
+ if (entry.isDirectory()) {
550
+ await cleanupTmpFilesInDir(nodePath.join(dir, entry.name));
551
+ }
552
+ }
553
+ }
554
+
555
+ function cancelCleanup() {
556
+ if (cleanupTimer) {
557
+ clearTimeout(cleanupTimer);
558
+ cleanupTimer = null;
559
+ }
560
+ }
561
+
562
+ // --- 工具函数 ---
563
+
564
+ function sendError(dc, code, message) {
565
+ try {
566
+ dc.send(JSON.stringify({ ok: false, error: { code, message } }));
567
+ } catch { /* DC 可能已关闭 */ }
568
+ try { dc.close(); } catch { /* ignore */ }
569
+ }
570
+
571
+ function safeUnlink(filePath) {
572
+ _unlink(filePath).catch(() => {});
573
+ }
574
+
575
+ return {
576
+ handleRpcRequest,
577
+ handleFileChannel,
578
+ scheduleTmpCleanup,
579
+ cancelCleanup,
580
+ // 暴露内部方法便于测试
581
+ __listFiles: listFiles,
582
+ __deleteFile: deleteFile,
583
+ __handleRead: handleRead,
584
+ __handleWrite: handleWrite,
585
+ __cleanupTmpFilesInDir: cleanupTmpFilesInDir,
586
+ };
587
+ }
@@ -98,6 +98,7 @@ export class RealtimeBridge {
98
98
  this.__serverHbMissCount = 0;
99
99
  this.__deviceIdentity = null;
100
100
  this.webrtcPeer = null;
101
+ this.__fileHandler = null;
101
102
  }
102
103
 
103
104
  __resolveWebSocket() {
@@ -379,6 +380,55 @@ export class RealtimeBridge {
379
380
  }
380
381
  }
381
382
 
383
+ /* c8 ignore start -- 仅通过 WebRTC 路径调用,依赖 gateway 连接,集成测试覆盖 */
384
+ /**
385
+ * 通过 gateway RPC 获取指定 agent 的 workspace 绝对路径
386
+ * @param {string} agentId
387
+ * @returns {Promise<string>}
388
+ */
389
+ async __resolveWorkspace(agentId) {
390
+ const result = await this.__gatewayRpc('agents.files.list', { agentId }, { timeoutMs: 5000 });
391
+ if (!result?.ok) {
392
+ const err = new Error(result?.error ?? 'Failed to resolve workspace');
393
+ err.code = 'AGENT_DENIED';
394
+ throw err;
395
+ }
396
+ const workspace = result?.response?.payload?.workspace;
397
+ if (!workspace) {
398
+ const err = new Error(`No workspace for agent: ${agentId}`);
399
+ err.code = 'AGENT_DENIED';
400
+ throw err;
401
+ }
402
+ return workspace;
403
+ }
404
+
405
+ /**
406
+ * 列出所有 agent 的 workspace 路径(供临时文件清理使用)
407
+ * @returns {Promise<string[]>}
408
+ */
409
+ async __listAgentWorkspaces() {
410
+ const listResult = await this.__gatewayRpc('agents.list', {}, { timeoutMs: 3000 });
411
+ let agentIds = ['main'];
412
+ if (listResult?.ok === true && Array.isArray(listResult?.response?.payload?.agents)) {
413
+ const ids = listResult.response.payload.agents
414
+ .map((a) => a?.id)
415
+ .filter((id) => typeof id === 'string' && id.trim());
416
+ if (ids.length > 0) agentIds = ids;
417
+ }
418
+ const workspaces = [];
419
+ for (const id of agentIds) {
420
+ try {
421
+ const ws = await this.__resolveWorkspace(id);
422
+ workspaces.push(ws);
423
+ } catch {
424
+ // 个别 agent 解析失败不阻断
425
+ }
426
+ }
427
+ return workspaces;
428
+ }
429
+
430
+ /* c8 ignore stop */
431
+
382
432
  __ensureDeviceIdentity() {
383
433
  if (!this.__deviceIdentity) {
384
434
  this.__deviceIdentity = this.__loadDeviceIdentity();
@@ -706,11 +756,28 @@ export class RealtimeBridge {
706
756
  try {
707
757
  if (!this.webrtcPeer) {
708
758
  const { WebRtcPeer } = await import('./webrtc-peer.js');
759
+ const { createFileHandler } = await import('./file-manager/handler.js');
760
+ /* c8 ignore start -- 仅通过 WebRTC 路径触发,集成测试覆盖 */
761
+ this.__fileHandler = createFileHandler({
762
+ resolveWorkspace: (agentId) => this.__resolveWorkspace(agentId),
763
+ logger: this.logger,
764
+ });
765
+ this.__fileHandler.scheduleTmpCleanup(() => this.__listAgentWorkspaces());
766
+ /* c8 ignore stop */
709
767
  this.webrtcPeer = new WebRtcPeer({
710
768
  onSend: (msg) => this.__forwardToServer(msg),
711
769
  onRequest: (dcPayload) => {
712
770
  void this.__handleGatewayRequestFromServer(dcPayload);
713
771
  },
772
+ /* c8 ignore start -- 仅通过 WebRTC 路径触发,集成测试覆盖 */
773
+ onFileRpc: (payload, sendFn) => {
774
+ this.__fileHandler.handleRpcRequest(payload, sendFn)
775
+ .catch((err) => this.logger.warn?.(`[coclaw/file] rpc error: ${err.message}`));
776
+ },
777
+ onFileChannel: (dc) => {
778
+ this.__fileHandler.handleFileChannel(dc);
779
+ },
780
+ /* c8 ignore stop */
714
781
  logger: this.logger,
715
782
  });
716
783
  }
@@ -750,6 +817,10 @@ export class RealtimeBridge {
750
817
  catch (e) { this.logger.warn?.(`[coclaw/rtc] closeAll failed: ${e?.message}`); }
751
818
  this.webrtcPeer = null;
752
819
  }
820
+ if (this.__fileHandler) {
821
+ this.__fileHandler.cancelCleanup();
822
+ this.__fileHandler = null;
823
+ }
753
824
 
754
825
  if (event?.code === 4001 || event?.code === 4003) {
755
826
  try {
@@ -812,6 +883,10 @@ export class RealtimeBridge {
812
883
  await this.webrtcPeer.closeAll().catch(() => {});
813
884
  this.webrtcPeer = null;
814
885
  }
886
+ if (this.__fileHandler) {
887
+ this.__fileHandler.cancelCleanup();
888
+ this.__fileHandler = null;
889
+ }
815
890
  const sock = this.serverWs;
816
891
  if (sock) {
817
892
  this.intentionallyClosed = true;
@@ -9,12 +9,16 @@ export class WebRtcPeer {
9
9
  * @param {object} opts
10
10
  * @param {function} opts.onSend - 将信令消息交给 RealtimeBridge 发送
11
11
  * @param {function} [opts.onRequest] - DataChannel 收到 req 消息时的回调 (payload, connId) => void
12
+ * @param {function} [opts.onFileRpc] - rpc DC 上 coclaw.file.* 请求的回调 (payload, sendFn, connId) => void
13
+ * @param {function} [opts.onFileChannel] - file:<transferId> DataChannel 的回调 (dc, connId) => void
12
14
  * @param {object} [opts.logger] - pino 风格 logger
13
15
  * @param {function} [opts.PeerConnection] - 可替换的构造函数(测试用)
14
16
  */
15
- constructor({ onSend, onRequest, logger, PeerConnection }) {
17
+ constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection }) {
16
18
  this.__onSend = onSend;
17
19
  this.__onRequest = onRequest;
20
+ this.__onFileRpc = onFileRpc;
21
+ this.__onFileChannel = onFileChannel;
18
22
  this.logger = logger ?? console;
19
23
  this.__PeerConnection = PeerConnection ?? WeriftRTCPeerConnection;
20
24
  /** @type {Map<string, { pc: object, rpcChannel: object|null }>} */
@@ -126,6 +130,8 @@ export class WebRtcPeer {
126
130
  if (channel.label === 'rpc') {
127
131
  session.rpcChannel = channel;
128
132
  this.__setupDataChannel(connId, channel);
133
+ } else if (channel.label.startsWith('file:')) {
134
+ this.__onFileChannel?.(channel, connId);
129
135
  }
130
136
  };
131
137
 
@@ -174,7 +180,16 @@ export class WebRtcPeer {
174
180
  const raw = typeof event.data === 'string' ? event.data : event.data.toString();
175
181
  const payload = JSON.parse(raw);
176
182
  if (payload.type === 'req') {
177
- this.__onRequest?.(payload, connId);
183
+ // coclaw.file.* 方法本地处理,不转发 gateway
184
+ if (payload.method?.startsWith('coclaw.file.') && this.__onFileRpc) {
185
+ const sendFn = (response) => {
186
+ try { dc.send(JSON.stringify(response)); }
187
+ catch { /* DC 可能已关闭 */ }
188
+ };
189
+ this.__onFileRpc(payload, sendFn, connId);
190
+ } else {
191
+ this.__onRequest?.(payload, connId);
192
+ }
178
193
  } else {
179
194
  this.__logDebug(`[${connId}] unknown DC message type: ${payload.type}`);
180
195
  }