forked from EduCraft/curriculum-project-hub
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
+30
-10
@@ -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,
|
||||
try {
|
||||
const savePath = await context.botCli.downloadResource({
|
||||
messageId: args.messageId,
|
||||
fileKey: args.fileKey,
|
||||
resourceType: args.resourceType,
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
workspaceDir: context.workspaceDir,
|
||||
workspaceRelativePath,
|
||||
args.resourceType,
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user