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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user