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