feat: initial public release
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-08-17 03:04:09 +00:00
commit 504145aec6
62 changed files with 18318 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
import { createHash, createPublicKey, sign as signData } from "node:crypto";
import { cpSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { basename, join, resolve, sep } from "node:path";
import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util";
const { values } = parseArgs({ options: {
"gateway-url": { type: "string" },
"download-base-url": { type: "string" },
"signing-key": { type: "string", default: ".secrets/release-signing-private.pem" },
out: { type: "string", default: "release" },
} });
const gatewayUrl = exactHttps(values["gateway-url"], "/mcp", "--gateway-url");
const downloadBaseUrl = httpsBase(values["download-base-url"], "--download-base-url");
const out = resolve(String(values.out));
const projectRoot = resolve(".");
const signingKeyPath = resolve(String(values["signing-key"]));
const publicKeyPath = resolve("distribution/release-signing-public.pem");
const packageMetadata = JSON.parse(readFileSync(resolve("package.json"), "utf8"));
const packageLock = JSON.parse(readFileSync(resolve("package-lock.json"), "utf8"));
const version = String(packageMetadata.version ?? "").trim();
if (!version) throw new Error("package.json 缺少版本号");
if (out === projectRoot || !out.startsWith(`${projectRoot}${sep}`)) throw new Error("--out 必须是项目目录下的专用子目录");
assertPrivateKey(signingKeyPath);
const privateKey = readFileSync(signingKeyPath);
const publicKey = readFileSync(publicKeyPath);
const derivedPublic = createPublicKey(privateKey).export({ type: "spki", format: "pem" });
if (normalizePem(derivedPublic) !== normalizePem(publicKey)) throw new Error("发布私钥与仓库固定公钥不匹配");
const keyId = releaseKeyId(publicKey);
const clientStaging = join(out, ".client-staging");
rmSync(out, { recursive: true, force: true });
mkdirSync(out, { recursive: true, mode: 0o755 });
execFileSync("npm", ["run", "build"], { stdio: "inherit", env: npmEnvironment() });
mkdirSync(join(clientStaging, "dist", "src"), { recursive: true });
for (const name of ["bridge.js", "entrypoint.js", "client-installer.js", "approval-contract.js"]) {
cpSync(resolve("dist", "src", name), join(clientStaging, "dist", "src", name));
}
const clientPackage = {
name: "dingtalk-approval-client",
version,
description: "Lightweight user client for the protected DingTalk OA and XFT approval gateway.",
type: "module",
bin: {
"dingtalk-approval": "dist/src/client-installer.js",
"dingtalk-approval-bridge": "dist/src/bridge.js",
},
files: ["dist/src"],
engines: { node: ">=22.13" },
dependencies: {
"@modelcontextprotocol/sdk": lockedVersion("@modelcontextprotocol/sdk"),
zod: lockedVersion("zod"),
},
bundledDependencies: ["@modelcontextprotocol/sdk", "zod"],
};
writeFileSync(join(clientStaging, "package.json"), `${JSON.stringify(clientPackage, null, 2)}\n`, "utf8");
execFileSync("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], {
cwd: clientStaging, stdio: "inherit", env: npmEnvironment(),
});
const packed = JSON.parse(execFileSync("npm", ["pack", "--json", "--pack-destination", out], {
cwd: clientStaging, encoding: "utf8", env: npmEnvironment(),
}));
const clientName = packed?.[0]?.filename;
if (typeof clientName !== "string") throw new Error("npm pack 没有返回客户端包文件名");
const installerName = "dingtalk-approval-installer.mjs";
const publicKeyName = "release-signing-public.pem";
const skillName = "dingtalk-approval-SKILL.md";
cpSync(resolve("dist/src/client-installer.js"), join(out, installerName));
cpSync(publicKeyPath, join(out, publicKeyName));
cpSync(resolve("skills/dingtalk-approval/SKILL.md"), join(out, skillName));
rmSync(clientStaging, { recursive: true, force: true });
const artifacts = {
installer: artifact(installerName),
publicKey: { ...artifact(publicKeyName), keyId },
client: artifact(clientName),
skill: artifact(skillName),
};
const manifest = {
schemaVersion: 3,
version,
createdAt: new Date().toISOString(),
gatewayUrl,
signing: { algorithm: "Ed25519", keyId },
requirements: { node: ">=22.13.0" },
artifacts,
};
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(join(out, "manifest.json"), manifestBytes);
const signature = signData(null, manifestBytes, privateKey).toString("base64");
writeFileSync(join(out, "manifest.sig"), `${JSON.stringify({
schemaVersion: 1, algorithm: "Ed25519", keyId, signature,
}, null, 2)}\n`, "utf8");
const template = readFileSync(resolve("distribution/agent-install.template.md"), "utf8");
const replacements = new Map([
["{{VERSION}}", version], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`],
["{{MANIFEST_SIGNATURE_URL}}", `${downloadBaseUrl}/manifest.sig`], ["{{GATEWAY_URL}}", gatewayUrl],
["{{INSTALLER_URL}}", artifacts.installer.url], ["{{INSTALLER_SHA256}}", artifacts.installer.sha256],
["{{PUBLIC_KEY_URL}}", artifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", artifacts.publicKey.sha256],
["{{RELEASE_KEY_ID}}", keyId],
]);
let prompt = template;
for (const [from, to] of replacements) prompt = prompt.replaceAll(from, to);
if (/\{\{[^}]+\}\}/.test(prompt)) throw new Error("安装指令仍包含未替换占位符");
writeFileSync(join(out, "install.md"), prompt, "utf8");
const checksums = Object.values(artifacts).map((value) => `${value.sha256} ${basename(new URL(value.url).pathname)}`);
checksums.push(`${sha256(join(out, "manifest.json"))} manifest.json`, `${sha256(join(out, "manifest.sig"))} manifest.sig`);
writeFileSync(join(out, "SHA256SUMS"), `${checksums.join("\n")}\n`, "utf8");
console.log(`发布目录:${out}`);
console.log(`签名密钥:${keyId}`);
console.log(`给用户的唯一链接:${downloadBaseUrl}/install.md`);
function artifact(name) {
return { url: `${downloadBaseUrl}/${basename(name)}`, sha256: sha256(join(out, name)) };
}
function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
function releaseKeyId(value) {
const der = createPublicKey(value).export({ type: "spki", format: "der" });
return createHash("sha256").update(der).digest("hex");
}
function normalizePem(value) { return Buffer.from(value).toString("utf8").replace(/\r/g, "").trim(); }
function npmEnvironment() { return { ...process.env, npm_config_cache: join(projectRoot, ".npm-cache") }; }
function lockedVersion(name) {
const value = packageLock.packages?.[`node_modules/${name}`]?.version;
if (typeof value !== "string" || !/^\d+\.\d+\.\d+/.test(value)) throw new Error(`package-lock.json 缺少 ${name} 的固定版本`);
return value;
}
function assertPrivateKey(path) {
const stat = statSync(path);
if (!stat.isFile()) throw new Error("发布签名私钥不是普通文件");
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("发布签名私钥权限必须为 600");
}
function exactHttps(value, pathname, flag) {
const url = parseHttps(value, flag);
if (url.pathname !== pathname || url.search || url.hash) throw new Error(`${flag} 必须是 HTTPS ${pathname} 地址`);
return url.toString().replace(/\/$/, "");
}
function httpsBase(value, flag) {
const url = parseHttps(value, flag);
if (url.search || url.hash) throw new Error(`${flag} 不能包含 query 或 hash`);
return url.toString().replace(/\/$/, "");
}
function parseHttps(value, flag) {
if (!value) throw new Error(`缺少 ${flag}`);
const url = new URL(String(value));
if (url.protocol !== "https:" || url.username || url.password) throw new Error(`${flag} 必须使用无内嵌凭证的 HTTPS URL`);
return url;
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { basename, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util";
const { values } = parseArgs({ options: {
"release-dir": { type: "string", default: "release-test" },
bundle: { type: "string", default: "output/dingmcp-test-bundle.tar.gz" },
"bundle-url": { type: "string" },
prompt: { type: "string", default: "output/agent-test-install.md" },
} });
const releaseDir = resolve(String(values["release-dir"]));
const bundle = resolve(String(values.bundle));
const bundleUrl = exactHttps(values["bundle-url"]);
const promptPath = resolve(String(values.prompt));
const manifest = JSON.parse(readFileSync(`${releaseDir}/manifest.json`, "utf8"));
if (typeof manifest.gatewayUrl !== "string" || !manifest.gatewayUrl.trim()) {
throw new Error("完整黑盒测试包必须配置薪福通 gatewayUrl;请先部署网关,并用 release:build --gateway-url https://<域名>/mcp 重新生成发行目录");
}
const gateway = new URL(manifest.gatewayUrl);
if (gateway.protocol !== "https:" || gateway.pathname !== "/mcp" || gateway.search || gateway.hash) {
throw new Error("manifest.gatewayUrl 必须是无 query/hash 的 HTTPS /mcp 地址");
}
const artifactNames = [...new Set(Object.values(manifest.artifacts ?? {}).map((item) => basename(String(item.url))))];
const members = ["SHA256SUMS", "install.md", "manifest.json", "manifest.sig", ...artifactNames];
execFileSync("tar", ["-czf", bundle, "-C", releaseDir, ...members]);
const digest = createHash("sha256").update(readFileSync(bundle)).digest("hex");
let prompt = readFileSync(resolve("distribution/agent-test.template.md"), "utf8");
prompt = prompt.replaceAll("{{BUNDLE_URL}}", bundleUrl).replaceAll("{{BUNDLE_SHA256}}", digest);
writeFileSync(promptPath, prompt, "utf8");
console.log(`测试包:${bundle}`);
console.log(`SHA-256:${digest}`);
console.log(`测试提示词:${promptPath}`);
function exactHttps(value) {
if (!value) throw new Error("缺少 --bundle-url");
const url = new URL(String(value));
if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new Error("--bundle-url 必须是无内嵌凭证的 HTTPS URL");
return url.toString();
}
+5
View File
@@ -0,0 +1,5 @@
import { rmSync } from "node:fs";
import { resolve } from "node:path";
// Build output only; source, runtime state and secrets are never touched.
rmSync(resolve("dist"), { recursive: true, force: true });
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
import { parseArgs } from "node:util";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const [mode, ...optionArgs] = process.argv.slice(2);
if (!mode || !["dingtalk", "xft"].includes(mode)) throw new Error(usage());
const { values } = parseArgs({ args: optionArgs, options: {
"business-no": { type: "string" },
"execute-real-write": { type: "boolean", default: false },
}, strict: true });
const endpoint = process.env.DINGTALK_APPROVAL_MCP_URL;
if (!endpoint) throw new Error("缺少 DINGTALK_APPROVAL_MCP_URL;脚本不会默认连接任何生产网关");
const endpointUrl = new URL(endpoint);
if (endpointUrl.protocol !== "https:" || endpointUrl.pathname !== "/mcp") {
throw new Error("DINGTALK_APPROVAL_MCP_URL 必须是 HTTPS /mcp 地址");
}
const businessNo = String(values["business-no"] ?? "").trim();
const execute = values["execute-real-write"] === true;
if (execute && !businessNo) throw new Error("真实写入必须同时提供 --business-no 精确指定审批");
const client = new Client({ name: "dingmcp-e2e", version: "1.0.0" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [new URL("../dist/src/bridge.js", import.meta.url).pathname, "--url", endpointUrl.toString()],
stderr: "pipe",
});
await client.connect(transport);
try {
await runWorkflow();
} finally {
await client.close();
}
async function runWorkflow() {
const listed = result(await client.callTool({
name: "approval_list_todos",
arguments: { source: mode, query: "", limit: 200 },
}));
const candidates = Array.isArray(listed.items) ? listed.items : [];
if (!businessNo) {
console.log(JSON.stringify({
mode, writeExecuted: false, total: candidates.length,
items: candidates.map(summaryItem),
next: "如需验证单张详情,重新运行并传 --business-no;除非明确获批,不要传 --execute-real-write。",
}, null, 2));
return;
}
const selected = candidates.find((item) => item?.businessNo === businessNo);
if (!selected) throw new Error(`${mode} 实时待办中不存在 businessNo=${businessNo}`);
const approvalId = selected.approvalId;
const detail = result(await client.callTool({
name: "approval_get_detail", arguments: { approvalId, view: mode === "xft" ? "expense" : "summary" },
}));
const actions = result(await client.callTool({ name: "approval_get_actions", arguments: { approvalId } }));
const approve = Array.isArray(actions.actions) && actions.actions.find((action) => action?.action === "approve");
if (!execute) {
console.log(JSON.stringify({
mode, writeExecuted: false, selected: summaryItem(selected),
detail: summarizeDetail(detail), actions: actions.actions,
}, null, 2));
return;
}
if (!approve?.allowed) throw new Error(`${mode} 指定待办实时动作不允许 approve`);
if (!Array.isArray(selected.availableActions) || !selected.availableActions.includes("approve") || selected.manualAction) {
throw new Error(`${mode} 指定待办不能通过 MCP 自动同意`);
}
const processed = result(await client.callTool({
name: "approval_process_batch",
arguments: { action: "approve", remark: "同意", items: [{ approvalId }] },
}));
const verified = result(await client.callTool({
name: "approval_list_todos", arguments: { source: mode, query: "", limit: 200 },
}));
const stillPresent = Array.isArray(verified.items) && verified.items.some((item) => item?.businessNo === businessNo);
console.log(JSON.stringify({
mode, writeExecuted: true, selected: summaryItem(selected), detail: summarizeDetail(detail),
actions: actions.actions, processed,
verification: { stillPresent, remaining: Array.isArray(verified.items) ? verified.items.length : undefined },
}, null, 2));
if (processed.succeeded !== 1 || stillPresent) process.exitCode = 2;
}
function usage() {
return "usage: DINGTALK_APPROVAL_MCP_URL=https://host/mcp e2e-approval-mcp.mjs dingtalk|xft [--business-no EXACT] [--execute-real-write]";
}
function result(call) {
const value = call?.structuredContent?.result;
if (!value || typeof value !== "object") throw new Error("MCP 工具没有返回 structuredContent.result");
return value;
}
function summaryItem(item) {
return {
businessNo: item?.businessNo, title: item?.title, applicant: item?.applicant,
amount: item?.amount, submittedAt: item?.submittedAt, availableActions: item?.availableActions,
};
}
function summarizeDetail(value) {
const detail = value?.detail && typeof value.detail === "object" ? value.detail : {};
return {
source: value.source, sourceType: value.sourceType, view: value.view,
applicant: detail.applicant, amount: detail.amount, submittedAt: detail.submittedAt,
summary: detail.summary,
fields: Array.isArray(detail.formFields) ? detail.formFields.slice(0, 12) : undefined,
expenseItems: Array.isArray(detail.expenseItems) ? detail.expenseItems.slice(0, 5) : undefined,
};
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto";
import { dirname, resolve } from "node:path";
import { parseArgs } from "node:util";
const { values } = parseArgs({ options: {
"private-key": { type: "string", default: ".secrets/release-signing-private.pem" },
"public-key": { type: "string", default: "distribution/release-signing-public.pem" },
} });
const privatePath = resolve(String(values["private-key"]));
const publicPath = resolve(String(values["public-key"]));
if (existsSync(privatePath) || existsSync(publicPath)) throw new Error("发布签名密钥已存在;拒绝覆盖或静默轮换");
const pair = generateKeyPairSync("ed25519");
const privatePem = pair.privateKey.export({ type: "pkcs8", format: "pem" });
const publicPem = pair.publicKey.export({ type: "spki", format: "pem" });
mkdirSync(dirname(privatePath), { recursive: true, mode: 0o700 });
mkdirSync(dirname(publicPath), { recursive: true, mode: 0o755 });
writeFileSync(privatePath, privatePem, { mode: 0o600 });
writeFileSync(publicPath, publicPem, { mode: 0o644 });
if (process.platform !== "win32") chmodSync(privatePath, 0o600);
const keyId = createHash("sha256").update(createPublicKey(publicPem).export({ type: "spki", format: "der" })).digest("hex");
console.log(`发布签名密钥已生成;keyId=${keyId}`);
console.log(`私钥(禁止提交或部署到下载目录):${privatePath}`);
console.log(`公钥(应提交 Git):${publicPath}`);
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const host = process.argv[2];
const remotePath = process.argv[3] ?? "/home/pi/.config/ding-approval-tui/config.yaml";
const destination = resolve(process.env.DINGTALK_CREDENTIALS_FILE ?? ".secrets/dingtalk.json");
if (!host || host.startsWith("-") || !/^[a-zA-Z0-9_.@:-]+$/.test(host)) {
throw new Error("用法:npm run import:dingcli -- 用户@主机 [远程配置路径]");
}
if (!remotePath.startsWith("/") || /[\r\n\0]/.test(remotePath)) {
throw new Error("远程配置路径必须是绝对路径");
}
const yaml = execFileSync("ssh", ["-o", "BatchMode=yes", host, "cat", remotePath], {
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
maxBuffer: 1024 * 1024,
});
const appKey = yamlValue(yaml, "app_key");
const appSecret = yamlValue(yaml, "app_secret");
if (!appKey || !appSecret) throw new Error("远端 dingCLI 配置缺少 app_key 或 app_secret");
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
const fd = openSync(destination, "wx", 0o600);
try {
writeFileSync(fd, `${JSON.stringify({ clientId: appKey, clientSecret: appSecret }, null, 2)}\n`);
} finally {
closeSync(fd);
}
console.error(`钉钉凭证已安全导入 ${destination}(未复制其他配置)`);
function yamlValue(source, key) {
const match = source.match(new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, "m"));
if (!match) return undefined;
const value = match[1].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
return value.slice(1, -1);
}
return value.replace(/\s+#.*$/, "").trim();
}
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
import { createHash, createPublicKey, verify as verifySignature } from "node:crypto";
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, resolve, sep } from "node:path";
import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util";
const { values } = parseArgs({ options: { "release-dir": { type: "string", default: "release" } } });
const releaseDir = resolve(String(values["release-dir"]));
const manifestBytes = readFileSync(join(releaseDir, "manifest.json"));
const manifest = JSON.parse(manifestBytes.toString("utf8"));
const signature = JSON.parse(readFileSync(join(releaseDir, "manifest.sig"), "utf8"));
const publicKey = readFileSync(resolve("distribution/release-signing-public.pem"));
const keyId = releaseKeyId(publicKey);
const version = String(manifest.version ?? "");
if (manifest.schemaVersion !== 3 || !/^\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(version)) throw new Error("manifest 缺少有效 schemaVersion 3 或版本号");
if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing?.keyId !== keyId) throw new Error("manifest 发布公钥编号不匹配");
if (signature.schemaVersion !== 1 || signature.algorithm !== "Ed25519" || signature.keyId !== keyId) throw new Error("manifest.sig 元数据不匹配");
if (!verifySignature(null, manifestBytes, publicKey, Buffer.from(String(signature.signature), "base64"))) throw new Error("manifest Ed25519 签名无效");
for (const [kind, artifact] of Object.entries(manifest.artifacts ?? {})) {
const name = artifactName(artifact, kind);
const expected = String(artifact.sha256 ?? "");
const actual = sha256(join(releaseDir, name));
if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) throw new Error(`${kind} SHA-256 不匹配`);
}
if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("publicKey artifact keyId 不匹配");
const root = mkdtempSync(join(tmpdir(), "dingmcp-release-verify-"));
try {
const clientName = artifactName(manifest.artifacts.client, "client");
const archivePath = join(releaseDir, clientName);
const members = execFileSync("tar", ["-tzf", archivePath], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
const verboseMembers = execFileSync("tar", ["-tvzf", archivePath], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
if (verboseMembers.some((line) => line.startsWith("l") || line.startsWith("h"))) {
throw new Error(`${clientName} 含符号链接或硬链接,拒绝解压`);
}
for (const member of members) {
if (member.startsWith("/") || member.split("/").includes("..")) throw new Error(`${clientName} 含不安全归档路径:${member}`);
if (/(?:^|\/)(?:\.secrets|\.runtime|auth\.sqlite|session\.json)(?:\/|$)/.test(member)) throw new Error(`${clientName} 含禁止发布文件:${member}`);
}
const clientRoot = join(root, "client");
execFileSync("mkdir", ["-p", clientRoot]);
execFileSync("tar", ["-xzf", archivePath, "-C", clientRoot]);
scanTree(clientRoot);
const packageMetadata = JSON.parse(readFileSync(join(root, "client", "package", "package.json"), "utf8"));
if (packageMetadata.name !== "dingtalk-approval-client" || packageMetadata.version !== version) throw new Error("客户端 package.json 名称或版本不一致");
if (JSON.stringify(Object.keys(packageMetadata.bin ?? {}).sort()) !== JSON.stringify(["dingtalk-approval", "dingtalk-approval-bridge"])) {
throw new Error("客户端只能发布安装 CLI 和 Bridge 两个 bin");
}
if (JSON.stringify([...(packageMetadata.bundledDependencies ?? packageMetadata.bundleDependencies ?? [])].sort()) !== JSON.stringify(["@modelcontextprotocol/sdk", "zod"])) {
throw new Error("客户端必须内置全部签名运行依赖");
}
for (const dependency of ["@modelcontextprotocol/sdk", "zod"]) {
if (!existsFile(join(root, "client", "package", "node_modules", ...dependency.split("/"), "package.json"))) {
throw new Error(`客户端缺少内置依赖:${dependency}`);
}
}
for (const target of Object.values(packageMetadata.bin ?? {})) {
const body = readFileSync(join(root, "client", "package", String(target)), "utf8");
if (!body.startsWith("#!/usr/bin/env node\n")) throw new Error(`客户端 CLI 缺少 Node shebang:${target}`);
}
const offlineRoot = join(root, "offline-install");
execFileSync("npm", [
"install", "--prefix", offlineRoot, "--cache", join(root, "empty-cache"), "--offline",
"--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", join(releaseDir, artifactName(manifest.artifacts.client, "client")),
], { stdio: "pipe", env: { ...process.env, npm_config_update_notifier: "false" } });
if (!existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "approval-contract.js"))) {
throw new Error("签名客户端无法在空缓存中离线安装");
}
const installer = readFileSync(join(releaseDir, artifactName(manifest.artifacts.installer, "installer")), "utf8");
if (!installer.startsWith("#!/usr/bin/env node\n") || /from ["']\.\//.test(installer)) throw new Error("首次安装器必须是无相对依赖的独立 Node 脚本");
const install = readFileSync(join(releaseDir, "install.md"), "utf8");
if (install.includes("{{") || !install.includes(`版本:\`${version}\``)
|| !install.includes("唯一通用安装入口") || !install.includes("postInstall")
|| !install.includes("onboarding.firstRun=true") || /--host|WorkBuddy|Codex|Claude/.test(install)) {
throw new Error("install.md 版本、占位符或通用安装引导不正确");
}
const skill = readFileSync(join(releaseDir, artifactName(manifest.artifacts.skill, "skill")), "utf8");
if (!skill.startsWith("---\nname: dingtalk-approval\n")) throw new Error("通用 Skill artifact 无效");
} finally {
rmSync(root, { recursive: true, force: true });
}
console.log(`发行包签名与客户端校验通过:${version}`);
function artifactName(value, kind) {
const url = value?.url;
if (typeof url !== "string") throw new Error(`manifest 缺少 ${kind} URL`);
return basename(new URL(url).pathname);
}
function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
function releaseKeyId(value) {
const der = createPublicKey(value).export({ type: "spki", format: "der" });
return createHash("sha256").update(der).digest("hex");
}
function existsFile(path) { try { return statSync(path).isFile(); } catch { return false; } }
function scanTree(rootPath) {
for (const path of walk(rootPath)) {
if (statSync(path).size > 2_000_000) continue;
const body = readFileSync(path, "utf8");
if (/(?:^|\r?\n)-----BEGIN (?:RSA |EC |ENCRYPTED )?PRIVATE KEY-----\r?\n(?:[A-Za-z0-9+/=]{20,}\r?\n)+/.test(body)) {
throw new Error(`发行物疑似包含私钥:${path}`);
}
if (!path.includes(`${sep}node_modules${sep}`)
&& (/\/(?:home|Users)\/[a-zA-Z0-9._-]+\//.test(body) || /[A-Za-z]:\\Users\\[a-zA-Z0-9._-]+\\/.test(body))) {
throw new Error(`发行物包含机器私有绝对路径:${path}`);
}
}
}
function* walk(rootPath) {
for (const entry of readdirSync(rootPath, { withFileTypes: true })) {
const path = join(rootPath, entry.name);
if (entry.isDirectory()) yield* walk(path);
else if (entry.isFile()) yield path;
}
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createApprovalMcpServer } from "../dist/src/server.js";
import { XftOpenApiClient } from "../dist/src/xft-open-api.js";
const [appId, secretFile, enterpriseId, dingTalkUserId] = process.argv.slice(2);
if (!appId || !secretFile || !enterpriseId || !dingTalkUserId) {
throw new Error("usage: xft-mcp-readonly-matrix.mjs <appId> <secretFile> <enterpriseId> <dingTalkUserId>");
}
const lines = readFileSync(secretFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const config = {
appMode: "gateway",
clientId: "readonly-mcp", clientSecret: "readonly-mcp",
apiBaseUrl: "https://api.dingtalk.com", oapiBaseUrl: "https://oapi.dingtalk.com",
publicBaseUrl: "https://readonly.invalid", port: 3000, requestTimeoutMs: 15_000,
officialLookbackDays: 120, officialScanConcurrency: 2, officialClientOnlyProcessCodes: [],
batchLimit: 20, approvalRefSecret: "readonly-matrix-reference-secret",
xftOpenApi: {
appId, authoritySecret: lines.length >= 2 ? lines[1] : lines[0], enterpriseId,
apiBaseUrl: "https://api.cmbchina.com", encrypted: true, dingTalkAppCode: "DDingTalk",
},
};
const xft = new XftOpenApiClient(config);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createApprovalMcpServer(
config,
{ async listMyTodos() { throw new Error("DingTalk source must not be called"); } },
xft,
undefined,
undefined,
{ userId: dingTalkUserId },
);
const client = new Client({ name: "xft-readonly-matrix", version: "1.0.0" });
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const listed = result(await client.callTool({ name: "approval_list_todos", arguments: { source: "xft", query: "", limit: 100 } }));
const todos = Array.isArray(listed.items) ? listed.items : [];
const selected = distinctBy(todos, (item) => `${item.title ?? ""}|${item.amount?.value ?? ""}`).slice(0, 8);
const flows = [];
for (const todo of selected) {
try {
const summary = result(await client.callTool({ name: "approval_get_detail", arguments: { approvalId: todo.approvalId, view: "summary" } }));
const expense = result(await client.callTool({ name: "approval_get_detail", arguments: { approvalId: todo.approvalId, view: "expense" } }));
const actions = result(await client.callTool({ name: "approval_get_actions", arguments: { approvalId: todo.approvalId } }));
flows.push({
businessNo: todo.businessNo, title: todo.title, applicant: todo.applicant, amount: todo.amount, status: "ok",
summaryFields: Array.isArray(summary.detail?.formFields) ? summary.detail.formFields.length : 0,
expenseItems: Array.isArray(expense.detail?.expenseItems) ? expense.detail.expenseItems.length : 0,
actions: Array.isArray(actions.actions) ? actions.actions.map((action) => ({ action: action.action, allowed: action.allowed })) : [],
rawOmitted: summary.diagnostics?.rawOmitted === true,
});
} catch (error) {
flows.push({ businessNo: todo.businessNo, title: todo.title, status: "failed", error: error instanceof Error ? error.message : String(error) });
}
}
console.log(JSON.stringify({ totalTodos: todos.length, testedFlows: flows.length, sourceStatus: listed.sourceStatus, flows }, null, 2));
} finally {
await client.close();
await server.close();
}
function result(call) {
const value = call?.structuredContent?.result;
if (!value || typeof value !== "object") throw new Error("MCP tool did not return structuredContent.result");
return value;
}
function distinctBy(items, key) {
const seen = new Set();
return items.filter((item) => { const value = key(item); if (seen.has(value)) return false; seen.add(value); return true; });
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { XftOpenApiClient } from "../dist/src/xft-open-api.js";
const [appId, secretFile, enterpriseId, dingTalkUserId, historyBillId] = process.argv.slice(2);
if (!appId || !secretFile || !enterpriseId || !dingTalkUserId) {
console.error("usage: node scripts/xft-open-api-readonly.mjs <appId> <secretFile> <enterpriseId> <dingTalkUserId>");
process.exit(2);
}
const config = {
clientId: "readonly-smoke-test",
clientSecret: "readonly-smoke-test",
apiBaseUrl: "https://api.dingtalk.com",
oapiBaseUrl: "https://oapi.dingtalk.com",
publicBaseUrl: "https://localhost",
port: 3000,
requestTimeoutMs: 15_000,
officialLookbackDays: 120,
officialScanConcurrency: 2,
officialClientOnlyProcessCodes: [],
batchLimit: 20,
xftOpenApi: {
appId,
authoritySecret: (() => {
const lines = readFileSync(secretFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return lines.length >= 2 ? lines[1] : lines[0];
})(),
enterpriseId,
apiBaseUrl: "https://api.cmbchina.com",
encrypted: true,
dingTalkAppCode: "DDingTalk",
},
};
const client = new XftOpenApiClient(config);
if (historyBillId && historyBillId !== "--auth-only") {
try {
const history = await client.request(
`/itrip/xft-api/v1/bills/taskHistory/${encodeURIComponent(historyBillId)}`,
undefined,
"GET",
);
console.log(JSON.stringify({ historyProbe: "ok", responseType: Array.isArray(history) ? "array" : typeof history }));
} catch (error) {
console.log(JSON.stringify({ historyProbe: "failed", message: error instanceof Error ? error.message : String(error) }));
}
}
const user = { userId: dingTalkUserId };
const auth = await client.authStatus(user);
if (historyBillId === "--auth-only") {
console.log(JSON.stringify({ authenticated: auth.authenticated, identity: auth.identity }, null, 2));
process.exit(0);
}
const todos = await client.listMyTodos(user, 200);
const detailCandidates = ["COMMON_REIM", "TRAVEL_REIM"]
.map((eventType) => todos.items.find((item) => item.eventType === eventType))
.filter(Boolean);
const details = await Promise.all(detailCandidates.map((item) => client.getTodoDetail(user, item.billId)));
console.log(JSON.stringify({
authenticated: auth.authenticated,
identity: auth.identity,
todoCount: todos.totalCount,
sample: todos.items.slice(0, 3).map(({ billId, title, applicantName, amount, eventType }) => ({ billId, title, applicantName, amount, eventType })),
detailProbes: details.map((detail) => ({
billId: detail.todo.billId,
eventType: detail.todo.eventType,
formType: Array.isArray(detail.form) ? "array" : typeof detail.form,
formTopLevelKeys: detail.form && typeof detail.form === "object" && !Array.isArray(detail.form)
? Object.keys(detail.form).slice(0, 30)
: [],
})),
diagnostics: todos.diagnostics,
}, null, 2));