From 88386fb943f724dd335da324c5e6190678b030e4 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Thu, 30 Jul 2026 11:27:53 +0800 Subject: [PATCH] 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. --- hub/.env.example | 4 + hub/deploy/install_service.sh | 1 + hub/src/feishu/botCli.ts | 227 +++++++++++++++++++++++++ hub/src/feishu/download.ts | 44 +++-- hub/src/feishu/fileDeliveryTool.ts | 10 +- hub/src/feishu/resourceStaging.ts | 22 +-- hub/src/feishu/trigger.ts | 14 +- hub/test/integration/trigger.test.ts | 45 +++-- hub/test/unit/feishu-bot-cli.test.ts | 101 +++++++++++ hub/test/unit/feishu-download.test.ts | 35 ++-- hub/test/unit/resource-staging.test.ts | 9 +- 11 files changed, 456 insertions(+), 56 deletions(-) create mode 100644 hub/src/feishu/botCli.ts create mode 100644 hub/test/unit/feishu-bot-cli.test.ts diff --git a/hub/.env.example b/hub/.env.example index 49ec0cc..3ac889c 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -54,6 +54,10 @@ HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills" # This process is pinned to exactly one Organization. Feishu credentials are # resolved from that Organization's encrypted ACTIVE connection. HUB_SILO_ORGANIZATION_ID="" +# Absolute path to the bot-only lark-cli binary used by Agent Feishu tools. +# The CLI is invoked by Hub with a disposable HOME and the ACTIVE Feishu +# Application Connection; the App Secret is never put in Agent argv/env. +HUB_FEISHU_CLI_BIN="/usr/local/bin/lark-cli" HUB_SYSTEMD_UNIT="cph-hub-example.service" # Absolute path to the `cph` binary (ADR-0016). Production preflight requires diff --git a/hub/deploy/install_service.sh b/hub/deploy/install_service.sh index 70c32ef..a1f7103 100755 --- a/hub/deploy/install_service.sh +++ b/hub/deploy/install_service.sh @@ -164,6 +164,7 @@ DATABASE_URL= HUB_SILO_ORGANIZATION_ID= HUB_SYSTEMD_UNIT=$SERVICE_UNIT CPH_BIN=$CPH_BIN_DEFAULT +HUB_FEISHU_CLI_BIN=/usr/local/bin/lark-cli HOST=$HOST PORT=$PORT HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT diff --git a/hub/src/feishu/botCli.ts b/hub/src/feishu/botCli.ts new file mode 100644 index 0000000..bf8182d --- /dev/null +++ b/hub/src/feishu/botCli.ts @@ -0,0 +1,227 @@ +import { chmod, mkdir, mkdtemp, rm, stat } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; +import type { PrismaClient } from "@prisma/client"; +import { + resolveActiveFeishuApplication, + type ResolvedFeishuApplication, +} from "../connections/feishuApplicationConnections.js"; +import type { LocalSecretEnvelope } from "../security/secretEnvelope.js"; +import { + WorkspaceFileBoundaryError, + writeNewWorkspaceFileNoFollow, +} from "../security/workspaceFiles.js"; + +const DEFAULT_TIMEOUT_MS = 180_000; +const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024; +const DEFAULT_CLI_PATH = "/usr/local/bin:/usr/bin:/bin"; + +const SAFE_CLI_ENV_KEYS = [ + "PATH", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TZ", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "NODE_USE_ENV_PROXY", +] as const; + +export interface FeishuBotCliDownloadRequest { + readonly messageId: string; + readonly fileKey: string; + readonly resourceType: "image" | "file"; + readonly workspaceRoot: string; + readonly workspaceDir: string; + readonly workspaceRelativePath: string; + readonly maxBytes?: number | undefined; +} + +export interface FeishuBotCli { + downloadResource(request: FeishuBotCliDownloadRequest): Promise; +} + +export interface FeishuBotCliOptions { + readonly organizationId: string; + readonly prisma: PrismaClient; + readonly secretEnvelope: LocalSecretEnvelope; + readonly binary?: string | undefined; + readonly timeoutMs?: number | undefined; + readonly resolveCredential?: (() => Promise) | undefined; +} + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; +} + +/** + * Runs the real lark-cli as a Hub-owned bot operation. + * + * The CLI receives the App Secret over stdin and gets a disposable HOME. No + * Feishu credential is placed in Agent environment, project files, argv, or + * the process-global CLI configuration. The caller still owns project/chat + * authorization; this adapter only performs bot-identity transport. + */ +export function createFeishuBotCli(options: FeishuBotCliOptions): FeishuBotCli { + const resolveCredential = options.resolveCredential ?? (() => resolveActiveFeishuApplication( + options.prisma, + options.secretEnvelope, + { organizationId: options.organizationId }, + )); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + return { + async downloadResource(request): Promise { + const credential = await resolveCredential(); + const root = await mkdtemp(join(tmpdir(), "cph-feishu-bot-cli-"), { encoding: "utf8" }); + const home = join(root, "home"); + await mkdirPrivate(home); + const cliEnv = buildCliEnv(home); + const cliBinary = options.binary ?? process.env["HUB_FEISHU_CLI_BIN"] ?? "lark-cli"; + const temporaryName = "resource.bin"; + const temporaryPath = join(home, temporaryName); + + try { + await runCli( + cliBinary, + ["config", "init", "--app-id", credential.appId, "--app-secret-stdin", "--brand", "feishu"], + { cwd: root, env: cliEnv, stdin: `${credential.appSecret}\n`, timeoutMs, label: "config init" }, + ); + await runCli( + cliBinary, + [ + "im", + "+messages-resources-download", + "--as", + "bot", + "--message-id", + request.messageId, + "--file-key", + request.fileKey, + "--type", + request.resourceType, + "--output", + temporaryName, + ], + { cwd: home, env: cliEnv, timeoutMs, label: "resource download" }, + ); + + const metadata = await stat(temporaryPath); + if (!metadata.isFile()) { + throw new Error("lark-cli resource download did not produce a regular file"); + } + if (request.maxBytes !== undefined && metadata.size > request.maxBytes) { + throw new WorkspaceFileBoundaryError( + `Feishu resource exceeds ${request.maxBytes} bytes: ${request.fileKey}`, + request.workspaceRelativePath, + "limit", + ); + } + + return await writeNewWorkspaceFileNoFollow( + request.workspaceRoot, + request.workspaceDir, + request.workspaceRelativePath, + createReadStream(temporaryPath), + request.maxBytes, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + }; +} + +async function mkdirPrivate(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }); + await chmod(path, 0o700); +} + +function buildCliEnv(home: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const name of SAFE_CLI_ENV_KEYS) { + const value = process.env[name]; + if (value !== undefined) env[name] = value; + } + if (env.PATH === undefined || env.PATH.trim() === "") env.PATH = DEFAULT_CLI_PATH; + env.HOME = home; + env.XDG_CONFIG_HOME = join(home, ".config"); + env.XDG_CACHE_HOME = join(home, ".cache"); + env.XDG_STATE_HOME = join(home, ".state"); + return env; +} + +async function runCli( + binary: string, + args: readonly string[], + input: { + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly stdin?: string | undefined; + readonly timeoutMs: number; + readonly label: string; + }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(binary, args, { + cwd: input.cwd, + env: input.env, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let outputBytes = 0; + let settled = false; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + finish(new Error(`lark-cli ${input.label} timed out after ${input.timeoutMs}ms`)); + }, input.timeoutMs); + + const appendOutput = (target: "stdout" | "stderr", chunk: Buffer | string): void => { + if (outputBytes >= MAX_COMMAND_OUTPUT_BYTES) return; + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + const remaining = MAX_COMMAND_OUTPUT_BYTES - outputBytes; + const bounded = text.slice(0, remaining); + outputBytes += Buffer.byteLength(bounded); + if (target === "stdout") stdout += bounded; + else stderr += bounded; + }; + + const finish = (error?: Error, result?: CommandResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error !== undefined) reject(error); + else resolve(result!); + }; + + child.stdout.on("data", (chunk: Buffer | string) => appendOutput("stdout", chunk)); + child.stderr.on("data", (chunk: Buffer | string) => appendOutput("stderr", chunk)); + child.once("error", (error) => { + finish(error instanceof Error ? error : new Error(String(error))); + }); + child.once("close", (code, signal) => { + if (code !== 0) { + const detail = (stderr.trim() || stdout.trim()).slice(0, 500); + const status = code === null ? signal ?? "signal" : `exit ${code}`; + finish(new Error( + detail === "" + ? `lark-cli ${input.label} failed (${status})` + : `lark-cli ${input.label} failed (${status}): ${detail}`, + )); + return; + } + finish(undefined, { stdout, stderr }); + }); + child.stdin.end(input.stdin); + }); +} diff --git a/hub/src/feishu/download.ts b/hub/src/feishu/download.ts index 84cf463..15652da 100644 --- a/hub/src/feishu/download.ts +++ b/hub/src/feishu/download.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; import type { ToolContext } from "../agent/tools.js"; -import { downloadMessageFile, type FeishuRuntime } from "./client.js"; +import type { FeishuBotCli } from "./botCli.js"; +import type { FeishuRuntime } from "./client.js"; export interface FeishuMessageResourceArgs { readonly messageId: string; @@ -13,7 +14,11 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr readonly path: string; } -type DownloadContext = Pick & { readonly workspaceRoot: string }; +type DownloadContext = Pick & { + readonly workspaceRoot: string; + readonly botCli: FeishuBotCli; + readonly maxFileBytes?: number | undefined; +}; interface MessageLookupResult { readonly data?: { @@ -51,14 +56,29 @@ export async function downloadFeishuMessageResource( "inbox", `feishu-${args.resourceType}-${randomUUID()}${extension}`, ); - const savePath = await downloadMessageFile( - rt, - args.messageId, - args.fileKey, - context.workspaceRoot, - context.workspaceDir, - workspaceRelativePath, - args.resourceType, - ); - return { ...args, path: savePath }; + try { + const savePath = await context.botCli.downloadResource({ + messageId: args.messageId, + fileKey: args.fileKey, + resourceType: args.resourceType, + workspaceRoot: context.workspaceRoot, + workspaceDir: context.workspaceDir, + workspaceRelativePath, + maxBytes: context.maxFileBytes, + }); + return { ...args, path: savePath }; + } catch (error) { + rt.logger.error( + { + err: error, + messageId: args.messageId, + fileKey: args.fileKey, + resourceType: args.resourceType, + boundChatId: context.boundChatId, + workspaceDir: context.workspaceDir, + }, + "Feishu bot CLI resource download failed", + ); + throw error; + } } diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts index c1de731..1feaedf 100644 --- a/hub/src/feishu/fileDeliveryTool.ts +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -1,6 +1,7 @@ import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js"; +import { createFeishuBotCli } from "./botCli.js"; import { resolveDeliverableFile } from "./fileDelivery.js"; import { downloadFeishuMessageResource } from "./download.js"; import { readFeishuContext } from "./read.js"; @@ -38,6 +39,11 @@ export interface FileDeliveryToolOptions { } export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance { + const botCli = createFeishuBotCli({ + organizationId: options.organizationId, + prisma: options.prisma, + secretEnvelope: options.secretEnvelope, + }); const enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS); const tools: Array> = []; @@ -155,7 +161,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M tools.push( tool( "feishu_download_resource", - "Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace. Use message_id and file_key returned by feishu_read_context.", + "Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace using the Organization bot identity. Use message_id and file_key returned by feishu_read_context.", { message_id: z.string().describe("The Feishu message id containing the resource."), file_key: z.string().describe("The image_key or file_key from that message's content."), @@ -183,6 +189,8 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M boundChatId: options.chatId, workspaceRoot, workspaceDir: options.workspaceDir, + botCli, + maxFileBytes: options.maxFileBytes, }, options.rt, ); diff --git a/hub/src/feishu/resourceStaging.ts b/hub/src/feishu/resourceStaging.ts index 5ec1869..87fecbf 100644 --- a/hub/src/feishu/resourceStaging.ts +++ b/hub/src/feishu/resourceStaging.ts @@ -6,7 +6,7 @@ import { removeWorkspaceFileIfUnchangedNoFollow, type WorkspaceFileWriteResult, } from "../security/workspaceFiles.js"; -import { downloadMessageFile, type FeishuRuntime } from "./client.js"; +import type { FeishuBotCli } from "./botCli.js"; export interface MessageResourceStageRequest { readonly fileKey: string; @@ -33,7 +33,7 @@ export interface PublishedMessageResource extends WorkspaceFileWriteResult { /** Download Feishu resources into a private temporary workspace, never the tenant workspace. */ export async function stageMessageResources( - rt: FeishuRuntime, + botCli: FeishuBotCli, messageId: string, requests: readonly MessageResourceStageRequest[], workspaceRoot: string, @@ -49,16 +49,18 @@ export async function stageMessageResources( try { await mkdir(stagingRoot, { mode: 0o700 }); for (const [index, request] of requests.entries()) { - const stagedPath = await downloadMessageFile( - rt, + // Bot-identity transport only (ADR-0024): org App Secret stays in Hub, + // never crosses into the Agent surface. Staging still lands under the + // private .cph-staging tree before publish link into the tenant workspace. + const stagedPath = await botCli.downloadResource({ messageId, - request.fileKey, + fileKey: request.fileKey, + resourceType: request.resourceType, workspaceRoot, - stagingRoot, - `resource-${index}`, - request.resourceType, - limits?.maxBytesPerFile, - ); + workspaceDir: stagingRoot, + workspaceRelativePath: `resource-${index}`, + maxBytes: limits?.maxBytesPerFile, + }); resources.push({ resourceType: request.resourceType, workspaceRelativePath: request.workspaceRelativePath, diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index da4ecb0..48b8690 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -54,6 +54,7 @@ import { type MessageResourceStageRequest, type StagedMessageResourceBatch, } from "./resourceStaging.js"; +import { createFeishuBotCli, type FeishuBotCli } from "./botCli.js"; import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js"; import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js"; import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js"; @@ -115,6 +116,8 @@ interface TriggerDeps { readonly allowLegacyFeishuIdentity?: boolean | undefined; /** Alpha Silo aggregate ingress ceiling across message and card events. */ readonly maxFeishuEventsPerMinute?: number | undefined; + /** Test/injection seam for bot-identity Feishu resource downloads. */ + readonly feishuBotCli?: FeishuBotCli | undefined; } interface TriggerActor { @@ -303,8 +306,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { senderOpenId, }); const senderMetadata = await senderAuditMetadata(rt, senderOpenId); + const botCli = deps.feishuBotCli ?? createFeishuBotCli({ + organizationId: deps.siloOrganizationId, + prisma: deps.prisma, + secretEnvelope: deps.secretEnvelope, + }); const stagedResources = await stageTriggerMessageResources( - rt, + botCli, msg, projectWorkspaceRoot, deps.resourceLimits, @@ -1952,7 +1960,7 @@ function isPrismaUniqueConstraintError(error: unknown): boolean { } async function stageTriggerMessageResources( - rt: FeishuRuntime, + botCli: FeishuBotCli, msg: MessageReceiveEvent["message"], workspaceRoot: string, limits?: TriggerDeps["resourceLimits"], @@ -1986,7 +1994,7 @@ async function stageTriggerMessageResources( } } return stageMessageResources( - rt, + botCli, msg.message_id, requests, workspaceRoot, diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index e303f7c..ab855c2 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -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 { 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(); const releaseResource = deferred(); - 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); diff --git a/hub/test/unit/feishu-bot-cli.test.ts b/hub/test/unit/feishu-bot-cli.test.ts new file mode 100644 index 0000000..31b2f11 --- /dev/null +++ b/hub/test/unit/feishu-bot-cli.test.ts @@ -0,0 +1,101 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { createFeishuBotCli } from "../../src/feishu/botCli.js"; +import type { PrismaClient } from "@prisma/client"; +import type { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js"; +const itOnLinux = process.platform === "linux" ? it : it.skip; + +const fakeCredential = { + connectionId: "connection-1", + organizationId: "org-1", + appId: "cli-test-app", + appSecret: "cli-test-secret", + botOpenId: "ou-test-bot", + verificationToken: "verification-token", + encryptKey: "encrypt-key", +}; + +describe("Feishu bot CLI adapter", () => { + itOnLinux("uses bot identity and writes the CLI result into the workspace", async () => { + const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-test-")); + const workspaceDir = join(root, "workspace"); + const binary = join(root, "fake-lark-cli"); + await mkdir(workspaceDir); + await writeFakeCli(binary); + try { + const cli = createFeishuBotCli({ + organizationId: "org-1", + prisma: {} as PrismaClient, + secretEnvelope: {} as LocalSecretEnvelope, + binary, + resolveCredential: async () => fakeCredential, + }); + + const result = await cli.downloadResource({ + messageId: "message-1", + fileKey: "file-1", + resourceType: "file", + workspaceRoot: root, + workspaceDir, + workspaceRelativePath: "inbox/resource.bin", + maxBytes: 1024, + }); + + await expect(readFile(result, "utf8")).resolves.toBe("resource bytes"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects a resource above the configured limit", async () => { + const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-limit-")); + const workspaceDir = join(root, "workspace"); + const binary = join(root, "fake-lark-cli"); + await mkdir(workspaceDir); + await writeFakeCli(binary); + try { + const cli = createFeishuBotCli({ + organizationId: "org-1", + prisma: {} as PrismaClient, + secretEnvelope: {} as LocalSecretEnvelope, + binary, + resolveCredential: async () => fakeCredential, + }); + + await expect(cli.downloadResource({ + messageId: "message-1", + fileKey: "file-1", + resourceType: "file", + workspaceRoot: root, + workspaceDir, + workspaceRelativePath: "inbox/resource.bin", + maxBytes: 4, + })).rejects.toMatchObject({ reason: "limit" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +async function writeFakeCli(path: string): Promise { + await writeFile(path, `#!/usr/bin/env node +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +const args = process.argv.slice(2); +if (args[0] === "config" && args[1] === "init") { + process.stdin.resume(); + process.stdin.on("end", () => process.exit(0)); +} else if (args.includes("+messages-resources-download")) { + const asIndex = args.indexOf("--as"); + const outputIndex = args.indexOf("--output"); + if (asIndex < 0 || args[asIndex + 1] !== "bot" || outputIndex < 0) process.exit(2); + writeFileSync(join(process.cwd(), args[outputIndex + 1]), "resource bytes"); + process.exit(0); +} else { + process.exit(3); +} +`); + await chmod(path, 0o755); +} diff --git a/hub/test/unit/feishu-download.test.ts b/hub/test/unit/feishu-download.test.ts index 58112ac..201b38e 100644 --- a/hub/test/unit/feishu-download.test.ts +++ b/hub/test/unit/feishu-download.test.ts @@ -5,6 +5,8 @@ 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; @@ -27,14 +29,12 @@ describe("Feishu message resource download", () => { await mkdir(workspaceDir); try { const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } })); - const messageResourceGet = vi.fn(async () => ({ - getReadableStream: () => Readable.from([Buffer.from("image bytes")]), - })); + 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 }, + { boundChatId: "chat-1", workspaceRoot, workspaceDir, botCli }, rt, ); @@ -44,10 +44,7 @@ describe("Feishu message resource download", () => { ); expect(result.path).toMatch(/\.png$/); await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes"); - expect(messageResourceGet).toHaveBeenCalledWith({ - params: { type: "image" }, - path: { message_id: "message-1", file_key: "img-key-1" }, - }); + expect(messageResourceGet).not.toHaveBeenCalled(); } finally { await rm(workspaceRoot, { recursive: true, force: true }); } @@ -60,10 +57,14 @@ describe("Feishu message resource download", () => { await expect(downloadFeishuMessageResource( { messageId: "message-other", fileKey: "img-key-other", resourceType: "image" }, - { boundChatId: "chat-1", workspaceRoot: "/tmp", workspaceDir: "/tmp/project-1" }, + { + boundChatId: "chat-1", + workspaceRoot: "/tmp", + workspaceDir: "/tmp/project-1", + botCli: fakeBotCli(), + }, rt, )).rejects.toThrow("current project's bound chat"); - expect(messageResourceGet).not.toHaveBeenCalled(); }); }); @@ -92,6 +93,18 @@ function mockRuntime( }; } +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, "\\$&"); } diff --git a/hub/test/unit/resource-staging.test.ts b/hub/test/unit/resource-staging.test.ts index 9975e8a..3b787c1 100644 --- a/hub/test/unit/resource-staging.test.ts +++ b/hub/test/unit/resource-staging.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { removeAbandonedMessageResourceStages, stageMessageResources } from "../../src/feishu/resourceStaging.js"; -import type { FeishuRuntime } from "../../src/feishu/client.js"; +import type { FeishuBotCli } from "../../src/feishu/botCli.js"; const roots: string[] = []; @@ -34,8 +34,13 @@ describe("Feishu resource staging recovery", () => { it("rejects too many resources before contacting Feishu", async () => { const root = await tempRoot(); + const botCli: FeishuBotCli = { + downloadResource: async () => { + throw new Error("should not contact Feishu when over limit"); + }, + }; await expect(stageMessageResources( - {} as FeishuRuntime, + botCli, "message-1", [ { fileKey: "a", resourceType: "file", workspaceRelativePath: "a" },