@dai_ming/plugin-deliverables 1.0.20 → 1.0.22

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/INSTALL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # plugin-deliverables 安装文档
2
2
 
3
- 本文档描述 `@dai_ming/plugin-deliverables@1.0.16` 的安装、升级与验证方式。
3
+ 本文档描述 `@dai_ming/plugin-deliverables@1.0.22` 的安装、升级与验证方式。
4
4
 
5
5
  ## 1. 目标
6
6
 
@@ -22,7 +22,7 @@
22
22
  适用于已经支持 `openclaw plugins install` 的运行环境。
23
23
 
24
24
  ```bash
25
- openclaw plugins install @dai_ming/plugin-deliverables@1.0.16 --pin
25
+ openclaw plugins install @dai_ming/plugin-deliverables@1.0.22 --pin
26
26
  openclaw plugins enable plugin-deliverables
27
27
  ```
28
28
 
@@ -47,7 +47,7 @@ openclaw plugins list
47
47
 
48
48
  ```yaml
49
49
  installPlugins:
50
- - "@dai_ming/plugin-deliverables@1.0.16"
50
+ - "@dai_ming/plugin-deliverables@1.0.22"
51
51
  ```
52
52
 
53
53
  Helm 初始化时会自动完成:
@@ -66,8 +66,8 @@ Helm 初始化时会自动完成:
66
66
  ```bash
67
67
  mkdir -p /home/node/.openclaw/extensions-extra/plugin-deliverables
68
68
  cd /tmp
69
- npm pack @dai_ming/plugin-deliverables@1.0.16 --registry https://registry.npmjs.org
70
- tar xzf dai_ming-plugin-deliverables-1.0.16.tgz \
69
+ npm pack @dai_ming/plugin-deliverables@1.0.22 --registry https://registry.npmjs.org
70
+ tar xzf dai_ming-plugin-deliverables-1.0.22.tgz \
71
71
  -C /home/node/.openclaw/extensions-extra/plugin-deliverables \
72
72
  --strip-components=1
73
73
  ```
@@ -212,5 +212,5 @@ cat /home/node/.openclaw/extensions-extra/plugin-deliverables/package.json
212
212
  必须是:
213
213
 
214
214
  ```json
215
- { "version": "1.0.16" }
215
+ { "version": "1.0.22" }
216
216
  ```
package/README.md CHANGED
@@ -24,17 +24,18 @@ OpenClaw 交付物插件 — 让 AI Agent 将生成的文件(文章、HTML 页
24
24
  现在这个包同时支持 OpenClaw 原生插件加载。也就是说,除了网关侧用 `openclaw-plugin.json` 注入 MCP/skill/AGENTS 规则外,OpenClaw 还会读取 `openclaw.plugin.json` + `index.js`,把运行时 hook 也一并启用。
25
25
 
26
26
  ```bash
27
- openclaw plugins install @dai_ming/plugin-deliverables@1.0.16 --pin
27
+ openclaw plugins install @dai_ming/plugin-deliverables@1.0.22 --pin
28
28
  openclaw plugins enable plugin-deliverables
29
29
  ```
30
30
 
31
31
  这一步启用后,插件会额外提供三层兜底:
32
32
 
33
33
  1. `before_prompt_build`:把“交付物必须 upload-first”的硬规则注入到主 agent 和子 agent 的系统上下文
34
- 2. `before_tool_call`:阻止通过 `message` 附件字段直接发文件
35
- 3. `message_sending`:如果仍然有媒体/文件旁路发送,最终发送前直接取消
34
+ 2. `before_tool_call`:记录 `write` 生成的工作区文件,并阻止通过 `message` 附件字段直接发文件
35
+ 3. Palz 出站补丁:最终消息发送前扫描内部工作区路径/近期写入文件,自动上传为交付物并改写成 gateway 链接
36
+ 4. `message_sending`:如果仍然有媒体/文件旁路发送,最终发送前直接取消
36
37
 
37
- > 注意:`message_sending` 只能改文本或取消发送,不能自动把错误发送重写成 `upload_deliverable`。所以它的职责是“兜底阻断”,不是“自动修正”。
38
+ > 注意:非 Palz 渠道仍以 `before_prompt_build` + `before_tool_call` + `message_sending` 兜底为主;Palz 渠道具备自动上传和文本改写能力。
38
39
 
39
40
  ### 方式一:通过 claw-gateway Helm 部署(推荐)
40
41
 
@@ -43,11 +44,11 @@ openclaw plugins enable plugin-deliverables
43
44
  ```yaml
44
45
  # values.yaml(或 claw-gateway 管理界面的 Helm 参数)
45
46
  installPlugins:
46
- - "@dai_ming/plugin-deliverables@1.0.16"
47
+ - "@dai_ming/plugin-deliverables@1.0.22"
47
48
  ```
48
49
 
49
50
  initContainer 执行顺序:
50
- 1. **Phase 3**:`npm pack @dai_ming/plugin-deliverables@1.0.16` 下载 tarball → 解压到 `/data/extensions-extra/plugin-deliverables/`
51
+ 1. **Phase 3**:`npm pack @dai_ming/plugin-deliverables@1.0.22` 下载 tarball → 解压到 `/data/extensions-extra/plugin-deliverables/`
51
52
  2. **Phase 3b**:在插件目录执行 `npm install --omit=dev`(本插件无运行时依赖,此步骤跳过)
52
53
  3. **Phase 3e**:读取 `openclaw-plugin.json` 清单,自动:
53
54
  - 复制 `mcp-servers/deliverables.js` → `/data/mcp-servers/deliverables.js`
@@ -197,10 +198,11 @@ fi
197
198
  | Hook | 作用 |
198
199
  |------|------|
199
200
  | `before_prompt_build` | 把“生成文件必须走交付物上传”的硬规则注入系统上下文,覆盖主 agent 和子 agent |
200
- | `before_tool_call` | 阻止通过 `message` 工具的 `media/path/filePath/buffer` 等字段直接发送文件 |
201
+ | `before_tool_call` | 记录 `write` 生成的工作区文件;阻止通过 `message` 工具的 `media/path/filePath/buffer` 等字段直接发送文件 |
202
+ | Palz 出站补丁 | 扫描最终消息中的 `/data/workspace-*`、`output/*.ext`、反引号文件名等工作区文件引用,自动上传后替换为交付物链接 |
201
203
  | `message_sending` | 如果上游仍然产生了媒体/文件旁路发送,在最终出站前直接取消 |
202
204
 
203
- 这三层一起工作的目标是:即使 prompt 漂移,也尽量把“message + file/media”这条旁路堵住,统一收敛到 `deliverables__upload_deliverable`。
205
+ 这些兜底一起工作的目标是:即使 prompt 漂移,也尽量把“工作区路径 / message + file/media”这类旁路堵住,统一收敛到 gateway 交付物链接。
204
206
 
205
207
  另外,Palz 渠道下如果交付物回复包含可信 gateway 交付物链接,会被拆成“内容简介 + 最终链接”两条消息;任意外部 URL 或非 gateway OSS URL 不会被包装成 `file_url` 文件消息。runtime plugin 还会分别打印这两次真实 HTTP 发送的 request/response 日志,便于直接从 pod stdout 排查。
206
208
 
@@ -224,7 +226,7 @@ npm publish --registry https://registry.npmjs.org --access public
224
226
 
225
227
  | claw-gateway 版本 | plugin 版本 |
226
228
  |-------------------|-------------|
227
- | 当前 | 1.0.16 |
229
+ | 当前 | 1.0.22 |
228
230
 
229
231
  ---
230
232
 
package/index.js CHANGED
@@ -1,16 +1,76 @@
1
1
  "use strict";
2
2
 
3
+ const fs = require("fs");
4
+ const http = require("http");
5
+ const https = require("https");
6
+ const path = require("path");
7
+
3
8
  const FETCH_PATCH_KEY = "__plugin_deliverables_palz_fetch_patch__";
4
9
  const SUMMARY_CACHE_KEY = "__plugin_deliverables_summary_cache__";
10
+ const PENDING_FILES_KEY = "__plugin_deliverables_pending_files__";
11
+ const UPLOAD_CACHE_KEY = "__plugin_deliverables_upload_cache__";
5
12
  const SUMMARY_CACHE_LIMIT = 200;
6
13
  const SHORT_SUMMARY_THRESHOLD = 120;
7
14
  const DEFAULT_GATEWAY_PUBLIC_URL = "https://claw-gateway.csagentai.com";
8
15
  const DEFAULT_GATEWAY_INTERNAL_URL = "http://claw-gateway:8080";
16
+ const AUTO_UPLOAD_TTL_MS = 15 * 60 * 1000;
17
+ const AUTO_UPLOAD_MAX_FILES = 8;
18
+ const AUTO_UPLOAD_MAX_BYTES = 200 * 1024 * 1024;
19
+ const AUTO_UPLOAD_SCAN_MAX_ENTRIES = 4000;
20
+ const AUTO_UPLOAD_SCAN_DEPTH = 5;
9
21
  const DELIVERABLE_GATEWAY_PATH_RE = /^\/openclaw-gateway\/(?:output|preview)(?:\/|$)/u;
10
22
  const DELIVERABLE_LINK_PREFIX_RE =
11
23
  /^\s*(?:[-*+]\s+)?(?:预览链接|下载链接|文件列表|项目入口|在线预览|在线体验|目录链接)\s*[::]/u;
12
24
  const DELIVERABLE_LINK_LABEL_RE =
13
25
  /^\s*(?:[-*+]\s+)?(?:预览链接|下载链接|文件列表|项目入口|在线预览|在线体验|目录链接)\s*[::]\s*\[[^\]]+\]\([^)]+\)\s*$/u;
26
+ const INTERNAL_ABSOLUTE_PATH_RE =
27
+ /(?:\/data\/workspace-[^\s`"'<>,。;:、))\]}]+|\/home\/node\/\.openclaw\/workspace(?:-[A-Za-z0-9_.-]+)?\/[^\s`"'<>,。;:、))\]}]+)/gu;
28
+ const INLINE_FILE_REF_RE = /`([^`\n]{1,512}\.[A-Za-z0-9]{1,16})`/gu;
29
+ const OUTPUT_RELATIVE_REF_RE = /(?:^|[\s(::])((?:\.\/)?output\/[^\s`"'<>,。;:、))\]}]+\.[A-Za-z0-9]{1,16})/gu;
30
+ const FILE_EXTENSION_RE = /\.[A-Za-z0-9]{1,16}$/u;
31
+ const TEXT_EXTENSIONS = new Set([
32
+ ".css",
33
+ ".csv",
34
+ ".html",
35
+ ".htm",
36
+ ".js",
37
+ ".json",
38
+ ".jsx",
39
+ ".log",
40
+ ".md",
41
+ ".markdown",
42
+ ".mjs",
43
+ ".py",
44
+ ".sql",
45
+ ".svg",
46
+ ".ts",
47
+ ".tsx",
48
+ ".txt",
49
+ ".xml",
50
+ ".yaml",
51
+ ".yml",
52
+ ]);
53
+ const IMAGE_EXTENSIONS = new Set([".apng", ".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
54
+ const VIDEO_EXTENSIONS = new Set([".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"]);
55
+ const PPT_EXTENSIONS = new Set([".ppt", ".pptx"]);
56
+ const ARCHIVE_EXTENSIONS = new Set([".7z", ".gz", ".rar", ".tar", ".tgz", ".zip"]);
57
+ const IGNORED_PATH_PARTS = new Set([
58
+ ".git",
59
+ ".hg",
60
+ ".svn",
61
+ "node_modules",
62
+ "sessions",
63
+ "skills",
64
+ ".cache",
65
+ "__pycache__",
66
+ ]);
67
+ const IGNORED_FILE_NAMES = new Set([
68
+ "AGENTS.md",
69
+ "SOUL.md",
70
+ "openclaw.json",
71
+ "openclaw-runtime.json",
72
+ "package-lock.json",
73
+ ]);
14
74
 
15
75
  const RUNTIME_DELIVERABLES_GUIDANCE = [
16
76
  "## Deliverables Runtime Guard (HARD CONSTRAINT)",
@@ -29,6 +89,648 @@ function isString(value) {
29
89
  return typeof value === "string";
30
90
  }
31
91
 
92
+ function trimString(value) {
93
+ return isString(value) ? value.trim() : "";
94
+ }
95
+
96
+ function getPendingFileStore() {
97
+ if (!globalThis[PENDING_FILES_KEY] || !(globalThis[PENDING_FILES_KEY] instanceof Map)) {
98
+ globalThis[PENDING_FILES_KEY] = new Map();
99
+ }
100
+ return globalThis[PENDING_FILES_KEY];
101
+ }
102
+
103
+ function getUploadCache() {
104
+ if (!globalThis[UPLOAD_CACHE_KEY] || !(globalThis[UPLOAD_CACHE_KEY] instanceof Map)) {
105
+ globalThis[UPLOAD_CACHE_KEY] = new Map();
106
+ }
107
+ return globalThis[UPLOAD_CACHE_KEY];
108
+ }
109
+
110
+ function pruneTimedMap(map, ttlMs) {
111
+ const now = Date.now();
112
+ for (const [key, value] of map.entries()) {
113
+ if (!value || !value.createdAt || now - Number(value.createdAt) > ttlMs) {
114
+ map.delete(key);
115
+ }
116
+ }
117
+ }
118
+
119
+ function normalizeSlash(value) {
120
+ return String(value || "").replace(/\\/g, "/");
121
+ }
122
+
123
+ function stripTrailingPathPunctuation(value) {
124
+ return String(value || "").replace(/[.,;:!?,。;:!?、))\]}》】]+$/u, "");
125
+ }
126
+
127
+ function hasFileExtension(value) {
128
+ return FILE_EXTENSION_RE.test(path.basename(stripTrailingPathPunctuation(value)));
129
+ }
130
+
131
+ function isURLLike(value) {
132
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(String(value || ""));
133
+ }
134
+
135
+ function fileExtension(filePath) {
136
+ return path.extname(stripTrailingPathPunctuation(filePath)).toLowerCase();
137
+ }
138
+
139
+ function isTextLikeFile(filePath) {
140
+ return TEXT_EXTENSIONS.has(fileExtension(filePath));
141
+ }
142
+
143
+ function deliverableTypeForPath(filePath, isDirectory) {
144
+ if (isDirectory) {
145
+ return "game";
146
+ }
147
+ const ext = fileExtension(filePath);
148
+ if (IMAGE_EXTENSIONS.has(ext) && ext !== ".svg") {
149
+ return "image";
150
+ }
151
+ if (VIDEO_EXTENSIONS.has(ext)) {
152
+ return "video";
153
+ }
154
+ if (PPT_EXTENSIONS.has(ext)) {
155
+ return "ppt";
156
+ }
157
+ if (ARCHIVE_EXTENSIONS.has(ext)) {
158
+ return "zip";
159
+ }
160
+ return "article";
161
+ }
162
+
163
+ function isAllowedWorkspacePath(candidatePath) {
164
+ const normalized = normalizeSlash(path.resolve(candidatePath));
165
+ return (
166
+ normalized.indexOf("/data/workspace-") === 0 ||
167
+ normalized.indexOf("/home/node/.openclaw/workspace") === 0
168
+ );
169
+ }
170
+
171
+ function shouldIgnorePath(candidatePath) {
172
+ const normalized = normalizeSlash(candidatePath);
173
+ const parts = normalized.split("/").filter(Boolean);
174
+ for (const part of parts) {
175
+ if (IGNORED_PATH_PARTS.has(part)) {
176
+ return true;
177
+ }
178
+ }
179
+ return IGNORED_FILE_NAMES.has(path.basename(normalized));
180
+ }
181
+
182
+ function safeStat(candidatePath) {
183
+ try {
184
+ return fs.statSync(candidatePath);
185
+ } catch (_err) {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function workspaceRoots() {
191
+ const roots = [];
192
+ ["/home/node/.openclaw/workspace-main", "/home/node/.openclaw/workspace"].forEach((root) => {
193
+ if (safeStat(root)) {
194
+ roots.push(root);
195
+ }
196
+ });
197
+ try {
198
+ for (const entry of fs.readdirSync("/data")) {
199
+ if (!entry || entry.indexOf("workspace-") !== 0) {
200
+ continue;
201
+ }
202
+ const root = path.join("/data", entry);
203
+ if (safeStat(root)) {
204
+ roots.push(root);
205
+ }
206
+ }
207
+ } catch (_err) {
208
+ // /data is not guaranteed in non-pod test environments.
209
+ }
210
+ return roots;
211
+ }
212
+
213
+ function resolveCandidatePath(rawPath) {
214
+ const cleaned = stripTrailingPathPunctuation(trimString(rawPath));
215
+ if (!cleaned || isURLLike(cleaned)) {
216
+ return "";
217
+ }
218
+ if (path.isAbsolute(cleaned)) {
219
+ return path.resolve(cleaned);
220
+ }
221
+ return cleaned;
222
+ }
223
+
224
+ function findFileByRelativeOrBaseName(rawRef) {
225
+ const cleaned = stripTrailingPathPunctuation(trimString(rawRef)).replace(/^\.\//, "");
226
+ if (!cleaned || isURLLike(cleaned) || !hasFileExtension(cleaned)) {
227
+ return "";
228
+ }
229
+ const roots = workspaceRoots();
230
+ const directCandidates = [];
231
+ for (const root of roots) {
232
+ directCandidates.push(path.join(root, cleaned));
233
+ directCandidates.push(path.join(root, "output", cleaned));
234
+ directCandidates.push(path.join(root, path.basename(cleaned)));
235
+ directCandidates.push(path.join(root, "output", path.basename(cleaned)));
236
+ }
237
+ for (const candidate of directCandidates) {
238
+ const stat = safeStat(candidate);
239
+ if (stat && (stat.isFile() || stat.isDirectory()) && isAllowedWorkspacePath(candidate) && !shouldIgnorePath(candidate)) {
240
+ return path.resolve(candidate);
241
+ }
242
+ }
243
+
244
+ const basename = path.basename(cleaned);
245
+ const found = [];
246
+ let visited = 0;
247
+ function scan(dir, depth) {
248
+ if (depth < 0 || visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES || shouldIgnorePath(dir)) {
249
+ return;
250
+ }
251
+ let entries;
252
+ try {
253
+ entries = fs.readdirSync(dir, { withFileTypes: true });
254
+ } catch (_err) {
255
+ return;
256
+ }
257
+ for (const entry of entries) {
258
+ if (visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES) {
259
+ return;
260
+ }
261
+ visited += 1;
262
+ const full = path.join(dir, entry.name);
263
+ if (shouldIgnorePath(full)) {
264
+ continue;
265
+ }
266
+ if (entry.isDirectory()) {
267
+ scan(full, depth - 1);
268
+ continue;
269
+ }
270
+ if (entry.isFile() && entry.name === basename) {
271
+ const stat = safeStat(full);
272
+ if (stat) {
273
+ found.push({ path: full, mtimeMs: stat.mtimeMs });
274
+ }
275
+ }
276
+ }
277
+ }
278
+ for (const root of roots) {
279
+ scan(root, AUTO_UPLOAD_SCAN_DEPTH);
280
+ }
281
+ found.sort((a, b) => b.mtimeMs - a.mtimeMs);
282
+ return found.length > 0 ? path.resolve(found[0].path) : "";
283
+ }
284
+
285
+ function extractFileReferencesFromText(text) {
286
+ const normalized = String(text || "");
287
+ const refs = [];
288
+ const seen = new Set();
289
+ function add(raw, value, kind) {
290
+ const cleaned = stripTrailingPathPunctuation(value);
291
+ if (!cleaned || isURLLike(cleaned)) {
292
+ return;
293
+ }
294
+ const key = `${kind}:${cleaned}`;
295
+ if (seen.has(key)) {
296
+ return;
297
+ }
298
+ seen.add(key);
299
+ refs.push({ raw, value: cleaned, kind });
300
+ }
301
+
302
+ for (const match of normalized.matchAll(INTERNAL_ABSOLUTE_PATH_RE)) {
303
+ add(match[0], match[0], "absolute");
304
+ }
305
+ for (const match of normalized.matchAll(INLINE_FILE_REF_RE)) {
306
+ add(match[0], match[1], "inline");
307
+ }
308
+ for (const match of normalized.matchAll(OUTPUT_RELATIVE_REF_RE)) {
309
+ add(match[1], match[1], "relative");
310
+ }
311
+ return refs;
312
+ }
313
+
314
+ function resolveFileReference(ref) {
315
+ if (!ref || !ref.value) {
316
+ return null;
317
+ }
318
+ const candidate = resolveCandidatePath(ref.value);
319
+ const resolved = path.isAbsolute(candidate) ? candidate : findFileByRelativeOrBaseName(candidate);
320
+ if (!resolved) {
321
+ return null;
322
+ }
323
+ const stat = safeStat(resolved);
324
+ if (!stat || (!stat.isFile() && !stat.isDirectory())) {
325
+ return null;
326
+ }
327
+ if (!isAllowedWorkspacePath(resolved) || shouldIgnorePath(resolved)) {
328
+ return null;
329
+ }
330
+ return {
331
+ raw: ref.raw,
332
+ path: resolved,
333
+ fileName: path.basename(resolved),
334
+ isDirectory: stat.isDirectory(),
335
+ size: stat.isDirectory() ? 0 : stat.size,
336
+ mtimeMs: stat.mtimeMs,
337
+ };
338
+ }
339
+
340
+ function collectDirectoryFiles(dirPath) {
341
+ const files = [];
342
+ let total = 0;
343
+ let visited = 0;
344
+ function scan(current, depth) {
345
+ if (depth < 0 || visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES || shouldIgnorePath(current)) {
346
+ return;
347
+ }
348
+ let entries;
349
+ try {
350
+ entries = fs.readdirSync(current, { withFileTypes: true });
351
+ } catch (_err) {
352
+ return;
353
+ }
354
+ for (const entry of entries) {
355
+ if (visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES || files.length >= AUTO_UPLOAD_MAX_FILES * 50) {
356
+ return;
357
+ }
358
+ visited += 1;
359
+ const full = path.join(current, entry.name);
360
+ if (shouldIgnorePath(full)) {
361
+ continue;
362
+ }
363
+ if (entry.isDirectory()) {
364
+ scan(full, depth - 1);
365
+ continue;
366
+ }
367
+ if (!entry.isFile()) {
368
+ continue;
369
+ }
370
+ const stat = safeStat(full);
371
+ if (!stat || total + stat.size > AUTO_UPLOAD_MAX_BYTES) {
372
+ continue;
373
+ }
374
+ total += stat.size;
375
+ const rel = normalizeSlash(path.relative(dirPath, full));
376
+ if (isTextLikeFile(full)) {
377
+ files.push({ name: rel, contentText: fs.readFileSync(full, "utf8") });
378
+ } else {
379
+ files.push({ name: rel, contentBase64: fs.readFileSync(full).toString("base64") });
380
+ }
381
+ }
382
+ }
383
+ scan(dirPath, AUTO_UPLOAD_SCAN_DEPTH);
384
+ return files;
385
+ }
386
+
387
+ function extractStringField(obj, keys) {
388
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
389
+ return "";
390
+ }
391
+ for (const key of keys) {
392
+ const value = obj[key];
393
+ if (isString(value) && value.trim()) {
394
+ return value.trim();
395
+ }
396
+ }
397
+ return "";
398
+ }
399
+
400
+ function extractContextString(event, keys) {
401
+ const direct = extractStringField(event, keys);
402
+ if (direct) {
403
+ return direct;
404
+ }
405
+ const containers = [
406
+ event && event.params,
407
+ event && event.metadata,
408
+ event && event.context,
409
+ event && event.ctx,
410
+ event && event.message,
411
+ ];
412
+ for (const container of containers) {
413
+ const value = extractStringField(container, keys);
414
+ if (value) {
415
+ return value;
416
+ }
417
+ }
418
+ return "";
419
+ }
420
+
421
+ function payloadResourceId(body) {
422
+ return extractStringField(body, ["resource_id", "resourceId", "conversation_id", "conversationId"]);
423
+ }
424
+
425
+ function payloadGroupId(body) {
426
+ return extractStringField(body, ["group_id", "groupId", "conversation_id", "conversationId"]);
427
+ }
428
+
429
+ function payloadUserId(body) {
430
+ return extractStringField(body, ["user_id", "userId", "sender_id", "senderId", "owner_id", "ownerId"]);
431
+ }
432
+
433
+ function isWriteTool(toolName) {
434
+ const normalized = normalizeToolName(toolName);
435
+ return normalized === "write" || normalized.endsWith("__write") || normalized === "write_file" || normalized.endsWith("__write_file");
436
+ }
437
+
438
+ function extractWritePath(params) {
439
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
440
+ return "";
441
+ }
442
+ return extractStringField(params, [
443
+ "path",
444
+ "file_path",
445
+ "filePath",
446
+ "filename",
447
+ "fileName",
448
+ "target",
449
+ "target_path",
450
+ "targetPath",
451
+ "destination",
452
+ ]);
453
+ }
454
+
455
+ function rememberPendingFile(event) {
456
+ const params = event && event.params;
457
+ const writePath = extractWritePath(params);
458
+ if (!writePath || isURLLike(writePath)) {
459
+ return;
460
+ }
461
+ const store = getPendingFileStore();
462
+ pruneTimedMap(store, AUTO_UPLOAD_TTL_MS);
463
+ const resourceId = extractContextString(event, ["resource_id", "resourceId", "conversation_id", "conversationId"]);
464
+ const key = `${resourceId || "unknown"}:${writePath}`;
465
+ store.set(key, {
466
+ path: writePath,
467
+ resourceId,
468
+ createdAt: Date.now(),
469
+ });
470
+ }
471
+
472
+ function pendingCandidatesForPayload(body) {
473
+ const resourceId = payloadResourceId(body);
474
+ if (!resourceId) {
475
+ return [];
476
+ }
477
+ const store = getPendingFileStore();
478
+ pruneTimedMap(store, AUTO_UPLOAD_TTL_MS);
479
+ const out = [];
480
+ for (const entry of store.values()) {
481
+ if (!entry || entry.resourceId !== resourceId) {
482
+ continue;
483
+ }
484
+ const ref = { raw: entry.path, value: entry.path, kind: "pending" };
485
+ const resolved = resolveFileReference(ref);
486
+ if (resolved) {
487
+ out.push(resolved);
488
+ }
489
+ }
490
+ return out;
491
+ }
492
+
493
+ function gatewayURL() {
494
+ return (process.env.CLAW_GATEWAY_URL || DEFAULT_GATEWAY_INTERNAL_URL).replace(/\/$/, "");
495
+ }
496
+
497
+ function gatewayPublicURL() {
498
+ return (process.env.CLAW_GATEWAY_PUBLIC_URL || process.env.CLAW_GATEWAY_URL || DEFAULT_GATEWAY_PUBLIC_URL).replace(/\/$/, "");
499
+ }
500
+
501
+ function apiKey() {
502
+ return process.env.CLAW_GATEWAY_API_KEY || process.env.OPENCLAW_GATEWAY_API_KEY || "api-key-1";
503
+ }
504
+
505
+ function deriveReleaseName() {
506
+ let release = process.env.botID || process.env.OPENCLAW_RELEASE || process.env.BOT_ID || "";
507
+ if (!release) {
508
+ const tok = process.env.OPENCLAW_GATEWAY_TOKEN || "";
509
+ if (tok.indexOf("oc-") === 0) {
510
+ release = tok.slice(3);
511
+ }
512
+ }
513
+ return release;
514
+ }
515
+
516
+ function httpJSONRequest(method, requestPath, body) {
517
+ return new Promise((resolve, reject) => {
518
+ let parsed;
519
+ try {
520
+ parsed = new URL(gatewayURL() + requestPath);
521
+ } catch (err) {
522
+ reject(err);
523
+ return;
524
+ }
525
+ const isHTTPS = parsed.protocol === "https:";
526
+ const transport = isHTTPS ? https : http;
527
+ const bodyStr = body ? JSON.stringify(body) : "";
528
+ const req = transport.request(
529
+ {
530
+ hostname: parsed.hostname,
531
+ port: parsed.port || (isHTTPS ? 443 : 80),
532
+ path: parsed.pathname + (parsed.search || ""),
533
+ method,
534
+ headers: {
535
+ "Content-Type": "application/json",
536
+ "X-API-Key": apiKey(),
537
+ "Content-Length": Buffer.byteLength(bodyStr),
538
+ },
539
+ },
540
+ (res) => {
541
+ const chunks = [];
542
+ res.on("data", (chunk) => chunks.push(chunk));
543
+ res.on("end", () => {
544
+ const text = Buffer.concat(chunks).toString("utf8");
545
+ let obj;
546
+ try {
547
+ obj = JSON.parse(text);
548
+ } catch (_err) {
549
+ reject(new Error(`gateway ${res.statusCode}: non-JSON response: ${text.slice(0, 200)}`));
550
+ return;
551
+ }
552
+ if (res.statusCode >= 400) {
553
+ reject(new Error(`gateway ${res.statusCode}: ${obj.message || text.slice(0, 200)}`));
554
+ return;
555
+ }
556
+ resolve(obj);
557
+ });
558
+ },
559
+ );
560
+ req.on("error", reject);
561
+ if (bodyStr) {
562
+ req.write(bodyStr);
563
+ }
564
+ req.end();
565
+ });
566
+ }
567
+
568
+ function uploadCacheKey(candidate, resourceId) {
569
+ return `${resourceId || ""}:${candidate.path}:${candidate.mtimeMs || 0}:${candidate.size || 0}`;
570
+ }
571
+
572
+ async function uploadCandidate(candidate, body) {
573
+ const resourceId = payloadResourceId(body);
574
+ const cache = getUploadCache();
575
+ pruneTimedMap(cache, AUTO_UPLOAD_TTL_MS);
576
+ const cacheKey = uploadCacheKey(candidate, resourceId);
577
+ const cached = cache.get(cacheKey);
578
+ if (cached && cached.result) {
579
+ return cached.result;
580
+ }
581
+
582
+ const stat = safeStat(candidate.path);
583
+ if (!stat) {
584
+ throw new Error(`file no longer exists: ${candidate.fileName}`);
585
+ }
586
+ const requestBody = {
587
+ resourceId,
588
+ groupId: payloadGroupId(body),
589
+ userId: payloadUserId(body),
590
+ release: deriveReleaseName(),
591
+ type: deliverableTypeForPath(candidate.path, stat.isDirectory()),
592
+ fileName: candidate.fileName || path.basename(candidate.path),
593
+ };
594
+ if (stat.isDirectory()) {
595
+ const files = collectDirectoryFiles(candidate.path);
596
+ if (files.length === 0) {
597
+ throw new Error(`directory has no uploadable files: ${candidate.fileName}`);
598
+ }
599
+ requestBody.files = files;
600
+ } else {
601
+ if (stat.size > AUTO_UPLOAD_MAX_BYTES) {
602
+ throw new Error(`file exceeds auto-upload limit: ${candidate.fileName}`);
603
+ }
604
+ requestBody.contentBase64 = fs.readFileSync(candidate.path).toString("base64");
605
+ }
606
+
607
+ const response = await httpJSONRequest("POST", "/openclaw-gateway/be/deliverables", requestBody);
608
+ const data = response.data || response;
609
+ const previewURL = data.previewUrl || `${gatewayPublicURL()}/openclaw-gateway/preview/${data.uuid}`;
610
+ const result = {
611
+ fileName: requestBody.fileName,
612
+ uuid: data.uuid,
613
+ previewURL,
614
+ downloadURL: data.downloadUrl || previewURL,
615
+ };
616
+ cache.set(cacheKey, { result, createdAt: Date.now() });
617
+ return result;
618
+ }
619
+
620
+ function dedupeCandidates(candidates) {
621
+ const out = [];
622
+ const seen = new Set();
623
+ for (const candidate of candidates) {
624
+ if (!candidate || !candidate.path) {
625
+ continue;
626
+ }
627
+ const key = path.resolve(candidate.path);
628
+ if (seen.has(key)) {
629
+ continue;
630
+ }
631
+ seen.add(key);
632
+ out.push(candidate);
633
+ if (out.length >= AUTO_UPLOAD_MAX_FILES) {
634
+ break;
635
+ }
636
+ }
637
+ return out;
638
+ }
639
+
640
+ function escapeMarkdownLabel(label) {
641
+ return String(label || "文件").replace(/[[\]()`]/g, "");
642
+ }
643
+
644
+ function removeInternalPathLeaks(text, candidates) {
645
+ let out = String(text || "");
646
+ for (const candidate of candidates) {
647
+ if (!candidate || !candidate.raw) {
648
+ continue;
649
+ }
650
+ out = out.split(candidate.raw).join(candidate.fileName || path.basename(candidate.path));
651
+ }
652
+ return out;
653
+ }
654
+
655
+ function appendDeliverableLinks(text, uploads) {
656
+ const lines = [];
657
+ uploads.forEach((upload) => {
658
+ const label = escapeMarkdownLabel(upload.fileName);
659
+ if (upload.previewURL) {
660
+ lines.push(`预览链接:[${label}](${upload.previewURL})`);
661
+ }
662
+ if (upload.downloadURL && upload.downloadURL !== upload.previewURL) {
663
+ lines.push(`下载链接:[${label}](${upload.downloadURL})`);
664
+ }
665
+ });
666
+ if (lines.length === 0) {
667
+ return text;
668
+ }
669
+ return `${String(text || "").trim()}\n\n交付物链接:\n${lines.join("\n")}`;
670
+ }
671
+
672
+ function hasTrustedDeliverableLink(text) {
673
+ return splitDeliverableMessage(text) !== null;
674
+ }
675
+
676
+ function shouldSkipAutoUploadPayload(body) {
677
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
678
+ return true;
679
+ }
680
+ if (!isString(body.content) || !body.content.trim()) {
681
+ return true;
682
+ }
683
+ if (body.stream_id || body.seq !== undefined || body.delta !== undefined || body.is_final !== undefined) {
684
+ return true;
685
+ }
686
+ if (body.palz_msg_type || body.tool_content) {
687
+ return true;
688
+ }
689
+ return false;
690
+ }
691
+
692
+ async function autoUploadAndRewritePayload(body, api) {
693
+ if (shouldSkipAutoUploadPayload(body) || hasTrustedDeliverableLink(body.content)) {
694
+ return body;
695
+ }
696
+ const refs = extractFileReferencesFromText(body.content);
697
+ const explicitCandidates = refs.map(resolveFileReference).filter(Boolean);
698
+ const candidates = dedupeCandidates(explicitCandidates.concat(pendingCandidatesForPayload(body)));
699
+ if (candidates.length === 0) {
700
+ return body;
701
+ }
702
+
703
+ const uploads = [];
704
+ const failures = [];
705
+ for (const candidate of candidates) {
706
+ try {
707
+ uploads.push(await uploadCandidate(candidate, body));
708
+ } catch (err) {
709
+ failures.push({ candidate, error: err });
710
+ api.logger.warn?.(
711
+ `[plugin-deliverables] auto-upload failed file=${candidate.fileName || candidate.path} error=${err.message}`,
712
+ );
713
+ }
714
+ }
715
+ if (uploads.length === 0) {
716
+ if (explicitCandidates.length === 0) {
717
+ return body;
718
+ }
719
+ const next = Object.assign({}, body);
720
+ next.content = "交付物上传失败,已阻止发送工作区内部文件路径。请稍后重试或联系管理员排查上传链路。";
721
+ return next;
722
+ }
723
+ const next = Object.assign({}, body);
724
+ next.content = appendDeliverableLinks(removeInternalPathLeaks(body.content, candidates), uploads);
725
+ if (failures.length > 0) {
726
+ next.content += `\n\n另有 ${failures.length} 个文件上传失败,已避免发送内部路径。`;
727
+ }
728
+ api.logger.info?.(
729
+ `[plugin-deliverables] auto-uploaded ${uploads.length} workspace file(s) for target=${String(body.conversation_id || "")}`,
730
+ );
731
+ return next;
732
+ }
733
+
32
734
  function getSummaryCache() {
33
735
  if (!globalThis[SUMMARY_CACHE_KEY] || !(globalThis[SUMMARY_CACHE_KEY] instanceof Map)) {
34
736
  globalThis[SUMMARY_CACHE_KEY] = new Map();
@@ -520,6 +1222,7 @@ function splitDeliverableMessage(text) {
520
1222
 
521
1223
  const linkLines = [];
522
1224
  const linkUrls = [];
1225
+ const linkItems = [];
523
1226
  for (let i = firstLinkIndex; i < lines.length; i += 1) {
524
1227
  const line = lines[i];
525
1228
  if (isLinksHeading(line)) {
@@ -532,6 +1235,7 @@ function splitDeliverableMessage(text) {
532
1235
  const url = extractDeliverableURL(normalizedLine);
533
1236
  if (url) {
534
1237
  linkUrls.push(url);
1238
+ linkItems.push({ line: normalizedLine, url });
535
1239
  }
536
1240
  }
537
1241
  }
@@ -546,10 +1250,54 @@ function splitDeliverableMessage(text) {
546
1250
  return {
547
1251
  summary,
548
1252
  links,
1253
+ linkUrls,
1254
+ linkItems,
549
1255
  primaryLinkUrl: linkUrls.length > 0 ? linkUrls[0] : "",
1256
+ fileLinkUrl: selectPalzFileURL(linkItems),
550
1257
  };
551
1258
  }
552
1259
 
1260
+ function extractDeliverableIdentity(rawURL) {
1261
+ if (!isString(rawURL) || !rawURL.trim()) {
1262
+ return "";
1263
+ }
1264
+ let parsed;
1265
+ try {
1266
+ parsed = new URL(rawURL.trim());
1267
+ } catch (_err) {
1268
+ return "";
1269
+ }
1270
+ if (!DELIVERABLE_GATEWAY_PATH_RE.test(parsed.pathname)) {
1271
+ return "";
1272
+ }
1273
+ const parts = parsed.pathname.split("/").filter(Boolean);
1274
+ const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
1275
+ for (const part of parts) {
1276
+ if (uuidRe.test(part)) {
1277
+ return part.toLowerCase();
1278
+ }
1279
+ }
1280
+ return parsed.href;
1281
+ }
1282
+
1283
+ function selectPalzFileURL(linkItems) {
1284
+ if (!Array.isArray(linkItems) || linkItems.length === 0) {
1285
+ return "";
1286
+ }
1287
+ const identities = new Set();
1288
+ for (const item of linkItems) {
1289
+ const identity = extractDeliverableIdentity(item && item.url);
1290
+ if (identity) {
1291
+ identities.add(identity);
1292
+ }
1293
+ }
1294
+ if (identities.size !== 1) {
1295
+ return "";
1296
+ }
1297
+ const download = linkItems.find((item) => /下载链接/u.test(String((item && item.line) || "")));
1298
+ return (download && download.url) || linkItems[0].url || "";
1299
+ }
1300
+
553
1301
  function cloneBody(body, content, suffix) {
554
1302
  const next = {};
555
1303
  Object.keys(body).forEach((key) => {
@@ -605,6 +1353,7 @@ function shouldSplitPalzPayload(body) {
605
1353
  summary: cachedSummary,
606
1354
  links: split.links,
607
1355
  primaryLinkUrl: split.primaryLinkUrl,
1356
+ fileLinkUrl: split.fileLinkUrl,
608
1357
  };
609
1358
  }
610
1359
  return split;
@@ -678,6 +1427,11 @@ function installPalzFetchPatch(api) {
678
1427
  return originalFetch(input, init);
679
1428
  }
680
1429
 
1430
+ const rewritten = await autoUploadAndRewritePayload(parsed, api);
1431
+ if (rewritten !== parsed) {
1432
+ parsed = rewritten;
1433
+ }
1434
+
681
1435
  const split = shouldSplitPalzPayload(parsed);
682
1436
  if (!split) {
683
1437
  const suspiciousURL = isString(parsed.content)
@@ -695,14 +1449,15 @@ function installPalzFetchPatch(api) {
695
1449
  }
696
1450
 
697
1451
  const summaryBody = cloneBody(parsed, split.summary, "__summary");
698
- const linksBody = split.primaryLinkUrl
699
- ? cloneBodyAsFileLink(parsed, split.primaryLinkUrl, "__links")
1452
+ const fileLinkUrl = split.fileLinkUrl || "";
1453
+ const linksBody = fileLinkUrl
1454
+ ? cloneBodyAsFileLink(parsed, fileLinkUrl, "__links")
700
1455
  : cloneBody(parsed, split.links, "__links");
701
1456
  const summaryBodyStr = JSON.stringify(summaryBody);
702
1457
  const linksBodyStr = JSON.stringify(linksBody);
703
1458
 
704
1459
  api.logger.info?.(
705
- `[plugin-deliverables] split deliverable reply injected by plugin-deliverables target=${String(parsed.conversation_id || "")} summaryMsgId=${String(summaryBody.msg_id || "")} linksMsgId=${String(linksBody.msg_id || "")} linksMode=${split.primaryLinkUrl ? "file_url" : "text"}`,
1460
+ `[plugin-deliverables] split deliverable reply injected by plugin-deliverables target=${String(parsed.conversation_id || "")} summaryMsgId=${String(summaryBody.msg_id || "")} linksMsgId=${String(linksBody.msg_id || "")} linksMode=${fileLinkUrl ? "file_url" : "text"}`,
706
1461
  );
707
1462
  api.logger.info?.(
708
1463
  `[plugin-deliverables] palz summary request body_length=${summaryBodyStr.length}\n request_body=${summaryBodyStr}`,
@@ -759,6 +1514,10 @@ const plugin = {
759
1514
  cacheUploadSummary(event.params);
760
1515
  return;
761
1516
  }
1517
+ if (isWriteTool(event.toolName)) {
1518
+ rememberPendingFile(event);
1519
+ return;
1520
+ }
762
1521
  if (!isOutboundMessageTool(event.toolName)) {
763
1522
  return;
764
1523
  }
@@ -789,11 +1548,14 @@ const plugin = {
789
1548
  };
790
1549
 
791
1550
  plugin.__test = {
1551
+ deliverableTypeForPath,
1552
+ extractFileReferencesFromText,
792
1553
  extractDeliverableURL,
793
1554
  findUntrustedOSSDeliverableURL,
794
1555
  isDeliverableLinkLine,
795
1556
  isOSSLikeURL,
796
1557
  isTrustedDeliverableURL,
1558
+ selectPalzFileURL,
797
1559
  splitDeliverableMessage,
798
1560
  };
799
1561
 
@@ -274,12 +274,70 @@ function normalizeBase64Content(value, label) {
274
274
  return Buffer.from(padded, "base64").toString("base64");
275
275
  }
276
276
 
277
+ function candidateWorkspaceRoots() {
278
+ var roots = [];
279
+ [
280
+ process.env.OPENCLAW_WORKSPACE_PATH,
281
+ process.env.WORKSPACE_PATH,
282
+ "/home/node/.openclaw/workspace-main",
283
+ "/home/node/.openclaw/workspace",
284
+ "/data/workspace",
285
+ "/data/workspace-main"
286
+ ].forEach(function(root) {
287
+ root = trimString(root);
288
+ if (root && roots.indexOf(root) < 0) {
289
+ roots.push(root);
290
+ }
291
+ });
292
+ try {
293
+ fs.readdirSync("/data").forEach(function(entry) {
294
+ if (entry && entry.indexOf("workspace-") === 0) {
295
+ var root = path.join("/data", entry);
296
+ if (roots.indexOf(root) < 0) {
297
+ roots.push(root);
298
+ }
299
+ }
300
+ });
301
+ } catch (_err) {}
302
+ return roots;
303
+ }
304
+
305
+ function resolveReadableFilePath(filePath) {
306
+ var rawPath = trimString(filePath);
307
+ if (!rawPath) {
308
+ return "";
309
+ }
310
+ var candidates = [];
311
+ function add(candidate) {
312
+ if (candidate && candidates.indexOf(candidate) < 0) {
313
+ candidates.push(candidate);
314
+ }
315
+ }
316
+ if (path.isAbsolute(rawPath)) {
317
+ add(rawPath);
318
+ } else {
319
+ add(path.resolve(process.cwd(), rawPath));
320
+ candidateWorkspaceRoots().forEach(function(root) {
321
+ add(path.join(root, rawPath));
322
+ });
323
+ }
324
+ for (var i = 0; i < candidates.length; i += 1) {
325
+ try {
326
+ var stat = fs.statSync(candidates[i]);
327
+ if (stat.isFile()) {
328
+ return candidates[i];
329
+ }
330
+ } catch (_err) {}
331
+ }
332
+ return candidates[0] || rawPath;
333
+ }
334
+
277
335
  function readFileAsBase64(filePath, label) {
278
336
  var rawPath = trimString(filePath);
279
337
  if (!rawPath) {
280
338
  return "";
281
339
  }
282
- var resolvedPath = path.resolve(process.cwd(), rawPath);
340
+ var resolvedPath = resolveReadableFilePath(rawPath);
283
341
  var stat;
284
342
  try {
285
343
  stat = fs.statSync(resolvedPath);
@@ -448,7 +506,8 @@ function startMCPServer() {
448
506
  module.exports.__test = {
449
507
  buildContentPayload: buildContentPayload,
450
508
  buildUploadRequestBody: buildUploadRequestBody,
451
- normalizeBase64Content: normalizeBase64Content
509
+ normalizeBase64Content: normalizeBase64Content,
510
+ resolveReadableFilePath: resolveReadableFilePath
452
511
  };
453
512
 
454
513
  if (require.main === module) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-deliverables",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "npm_package": "@dai_ming/plugin-deliverables",
5
5
  "description": "Deliverables plugin: MCP server + skill + AGENTS rules for AI-generated file uploads",
6
6
  "mcp_servers": {
@@ -2,7 +2,7 @@
2
2
  "id": "plugin-deliverables",
3
3
  "name": "Deliverables",
4
4
  "description": "Deliverables runtime guard for upload-first file delivery with Palz split-send diagnostics.",
5
- "version": "1.0.20",
5
+ "version": "1.0.22",
6
6
  "skills": ["./skills"],
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dai_ming/plugin-deliverables",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "description": "OpenClaw deliverables plugin — upload AI-generated files to OSS and return shareable preview/download links",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -6,9 +6,12 @@ process.env.CLAW_GATEWAY_PUBLIC_URL = "https://claw-gateway.csagentai.com";
6
6
 
7
7
  const plugin = require("../index.js");
8
8
  const {
9
+ deliverableTypeForPath,
10
+ extractFileReferencesFromText,
9
11
  findUntrustedOSSDeliverableURL,
10
12
  isDeliverableLinkLine,
11
13
  isTrustedDeliverableURL,
14
+ selectPalzFileURL,
12
15
  splitDeliverableMessage,
13
16
  } = plugin.__test;
14
17
 
@@ -38,6 +41,19 @@ const split = splitDeliverableMessage(
38
41
  );
39
42
  assert.ok(split);
40
43
  assert.strictEqual(split.primaryLinkUrl, gatewayURL);
44
+ assert.strictEqual(split.fileLinkUrl, gatewayURL);
45
+
46
+ const outputURL =
47
+ "https://claw-gateway.csagentai.com/openclaw-gateway/output/user/res/uuid/7a9e27b7-08ca-4453-81e8-fe6a630d4566/report.md";
48
+ const previewURL =
49
+ "https://claw-gateway.csagentai.com/openclaw-gateway/preview/7a9e27b7-08ca-4453-81e8-fe6a630d4566";
50
+ assert.strictEqual(
51
+ selectPalzFileURL([
52
+ { line: `预览链接:[点击预览](${previewURL})`, url: previewURL },
53
+ { line: `下载链接:[点击下载](${outputURL})`, url: outputURL },
54
+ ]),
55
+ outputURL,
56
+ );
41
57
 
42
58
  assert.strictEqual(
43
59
  splitDeliverableMessage(
@@ -71,3 +87,28 @@ assert.strictEqual(
71
87
  ),
72
88
  "",
73
89
  );
90
+
91
+ const multiSplit = splitDeliverableMessage(
92
+ [
93
+ "已生成多个文件。",
94
+ "",
95
+ `预览链接:[设计文档](${gatewayURL})`,
96
+ `下载链接:[测试报告](https://claw-gateway.csagentai.com/openclaw-gateway/output/user/res/uuid/report.md)`,
97
+ ].join("\n"),
98
+ );
99
+ assert.ok(multiSplit);
100
+ assert.strictEqual(multiSplit.linkUrls.length, 2);
101
+
102
+ const refs = extractFileReferencesFromText(
103
+ "设计文档:`game-brief.md`\n开发路径:/data/workspace-lobster_abc/output/app/index.html\n相对路径 output/report.pdf",
104
+ );
105
+ assert.deepStrictEqual(
106
+ refs.map((ref) => ref.value),
107
+ ["/data/workspace-lobster_abc/output/app/index.html", "game-brief.md", "output/report.pdf"],
108
+ );
109
+
110
+ assert.strictEqual(deliverableTypeForPath("intro.pdf", false), "article");
111
+ assert.strictEqual(deliverableTypeForPath("cover.png", false), "image");
112
+ assert.strictEqual(deliverableTypeForPath("slides.pptx", false), "ppt");
113
+ assert.strictEqual(deliverableTypeForPath("dist.zip", false), "zip");
114
+ assert.strictEqual(deliverableTypeForPath("/data/workspace-lobster_abc/output/site", true), "game");
@@ -8,6 +8,7 @@ const path = require("path");
8
8
  const {
9
9
  buildUploadRequestBody,
10
10
  normalizeBase64Content,
11
+ resolveReadableFilePath,
11
12
  } = require("../mcp-servers/deliverables.js").__test;
12
13
 
13
14
  assert.strictEqual(
@@ -24,6 +25,7 @@ assert.throws(
24
25
  );
25
26
 
26
27
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deliverables-mcp-"));
28
+ process.env.OPENCLAW_WORKSPACE_PATH = tmpDir;
27
29
  const pdfPath = path.join(tmpDir, "sample.pdf");
28
30
  const pdfBytes = Buffer.from("%PDF-1.3\nsample\n", "utf8");
29
31
  fs.writeFileSync(pdfPath, pdfBytes);
@@ -42,3 +44,17 @@ assert.strictEqual(body.resourceId, "res-1");
42
44
  assert.strictEqual(body.fileName, "sample.pdf");
43
45
  assert.strictEqual(body.contentText, "");
44
46
  assert.strictEqual(body.contentBase64, pdfBytes.toString("base64"));
47
+
48
+ const outputDir = path.join(tmpDir, "output");
49
+ fs.mkdirSync(outputDir, { recursive: true });
50
+ const relativeDoc = path.join(outputDir, "relative.md");
51
+ fs.writeFileSync(relativeDoc, "# Relative\n", "utf8");
52
+ assert.strictEqual(resolveReadableFilePath("output/relative.md"), relativeDoc);
53
+
54
+ const relativeBody = buildUploadRequestBody({
55
+ resource_id: "res-1",
56
+ type: "article",
57
+ file_name: "relative.md",
58
+ file_path: "output/relative.md",
59
+ });
60
+ assert.strictEqual(relativeBody.contentBase64, Buffer.from("# Relative\n").toString("base64"));