@openclaw/feishu 2026.3.13 → 2026.5.1-beta.1

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 (188) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1653 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +32 -94
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +375 -26
  91. package/src/media.ts +434 -88
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +11 -9
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +220 -60
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +65 -7
  122. package/src/monitor.webhook-security.test.ts +122 -0
  123. package/src/monitor.webhook.test-helpers.ts +44 -26
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +616 -37
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +14 -9
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +4 -34
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +660 -29
  142. package/src/reply-dispatcher.ts +407 -154
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +77 -2
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +399 -86
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. package/src/targets.test.ts +0 -70
package/src/docx.test.ts CHANGED
@@ -1,40 +1,67 @@
1
- import { promises as fs } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
4
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { createToolFactoryHarness, type ToolLike } from "./tool-factory-test-harness.js";
5
5
 
6
6
  const createFeishuClientMock = vi.hoisted(() => vi.fn());
7
+ const resolveFeishuToolAccountMock = vi.hoisted(() => vi.fn());
7
8
  const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
8
-
9
- vi.mock("./client.js", () => ({
10
- createFeishuClient: createFeishuClientMock,
11
- }));
12
-
13
- vi.mock("./runtime.js", () => ({
14
- getFeishuRuntime: () => ({
15
- channel: {
9
+ const loadWebMediaMock = vi.hoisted(() => vi.fn());
10
+ const convertMock = vi.hoisted(() => vi.fn());
11
+ const documentCreateMock = vi.hoisted(() => vi.fn());
12
+ const blockListMock = vi.hoisted(() => vi.fn());
13
+ const blockChildrenCreateMock = vi.hoisted(() => vi.fn());
14
+ const blockChildrenGetMock = vi.hoisted(() => vi.fn());
15
+ const blockChildrenBatchDeleteMock = vi.hoisted(() => vi.fn());
16
+ const blockDescendantCreateMock = vi.hoisted(() => vi.fn());
17
+ const driveUploadAllMock = vi.hoisted(() => vi.fn());
18
+ const permissionMemberCreateMock = vi.hoisted(() => vi.fn());
19
+ const blockPatchMock = vi.hoisted(() => vi.fn());
20
+ const scopeListMock = vi.hoisted(() => vi.fn());
21
+ const toolAccountModule = await import("./tool-account.js");
22
+ const runtimeModule = await import("./runtime.js");
23
+
24
+ vi.spyOn(toolAccountModule, "createFeishuToolClient").mockImplementation(() =>
25
+ createFeishuClientMock(),
26
+ );
27
+ vi.spyOn(toolAccountModule, "resolveAnyEnabledFeishuToolsConfig").mockReturnValue({
28
+ doc: true,
29
+ chat: false,
30
+ wiki: false,
31
+ drive: false,
32
+ perm: false,
33
+ scopes: false,
34
+ });
35
+ vi.spyOn(toolAccountModule, "resolveFeishuToolAccount").mockImplementation((...args) =>
36
+ resolveFeishuToolAccountMock(...args),
37
+ );
38
+ vi.spyOn(runtimeModule, "getFeishuRuntime").mockImplementation(
39
+ () =>
40
+ ({
41
+ channel: {
42
+ media: {
43
+ fetchRemoteMedia: fetchRemoteMediaMock,
44
+ saveMediaBuffer: vi.fn(),
45
+ },
46
+ },
16
47
  media: {
17
- fetchRemoteMedia: fetchRemoteMediaMock,
48
+ loadWebMedia: loadWebMediaMock,
49
+ detectMime: vi.fn(async () => "application/octet-stream"),
50
+ mediaKindFromMime: vi.fn(() => "image"),
51
+ isVoiceCompatibleAudio: vi.fn(() => false),
52
+ getImageMetadata: vi.fn(async () => null),
53
+ resizeToJpeg: vi.fn(async () => Buffer.alloc(0)),
18
54
  },
19
- },
20
- }),
21
- }));
55
+ }) as unknown as ReturnType<typeof runtimeModule.getFeishuRuntime>,
56
+ );
22
57
 
23
- import { registerFeishuDocTools } from "./docx.js";
58
+ const { registerFeishuDocTools } = await import("./docx.js");
24
59
 
25
- describe("feishu_doc image fetch hardening", () => {
26
- const convertMock = vi.hoisted(() => vi.fn());
27
- const documentCreateMock = vi.hoisted(() => vi.fn());
28
- const blockListMock = vi.hoisted(() => vi.fn());
29
- const blockChildrenCreateMock = vi.hoisted(() => vi.fn());
30
- const blockChildrenGetMock = vi.hoisted(() => vi.fn());
31
- const blockChildrenBatchDeleteMock = vi.hoisted(() => vi.fn());
32
- const blockDescendantCreateMock = vi.hoisted(() => vi.fn());
33
- const driveUploadAllMock = vi.hoisted(() => vi.fn());
34
- const permissionMemberCreateMock = vi.hoisted(() => vi.fn());
35
- const blockPatchMock = vi.hoisted(() => vi.fn());
36
- const scopeListMock = vi.hoisted(() => vi.fn());
60
+ type ToolResultWithDetails = {
61
+ details: Record<string, unknown>;
62
+ };
37
63
 
64
+ describe("feishu_doc image fetch hardening", () => {
38
65
  beforeEach(() => {
39
66
  vi.clearAllMocks();
40
67
 
@@ -71,6 +98,9 @@ describe("feishu_doc image fetch hardening", () => {
71
98
  },
72
99
  },
73
100
  });
101
+ resolveFeishuToolAccountMock.mockReturnValue({
102
+ config: { mediaMaxMb: 30 },
103
+ });
74
104
 
75
105
  convertMock.mockResolvedValue({
76
106
  code: 0,
@@ -115,26 +145,26 @@ describe("feishu_doc image fetch hardening", () => {
115
145
  });
116
146
 
117
147
  function resolveFeishuDocTool(context: Record<string, unknown> = {}) {
118
- const registerTool = vi.fn();
119
- registerFeishuDocTools({
120
- config: {
121
- channels: {
122
- feishu: {
123
- appId: "app_id",
124
- appSecret: "app_secret",
125
- },
148
+ const harness = createToolFactoryHarness({
149
+ channels: {
150
+ feishu: {
151
+ enabled: true,
152
+ appId: "app_id",
153
+ appSecret: "app_secret",
126
154
  },
127
- } as any,
128
- logger: { debug: vi.fn(), info: vi.fn() } as any,
129
- registerTool,
130
- } as any);
131
-
132
- const tool = registerTool.mock.calls
133
- .map((call) => call[0])
134
- .map((candidate) => (typeof candidate === "function" ? candidate(context) : candidate))
135
- .find((candidate) => candidate.name === "feishu_doc");
155
+ },
156
+ });
157
+ registerFeishuDocTools(harness.api);
158
+ const tool = harness.resolveTool("feishu_doc", context);
136
159
  expect(tool).toBeDefined();
137
- return tool as { execute: (callId: string, params: Record<string, unknown>) => Promise<any> };
160
+ return tool;
161
+ }
162
+
163
+ async function executeFeishuDocTool(
164
+ tool: ToolLike,
165
+ params: Record<string, unknown>,
166
+ ): Promise<ToolResultWithDetails> {
167
+ return (await tool.execute("tool-call", params)) as ToolResultWithDetails;
138
168
  }
139
169
 
140
170
  it("inserts blocks sequentially to preserve document order", async () => {
@@ -160,7 +190,7 @@ describe("feishu_doc image fetch hardening", () => {
160
190
 
161
191
  const feishuDocTool = resolveFeishuDocTool();
162
192
 
163
- const result = await feishuDocTool.execute("tool-call", {
193
+ const result = await executeFeishuDocTool(feishuDocTool, {
164
194
  action: "append",
165
195
  doc_token: "doc_1",
166
196
  content: "plain text body",
@@ -175,6 +205,50 @@ describe("feishu_doc image fetch hardening", () => {
175
205
  expect(result.details.blocks_added).toBe(3);
176
206
  });
177
207
 
208
+ it("reorders convert output by document tree instead of raw block array order", async () => {
209
+ const blocks = [
210
+ { block_type: 13, block_id: "li2", parent_id: "list1" },
211
+ { block_type: 4, block_id: "h2" },
212
+ { block_type: 13, block_id: "li1", parent_id: "list1" },
213
+ { block_type: 3, block_id: "h1" },
214
+ { block_type: 12, block_id: "list1", children: ["li1", "li2"] },
215
+ { block_type: 2, block_id: "p1" },
216
+ ];
217
+ convertMock.mockResolvedValue({
218
+ code: 0,
219
+ data: {
220
+ blocks,
221
+ first_level_block_ids: ["h1", "p1", "h2", "list1"],
222
+ },
223
+ });
224
+
225
+ blockDescendantCreateMock.mockImplementationOnce(async ({ data }) => ({
226
+ code: 0,
227
+ data: {
228
+ children: (data.children_id as string[]).map((id) => ({ block_id: id })),
229
+ },
230
+ }));
231
+
232
+ const feishuDocTool = resolveFeishuDocTool();
233
+
234
+ await feishuDocTool.execute("tool-call", {
235
+ action: "append",
236
+ doc_token: "doc_1",
237
+ content: "tree reorder",
238
+ });
239
+
240
+ const call = blockDescendantCreateMock.mock.calls[0]?.[0];
241
+ expect(call?.data.children_id).toEqual(["h1", "p1", "h2", "list1"]);
242
+ expect((call?.data.descendants as Array<{ block_id: string }>).map((b) => b.block_id)).toEqual([
243
+ "h1",
244
+ "p1",
245
+ "h2",
246
+ "list1",
247
+ "li1",
248
+ "li2",
249
+ ]);
250
+ });
251
+
178
252
  it("falls back to size-based convert chunking for long no-heading markdown", async () => {
179
253
  let successChunkCount = 0;
180
254
  convertMock.mockImplementation(async ({ data }) => {
@@ -209,7 +283,7 @@ describe("feishu_doc image fetch hardening", () => {
209
283
  (_, i) => `line ${i} with enough content to trigger fallback chunking`,
210
284
  ).join("\n");
211
285
 
212
- const result = await feishuDocTool.execute("tool-call", {
286
+ const result = await executeFeishuDocTool(feishuDocTool, {
213
287
  action: "append",
214
288
  doc_token: "doc_1",
215
289
  content: longMarkdown,
@@ -263,7 +337,7 @@ describe("feishu_doc image fetch hardening", () => {
263
337
  "Tail paragraph three with enough text to exceed API limits when combined. ".repeat(8),
264
338
  ].join("\n");
265
339
 
266
- const result = await feishuDocTool.execute("tool-call", {
340
+ const result = await executeFeishuDocTool(feishuDocTool, {
267
341
  action: "append",
268
342
  doc_token: "doc_1",
269
343
  content: fencedMarkdown,
@@ -286,7 +360,7 @@ describe("feishu_doc image fetch hardening", () => {
286
360
 
287
361
  const feishuDocTool = resolveFeishuDocTool();
288
362
 
289
- const result = await feishuDocTool.execute("tool-call", {
363
+ const result = await executeFeishuDocTool(feishuDocTool, {
290
364
  action: "write",
291
365
  doc_token: "doc_1",
292
366
  content: "![x](https://x.test/image.png)",
@@ -306,7 +380,7 @@ describe("feishu_doc image fetch hardening", () => {
306
380
  requesterSenderId: "ou_123",
307
381
  });
308
382
 
309
- const result = await feishuDocTool.execute("tool-call", {
383
+ const result = await executeFeishuDocTool(feishuDocTool, {
310
384
  action: "create",
311
385
  title: "Demo",
312
386
  });
@@ -331,7 +405,7 @@ describe("feishu_doc image fetch hardening", () => {
331
405
  messageChannel: "feishu",
332
406
  });
333
407
 
334
- const result = await feishuDocTool.execute("tool-call", {
408
+ const result = await executeFeishuDocTool(feishuDocTool, {
335
409
  action: "create",
336
410
  title: "Demo",
337
411
  });
@@ -347,7 +421,7 @@ describe("feishu_doc image fetch hardening", () => {
347
421
  requesterSenderId: "ou_123",
348
422
  });
349
423
 
350
- const result = await feishuDocTool.execute("tool-call", {
424
+ const result = await executeFeishuDocTool(feishuDocTool, {
351
425
  action: "create",
352
426
  title: "Demo",
353
427
  grant_to_requester: false,
@@ -365,7 +439,7 @@ describe("feishu_doc image fetch hardening", () => {
365
439
 
366
440
  const feishuDocTool = resolveFeishuDocTool();
367
441
 
368
- const result = await feishuDocTool.execute("tool-call", {
442
+ const result = await executeFeishuDocTool(feishuDocTool, {
369
443
  action: "create",
370
444
  title: "Demo",
371
445
  });
@@ -381,15 +455,17 @@ describe("feishu_doc image fetch hardening", () => {
381
455
  },
382
456
  });
383
457
 
384
- const localPath = join(tmpdir(), `feishu-docx-upload-${Date.now()}.txt`);
385
- await fs.writeFile(localPath, "hello from local file", "utf8");
458
+ loadWebMediaMock.mockResolvedValueOnce({
459
+ buffer: Buffer.from("hello from local file", "utf8"),
460
+ fileName: "test-local.txt",
461
+ });
386
462
 
387
463
  const feishuDocTool = resolveFeishuDocTool();
388
464
 
389
- const result = await feishuDocTool.execute("tool-call", {
465
+ const result = await executeFeishuDocTool(feishuDocTool, {
390
466
  action: "upload_file",
391
467
  doc_token: "doc_1",
392
- file_path: localPath,
468
+ file_path: "/tmp/allowed/test-local.txt",
393
469
  filename: "test-local.txt",
394
470
  });
395
471
 
@@ -397,6 +473,13 @@ describe("feishu_doc image fetch hardening", () => {
397
473
  expect(result.details.file_token).toBe("token_1");
398
474
  expect(result.details.file_name).toBe("test-local.txt");
399
475
 
476
+ // Without workspace-only policy, localRoots stays undefined so loadWebMedia
477
+ // applies its default managed-root access behavior.
478
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
479
+ expect.stringContaining("test-local.txt"),
480
+ expect.objectContaining({ optimizeImages: false, localRoots: undefined }),
481
+ );
482
+
400
483
  expect(driveUploadAllMock).toHaveBeenCalledWith(
401
484
  expect.objectContaining({
402
485
  data: expect.objectContaining({
@@ -406,8 +489,124 @@ describe("feishu_doc image fetch hardening", () => {
406
489
  }),
407
490
  }),
408
491
  );
492
+ });
493
+
494
+ it("passes workspace localRoots for upload_file when workspace-only policy is active", async () => {
495
+ blockChildrenCreateMock.mockResolvedValueOnce({
496
+ code: 0,
497
+ data: {
498
+ children: [{ block_type: 23, block_id: "file_block_1" }],
499
+ },
500
+ });
501
+
502
+ loadWebMediaMock.mockResolvedValueOnce({
503
+ buffer: Buffer.from("hello from local file", "utf8"),
504
+ fileName: "test-local.txt",
505
+ });
506
+
507
+ const feishuDocTool = resolveFeishuDocTool({
508
+ workspaceDir: "/workspace",
509
+ fsPolicy: { workspaceOnly: true },
510
+ });
511
+
512
+ await executeFeishuDocTool(feishuDocTool, {
513
+ action: "upload_file",
514
+ doc_token: "doc_1",
515
+ file_path: "/tmp/openclaw-1000/test-local.txt",
516
+ filename: "test-local.txt",
517
+ });
518
+
519
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
520
+ expect.stringContaining("test-local.txt"),
521
+ expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }),
522
+ );
523
+ });
524
+
525
+ it("passes empty localRoots when workspace-only policy is active without workspaceDir", async () => {
526
+ blockChildrenCreateMock.mockResolvedValueOnce({
527
+ code: 0,
528
+ data: {
529
+ children: [{ block_type: 23, block_id: "file_block_1" }],
530
+ },
531
+ });
532
+
533
+ loadWebMediaMock.mockResolvedValueOnce({
534
+ buffer: Buffer.from("hello from local file", "utf8"),
535
+ fileName: "test-local.txt",
536
+ });
537
+
538
+ const feishuDocTool = resolveFeishuDocTool({
539
+ fsPolicy: { workspaceOnly: true },
540
+ });
541
+
542
+ await executeFeishuDocTool(feishuDocTool, {
543
+ action: "upload_file",
544
+ doc_token: "doc_1",
545
+ file_path: "/tmp/openclaw-1000/test-local.txt",
546
+ filename: "test-local.txt",
547
+ });
548
+
549
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
550
+ expect.stringContaining("test-local.txt"),
551
+ expect.objectContaining({ optimizeImages: false, localRoots: [] }),
552
+ );
553
+ });
554
+
555
+ it("passes workspace localRoots for upload_image local paths when workspace-only policy is active", async () => {
556
+ loadWebMediaMock.mockResolvedValueOnce({
557
+ buffer: Buffer.from("hello from local file", "utf8"),
558
+ fileName: "test-local.png",
559
+ });
560
+
561
+ const feishuDocTool = resolveFeishuDocTool({
562
+ workspaceDir: "/workspace",
563
+ fsPolicy: { workspaceOnly: true },
564
+ });
565
+
566
+ await executeFeishuDocTool(feishuDocTool, {
567
+ action: "upload_image",
568
+ doc_token: "doc_1",
569
+ image: "./test-local.png",
570
+ filename: "test-local.png",
571
+ });
572
+
573
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
574
+ expect.stringContaining("test-local.png"),
575
+ expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }),
576
+ );
577
+ });
578
+
579
+ it("passes workspace localRoots for upload_image absolute local paths when workspace-only policy is active", async () => {
580
+ const fixtureDir = path.join(process.cwd(), ".tmp-docx-upload-image-absolute");
581
+ const absoluteImagePath = path.join(fixtureDir, "absolute-image.png");
582
+ mkdirSync(fixtureDir, { recursive: true });
583
+ writeFileSync(absoluteImagePath, "not-real-image");
584
+
585
+ loadWebMediaMock.mockResolvedValueOnce({
586
+ buffer: Buffer.from("hello from local file", "utf8"),
587
+ fileName: "absolute-image.png",
588
+ });
589
+
590
+ const feishuDocTool = resolveFeishuDocTool({
591
+ workspaceDir: "/workspace",
592
+ fsPolicy: { workspaceOnly: true },
593
+ });
594
+
595
+ try {
596
+ await executeFeishuDocTool(feishuDocTool, {
597
+ action: "upload_image",
598
+ doc_token: "doc_1",
599
+ image: absoluteImagePath,
600
+ filename: "absolute-image.png",
601
+ });
409
602
 
410
- await fs.unlink(localPath);
603
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
604
+ expect.stringContaining("absolute-image.png"),
605
+ expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }),
606
+ );
607
+ } finally {
608
+ rmSync(fixtureDir, { recursive: true, force: true });
609
+ }
411
610
  });
412
611
 
413
612
  it("returns an error when upload_file cannot list placeholder siblings", async () => {
@@ -423,23 +622,64 @@ describe("feishu_doc image fetch hardening", () => {
423
622
  data: { items: [] },
424
623
  });
425
624
 
426
- const localPath = join(tmpdir(), `feishu-docx-upload-fail-${Date.now()}.txt`);
427
- await fs.writeFile(localPath, "hello from local file", "utf8");
625
+ loadWebMediaMock.mockResolvedValueOnce({
626
+ buffer: Buffer.from("hello from local file", "utf8"),
627
+ fileName: "test-local.txt",
628
+ });
428
629
 
429
- try {
430
- const feishuDocTool = resolveFeishuDocTool();
630
+ const feishuDocTool = resolveFeishuDocTool();
431
631
 
432
- const result = await feishuDocTool.execute("tool-call", {
433
- action: "upload_file",
434
- doc_token: "doc_1",
435
- file_path: localPath,
436
- filename: "test-local.txt",
437
- });
632
+ const result = await executeFeishuDocTool(feishuDocTool, {
633
+ action: "upload_file",
634
+ doc_token: "doc_1",
635
+ file_path: "/tmp/allowed/test-local.txt",
636
+ filename: "test-local.txt",
637
+ });
438
638
 
439
- expect(result.details.error).toBe("list failed");
440
- expect(driveUploadAllMock).not.toHaveBeenCalled();
441
- } finally {
442
- await fs.unlink(localPath);
443
- }
639
+ expect(result.details.error).toBe("list failed");
640
+ expect(driveUploadAllMock).not.toHaveBeenCalled();
641
+ });
642
+
643
+ it("rejects traversal paths in upload_file via loadWebMedia sandbox", async () => {
644
+ loadWebMediaMock.mockRejectedValueOnce(
645
+ new Error("Local media path is not under an allowed directory: /etc/passwd"),
646
+ );
647
+
648
+ const feishuDocTool = resolveFeishuDocTool();
649
+
650
+ const result = await executeFeishuDocTool(feishuDocTool, {
651
+ action: "upload_file",
652
+ doc_token: "doc_1",
653
+ file_path: "/etc/passwd",
654
+ });
655
+
656
+ expect(result.details.error).toContain("not under an allowed directory");
657
+ expect(driveUploadAllMock).not.toHaveBeenCalled();
658
+ });
659
+
660
+ it("rejects traversal paths in upload_image via loadWebMedia sandbox", async () => {
661
+ blockChildrenCreateMock.mockResolvedValueOnce({
662
+ code: 0,
663
+ data: {
664
+ children: [{ block_type: 27, block_id: "img_block_1" }],
665
+ },
666
+ });
667
+
668
+ loadWebMediaMock.mockRejectedValueOnce(
669
+ new Error(
670
+ "Local media path is not under an allowed directory: /home/admin/.openclaw/openclaw.json",
671
+ ),
672
+ );
673
+
674
+ const feishuDocTool = resolveFeishuDocTool();
675
+
676
+ const result = await executeFeishuDocTool(feishuDocTool, {
677
+ action: "upload_image",
678
+ doc_token: "doc_1",
679
+ file_path: "/home/admin/.openclaw/openclaw.json",
680
+ });
681
+
682
+ expect(result.details.error).toContain("not under an allowed directory");
683
+ expect(driveUploadAllMock).not.toHaveBeenCalled();
444
684
  });
445
685
  });