@xgjktech/xg_cwork_im 1.17.39 → 1.17.41

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.
Files changed (84) hide show
  1. package/README.md +6 -1
  2. package/dist/index.js +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/src/account-connection.d.ts +1 -1
  5. package/dist/src/account-connection.d.ts.map +1 -1
  6. package/dist/src/account-connection.js +7 -0
  7. package/dist/src/account-connection.js.map +1 -1
  8. package/dist/src/account-reconciler.d.ts +1 -1
  9. package/dist/src/account-reconciler.d.ts.map +1 -1
  10. package/dist/src/apply-sync-mapping.d.ts +1 -1
  11. package/dist/src/apply-sync-mapping.d.ts.map +1 -1
  12. package/dist/src/channel-config.d.ts +32 -1
  13. package/dist/src/channel-config.d.ts.map +1 -1
  14. package/dist/src/channel-config.js +27 -3
  15. package/dist/src/channel-config.js.map +1 -1
  16. package/dist/src/channel-config.test.js +58 -1
  17. package/dist/src/channel-config.test.js.map +1 -1
  18. package/dist/src/connection-workspace-event.test.d.ts +2 -0
  19. package/dist/src/connection-workspace-event.test.d.ts.map +1 -0
  20. package/dist/src/connection-workspace-event.test.js +74 -0
  21. package/dist/src/connection-workspace-event.test.js.map +1 -0
  22. package/dist/src/connection.d.ts +4 -0
  23. package/dist/src/connection.d.ts.map +1 -1
  24. package/dist/src/connection.js +32 -0
  25. package/dist/src/connection.js.map +1 -1
  26. package/dist/src/dispatch-mentioned-reply.d.ts +1 -1
  27. package/dist/src/dispatch-mentioned-reply.d.ts.map +1 -1
  28. package/dist/src/group-history-tool.d.ts +1 -1
  29. package/dist/src/group-history-tool.d.ts.map +1 -1
  30. package/dist/src/inbound-input-media.d.ts +1 -1
  31. package/dist/src/inbound-input-media.d.ts.map +1 -1
  32. package/dist/src/inbound-media-local.d.ts +1 -1
  33. package/dist/src/inbound-media-local.d.ts.map +1 -1
  34. package/dist/src/nas-site.d.ts +16 -0
  35. package/dist/src/nas-site.d.ts.map +1 -0
  36. package/dist/src/nas-site.js +17 -0
  37. package/dist/src/nas-site.js.map +1 -0
  38. package/dist/src/nas-volumes.d.ts +9 -1
  39. package/dist/src/nas-volumes.d.ts.map +1 -1
  40. package/dist/src/nas-volumes.js +66 -0
  41. package/dist/src/nas-volumes.js.map +1 -1
  42. package/dist/src/plan-event-subscription.d.ts +1 -1
  43. package/dist/src/plan-event-subscription.d.ts.map +1 -1
  44. package/dist/src/plan-tool-middleware.d.ts +1 -1
  45. package/dist/src/plan-tool-middleware.d.ts.map +1 -1
  46. package/dist/src/register-exec-env-hook.d.ts +1 -1
  47. package/dist/src/register-exec-env-hook.d.ts.map +1 -1
  48. package/dist/src/register-exec-env-hook.js.map +1 -1
  49. package/dist/src/register-per-message-model-hook.d.ts +1 -1
  50. package/dist/src/register-per-message-model-hook.d.ts.map +1 -1
  51. package/dist/src/resolve-inbound-group-prompt.d.ts +1 -1
  52. package/dist/src/resolve-inbound-group-prompt.d.ts.map +1 -1
  53. package/dist/src/send-file-tool.d.ts +1 -1
  54. package/dist/src/send-file-tool.d.ts.map +1 -1
  55. package/dist/src/send-group-message-tool.d.ts +1 -1
  56. package/dist/src/send-group-message-tool.d.ts.map +1 -1
  57. package/dist/src/subagent-monitor.d.ts +1 -1
  58. package/dist/src/subagent-monitor.d.ts.map +1 -1
  59. package/dist/src/tool-json-result.d.ts +1 -1
  60. package/dist/src/tool-json-result.d.ts.map +1 -1
  61. package/dist/src/types.d.ts +19 -1
  62. package/dist/src/types.d.ts.map +1 -1
  63. package/dist/src/types.js.map +1 -1
  64. package/dist/src/workspace-event-protocol.d.ts +3 -0
  65. package/dist/src/workspace-event-protocol.d.ts.map +1 -0
  66. package/dist/src/workspace-event-protocol.js +3 -0
  67. package/dist/src/workspace-event-protocol.js.map +1 -0
  68. package/dist/src/workspace-event-scheduler.d.ts +54 -0
  69. package/dist/src/workspace-event-scheduler.d.ts.map +1 -0
  70. package/dist/src/workspace-event-scheduler.js +131 -0
  71. package/dist/src/workspace-event-scheduler.js.map +1 -0
  72. package/dist/src/workspace-event-scheduler.test.d.ts +2 -0
  73. package/dist/src/workspace-event-scheduler.test.d.ts.map +1 -0
  74. package/dist/src/workspace-event-scheduler.test.js +157 -0
  75. package/dist/src/workspace-event-scheduler.test.js.map +1 -0
  76. package/dist/src/workspace-event.d.ts +28 -0
  77. package/dist/src/workspace-event.d.ts.map +1 -0
  78. package/dist/src/workspace-event.js +674 -0
  79. package/dist/src/workspace-event.js.map +1 -0
  80. package/dist/src/workspace-event.test.d.ts +2 -0
  81. package/dist/src/workspace-event.test.d.ts.map +1 -0
  82. package/dist/src/workspace-event.test.js +265 -0
  83. package/dist/src/workspace-event.test.js.map +1 -0
  84. package/package.json +2 -2
@@ -0,0 +1,674 @@
1
+ /**
2
+ * 会话工作区目录浏览与文本文件读取。
3
+ *
4
+ * 该信令与 robotMention/groupMessage 完全分流,不创建 Agent 轮次、不写会话记录。
5
+ * LOCAL 工作区以 IM 计算的 localRoot 为授权根;NAS 工作区只访问 nasVolumes 明确授权的
6
+ * dockerDir,并通过当前机器 nasVolumeRootMappings 映射到宿主目录。
7
+ */
8
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { TextDecoder } from "node:util";
11
+ import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
12
+ import { normalizeNasDockerDir, resolveNasVolumeHostDirectoryForRead, } from "./nas-volumes.js";
13
+ import { resolveNasSiteCode } from "./nas-site.js";
14
+ import { resolveWorkspaceEventConcurrencyLimits, workspaceEventScheduler, } from "./workspace-event-scheduler.js";
15
+ import { LOCAL_ROOT_KINDS, resolveLocalAbsolutePath, } from "./project-path-normalize.js";
16
+ import { OPENCLAW_WORKSPACE_EVENT_CMD } from "./workspace-event-protocol.js";
17
+ export { OPENCLAW_WORKSPACE_EVENT_CMD } from "./workspace-event-protocol.js";
18
+ const DEFAULT_TREE_LIMIT = 200;
19
+ const MAX_TREE_LIMIT = 500;
20
+ const MAX_FILE_BYTES = 1024 * 1024;
21
+ const MAX_CURSOR_CHARS = 4096;
22
+ const MAX_REQUEST_PATH_CHARS = 4096;
23
+ /** ws-notify 文本帧上限为 2 MiB;保留 64 KiB 给信封和实现差异。 */
24
+ const MAX_WS_RESPONSE_BYTES = 2 * 1024 * 1024 - 64 * 1024;
25
+ class WorkspaceEventError extends Error {
26
+ code;
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.code = code;
30
+ }
31
+ }
32
+ function asRecord(value) {
33
+ return value != null && typeof value === "object" && !Array.isArray(value)
34
+ ? value
35
+ : undefined;
36
+ }
37
+ function requiredString(record, key, maxLength = 1024) {
38
+ const value = record[key];
39
+ if (typeof value !== "string" || !value.trim() || value.length > maxLength) {
40
+ throw new WorkspaceEventError("INVALID_REQUEST", `${key} 缺失或格式错误`);
41
+ }
42
+ return value.trim();
43
+ }
44
+ function parseWorkspaceDescriptor(raw) {
45
+ const record = asRecord(raw);
46
+ if (!record)
47
+ throw new WorkspaceEventError("INVALID_REQUEST", "workspace 缺失或格式错误");
48
+ const sourceType = typeof record.sourceType === "string" ? record.sourceType.trim().toUpperCase() : "";
49
+ const rootPath = typeof record.rootPath === "string" ? record.rootPath.trim() : "";
50
+ if (rootPath.length > MAX_REQUEST_PATH_CHARS) {
51
+ throw new WorkspaceEventError("INVALID_REQUEST", "workspace.rootPath 过长");
52
+ }
53
+ if (sourceType === "LOCAL") {
54
+ const local = asRecord(record.localRoot);
55
+ if (!local)
56
+ throw new WorkspaceEventError("INVALID_REQUEST", "workspace.localRoot 缺失");
57
+ const kind = requiredString(local, "kind", 64);
58
+ const localPath = requiredString(local, "path", MAX_REQUEST_PATH_CHARS);
59
+ if (!LOCAL_ROOT_KINDS.includes(kind)) {
60
+ throw new WorkspaceEventError("INVALID_REQUEST", "workspace.localRoot.kind 不支持");
61
+ }
62
+ return {
63
+ sourceType: "LOCAL",
64
+ rootPath,
65
+ localRoot: { kind: kind, path: localPath },
66
+ };
67
+ }
68
+ if (sourceType === "NAS") {
69
+ if (!Array.isArray(record.nasVolumes) || record.nasVolumes.length === 0) {
70
+ throw new WorkspaceEventError("INVALID_REQUEST", "workspace.nasVolumes 缺失");
71
+ }
72
+ const nasVolumes = [];
73
+ for (const value of record.nasVolumes) {
74
+ if (nasVolumes.length >= 16)
75
+ break;
76
+ const item = asRecord(value);
77
+ if (!item)
78
+ continue;
79
+ try {
80
+ const rawMode = typeof item.mode === "string" ? item.mode.trim().toLowerCase() : "";
81
+ const mode = rawMode === "ro" || rawMode === "rw" ? rawMode : undefined;
82
+ nasVolumes.push({
83
+ realDir: requiredString(item, "realDir", 1024),
84
+ dockerDir: requiredString(item, "dockerDir", 1024),
85
+ ...(mode ? { mode } : {}),
86
+ });
87
+ }
88
+ catch {
89
+ // 单个旧格式或损坏声明不应阻断同一请求中的其他有效 NAS 目录。
90
+ }
91
+ }
92
+ if (nasVolumes.length === 0) {
93
+ throw new WorkspaceEventError("INVALID_REQUEST", "workspace.nasVolumes 没有可用的授权目录");
94
+ }
95
+ return { sourceType: "NAS", rootPath, nasVolumes };
96
+ }
97
+ throw new WorkspaceEventError("INVALID_REQUEST", "workspace.sourceType 不支持");
98
+ }
99
+ function parseWorkspaceRequest(raw) {
100
+ const record = asRecord(raw);
101
+ if (!record)
102
+ throw new WorkspaceEventError("INVALID_REQUEST", "params 缺失或格式错误");
103
+ const event = requiredString(record, "event", 32);
104
+ if (event !== "TREE_REQUEST" && event !== "FILE_REQUEST") {
105
+ throw new WorkspaceEventError("INVALID_REQUEST", "event 不支持");
106
+ }
107
+ const fullPath = typeof record.path === "string" ? record.path.trim() : "";
108
+ if (fullPath.length > MAX_REQUEST_PATH_CHARS) {
109
+ throw new WorkspaceEventError("INVALID_REQUEST", "path 过长");
110
+ }
111
+ const rawLimit = record.limit;
112
+ const limit = rawLimit == null ? DEFAULT_TREE_LIMIT : Number(rawLimit);
113
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_TREE_LIMIT) {
114
+ throw new WorkspaceEventError("INVALID_REQUEST", "limit 必须在 1 到 500 之间");
115
+ }
116
+ const cursor = record.cursor == null ? undefined : String(record.cursor);
117
+ if (cursor != null && (!cursor || cursor.length > MAX_CURSOR_CHARS)) {
118
+ throw new WorkspaceEventError("INVALID_REQUEST", "cursor 格式错误");
119
+ }
120
+ return {
121
+ relayId: requiredString(record, "_wsRelayId", 256),
122
+ event,
123
+ groupId: requiredString(record, "groupId", 128),
124
+ workspace: parseWorkspaceDescriptor(record.workspace),
125
+ fullPath,
126
+ limit,
127
+ ...(cursor ? { cursor } : {}),
128
+ };
129
+ }
130
+ function normalizeLogicalPath(raw, allowEmpty) {
131
+ let value = raw.trim().replace(/\\/g, "/");
132
+ value = value.replace(/\/{2,}/g, "/");
133
+ while (value.startsWith("./"))
134
+ value = value.slice(2);
135
+ while (value.endsWith("/") && value.length > 1)
136
+ value = value.slice(0, -1);
137
+ if (!value) {
138
+ if (allowEmpty)
139
+ return "";
140
+ throw new WorkspaceEventError("INVALID_REQUEST", "path 不能为空");
141
+ }
142
+ const segments = value.split("/");
143
+ if (segments.some((segment) => segment === "." || segment === "..")) {
144
+ throw new WorkspaceEventError("PATH_OUTSIDE_WORKSPACE", "路径超出工作区范围");
145
+ }
146
+ return value;
147
+ }
148
+ function equalsPath(left, right) {
149
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
150
+ }
151
+ function startsWithPath(target, root) {
152
+ if (!root)
153
+ return !target.startsWith("/") && !/^[A-Za-z]:\//.test(target);
154
+ const left = process.platform === "win32" ? target.toLowerCase() : target;
155
+ const right = process.platform === "win32" ? root.toLowerCase() : root;
156
+ return left.startsWith(`${right}/`);
157
+ }
158
+ function resolveRequestPath(rootRaw, fullRaw) {
159
+ const rootPath = normalizeLogicalPath(rootRaw, true);
160
+ const fullPath = normalizeLogicalPath(fullRaw, true);
161
+ if (!rootPath) {
162
+ if (fullPath.startsWith("/") || /^[A-Za-z]:\//.test(fullPath)) {
163
+ throw new WorkspaceEventError("PATH_OUTSIDE_WORKSPACE", "路径超出工作区范围");
164
+ }
165
+ return { displayPath: fullPath, logicalPath: fullPath };
166
+ }
167
+ if (equalsPath(fullPath, rootPath)) {
168
+ return { displayPath: "", logicalPath: fullPath };
169
+ }
170
+ if (rootPath === "/" && fullPath.startsWith("/")) {
171
+ return { displayPath: fullPath.slice(1), logicalPath: fullPath };
172
+ }
173
+ if (!startsWithPath(fullPath, rootPath)) {
174
+ throw new WorkspaceEventError("PATH_OUTSIDE_WORKSPACE", "路径超出工作区范围");
175
+ }
176
+ return {
177
+ displayPath: fullPath.slice(rootPath.length + 1),
178
+ logicalPath: fullPath,
179
+ };
180
+ }
181
+ function isInsideOrEqual(root, target) {
182
+ const relative = path.relative(root, target);
183
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
184
+ }
185
+ async function existingRealRoot(root) {
186
+ try {
187
+ const resolved = await realpath(root);
188
+ const info = await stat(resolved);
189
+ if (!info.isDirectory())
190
+ throw new WorkspaceEventError("NOT_A_DIRECTORY", "工作区根目录不是目录");
191
+ return resolved;
192
+ }
193
+ catch (error) {
194
+ if (error instanceof WorkspaceEventError)
195
+ throw error;
196
+ if (isFsCode(error, "ENOENT"))
197
+ throw new WorkspaceEventError("NOT_FOUND", "工作区目录不存在");
198
+ throw new WorkspaceEventError("READ_FAILED", "无法访问工作区目录");
199
+ }
200
+ }
201
+ async function resolveExistingTarget(realRoot, relativePath) {
202
+ const lexicalTarget = relativePath
203
+ ? path.resolve(realRoot, ...relativePath.split("/"))
204
+ : realRoot;
205
+ if (!isInsideOrEqual(realRoot, lexicalTarget)) {
206
+ throw new WorkspaceEventError("PATH_OUTSIDE_WORKSPACE", "路径超出工作区范围");
207
+ }
208
+ try {
209
+ const target = await realpath(lexicalTarget);
210
+ if (!isInsideOrEqual(realRoot, target)) {
211
+ throw new WorkspaceEventError("PATH_OUTSIDE_WORKSPACE", "符号链接指向工作区外部");
212
+ }
213
+ return target;
214
+ }
215
+ catch (error) {
216
+ if (error instanceof WorkspaceEventError)
217
+ throw error;
218
+ if (isFsCode(error, "ENOENT"))
219
+ throw new WorkspaceEventError("NOT_FOUND", "目录或文件不存在");
220
+ throw new WorkspaceEventError("READ_FAILED", "无法解析目录或文件路径");
221
+ }
222
+ }
223
+ function isFsCode(error, code) {
224
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
225
+ }
226
+ function joinDisplayPath(parent, name) {
227
+ return parent ? `${parent}/${name}` : name;
228
+ }
229
+ function entrySortKey(entry) {
230
+ return `${entry.type === "DIRECTORY" ? "0" : "1"}\0${entry.name}`;
231
+ }
232
+ function compareEntries(left, right) {
233
+ const a = entrySortKey(left);
234
+ const b = entrySortKey(right);
235
+ return a < b ? -1 : a > b ? 1 : 0;
236
+ }
237
+ function encodeCursor(displayPath, entry) {
238
+ return Buffer.from(JSON.stringify({ v: 1, path: displayPath, key: entrySortKey(entry) }), "utf8").toString("base64url");
239
+ }
240
+ function decodeCursor(cursor, displayPath) {
241
+ if (!cursor)
242
+ return undefined;
243
+ try {
244
+ const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
245
+ if (value.v !== 1 || value.path !== displayPath || typeof value.key !== "string")
246
+ throw new Error("bad cursor");
247
+ return value.key;
248
+ }
249
+ catch {
250
+ throw new WorkspaceEventError("INVALID_REQUEST", "cursor 无效或不属于当前目录");
251
+ }
252
+ }
253
+ function paginateEntries(entries, displayPath, limit, cursor) {
254
+ const after = decodeCursor(cursor, displayPath);
255
+ const sorted = [...entries].sort(compareEntries);
256
+ const eligible = after == null ? sorted : sorted.filter((entry) => entrySortKey(entry) > after);
257
+ const page = eligible.slice(0, limit);
258
+ return {
259
+ entries: page,
260
+ nextCursor: eligible.length > page.length && page.length > 0
261
+ ? encodeCursor(displayPath, page[page.length - 1])
262
+ : null,
263
+ };
264
+ }
265
+ async function listPhysicalDirectory(realRoot, target, displayPath) {
266
+ let targetInfo;
267
+ try {
268
+ targetInfo = await stat(target);
269
+ }
270
+ catch (error) {
271
+ if (isFsCode(error, "ENOENT"))
272
+ throw new WorkspaceEventError("NOT_FOUND", "目录不存在");
273
+ throw new WorkspaceEventError("READ_FAILED", "无法读取目录信息");
274
+ }
275
+ if (!targetInfo.isDirectory())
276
+ throw new WorkspaceEventError("NOT_A_DIRECTORY", "请求路径不是目录");
277
+ let dirents;
278
+ try {
279
+ dirents = await readdir(target, { withFileTypes: true });
280
+ }
281
+ catch {
282
+ throw new WorkspaceEventError("READ_FAILED", "无法读取目录");
283
+ }
284
+ const entries = [];
285
+ for (const dirent of dirents) {
286
+ try {
287
+ const child = await realpath(path.join(target, dirent.name));
288
+ if (!isInsideOrEqual(realRoot, child))
289
+ continue;
290
+ const info = await stat(child);
291
+ if (!info.isDirectory() && !info.isFile())
292
+ continue;
293
+ entries.push({
294
+ name: dirent.name,
295
+ path: joinDisplayPath(displayPath, dirent.name),
296
+ type: info.isDirectory() ? "DIRECTORY" : "FILE",
297
+ modifiedAt: Math.trunc(info.mtimeMs),
298
+ ...(info.isFile() ? { size: info.size } : {}),
299
+ });
300
+ }
301
+ catch {
302
+ // 文件可能正在被删除、权限刚变化或符号链接不可用;跳过该项,不阻断整个目录。
303
+ }
304
+ }
305
+ return entries;
306
+ }
307
+ async function resolveLocalTarget(ctx, request, resolvedPath) {
308
+ const workspace = request.workspace;
309
+ let workspaceHostRoot = ctx.workspaceHostRoot;
310
+ if (workspace.localRoot.kind === "workspace_relative" && !workspaceHostRoot?.trim()) {
311
+ try {
312
+ workspaceHostRoot = resolveAgentWorkspaceDir(ctx.cfg, ctx.config.agentId || "main");
313
+ }
314
+ catch {
315
+ throw new WorkspaceEventError("READ_FAILED", "无法解析 Agent 工作区目录");
316
+ }
317
+ }
318
+ let localRoot;
319
+ try {
320
+ localRoot = resolveLocalAbsolutePath(workspace.localRoot, workspaceHostRoot);
321
+ }
322
+ catch {
323
+ throw new WorkspaceEventError("INVALID_REQUEST", "本地工作区配置无效");
324
+ }
325
+ const realRoot = await existingRealRoot(localRoot);
326
+ const target = await resolveExistingTarget(realRoot, resolvedPath.displayPath);
327
+ return { realRoot, target };
328
+ }
329
+ function normalizeNasDeclarations(request) {
330
+ const workspace = request.workspace;
331
+ const declarations = workspace.nasVolumes
332
+ .map((volume) => {
333
+ try {
334
+ return {
335
+ dockerDir: normalizeNasDockerDir(volume.dockerDir),
336
+ realDir: volume.realDir,
337
+ };
338
+ }
339
+ catch {
340
+ return undefined;
341
+ }
342
+ })
343
+ .filter((volume) => Boolean(volume));
344
+ if (declarations.length === 0) {
345
+ throw new WorkspaceEventError("INVALID_REQUEST", "NAS 授权目录声明无效");
346
+ }
347
+ return declarations;
348
+ }
349
+ async function resolveNasDeclaration(volume, ctx) {
350
+ try {
351
+ return {
352
+ ...volume,
353
+ hostDir: await resolveNasVolumeHostDirectoryForRead(volume.realDir, ctx.config),
354
+ };
355
+ }
356
+ catch {
357
+ throw new WorkspaceEventError("READ_FAILED", "NAS 授权目录当前不可访问");
358
+ }
359
+ }
360
+ function pathContains(root, target) {
361
+ return target === root || target.startsWith(`${root}/`);
362
+ }
363
+ function selectContainingNasVolume(volumes, logicalPath) {
364
+ return volumes
365
+ .filter((volume) => pathContains(volume.dockerDir, logicalPath))
366
+ .sort((left, right) => right.dockerDir.length - left.dockerDir.length)[0];
367
+ }
368
+ async function listNasDirectory(request, resolvedPath, ctx) {
369
+ const declarations = normalizeNasDeclarations(request);
370
+ const entries = new Map();
371
+ const containing = selectContainingNasVolume(declarations, resolvedPath.logicalPath);
372
+ let resolvedAny = false;
373
+ let resolvedContaining;
374
+ if (containing) {
375
+ try {
376
+ resolvedContaining = await resolveNasDeclaration(containing, ctx);
377
+ }
378
+ catch {
379
+ ctx.log.warn?.(`${ctx.logPrefix} [workspace] unavailable NAS mount skipped: dockerDir=${clipPath(containing.dockerDir)}`);
380
+ }
381
+ }
382
+ if (resolvedContaining) {
383
+ const suffix = resolvedPath.logicalPath === resolvedContaining.dockerDir
384
+ ? ""
385
+ : resolvedPath.logicalPath.slice(resolvedContaining.dockerDir.length + 1);
386
+ const realRoot = await existingRealRoot(resolvedContaining.hostDir);
387
+ const target = await resolveExistingTarget(realRoot, suffix);
388
+ for (const entry of await listPhysicalDirectory(realRoot, target, resolvedPath.displayPath)) {
389
+ entries.set(entry.name, entry);
390
+ }
391
+ resolvedAny = true;
392
+ }
393
+ const prefix = resolvedPath.logicalPath ? `${resolvedPath.logicalPath}/` : "";
394
+ const descendants = declarations.filter((volume) => resolvedPath.logicalPath ? volume.dockerDir.startsWith(prefix) : true);
395
+ const resolvedDescendants = await Promise.all(descendants.map(async (volume) => {
396
+ try {
397
+ return await resolveNasDeclaration(volume, ctx);
398
+ }
399
+ catch {
400
+ ctx.log.warn?.(`${ctx.logPrefix} [workspace] unavailable NAS mount skipped: dockerDir=${clipPath(volume.dockerDir)}`);
401
+ return undefined;
402
+ }
403
+ }));
404
+ for (const volume of resolvedDescendants) {
405
+ if (!volume)
406
+ continue;
407
+ resolvedAny = true;
408
+ const suffix = resolvedPath.logicalPath
409
+ ? volume.dockerDir.slice(prefix.length)
410
+ : volume.dockerDir;
411
+ if (!suffix)
412
+ continue;
413
+ const name = suffix.split("/")[0];
414
+ const info = await stat(volume.hostDir);
415
+ const existing = entries.get(name);
416
+ const modifiedAt = Math.max(existing?.modifiedAt ?? 0, Math.trunc(info.mtimeMs));
417
+ entries.set(name, {
418
+ name,
419
+ path: joinDisplayPath(resolvedPath.displayPath, name),
420
+ type: "DIRECTORY",
421
+ modifiedAt,
422
+ });
423
+ }
424
+ if (!containing && descendants.length === 0) {
425
+ throw new WorkspaceEventError("PATH_OUTSIDE_WORKSPACE", "路径不在任何 NAS 授权目录内");
426
+ }
427
+ if (!resolvedAny) {
428
+ throw new WorkspaceEventError("READ_FAILED", "NAS 授权目录当前均不可访问");
429
+ }
430
+ return [...entries.values()];
431
+ }
432
+ async function resolveNasFileTarget(request, resolvedPath, ctx) {
433
+ const declarations = normalizeNasDeclarations(request);
434
+ const containing = selectContainingNasVolume(declarations, resolvedPath.logicalPath);
435
+ if (!containing) {
436
+ throw new WorkspaceEventError("PATH_OUTSIDE_WORKSPACE", "文件不在任何 NAS 授权目录内");
437
+ }
438
+ const resolved = await resolveNasDeclaration(containing, ctx);
439
+ const suffix = resolvedPath.logicalPath === resolved.dockerDir
440
+ ? ""
441
+ : resolvedPath.logicalPath.slice(resolved.dockerDir.length + 1);
442
+ const realRoot = await existingRealRoot(resolved.hostDir);
443
+ const target = await resolveExistingTarget(realRoot, suffix);
444
+ return { realRoot, target };
445
+ }
446
+ function contentTypeFor(fileName) {
447
+ const extension = path.extname(fileName).toLowerCase();
448
+ if (extension === ".md")
449
+ return "text/markdown; charset=utf-8";
450
+ if (extension === ".html")
451
+ return "text/html; charset=utf-8";
452
+ if (extension === ".json")
453
+ return "application/json; charset=utf-8";
454
+ throw new WorkspaceEventError("UNSUPPORTED_FILE_TYPE", "只支持 JSON、MD、HTML 文件");
455
+ }
456
+ async function readTextFile(target, requestedFileName) {
457
+ const fileName = requestedFileName;
458
+ const contentType = contentTypeFor(fileName);
459
+ let info;
460
+ try {
461
+ info = await stat(target);
462
+ }
463
+ catch (error) {
464
+ if (isFsCode(error, "ENOENT"))
465
+ throw new WorkspaceEventError("NOT_FOUND", "文件不存在");
466
+ throw new WorkspaceEventError("READ_FAILED", "无法读取文件信息");
467
+ }
468
+ if (!info.isFile())
469
+ throw new WorkspaceEventError("NOT_A_FILE", "请求路径不是文件");
470
+ if (info.size > MAX_FILE_BYTES) {
471
+ throw new WorkspaceEventError("FILE_TOO_LARGE", "文件超过1MiB,无法预览或下载");
472
+ }
473
+ let buffer;
474
+ try {
475
+ buffer = await readFile(target);
476
+ }
477
+ catch {
478
+ throw new WorkspaceEventError("READ_FAILED", "无法读取文件");
479
+ }
480
+ if (buffer.length > MAX_FILE_BYTES) {
481
+ throw new WorkspaceEventError("FILE_TOO_LARGE", "文件超过1MiB,无法预览或下载");
482
+ }
483
+ const decoded = decodeTextBuffer(buffer);
484
+ const latest = await stat(target);
485
+ return {
486
+ fileName,
487
+ size: buffer.length,
488
+ modifiedAt: Math.trunc(latest.mtimeMs),
489
+ contentType,
490
+ content: decoded.content,
491
+ sourceEncoding: decoded.encoding,
492
+ };
493
+ }
494
+ /**
495
+ * 无第三方依赖的低成本文本编码兼容:UTF-8 优先;UTF-16 仅认 BOM;最后尝试
496
+ * GB18030(覆盖常见 GBK/GB2312)。回传 JSON 仍统一由 WebSocket 编码为 UTF-8。
497
+ */
498
+ function decodeTextBuffer(buffer) {
499
+ try {
500
+ return {
501
+ content: new TextDecoder("utf-8", { fatal: true }).decode(buffer),
502
+ encoding: "utf-8",
503
+ };
504
+ }
505
+ catch {
506
+ // 继续尝试带 BOM 的 UTF-16 和中文环境常见编码。
507
+ }
508
+ if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
509
+ try {
510
+ return {
511
+ content: new TextDecoder("utf-16le", { fatal: true }).decode(buffer),
512
+ encoding: "utf-16le",
513
+ };
514
+ }
515
+ catch {
516
+ // 继续尝试 GB18030。
517
+ }
518
+ }
519
+ if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
520
+ try {
521
+ return {
522
+ content: new TextDecoder("utf-16be", { fatal: true }).decode(buffer),
523
+ encoding: "utf-16be",
524
+ };
525
+ }
526
+ catch {
527
+ // 继续尝试 GB18030。
528
+ }
529
+ }
530
+ try {
531
+ return {
532
+ content: new TextDecoder("gb18030", { fatal: true }).decode(buffer),
533
+ encoding: "gb18030",
534
+ };
535
+ }
536
+ catch {
537
+ throw new WorkspaceEventError("READ_FAILED", "文件不是支持的文本编码");
538
+ }
539
+ }
540
+ function responseBytes(params) {
541
+ return Buffer.byteLength(JSON.stringify({ cmd: OPENCLAW_WORKSPACE_EVENT_CMD, params }), "utf8");
542
+ }
543
+ function baseResponse(request, resolvedPath) {
544
+ return {
545
+ _wsRelayId: request.relayId,
546
+ event: request.event === "TREE_REQUEST" ? "TREE_RESULT" : "FILE_RESULT",
547
+ groupId: request.groupId,
548
+ path: resolvedPath.displayPath,
549
+ };
550
+ }
551
+ function configuredBaseResponse(request, resolvedPath, config) {
552
+ const base = baseResponse(request, resolvedPath);
553
+ if (request.workspace.sourceType === "NAS")
554
+ base.nasSiteCode = resolveNasSiteCode(config);
555
+ return base;
556
+ }
557
+ function fallbackFields(raw, config) {
558
+ const record = asRecord(raw) ?? {};
559
+ const workspace = asRecord(record.workspace);
560
+ const isNas = typeof workspace?.sourceType === "string" && workspace.sourceType.trim().toUpperCase() === "NAS";
561
+ const event = record.event === "FILE_REQUEST" ? "FILE_RESULT" : "TREE_RESULT";
562
+ return {
563
+ _wsRelayId: boundedFallbackString(record._wsRelayId, 256),
564
+ event,
565
+ groupId: boundedFallbackString(record.groupId, 128),
566
+ ...(isNas ? { nasSiteCode: resolveNasSiteCode(config) } : {}),
567
+ path: "",
568
+ };
569
+ }
570
+ function boundedFallbackString(value, maxLength) {
571
+ return typeof value === "string" && value.length <= maxLength ? value.trim() : "";
572
+ }
573
+ function failureResponse(base, code, message) {
574
+ return { ...base, success: false, errorCode: code, message };
575
+ }
576
+ function fitTreeResponse(base, displayPath, page) {
577
+ const entries = [...page.entries];
578
+ let nextCursor = page.nextCursor;
579
+ while (true) {
580
+ const response = { ...base, success: true, entries, nextCursor };
581
+ if (responseBytes(response) <= MAX_WS_RESPONSE_BYTES)
582
+ return response;
583
+ const removed = entries.pop();
584
+ if (!removed || entries.length === 0) {
585
+ throw new WorkspaceEventError("READ_FAILED", "目录项过大,无法通过WebSocket返回");
586
+ }
587
+ nextCursor = encodeCursor(displayPath, entries[entries.length - 1]);
588
+ }
589
+ }
590
+ /** 实际执行一次工作区读取;并发控制由导出的 handleWorkspaceEvent 统一处理。 */
591
+ async function executeWorkspaceEvent(ctx, rawParams) {
592
+ let request;
593
+ let resolvedPath;
594
+ try {
595
+ request = parseWorkspaceRequest(rawParams);
596
+ resolvedPath = resolveRequestPath(request.workspace.rootPath, request.fullPath);
597
+ const base = configuredBaseResponse(request, resolvedPath, ctx.config);
598
+ if (request.event === "TREE_REQUEST") {
599
+ const entries = request.workspace.sourceType === "NAS"
600
+ ? await listNasDirectory(request, resolvedPath, ctx)
601
+ : await (async () => {
602
+ const target = await resolveLocalTarget(ctx, request, resolvedPath);
603
+ return listPhysicalDirectory(target.realRoot, target.target, resolvedPath.displayPath);
604
+ })();
605
+ const page = paginateEntries(entries, resolvedPath.displayPath, request.limit, request.cursor);
606
+ const response = fitTreeResponse(base, resolvedPath.displayPath, page);
607
+ ctx.log.info(`${ctx.logPrefix} [workspace] event=TREE_REQUEST groupId=${request.groupId} ` +
608
+ `relayId=${clipPath(request.relayId)} path=${clipPath(resolvedPath.displayPath)} entries=${response.entries.length}`);
609
+ return response;
610
+ }
611
+ const target = request.workspace.sourceType === "NAS"
612
+ ? await resolveNasFileTarget(request, resolvedPath, ctx)
613
+ : await resolveLocalTarget(ctx, request, resolvedPath);
614
+ const readResult = await readTextFile(target.target, path.posix.basename(resolvedPath.displayPath));
615
+ const { sourceEncoding, ...file } = readResult;
616
+ const response = { ...base, success: true, ...file };
617
+ if (responseBytes(response) > MAX_WS_RESPONSE_BYTES) {
618
+ throw new WorkspaceEventError("FILE_TOO_LARGE", "文件编码后超过WebSocket返回上限");
619
+ }
620
+ ctx.log.info(`${ctx.logPrefix} [workspace] event=FILE_REQUEST groupId=${request.groupId} ` +
621
+ `relayId=${clipPath(request.relayId)} path=${clipPath(resolvedPath.displayPath)} ` +
622
+ `size=${file.size} encoding=${sourceEncoding}`);
623
+ return response;
624
+ }
625
+ catch (error) {
626
+ const workspaceError = error instanceof WorkspaceEventError
627
+ ? error
628
+ : new WorkspaceEventError("READ_FAILED", "工作区读取失败");
629
+ const base = request && resolvedPath
630
+ ? configuredBaseResponse(request, resolvedPath, ctx.config)
631
+ : fallbackFields(rawParams, ctx.config);
632
+ ctx.log.warn?.(`${ctx.logPrefix} [workspace] failed event=${String(base.event)} groupId=${String(base.groupId)} ` +
633
+ `relayId=${clipPath(String(base._wsRelayId ?? ""))} path=${clipPath(String(base.path ?? ""))} code=${workspaceError.code}`);
634
+ return failureResponse(base, workspaceError.code, workspaceError.message);
635
+ }
636
+ }
637
+ function busyWorkspaceResponse(ctx, rawParams, reason) {
638
+ let base = fallbackFields(rawParams, ctx.config);
639
+ try {
640
+ const request = parseWorkspaceRequest(rawParams);
641
+ const resolvedPath = resolveRequestPath(request.workspace.rootPath, request.fullPath);
642
+ base = configuredBaseResponse(request, resolvedPath, ctx.config);
643
+ }
644
+ catch {
645
+ // 忙碌回包优先保留 relay/event/groupId;请求本身的详细校验留给非过载路径。
646
+ }
647
+ const state = workspaceEventScheduler.snapshot();
648
+ ctx.log.warn?.(`${ctx.logPrefix} [workspace] busy reason=${reason} event=${String(base.event)} ` +
649
+ `groupId=${String(base.groupId)} relayId=${clipPath(String(base._wsRelayId ?? ""))} ` +
650
+ `active=${state.activeTotal} files=${state.activeFiles} queued=${state.queued}`);
651
+ return failureResponse(base, "BUSY", "工作区请求较多,请稍后重试");
652
+ }
653
+ /**
654
+ * 处理工作区请求。所有账户共享进程级调度器;无效 event 不进入队列,直接返回协议错误。
655
+ */
656
+ export async function handleWorkspaceEvent(ctx, rawParams) {
657
+ const rawEvent = asRecord(rawParams)?.event;
658
+ if (rawEvent !== "TREE_REQUEST" && rawEvent !== "FILE_REQUEST") {
659
+ return executeWorkspaceEvent(ctx, rawParams);
660
+ }
661
+ const kind = rawEvent === "FILE_REQUEST" ? "FILE" : "TREE";
662
+ return workspaceEventScheduler.run({
663
+ agentKey: ctx.config.agentId?.trim() || "main",
664
+ kind,
665
+ limits: resolveWorkspaceEventConcurrencyLimits(ctx.config),
666
+ task: () => executeWorkspaceEvent(ctx, rawParams),
667
+ onBusy: (reason) => busyWorkspaceResponse(ctx, rawParams, reason),
668
+ });
669
+ }
670
+ function clipPath(value) {
671
+ const normalized = value.replace(/[\r\n\t]/g, " ");
672
+ return normalized.length <= 160 ? normalized : `${normalized.slice(0, 157)}...`;
673
+ }
674
+ //# sourceMappingURL=workspace-event.js.map