@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 +2 -2
- package/src/file-manager/handler.js +587 -0
- package/src/realtime-bridge.js +75 -0
- package/src/webrtc-peer.js +17 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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
|
+
}
|
package/src/realtime-bridge.js
CHANGED
|
@@ -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;
|
package/src/webrtc-peer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|