refactor: reduce approval context overhead
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-18 03:25:06 +00:00
parent 88f221e811
commit ac7be79288
12 changed files with 136 additions and 43 deletions
+2 -1
View File
@@ -39,6 +39,7 @@ 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));
}
cpSync(resolve("skills/dingtalk-approval"), join(clientStaging, "skill", "dingtalk-approval"), { recursive: true });
const clientPackage = {
name: "dingtalk-approval-client",
version,
@@ -48,7 +49,7 @@ const clientPackage = {
"dingtalk-approval": "dist/src/client-installer.js",
"dingtalk-approval-bridge": "dist/src/bridge.js",
},
files: ["dist/src"],
files: ["dist/src", "skill/dingtalk-approval"],
engines: { node: ">=22.13" },
dependencies: {
"@modelcontextprotocol/sdk": lockedVersion("@modelcontextprotocol/sdk"),
+6 -1
View File
@@ -61,13 +61,18 @@ try {
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}`);
}
if (!existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "SKILL.md"))
|| !existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "references", "create-approval.md"))) {
throw new Error("客户端包缺少完整 Skill 与创建审批 reference");
}
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"))) {
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "approval-contract.js"))
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval", "references", "create-approval.md"))) {
throw new Error("签名客户端无法在空缓存中离线安装");
}
const installer = readFileSync(join(releaseDir, artifactName(manifest.artifacts.installer, "installer")), "utf8");
+1 -19
View File
@@ -30,22 +30,4 @@ Call `approval_list_history` with `scope=handled` for approvals the current user
## Create an approval
Create only XFT approvals and these DingTalk templates: `出差申请单_北极大`, `外出_北极大`, `业务招待申请_北极大`, `差旅费用报销_捷康`, `业务招待费用报销_捷康`, and `业务招待申请_捷康`. Other templates remain unsupported even when they are in the same DingTalk category; never attempt to construct or reuse a hidden template reference.
1. Call `approval_list_submission_templates` and use only its returned `templateId`.
2. Call `approval_get_submission_schema`. Compare every required field in `fields` and `inputContract` with information the user has already supplied. Never guess a missing value.
If `submissionCapability.status` is `client_only`, stop API construction and tell the user to start that template in its native client. All six current DingTalk candidates are client-only: five contain unsupported business suites, while live enterprise-API tests rejected the ordinary table form in `业务招待申请_北极大`. XFT whitelist templates use their formal create APIs and are verified against live status after submission.
For an XFT department or city code, call `approval_get_submission_options` with the same `templateId`; use only a returned `code`. Department results are restricted to the current signed-in user's member record. Never invent or request an internal code when the tool can resolve it.
For DingTalk, do not ask for or fill applicant and top-level department controls marked `serverManaged`; keep `deptId=-1` unless the user selected a department that the gateway can verify belongs to them. The gateway derives the default department from the authenticated user's live directory record.
3. While any required field is missing or ambiguous, ask the user for the missing information and repeat the check after each answer. Prefer one concise question at a time, grouping fields only when they naturally belong together. Do not call `approval_prepare_submission` until all required fields are complete.
4. Explicitly show the remaining optional fields with short explanations and ask whether the user wants to add any. The user may choose to skip all optional fields; record that choice instead of silently omitting them.
5. Call `approval_prepare_submission` with `allowDuplicateInvoices=false`. It automatically checks invoice identifiers and expense signatures against the signed-in user's submitted DingTalk and XFT history for the most recent 30 days. If it reports another missing, invalid, unsupported, or selection-required field, return to the collection loop and ask until validation succeeds. Never work around validation.
6. If preparation returns `duplicate_confirmation_required` or `duplicate_check_unavailable`, no submit-capable `confirmationId` was created. Show the duplicate check's masked invoice evidence, matching source/business number/status, and coverage warning. Ask whether the user still wants to submit. Never infer consent from the original creation request. Only after the user explicitly accepts that exact risk, call prepare again with the same content and `allowDuplicateInvoices=true`. A rejected, revoked, or void prior bill is still a warning; a pending, approved, or unknown prior bill is blocking by default. If another active preparation reserved the same invoice, wait for or finish that preparation rather than overriding it.
7. Show the successful returned preview, including `duplicateCheck`, and obtain explicit final confirmation to create the approval. Do not treat answers to field questions, the choice to skip optional fields, or the duplicate-risk acknowledgement as this final submission confirmation.
8. After final confirmation, call `approval_submit` once with the same `templateId`, `values`, department, selections, `allowDuplicateInvoices`, and returned `confirmationId`. Never modify the content or retry automatically.
If the user explicitly asks to undo a DingTalk or XFT approval just created through this MCP, call `approval_revoke_submission` once with the returned `revokeId`. Never use revoke to reject another person's todo, never accept a raw user ID, employee ID, or bill ID, and never automatically retry `failed` or `outcome_uncertain`. XFT recall is experimental in this release candidate: the current enterprise has returned `XFTSYS9999` for a real pending bill and no successful XFT recall has been observed. State this limitation when relevant and never report a recall as successful unless the tool returns `revoked` after live reconciliation.
For an XFT submission or recall result that needs reconciliation, call `approval_get_submission_status` with the `businessId` returned by `approval_submit`. It is read-only and revalidates that the bill belongs to the signed-in user's initiated bills. Report `statusName` and the compact flow history. `completed` means the workflow finished immediately under the enterprise's approval rules (for example, no other effective approver); do not retry it. A fresh short-lived `revokeId` is returned only while the live bill status is pending approval; it still requires the user's explicit authorization before one recall attempt. Never treat status lookup alone as permission to repeat a write, and never retry `completed`, `upstream_state_mismatch`, or `outcome_uncertain`.
Never ask for or pass an applicant user ID, employee number, owner, or approval status. The gateway derives them from the authenticated user. For XFT custom fields, use the live `fieldNumber` and its declared `valueFormat`; people use roster-number arrays, departments use organization-code arrays, and tables use `sequence`/`columnValues`/`titleCode`. If a required field type is unsupported or XFT reports a missing employee profile or permission, explain the requirement and stop.
Before creating, submitting, revoking, or reconciling a newly initiated approval, read `references/create-approval.md` completely and follow it. Do not use that reference for ordinary todo review, history lookup, or processing someone else's pending approval.
@@ -0,0 +1,21 @@
# Create, submit, and revoke an approval
Create only XFT approvals and these DingTalk templates: `出差申请单_北极大`, `外出_北极大`, `业务招待申请_北极大`, `差旅费用报销_捷康`, `业务招待费用报销_捷康`, and `业务招待申请_捷康`. Other templates remain unsupported even when they are in the same DingTalk category; never attempt to construct or reuse a hidden template reference.
1. Call `approval_list_submission_templates` and use only its returned `templateId`.
2. Call `approval_get_submission_schema`. Compare every required field in `fields` and `inputContract` with information the user has already supplied. Never guess a missing value.
If `submissionCapability.status` is `client_only`, stop API construction and tell the user to start that template in its native client. All six current DingTalk candidates are client-only: five contain unsupported business suites, while live enterprise-API tests rejected the ordinary table form in `业务招待申请_北极大`. XFT whitelist templates use their formal create APIs and are verified against live status after submission.
For an XFT department or city code, call `approval_get_submission_options` with the same `templateId`; use only a returned `code`. Department results are restricted to the current signed-in user's member record. Never invent or request an internal code when the tool can resolve it.
For DingTalk, do not ask for or fill applicant and top-level department controls marked `serverManaged`; keep `deptId=-1` unless the user selected a department that the gateway can verify belongs to them. The gateway derives the default department from the authenticated user's live directory record.
3. While any required field is missing or ambiguous, ask the user for the missing information and repeat the check after each answer. Prefer one concise question at a time, grouping fields only when they naturally belong together. Do not call `approval_prepare_submission` until all required fields are complete.
4. Explicitly show the remaining optional fields with short explanations and ask whether the user wants to add any. The user may choose to skip all optional fields; record that choice instead of silently omitting them.
5. Call `approval_prepare_submission` with `allowDuplicateInvoices=false`. It automatically checks invoice identifiers and expense signatures against the signed-in user's submitted DingTalk and XFT history for the most recent 30 days. If it reports another missing, invalid, unsupported, or selection-required field, return to the collection loop and ask until validation succeeds. Never work around validation.
6. If preparation returns `duplicate_confirmation_required` or `duplicate_check_unavailable`, no submit-capable `confirmationId` was created. Show the duplicate check's masked invoice evidence, matching source/business number/status, and coverage warning. Ask whether the user still wants to submit. Never infer consent from the original creation request. Only after the user explicitly accepts that exact risk, call prepare again with the same content and `allowDuplicateInvoices=true`. A rejected, revoked, or void prior bill is still a warning; a pending, approved, or unknown prior bill is blocking by default. If another active preparation reserved the same invoice, wait for or finish that preparation rather than overriding it.
7. Show the successful returned preview, including `duplicateCheck`, and obtain explicit final confirmation to create the approval. Do not treat answers to field questions, the choice to skip optional fields, or the duplicate-risk acknowledgement as this final submission confirmation.
8. After final confirmation, call `approval_submit` once with the same `templateId`, `values`, department, selections, `allowDuplicateInvoices`, and returned `confirmationId`. Never modify the content or retry automatically.
If the user explicitly asks to undo a DingTalk or XFT approval just created through this MCP, call `approval_revoke_submission` once with the returned `revokeId`. Never use revoke to reject another person's todo, never accept a raw user ID, employee ID, or bill ID, and never automatically retry `failed` or `outcome_uncertain`. XFT recall is experimental in this release candidate: the current enterprise has returned `XFTSYS9999` for a real pending bill and no successful XFT recall has been observed. State this limitation when relevant and never report a recall as successful unless the tool returns `revoked` after live reconciliation.
For an XFT submission or recall result that needs reconciliation, call `approval_get_submission_status` with the `businessId` returned by `approval_submit`. It is read-only and revalidates that the bill belongs to the signed-in user's initiated bills. Report `statusName` and the compact flow history. `completed` means the workflow finished immediately under the enterprise's approval rules (for example, no other effective approver); do not retry it. A fresh short-lived `revokeId` is returned only while the live bill status is pending approval; it still requires the user's explicit authorization before one recall attempt. Never treat status lookup alone as permission to repeat a write, and never retry `completed`, `upstream_state_mismatch`, or `outcome_uncertain`.
Never ask for or pass an applicant user ID, employee number, owner, or approval status. The gateway derives them from the authenticated user. For XFT custom fields, use the live `fieldNumber` and its declared `valueFormat`; people use roster-number arrays, departments use organization-code arrays, and tables use `sequence`/`columnValues`/`titleCode`. If a required field type is unsupported or XFT reports a missing employee profile or permission, explain the requirement and stop.
+30 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { basename, dirname, 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";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -488,11 +489,38 @@ export async function startBridge(endpoint: string, statePath = process.env.DING
}
async function runPostInstallLogin(endpoint: string, statePath: string): Promise<void> {
syncBundledSkillForLegacyUpdate(statePath);
const bridge = new RemoteBridge(endpoint, resolve(statePath));
bridge.ensureOnboardingPending();
process.stdout.write(`${JSON.stringify(await bridge.loginChallenge())}\n`);
}
export function syncBundledSkillForLegacyUpdate(
statePath: string,
bundled = resolve(dirname(fileURLToPath(import.meta.url)), "../../skill/dingtalk-approval"),
): void {
const resolvedState = resolve(statePath);
if (basename(resolvedState) !== "session.json" || basename(dirname(resolvedState)) !== "state") return;
if (!existsSync(join(bundled, "SKILL.md")) || !existsSync(join(bundled, "references", "create-approval.md"))) return;
const target = resolve(dirname(resolvedState), "../skill/dingtalk-approval");
const parent = dirname(target);
mkdirSync(parent, { recursive: true, mode: 0o700 });
const staging = join(parent, `.dingtalk-approval.staging-${process.pid}`);
const backup = join(parent, `.dingtalk-approval.backup-${process.pid}`);
rmSync(staging, { recursive: true, force: true });
rmSync(backup, { recursive: true, force: true });
try {
cpSync(bundled, staging, { recursive: true });
if (existsSync(target)) renameSync(target, backup);
renameSync(staging, target);
rmSync(backup, { recursive: true, force: true });
} catch (error) {
if (!existsSync(target) && existsSync(backup)) renameSync(backup, target);
rmSync(staging, { recursive: true, force: true });
throw error;
}
}
if (isDirectEntrypoint(import.meta.url, process.argv[1])) {
const args = process.argv.slice(2);
const endpoint = parseEndpoint(args);
+24 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { createHash, createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import {
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync,
readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
@@ -301,8 +301,7 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
}
renameSync(staging, versionDir);
}
mkdirPrivate(paths.skillDir);
writeAtomic(paths.skillFile, skillBody, 0o600);
installBundledSkill(paths, join(versionDir, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval"), skillBody);
writeAtomic(paths.publicKeyFile, publicArtifact, 0o600);
writeLaunchers(paths, context.platform);
const state: ClientInstallState = {
@@ -590,6 +589,28 @@ function mcpConfig(paths: ClientPaths): Record<string, unknown> {
return { mcpServers: { "dingtalk-approval": { type: "stdio", command: paths.bridgeCommand, args: [] } } };
}
function installBundledSkill(paths: ClientPaths, bundledSkillDir: string, skillBody: Buffer): void {
const reference = join(bundledSkillDir, "references", "create-approval.md");
if (!existsSync(join(bundledSkillDir, "SKILL.md")) || !existsSync(reference)) {
throw new Error("客户端包缺少完整 Skill 资源");
}
const parent = dirname(paths.skillDir);
mkdirPrivate(parent);
const staging = join(parent, `.dingtalk-approval.staging-${process.pid}-${randomUUID()}`);
const backup = join(parent, `.dingtalk-approval.backup-${process.pid}-${randomUUID()}`);
try {
cpSync(bundledSkillDir, staging, { recursive: true });
writeAtomic(join(staging, "SKILL.md"), skillBody, 0o600);
if (existsSync(paths.skillDir)) renameSync(paths.skillDir, backup);
renameSync(staging, paths.skillDir);
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
} catch (error) {
if (!existsSync(paths.skillDir) && existsSync(backup)) renameSync(backup, paths.skillDir);
if (existsSync(staging)) rmSync(staging, { recursive: true, force: true });
throw error;
}
}
function writeLaunchers(paths: ClientPaths, platform: NodeJS.Platform): void {
mkdirPrivate(paths.binDir);
const launcher = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nimport { dirname, join, resolve } from "node:path";\nimport { fileURLToPath } from "node:url";\nimport { spawn } from "node:child_process";\nconst mode = process.argv[2];\nconst root = resolve(dirname(fileURLToPath(import.meta.url)), "..");\nconst state = JSON.parse(readFileSync(join(root, "install-state.json"), "utf8"));\nconst runtime = resolve(root, state.runtimeDir);\nconst target = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", mode === "bridge" ? "bridge.js" : "client-installer.js");\nconst args = mode === "bridge" ? ["--url", state.gatewayUrl, ...process.argv.slice(3)] : [...process.argv.slice(3), "--app-root", root];\nconst env = mode === "bridge" ? { ...process.env, DINGTALK_APPROVAL_BRIDGE_STATE: join(root, "state", "session.json") } : process.env;\nconst child = spawn(process.execPath, [target, ...args], { stdio: "inherit", env });\nchild.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\nchild.on("error", (error) => { console.error(error.message); process.exitCode = 1; });\n`;
+1 -1
View File
@@ -65,7 +65,7 @@ export function createApprovalMcpServer(
server.registerTool("approval_list_todos", {
title: "列出我的审批待办",
description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额、UTC提交时间及Asia/Shanghai本地时间。",
description: "审批操作的入口。通过同一登录身份实时列出当前用户在钉钉OA和薪福通中的待办,并取得后续接口必须使用的approvalId;列表尽可能直接包含金额和带时区的提交时间。",
inputSchema: {
source: z.enum(["all", "dingtalk", "xft"]).default("all"),
query: z.string().max(200).default(""),
+6 -7
View File
@@ -1149,21 +1149,18 @@ export class UnifiedApprovalService {
return {
approvalId: this.signPortableApprovalReference(ref),
source: "dingtalk",
sourceType: item.source,
businessNo: item.processInstanceId,
title: item.title ?? item.processName ?? "钉钉审批",
applicant: {
...(item.applicantName ? { name: item.applicantName } : {}),
...(item.applicantDepartment ? { department: item.applicantDepartment } : {}),
},
...(item.submittedAt ? { submittedAt: item.submittedAt } : {}),
...(item.submittedAt ? localTimeFields(item.submittedAt) : {}),
...(item.submittedAt ? { submittedAt: localTime(item.submittedAt) } : {}),
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
...(item.summary ? { summary: item.summary } : {}),
availableActions: [
...(clientOnly ? [] : ["approve", "reject"]),
],
actionsHint: "approve/reject 可直接批量提交,网关会在写入前逐张实时复核;只有退回需要调用 approval_get_actions 获取 targetId。",
...(clientOnly ? {
manualAction: {
required: true,
@@ -1188,15 +1185,13 @@ export class UnifiedApprovalService {
return {
approvalId: this.signPortableApprovalReference(ref),
source: "xft",
sourceType: "smart_expense",
businessNo: item.billId,
title: item.title ?? "薪福通审批",
applicant: {
...(item.applicantName ? { name: item.applicantName } : {}),
...(item.applicantDepartment ? { department: item.applicantDepartment } : {}),
},
...(item.applyTime ? { submittedAt: item.applyTime } : {}),
...(item.applyTime ? localTimeFields(item.applyTime) : {}),
...(item.applyTime ? { submittedAt: localTime(item.applyTime) } : {}),
...(item.amount !== undefined ? { amount: { value: item.amount, currency: item.currency ?? "CNY" } } : {}),
...(item.reason ? { summary: item.reason } : {}),
availableActions: [
@@ -1848,6 +1843,10 @@ function localTimeFields(value: string): Record<string, string> {
return { submittedAtLocal: local, submittedTimeZone: "Asia/Shanghai" };
}
function localTime(value: string): string {
return localTimeFields(value).submittedAtLocal ?? value;
}
function dingTalkSubmissionTemplateAllowed(template: { dirName?: string; name?: string }): boolean {
if (!template.dirName || !template.name) return false;
const names = DINGTALK_SUBMISSION_TEMPLATES[template.dirName as keyof typeof DINGTALK_SUBMISSION_TEMPLATES];
+23 -2
View File
@@ -1,11 +1,32 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, createBridgeMcpServer } from "../src/bridge.js";
import {
APPROVAL_ONBOARDING_EXAMPLES, RemoteBridge, bridgeAuthToolResult, createBridgeMcpServer,
syncBundledSkillForLegacyUpdate,
} from "../src/bridge.js";
test("legacy updater post-install syncs the complete bundled Skill", () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-skill-sync-"));
const bundled = join(root, "package", "skill", "dingtalk-approval");
const statePath = join(root, "client", "state", "session.json");
const target = join(root, "client", "skill", "dingtalk-approval");
mkdirSync(join(bundled, "references"), { recursive: true });
mkdirSync(target, { recursive: true });
writeFileSync(join(bundled, "SKILL.md"), "new skill");
writeFileSync(join(bundled, "references", "create-approval.md"), "create rules");
writeFileSync(join(target, "SKILL.md"), "old skill");
syncBundledSkillForLegacyUpdate(statePath, bundled);
assert.equal(readFileSync(join(target, "SKILL.md"), "utf8"), "new skill");
assert.equal(readFileSync(join(target, "references", "create-approval.md"), "utf8"), "create rules");
assert.deepEqual(readdirSync(join(root, "client", "skill")), ["dingtalk-approval"]);
});
test("stdio bridge creates a messenger-friendly device login challenge and protects local state", async () => {
const calls: string[] = [];
+5
View File
@@ -130,6 +130,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
const paths = clientPaths({ DINGTALK_APPROVAL_CLIENT_HOME: appRoot }, process.platform, join(root, "home"));
assert.equal(existsSync(paths.bridgeCommand), true);
assert.equal(existsSync(paths.skillFile), true);
assert.equal(existsSync(join(paths.skillDir, "references", "create-approval.md")), true);
const state = JSON.parse(readFileSync(paths.stateFile, "utf8"));
assert.equal(state.version, "1.1.0-rc.5");
assert.equal(state.host, undefined);
@@ -340,6 +341,10 @@ const target = join(prefix, "node_modules", "dingtalk-approval-client", "dist",
mkdirSync(target, { recursive: true });
writeFileSync(join(target, "bridge.js"), ${JSON.stringify(fakeBridge)});
writeFileSync(join(target, "client-installer.js"), "#!/usr/bin/env node\\n");
const skill = join(prefix, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval");
mkdirSync(join(skill, "references"), { recursive: true });
writeFileSync(join(skill, "SKILL.md"), "---\\nname: dingtalk-approval\\ndescription: Bundled test\\n---\\n");
writeFileSync(join(skill, "references", "create-approval.md"), "# Create approval\\n");
`);
if (process.platform === "win32") {
writeFileSync(join(bin, "npm.cmd"), "@echo off\r\nexit /b 99\r\n");
+5 -1
View File
@@ -20,7 +20,10 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.doesNotMatch(installer, /DWS_CLI_PATH|dingtalk-workspace-cli|APP_MODE=local/);
const skill = readFileSync("skills/dingtalk-approval/SKILL.md", "utf8");
assert.match(skill, /XFT recall is experimental.*XFTSYS9999/s);
const createApproval = readFileSync("skills/dingtalk-approval/references/create-approval.md", "utf8");
assert.match(skill, /read `references\/create-approval\.md` completely/);
assert.doesNotMatch(skill, /XFT recall is experimental/);
assert.match(createApproval, /XFT recall is experimental.*XFTSYS9999/s);
assert.match(skill, /does not send or push.*current user-facing reply/s);
assert.match(skill, /handle the installer's `postInstall` result immediately.*waiting for the user's first approval request/s);
assert.match(skill, /onboarding\.firstRun=true.*Do not execute an example automatically/s);
@@ -29,6 +32,7 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(builder, /schemaVersion: 3,\s+version,/s);
assert.match(builder, /signData\(null, manifestBytes/);
assert.match(builder, /dingtalk-approval-client/);
assert.match(builder, /cpSync\(resolve\("skills\/dingtalk-approval"\).*recursive: true/s);
assert.doesNotMatch(builder, /codexPlugin|codex-plugin|\.codex-plugin|openai\.yaml/);
assert.doesNotMatch(builder, /dingtalk-workspace-cli|DWS_URL|DWS_SHA256/);
});
+12 -6
View File
@@ -140,8 +140,11 @@ test("unified approval API returns opaque user-bound IDs and routes DingTalk act
const listed = await service.listTodos(user, "all", 50);
const approvalId = String(listed.items[0]!.approvalId);
assert.deepEqual(listed.items[0]!.amount, { value: 100, currency: "CNY" });
assert.equal(listed.items[0]!.submittedAtLocal, "2026-08-11T09:00:00.000+08:00");
assert.equal(listed.items[0]!.submittedTimeZone, "Asia/Shanghai");
assert.equal(listed.items[0]!.submittedAt, "2026-08-11T09:00:00.000+08:00");
assert.equal("submittedAtLocal" in listed.items[0]!, false);
assert.equal("submittedTimeZone" in listed.items[0]!, false);
assert.equal("sourceType" in listed.items[0]!, false);
assert.equal("actionsHint" in listed.items[0]!, false);
assert.match(approvalId, /^v4\./);
assert.ok(approvalId.length <= 128, `common DingTalk approvalId should be compact, got ${approvalId.length} characters`);
assert.doesNotMatch(approvalId, /instance-secret|task-secret/);
@@ -247,12 +250,15 @@ test("combined todo lists are deterministic regardless of source response timing
} as unknown as ApprovalService;
const xft = {
async listMyTodos() {
return { items: [{ source: "xft", billId: "2026081300000001", title: "XFT" }] };
return { items: [{ source: "xft", billId: "2026081300000001", title: "XFT", applyTime: "2026-08-13T01:30:00.000Z" }] };
},
};
const service = new UnifiedApprovalService(config(), ding, xft as never);
const listed = await service.listTodos({ userId: "user-1" }, "all", 2);
assert.deepEqual(listed.items.map((item) => item.source), ["dingtalk", "xft"]);
assert.equal(listed.items[1]!.submittedAt, "2026-08-13T09:30:00.000+08:00");
assert.equal("submittedAtLocal" in listed.items[1]!, false);
assert.equal("submittedTimeZone" in listed.items[1]!, false);
});
test("v4 references remain backward compatible with unexpired v2 and v3 approvals", async () => {
@@ -309,8 +315,8 @@ test("v4 approval references stay compact for representative DingTalk, Yida, and
};
const service = new UnifiedApprovalService(config(), ding, xft as never);
const listed = await service.listTodos({ userId: "user-1" }, "all", 10);
const official = String(listed.items.find((item) => item.sourceType === "official_oa")?.approvalId);
const yida = String(listed.items.find((item) => item.sourceType === "yida")?.approvalId);
const official = String(listed.items.find((item) => item.businessNo === "5VXimwfMSBSWhjzttVCyWA10041785394274")?.approvalId);
const yida = String(listed.items.find((item) => item.businessNo === "FINST-7b7ffc7800d942b7b8f6e70e16efab34")?.approvalId);
const xftId = String(listed.items.find((item) => item.source === "xft")?.approvalId);
assert.match(official, /^v4\./);
assert.ok(official.length <= 128, `DingTalk reference is ${official.length} characters`);
@@ -589,7 +595,7 @@ test("unified OA return signs live targets, revalidates them, and confirms task
const user = { userId: "user-1", name: "测试用户乙" };
const listed = await service.listTodos(user, "dingtalk", 10);
assert.deepEqual(listed.items[0]!.availableActions, ["approve", "reject"]);
assert.match(String(listed.items[0]!.actionsHint), /approval_get_actions/);
assert.equal("actionsHint" in listed.items[0]!, false);
const approvalId = String(listed.items[0]!.approvalId);
const actions = await service.getActions(user, approvalId);
const returnAction = actions.actions[2]! as {