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); }); }