From d42cb1f80486efbf2ee96124785f7c5972130a66 Mon Sep 17 00:00:00 2001 From: shaun Date: Mon, 3 Aug 2026 16:38:03 +0800 Subject: [PATCH 1/3] Add WeCom CLI integration --- .gitignore | 6 + NOTICE | 4 +- THIRD_PARTY_NOTICES.md | 12 +- docs/architecture.md | 16 +- electron-builder.ts | 4 + electron/agent/binaries.ts | 16 + electron/agent/manager.test.ts | 23 ++ electron/agent/manager.ts | 27 +- electron/agent/sidecar.ts | 4 + electron/agent/workspace.test.ts | 26 +- electron/agent/workspace.ts | 18 +- electron/connections/common.ts | 6 + electron/link-runtime/common.ts | 18 + electron/link-runtime/node.ts | 33 +- electron/link-runtime/wecom-cli.test.ts | 95 +++++ electron/link-runtime/wecom-cli.ts | 361 ++++++++++++++++++ electron/main.ts | 29 +- package.json | 2 +- scripts/download-wecom-cli.ts | 9 + scripts/prepare-binaries.ts | 6 + scripts/wecom-cli.test.ts | 29 ++ scripts/wecom-cli.ts | 203 ++++++++++ src/assets/apps/wecom.svg | 22 ++ src/hooks/useLarkCliConnection.ts | 1 + src/hooks/useWecomCliConnection.test.ts | 36 ++ src/hooks/useWecomCliConnection.ts | 158 ++++++++ src/i18n/app-messages.en.ts | 11 + src/i18n/app-messages.zh.ts | 11 + .../Connections/ConnectionAccountsList.tsx | 8 +- .../ConnectionProviderDetailPane.tsx | 22 +- src/routes/Connections/index.tsx | 114 +++++- 31 files changed, 1286 insertions(+), 44 deletions(-) create mode 100644 electron/link-runtime/wecom-cli.test.ts create mode 100644 electron/link-runtime/wecom-cli.ts create mode 100644 scripts/download-wecom-cli.ts create mode 100644 scripts/wecom-cli.test.ts create mode 100644 scripts/wecom-cli.ts create mode 100644 src/assets/apps/wecom.svg create mode 100644 src/hooks/useWecomCliConnection.test.ts create mode 100644 src/hooks/useWecomCliConnection.ts diff --git a/.gitignore b/.gitignore index e245ceb7..6bf959c8 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ release # Locally downloaded Lark CLI binary (dev/build) — produced by scripts/download-lark-cli.ts .lark-cli-bin/ +# Locally downloaded WeCom CLI binary (dev/build) — produced by scripts/download-wecom-cli.ts +.wecom-cli-bin/ + # Bundled oo/opencode binaries staged by scripts/prepare-binaries.ts (not committed) resources/bin @@ -51,6 +54,9 @@ resources/skills # Lark CLI skills exported from the pinned binary (not committed) resources/lark-skills +# WeCom CLI skills exported from the pinned source commit (not committed) +resources/wecom-skills + # Bundled self-contained OpenCode custom-tool runtime (not committed) resources/agent-tool-runtime diff --git a/NOTICE b/NOTICE index 894ef538..85d6909d 100644 --- a/NOTICE +++ b/NOTICE @@ -2,8 +2,8 @@ Wanta Copyright 2026 OOMOL This product includes software developed by third parties. In particular, Wanta uses OpenCode as -its local Agent engine and distributes the oo CLI and its bundled Skills for Connector access. -License and attribution details are provided in THIRD_PARTY_NOTICES.md. +its local Agent engine and distributes the oo CLI, the WeCom CLI, and their bundled Skills for +connected-app access. License and attribution details are provided in THIRD_PARTY_NOTICES.md. Wanta and OOMOL names and logos are not licensed under the Apache License, Version 2.0. See TRADEMARKS.md. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5cbced61..0a0ec797 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -35,15 +35,25 @@ The CLI and Skills are included by default so official OOMOL Connector and endpo self-hosted OpenConnector deployments can use the same invocation path. Local BYOK mode does not register Connector tools or inject the oo runtime environment. +## WeCom CLI and Skills + +Wanta packages the official `@wecom/cli@0.1.9` platform binary and the matching `wecomcli-*` +Skills from source commit `72e14f7695f34d28f1ff23ea504ddd2210a87c13` for the local WeCom Direct +provider. + +Source: [WecomTeam/wecom-cli](https://github.com/WecomTeam/wecom-cli). License: MIT. Copyright (c) +2026 WeCom. + ## MIT License Text -The following text applies to the OpenCode and oo CLI entries above: +The following text applies to the OpenCode, oo CLI, and WeCom CLI entries above: ```text MIT License Copyright (c) 2025 opencode Copyright (c) 2026 OOMOL Lab +Copyright (c) 2026 WeCom Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/architecture.md b/docs/architecture.md index 2f724f01..97de20ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -135,6 +135,20 @@ config root is injected through `LARKSUITE_CLI_CONFIG_DIR`, and its matching Ski the private Agent workspace. Thus chat uses the same local identity authorized from Connections, independently of the selected OOMOL/OpenConnector Link runtime. +WeCom CLI is a separate local `direct` provider with a provider-specific QR-code experience rather +than a Lark-shaped authorization flow. `WecomCliManager` runs the official +`init --noninteractive --no-open` command against `/wecom-cli/config`, opens only the +allowlisted `https://work.weixin.qq.com/ai/qc/gen` page, keeps the short-lived `scode` URL in main +process memory, and lets the user reopen or cancel the scan while it is pending. Connection state is +read through the CLI's hidden `auth show` contract; only the bot ID, CLI version, phase, and redacted +errors cross `LinkRuntimeServiceImpl`. Disconnect removes only Wanta's isolated WeCom config and +temporary-media directories, never the user's global `~/.config/wecom` or the robot in WeCom. The +shipped platform binary is downloaded from the matching `@wecom/cli-*` npm package and verified +against registry `dist.integrity`; `wecomcli-*` Skills come from the exact source `gitHead` recorded +by the pinned `@wecom/cli` package. `WECOM_CLI_CONFIG_DIR` / `WECOM_CLI_TMP_DIR`, the managed binary +directory, and these Skills are injected into the private Agent runtime independently of Lark and of +the selected Link backend. Credentials and raw QR output never enter the renderer. + Vite (`vite-plugin-electron/simple` in `vite.config.ts`) bundles `electron/main.ts` and `electron/preload.ts` into `dist-electron/main.js` + `preload.js`; the main-process build has a **third** rollup input, `electron/chat/spreadsheet-preview-worker.ts` → `dist-electron/spreadsheet-preview-worker.js`, @@ -621,7 +635,7 @@ electron/ chat/ common,node + ~45 modules by far the largest main-process domain: SSE event bridge; per-turn lifecycle & outputs (turn-lifecycle, turn-outputs); structured artifact registration/persistence (artifact-bundles, artifacts) + previews (spreadsheet-preview-worker[-client]); permission / local-access policy (permission-state, project-permission); project-* commands; attachments; stream buffering (stream-event-buffer, context-system). Also thin main-process facades openExternalUrl (shell external open) / setAgentTeam (agent team scope) for the renderer request layer (§4, §5) git/ common,node,status,turn-diff(+test) GitService (serviceName("git-service")): project git status + per-turn diff review knowledge/ common,node,store,runner,uri,thumbnail(+test) WikiGraph knowledge-base import, registration, query runtime & RPC service - link-runtime/ common,node,lark-cli(+test) selected Link runtime, origin-bound OpenConnector token, health/inventory facade, and isolated direct Lark CLI lifecycle + link-runtime/ common,node,lark-cli,wecom-cli(+test) selected Link runtime, origin-bound OpenConnector token, health/inventory facade, and isolated provider-specific Lark/WeCom direct CLI lifecycles teams/ common types only, no node.ts — team requests moved renderer-side (src/lib/teams-client.ts, §4) connections/ common,summary,usage,executions,federated,domain,summary-model(+test) **pure functions + types, no node.ts** — connector requests moved renderer-side (src/lib/connections-client.ts, §4/§7); electron-free, imported straight into the renderer bundle skills/ common,node,actions,scan,inventory,… skill service (install/scan/inventory); browse GET moved renderer-side (src/lib/skills-catalog-client.ts); actions.ts normalize* reused by the renderer (§4) diff --git a/electron-builder.ts b/electron-builder.ts index 44646994..2ca51621 100644 --- a/electron-builder.ts +++ b/electron-builder.ts @@ -72,6 +72,10 @@ export default { from: "resources/lark-skills", to: "lark-skills", }, + { + from: "resources/wecom-skills", + to: "wecom-skills", + }, { from: "resources/agent-tool-runtime", to: "agent-tool-runtime", diff --git a/electron/agent/binaries.ts b/electron/agent/binaries.ts index 25e35d26..5e1c3369 100644 --- a/electron/agent/binaries.ts +++ b/electron/agent/binaries.ts @@ -26,6 +26,10 @@ export function larkCliBinaryName(platform: NodeJS.Platform = process.platform): return platform === "win32" ? "lark-cli.exe" : "lark-cli" } +export function wecomCliBinaryName(platform: NodeJS.Platform = process.platform): string { + return platform === "win32" ? "wecom-cli.exe" : "wecom-cli" +} + /** dev:从项目本地 .oo-bin 解析 oo 二进制(postinstall 下载、prepare-binaries 同源;生产由 extraResources 解析)。 */ export function resolveDevOoBin(repoRoot: string, platform: NodeJS.Platform = process.platform): string { return path.join(repoRoot, ".oo-bin", ooBinaryName(platform)) @@ -35,6 +39,10 @@ export function resolveDevLarkCliBin(repoRoot: string, platform: NodeJS.Platform return path.join(repoRoot, ".lark-cli-bin", larkCliBinaryName(platform)) } +export function resolveDevWecomCliBin(repoRoot: string, platform: NodeJS.Platform = process.platform): string { + return path.join(repoRoot, ".wecom-cli-bin", wecomCliBinaryName(platform)) +} + /** 生产:从打包的 Resources/bin 解析二进制(prepare-binaries 复制、extraResources 打入)。 */ export function resolveBundledBin(resourcesPath: string, binaryName: string): string { return path.join(resourcesPath, "bin", binaryName) @@ -58,6 +66,14 @@ export function resolveBundledLarkSkillsDir(resourcesPath: string): string { return path.join(resourcesPath, "lark-skills") } +export function resolveDevBundledWecomSkillsDir(repoRoot: string): string { + return path.join(repoRoot, "resources", "wecom-skills") +} + +export function resolveBundledWecomSkillsDir(resourcesPath: string): string { + return path.join(resourcesPath, "wecom-skills") +} + /** dev:构建期合并的自定义工具 runtime(postinstall 生成)。 */ export function resolveDevBundledToolRuntimePath(repoRoot: string): string { return path.join(repoRoot, "resources", "agent-tool-runtime", "tool.js") diff --git a/electron/agent/manager.test.ts b/electron/agent/manager.test.ts index a318f75f..6e6a05e7 100644 --- a/electron/agent/manager.test.ts +++ b/electron/agent/manager.test.ts @@ -164,6 +164,29 @@ describe("AgentManager", () => { expect(env).not.toHaveProperty("WIKIGRAPH_STATE_DIR") }) + it("isolates local direct CLI configuration in the sidecar", () => { + const env = buildAgentSidecarEnv({ + commandPath: "/managed/bin:/usr/bin", + larkCliBinPath: "/managed/bin/lark-cli", + larkCliConfigDir: "/private/lark/config", + linkRuntime: null, + storeDir: "/private/oo-store", + teamScopePath: "/private/team-scope.json", + wecomCliBinPath: "/managed/bin/wecom-cli", + wecomCliConfigDir: "/private/wecom/config", + wecomCliTmpDir: "/private/wecom/tmp", + }) + + expect(env).toMatchObject({ + LARKSUITE_CLI_CONFIG_DIR: "/private/lark/config", + PATH: "/managed/bin:/usr/bin", + WANTA_LARK_CLI_BIN: "/managed/bin/lark-cli", + WANTA_WECOM_CLI_BIN: "/managed/bin/wecom-cli", + WECOM_CLI_CONFIG_DIR: "/private/wecom/config", + WECOM_CLI_TMP_DIR: "/private/wecom/tmp", + }) + }) + it("exposes OOMOL authentication and the managed Node runtime to Skill commands", () => { const env = buildAgentSidecarEnv({ commandPath: "/usr/bin:/bin", diff --git a/electron/agent/manager.ts b/electron/agent/manager.ts index 179be91d..6934784a 100644 --- a/electron/agent/manager.ts +++ b/electron/agent/manager.ts @@ -55,12 +55,17 @@ export interface AgentManagerOptions { listOpenConnectorAuthorizedServices?: (signal?: AbortSignal) => Promise /** 内置 skill 源目录(resources/skills 或打包 Resources/skills);启动时拷进 .opencode/skill/。 */ bundledSkillsDir?: string - /** Official Lark CLI skills, available for the local direct connection. */ - bundledLarkSkillsDir?: string + /** Official local direct-CLI skills, independent of the selected Link runtime. */ + bundledDirectSkillsDirs?: string[] /** Active Wanta-managed Lark CLI direct-runtime binary. */ larkCliBinPath?: string /** Isolated Lark CLI config directory; credentials remain owned by the CLI/keychain. */ larkCliConfigDir?: string + /** Active Wanta-managed WeCom CLI direct-runtime binary. */ + wecomCliBinPath?: string + /** Isolated WeCom CLI config and temporary roots. */ + wecomCliConfigDir?: string + wecomCliTmpDir?: string /** 构建期合并的自定义工具 runtime;启动时拷进 .opencode/runtime/tool.js。 */ bundledToolRuntimePath?: string /** App 私有根目录(userData 下):workspace / oo-store / isolation 都在其下。 */ @@ -114,6 +119,9 @@ export interface AgentSidecarEnvOptions { teamScopePath: string larkCliBinPath?: string larkCliConfigDir?: string + wecomCliBinPath?: string + wecomCliConfigDir?: string + wecomCliTmpDir?: string } export function buildAgentSidecarEnv({ @@ -126,6 +134,9 @@ export function buildAgentSidecarEnv({ teamScopePath, larkCliBinPath, larkCliConfigDir, + wecomCliBinPath, + wecomCliConfigDir, + wecomCliTmpDir, }: AgentSidecarEnvOptions): Record { const ooEnv = linkRuntime ? buildAgentLinkEnv({ @@ -146,6 +157,9 @@ export function buildAgentSidecarEnv({ LARKSUITE_CLI_CONFIG_DIR: larkCliConfigDir ?? "", LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1", LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1", + WANTA_WECOM_CLI_BIN: wecomCliBinPath ?? "", + WECOM_CLI_CONFIG_DIR: wecomCliConfigDir ?? "", + WECOM_CLI_TMP_DIR: wecomCliTmpDir ?? "", } } @@ -429,8 +443,8 @@ export class AgentManager { await ensureAgentWorkspace(workspaceDir, bundledSkillsDir, bundledToolRuntimePath, { bundledOoSkills: this.options.linkRuntime?.kind === "oomol", - bundledLarkSkillsDir: this.options.bundledLarkSkillsDir, connectors: this.options.linkRuntime !== null, + directSkillsDirs: this.options.bundledDirectSkillsDirs, }) this.teamScopePath = teamScopePath await this.writeTeamState(this.teamName) @@ -450,6 +464,9 @@ export class AgentManager { wikiGraphStateDir, larkCliBinPath, larkCliConfigDir, + wecomCliBinPath, + wecomCliConfigDir, + wecomCliTmpDir, } = this.options const workspaceDir = path.join(rootDir, "workspace") const isolationDir = path.join(rootDir, "isolation") @@ -460,6 +477,7 @@ export class AgentManager { const baseCommandPath = await resolveUserCommandPath({ preferredDirectories: [ ...(larkCliBinPath ? [path.dirname(larkCliBinPath)] : []), + ...(wecomCliBinPath ? [path.dirname(wecomCliBinPath)] : []), ...(linkRuntime && ooBinPath ? [path.dirname(ooBinPath)] : []), ], }) @@ -484,6 +502,9 @@ export class AgentManager { teamScopePath, larkCliBinPath, larkCliConfigDir, + wecomCliBinPath, + wecomCliConfigDir, + wecomCliTmpDir, }) const sidecar = new OpencodeSidecar({ diff --git a/electron/agent/sidecar.ts b/electron/agent/sidecar.ts index f2975ea1..8c26cc51 100644 --- a/electron/agent/sidecar.ts +++ b/electron/agent/sidecar.ts @@ -430,6 +430,10 @@ export class OpencodeSidecar { XDG_CONFIG_HOME: xdgConfigHome, XDG_DATA_HOME: xdgDataHome, } + // WeCom commands inherit this environment from OpenCode. Never let a launcher-level logging + // override redirect credential-adjacent CLI diagnostics outside Wanta's private runtime. + delete baseChildEnv.WECOM_CLI_LOG_FILE + delete baseChildEnv.WECOM_CLI_LOG_LEVEL const proxy = await systemProxy() const childEnv = mergeSystemProxyEnvironment(baseChildEnv, proxy) logDiagnostic("opencode-sidecar", "opencode sidecar network environment", { diff --git a/electron/agent/workspace.test.ts b/electron/agent/workspace.test.ts index 55418b0c..d08532ad 100644 --- a/electron/agent/workspace.test.ts +++ b/electron/agent/workspace.test.ts @@ -171,7 +171,7 @@ test("ensureAgentWorkspace installs Lark direct-mode skills independently of the await writeSkill(bundledLarkSkillsDir, "lark-calendar") await ensureAgentWorkspace(workspaceDir, bundledSkillsDir, bundledToolRuntimePath, { - bundledLarkSkillsDir, + directSkillsDirs: [bundledLarkSkillsDir], bundledOoSkills: false, connectors: false, }) @@ -185,6 +185,30 @@ test("ensureAgentWorkspace installs Lark direct-mode skills independently of the } }) +test("ensureAgentWorkspace installs independent Lark and WeCom direct-mode skills", async () => { + const base = await mkdtemp(path.join(os.tmpdir(), "wanta-workspace-")) + try { + const workspaceDir = path.join(base, "workspace") + const bundledToolRuntimePath = await writeToolRuntime(base) + const larkSkillsDir = path.join(base, "lark-skills") + const wecomSkillsDir = path.join(base, "wecom-skills") + await writeSkill(larkSkillsDir, "lark-calendar") + await writeSkill(wecomSkillsDir, "wecomcli-todo") + + await ensureAgentWorkspace(workspaceDir, undefined, bundledToolRuntimePath, { + bundledOoSkills: false, + connectors: false, + directSkillsDirs: [larkSkillsDir, wecomSkillsDir], + }) + + const skillRoot = path.join(workspaceDir, ".opencode", "skill") + assert.ok(await exists(path.join(skillRoot, "lark-calendar", "SKILL.md"))) + assert.ok(await exists(path.join(skillRoot, "wecomcli-todo", "SKILL.md"))) + } finally { + await rm(base, { force: true, recursive: true }) + } +}) + test("ensureAgentWorkspace works without a bundled skills directory", async () => { const base = await mkdtemp(path.join(os.tmpdir(), "wanta-workspace-")) try { diff --git a/electron/agent/workspace.ts b/electron/agent/workspace.ts index e8e81b64..67adf563 100644 --- a/electron/agent/workspace.ts +++ b/electron/agent/workspace.ts @@ -6,7 +6,7 @@ const alwaysAvailableBundledSkillIds = new Set(["browser"]) export interface AgentWorkspaceOptions { bundledOoSkills: boolean - bundledLarkSkillsDir?: string + directSkillsDirs?: string[] connectors: boolean } @@ -37,7 +37,7 @@ export async function ensureAgentWorkspace( ), ) await syncToolRuntime(opencodeDir, bundledToolRuntimePath) - await syncBundledSkills(opencodeDir, bundledSkillsDir, options.bundledLarkSkillsDir, options.bundledOoSkills) + await syncBundledSkills(opencodeDir, bundledSkillsDir, options.directSkillsDirs ?? [], options.bundledOoSkills) return rootDir } @@ -63,22 +63,23 @@ async function syncToolRuntime(opencodeDir: string, bundledToolRuntimePath: stri async function syncBundledSkills( opencodeDir: string, bundledSkillsDir: string | undefined, - bundledLarkSkillsDir: string | undefined, + directSkillsDirs: string[], includeOomolSkills: boolean, ): Promise { const skillDir = path.join(opencodeDir, "skill") - if (!bundledSkillsDir && !bundledLarkSkillsDir) { + if (!bundledSkillsDir && directSkillsDirs.length === 0) { await rm(skillDir, { force: true, recursive: true }) return } - const sources: Array<{ directory: string; names: string[] }> = [] - for (const directory of [bundledSkillsDir, bundledLarkSkillsDir]) { + const sources: Array<{ alwaysInclude: boolean; directory: string; names: string[] }> = [] + for (const directory of [bundledSkillsDir, ...directSkillsDirs]) { if (!directory) continue try { const entries = await readdir(directory, { withFileTypes: true }) sources.push({ + alwaysInclude: directSkillsDirs.includes(directory), directory, names: entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name), }) @@ -95,10 +96,7 @@ async function syncBundledSkills( const skillSources = sources.flatMap((source) => source.names - .filter( - (name) => - source.directory === bundledLarkSkillsDir || includeOomolSkills || alwaysAvailableBundledSkillIds.has(name), - ) + .filter((name) => source.alwaysInclude || includeOomolSkills || alwaysAvailableBundledSkillIds.has(name)) .map((name) => ({ name, source: source.directory })), ) if (skillSources.length === 0) { diff --git a/electron/connections/common.ts b/electron/connections/common.ts index 999475bf..695e471a 100644 --- a/electron/connections/common.ts +++ b/electron/connections/common.ts @@ -62,7 +62,13 @@ export interface ConnectionProviderSummary { authTypes: Exclude[] actionKind: ConnectionProviderActionKind canDisconnect: boolean + /** Local Direct providers opt into reconnect only when their runtime can replace an existing identity. */ + canReconnect?: boolean categoryLabels: string[] + /** Renderer-owned copy for a local Direct provider's provider-specific primary action. */ + connectActionLabel?: string + /** Renderer-owned copy for a local Direct provider's non-OAuth connection method. */ + connectionMethodLabel?: string connectedUpdatedAt?: number displayName: string description?: string diff --git a/electron/link-runtime/common.ts b/electron/link-runtime/common.ts index eeadf83f..453d4efd 100644 --- a/electron/link-runtime/common.ts +++ b/electron/link-runtime/common.ts @@ -67,11 +67,24 @@ export interface LarkCliState { updateStatus: "idle" | "checking" | "current" | "updating" | "updated" | "failed" } +export type WecomCliConnectionPhase = "idle" | "preparing" | "waiting_for_scan" | "verifying" | "disconnecting" + +export interface WecomCliState { + accountLabel?: string + activeVersion: string | null + available: boolean + canReopenAuthorization: boolean + connection: "connected" | "disconnected" + error?: string + phase: WecomCliConnectionPhase +} + export type LinkRuntimeService = typeof LinkRuntimeService export const LinkRuntimeService = serviceName("link-runtime-service") as ServiceName<{ ServerEvents: { linkRuntimeChanged: LinkRuntimeState larkCliChanged: LarkCliState + wecomCliChanged: WecomCliState } ClientInvokes: { getState(): Promise @@ -86,5 +99,10 @@ export const LinkRuntimeService = serviceName("link-runtime-service") as Service connectLarkCli(): Promise disconnectLarkCli(): Promise cancelLarkCliConnection(): Promise + getWecomCliState(): Promise + connectWecomCli(): Promise + disconnectWecomCli(): Promise + cancelWecomCliConnection(): Promise + reopenWecomCliAuthorization(): Promise } }> diff --git a/electron/link-runtime/node.ts b/electron/link-runtime/node.ts index 03879db1..5bd2bc40 100644 --- a/electron/link-runtime/node.ts +++ b/electron/link-runtime/node.ts @@ -6,6 +6,7 @@ import type { OpenConnectorRuntimeStatus, OpenConnectorTestResult, LarkCliState, + WecomCliState, } from "./common.ts" import type { IConnectionService } from "@oomol/connection" @@ -16,6 +17,7 @@ import { atomicWriteText } from "../atomic-file.ts" import { ServiceEvent } from "../service-events.ts" import { LinkRuntimeService as LinkRuntimeServiceName } from "./common.ts" import { LarkCliManager } from "./lark-cli.ts" +import { WecomCliManager } from "./wecom-cli.ts" export interface RuntimeCredentialEncryption { decryptString(encrypted: Buffer): string @@ -446,13 +448,16 @@ export class LinkRuntimeServiceImpl { private readonly manager: LinkRuntimeManager private readonly larkCli: LarkCliManager + private readonly wecomCli: WecomCliManager private readonly unsubscribe: () => void private readonly unsubscribeLarkCli: () => void + private readonly unsubscribeWecomCli: () => void - public constructor(manager: LinkRuntimeManager, larkCli: LarkCliManager) { + public constructor(manager: LinkRuntimeManager, larkCli: LarkCliManager, wecomCli: WecomCliManager) { super(LinkRuntimeServiceName) this.manager = manager this.larkCli = larkCli + this.wecomCli = wecomCli this.unsubscribe = manager.stateChanged.on((state) => { void this.send("linkRuntimeChanged", state).catch((error: unknown) => { console.warn("[wanta] Link runtime broadcast failed:", error) @@ -463,6 +468,11 @@ export class LinkRuntimeServiceImpl console.warn("[wanta] Lark CLI state broadcast failed:", error) }) }) + this.unsubscribeWecomCli = wecomCli.stateChanged.on((state) => { + void this.send("wecomCliChanged", state).catch((error: unknown) => { + console.warn("[wanta] WeCom CLI state broadcast failed:", error) + }) + }) } public getState(): Promise { @@ -517,9 +527,30 @@ export class LinkRuntimeServiceImpl this.larkCli.cancelConnection() } + public getWecomCliState(): Promise { + return this.wecomCli.getState() + } + + public connectWecomCli(): Promise { + return this.wecomCli.connect() + } + + public disconnectWecomCli(): Promise { + return this.wecomCli.disconnect() + } + + public async cancelWecomCliConnection(): Promise { + this.wecomCli.cancelConnection() + } + + public async reopenWecomCliAuthorization(): Promise { + return this.wecomCli.reopenAuthorization() + } + public override dispose(): void { this.unsubscribe() this.unsubscribeLarkCli() + this.unsubscribeWecomCli() super.dispose() } } diff --git a/electron/link-runtime/wecom-cli.test.ts b/electron/link-runtime/wecom-cli.test.ts new file mode 100644 index 00000000..a1267449 --- /dev/null +++ b/electron/link-runtime/wecom-cli.test.ts @@ -0,0 +1,95 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { describe, expect, it } from "vitest" +import { findOfficialWecomAuthorizationUrl, redactWecomCliOutput, WecomCliManager } from "./wecom-cli.ts" + +describe("WeCom CLI authorization URL", () => { + it("accepts the official QR-code page", () => { + expect( + findOfficialWecomAuthorizationUrl( + "请打开二维码链接扫码: https://work.weixin.qq.com/ai/qc/gen?source=wecom_cli_external&scode=temporary", + ), + ).toBe("https://work.weixin.qq.com/ai/qc/gen?source=wecom_cli_external&scode=temporary") + }) + + it("rejects lookalike hosts, ports, protocols, and paths", () => { + expect(findOfficialWecomAuthorizationUrl("https://work.weixin.qq.com.evil.test/ai/qc/gen?scode=x")).toBeUndefined() + expect(findOfficialWecomAuthorizationUrl("https://work.weixin.qq.com:8443/ai/qc/gen?scode=x")).toBeUndefined() + expect(findOfficialWecomAuthorizationUrl("http://work.weixin.qq.com/ai/qc/gen?scode=x")).toBeUndefined() + expect(findOfficialWecomAuthorizationUrl("https://work.weixin.qq.com/other?scode=x")).toBeUndefined() + expect(findOfficialWecomAuthorizationUrl("https://work.weixin.qq.com/ai/qc/gen")).toBeUndefined() + }) +}) + +describe("WeCom CLI error redaction", () => { + it("removes QR URLs and credentials", () => { + const value = redactWecomCliOutput( + 'https://work.weixin.qq.com/ai/qc/gen?scode=temporary {"secret":"top-secret","access_token":"token"}', + 1, + ) + expect(value).not.toContain("temporary") + expect(value).not.toContain("top-secret") + expect(value).not.toContain('"token"') + expect(value).toContain("[authorization-url]") + expect(value).toContain("exit 1") + }) +}) + +describe.runIf(process.platform !== "win32")("WeCom CLI lifecycle", () => { + it("connects by QR code and disconnects only the isolated credential directories", async () => { + const base = await mkdtemp(path.join(os.tmpdir(), "wanta-wecom-cli-")) + try { + const binaryPath = path.join(base, "wecom-cli") + const rootDir = path.join(base, "private-runtime") + const skillsDir = path.join(base, "skills") + const retained = path.join(rootDir, "retained.txt") + await mkdir(skillsDir) + await mkdir(rootDir) + await writeFile(retained, "keep", "utf-8") + await writeFile( + binaryPath, + `#!/bin/sh +if [ "$1" = "--version" ]; then echo "wecom-cli 0.1.9"; exit 0; fi +if [ "$1" = "auth" ] && [ "$3" = "--auth-status" ]; then + if [ -f "$WECOM_CLI_CONFIG_DIR/authorized" ]; then echo authorized; else echo unauthorized; fi + exit 0 +fi +if [ "$1" = "auth" ]; then echo '{"id":"bot-123"}'; exit 0; fi +if [ "$1" = "init" ]; then + mkdir -p "$WECOM_CLI_CONFIG_DIR" + echo 'https://work.weixin.qq.com/ai/qc/gen?source=test&scode=temporary' + touch "$WECOM_CLI_CONFIG_DIR/authorized" + exit 0 +fi +exit 1 +`, + "utf-8", + ) + await chmod(binaryPath, 0o755) + const opened: string[] = [] + const manager = new WecomCliManager({ + binaryPath, + openExternalUrl: (url) => opened.push(url), + rootDir, + skillsDir, + }) + + const connected = await manager.connect() + expect(connected).toMatchObject({ + accountLabel: "bot-123", + canReopenAuthorization: false, + connection: "connected", + phase: "idle", + }) + expect(opened).toEqual(["https://work.weixin.qq.com/ai/qc/gen?source=test&scode=temporary"]) + + const disconnected = await manager.disconnect() + expect(disconnected.connection).toBe("disconnected") + await expect(readFile(retained, "utf-8")).resolves.toBe("keep") + await expect(stat(path.join(rootDir, "config"))).resolves.toMatchObject({}) + } finally { + await rm(base, { force: true, recursive: true }) + } + }) +}) diff --git a/electron/link-runtime/wecom-cli.ts b/electron/link-runtime/wecom-cli.ts new file mode 100644 index 00000000..7634d99f --- /dev/null +++ b/electron/link-runtime/wecom-cli.ts @@ -0,0 +1,361 @@ +import type { WecomCliState } from "./common.ts" +import type { ChildProcess } from "node:child_process" + +import { execFile, spawn } from "node:child_process" +import { chmod, mkdir, rm, stat } from "node:fs/promises" +import path from "node:path" +import { promisify } from "node:util" +import { logDiagnostic } from "../diagnostics-log.ts" +import { ServiceEvent } from "../service-events.ts" + +const execFileAsync = promisify(execFile) +const authorizationTimeoutMs = 6 * 60_000 +const maxOutputBytes = 512 * 1024 + +interface WecomCliManagerOptions { + binaryPath: string + onRuntimeChanged?: () => Promise | void + openExternalUrl: (url: string) => void + rootDir: string + skillsDir: string +} + +export interface WecomCliRuntime { + binaryPath: string + skillsDir: string + version: string +} + +export class WecomCliManager { + private readonly binaryPath: string + private readonly configDir: string + private readonly onRuntimeChanged?: () => Promise | void + private readonly openExternalUrl: (url: string) => void + private readonly rootDir: string + private readonly skillsDir: string + private readonly temporaryDir: string + private activeChild: ChildProcess | null = null + private activeAuthorizationUrl: string | null = null + private cancelRequested = false + private operation: { kind: "connect" | "disconnect"; promise: Promise } | null = null + private state: WecomCliState = { + activeVersion: null, + available: false, + canReopenAuthorization: false, + connection: "disconnected", + phase: "idle", + } + public readonly stateChanged = new ServiceEvent() + + public constructor(options: WecomCliManagerOptions) { + this.binaryPath = options.binaryPath + this.configDir = path.join(options.rootDir, "config") + this.onRuntimeChanged = options.onRuntimeChanged + this.openExternalUrl = options.openExternalUrl + this.rootDir = options.rootDir + this.skillsDir = options.skillsDir + this.temporaryDir = path.join(options.rootDir, "tmp") + } + + public async getState(): Promise { + if (this.operation) return this.state + try { + const runtime = await this.activeRuntime() + if (!runtime) throw new Error("The bundled WeCom CLI runtime is unavailable.") + const auth = await this.readAuthState() + this.state = { + ...this.state, + accountLabel: auth.accountLabel, + activeVersion: runtime.version, + available: true, + connection: auth.connected ? "connected" : "disconnected", + error: undefined, + phase: "idle", + } + } catch (error) { + this.state = { + ...this.state, + accountLabel: undefined, + activeVersion: null, + available: false, + connection: "disconnected", + error: errorMessage(error), + phase: "idle", + } + } + return this.state + } + + public connect(): Promise { + if (this.operation) { + return this.operation.kind === "connect" + ? this.operation.promise + : Promise.reject(new Error("A WeCom CLI disconnect operation is already running.")) + } + this.cancelRequested = false + const operation = this.connectNow() + .catch((error: unknown) => { + this.setState({ error: this.cancelRequested ? undefined : errorMessage(error), phase: "idle" }) + throw error + }) + .finally(() => { + if (this.operation?.promise === operation) this.operation = null + this.activeAuthorizationUrl = null + if (this.state.canReopenAuthorization) this.setState({ canReopenAuthorization: false }) + this.cancelRequested = false + }) + this.operation = { kind: "connect", promise: operation } + return operation + } + + public disconnect(): Promise { + if (this.operation) { + return this.operation.kind === "disconnect" + ? this.operation.promise + : Promise.reject(new Error("A WeCom CLI connection operation is already running.")) + } + const operation = this.disconnectNow() + .catch((error: unknown) => { + this.setState({ error: errorMessage(error), phase: "idle" }) + throw error + }) + .finally(() => { + if (this.operation?.promise === operation) this.operation = null + }) + this.operation = { kind: "disconnect", promise: operation } + return operation + } + + public cancelConnection(): void { + if (this.operation?.kind !== "connect") return + this.cancelRequested = true + const child = this.activeChild + child?.kill() + if (child) { + const escalation = setTimeout(() => { + if (this.activeChild !== child) return + child.kill("SIGKILL") + this.activeChild = null + }, 5_000) + escalation.unref() + child.once("exit", () => clearTimeout(escalation)) + } + this.setState({ phase: "idle" }) + } + + public reopenAuthorization(): boolean { + if (this.operation?.kind !== "connect" || !this.activeAuthorizationUrl) return false + this.openExternalUrl(this.activeAuthorizationUrl) + return true + } + + public async activeRuntime(): Promise { + try { + const [binary, skills, version] = await Promise.all([ + stat(this.binaryPath), + stat(this.skillsDir), + this.readVersion(), + ]) + if (!binary.isFile() || !skills.isDirectory()) return null + return { binaryPath: this.binaryPath, skillsDir: this.skillsDir, version } + } catch { + return null + } + } + + private async connectNow(): Promise { + this.setState({ canReopenAuthorization: false, error: undefined, phase: "preparing" }) + const runtime = await this.activeRuntime() + if (!runtime) throw new Error("The bundled WeCom CLI runtime is unavailable.") + await this.ensurePrivateDirectories() + const current = await this.readAuthState() + if (!current.connected) { + this.setState({ phase: "waiting_for_scan" }) + await this.runAuthorizationCommand() + } + this.assertNotCancelled() + this.setState({ phase: "verifying" }) + const auth = await this.readAuthState() + if (!auth.connected) throw new Error("WeCom CLI did not confirm the bot connection.") + this.setState({ + accountLabel: auth.accountLabel, + activeVersion: runtime.version, + available: true, + canReopenAuthorization: false, + connection: "connected", + error: undefined, + phase: "idle", + }) + void Promise.resolve(this.onRuntimeChanged?.()).catch(() => undefined) + return this.state + } + + private async disconnectNow(): Promise { + this.setState({ error: undefined, phase: "disconnecting" }) + await Promise.all([ + rm(this.configDir, { force: true, recursive: true }), + rm(this.temporaryDir, { force: true, recursive: true }), + ]) + await this.ensurePrivateDirectories() + this.setState({ + accountLabel: undefined, + canReopenAuthorization: false, + connection: "disconnected", + phase: "idle", + }) + void Promise.resolve(this.onRuntimeChanged?.()).catch(() => undefined) + return this.state + } + + private async ensurePrivateDirectories(): Promise { + await mkdir(this.rootDir, { mode: 0o700, recursive: true }) + await Promise.all([ + mkdir(this.configDir, { mode: 0o700, recursive: true }), + mkdir(this.temporaryDir, { mode: 0o700, recursive: true }), + ]) + if (process.platform !== "win32") { + await Promise.all([chmod(this.rootDir, 0o700), chmod(this.configDir, 0o700), chmod(this.temporaryDir, 0o700)]) + } + } + + private commandEnvironment(): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = { + ...process.env, + WECOM_CLI_CONFIG_DIR: this.configDir, + WECOM_CLI_TMP_DIR: this.temporaryDir, + } + delete environment.WECOM_CLI_LOG_FILE + delete environment.WECOM_CLI_LOG_LEVEL + return environment + } + + private async readVersion(): Promise { + const result = await this.runCommand(["--version"], 10_000) + const version = `${result.stdout}\n${result.stderr}`.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/u)?.[1] + if (!version) throw new Error("WeCom CLI returned an unreadable version.") + return version + } + + private async readAuthState(): Promise<{ accountLabel?: string; connected: boolean }> { + await this.ensurePrivateDirectories() + const status = await this.runCommand(["auth", "show", "--auth-status"], 10_000) + if (status.stdout.trim() !== "authorized") return { connected: false } + const identity = await this.runCommand(["auth", "show"], 10_000) + try { + const value = JSON.parse(identity.stdout) as { id?: unknown } + return { accountLabel: typeof value.id === "string" && value.id ? value.id : undefined, connected: true } + } catch { + return { connected: true } + } + } + + private async runCommand(args: string[], timeout: number): Promise<{ stderr: string; stdout: string }> { + try { + return await execFileAsync(this.binaryPath, args, { + encoding: "utf-8", + env: this.commandEnvironment(), + maxBuffer: maxOutputBytes, + timeout, + }) + } catch (error) { + const candidate = error as Error & { stderr?: string; stdout?: string } + throw new Error( + redactWecomCliOutput(`${candidate.stderr ?? ""}\n${candidate.stdout ?? ""}\n${candidate.message}`), + ) + } + } + + private runAuthorizationCommand(): Promise { + return new Promise((resolve, reject) => { + const child = spawn(this.binaryPath, ["init", "--noninteractive", "--no-open"], { + env: this.commandEnvironment(), + stdio: ["ignore", "pipe", "pipe"], + }) + this.activeChild = child + let output = "" + let openedUrl = false + const consume = (chunk: Buffer | string): void => { + if (Buffer.byteLength(output) < maxOutputBytes) { + output += chunk.toString().slice(0, maxOutputBytes - Buffer.byteLength(output)) + } + if (!openedUrl) { + const url = findOfficialWecomAuthorizationUrl(output) + if (url) { + openedUrl = true + this.activeAuthorizationUrl = url + this.setState({ canReopenAuthorization: true }) + this.openExternalUrl(url) + } + } + } + child.stdout?.on("data", consume) + child.stderr?.on("data", consume) + const timeout = setTimeout(() => { + child.kill() + reject(new Error("WeCom QR-code connection timed out.")) + }, authorizationTimeoutMs) + timeout.unref() + child.once("error", (error) => { + clearTimeout(timeout) + if (this.activeChild === child) this.activeChild = null + reject(new Error(redactWecomCliOutput(error.message))) + }) + child.once("exit", (code, signal) => { + clearTimeout(timeout) + if (this.activeChild === child) this.activeChild = null + if (this.cancelRequested) { + reject(new Error("WeCom QR-code connection cancelled.")) + } else if (code === 0) { + resolve() + } else { + reject(new Error(redactWecomCliOutput(output, code, signal))) + } + }) + }) + } + + private assertNotCancelled(): void { + if (this.cancelRequested) throw new Error("WeCom QR-code connection cancelled.") + } + + private setState(patch: Partial): void { + this.state = { ...this.state, ...patch } + this.stateChanged.emit(this.state) + } +} + +export function findOfficialWecomAuthorizationUrl(output: string): string | undefined { + for (const match of output.matchAll(/https:\/\/[^\s"'<>]+/gu)) { + const raw = match[0].replace(/[),.;,。;]+$/u, "") + try { + const url = new URL(raw) + if ( + url.hostname.toLowerCase() === "work.weixin.qq.com" && + (!url.port || url.port === "443") && + url.pathname === "/ai/qc/gen" && + url.searchParams.has("scode") + ) { + return raw + } + } catch { + // Ignore partial output until a complete official URL is present. + } + } + return undefined +} + +export function redactWecomCliOutput(output: string, code?: number | null, signal?: NodeJS.Signals | null): string { + const redacted = output + .replaceAll(/https:\/\/[^\s"'<>]+/gu, "[authorization-url]") + .replaceAll(/("?(?:secret|bot_secret|access_token|refresh_token|scode)"?\s*[:=]\s*)[^\s,}\]]+/giu, "$1[redacted]") + .replaceAll(/(Secret\s*[::]\s*)\S+/giu, "$1[redacted]") + .trim() + const suffix = code === undefined ? "" : ` (exit ${code ?? "null"}${signal ? `, ${signal}` : ""})` + return `${redacted || "WeCom CLI command failed"}${suffix}` +} + +function errorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + logDiagnostic("wecom-cli", "WeCom CLI operation failed", { error: redactWecomCliOutput(message) }, "warn") + return redactWecomCliOutput(message) +} diff --git a/electron/main.ts b/electron/main.ts index 2eaee15b..86c46175 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -24,16 +24,20 @@ import { AgentRefreshScheduler } from "./agent-refresh-scheduler.ts" import { ooBinaryName, larkCliBinaryName, + wecomCliBinaryName, opencodeBinaryName, resolveBundledBin, resolveBundledSkillsDir, resolveBundledLarkSkillsDir, + resolveBundledWecomSkillsDir, resolveBundledToolRuntimePath, resolveDevBundledSkillsDir, resolveDevBundledLarkSkillsDir, + resolveDevBundledWecomSkillsDir, resolveDevBundledToolRuntimePath, resolveDevOoBin, resolveDevLarkCliBin, + resolveDevWecomCliBin, resolveDevOpencodeBin, } from "./agent/binaries.ts" import { AgentManager } from "./agent/manager.ts" @@ -69,6 +73,7 @@ import { GitServiceImpl } from "./git/node.ts" import { KnowledgeServiceImpl } from "./knowledge/node.ts" import { LarkCliManager } from "./link-runtime/lark-cli.ts" import { LinkRuntimeManager, LinkRuntimeServiceImpl } from "./link-runtime/node.ts" +import { WecomCliManager } from "./link-runtime/wecom-cli.ts" import { isAudioOnlyMediaRequest, isTrustedRendererUrl } from "./media-permission-policy.ts" import { ModelCredentialStore } from "./models/credential-store.ts" import { ModelsServiceImpl } from "./models/node.ts" @@ -156,6 +161,9 @@ const ooBinPath = app.isPackaged ? resolveBundledBin(process.resourcesPath, ooBi const bundledLarkCliBinPath = app.isPackaged ? resolveBundledBin(process.resourcesPath, larkCliBinaryName()) : resolveDevLarkCliBin(appRoot) +const bundledWecomCliBinPath = app.isPackaged + ? resolveBundledBin(process.resourcesPath, wecomCliBinaryName()) + : resolveDevWecomCliBin(appRoot) process.env.OO_CLI_PATH = ooBinPath // 内置 skill 源目录:生产从打包 Resources/skills,dev 从 resources/skills(postinstall 导出)。 // AgentManager 启动时拷进 OpenCode workspace 的 .opencode/skill/,使 agent 直接读到。 @@ -165,6 +173,9 @@ const bundledSkillsDir = app.isPackaged const bundledLarkSkillsDir = app.isPackaged ? resolveBundledLarkSkillsDir(process.resourcesPath) : resolveDevBundledLarkSkillsDir(appRoot) +const bundledWecomSkillsDir = app.isPackaged + ? resolveBundledWecomSkillsDir(process.resourcesPath) + : resolveDevBundledWecomSkillsDir(appRoot) const bundledToolRuntimePath = app.isPackaged ? resolveBundledToolRuntimePath(process.resourcesPath) : resolveDevBundledToolRuntimePath(appRoot) @@ -290,7 +301,14 @@ const larkCliManager = new LarkCliManager({ openExternalUrl, rootDir: path.join(app.getPath("userData"), "lark-cli"), }) -const linkRuntimeService = new LinkRuntimeServiceImpl(linkRuntimeManager, larkCliManager) +const wecomCliManager = new WecomCliManager({ + binaryPath: bundledWecomCliBinPath, + onRuntimeChanged: () => agentRefreshScheduler.schedule("WeCom CLI connection changed", 0), + openExternalUrl, + rootDir: path.join(app.getPath("userData"), "wecom-cli"), + skillsDir: bundledWecomSkillsDir, +}) +const linkRuntimeService = new LinkRuntimeServiceImpl(linkRuntimeManager, larkCliManager, wecomCliManager) const authService = new AuthServiceImpl(authManager) const skillService = new SkillServiceImpl(authManager, { onRuntimeSkillsChanged: (reason) => agentRefreshScheduler.schedule(reason), @@ -709,6 +727,7 @@ async function applyAuthAccountNow(account: AuthRuntimeAccount | null): Promise< return } const larkCliRuntime = await larkCliManager.activeRuntime() + const wecomCliRuntime = await wecomCliManager.activeRuntime() const nextAgent = new AgentManager({ browserControl: browserControlConnection, defaultModel: runtime.defaultModel, @@ -723,10 +742,16 @@ async function applyAuthAccountNow(account: AuthRuntimeAccount | null): Promise< .filter((item) => item.status === "active") .map((item) => item.service), bundledSkillsDir, - bundledLarkSkillsDir: larkCliRuntime?.skillsDir ?? bundledLarkSkillsDir, + bundledDirectSkillsDirs: [ + larkCliRuntime?.skillsDir ?? bundledLarkSkillsDir, + wecomCliRuntime?.skillsDir ?? bundledWecomSkillsDir, + ], bundledToolRuntimePath, larkCliBinPath: larkCliRuntime?.binaryPath ?? bundledLarkCliBinPath, larkCliConfigDir: path.join(app.getPath("userData"), "lark-cli", "config"), + wecomCliBinPath: wecomCliRuntime?.binaryPath ?? bundledWecomCliBinPath, + wecomCliConfigDir: path.join(app.getPath("userData"), "wecom-cli", "config"), + wecomCliTmpDir: path.join(app.getPath("userData"), "wecom-cli", "tmp"), rootDir: path.join(app.getPath("userData"), "agent"), customModels: runtimeModels.customModels, }) diff --git a/package.json b/package.json index 3bb00b8b..df4d198e 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "auth:restore": "node --experimental-strip-types ./scripts/dev-auth-state.ts restore", "auth:clean": "node --experimental-strip-types ./scripts/dev-auth-state.ts clean", "auth:status": "node --experimental-strip-types ./scripts/dev-auth-state.ts status", - "postinstall": "node --experimental-strip-types ./scripts/download-electron.ts && node --experimental-strip-types ./scripts/download-oo.ts && node --experimental-strip-types ./scripts/download-skills.ts && node --experimental-strip-types ./scripts/download-ripgrep.ts && node --experimental-strip-types ./scripts/download-lark-cli.ts && node --experimental-strip-types ./scripts/build-agent-tool-runtime.ts", + "postinstall": "node --experimental-strip-types ./scripts/download-electron.ts && node --experimental-strip-types ./scripts/download-oo.ts && node --experimental-strip-types ./scripts/download-skills.ts && node --experimental-strip-types ./scripts/download-ripgrep.ts && node --experimental-strip-types ./scripts/download-lark-cli.ts && node --experimental-strip-types ./scripts/download-wecom-cli.ts && node --experimental-strip-types ./scripts/build-agent-tool-runtime.ts", "predev": "node --experimental-strip-types ./scripts/check-oo.ts", "dev": "node --experimental-strip-types ./scripts/dev.ts", "dev:worktree": "node --experimental-strip-types ./scripts/dev-worktree.ts", diff --git a/scripts/download-wecom-cli.ts b/scripts/download-wecom-cli.ts new file mode 100644 index 00000000..23bcb693 --- /dev/null +++ b/scripts/download-wecom-cli.ts @@ -0,0 +1,9 @@ +import { downloadWecomCliBinary, exportWecomCliSkills, WECOM_CLI_VERSION } from "./wecom-cli.ts" + +try { + const [binary, skills] = await Promise.all([downloadWecomCliBinary(), exportWecomCliSkills()]) + console.log(`[wanta] WeCom CLI ${WECOM_CLI_VERSION} ready: ${binary}`) + console.log(`[wanta] WeCom CLI skills ready: ${skills}`) +} catch (error) { + console.warn("[wanta] download-wecom-cli postinstall failed (non-fatal):", error) +} diff --git a/scripts/prepare-binaries.ts b/scripts/prepare-binaries.ts index efe9433d..3ebf21e6 100644 --- a/scripts/prepare-binaries.ts +++ b/scripts/prepare-binaries.ts @@ -14,6 +14,7 @@ import { downloadLarkCliBinary, exportLarkCliSkills, larkCliBinaryName } from ". import { downloadOoBinary, ooExecutableName } from "./oo-cli.ts" import { downloadRipgrepBinary, ripgrepExecutableName } from "./ripgrep.ts" import { bundledSkillsDir, exportBundledSkills } from "./skills.ts" +import { downloadWecomCliBinary, exportWecomCliSkills, wecomCliBinaryName } from "./wecom-cli.ts" const dirname = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.join(dirname, "..") @@ -50,6 +51,11 @@ bundle("Lark CLI", larkCliSrc, larkCliBinaryName()) await exportLarkCliSkills() console.log("[wanta] bundled Lark CLI skills") +const wecomCliSrc = await downloadWecomCliBinary() +bundle("WeCom CLI", wecomCliSrc, wecomCliBinaryName()) +await exportWecomCliSkills() +console.log("[wanta] bundled WeCom CLI skills") + // 内置 4 个 oo skill:导出到 resources/skills/,由 electron-builder extraResources 打入 Resources/skills, // 运行时拷进 OpenCode workspace 的 .opencode/skill/(见 electron/agent/workspace.ts)。 await exportBundledSkills() diff --git a/scripts/wecom-cli.test.ts b/scripts/wecom-cli.test.ts new file mode 100644 index 00000000..d410c083 --- /dev/null +++ b/scripts/wecom-cli.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" +import { resolveWecomCliTarget, wecomCliBinaryName } from "./wecom-cli.ts" + +describe("WeCom CLI build target", () => { + it("maps every officially supported desktop target", () => { + expect(resolveWecomCliTarget("darwin", "arm64")).toEqual({ + binaryName: "wecom-cli", + packageName: "@wecom/cli-darwin-arm64", + }) + expect(resolveWecomCliTarget("darwin", "x64").packageName).toBe("@wecom/cli-darwin-x64") + expect(resolveWecomCliTarget("linux", "arm64").packageName).toBe("@wecom/cli-linux-arm64") + expect(resolveWecomCliTarget("linux", "x64").packageName).toBe("@wecom/cli-linux-x64") + expect(resolveWecomCliTarget("win32", "x64")).toEqual({ + binaryName: "wecom-cli.exe", + packageName: "@wecom/cli-win32-x64", + }) + }) + + it("rejects unsupported targets", () => { + expect(() => resolveWecomCliTarget("win32", "arm64")).toThrow("No prebuilt WeCom CLI binary") + expect(() => resolveWecomCliTarget("linux", "riscv64")).toThrow("No prebuilt WeCom CLI binary") + expect(() => resolveWecomCliTarget("freebsd", "x64")).toThrow("No prebuilt WeCom CLI binary") + }) + + it("uses the platform executable name", () => { + expect(wecomCliBinaryName("win32")).toBe("wecom-cli.exe") + expect(wecomCliBinaryName("darwin")).toBe("wecom-cli") + }) +}) diff --git a/scripts/wecom-cli.ts b/scripts/wecom-cli.ts new file mode 100644 index 00000000..e704cd62 --- /dev/null +++ b/scripts/wecom-cli.ts @@ -0,0 +1,203 @@ +import { execFile } from "node:child_process" +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import { gunzipSync } from "node:zlib" +import { fetchWithRetry } from "./network-download.ts" +import { extractFileFromTar, verifyTarballIntegrity } from "./oo-cli.ts" + +const execFileAsync = promisify(execFile) +const dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.join(dirname, "..") +const maxDownloadBytes = 128 * 1024 * 1024 + +export const WECOM_CLI_VERSION = "0.1.9" +export const WECOM_CLI_GIT_HEAD = "72e14f7695f34d28f1ff23ea504ddd2210a87c13" +export const localWecomCliBinDir = path.join(repoRoot, ".wecom-cli-bin") +export const bundledWecomSkillsDir = path.join(repoRoot, "resources", "wecom-skills") + +interface WecomCliTarget { + binaryName: string + packageName: string +} + +interface PackageVersionMetadata { + dist?: { integrity?: string; tarball?: string } +} + +interface TarEntry { + data: Buffer + path: string + type: string +} + +export function wecomCliBinaryName(platform: NodeJS.Platform = process.platform): string { + return platform === "win32" ? "wecom-cli.exe" : "wecom-cli" +} + +export function resolveWecomCliTarget( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): WecomCliTarget { + const binaryName = wecomCliBinaryName(platform) + if (platform === "darwin" && (arch === "arm64" || arch === "x64")) { + return { binaryName, packageName: `@wecom/cli-darwin-${arch}` } + } + if (platform === "linux" && (arch === "arm64" || arch === "x64")) { + return { binaryName, packageName: `@wecom/cli-linux-${arch}` } + } + if (platform === "win32" && arch === "x64") { + return { binaryName, packageName: "@wecom/cli-win32-x64" } + } + throw new Error(`No prebuilt WeCom CLI binary is available for ${platform} ${arch}.`) +} + +export function localWecomCliBinPath(platform: NodeJS.Platform = process.platform): string { + return path.join(localWecomCliBinDir, wecomCliBinaryName(platform)) +} + +async function fetchBytes(url: string): Promise { + const response = await fetchWithRetry(url) + if (!response.ok) throw new Error(`download WeCom CLI failed: HTTP ${response.status} ${url}`) + const length = Number(response.headers.get("content-length") ?? "0") + if (length > maxDownloadBytes) throw new Error(`WeCom CLI download exceeded ${maxDownloadBytes} bytes`) + const bytes = Buffer.from(await response.arrayBuffer()) + if (bytes.byteLength > maxDownloadBytes) throw new Error(`WeCom CLI download exceeded ${maxDownloadBytes} bytes`) + return bytes +} + +async function packageMetadata(packageName: string, version: string): Promise { + const response = await fetchWithRetry(`https://registry.npmjs.org/${packageName}`) + if (!response.ok) throw new Error(`fetch WeCom CLI package metadata failed: HTTP ${response.status} ${packageName}`) + const packument = (await response.json()) as { versions?: Record } + const metadata = packument.versions?.[version] + if (!metadata) throw new Error(`No npm metadata for ${packageName}@${version}`) + return metadata +} + +async function binaryReady(destination: string, marker: string, packageName: string): Promise { + try { + await stat(destination) + return (await readFile(marker, "utf-8")).trim() === `${packageName}@${WECOM_CLI_VERSION}` + } catch { + return false + } +} + +export async function downloadWecomCliBinary(): Promise { + const target = resolveWecomCliTarget() + const destination = localWecomCliBinPath() + const marker = path.join(localWecomCliBinDir, ".version") + if (await binaryReady(destination, marker, target.packageName)) return destination + + const metadata = await packageMetadata(target.packageName, WECOM_CLI_VERSION) + if (!metadata.dist?.tarball || !metadata.dist.integrity) { + throw new Error(`Incomplete npm dist metadata for ${target.packageName}@${WECOM_CLI_VERSION}`) + } + const archive = await fetchBytes(metadata.dist.tarball) + verifyTarballIntegrity(archive, metadata.dist.integrity, metadata.dist.tarball) + const binary = extractFileFromTar(gunzipSync(archive), `package/bin/${target.binaryName}`) + if (!binary) throw new Error(`WeCom CLI binary is missing from ${target.packageName}@${WECOM_CLI_VERSION}`) + + await mkdir(localWecomCliBinDir, { recursive: true }) + const temporary = `${destination}.download` + try { + await writeFile(temporary, binary) + await chmod(temporary, 0o755) + const result = await execFileAsync(temporary, ["--version"], { encoding: "utf-8", timeout: 10_000 }) + const reported = `${result.stdout}\n${result.stderr}`.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/u)?.[1] + if (reported !== WECOM_CLI_VERSION) { + throw new Error( + `Downloaded WeCom CLI reports ${reported ?? "an unreadable version"}; expected ${WECOM_CLI_VERSION}`, + ) + } + await rename(temporary, destination) + } finally { + await rm(temporary, { force: true }) + } + await writeFile(marker, `${target.packageName}@${WECOM_CLI_VERSION}\n`, "utf-8") + return destination +} + +function tarString(header: Buffer, start: number, length: number): string { + const value = header.subarray(start, start + length) + const nul = value.indexOf(0) + return value.toString("utf-8", 0, nul === -1 ? length : nul) +} + +function tarEntries(tar: Buffer): TarEntry[] { + const entries: TarEntry[] = [] + let offset = 0 + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512) + if (header.every((byte) => byte === 0)) break + const name = tarString(header, 0, 100) + const prefix = tarString(header, 345, 155) + const entryPath = prefix ? `${prefix}/${name}` : name + const size = Number.parseInt(tarString(header, 124, 12).trim() || "0", 8) + const dataStart = offset + 512 + if (!Number.isSafeInteger(size) || size < 0 || dataStart + size > tar.length) { + throw new Error(`Invalid or truncated WeCom CLI source archive entry: ${entryPath}`) + } + entries.push({ + data: tar.subarray(dataStart, dataStart + size), + path: entryPath, + type: String.fromCharCode(header[156] ?? 0), + }) + offset = dataStart + Math.ceil(size / 512) * 512 + } + return entries +} + +function safeSkillPath(value: string): boolean { + if (!value || path.isAbsolute(value)) return false + const segments = value.split(/[\\/]/u) + return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..") +} + +export async function exportWecomCliSkills(outputRoot: string = bundledWecomSkillsDir): Promise { + const commit = WECOM_CLI_GIT_HEAD + const expectedMarker = `${WECOM_CLI_VERSION}@${commit}` + try { + if ((await readFile(path.join(outputRoot, ".version"), "utf-8")).trim() === expectedMarker) return outputRoot + } catch { + // Missing or stale exports are rebuilt below. + } + + const archive = await fetchBytes(`https://codeload.github.com/WecomTeam/wecom-cli/tar.gz/${commit}`) + const entries = tarEntries(gunzipSync(archive)) + const skillFiles = entries.flatMap((entry) => { + const marker = "/skills/" + const markerIndex = entry.path.indexOf(marker) + if (markerIndex === -1 || (entry.type !== "0" && entry.type !== "\0")) return [] + const relative = entry.path.slice(markerIndex + marker.length) + const skillName = relative.split("/")[0] + if (!skillName || !/^wecomcli-[a-z0-9-]+$/u.test(skillName) || !safeSkillPath(relative)) return [] + return [{ data: entry.data, relative, skillName }] + }) + const skillNames = new Set(skillFiles.map((entry) => entry.skillName)) + for (const skillName of skillNames) { + if (!skillFiles.some((entry) => entry.relative === `${skillName}/SKILL.md`)) { + throw new Error(`Official WeCom skill ${skillName} has no SKILL.md`) + } + } + if (skillNames.size === 0) throw new Error("Official WeCom CLI source contains no wecomcli-* skills") + + const staging = `${outputRoot}.staging` + await rm(staging, { force: true, recursive: true }) + await mkdir(staging, { recursive: true }) + try { + for (const entry of skillFiles) { + const destination = path.join(staging, entry.relative) + await mkdir(path.dirname(destination), { recursive: true }) + await writeFile(destination, entry.data) + } + await writeFile(path.join(staging, ".version"), `${expectedMarker}\n`, "utf-8") + await rm(outputRoot, { force: true, recursive: true }) + await rename(staging, outputRoot) + } finally { + await rm(staging, { force: true, recursive: true }) + } + return outputRoot +} diff --git a/src/assets/apps/wecom.svg b/src/assets/apps/wecom.svg new file mode 100644 index 00000000..e6e2714b --- /dev/null +++ b/src/assets/apps/wecom.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/src/hooks/useLarkCliConnection.ts b/src/hooks/useLarkCliConnection.ts index 1e2b4908..26f7aef6 100644 --- a/src/hooks/useLarkCliConnection.ts +++ b/src/hooks/useLarkCliConnection.ts @@ -43,6 +43,7 @@ export function larkCliProviderFromState( authTypes: ["oauth2"], actionKind: state.available ? "oauth2" : "unavailable", canDisconnect: Boolean(app), + canReconnect: true, categoryLabels: ["Communication", "Documentation", "Productivity"], connectedUpdatedAt: app?.updatedAt, description: copy.description, diff --git a/src/hooks/useWecomCliConnection.test.ts b/src/hooks/useWecomCliConnection.test.ts new file mode 100644 index 00000000..2480347e --- /dev/null +++ b/src/hooks/useWecomCliConnection.test.ts @@ -0,0 +1,36 @@ +import type { WecomCliState } from "../../electron/link-runtime/common.ts" + +import { describe, expect, it } from "vitest" +import { wecomCliProviderFromState } from "./useWecomCliConnection.ts" + +const copy = { + connectActionLabel: "Scan to connect", + connectionMethodLabel: "WeCom QR code", + description: "WeCom tools", + displayName: "WeCom CLI", +} + +describe("WeCom CLI provider model", () => { + it("uses provider-specific QR-code copy without exposing OAuth in the provider model", () => { + const state: WecomCliState = { + accountLabel: "bot-id", + activeVersion: "0.1.9", + available: true, + canReopenAuthorization: false, + connection: "connected", + phase: "idle", + } + const provider = wecomCliProviderFromState(state, copy, 123) + + expect(provider).toMatchObject({ + connectActionLabel: "Scan to connect", + connectedUpdatedAt: 123, + connectionMethodLabel: "WeCom QR code", + executionMode: "direct", + runtimeVersion: "0.1.9", + service: "wecom-cli", + status: "connected", + }) + expect(provider.apps[0]?.id).toBe("direct:wecom-cli:default") + }) +}) diff --git a/src/hooks/useWecomCliConnection.ts b/src/hooks/useWecomCliConnection.ts new file mode 100644 index 00000000..9c84bbd1 --- /dev/null +++ b/src/hooks/useWecomCliConnection.ts @@ -0,0 +1,158 @@ +import type { + ConnectionAppSummary, + ConnectionProviderDetail, + ConnectionProviderSummary, +} from "../../electron/connections/common.ts" +import type { WecomCliState } from "../../electron/link-runtime/common.ts" + +import * as React from "react" +import { useLinkRuntimeService } from "../components/AppContext.ts" +import { resolveConnectionError } from "../lib/connections-error.ts" +import wecomIconUrl from "@/assets/apps/wecom.svg" + +export const wecomCliService = "wecom-cli" + +function appFromState(state: WecomCliState, connectedUpdatedAt?: number): ConnectionAppSummary | null { + if (state.connection === "disconnected") return null + const timestamp = connectedUpdatedAt ?? Date.now() + return { + accountLabel: state.accountLabel, + authType: "oauth2", + connectionName: "default", + createdAt: timestamp, + displayName: state.accountLabel, + id: "direct:wecom-cli:default", + isDefault: true, + service: wecomCliService, + status: "active", + updatedAt: timestamp, + } +} + +export function wecomCliProviderFromState( + state: WecomCliState, + copy: { connectActionLabel: string; connectionMethodLabel: string; description: string; displayName: string }, + connectedUpdatedAt?: number, +): ConnectionProviderSummary { + const app = appFromState(state, connectedUpdatedAt) + return { + accountLabel: state.accountLabel, + actionKind: state.available ? "oauth2" : "unavailable", + appAuthType: "oauth2", + appCount: app ? 1 : 0, + apps: app ? [app] : [], + authTypes: ["oauth2"], + canDisconnect: Boolean(app), + categoryLabels: ["Communication", "Documentation", "Productivity"], + connectActionLabel: copy.connectActionLabel, + connectedUpdatedAt: app?.updatedAt, + connectionMethodLabel: copy.connectionMethodLabel, + description: copy.description, + displayName: copy.displayName, + executionMode: "direct", + iconUrl: wecomIconUrl, + runtimeVersion: state.activeVersion ?? undefined, + service: wecomCliService, + status: state.connection === "connected" ? "connected" : "available", + } +} + +export function wecomCliProviderDetail(provider: ConnectionProviderSummary): ConnectionProviderDetail { + return { + ...provider, + apiKeyConfig: null, + customCredentialConfig: null, + federatedCredentialConfig: null, + homepageUrl: "https://github.com/WecomTeam/wecom-cli", + oauthClientConfig: null, + } +} + +export function useWecomCliConnection() { + const linkRuntimeService = useLinkRuntimeService() + const [state, setState] = React.useState(null) + const [error, setError] = React.useState | null>(null) + const cancelledOperationRef = React.useRef<"connect" | null>(null) + const connectionRef = React.useRef(undefined) + const connectedUpdatedAtRef = React.useRef(undefined) + const acceptState = React.useCallback((next: WecomCliState) => { + if (next.connection === "connected" && connectionRef.current !== "connected") { + connectedUpdatedAtRef.current = Date.now() + } else if (next.connection === "disconnected") { + connectedUpdatedAtRef.current = undefined + } + connectionRef.current = next.connection + setState(next) + }, []) + + React.useEffect(() => { + let active = true + void linkRuntimeService + .invoke("getWecomCliState") + .then((next) => { + if (active) acceptState(next) + }) + .catch((cause: unknown) => { + if (active) setError(resolveConnectionError(cause, "summary")) + }) + const unsubscribe = linkRuntimeService.serverEvents.on("wecomCliChanged", (next) => { + if (active) acceptState(next) + }) + return () => { + active = false + unsubscribe() + } + }, [acceptState, linkRuntimeService]) + + const mutate = React.useCallback( + async (method: "connectWecomCli" | "disconnectWecomCli") => { + const operation = method === "connectWecomCli" ? "connect" : "disconnect" + if (operation === "connect") cancelledOperationRef.current = null + setError(null) + try { + const next = await linkRuntimeService.invoke(method) + acceptState(next) + return true + } catch (cause) { + if (cancelledOperationRef.current !== operation) { + setError(resolveConnectionError(cause, operation)) + } + if (cancelledOperationRef.current === operation) cancelledOperationRef.current = null + return false + } + }, + [acceptState, linkRuntimeService], + ) + + const cancel = React.useCallback(() => { + cancelledOperationRef.current = "connect" + setError(null) + return linkRuntimeService.invoke("cancelWecomCliConnection").catch((cause: unknown) => { + cancelledOperationRef.current = null + setError(resolveConnectionError(cause, "connect")) + }) + }, [linkRuntimeService]) + const connect = React.useCallback(() => mutate("connectWecomCli"), [mutate]) + const disconnect = React.useCallback(() => mutate("disconnectWecomCli"), [mutate]) + const reopenAuthorization = React.useCallback( + () => linkRuntimeService.invoke("reopenWecomCliAuthorization").then(() => undefined), + [linkRuntimeService], + ) + const stateError = React.useMemo( + () => (state?.error ? resolveConnectionError(new Error(state.error), "summary") : null), + [state?.error], + ) + + return React.useMemo( + () => ({ + cancel, + connect, + connectedUpdatedAt: connectedUpdatedAtRef.current, + disconnect, + error: error ?? stateError, + reopenAuthorization, + state, + }), + [cancel, connect, disconnect, error, reopenAuthorization, state, stateError], + ) +} diff --git a/src/i18n/app-messages.en.ts b/src/i18n/app-messages.en.ts index de3db584..b269ecbe 100644 --- a/src/i18n/app-messages.en.ts +++ b/src/i18n/app-messages.en.ts @@ -1146,6 +1146,17 @@ export const enMessages = { "connections.larkCli.phase.authorizing": "Waiting for authorization", "connections.larkCli.phase.verifying": "Verifying the login", "connections.larkCli.phase.disconnecting": "Disconnecting", + "connections.wecomCli.name": "WeCom CLI", + "connections.wecomCli.description": + "Scan with WeCom to connect a bot for contacts, messages, schedules, todos, meetings, Docs, and Smart Sheets. The official CLI currently limits access to organizations with up to 10 members.", + "connections.wecomCli.connectAction": "Scan to connect", + "connections.wecomCli.reopenAuthorization": "Open QR code again", + "connections.wecomCli.connectionMethod": "WeCom QR code", + "connections.wecomCli.phase.idle": "Scan to connect", + "connections.wecomCli.phase.preparing": "Preparing the WeCom CLI", + "connections.wecomCli.phase.waiting_for_scan": "Waiting for a WeCom scan", + "connections.wecomCli.phase.verifying": "Verifying the bot connection", + "connections.wecomCli.phase.disconnecting": "Disconnecting WeCom CLI", "connections.selfHosted.title": "Connect a self-hosted OpenConnector", "connections.selfHosted.description": "Choose and configure a Link runtime in Settings to make connector tools available to the agent.", diff --git a/src/i18n/app-messages.zh.ts b/src/i18n/app-messages.zh.ts index 7068899f..b6a2159c 100644 --- a/src/i18n/app-messages.zh.ts +++ b/src/i18n/app-messages.zh.ts @@ -1096,6 +1096,17 @@ export const zhCNMessages = { "connections.larkCli.phase.authorizing": "等待授权完成", "connections.larkCli.phase.verifying": "正在验证登录状态", "connections.larkCli.phase.disconnecting": "正在断开连接", + "connections.wecomCli.name": "企业微信 CLI", + "connections.wecomCli.description": + "使用企业微信扫码接入机器人,可使用通讯录、消息、日程、待办、会议、文档和智能表格等能力。官方 CLI 当前仅向不超过 10 人的企业开放。", + "connections.wecomCli.connectAction": "扫码接入", + "connections.wecomCli.reopenAuthorization": "重新打开二维码", + "connections.wecomCli.connectionMethod": "企业微信扫码", + "connections.wecomCli.phase.idle": "扫码接入", + "connections.wecomCli.phase.preparing": "正在准备企业微信 CLI", + "connections.wecomCli.phase.waiting_for_scan": "等待企业微信扫码确认", + "connections.wecomCli.phase.verifying": "正在验证机器人连接", + "connections.wecomCli.phase.disconnecting": "正在断开企业微信 CLI", "connections.selfHosted.title": "连接自部署的 OpenConnector", "connections.selfHosted.description": "请先在设置中选择并配置 Link 运行时,Agent 才能使用连接器工具。", "connections.selfHosted.openSettings": "打开 Link 运行时设置", diff --git a/src/routes/Connections/ConnectionAccountsList.tsx b/src/routes/Connections/ConnectionAccountsList.tsx index c2131f09..38939233 100644 --- a/src/routes/Connections/ConnectionAccountsList.tsx +++ b/src/routes/Connections/ConnectionAccountsList.tsx @@ -109,10 +109,14 @@ function ConnectionAccountItem({ const [aliasEditing, setAliasEditing] = React.useState(false) const [aliasBusy, setAliasBusy] = React.useState(false) const reconnectAuthType = - app.authType && app.authType !== "no_auth" && isConnectionAuthType(app.authType, provider.authTypes) + (provider.executionMode !== "direct" || provider.canReconnect === true) && + app.authType && + app.authType !== "no_auth" && + isConnectionAuthType(app.authType, provider.authTypes) ? app.authType : null - const authLabel = app.authType ? authTypeLabel(t, app.authType) : t("connections.authUnknown") + const authLabel = + provider.connectionMethodLabel ?? (app.authType ? authTypeLabel(t, app.authType) : t("connections.authUnknown")) const accountLabel = getConnectionAppDisplayLabel(app, index, t) const connectedAccount = app.accountLabel?.trim() || app.providerAccountId?.trim() || "" const aliasValue = aliasDraft.trim() diff --git a/src/routes/Connections/ConnectionProviderDetailPane.tsx b/src/routes/Connections/ConnectionProviderDetailPane.tsx index 9ba47f45..18ac17e2 100644 --- a/src/routes/Connections/ConnectionProviderDetailPane.tsx +++ b/src/routes/Connections/ConnectionProviderDetailPane.tsx @@ -85,8 +85,10 @@ export function ProviderDetail({ onClose, onConnect, onDisconnect, + onReopenPolling, polling, progressLabel, + reopenPollingLabel, provider, showCloseButton = false, }: { @@ -107,8 +109,10 @@ export function ProviderDetail({ appId?: string, ) => Promise onDisconnect: (target: DisconnectTarget) => void + onReopenPolling?: () => void polling: string | null progressLabel?: string + reopenPollingLabel?: string provider: ConnectionProviderSummary showCloseButton?: boolean }) { @@ -172,8 +176,10 @@ export function ProviderDetail({ onCancelPolling={onCancelPolling} onConnect={onConnect} onDisconnect={onDisconnect} + onReopenPolling={onReopenPolling} polling={polling} progressLabel={progressLabel} + reopenPollingLabel={reopenPollingLabel} provider={provider} /> )} @@ -187,7 +193,10 @@ export function ProviderDetail({
{direct ? : null} {directlyAvailable ? null : } - + {provider.runtimeVersion ? ( ) : null} @@ -272,8 +281,10 @@ function ConnectionPanel({ onCancelPolling, onConnect, onDisconnect, + onReopenPolling, polling, progressLabel, + reopenPollingLabel, provider, }: { actionsPending?: boolean @@ -290,8 +301,10 @@ function ConnectionPanel({ appId?: string, ) => Promise onDisconnect: (target: DisconnectTarget) => void + onReopenPolling?: () => void polling: string | null progressLabel?: string + reopenPollingLabel?: string provider: ConnectionProviderSummary }) { const t = useT() @@ -358,6 +371,11 @@ function ConnectionPanel({ {progressLabel ?? t("connections.oauthWaiting")} + {onReopenPolling && reopenPollingLabel ? ( + + ) : null} @@ -389,7 +407,7 @@ function ConnectionPanel({ {authIntent ? t("connections.connectAndContinue") : direct - ? t("connections.connectDirectProvider") + ? (provider.connectActionLabel ?? t("connections.connectDirectProvider")) : directlyAvailable ? t("connections.configureAuth", { auth: formatAuthTypes([activeAuthType], t) }) : provider.apps.length > 0 diff --git a/src/routes/Connections/index.tsx b/src/routes/Connections/index.tsx index c41e7552..a188c22c 100644 --- a/src/routes/Connections/index.tsx +++ b/src/routes/Connections/index.tsx @@ -46,6 +46,12 @@ import { } from "@/components/ui/split-view" import { isConnectionServicePollingTarget } from "@/hooks/connection-oauth-pending" import { larkCliProviderDetail, larkCliProviderFromState, useLarkCliConnection } from "@/hooks/useLarkCliConnection" +import { + useWecomCliConnection, + wecomCliProviderDetail, + wecomCliProviderFromState, + wecomCliService, +} from "@/hooks/useWecomCliConnection" import { useT } from "@/i18n/i18n" import { getOAuthClientConfig } from "@/lib/connections-client" import { userFacingErrorDescription } from "@/lib/user-facing-error" @@ -91,6 +97,7 @@ export function ConnectionsPanel({ summaryError, } = connections const larkCli = useLarkCliConnection() + const wecomCli = useWecomCliConnection() const [query, setQuery] = React.useState("") const [activeFilter, setActiveFilter] = React.useState(requestedFilter ?? { kind: "all" }) const [selectedProviderService, setSelectedProviderService] = React.useState(null) @@ -124,12 +131,31 @@ export function ConnectionsPanel({ : null, [larkCli.connectedUpdatedAt, larkCli.state, t], ) + const wecomCliProvider = React.useMemo( + () => + wecomCli.state + ? wecomCliProviderFromState( + wecomCli.state, + { + connectActionLabel: t("connections.wecomCli.connectAction"), + connectionMethodLabel: t("connections.wecomCli.connectionMethod"), + description: t("connections.wecomCli.description"), + displayName: t("connections.wecomCli.name"), + }, + wecomCli.connectedUpdatedAt, + ) + : null, + [t, wecomCli.connectedUpdatedAt, wecomCli.state], + ) const providers = React.useMemo( () => [ - ...(summary?.providers ?? []).filter((provider) => provider.service !== "lark-cli"), + ...(summary?.providers ?? []).filter( + (provider) => provider.service !== "lark-cli" && provider.service !== wecomCliService, + ), ...(larkCliProvider ? [larkCliProvider] : []), + ...(wecomCliProvider ? [wecomCliProvider] : []), ], - [larkCliProvider, summary?.providers], + [larkCliProvider, summary?.providers, wecomCliProvider], ) const deferredQuery = React.useDeferredValue(query) const normalizedQuery = deferredQuery.trim().toLowerCase() @@ -155,7 +181,13 @@ export function ConnectionsPanel({ const selectedProvider = selectedProviderService ? (filteredProviders.find((provider) => provider.service === selectedProviderService) ?? null) : null - const selectedProviderIsDirect = selectedProvider?.executionMode === "direct" + const selectedDirectService = + selectedProvider?.service === "lark-cli" || selectedProvider?.service === wecomCliService + ? selectedProvider.service + : null + const selectedDirectCli = + selectedDirectService === "lark-cli" ? larkCli : selectedDirectService === wecomCliService ? wecomCli : null + const selectedProviderIsDirect = selectedDirectCli !== null const selectedProviderActionsEnabled = selectedProviderIsDirect ? true : connectionActionsEnabled const providerDetail = useConnectionProviderDetail({ enabled: selectedProviderActionsEnabled, @@ -164,9 +196,13 @@ export function ConnectionsPanel({ workspaceKey: summaryWorkspaceKey, }) const selectedProviderDetail = - selectedProviderIsDirect && selectedProvider ? larkCliProviderDetail(selectedProvider) : providerDetail.detail + selectedProviderIsDirect && selectedProvider + ? selectedDirectService === "lark-cli" + ? larkCliProviderDetail(selectedProvider) + : wecomCliProviderDetail(selectedProvider) + : providerDetail.detail const selectedProviderDetailLoading = selectedProviderIsDirect ? false : providerDetail.loading - const selectedProviderDetailError = selectedProviderIsDirect ? larkCli.error : providerDetail.error + const selectedProviderDetailError = selectedProviderIsDirect ? selectedDirectCli.error : providerDetail.error const selectedProviderActionsBlocked = Boolean( !selectedProviderActionsEnabled || (selectedProviderIsDirect && selectedProvider?.actionKind === "unavailable") || @@ -178,7 +214,7 @@ export function ConnectionsPanel({ ) const detailErrorNotice = selectedProvider ? getConnectionDetailErrorNotice({ - actionError: selectedProviderIsDirect ? larkCli.error : actionError, + actionError: selectedProviderIsDirect ? selectedDirectCli.error : actionError, detailError: selectedProviderDetailError, }) : null @@ -188,16 +224,43 @@ export function ConnectionsPanel({ : larkCli.state && larkCli.state.phase !== "idle" ? "connect" : null - const selectedProviderBusy = selectedProviderIsDirect ? larkCliBusy : busy + const wecomCliBusy: UseConnections["busy"] = + wecomCli.state?.phase === "disconnecting" + ? "disconnect" + : wecomCli.state && wecomCli.state.phase !== "idle" + ? "connect" + : null + const selectedProviderBusy = selectedProviderIsDirect + ? selectedDirectService === "lark-cli" + ? larkCliBusy + : wecomCliBusy + : busy + const confirmDisconnectBusy = + confirmDisconnect?.provider.service === "lark-cli" + ? larkCliBusy + : confirmDisconnect?.provider.service === wecomCliService + ? wecomCliBusy + : busy const selectedProviderPolling = selectedProviderIsDirect - ? larkCli.state && larkCli.state.phase !== "idle" && larkCli.state.phase !== "disconnecting" - ? "lark-cli" + ? selectedDirectCli.state && + selectedDirectCli.state.phase !== "idle" && + selectedDirectCli.state.phase !== "disconnecting" + ? selectedDirectService : null : polling const selectedProviderProgressLabel = selectedProviderIsDirect - ? t(`connections.larkCli.phase.${larkCli.state?.phase ?? "idle"}`) + ? selectedDirectService === "lark-cli" + ? t(`connections.larkCli.phase.${larkCli.state?.phase ?? "idle"}`) + : t(`connections.wecomCli.phase.${wecomCli.state?.phase ?? "idle"}`) + : undefined + const cancelSelectedProviderPolling = selectedProviderIsDirect ? selectedDirectCli.cancel : cancelPolling + const reopenSelectedProviderPolling = + selectedDirectService === wecomCliService && wecomCli.state?.canReopenAuthorization + ? wecomCli.reopenAuthorization + : undefined + const reopenSelectedProviderPollingLabel = reopenSelectedProviderPolling + ? t("connections.wecomCli.reopenAuthorization") : undefined - const cancelSelectedProviderPolling = selectedProviderPolling === "lark-cli" ? larkCli.cancel : cancelPolling const summaryLoading = busy === "refresh" && !summary const listErrorNotice = getConnectionListErrorNotice({ summaryError, detailError: detailErrorNotice?.error ?? null }) const deleteCachedDetailForService = providerDetail.invalidate @@ -306,7 +369,8 @@ export function ConnectionsPanel({ if ( filteredProviders.some((provider) => provider.service === selectedProviderService) || - (selectedProviderService === "lark-cli" && !larkCliProvider) + (selectedProviderService === "lark-cli" && !larkCliProvider) || + (selectedProviderService === wecomCliService && !wecomCliProvider) ) { return } @@ -315,7 +379,7 @@ export function ConnectionsPanel({ setSelectedProviderService(null) setDetailPaneClosing(false) setNarrowPane("list") - }, [clearDetailCloseTimer, filteredProviders, larkCliProvider, selectedProviderService]) + }, [clearDetailCloseTimer, filteredProviders, larkCliProvider, selectedProviderService, wecomCliProvider]) const connectProvider = React.useCallback( async ( @@ -324,7 +388,10 @@ export function ConnectionsPanel({ appId?: string, ): Promise => { if (provider.executionMode === "direct") { - const ok = await larkCli.connect() + const directCli = + provider.service === "lark-cli" ? larkCli : provider.service === wecomCliService ? wecomCli : null + if (!directCli) return + const ok = await directCli.connect() if (ok) { onConnectionReady?.({ service: provider.service, connectionName: "default" }) } @@ -418,6 +485,7 @@ export function ConnectionsPanel({ onConnectionReady, polling, providerDetail, + wecomCli, ], ) @@ -470,7 +538,11 @@ export function ConnectionsPanel({ connectionActionRequestIdRef.current = requestId const ok = target.provider.executionMode === "direct" - ? await larkCli.disconnect() + ? target.provider.service === "lark-cli" + ? await larkCli.disconnect() + : target.provider.service === wecomCliService + ? await wecomCli.disconnect() + : false : target.app ? await disconnectAccount(target.app.id) : await disconnect(target.provider.service) @@ -482,7 +554,7 @@ export function ConnectionsPanel({ setConfirmDisconnect(null) } }, - [connectionActionsEnabled, deleteCachedDetailForService, disconnect, disconnectAccount, larkCli], + [connectionActionsEnabled, deleteCachedDetailForService, disconnect, disconnectAccount, larkCli, wecomCli], ) if (presentation === "drawer") { @@ -503,8 +575,10 @@ export function ConnectionsPanel({ onClose={onClose ?? closeDetail} onConnect={connectProvider} onDisconnect={requestDisconnectTarget} + onReopenPolling={reopenSelectedProviderPolling} polling={selectedProviderPolling} progressLabel={selectedProviderProgressLabel} + reopenPollingLabel={reopenSelectedProviderPollingLabel} provider={selectedProvider} showCloseButton /> @@ -549,7 +623,7 @@ export function ConnectionsPanel({ /> setConfirmDisconnect(null)} onConfirm={confirmDisconnectTarget} /> @@ -631,8 +705,10 @@ export function ConnectionsPanel({ onClose={closeDetail} onConnect={connectProvider} onDisconnect={requestDisconnectTarget} + onReopenPolling={reopenSelectedProviderPolling} polling={selectedProviderPolling} progressLabel={selectedProviderProgressLabel} + reopenPollingLabel={reopenSelectedProviderPollingLabel} provider={selectedProvider} /> @@ -661,8 +737,10 @@ export function ConnectionsPanel({ onClose={closeDetail} onConnect={connectProvider} onDisconnect={requestDisconnectTarget} + onReopenPolling={reopenSelectedProviderPolling} polling={selectedProviderPolling} progressLabel={selectedProviderProgressLabel} + reopenPollingLabel={reopenSelectedProviderPollingLabel} provider={selectedProvider} /> @@ -684,7 +762,7 @@ export function ConnectionsPanel({ setConfirmDisconnect(null)} onConfirm={confirmDisconnectTarget} /> From ab37596756620c0babadd93c9e49da300e27a170 Mon Sep 17 00:00:00 2001 From: shaun Date: Mon, 3 Aug 2026 17:27:43 +0800 Subject: [PATCH 2/3] fix(wecom): address review feedback --- electron/link-runtime/wecom-cli.test.ts | 40 ++++++- electron/link-runtime/wecom-cli.ts | 5 +- scripts/wecom-cli.test.ts | 29 ++++- scripts/wecom-cli.ts | 20 +++- src/hooks/useLarkCliConnection.ts | 6 +- src/i18n/app-messages.en.ts | 2 +- src/i18n/app-messages.zh.ts | 2 +- src/routes/Connections/index.tsx | 153 +++++++++++++----------- 8 files changed, 174 insertions(+), 83 deletions(-) diff --git a/electron/link-runtime/wecom-cli.test.ts b/electron/link-runtime/wecom-cli.test.ts index a1267449..f65338fd 100644 --- a/electron/link-runtime/wecom-cli.test.ts +++ b/electron/link-runtime/wecom-cli.test.ts @@ -58,7 +58,9 @@ fi if [ "$1" = "auth" ]; then echo '{"id":"bot-123"}'; exit 0; fi if [ "$1" = "init" ]; then mkdir -p "$WECOM_CLI_CONFIG_DIR" - echo 'https://work.weixin.qq.com/ai/qc/gen?source=test&scode=temporary' + printf 'https://work.weixin.qq.com/ai/qc/gen?source=test&scode=tempor' + sleep 0.05 + echo 'ary' touch "$WECOM_CLI_CONFIG_DIR/authorized" exit 0 fi @@ -92,4 +94,40 @@ exit 1 await rm(base, { force: true, recursive: true }) } }) + + it("honors cancellation while checking existing authorization", async () => { + const base = await mkdtemp(path.join(os.tmpdir(), "wanta-wecom-cli-cancel-")) + try { + const binaryPath = path.join(base, "wecom-cli") + const rootDir = path.join(base, "private-runtime") + const skillsDir = path.join(base, "skills") + await mkdir(skillsDir) + await writeFile( + binaryPath, + `#!/bin/sh +if [ "$1" = "--version" ]; then echo "wecom-cli 0.1.9"; exit 0; fi +if [ "$1" = "auth" ] && [ "$3" = "--auth-status" ]; then sleep 0.1; echo unauthorized; exit 0; fi +if [ "$1" = "init" ]; then touch "$WECOM_CLI_CONFIG_DIR/init-started"; exit 1; fi +exit 1 +`, + "utf-8", + ) + await chmod(binaryPath, 0o755) + const manager = new WecomCliManager({ + binaryPath, + openExternalUrl: () => undefined, + rootDir, + skillsDir, + }) + + const connection = manager.connect() + await new Promise((resolve) => setTimeout(resolve, 25)) + manager.cancelConnection() + + await expect(connection).rejects.toThrow("cancelled") + await expect(stat(path.join(rootDir, "config", "init-started"))).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + await rm(base, { force: true, recursive: true }) + } + }) }) diff --git a/electron/link-runtime/wecom-cli.ts b/electron/link-runtime/wecom-cli.ts index 7634d99f..5e9b2966 100644 --- a/electron/link-runtime/wecom-cli.ts +++ b/electron/link-runtime/wecom-cli.ts @@ -169,6 +169,7 @@ export class WecomCliManager { if (!runtime) throw new Error("The bundled WeCom CLI runtime is unavailable.") await this.ensurePrivateDirectories() const current = await this.readAuthState() + this.assertNotCancelled() if (!current.connected) { this.setState({ phase: "waiting_for_scan" }) await this.runAuthorizationCommand() @@ -279,7 +280,9 @@ export class WecomCliManager { output += chunk.toString().slice(0, maxOutputBytes - Buffer.byteLength(output)) } if (!openedUrl) { - const url = findOfficialWecomAuthorizationUrl(output) + const lastNewline = output.lastIndexOf("\n") + const settledOutput = lastNewline === -1 ? "" : output.slice(0, lastNewline + 1) + const url = findOfficialWecomAuthorizationUrl(settledOutput) if (url) { openedUrl = true this.activeAuthorizationUrl = url diff --git a/scripts/wecom-cli.test.ts b/scripts/wecom-cli.test.ts index d410c083..b81fb694 100644 --- a/scripts/wecom-cli.test.ts +++ b/scripts/wecom-cli.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest" -import { resolveWecomCliTarget, wecomCliBinaryName } from "./wecom-cli.ts" +import { resolveWecomCliTarget, safeSkillPath, tarEntries, wecomCliBinaryName } from "./wecom-cli.ts" + +function tarHeader(name: string, size: number): Buffer { + const header = Buffer.alloc(512) + header.write(name, 0, 100, "utf-8") + header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, 12, "ascii") + header[156] = "0".charCodeAt(0) + return header +} describe("WeCom CLI build target", () => { it("maps every officially supported desktop target", () => { @@ -27,3 +35,22 @@ describe("WeCom CLI build target", () => { expect(wecomCliBinaryName("darwin")).toBe("wecom-cli") }) }) + +describe("WeCom CLI Skill archive", () => { + it("rejects traversal and absolute Skill paths", () => { + expect(safeSkillPath("wecomcli-doc/SKILL.md")).toBe(true) + expect(safeSkillPath("wecomcli-doc/../secret")).toBe(false) + expect(safeSkillPath("/absolute/SKILL.md")).toBe(false) + expect(safeSkillPath("C:\\absolute\\SKILL.md")).toBe(false) + }) + + it("rejects truncated and oversized tar entries", () => { + expect(() => tarEntries(tarHeader("truncated", 1))).toThrow("Invalid or truncated") + expect(() => tarEntries(tarHeader("oversized", Number.parseInt("77777777777", 8)))).toThrow("Invalid or truncated") + }) + + it("parses a complete padded tar entry", () => { + const archive = Buffer.concat([tarHeader("skill/SKILL.md", 4), Buffer.from("test"), Buffer.alloc(508)]) + expect(tarEntries(archive)).toEqual([{ data: Buffer.from("test"), path: "skill/SKILL.md", type: "0" }]) + }) +}) diff --git a/scripts/wecom-cli.ts b/scripts/wecom-cli.ts index e704cd62..f6eb62d9 100644 --- a/scripts/wecom-cli.ts +++ b/scripts/wecom-cli.ts @@ -24,6 +24,7 @@ interface WecomCliTarget { interface PackageVersionMetadata { dist?: { integrity?: string; tarball?: string } + gitHead?: string } interface TarEntry { @@ -95,6 +96,11 @@ export async function downloadWecomCliBinary(): Promise { if (!metadata.dist?.tarball || !metadata.dist.integrity) { throw new Error(`Incomplete npm dist metadata for ${target.packageName}@${WECOM_CLI_VERSION}`) } + if (metadata.gitHead !== WECOM_CLI_GIT_HEAD) { + throw new Error( + `${target.packageName}@${WECOM_CLI_VERSION} comes from ${metadata.gitHead ?? "an unknown commit"}; expected ${WECOM_CLI_GIT_HEAD}`, + ) + } const archive = await fetchBytes(metadata.dist.tarball) verifyTarballIntegrity(archive, metadata.dist.integrity, metadata.dist.tarball) const binary = extractFileFromTar(gunzipSync(archive), `package/bin/${target.binaryName}`) @@ -126,7 +132,7 @@ function tarString(header: Buffer, start: number, length: number): string { return value.toString("utf-8", 0, nul === -1 ? length : nul) } -function tarEntries(tar: Buffer): TarEntry[] { +export function tarEntries(tar: Buffer): TarEntry[] { const entries: TarEntry[] = [] let offset = 0 while (offset + 512 <= tar.length) { @@ -137,21 +143,23 @@ function tarEntries(tar: Buffer): TarEntry[] { const entryPath = prefix ? `${prefix}/${name}` : name const size = Number.parseInt(tarString(header, 124, 12).trim() || "0", 8) const dataStart = offset + 512 - if (!Number.isSafeInteger(size) || size < 0 || dataStart + size > tar.length) { + const dataEnd = dataStart + size + const nextOffset = dataStart + Math.ceil(size / 512) * 512 + if (!Number.isSafeInteger(size) || size < 0 || dataEnd > tar.length || nextOffset > tar.length) { throw new Error(`Invalid or truncated WeCom CLI source archive entry: ${entryPath}`) } entries.push({ - data: tar.subarray(dataStart, dataStart + size), + data: tar.subarray(dataStart, dataEnd), path: entryPath, type: String.fromCharCode(header[156] ?? 0), }) - offset = dataStart + Math.ceil(size / 512) * 512 + offset = nextOffset } return entries } -function safeSkillPath(value: string): boolean { - if (!value || path.isAbsolute(value)) return false +export function safeSkillPath(value: string): boolean { + if (!value || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) return false const segments = value.split(/[\\/]/u) return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..") } diff --git a/src/hooks/useLarkCliConnection.ts b/src/hooks/useLarkCliConnection.ts index 26f7aef6..81faea70 100644 --- a/src/hooks/useLarkCliConnection.ts +++ b/src/hooks/useLarkCliConnection.ts @@ -10,7 +10,7 @@ import { useLinkRuntimeService } from "../components/AppContext.ts" import { resolveConnectionError } from "../lib/connections-error.ts" import larkIconUrl from "@/assets/apps/lark.svg" -const service = "lark-cli" +export const larkCliService = "lark-cli" function appFromState(state: LarkCliState, connectedUpdatedAt?: number): ConnectionAppSummary | null { if (state.connection === "disconnected") return null @@ -23,7 +23,7 @@ function appFromState(state: LarkCliState, connectedUpdatedAt?: number): Connect displayName: state.accountLabel, id: "direct:lark-cli:default", isDefault: true, - service, + service: larkCliService, status: state.connection === "connected" ? "active" : "reauth_required", updatedAt: timestamp, } @@ -51,7 +51,7 @@ export function larkCliProviderFromState( executionMode: "direct", iconUrl: larkIconUrl, runtimeVersion: state.activeVersion ?? undefined, - service, + service: larkCliService, status: state.connection === "connected" ? "connected" : state.connection === "expired" ? "needs_attention" : "available", } diff --git a/src/i18n/app-messages.en.ts b/src/i18n/app-messages.en.ts index b269ecbe..67723b11 100644 --- a/src/i18n/app-messages.en.ts +++ b/src/i18n/app-messages.en.ts @@ -1148,7 +1148,7 @@ export const enMessages = { "connections.larkCli.phase.disconnecting": "Disconnecting", "connections.wecomCli.name": "WeCom CLI", "connections.wecomCli.description": - "Scan with WeCom to connect a bot for contacts, messages, schedules, todos, meetings, Docs, and Smart Sheets. The official CLI currently limits access to organizations with up to 10 members.", + "Scan with WeCom to connect a bot. Organizations with more than 10 members support Docs, Smart Sheets, and Smart Docs; teams with up to 10 members also support messages, schedules, meetings, and todos.", "connections.wecomCli.connectAction": "Scan to connect", "connections.wecomCli.reopenAuthorization": "Open QR code again", "connections.wecomCli.connectionMethod": "WeCom QR code", diff --git a/src/i18n/app-messages.zh.ts b/src/i18n/app-messages.zh.ts index b6a2159c..4a2c2391 100644 --- a/src/i18n/app-messages.zh.ts +++ b/src/i18n/app-messages.zh.ts @@ -1098,7 +1098,7 @@ export const zhCNMessages = { "connections.larkCli.phase.disconnecting": "正在断开连接", "connections.wecomCli.name": "企业微信 CLI", "connections.wecomCli.description": - "使用企业微信扫码接入机器人,可使用通讯录、消息、日程、待办、会议、文档和智能表格等能力。官方 CLI 当前仅向不超过 10 人的企业开放。", + "使用企业微信扫码接入机器人。10 人以上企业支持文档、智能表格和智能文档;10 人及以下团队还支持消息、日程、会议和待办等能力。", "connections.wecomCli.connectAction": "扫码接入", "connections.wecomCli.reopenAuthorization": "重新打开二维码", "connections.wecomCli.connectionMethod": "企业微信扫码", diff --git a/src/routes/Connections/index.tsx b/src/routes/Connections/index.tsx index a188c22c..27f9e0b0 100644 --- a/src/routes/Connections/index.tsx +++ b/src/routes/Connections/index.tsx @@ -45,7 +45,12 @@ import { SplitViewRoot, } from "@/components/ui/split-view" import { isConnectionServicePollingTarget } from "@/hooks/connection-oauth-pending" -import { larkCliProviderDetail, larkCliProviderFromState, useLarkCliConnection } from "@/hooks/useLarkCliConnection" +import { + larkCliProviderDetail, + larkCliProviderFromState, + larkCliService, + useLarkCliConnection, +} from "@/hooks/useLarkCliConnection" import { useWecomCliConnection, wecomCliProviderDetail, @@ -70,6 +75,19 @@ interface ConnectionsPanelProps { selectedService?: string | null } +interface DirectProviderBinding { + busy: UseConnections["busy"] + cancel: () => void + connect: () => Promise + detail: (provider: ConnectionProviderSummary) => ConnectionProviderDetail + disconnect: () => Promise + error: UseConnections["actionError"] + phase: string + phaseLabel: string + provider: ConnectionProviderSummary | null + reopenPolling?: () => void +} + export function ConnectionsPanel({ authIntent, canManageConnections, @@ -150,7 +168,7 @@ export function ConnectionsPanel({ const providers = React.useMemo( () => [ ...(summary?.providers ?? []).filter( - (provider) => provider.service !== "lark-cli" && provider.service !== wecomCliService, + (provider) => provider.service !== larkCliService && provider.service !== wecomCliService, ), ...(larkCliProvider ? [larkCliProvider] : []), ...(wecomCliProvider ? [wecomCliProvider] : []), @@ -178,16 +196,51 @@ export function ConnectionsPanel({ .filter((provider) => matchesProviderQuery(provider, normalizedQuery, t)) .sort(compareConnectionProvidersByRecommendation) }, [catalogProviders, normalizedQuery, t]) + const larkCliBusy: UseConnections["busy"] = + larkCli.state?.phase === "disconnecting" + ? "disconnect" + : larkCli.state && larkCli.state.phase !== "idle" + ? "connect" + : null + const wecomCliBusy: UseConnections["busy"] = + wecomCli.state?.phase === "disconnecting" + ? "disconnect" + : wecomCli.state && wecomCli.state.phase !== "idle" + ? "connect" + : null + const directProviderByService = React.useMemo>( + () => ({ + [larkCliService]: { + busy: larkCliBusy, + cancel: larkCli.cancel, + connect: larkCli.connect, + detail: larkCliProviderDetail, + disconnect: larkCli.disconnect, + error: larkCli.error, + phase: larkCli.state?.phase ?? "idle", + phaseLabel: t(`connections.larkCli.phase.${larkCli.state?.phase ?? "idle"}`), + provider: larkCliProvider, + }, + [wecomCliService]: { + busy: wecomCliBusy, + cancel: wecomCli.cancel, + connect: wecomCli.connect, + detail: wecomCliProviderDetail, + disconnect: wecomCli.disconnect, + error: wecomCli.error, + phase: wecomCli.state?.phase ?? "idle", + phaseLabel: t(`connections.wecomCli.phase.${wecomCli.state?.phase ?? "idle"}`), + provider: wecomCliProvider, + reopenPolling: wecomCli.state?.canReopenAuthorization ? wecomCli.reopenAuthorization : undefined, + }, + }), + [larkCli, larkCliBusy, larkCliProvider, t, wecomCli, wecomCliBusy, wecomCliProvider], + ) const selectedProvider = selectedProviderService ? (filteredProviders.find((provider) => provider.service === selectedProviderService) ?? null) : null - const selectedDirectService = - selectedProvider?.service === "lark-cli" || selectedProvider?.service === wecomCliService - ? selectedProvider.service - : null - const selectedDirectCli = - selectedDirectService === "lark-cli" ? larkCli : selectedDirectService === wecomCliService ? wecomCli : null - const selectedProviderIsDirect = selectedDirectCli !== null + const selectedDirectProvider = selectedProvider ? directProviderByService[selectedProvider.service] : undefined + const selectedProviderIsDirect = selectedDirectProvider !== undefined const selectedProviderActionsEnabled = selectedProviderIsDirect ? true : connectionActionsEnabled const providerDetail = useConnectionProviderDetail({ enabled: selectedProviderActionsEnabled, @@ -196,13 +249,9 @@ export function ConnectionsPanel({ workspaceKey: summaryWorkspaceKey, }) const selectedProviderDetail = - selectedProviderIsDirect && selectedProvider - ? selectedDirectService === "lark-cli" - ? larkCliProviderDetail(selectedProvider) - : wecomCliProviderDetail(selectedProvider) - : providerDetail.detail + selectedDirectProvider && selectedProvider ? selectedDirectProvider.detail(selectedProvider) : providerDetail.detail const selectedProviderDetailLoading = selectedProviderIsDirect ? false : providerDetail.loading - const selectedProviderDetailError = selectedProviderIsDirect ? selectedDirectCli.error : providerDetail.error + const selectedProviderDetailError = selectedDirectProvider ? selectedDirectProvider.error : providerDetail.error const selectedProviderActionsBlocked = Boolean( !selectedProviderActionsEnabled || (selectedProviderIsDirect && selectedProvider?.actionKind === "unavailable") || @@ -214,50 +263,23 @@ export function ConnectionsPanel({ ) const detailErrorNotice = selectedProvider ? getConnectionDetailErrorNotice({ - actionError: selectedProviderIsDirect ? selectedDirectCli.error : actionError, + actionError: selectedDirectProvider ? selectedDirectProvider.error : actionError, detailError: selectedProviderDetailError, }) : null - const larkCliBusy: UseConnections["busy"] = - larkCli.state?.phase === "disconnecting" - ? "disconnect" - : larkCli.state && larkCli.state.phase !== "idle" - ? "connect" - : null - const wecomCliBusy: UseConnections["busy"] = - wecomCli.state?.phase === "disconnecting" - ? "disconnect" - : wecomCli.state && wecomCli.state.phase !== "idle" - ? "connect" - : null - const selectedProviderBusy = selectedProviderIsDirect - ? selectedDirectService === "lark-cli" - ? larkCliBusy - : wecomCliBusy - : busy - const confirmDisconnectBusy = - confirmDisconnect?.provider.service === "lark-cli" - ? larkCliBusy - : confirmDisconnect?.provider.service === wecomCliService - ? wecomCliBusy - : busy - const selectedProviderPolling = selectedProviderIsDirect - ? selectedDirectCli.state && - selectedDirectCli.state.phase !== "idle" && - selectedDirectCli.state.phase !== "disconnecting" - ? selectedDirectService + const selectedProviderBusy = selectedDirectProvider ? selectedDirectProvider.busy : busy + const confirmDirectProvider = confirmDisconnect + ? directProviderByService[confirmDisconnect.provider.service] + : undefined + const confirmDisconnectBusy = confirmDirectProvider ? confirmDirectProvider.busy : busy + const selectedProviderPolling = selectedDirectProvider + ? selectedDirectProvider.phase !== "idle" && selectedDirectProvider.phase !== "disconnecting" + ? (selectedProvider?.service ?? null) : null : polling - const selectedProviderProgressLabel = selectedProviderIsDirect - ? selectedDirectService === "lark-cli" - ? t(`connections.larkCli.phase.${larkCli.state?.phase ?? "idle"}`) - : t(`connections.wecomCli.phase.${wecomCli.state?.phase ?? "idle"}`) - : undefined - const cancelSelectedProviderPolling = selectedProviderIsDirect ? selectedDirectCli.cancel : cancelPolling - const reopenSelectedProviderPolling = - selectedDirectService === wecomCliService && wecomCli.state?.canReopenAuthorization - ? wecomCli.reopenAuthorization - : undefined + const selectedProviderProgressLabel = selectedDirectProvider?.phaseLabel + const cancelSelectedProviderPolling = selectedDirectProvider?.cancel ?? cancelPolling + const reopenSelectedProviderPolling = selectedDirectProvider?.reopenPolling const reopenSelectedProviderPollingLabel = reopenSelectedProviderPolling ? t("connections.wecomCli.reopenAuthorization") : undefined @@ -369,8 +391,7 @@ export function ConnectionsPanel({ if ( filteredProviders.some((provider) => provider.service === selectedProviderService) || - (selectedProviderService === "lark-cli" && !larkCliProvider) || - (selectedProviderService === wecomCliService && !wecomCliProvider) + (directProviderByService[selectedProviderService] && !directProviderByService[selectedProviderService].provider) ) { return } @@ -379,7 +400,7 @@ export function ConnectionsPanel({ setSelectedProviderService(null) setDetailPaneClosing(false) setNarrowPane("list") - }, [clearDetailCloseTimer, filteredProviders, larkCliProvider, selectedProviderService, wecomCliProvider]) + }, [clearDetailCloseTimer, directProviderByService, filteredProviders, selectedProviderService]) const connectProvider = React.useCallback( async ( @@ -388,10 +409,9 @@ export function ConnectionsPanel({ appId?: string, ): Promise => { if (provider.executionMode === "direct") { - const directCli = - provider.service === "lark-cli" ? larkCli : provider.service === wecomCliService ? wecomCli : null - if (!directCli) return - const ok = await directCli.connect() + const directProvider = directProviderByService[provider.service] + if (!directProvider) return + const ok = await directProvider.connect() if (ok) { onConnectionReady?.({ service: provider.service, connectionName: "default" }) } @@ -481,11 +501,10 @@ export function ConnectionsPanel({ connect, deleteCachedDetailForService, getAppDetail, - larkCli, + directProviderByService, onConnectionReady, polling, providerDetail, - wecomCli, ], ) @@ -538,11 +557,7 @@ export function ConnectionsPanel({ connectionActionRequestIdRef.current = requestId const ok = target.provider.executionMode === "direct" - ? target.provider.service === "lark-cli" - ? await larkCli.disconnect() - : target.provider.service === wecomCliService - ? await wecomCli.disconnect() - : false + ? ((await directProviderByService[target.provider.service]?.disconnect()) ?? false) : target.app ? await disconnectAccount(target.app.id) : await disconnect(target.provider.service) @@ -554,7 +569,7 @@ export function ConnectionsPanel({ setConfirmDisconnect(null) } }, - [connectionActionsEnabled, deleteCachedDetailForService, disconnect, disconnectAccount, larkCli, wecomCli], + [connectionActionsEnabled, deleteCachedDetailForService, directProviderByService, disconnect, disconnectAccount], ) if (presentation === "drawer") { From 6a0fb6b892668adecd7e03989d903bd2cd358bd3 Mon Sep 17 00:00:00 2001 From: shaun Date: Mon, 3 Aug 2026 17:42:18 +0800 Subject: [PATCH 3/3] fix(wecom): validate tar size fields --- scripts/wecom-cli.test.ts | 8 ++++++++ scripts/wecom-cli.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/wecom-cli.test.ts b/scripts/wecom-cli.test.ts index b81fb694..a1ce45b5 100644 --- a/scripts/wecom-cli.test.ts +++ b/scripts/wecom-cli.test.ts @@ -49,6 +49,14 @@ describe("WeCom CLI Skill archive", () => { expect(() => tarEntries(tarHeader("oversized", Number.parseInt("77777777777", 8)))).toThrow("Invalid or truncated") }) + it("rejects a tar size field with a trailing non-octal character", () => { + const header = tarHeader("malformed-size", 1) + header.write("00000000001x", 124, 12, "ascii") + const archive = Buffer.concat([header, Buffer.alloc(512)]) + + expect(() => tarEntries(archive)).toThrow("Invalid or truncated") + }) + it("parses a complete padded tar entry", () => { const archive = Buffer.concat([tarHeader("skill/SKILL.md", 4), Buffer.from("test"), Buffer.alloc(508)]) expect(tarEntries(archive)).toEqual([{ data: Buffer.from("test"), path: "skill/SKILL.md", type: "0" }]) diff --git a/scripts/wecom-cli.ts b/scripts/wecom-cli.ts index f6eb62d9..50103fb3 100644 --- a/scripts/wecom-cli.ts +++ b/scripts/wecom-cli.ts @@ -141,7 +141,9 @@ export function tarEntries(tar: Buffer): TarEntry[] { const name = tarString(header, 0, 100) const prefix = tarString(header, 345, 155) const entryPath = prefix ? `${prefix}/${name}` : name - const size = Number.parseInt(tarString(header, 124, 12).trim() || "0", 8) + let sizeField = header.subarray(124, 136).toString("ascii").trim() + while (sizeField.endsWith("\0")) sizeField = sizeField.slice(0, -1) + const size = sizeField === "" || /^[0-7]+$/u.test(sizeField) ? Number.parseInt(sizeField || "0", 8) : Number.NaN const dataStart = offset + 512 const dataEnd = dataStart + size const nextOffset = dataStart + Math.ceil(size / 512) * 512