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
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:
@@ -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` 的连接器通过其官方入口删除。
|
||||
|
||||
## 数据与安全
|
||||
|
||||
|
||||
@@ -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。网关短暂不可用时保留现有登录状态并稍后重试,不要删除会话文件或主动要求用户重新授权。
|
||||
|
||||
@@ -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
@@ -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。
|
||||
|
||||
|
||||
@@ -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、部门/城市选项和越权拒绝;历史引用必须被动作查询和批处理拒绝。
|
||||
|
||||
@@ -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
@@ -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
@@ -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])) {
|
||||
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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: "需要完成钉钉授权登录。",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user