feat: add standalone login renewal
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 08:04:37 +00:00
parent ac7be79288
commit b3fa2315ba
11 changed files with 118 additions and 19 deletions
+5 -2
View File
@@ -75,15 +75,18 @@ Agent 不传 `userId`、员工号或单据所有者。网关从 OAuth Token 确
https://approval.justconn.top/downloads/dingmcp/install.md
```
用户把这个链接交给 Agent 后,Agent 只需校验并运行一个原生 Node 安装器。安装器使用固定 Ed25519 公钥验证清单和发布物,把轻量客户端安装到稳定的用户数据目录,并统一返回 `bridgePath`、`skillPath` 和标准 `mcpConfig`;不再区分 Agent 品牌,不全局安装 npm 包,也不会猜测或直接修改 Agent 的私有配置。安装器会立即在受保护的本地会话中检查登录并返回机器可读 `postInstall`:未登录时直接带回当前授权链接,网关暂时不可用也不会使安装回滚。Agent 随后按自身官方方式安装 Skill、注册并信任本地 stdio MCP;不能热加载连接器时再新建会话或重启。用户端不安装 DWS,不需要企业凭证。
用户把这个链接交给 Agent 后,Agent 只需校验并运行一个原生 Node 安装器。安装器使用固定 Ed25519 公钥验证清单和发布物,把轻量客户端安装到稳定的用户数据目录,并统一返回 `bridgePath`、`skillPath`、`loginCommand` 和标准 `mcpConfig`;不再区分 Agent 品牌,不全局安装 npm 包,也不会猜测或直接修改 Agent 的私有配置。安装器会立即在受保护的本地会话中检查登录并返回机器可读 `postInstall`:未登录时直接带回当前授权链接,网关暂时不可用也不会使安装回滚。Agent 随后按自身官方方式安装 Skill、注册并信任本地 stdio MCP;不能热加载连接器时再新建会话或重启。用户端不安装 DWS,不需要企业凭证。
Agent 必须在安装后立即展示 `postInstall.authChallenge.directUrl`,不得等到第一次审批查询,也不得声称链接已另行推送。用户授权后,Agent 通过 `approval_login` 确认登录并用 `source=all` 做一次双来源只读验证;首次成功认证响应还会在普通 `content.text` 和 `structuredContent.onboarding` 中返回一次性的简短使用示例,展示后不会重复,也不会自动执行任何写操作。
授权链接约十分钟有效。过期后不需要更新或重装:MCP 已加载时再次调用 `approval_login`;尚未加载时执行安装结果中的 `loginCommand`(等价于稳定客户端的 `login --json`)。两条入口复用同一本地 OAuth 会话,都会返回当前有效链接或自动生成新链接。
首次授权会先显示网关自己的 OAuth 确认页,列出请求客户端、返回域名、权限范围或设备授权码;用户确认后才跳转钉钉。客户端名称由客户端自行声明,因此应同时核对域名或授权码。短暂的网关 5xx/网络故障不会清除本地 refresh token,也不会强迫用户重新登录。
客户端后续可使用稳定 CLI:
```text
dingtalk-approval login --json
dingtalk-approval print-path bridge
dingtalk-approval print-config --format mcp-json
dingtalk-approval doctor --json
@@ -91,7 +94,7 @@ dingtalk-approval update --json
dingtalk-approval uninstall --yes --json
```
实际 `dingtalk-approval` 路径由首次安装结果的 `cliPath` 给出。`update` 会复用已固定的清单地址与公钥,并保留 OAuth 登录会话;更新完成后应重新连接该 MCP,Agent 不能单独重启连接器时再新建会话或重启。卸载默认删除客户端运行时、Skill、安装记录和 OAuth 会话;Agent 中名为 `dingtalk-approval` 的连接器通过其官方入口删除。
实际 `dingtalk-approval` 路径由首次安装结果的 `cliPath` 给出。`login` 只使用现有运行时与登录会话;`update` 会复用已固定的清单地址与公钥,并保留 OAuth 登录会话。更新完成后应重新连接该 MCP,Agent 不能单独重启连接器时再新建会话或重启。卸载默认删除客户端运行时、Skill、安装记录和 OAuth 会话;Agent 中名为 `dingtalk-approval` 的连接器通过其官方入口删除。
## 数据与安全
+3 -1
View File
@@ -26,11 +26,13 @@
node <已校验的安装器路径> install --manifest-url {{MANIFEST_URL}} --public-key <已校验的公钥路径> --json
```
3. 安装器会立即尝试准备登录。读取返回的 `postInstall`,不得等到用户第一次查询审批:若状态为 `authorization_required`,必须把 `authChallenge.directUrl` 原样写入当前回复;若为 `authenticated`,直接继续验证;若为 `unavailable`,保留安装并在 MCP 加载后调用 `approval_login` 重试。不得声称链接已通过其他渠道推送。
3. 安装器会立即尝试准备登录。读取返回的 `postInstall`,不得等到用户第一次查询审批:若状态为 `authorization_required`,必须把 `authChallenge.directUrl` 原样写入当前回复;若为 `authenticated`,直接继续验证;若为 `unavailable`,保留安装并在 MCP 加载后调用 `approval_login` 重试。不得声称链接已通过其他渠道推送。保存返回的 `loginCommand` 供本次安装流程恢复使用。
4. 按当前 Agent 的官方方式安装返回的 `skillPath`,并使用 `mcpConfig` 只新增或替换名为 `dingtalk-approval` 的本地 stdio MCP。安装器本身不会猜测或修改 Agent 的私有配置。
5. 如果当前 Agent 要求信任本地连接器,请完成其官方信任流程。连接器不能热加载时,新建会话或完整重启 Agent,并恢复本节尚未完成的登录步骤。
6. 用户完成网页授权后调用 `approval_login`。工具不会通过其他消息通道自动推送链接,必须把 `content.text` 中的完整登录 URL 原样写入当前回复。然后按 `postInstall.verifyTool` 只读调用 `approval_list_todos`,确认钉钉 OA 与薪福通均可读取。成功响应包含 `onboarding.firstRun=true` 时,把其中的简短示例和安全提示展示一次,但不要自动执行示例。安装阶段不得处理审批,也不得在登录和双来源验证完成前声称“已可使用”。
登录链接过期时绝不能重新安装:如果 MCP 已加载,重新调用一次 `approval_login`;如果连接器尚未信任、加载或重启完成,执行安装结果中的 `loginCommand`,读取新的 `authChallenge.directUrl` 并原样展示。该命令只检查登录或生成新链接,不下载发布物、不修改 Agent 配置。
更新时重复执行同一安装入口;安装器会保留 OAuth 登录会话,稳定 Bridge 路径不会随 Node/NVM 版本改变。更新成功后重新连接名为 `dingtalk-approval` 的 MCP;Agent 不能单独重启连接器时,新建会话或重启 Agent。卸载时先从 Agent 删除同名条目,再执行安装结果中的 `cliPath uninstall --yes --json`。卸载默认清除运行时、Skill、安装记录和 OAuth 会话。
授权页面会先展示请求客户端、回调域名、权限范围或设备授权码。应让用户本人核对并确认,不得替用户自动提交 consent。网关短暂不可用时保留现有登录状态并稍后重试,不要删除会话文件或主动要求用户重新授权。
+1 -1
View File
@@ -40,7 +40,7 @@ Agent 面向统一审批语义,不直接理解钉钉 `processInstanceId/taskId
## 一次登录
安装器在安装完成后立即调用 Bridge 的登录准备模式,不等待首次业务请求。未登录时,`postInstall.authChallenge.directUrl` 直接携带一个钉钉授权链接;用户授权后,Agent 调用 `approval_login` 确认状态。授权成功后,网关从钉钉身份得到当前企业 `userId`:
安装器在安装完成后立即调用 Bridge 的登录准备模式,不等待首次业务请求。未登录时,`postInstall.authChallenge.directUrl` 直接携带一个钉钉授权链接;用户授权后,Agent 调用 `approval_login` 确认状态。安装结果同时返回轻量 `loginCommand`:连接器尚未加载且链接过期时,它直接复用已安装 Bridge 和本地会话生成新链接,不下载发布物、不改 Agent 配置;连接器已加载时则再次调用 `approval_login`。授权成功后,网关从钉钉身份得到当前企业 `userId`:
- 钉钉 OA 直接按该 `userId` 查询本人审批任务;
- 薪福通先把该 `userId` 映射为企业成员,再从本人企业成员档案读取花名册员工号。
+2 -2
View File
@@ -120,9 +120,9 @@ Agent 只需校验安装器和公钥 SHA-256,再调用原生安装器。安装
安装器不会识别 Agent 品牌或直接修改其配置。所有 Agent 都使用返回的 `skillPath` 和标准 `mcpConfig`,按自身官方方式安装 Skill、注册并信任名为 `dingtalk-approval` 的本地 stdio MCP;不能热加载连接器时再新建会话或重启。
安装器在返回前通过已安装 Bridge 主动准备登录,并在 JSON 的 `postInstall` 中返回 `authenticated`、`authorization_required` 或 `unavailable`。Agent 必须立即显示其中的短期 `directUrl`,不得等待首次审批请求;网页授权后调用 `approval_login`,再按 `verifyTool` 用 `source=all` 做双来源只读验证。首次认证成功会返回一次性 onboarding 示例,普通文本宿主也可见。登录准备失败不回滚安装,且整个流程不提交 consent、不读取审批、不执行写操作。
安装器在返回前通过已安装 Bridge 主动准备登录,并在 JSON 的 `postInstall` 中返回 `authenticated`、`authorization_required` 或 `unavailable`,同时返回独立的 `loginCommand`。Agent 必须立即显示其中的短期 `directUrl`,不得等待首次审批请求;网页授权后调用 `approval_login`,再按 `verifyTool` 用 `source=all` 做双来源只读验证。链接过期时,MCP 已加载就重新调用 `approval_login`,尚未加载就执行 `loginCommand`,禁止为刷新链接重跑安装。首次认证成功会返回一次性 onboarding 示例,普通文本宿主也可见。登录准备失败不回滚安装,且整个流程不提交 consent、不读取审批、不执行写操作。
安装后可用稳定 CLI 自检或提取配置:`print-path bridge` 默认只输出路径,`print-config --format mcp-json` 输出可直接消费的标准 MCP JSON,`doctor --json` 同时检查 Node、npm、运行时、Skill、公钥和网关配置。后续更新直接执行安装结果中 `cliPath` 对应的 `update --json`,它会复用已固定的签名清单地址和本地公钥,并保留 OAuth 会话。更新完成后应重新连接该 MCP;Agent 不能单独重启连接器时,再新建会话或重启。
安装后可用稳定 CLI:`login --json` 只检查登录或生成新授权链接;`print-path bridge` 默认只输出路径;`print-config --format mcp-json` 输出可直接消费的标准 MCP JSON;`doctor --json` 同时检查 Node、npm、运行时、Skill、公钥和网关配置。后续更新直接执行安装结果中 `cliPath` 对应的 `update --json`,它会复用已固定的签名清单地址和本地公钥,并保留 OAuth 会话。更新完成后应重新连接该 MCP;Agent 不能单独重启连接器时,再新建会话或重启。
客户端卸载使用安装结果返回的 `cliPath uninstall --yes --json`,默认删除 OAuth 会话,并安全清理由旧版手工删除留下的四个 npm 入口;只会删除名称精确匹配且目标属于 `dingtalk-approval-mcp` 的链接或 Windows shim。
+1
View File
@@ -7,6 +7,7 @@
- `release:build`、`test-bundle:build` 和 `release:verify` 成功,`SHA256SUMS` 校验通过,发行物不包含 `.secrets`、`.runtime`、数据库、私钥或机器私有路径。
- Nginx bootstrap 只开放 ACME challenge;正式站点启用 HSTS,mutable 发行文件返回 `no-cache`,客户端 tgz 才返回 immutable。连续两次部署时公开目录始终对应一个完整签名清单,旧版本保留数符合策略。
- 从新生成的公开 `install.md` 在干净的用户级目录安装,不使用 `sudo`;安装结果主动返回 `postInstall`,可发现全部 13 个 MCP 工具。
- 验证安装结果返回 `loginCommand`:未加载 MCP 时执行 `login --json` 能复用现有运行时生成或检查登录链接,不下载发布物;链接过期后生成新链接,已登录时不再返回挑战,未安装时明确失败。
- 首次安装不等待业务查询即产生一条可传递链接;挑战阶段 onboarding 仍为 pending,授权后的首次成功响应在普通文本中展示一次示例,此后不重复。
- 授权后钉钉 OA 与薪福通均为 `ok`,网关重启后登录状态仍可恢复;登录准备暂时失败不会回滚安装或删除原 OAuth 会话。
- 只读验证待办、本人已处理历史、本人发起历史、历史默认最近 30 天及显式时间窗口、搜索、详情、费用结构化字段、动作、模板 Schema、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。
+2 -2
View File
@@ -9,11 +9,11 @@ Use only the `dingtalk-approval` MCP tools. DingTalk OA and XFT use the same gat
## Login and first run
After installation or update, handle the installer's `postInstall` result immediately instead of waiting for the user's first approval request. If it contains `authChallenge.directUrl`, copy that complete clickable URL into the current user-facing reply. After the user authorizes, call `approval_login`, then make one read-only `approval_list_todos` call with `source=all` to verify both DingTalk OA and XFT. If login preparation was temporarily unavailable, keep the installation and call `approval_login` after the MCP is loaded.
After installation or update, handle the installer's `postInstall` result immediately instead of waiting for the user's first approval request. If it contains `authChallenge.directUrl`, copy that complete clickable URL into the current user-facing reply. After the user authorizes, call `approval_login`, then make one read-only `approval_list_todos` call with `source=all` to verify both DingTalk OA and XFT. If login preparation was temporarily unavailable, keep the installation and call `approval_login` after the MCP is loaded. If the installation link expires before the MCP can load, execute the returned `loginCommand` and show its new `authChallenge.directUrl`; never rerun installation just to renew a link.
When a successful response contains `onboarding.firstRun=true`, show its short examples and write-safety note to the user once. Do not execute an example automatically. The same onboarding may be returned directly in `postInstall` when an existing login is reused.
On first use or expired authorization, call `approval_login`. The tool does not send or push the login link through another channel. Copy the clickable URL from `content.text` verbatim into the current user-facing reply; when available, `authChallenge.directUrl` and the `resource_link` contain the same URL. Never merely claim that a link was generated or pushed, never read bridge state files to recover it, and never render QR images as ASCII. One successful login covers both approval sources.
On first use, expired authorization, or an expired login link, call `approval_login` again. It checks the existing session and creates a fresh link when necessary; never reinstall for this purpose. The tool does not send or push the login link through another channel. Copy the clickable URL from `content.text` verbatim into the current user-facing reply; when available, `authChallenge.directUrl` and the `resource_link` contain the same URL. Never merely claim that a link was generated or pushed, never read bridge state files to recover it, and never render QR images as ASCII. One successful login covers both approval sources.
## Review and process
+6 -5
View File
@@ -267,6 +267,7 @@ export class RemoteBridge {
`备用原始链接:${directUrl}`,
...(challenge.user_code ? [`授权码:${challenge.user_code}`] : []),
`链接有效期至:${expiresAt}`,
"链接过期后让 Agent 重新获取即可,无需重新安装客户端。",
"Agent 必须在当前回复正文中原样显示以上链接,不得只说“已生成”或“已推送”。",
"登录完成后重新调用原工具。",
].join("\n");
@@ -388,10 +389,10 @@ function parseEndpoint(argv: string[]): string {
export function createBridgeMcpServer(bridge: RemoteBridge): McpServer {
const server = new McpServer({ name: "dingtalk-approval-stdio-bridge", version: "1.1.0-rc.8" }, {
instructions: "首次使用或登录过期时,工具不会通过其他消息通道自动发送链接。必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。",
instructions: "首次使用或登录链接过期时调用approval_login;每次调用会检查登录并在需要时生成有效的新链接,绝不因链接过期而重新安装。工具不会通过其他消息通道自动发送链接,必须把content.text中的登录URL原样写入当前面向用户的回复,不得只声称已生成或已推送。审批引用、调用容量和内部分批不得向用户解释;查看时只回答业务信息,用户明确授权后在授权范围内自动处理。写前校验失败时零写入,任何不确定结果都不得自动重试。",
});
server.registerTool("approval_login", {
title: "登录审批服务", description: "创建或检查唯一的钉钉登录链接;同一身份同时用于钉钉OA和薪福通。", inputSchema: {},
title: "登录审批服务", description: "检查登录;未登录或旧链接已过期时生成新的钉钉授权链接。同一身份同时用于钉钉OA和薪福通,无需重新安装。", inputSchema: {},
annotations: { readOnlyHint: true, destructiveHint: false },
}, async () => {
const result = await bridge.loginChallenge();
@@ -488,7 +489,7 @@ export async function startBridge(endpoint: string, statePath = process.env.DING
await server.connect(new StdioServerTransport());
}
async function runPostInstallLogin(endpoint: string, statePath: string): Promise<void> {
async function runLogin(endpoint: string, statePath: string): Promise<void> {
syncBundledSkillForLegacyUpdate(statePath);
const bridge = new RemoteBridge(endpoint, resolve(statePath));
bridge.ensureOnboardingPending();
@@ -526,8 +527,8 @@ if (isDirectEntrypoint(import.meta.url, process.argv[1])) {
const endpoint = parseEndpoint(args);
const statePath = process.env.DINGTALK_APPROVAL_BRIDGE_STATE
?? join(homedir(), ".config", "dingtalk-approval-bridge", "session.json");
const task = args.includes("--post-install-login")
? runPostInstallLogin(endpoint, statePath)
const task = args.includes("--login") || args.includes("--post-install-login")
? runLogin(endpoint, statePath)
: startBridge(endpoint, statePath);
task.catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
}
+26 -5
View File
@@ -179,6 +179,7 @@ export async function runClientInstaller(
assertSafeClientRoot(paths.root, context.home);
let result: Record<string, unknown>;
if (args.command === "install" || args.command === "update") result = await installClient(paths, args, context);
else if (args.command === "login") result = loginClient(paths, context);
else if (args.command === "uninstall") result = uninstallClient(paths, args, context);
else if (args.command === "print-path") result = printPath(paths, args);
else if (args.command === "print-config") result = printConfig(paths, args);
@@ -232,6 +233,7 @@ function parseClientArgs(argv: string[]): ParsedArgs {
}
const allowed = new Set(command === "install" || command === "update"
? ["--manifest-url", "--public-key", "--app-root", "--json", "--allow-downgrade"]
: command === "login" ? ["--app-root", "--json"]
: command === "uninstall" ? ["--app-root", "--json", "--yes"]
: command === "print-path" ? ["--app-root", "--json"]
: command === "print-config" ? ["--app-root", "--json", "--format"]
@@ -319,7 +321,7 @@ async function installClient(paths: ClientPaths, args: ParsedArgs, context: Comm
const legacyCleanupFailed: string[] = [];
const legacyRemoved = cleanupLegacyInstallations(context.env, context.platform, context.home, legacyCleanupFailed);
cleanupOldVersions(paths, versionDir);
const postInstall = attemptPostInstallLogin(clientEntry, manifest.gatewayUrl, paths, context);
const postInstall = attemptLogin(clientEntry, manifest.gatewayUrl, paths, context);
return installResult(paths, state, legacyRemoved, legacyCleanupFailed, postInstall);
} finally {
rmSync(temporary, { recursive: true, force: true });
@@ -483,6 +485,7 @@ function installResult(
"立即处理 postInstall:若有 authChallenge.directUrl,必须在当前回复中原样展示,不得声称已另行推送,也不得等待用户首次查询审批。",
"用户授权后调用 approval_login;登录成功后按 verifyTool 做一次双来源只读验证,并展示一次性使用示例。",
"宿主若不能热加载 MCP,请新建会话或完整重启;恢复上述登录流程后再宣告安装完成,不得执行任何审批写操作。",
"登录链接过期时绝不重新安装:MCP 已加载就再次调用 approval_login;尚未加载就执行 loginCommand。",
];
return {
ok: true,
@@ -491,17 +494,35 @@ function installResult(
root: paths.root,
bridgePath: paths.bridgeCommand,
cliPath: paths.cliCommand,
loginCommand: loginCommand(paths),
skillPath: paths.skillDir,
mcpConfig: mcpConfig(paths),
legacyEntriesRemoved: legacyRemoved,
legacyCleanupFailed,
configurationModified: false,
postInstall,
postInstall: { ...postInstall, loginCommand: loginCommand(paths) },
nextSteps,
};
}
function attemptPostInstallLogin(
function loginClient(paths: ClientPaths, context: Pick<CommandContext, "env">): Record<string, unknown> {
const state = assertInstalled(paths);
const runtime = resolveInside(paths.root, state.runtimeDir);
const clientEntry = join(runtime, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js");
if (!existsSync(clientEntry)) throw new Error("客户端运行时缺少 Bridge 入口,请运行 update 修复安装");
return {
ok: true,
operation: "login",
...attemptLogin(clientEntry, state.gatewayUrl, paths, context),
loginCommand: loginCommand(paths),
};
}
function loginCommand(paths: ClientPaths): { command: string; args: ["login", "--json"] } {
return { command: paths.cliCommand, args: ["login", "--json"] };
}
function attemptLogin(
clientEntry: string,
gatewayUrl: string,
paths: ClientPaths,
@@ -514,7 +535,7 @@ function attemptPostInstallLogin(
verifyTool: { name: "approval_list_todos" as const, arguments: { source: "all" as const, query: "" as const, limit: 1 as const } },
completionCriteria: "登录确认并完成钉钉 OA 与薪福通双来源只读验证",
};
const result = spawnSync(process.execPath, [clientEntry, "--url", gatewayUrl, "--post-install-login"], {
const result = spawnSync(process.execPath, [clientEntry, "--url", gatewayUrl, "--login"], {
encoding: "utf8",
timeout: 30_000,
maxBuffer: 256 * 1024,
@@ -815,7 +836,7 @@ function writeResult(value: Record<string, unknown>, args: ParsedArgs, context:
}
function usage(): string {
return "用法:dingtalk-approval install|update|uninstall|print-path|print-config|doctor [--manifest-url URL] [--format mcp-json] [--json]";
return "用法:dingtalk-approval install|update|login|uninstall|print-path|print-config|doctor [--manifest-url URL] [--format mcp-json] [--json]";
}
if (isClientDirectEntrypoint(import.meta.url, process.argv[1])) {
+34
View File
@@ -182,6 +182,39 @@ test("stdio bridge discards a connected client after its access token expires",
assert.equal(result.content[1].uri, "https://approval.example.test/oauth/device?user_code=NEXT-1234");
});
test("expired device link is replaced by a fresh login challenge without reinstalling", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-expired-link-"));
const statePath = join(root, "session.json");
writeFileSync(statePath, JSON.stringify({
client: { client_id: "bridge-client" },
device: {
device_code: "expired-device", user_code: "OLD-1234",
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=OLD-1234",
expiresAt: Date.now() - 1, interval: 5,
},
}));
const paths: string[] = [];
const bridge = new RemoteBridge("https://approval.example.test/mcp", statePath, async (input) => {
const path = new URL(String(input)).pathname;
paths.push(path);
if (path === "/oauth/device/authorize") return Response.json({
device_code: "fresh-device", user_code: "NEW-5678",
verification_uri_complete: "https://approval.example.test/oauth/device?user_code=NEW-5678",
expires_in: 600, interval: 5,
});
throw new Error(`unexpected ${path}`);
});
const result = await bridge.loginChallenge();
assert.deepEqual(paths, ["/oauth/device/authorize"]);
assert.equal(result.authenticated, false);
assert.ok("authChallenge" in result);
assert.equal(result.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=NEW-5678");
assert.match(result.message, /无需重新安装/);
assert.doesNotMatch(readFileSync(statePath, "utf8"), /expired-device|OLD-1234/);
});
test("MCP approval_login returns both portable text and a standard resource link", async () => {
const root = mkdtempSync(join(tmpdir(), "ding-bridge-mcp-login-"));
const bridge = new RemoteBridge("https://approval.example.test/mcp", join(root, "session.json"), async (input) => {
@@ -210,6 +243,7 @@ test("MCP approval_login returns both portable text and a standard resource link
assert.equal((response.structuredContent as any).result.authChallenge.directUrl, link?.uri);
const tools = await client.listTools();
const instructions = client.getInstructions() ?? "";
assert.match(instructions, /登录链接过期.*approval_login.*绝不.*重新安装/);
assert.match(instructions, /审批引用.*调用容量.*内部分批.*不得向用户解释/);
assert.doesNotMatch(instructions, /最多.*20|selectionId|approvalId|targetId/);
const history = tools.tools.find((tool) => tool.name === "approval_list_history")!;
+36 -1
View File
@@ -98,7 +98,9 @@ test("generic installer preserves identity across safe updates, rollback, downgr
["https://approval.example.test/downloads/manifest.sig", signature],
...Object.entries(manifest.artifacts).map(([name, artifact]) => [artifact.url, bodies[name as keyof typeof bodies]] as [string, Buffer]),
]);
let downloadCalls = 0;
const fetchFn = async (input: string | URL | Request) => {
downloadCalls += 1;
const url = String(input);
const body = responses.get(url);
return body ? new Response(new Uint8Array(body), { status: 200 }) : new Response("missing", { status: 404 });
@@ -122,6 +124,11 @@ test("generic installer preserves identity across safe updates, rollback, downgr
assert.match(installOutput.nextSteps.join(" "), /立即处理 postInstall.*不得等待用户首次查询审批/s);
assert.equal(installOutput.postInstall.status, "authorization_required");
assert.equal(installOutput.postInstall.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234");
assert.deepEqual(installOutput.loginCommand, {
command: join(appRoot, "bin", process.platform === "win32" ? "dingtalk-approval.cmd" : "dingtalk-approval"),
args: ["login", "--json"],
});
assert.deepEqual(installOutput.postInstall.loginCommand, installOutput.loginCommand);
assert.deepEqual(installOutput.postInstall.confirmTool, { name: "approval_login", arguments: {} });
assert.deepEqual(installOutput.postInstall.verifyTool, {
name: "approval_list_todos", arguments: { source: "all", query: "", limit: 1 },
@@ -137,6 +144,27 @@ test("generic installer preserves identity across safe updates, rollback, downgr
assert.doesNotMatch(readFileSync(paths.bridgeCommand, "utf8"), /--url/);
const stableBridgePath = paths.bridgeCommand;
const initialRuntime = state.runtimeDir;
const callsBeforeStandaloneLogin = downloadCalls;
output.length = 0;
assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], commandContext), 0);
const loginOutput = JSON.parse(output.pop()!);
assert.equal(loginOutput.operation, "login");
assert.equal(loginOutput.status, "authorization_required");
assert.equal(loginOutput.authChallenge.directUrl, "https://approval.example.test/oauth/device?user_code=INSTALL-1234");
assert.deepEqual(loginOutput.loginCommand, installOutput.loginCommand);
assert.equal(downloadCalls, callsBeforeStandaloneLogin, "standalone login must not download or reinstall artifacts");
output.length = 0;
assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], {
...commandContext, env: { ...env, FAKE_BRIDGE_AUTHENTICATED: "1" },
}), 0);
const authenticatedLogin = JSON.parse(output.pop()!);
assert.equal(authenticatedLogin.status, "authenticated");
assert.equal(authenticatedLogin.authenticated, true);
assert.equal(authenticatedLogin.authChallenge, undefined);
assert.equal(downloadCalls, callsBeforeStandaloneLogin, "authenticated login check must stay local except for the Bridge flow");
mkdirSync(dirname(paths.sessionFile), { recursive: true });
const sessionBody = Buffer.from(`${JSON.stringify({
authenticated: true,
@@ -273,6 +301,9 @@ test("generic installer preserves identity across safe updates, rollback, downgr
});
assert.equal(removed, 0);
assert.equal(existsSync(appRoot), false);
output.length = 0;
assert.equal(await runClientInstaller(["login", "--app-root", appRoot, "--json"], commandContext), 1);
assert.match(JSON.parse(output.pop()!).error, /尚未安装/);
});
function manifestFixture(
@@ -311,7 +342,11 @@ function digest(value: Buffer): string { return createHash("sha256").update(valu
function createFakeNpm(bin: string): void {
const fakeBridge = `#!/usr/bin/env node
if (process.env.FAKE_BRIDGE_FAIL === "1") process.exit(2);
if (process.argv.includes("--post-install-login")) {
if (process.argv.includes("--login") || process.argv.includes("--post-install-login")) {
if (process.env.FAKE_BRIDGE_AUTHENTICATED === "1") {
console.log(JSON.stringify({ authenticated: true, message: "已登录,可以读取审批。" }));
process.exit(0);
}
console.log(JSON.stringify({
authenticated: false,
message: "需要完成钉钉授权登录。",
+2
View File
@@ -15,6 +15,7 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.doesNotMatch(installer, /--host|WorkBuddy|Codex|Claude/);
assert.match(installer, /不会通过其他消息通道自动推送链接.*完整登录 URL.*当前回复/s);
assert.match(installer, /读取返回的 `postInstall`.*不得等到用户第一次查询审批/s);
assert.match(installer, /登录链接过期.*绝不能重新安装.*approval_login.*loginCommand/s);
assert.match(installer, /onboarding\.firstRun=true.*简短示例.*不要自动执行/s);
assert.doesNotMatch(installer, /通过当前 Messenger/);
assert.doesNotMatch(installer, /DWS_CLI_PATH|dingtalk-workspace-cli|APP_MODE=local/);
@@ -27,6 +28,7 @@ test("customer distribution is a generic single-login gateway bridge", () => {
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);
assert.match(skill, /expired login link.*approval_login.*never reinstall/s);
const builder = readFileSync("scripts/build-release.mjs", "utf8");
assert.match(builder, /schemaVersion: 3,\s+version,/s);