fix(hub): download Feishu resources via bot-owned lark-cli

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.
This commit is contained in:
2026-07-30 11:27:53 +08:00
parent 97c7054529
commit 88386fb943
11 changed files with 456 additions and 56 deletions
+28 -17
View File
@@ -11,11 +11,14 @@ import {
seedProject,
seedTestOrganization,
silentLogger,
testSecretEnvelope,
} from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
@@ -34,6 +37,7 @@ function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProduc
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: DEFAULT_ORG_ID,
allowLegacyFeishuIdentity: true,
secretEnvelope: testSecretEnvelope,
...deps,
});
}
@@ -190,13 +194,14 @@ describe("trigger full lifecycle (integration)", () => {
where: { id: "proj-post-image" },
data: { workspaceDir },
});
const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}));
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const downloadResource = vi.fn(async (request) => writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("image bytes")]),
request.maxBytes,
));
const feishuBotCli: FeishuBotCli = { downloadResource };
const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图");
const event: MessageReceiveEvent = {
...baseEvent,
@@ -220,6 +225,7 @@ describe("trigger full lifecycle (integration)", () => {
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
});
await trigger(event, rt);
@@ -228,10 +234,11 @@ describe("trigger full lifecycle (integration)", () => {
expect(runAgentCalls).toHaveLength(1);
});
expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" },
path: { message_id: event.message.message_id, file_key: "img-key-1" },
});
expect(downloadResource).toHaveBeenCalledWith(expect.objectContaining({
messageId: event.message.message_id,
fileKey: "img-key-1",
resourceType: "image",
}));
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
expect(inboxFiles).toHaveLength(1);
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
@@ -1109,15 +1116,18 @@ describe("trigger full lifecycle (integration)", () => {
});
const resourceEntered = deferred<void>();
const releaseResource = deferred<void>();
const messageResourceGet = vi.fn(async () => {
const downloadResource = vi.fn(async (request) => {
resourceEntered.resolve();
await releaseResource.promise;
return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
return writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("staged image bytes")]),
request.maxBytes,
);
});
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const feishuBotCli: FeishuBotCli = { downloadResource };
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
const event: MessageReceiveEvent = {
...baseEvent,
@@ -1141,6 +1151,7 @@ describe("trigger full lifecycle (integration)", () => {
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
});
const pendingTrigger = trigger(event, rt);