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
+227
View File
@@ -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<string>;
}
export interface FeishuBotCliOptions {
readonly organizationId: string;
readonly prisma: PrismaClient;
readonly secretEnvelope: LocalSecretEnvelope;
readonly binary?: string | undefined;
readonly timeoutMs?: number | undefined;
readonly resolveCredential?: (() => Promise<ResolvedFeishuApplication>) | 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<string> {
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<void> {
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<CommandResult> {
return new Promise<CommandResult>((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);
});
}
+32 -12
View File
@@ -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<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & {
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;
}
}
+9 -1
View File
@@ -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<SdkMcpToolDefinition<any>> = [];
@@ -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,
);
+12 -10
View File
@@ -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,
+11 -3
View File
@@ -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,