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
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:
+208
-5
@@ -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(""),
|
||||
|
||||
Reference in New Issue
Block a user