forked from EduCraft/curriculum-project-hub
88386fb943
Agent tool downloads and trigger attachment staging both used the SDK messageResource path, which fails closed for multi-MB teacher files and did not share the bot-identity transport contract. Route every download through Hub-owned createFeishuBotCli (secret via stdin, disposable HOME, HUB_FEISHU_CLI_BIN), keep workspace containment on write, and inject the adapter in trigger tests.
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
|
|
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
|
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
|
|
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
|
|
import { downloadFeishuMessageResource } from "../../src/feishu/download.js";
|
|
|
|
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
|
|
|
describe("Feishu message resource download", () => {
|
|
it("exposes the download tool to default and explicitly configured roles", () => {
|
|
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
|
|
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual([
|
|
"todo_write",
|
|
"feishu_download_resource",
|
|
]);
|
|
expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([
|
|
"mcp__cph_hub__feishu_download_resource",
|
|
]);
|
|
});
|
|
|
|
itOnLinux("downloads a bound-chat image into the project inbox", async () => {
|
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "hub-feishu-download-"));
|
|
const workspaceDir = join(workspaceRoot, "project");
|
|
await mkdir(workspaceDir);
|
|
try {
|
|
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } }));
|
|
const messageResourceGet = vi.fn();
|
|
const rt = mockRuntime(messageGet, messageResourceGet);
|
|
const botCli = fakeBotCli();
|
|
const result = await downloadFeishuMessageResource(
|
|
{ messageId: "message-1", fileKey: "img-key-1", resourceType: "image" },
|
|
{ boundChatId: "chat-1", workspaceRoot, workspaceDir, botCli },
|
|
rt,
|
|
);
|
|
|
|
expect(result).toMatchObject({ resourceType: "image" });
|
|
expect(result.path).toMatch(
|
|
new RegExp(`^${escapeRegExp(join(await realpath(workspaceDir), ".cph", "inbox"))}`),
|
|
);
|
|
expect(result.path).toMatch(/\.png$/);
|
|
await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes");
|
|
expect(messageResourceGet).not.toHaveBeenCalled();
|
|
} finally {
|
|
await rm(workspaceRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects resources from a message outside the bound chat", async () => {
|
|
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-other" }] } }));
|
|
const messageResourceGet = vi.fn();
|
|
const rt = mockRuntime(messageGet, messageResourceGet);
|
|
|
|
await expect(downloadFeishuMessageResource(
|
|
{ messageId: "message-other", fileKey: "img-key-other", resourceType: "image" },
|
|
{
|
|
boundChatId: "chat-1",
|
|
workspaceRoot: "/tmp",
|
|
workspaceDir: "/tmp/project-1",
|
|
botCli: fakeBotCli(),
|
|
},
|
|
rt,
|
|
)).rejects.toThrow("current project's bound chat");
|
|
});
|
|
});
|
|
|
|
function mockRuntime(
|
|
messageGet: (payload: unknown) => Promise<unknown>,
|
|
messageResourceGet: (payload: unknown) => Promise<unknown>,
|
|
): FeishuRuntime {
|
|
return {
|
|
client: {
|
|
im: {
|
|
v1: {
|
|
message: { get: messageGet },
|
|
messageResource: { get: messageResourceGet },
|
|
},
|
|
},
|
|
} as unknown as FeishuRuntime["client"],
|
|
logger: {
|
|
info() {},
|
|
warn() {},
|
|
error() {},
|
|
debug() {},
|
|
fatal() {},
|
|
child() { return this; },
|
|
level: "silent",
|
|
} as unknown as FeishuRuntime["logger"],
|
|
};
|
|
}
|
|
|
|
function fakeBotCli(): FeishuBotCli {
|
|
return {
|
|
downloadResource: (request) => writeNewWorkspaceFileNoFollow(
|
|
request.workspaceRoot,
|
|
request.workspaceDir,
|
|
request.workspaceRelativePath,
|
|
Readable.from([Buffer.from("image bytes")]),
|
|
request.maxBytes,
|
|
),
|
|
};
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|