fix: confine Agent and MCP data access

This commit is contained in:
2026-07-10 16:38:08 +08:00
parent 73fa28a178
commit f4968d6657
25 changed files with 1521 additions and 221 deletions
+66 -16
View File
@@ -6,8 +6,12 @@
* reactions, file download/upload, and the card action callback.
*/
import * as lark from "@larksuiteoapi/node-sdk";
import { readFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { readFile } from "node:fs/promises";
import type { Readable } from "node:stream";
import {
WorkspaceFileBoundaryError,
writeNewWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
import type { SenderNameCache } from "./senderCache.js";
@@ -486,20 +490,38 @@ export async function downloadMessageFile(
rt: FeishuRuntime,
messageId: string,
fileKey: string,
savePath: string,
workspaceRoot: string,
workspaceDir: string,
workspaceRelativePath: string,
resourceType: "image" | "file",
): Promise<void> {
await mkdir(dirname(savePath), { recursive: true });
): Promise<string> {
try {
await withRetry(async () => {
return await withRetry(async () => {
const resource = await rt.client.im.v1.messageResource.get({
params: { type: resourceType },
path: { message_id: messageId, file_key: fileKey },
});
await resource.writeFile(savePath);
const readableResource = resource as unknown as { getReadableStream: () => Readable };
const source = readableResource.getReadableStream();
try {
return await writeNewWorkspaceFileNoFollow(
workspaceRoot,
workspaceDir,
workspaceRelativePath,
source,
);
} catch (error) {
source.destroy();
throw error;
}
}, {
shouldRetry: (error) => !(error instanceof WorkspaceFileBoundaryError),
});
} catch (e) {
rt.logger.error({ err: e, messageId, fileKey, resourceType, savePath }, "downloadMessageFile failed");
rt.logger.error(
{ err: e, messageId, fileKey, resourceType, workspaceRoot, workspaceDir, workspaceRelativePath },
"downloadMessageFile failed",
);
throw e;
}
}
@@ -512,33 +534,61 @@ export async function sendFile(
fileName: string,
options?: SendMessageOptions,
): Promise<string | null> {
try {
return await sendFileData(rt, chatId, await readFile(filePath), fileName, options);
} catch (error) {
// Legacy callers model delivery as nullable. Keep that API while retaining
// the complete failure object logged by sendFileData (or log read failure).
if (!(error instanceof FeishuFileDeliveryError)) {
rt.logger.error({ chatId, filePath, fileName, err: error }, "sendFile failed before upload");
}
return null;
}
}
export class FeishuFileDeliveryError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "FeishuFileDeliveryError";
}
}
/** Upload and send an already-opened, no-follow file snapshot to a chat. */
export async function sendFileData(
rt: FeishuRuntime,
chatId: string,
fileData: Buffer,
fileName: string,
options?: SendMessageOptions,
): Promise<string> {
const client = rt.client as unknown as {
im: { v1: {
file: { create: (p: unknown) => Promise<FileCreateResponse> }
} };
};
try {
const buf = await readFile(filePath);
const ext = fileName.split(".").pop() ?? "";
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
const uploadRes = await withRetry(async () =>
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } }),
client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: fileData } }),
);
const fileKey = fileKeyFromResponse(uploadRes);
if (fileKey === undefined) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
return null;
throw new FeishuFileDeliveryError("Feishu file upload response is missing file_key");
}
const messageId = await withRetry(async () =>
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
);
if (messageId === null) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
throw new FeishuFileDeliveryError("Feishu file message response is missing message_id");
}
return messageId;
} catch (e) {
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
return null;
} catch (error) {
const failure = error instanceof FeishuFileDeliveryError
? error
: new FeishuFileDeliveryError("Feishu file delivery failed", { cause: error });
rt.logger.error({ chatId, fileName, err: failure }, "sendFileData failed");
throw failure;
}
}