feat: support secure XFT submission attachments
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-09-01 03:44:36 +00:00
parent ba2cfa48cb
commit b7313ab74d
21 changed files with 1078 additions and 98 deletions
+208 -5
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env node
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import {
chmodSync, closeSync, constants as fsConstants, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync,
readFileSync, renameSync, rmSync, writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -11,6 +15,11 @@ import { z } from "zod";
import { APPROVAL_BATCH_LIMIT } from "./approval-contract.js";
import { isDirectEntrypoint } from "./entrypoint.js";
import { APPLICATION_VERSION } from "./version.js";
import {
XFT_ATTACHMENT_MAX_FILE_BYTES,
XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION,
XFT_ATTACHMENT_MAX_TOTAL_BYTES,
} from "./xft.js";
interface BridgeState {
client?: { client_id: string; client_secret?: string };
@@ -39,6 +48,23 @@ export const APPROVAL_ONBOARDING_EXAMPLES = [
const REMOTE_WRITE_TIMEOUT_MS = 45_000;
interface LocalSubmissionAttachment {
fieldName: string;
localPath: string;
displayName?: string;
size?: number;
modifiedAt?: string;
sha256?: string;
}
interface InspectedLocalAttachment {
fieldName: string;
fileName: string;
size: number;
sha256: string;
content: Buffer;
}
class AuthRequired extends Error {
constructor(readonly challenge: NonNullable<BridgeState["device"]>) { super("需要钉钉登录"); }
}
@@ -100,6 +126,32 @@ export class RemoteBridge {
});
}
async prepareSubmission(args: Record<string, unknown>): Promise<any> {
const attachments = inspectLocalSubmissionAttachments(args.attachments);
return this.call("approval_prepare_submission", {
...args,
attachments: attachments.map(localAttachmentMetadata),
});
}
async submitApproval(args: Record<string, unknown>): Promise<any> {
const attachments = inspectLocalSubmissionAttachments(args.attachments);
if (!attachments.length) return this.call("approval_submit", { ...args, attachments: [] });
const confirmationId = typeof args.confirmationId === "string" ? args.confirmationId : "";
if (!confirmationId) throw new Error("附件提交缺少最终确认,请重新预览并取得用户确认");
try {
const uploaded = [];
for (const attachment of attachments) {
const result = await this.uploadSubmissionAttachment(attachment, confirmationId);
uploaded.push({ ...localAttachmentMetadata(attachment), attachmentRef: result.attachmentRef });
}
return this.call("approval_submit", { ...args, attachments: uploaded });
} catch (error) {
if (error instanceof AuthRequired) return this.authResult(error.challenge);
throw error;
}
}
private async withCallSlot<T>(work: () => Promise<T>): Promise<T> {
if (this.activeCalls < 3) this.activeCalls += 1;
else await new Promise<void>((resolve) => this.callWaiters.push(resolve));
@@ -111,6 +163,53 @@ export class RemoteBridge {
}
}
private async uploadSubmissionAttachment(
attachment: InspectedLocalAttachment,
confirmationId: string,
): Promise<{ attachmentRef: string }> {
await this.tryCompleteDeviceLogin();
if (!this.usableAccessToken()) await this.refreshAccessToken();
if (!this.usableAccessToken()) throw new AuthRequired(await this.startDeviceLogin());
let response: Response;
try {
response = await this.fetchFn(new URL("/xft/submission-attachments", this.origin), {
method: "POST",
headers: {
authorization: `Bearer ${this.state.tokens!.access_token}`,
"content-type": "application/octet-stream",
"content-length": String(attachment.size),
"x-dingmcp-field-name": encodeURIComponent(attachment.fieldName),
"x-dingmcp-file-name": encodeURIComponent(attachment.fileName),
"x-dingmcp-file-sha256": attachment.sha256,
"x-dingmcp-confirmation-id": confirmationId,
},
body: attachment.content.buffer.slice(
attachment.content.byteOffset,
attachment.content.byteOffset + attachment.content.byteLength,
) as ArrayBuffer,
signal: AbortSignal.timeout(Math.max(REMOTE_WRITE_TIMEOUT_MS, 120_000)),
});
} catch (error) {
throw new Error(`附件“${attachment.fileName}”上传到审批网关时连接失败;审批尚未创建,请勿自动重试`, { cause: error });
}
let value: Record<string, unknown>;
try { value = await response.json() as Record<string, unknown>; }
catch { throw new Error(`附件“${attachment.fileName}”上传失败:审批网关返回了无法解析的响应`); }
if (!response.ok || typeof value.attachmentRef !== "string") {
const detail = typeof value.error === "string" ? value.error : `HTTP ${response.status}`;
throw new Error(`附件“${attachment.fileName}”上传失败:${detail}`);
}
if (
value.fieldName !== attachment.fieldName
|| value.fileName !== attachment.fileName
|| value.size !== attachment.size
|| value.sha256 !== attachment.sha256
) {
throw new Error(`附件“${attachment.fileName}”上传响应与本地文件不一致,审批尚未创建`);
}
return { attachmentRef: value.attachmentRef };
}
private async remoteClient(): Promise<Client> {
if (this.client && this.usableAccessToken()) return this.client;
if (this.client) {
@@ -420,6 +519,101 @@ function isExplicitOAuthInvalidation(status: number, value: Record<string, unkno
&& ["invalid_grant", "invalid_client", "unauthorized_client", "access_denied", "expired_token"].includes(code);
}
function inspectLocalSubmissionAttachments(value: unknown): InspectedLocalAttachment[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error("attachments 必须是本机附件数组");
if (value.length > XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION) {
throw new Error(`一次薪福通提单最多选择 ${XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION} 个附件`);
}
const attachments = value.map((item) => inspectLocalSubmissionAttachment(item));
const total = attachments.reduce((sum, attachment) => sum + attachment.size, 0);
if (total > XFT_ATTACHMENT_MAX_TOTAL_BYTES) throw new Error("一次薪福通提单的附件总大小不能超过 50MB");
return attachments;
}
function inspectLocalSubmissionAttachment(value: unknown): InspectedLocalAttachment {
const attachment = isObject(value) ? value as unknown as LocalSubmissionAttachment : undefined;
if (!attachment || typeof attachment.fieldName !== "string" || !attachment.fieldName.trim()) {
throw new Error("附件缺少实时表单中的字段名称");
}
if (typeof attachment.localPath !== "string" || !attachment.localPath.trim()) {
throw new Error("附件缺少用户明确选择的本机文件");
}
if (!isAbsolute(attachment.localPath)) throw new Error("附件必须使用本机绝对路径,请重新选择文件");
const resolvedPath = resolve(attachment.localPath);
const fileName = basename(resolvedPath);
if (attachment.displayName !== undefined && attachment.displayName !== fileName) {
throw new Error(`附件“${attachment.displayName}”与当前选择的文件名不一致,请重新选择`);
}
if (!fileName || fileName.length > 240 || /[\r\n\0"]/u.test(fileName)) throw new Error("附件文件名无效,请重新命名后选择");
let before;
let fd: number | undefined;
try {
before = lstatSync(resolvedPath);
if (!before.isFile() || before.isSymbolicLink()) throw new Error("not a regular file");
fd = openSync(resolvedPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
const opened = fstatSync(fd);
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) throw new Error("file changed while opening");
if (opened.size <= 0 || opened.size > XFT_ATTACHMENT_MAX_FILE_BYTES) throw new Error("invalid file size");
const content = readFileSync(fd);
const after = fstatSync(fd);
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || content.length !== opened.size) {
throw new Error("file changed while reading");
}
validateLocalInvoiceAttachment(fileName, content);
const sha256 = createHash("sha256").update(content).digest("hex");
if (attachment.size !== undefined && attachment.size !== content.length) {
throw new Error("stored size changed");
}
if (attachment.sha256 !== undefined && attachment.sha256 !== sha256) {
throw new Error("stored hash changed");
}
return {
fieldName: attachment.fieldName.trim(),
fileName,
size: content.length,
sha256,
content,
};
} catch (error) {
const reason = error instanceof Error ? error.message : "file validation failed";
if (reason === "invalid file type") throw new Error(`附件“${fileName}”不是支持的 PDF、OFD、JPG、PNG、XML 或 ZIP 文件`);
if (reason === "invalid file size") throw new Error(`附件“${fileName}”必须大于 0 字节且单个不超过 50MB`);
if (reason === "stored hash changed" || reason === "stored size changed" || reason === "file changed while reading") {
throw new Error(`附件“${fileName}”自保存草稿或预览后发生变化,请重新选择并确认`);
}
throw new Error(`无法安全读取附件“${fileName}”;请选择未经过符号链接的普通文件`);
} finally {
if (fd !== undefined) closeSync(fd);
}
}
function validateLocalInvoiceAttachment(fileName: string, content: Buffer): void {
const extension = extname(fileName).toLowerCase();
const zip = content.length >= 4 && content[0] === 0x50 && content[1] === 0x4b
&& [[0x03, 0x04], [0x05, 0x06], [0x07, 0x08]].some(([a, b]) => content[2] === a && content[3] === b);
const valid = extension === ".pdf" ? content.subarray(0, 5).toString("ascii") === "%PDF-"
: extension === ".png" ? content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
: extension === ".jpg" || extension === ".jpeg" ? content[0] === 0xff && content[1] === 0xd8 && content[2] === 0xff
: extension === ".xml" ? content.subarray(0, 1024).toString("utf8").replace(/^\uFEFF?\s*/u, "").startsWith("<")
: extension === ".ofd" || extension === ".zip" ? zip
: false;
if (!valid) throw new Error("invalid file type");
}
function localAttachmentMetadata(attachment: InspectedLocalAttachment) {
return {
fieldName: attachment.fieldName,
fileName: attachment.fileName,
size: attachment.size,
sha256: attachment.sha256,
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeEndpoint(value: string): URL {
const url = new URL(value);
if (url.protocol !== "https:" || url.pathname !== "/mcp") throw new Error("远程 MCP 必须是 HTTPS /mcp 地址");
@@ -433,7 +627,7 @@ function parseEndpoint(argv: string[]): string {
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: APPLICATION_VERSION }, {
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。薪福通待办不按单据类型过滤;availableActions=[]且manualAction.reason=unsupported_xft_task_type的项目仍要展示,但只能引导薪福通客户端查看处理,禁止尝试MCP写入。写前校验失败时零写入,任何不确定结果都不得自动重试。提单草稿仅可保存在 Agent 所在机器的当前用户私有目录,不得上传网关;恢复草稿后必须重新读取实时模板 Schema,短期提交引用不得写入草稿。草稿附件保存字段名称、本地路径、大小、修改时间和SHA-256;prepare只读取并复核本地文件、把不含路径的元数据用于预览,最终确认后的submit才上传,网关不保存文件。薪福通所有选择型字段只向用户展示业务名称:optionSource 选项展示label,内嵌枚举展示enumLabels;用户选定label后,values中必须填同一项的code而不能填label,code只存在于工具调用内部。不得向用户索取或展示城市码、组织码、工号、档案编号、数字舱位值或其他内部ID;selectionCapability=client_only时停止构造该必填项或允许跳过该可选项。",
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
@@ -503,16 +697,25 @@ export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
actionerStaffIds: z.array(z.string().min(1).max(500)).min(1).max(50),
})).max(20).default([]),
allowDuplicateInvoices: z.boolean().default(false).describe("仅在网关已提示重复风险或查重不完整、且用户明确确认仍要提交后设为 true"),
attachments: z.array(z.object({
fieldName: z.string().min(1).max(200).describe("实时 Schema 中的附件字段业务名称"),
localPath: z.string().min(1).max(4096).describe("用户明确选择的本机绝对文件路径;只由签名 Bridge 读取,不发送给网关"),
displayName: z.string().min(1).max(240).optional(),
size: z.number().int().min(1).max(XFT_ATTACHMENT_MAX_FILE_BYTES).optional(),
modifiedAt: z.string().datetime().optional(),
sha256: z.string().regex(/^[0-9a-f]{64}$/).optional(),
})).max(XFT_ATTACHMENT_MAX_FILES_PER_SUBMISSION).default([])
.describe("本机草稿附件引用;prepare复核文件且不上传,用户最终确认后的submit才上传"),
};
server.registerTool("approval_prepare_submission", {
title: "校验并预览新审批", description: "无副作用校验全部字段并自动查重本人近30天钉钉/薪福通发票;选择项在values中填所选label对应的code,成功预览只显示业务名称;有重复风险或查重不完整时要求用户明确确认,不会直接生成提交引用。", inputSchema: submissionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false },
}, async (args) => bridge.call("approval_prepare_submission", args));
}, async (args) => bridge.prepareSubmission(args));
server.registerTool("approval_submit", {
title: "正式发起审批", description: "仅在用户看过最终预览并明确确认后,使用完全相同内容和重复风险决定发起审批;禁止自动重试。", inputSchema: {
confirmationId: z.string().min(20).max(4096), ...submissionInputSchema,
}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
}, async (args) => bridge.call("approval_submit", args));
}, async (args) => bridge.submitApproval(args));
server.registerTool("approval_revoke_submission", {
title: "撤销本人发起的审批", description: "使用提单成功后返回的 revokeId 撤销本人发起的审批;禁止自动重试。薪福通 recall 当前为实验能力,必须以写后状态复核为准。", inputSchema: {
revokeId: z.string().min(20).max(4096), remark: z.string().max(1000).default(""),