From 6d81281811a5e117e0ef27af8bba067a08ccc8de Mon Sep 17 00:00:00 2001 From: jepson-liu Date: Sat, 21 Mar 2026 09:57:55 +0800 Subject: [PATCH] fix(feishu): fail fast on local media allowlist violations #51347 --- extensions/feishu/src/outbound.test.ts | 83 ++++++++++++++++++++++++++ extensions/feishu/src/outbound.ts | 58 ++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 64420f0a573..700e0fbb032 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -121,6 +121,35 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { } }); + it("throws clear guidance when local-image path is outside allowlist", async () => { + const { dir, file } = await createTmpImage(); + sendMediaFeishuMock.mockRejectedValueOnce({ + name: "LocalMediaAccessError", + code: "path-not-allowed", + message: `Local media path is not under an allowed directory: ${file}`, + }); + try { + let message = ""; + try { + await sendText({ + cfg: {} as any, + to: "chat_1", + text: file, + accountId: "main", + mediaLocalRoots: ["/state/media", "/state/workspace"], + }); + } catch (err) { + message = err instanceof Error ? err.message : String(err); + } + expect(message).toContain("channels.feishu.mediaLocalRoots is unsupported"); + expect(message).toContain("Allowed local media roots: /state/media, /state/workspace"); + expect(message).toContain("macOS note: OpenClaw uses os.tmpdir()"); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + it("uses markdown cards when renderMode=card", async () => { const result = await sendText({ cfg: { @@ -357,3 +386,57 @@ describe("feishuOutbound.sendMedia renderMode", () => { ); }); }); + +describe("feishuOutbound.sendMedia failure handling", () => { + beforeEach(() => { + resetOutboundMocks(); + }); + + it("throws non-zero guidance for path-not-allowed media paths", async () => { + sendMediaFeishuMock.mockRejectedValueOnce({ + name: "LocalMediaAccessError", + code: "path-not-allowed", + message: "Local media path is not under an allowed directory: /tmp/test.pdf", + }); + + let message = ""; + try { + await feishuOutbound.sendMedia?.({ + cfg: {} as any, + to: "chat_1", + text: "", + mediaUrl: "/tmp/test.pdf", + mediaLocalRoots: ["/state/media", "/state/workspace"], + accountId: "main", + }); + } catch (err) { + message = err instanceof Error ? err.message : String(err); + } + expect(message).toContain("command exits with non-zero status"); + expect(message).toContain("channels.feishu.mediaLocalRoots is unsupported"); + expect(message).toContain("Allowed local media roots: /state/media, /state/workspace"); + + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); + }); + + it("keeps URL-link fallback for non-allowlist media failures", async () => { + sendMediaFeishuMock.mockRejectedValueOnce(new Error("upstream upload failed")); + + await feishuOutbound.sendMedia?.({ + cfg: {} as any, + to: "chat_1", + text: "", + mediaUrl: "https://example.com/file.pdf", + accountId: "main", + }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + text: "📎 https://example.com/file.pdf", + accountId: "main", + }), + ); + }); +}); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 0c449f82bd2..7b9c9650da3 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -1,6 +1,7 @@ import fs from "fs"; import path from "path"; import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { getDefaultMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { sendMediaFeishu } from "./media.js"; @@ -59,6 +60,53 @@ function resolveReplyToMessageId(params: { return trimmed || undefined; } +function findLocalMediaPathNotAllowedError(err: unknown): unknown { + let current: unknown = err; + let depth = 0; + while (current && typeof current === "object" && depth < 5) { + const maybe = current as { name?: unknown; code?: unknown; cause?: unknown }; + if (maybe.name === "LocalMediaAccessError" && maybe.code === "path-not-allowed") { + return current; + } + current = maybe.cause; + depth += 1; + } + return null; +} + +function buildFeishuLocalMediaGuidanceError(params: { + err: unknown; + mediaUrl: string; + mediaLocalRoots?: readonly string[]; +}): Error { + const roots = + params.mediaLocalRoots && params.mediaLocalRoots.length > 0 + ? [...params.mediaLocalRoots] + : [...getDefaultMediaLocalRoots()]; + const stagingDir = roots.find((root) => path.basename(root) === "media") ?? "(stateDir)/media"; + const details = + params.err instanceof Error + ? params.err.message + : typeof params.err === "object" && + params.err && + "message" in params.err && + typeof (params.err as { message?: unknown }).message === "string" + ? ((params.err as { message: string }).message ?? "") + : String(params.err); + return new Error( + [ + "Feishu media upload failed; local media path is outside the allowed roots (command exits with non-zero status).", + "channels.feishu.mediaLocalRoots is unsupported for Feishu and ignored.", + `Rejected path: ${params.mediaUrl}`, + `Allowed local media roots: ${roots.join(", ")}`, + `Recommended staging directory: ${stagingDir}`, + "macOS note: OpenClaw uses os.tmpdir() (typically /var/folders/.../T), not /tmp.", + `Details: ${details}`, + ].join(" "), + { cause: params.err instanceof Error ? params.err : undefined }, + ); +} + async function sendOutboundText(params: { cfg: Parameters[0]["cfg"]; to: string; @@ -110,6 +158,13 @@ export const feishuOutbound: ChannelOutboundAdapter = { mediaLocalRoots, }); } catch (err) { + if (findLocalMediaPathNotAllowedError(err)) { + throw buildFeishuLocalMediaGuidanceError({ + err, + mediaUrl: localImagePath, + mediaLocalRoots, + }); + } console.error(`[feishu] local image path auto-send failed:`, err); // fall through to plain text as last resort } @@ -179,6 +234,9 @@ export const feishuOutbound: ChannelOutboundAdapter = { replyToMessageId, }); } catch (err) { + if (findLocalMediaPathNotAllowedError(err)) { + throw buildFeishuLocalMediaGuidanceError({ err, mediaUrl, mediaLocalRoots }); + } // Log the error for debugging console.error(`[feishu] sendMediaFeishu failed:`, err); // Fallback to URL link if upload fails