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
+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])) {