diff --git a/.github/workflows/wecom-package.yml b/.github/workflows/wecom-package.yml new file mode 100644 index 0000000..967ea4f --- /dev/null +++ b/.github/workflows/wecom-package.yml @@ -0,0 +1,80 @@ +name: WeCom account authentication package +on: + pull_request: + paths: ['plugins/wecom/**', '.github/workflows/wecom-package.yml'] + push: + branches: [main, feature/wecom-account-auth] + paths: ['plugins/wecom/**', '.github/workflows/wecom-package.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + native: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + platform: linux-amd64 + - os: ubuntu-22.04-arm + platform: linux-arm64 + - os: macos-15 + platform: darwin-arm64 + - os: macos-15-intel + platform: darwin-amd64 + - os: windows-latest + platform: windows-amd64 + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v6 + with: + python-version: '3.12' + - name: Verify committed assets before rebuilding + run: uv run --no-project python plugins/wecom/.wework-build/wecom-auth/package.py --plugin plugins/wecom --output .ci-artifacts/wecom-committed.zip + - name: Select pinned compiler + run: | + rustup toolchain install 1.94.1 --profile minimal + rustup default 1.94.1 + - name: Test public routing + run: uv run --no-project python -m unittest discover -s plugins/wecom/scripts/tests -v + - name: Build native candidate and test private source boundary + run: uv run --no-project python plugins/wecom/.wework-build/wecom-auth/build.py --output .ci-artifacts/wecom-native + - name: Stage host candidate + run: uv run --no-project python -c "import shutil; shutil.copytree('.ci-artifacts/wecom-native', 'plugins/wecom/scripts/native', dirs_exist_ok=True)" + - name: Verify native private entry and exact child status + run: uv run --no-project python plugins/wecom/.wework-build/wecom-auth/verify.py --plugin plugins/wecom --host-only + - uses: actions/upload-artifact@v4 + with: + name: wecom-native-${{ matrix.platform }}-${{ github.sha }} + path: .ci-artifacts/wecom-native/ + if-no-files-found: error + retention-days: 14 + package: + needs: native + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v6 + with: + python-version: '3.12' + - uses: actions/download-artifact@v4 + with: + pattern: wecom-native-*-${{ github.sha }} + path: plugins/wecom/scripts/native + merge-multiple: true + - name: Assemble verified five-platform package + run: uv run --no-project python plugins/wecom/.wework-build/wecom-auth/package.py --plugin plugins/wecom --output .ci-artifacts/wecom-account-auth.zip + - uses: actions/upload-artifact@v4 + with: + name: wecom-account-auth-${{ github.sha }} + path: | + .ci-artifacts/wecom-account-auth.zip + .ci-artifacts/wecom-account-auth.zip.sha256 + if-no-files-found: error + retention-days: 14 diff --git a/plugins/wecom/.codex-plugin/plugin.json b/plugins/wecom/.codex-plugin/plugin.json index 5defa5b..42bfc14 100644 --- a/plugins/wecom/.codex-plugin/plugin.json +++ b/plugins/wecom/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "wecom", - "version": "0.1.4", - "description": "通过本机已安装并完成认证的企业微信 CLI,使用消息、通讯录、会议、日程、待办与文档等协作能力。", + "version": "0.2.0", + "description": "本地扫码连接企业微信机器人后,在云端复用托管认证调用官方 CLI 协作能力。", "author": { "name": "Wegent" }, @@ -22,8 +22,8 @@ "skills": "./skills/", "interface": { "displayName": "企业微信", - "shortDescription": "通过本机 WeCom CLI 使用企业微信协作能力", - "longDescription": "自动准备跨平台企业微信官方 CLI,通过浏览器和企业微信扫码在本机创建并加密保存机器人配置,支持消息、通讯录、会议、日程、待办、普通文档、在线表格、智能表格和智能文档。凭据不上传 Wegent Backend。", + "shortDescription": "本地与云端复用企业微信机器人认证", + "longDescription": "沿用企业微信官方 CLI 0.1.9 的扫码接入,Bot ID 和 Secret 通过私有通道同步至 Wegent。云端使用内存机器人凭据与 MCP 配置,保留消息、联系人、日程、待办和文档命令。", "developerName": "Wegent", "category": "协作", "capabilities": [ @@ -40,5 +40,38 @@ "defaultPrompt": [ "请自动检查企业微信 CLI 和本机机器人配置,然后帮我处理企业微信中的消息、通讯录、会议、日程、待办或文档任务。" ] - } + }, + "connectors": [ + { + "slug": "wecom", + "authPolicy": "on_install", + "localAuth": { + "kind": "browser_oauth", + "health": [ + "scripts/local-auth.sh", + "health" + ], + "start": [ + "scripts/local-auth.sh", + "login" + ], + "logout": [ + "scripts/local-auth.sh", + "logout" + ], + "timeoutSeconds": 600, + "logoutOnUninstall": false + }, + "accountAuth": { + "protocolVersion": 1, + "credentialType": "password", + "adapter": "scripts/account-auth.py", + "localEnvironment": { + "WECOM_CLI_CONFIG_DIR": { + "type": "directory" + } + } + } + } + ] } diff --git a/plugins/wecom/.gitattributes b/plugins/wecom/.gitattributes new file mode 100644 index 0000000..fa9f195 --- /dev/null +++ b/plugins/wecom/.gitattributes @@ -0,0 +1,4 @@ +*.py text eol=lf +*.rs text eol=lf +*.json text eol=lf +*.xz binary diff --git a/plugins/wecom/.wework-build.json b/plugins/wecom/.wework-build.json new file mode 100644 index 0000000..a1cdfe9 --- /dev/null +++ b/plugins/wecom/.wework-build.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "entrypoint": ".wework-build/wecom-auth/package.py", + "outputs": ["build-info"] +} diff --git a/plugins/wecom/.wework-build/wecom-auth/build.py b/plugins/wecom/.wework-build/wecom-auth/build.py new file mode 100644 index 0000000..10fe358 --- /dev/null +++ b/plugins/wecom/.wework-build/wecom-auth/build.py @@ -0,0 +1,210 @@ +"""Build the pinned original WeCom CLI with an in-memory auth boundary.""" + +import argparse +import hashlib +import json +import lzma +import os +import platform +import shutil +import subprocess +import tarfile +import tempfile +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +REVISION = "72e14f7695f34d28f1ff23ea504ddd2210a87c13" +SOURCE_SHA256 = "b8af1eeffd346646f1a1a20dbe11cb4ab50d12c26664ddfd433661402fe8840e" +RUST_VERSION = "1.94.1" +TARGETS = { + "darwin-arm64": "aarch64-apple-darwin", + "darwin-amd64": "x86_64-apple-darwin", + "linux-amd64": "x86_64-unknown-linux-gnu", + "linux-arm64": "aarch64-unknown-linux-gnu", + "windows-amd64": "x86_64-pc-windows-msvc", +} + + +def source_hash(): + return { + p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in [ROOT / "build.py", ROOT / "wegent.rs"] + } + + +def replace(path, old, new): + value = path.read_text(encoding="utf-8") + if value.count(old) != 1: + raise ValueError(f"Upstream boundary changed: {path.name}") + path.write_text(value.replace(old, new), encoding="utf-8") + + +def prepare(directory, archive=None): + archive = archive or directory / "source.tar.gz" + if not archive.exists(): + with urllib.request.urlopen( + f"https://codeload.github.com/WecomTeam/wecom-cli/tar.gz/{REVISION}", + timeout=60, + ) as response: + archive.write_bytes(response.read(100 * 1024 * 1024 + 1)) + if hashlib.sha256(archive.read_bytes()).hexdigest() != SOURCE_SHA256: + raise ValueError("Upstream source checksum mismatch") + with tarfile.open(archive) as source: + source.extractall(directory, filter="data") + source = directory / ("wecom-cli-" + REVISION) + src = source / "src" + shutil.copyfile(ROOT / "wegent.rs", src / "wegent.rs") + path = src / "main.rs" + value = path.read_text(encoding="utf-8") + start = value.index("/// Entry point:") + body = value[start:] + body = body.replace( + "/// Entry point: parse CLI arguments and dispatch to the corresponding subcommand handler.\n#[tokio::main]\nasync fn main() -> Result<()> {\n dotenvy::dotenv().ok();\n\n logging::init_logging();", + "async fn run_cli(arguments: Vec) -> Result<()> {", + ) + body = body.replace( + "std::env::args()\n .skip(1)", "arguments.iter().cloned()" + ) + body = body.replace( + "let matches = cmd.get_matches();", + 'let matches = match cmd.try_get_matches_from(std::iter::once("wecom-cli".to_string()).chain(arguments)) { Ok(value) => value, Err(error) if matches!(error.kind(), clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion) => { println!("{}", error); return Ok(()); }, Err(error) => return Err(error.into()) };', + ) + main = """\n#[tokio::main]\nasync fn main() {\n let arguments: Vec = std::env::args().skip(1).collect();\n let result = if arguments.first().map(String::as_str) == Some("__wegent") {\n wegent::execute(arguments[1..].to_vec()).await\n } else {\n // The launcher owns the environment; never load workspace .env files.\n run_cli(arguments).await\n };\n if result.is_err() { std::eprintln!("plugin_auth_wecom_failed"); std::process::exit(1); }\n}\n""" + path.write_text( + 'macro_rules! println { () => { crate::wegent::emit(format_args!("")) }; ($($arg:tt)*) => { crate::wegent::emit(format_args!($($arg)*)) }; }\nmod wegent;\n' + + value[:start] + + body + + main, + encoding="utf-8", + ) + replace( + src / "auth/bot.rs", + "pub fn get_bot_info() -> Option {", + "pub fn get_bot_info() -> Option {\n if crate::wegent::managed() { return crate::wegent::bot(); }", + ) + replace( + src / "auth/bot.rs", + "pub fn set_bot_info(bot: &Bot) -> Result<()> {", + 'pub fn set_bot_info(bot: &Bot) -> Result<()> {\n anyhow::ensure!(!crate::wegent::managed(), "managed source denied");', + ) + replace( + src / "auth/bot.rs", + "pub fn clear_bot_info() {", + "pub fn clear_bot_info() {\n if crate::wegent::managed() { return; }", + ) + replace( + src / "mcp/config.rs", + "pub fn load_mcp_config() -> Option> {", + "pub fn load_mcp_config() -> Option> {\n if crate::wegent::managed() { return crate::wegent::config(); }", + ) + replace( + src / "mcp/config.rs", + "pub fn save_mcp_config(items: &[McpConfigItem]) -> Result<()> {", + "pub fn save_mcp_config(items: &[McpConfigItem]) -> Result<()> {\n if crate::wegent::managed() { return crate::wegent::save_config(items); }", + ) + replace( + src / "mcp/config.rs", + "pub fn clear_mcp_config() {", + "pub fn clear_mcp_config() {\n if crate::wegent::managed() { return; }", + ) + replace( + src / "crypto/keystore.rs", + "pub fn load_existing_key() -> Option<[u8; 32]> {", + "pub fn load_existing_key() -> Option<[u8; 32]> {\n if crate::wegent::managed() { return None; }", + ) + replace( + src / "crypto/keystore.rs", + "pub fn save_key(key: &[u8; 32]) -> Result<()> {", + 'pub fn save_key(key: &[u8; 32]) -> Result<()> {\n anyhow::ensure!(!crate::wegent::managed(), "managed source denied");', + ) + replace( + src / "crypto/keystore.rs", + "pub fn try_decrypt_data(data: &[u8]) -> Result {", + 'pub fn try_decrypt_data(data: &[u8]) -> Result {\n anyhow::ensure!(!crate::wegent::managed(), "managed source denied");', + ) + replace( + src / "registry.rs", + "if let Some(tools) = get_cache_content::>(&cache_file) {", + "if !crate::wegent::managed() && let Some(tools) = get_cache_content::>(&cache_file) {", + ) + replace( + src / "registry.rs", + "if let Ok(json) = serde_json::to_string(&tools) {", + "if !crate::wegent::managed() && let Ok(json) = serde_json::to_string(&tools) {", + ) + for name in ["mcp/config.rs", "json_rpc.rs"]: + replace( + src / name, + "reqwest::Client::builder()\n .build()", + "reqwest::Client::builder()\n .no_proxy().redirect(reqwest::redirect::Policy::none()).timeout(std::time::Duration::from_secs(30))\n .build()", + ) + return source + + +def build(output, archive=None): + expected_sources = source_hash() + version = subprocess.check_output(["rustc", "--version"], text=True).split()[1] + if version != RUST_VERSION: + raise ValueError("Use Rust " + RUST_VERSION) + host = ( + subprocess.check_output(["rustc", "-vV"], text=True) + .split("host: ")[1] + .splitlines()[0] + ) + target = next((key for key, value in TARGETS.items() if value == host), None) + if target is None: + raise ValueError("Unsupported build host") + with tempfile.TemporaryDirectory(prefix="wecom-build-") as temporary: + root = prepare(Path(temporary), archive) + env = { + **os.environ, + "CARGO_TARGET_DIR": str(output.parent / "wecom-cargo-target"), + "CARGO_PROFILE_RELEASE_STRIP": "true", + "CARGO_PROFILE_RELEASE_CODEGEN_UNITS": "1", + "CARGO_PROFILE_RELEASE_LTO": "thin", + } + subprocess.run( + ["cargo", "test", "--locked", "wegent::tests", "--", "--test-threads=1"], + cwd=root, + env=env, + check=True, + ) + subprocess.run( + ["cargo", "build", "--locked", "--release"], cwd=root, env=env, check=True + ) + binary = ( + Path(env["CARGO_TARGET_DIR"]) + / "release" + / ("wecom-cli.exe" if os.name == "nt" else "wecom-cli") + ) + content = binary.read_bytes() + folder = output / target + folder.mkdir(parents=True, exist_ok=True) + blob = folder / "wecom-account-auth.xz" + blob.write_bytes(lzma.compress(content, preset=9)) + if source_hash() != expected_sources: + raise ValueError("Build sources changed during compilation") + metadata = { + "nativeProtocolVersion": 1, + "target": target.replace("-", "/", 1), + "upstreamVersion": "0.1.9", + "upstreamRevision": REVISION, + "upstreamSourceSha256": SOURCE_SHA256, + "rustVersion": RUST_VERSION, + "sources": expected_sources, + "binaryBytes": len(content), + "binarySha256": hashlib.sha256(content).hexdigest(), + "compressedSha256": hashlib.sha256(blob.read_bytes()).hexdigest(), + } + blob.with_suffix(".json").write_text(json.dumps(metadata, indent=2) + "\n") + shutil.copyfile(root / "LICENSE", folder / "UPSTREAM-LICENSE") + print(json.dumps(metadata)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--source-archive", type=Path) + args = parser.parse_args() + build(args.output.resolve(), args.source_archive) diff --git a/plugins/wecom/.wework-build/wecom-auth/package.py b/plugins/wecom/.wework-build/wecom-auth/package.py new file mode 100644 index 0000000..ef34ff8 --- /dev/null +++ b/plugins/wecom/.wework-build/wecom-auth/package.py @@ -0,0 +1,114 @@ +"""Verify pinned native assets and assemble a standalone marketplace package.""" + +import argparse +import hashlib +import json +import lzma +import shutil +import stat +import tempfile +import zipfile +from pathlib import Path +from build import TARGETS, SOURCE_SHA256, RUST_VERSION, source_hash +from verify import verify_package + + +def validate_inventory(plugin): + inventory = plugin / "scripts/native" + if {path.name for path in inventory.iterdir() if path.is_dir()} != set(TARGETS): + raise ValueError("All five native platforms are required") + for target in TARGETS: + folder = inventory / target + metadata = json.loads( + (folder / "wecom-account-auth.json").read_text(encoding="utf-8") + ) + blob = folder / "wecom-account-auth.xz" + if ( + metadata.get("sources") != source_hash() + or metadata.get("upstreamSourceSha256") != SOURCE_SHA256 + or metadata.get("rustVersion") != RUST_VERSION + or metadata.get("target") != target.replace("-", "/", 1) + ): + raise ValueError("Rebuild native assets after changing provider sources") + packed = blob.read_bytes() + if hashlib.sha256(packed).hexdigest() != metadata["compressedSha256"]: + raise ValueError("Native asset checksum mismatch") + decoder = lzma.LZMADecompressor(memlimit=128 * 1024 * 1024) + data = decoder.decompress(packed, max_length=80 * 1024 * 1024) + if ( + not decoder.eof + or len(data) != metadata["binaryBytes"] + or hashlib.sha256(data).hexdigest() != metadata["binarySha256"] + ): + raise ValueError("Native binary checksum mismatch") + + +def assemble(plugin, output): + validate_inventory(plugin) + verify_package(plugin) + with tempfile.TemporaryDirectory(prefix="wecom-package-") as temporary: + root = Path(temporary) / "plugin" + shutil.copytree( + plugin, + root, + symlinks=True, + ignore=shutil.ignore_patterns( + "__pycache__", "*.pyc", ".DS_Store", "build-info" + ), + ) + marker = root / "build-info/package.json" + marker.parent.mkdir() + marker.write_text( + json.dumps( + { + "protocolVersion": 1, + "platforms": sorted(TARGETS), + "sources": source_hash(), + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + files = sorted(p for p in root.rglob("*") if p.is_file()) + if any(p.is_symlink() or p.name in {".env", ".git"} for p in root.rglob("*")): + raise ValueError("Private state or symlinks are forbidden") + if sum(p.stat().st_size for p in files) > 200 * 1024 * 1024: + raise ValueError("Package exceeds expanded limit") + candidate = Path(temporary) / "plugin.zip" + with zipfile.ZipFile( + candidate, "w", zipfile.ZIP_DEFLATED, compresslevel=9 + ) as archive: + for path in files: + info = zipfile.ZipInfo( + path.relative_to(root).as_posix(), (1980, 1, 1, 0, 0, 0) + ) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | 0o644) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, path.read_bytes(), compresslevel=9) + if candidate.stat().st_size > 50 * 1024 * 1024: + raise ValueError("Package exceeds upload limit") + output.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(candidate, output) + checksum = hashlib.sha256(output.read_bytes()).hexdigest() + output.with_name(output.name + ".sha256").write_text( + checksum + " " + output.name + "\n", encoding="utf-8" + ) + print( + json.dumps( + { + "artifact": str(output), + "sha256": checksum, + "bytes": output.stat().st_size, + } + ) + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--plugin", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + assemble(args.plugin.resolve(), args.output.resolve()) diff --git a/plugins/wecom/.wework-build/wecom-auth/verify.py b/plugins/wecom/.wework-build/wecom-auth/verify.py new file mode 100644 index 0000000..49cc386 --- /dev/null +++ b/plugins/wecom/.wework-build/wecom-auth/verify.py @@ -0,0 +1,122 @@ +"""Check native inventory, child status, and public broker routing from the artifact.""" + +import hashlib +import json +import os +import socket +import struct +import subprocess +import sys +import tempfile +from pathlib import Path + + +def verify_package(plugin, require_all=True): + inventory = list((plugin / "scripts/native").glob("*/wecom-account-auth.json")) + expected = { + "darwin-amd64", + "darwin-arm64", + "linux-amd64", + "linux-arm64", + "windows-amd64", + } + if require_all and {p.parent.name for p in inventory} != expected: + raise ValueError("Incomplete native platform inventory") + for metadata in inventory: + value = json.loads(metadata.read_text()) + blob = metadata.with_suffix(".xz") + if hashlib.sha256(blob.read_bytes()).hexdigest() != value["compressedSha256"]: + raise ValueError("Native artifact checksum mismatch") + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("WEGENT_", "LARK", "OPENCLAW", "HERMES")) + } + env.update( + HOME=str(home), + USERPROFILE=str(home), + WEGENT_EXECUTOR_HOME=str(home / "executor"), + PYTHONDONTWRITEBYTECODE="1", + ) + # Exercise the packaged launcher and private channel with an invalid + # business credential; never touch a real account or system keychain. + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(30) + env["WEGENT_PLUGIN_AUTH_PORT"] = str(listener.getsockname()[1]) + process = subprocess.Popen( + [ + sys.executable, + str(plugin / "scripts/account-auth.py"), + "run", + "account-status", + ], + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + process.stdin.write(b"x" * 32) + process.stdin.close() + process.stdin = None + connection, _ = listener.accept() + with connection: + connection.settimeout(15) + with connection.makefile("rwb", buffering=0) as stream: + nonce = b"" + while len(nonce) < 32: + chunk = stream.read(32 - len(nonce)) + if not chunk: + raise ValueError("Truncated native nonce") + nonce += chunk + if nonce != b"x" * 32: + raise ValueError("Native nonce mismatch") + payload = json.dumps( + { + "protocolVersion": 1, + "connectorSlug": "wecom", + "credentialType": "password", + "credential": { + "username": "synthetic", + "password": "synthetic-private-token", + "unexpected": "denied", + }, + } + ).encode() + stream.write(struct.pack(">I", len(payload)) + payload) + out, err = process.communicate(timeout=20) + if process.returncode == 0 or b"synthetic-private-token" in out + err: + raise ValueError( + "Packaged adapter accepted or exposed an invalid credential" + ) + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=5) + subprocess.run( + [ + sys.executable, + "-m", + "unittest", + "discover", + "-s", + str(plugin / "scripts/tests"), + "-v", + ], + env=env, + check=True, + ) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--plugin", type=Path, required=True) + parser.add_argument("--host-only", action="store_true") + args = parser.parse_args() + verify_package(args.plugin.resolve(), require_all=not args.host_only) diff --git a/plugins/wecom/.wework-build/wecom-auth/wegent.rs b/plugins/wecom/.wework-build/wecom-auth/wegent.rs new file mode 100644 index 0000000..241ebe4 --- /dev/null +++ b/plugins/wecom/.wework-build/wecom-auth/wegent.rs @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: MIT +//! Private native credential channel and in-memory upstream integration. +use crate::auth::Bot; +use crate::mcp::config::{McpBindSource, McpConfigItem}; +use anyhow::{Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +static BOT: OnceLock = OnceLock::new(); +static CONFIG: Mutex>> = Mutex::new(None); +static OUTPUT: Mutex<(String, bool)> = Mutex::new((String::new(), false)); +const MAX_OUTPUT: usize = 8 * 1024 * 1024; + +pub fn managed() -> bool { + BOT.get().is_some() +} +pub fn bot() -> Option { + BOT.get().cloned() +} +pub fn config() -> Option> { + CONFIG.lock().ok()?.clone() +} +pub fn save_config(items: &[McpConfigItem]) -> Result<()> { + for item in items { + if let Some(value) = &item.url { + let url = reqwest::Url::parse(value)?; + ensure!( + url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && url.port_or_known_default() == Some(443), + "invalid capability origin" + ); + } + } + *CONFIG + .lock() + .map_err(|_| anyhow::anyhow!("state unavailable"))? = Some(items.to_vec()); + Ok(()) +} +pub fn emit(value: std::fmt::Arguments<'_>) { + if !managed() { + std::println!("{value}"); + return; + } + if let Ok(mut output) = OUTPUT.lock() { + let value = value.to_string(); + if output.0.len() + value.len() + 1 > MAX_OUTPUT { + output.1 = true; + } else { + output.0.push_str(&value); + output.0.push('\n'); + } + } +} +fn flush() -> Result<()> { + let output = OUTPUT + .lock() + .map_err(|_| anyhow::anyhow!("output unavailable"))?; + ensure!(!output.1, "output exceeded limit"); + if let Some(bot) = BOT.get() { + ensure!(!output.0.contains(&bot.secret), "private output"); + } + if let Some(items) = config() { + for item in items { + if let Some(url) = item.url { + ensure!(!output.0.contains(&url), "private output"); + } + } + } + std::io::stdout().write_all(output.0.as_bytes())?; + Ok(()) +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Credential { + username: String, + password: String, +} +impl Credential { + fn validate(&self) -> Result<()> { + for value in [&self.username, &self.password] { + ensure!( + !value.trim().is_empty() + && value.len() <= 4096 + && !value.chars().any(char::is_control), + "invalid credential" + ); + } + ensure!(self.username.len() <= 256, "invalid account"); + Ok(()) + } + fn to_bot(&self) -> Bot { + Bot { + id: self.username.clone(), + secret: self.password.clone(), + create_time: 0, + } + } +} +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Frame { + protocol_version: u8, + connector_slug: String, + credential_type: String, + credential: Credential, +} +fn connect() -> Result { + ensure!( + std::env::var_os("WEGENT_PLUGIN_AUTH_FD").is_none(), + "invalid transport" + ); + let port: u16 = std::env::var("WEGENT_PLUGIN_AUTH_PORT")?.parse()?; + ensure!(port != 0, "invalid transport"); + let mut nonce = [0u8; 32]; + std::io::stdin().read_exact(&mut nonce)?; + let mut stream = TcpStream::connect_timeout( + &SocketAddr::from(([127, 0, 0, 1], port)), + Duration::from_secs(5), + )?; + stream.set_read_timeout(Some(Duration::from_secs(300)))?; + stream.set_write_timeout(Some(Duration::from_secs(300)))?; + stream.write_all(&nonce)?; + Ok(stream) +} +fn receive(stream: &mut impl Read) -> Result { + let mut size = [0; 4]; + stream.read_exact(&mut size)?; + let size = u32::from_be_bytes(size) as usize; + ensure!(size > 0 && size <= 65536, "invalid frame"); + let mut payload = vec![0; size]; + stream.read_exact(&mut payload)?; + let frame: Frame = serde_json::from_slice(&payload)?; + ensure!( + frame.protocol_version == 1 + && frame.connector_slug == "wecom" + && frame.credential_type == "password", + "invalid frame" + ); + frame.credential.validate()?; + Ok(frame.credential) +} +fn send(stream: &mut impl Write, credential: Credential) -> Result<()> { + credential.validate()?; + let frame = Frame { + protocol_version: 1, + connector_slug: "wecom".into(), + credential_type: "password".into(), + credential, + }; + let data = serde_json::to_vec(&frame)?; + ensure!(data.len() <= 65536, "invalid frame"); + stream.write_all(&(data.len() as u32).to_be_bytes())?; + stream.write_all(&data)?; + Ok(()) +} +pub fn allowed(args: &[String]) -> bool { + args.first().is_some_and(|name| { + matches!( + name.as_str(), + "contact" | "doc" | "meeting" | "msg" | "schedule" | "todo" | "account-status" + ) + }) +} +async fn verify(bot: Bot) -> Result<()> { + BOT.set(bot) + .map_err(|_| anyhow::anyhow!("credential already set"))?; + crate::mcp::config::fetch_mcp_config(McpBindSource::Interactive).await?; + Ok(()) +} +pub async fn execute(args: Vec) -> Result<()> { + match args.first().map(String::as_str) { + Some("export") if args.len() == 1 => { + let mut stream = connect()?; + let value = + crate::auth::get_bot_info().ok_or_else(|| anyhow::anyhow!("login required"))?; + let credential = Credential { + username: value.id.clone(), + password: value.secret.clone(), + }; + credential.validate()?; + verify(value).await?; + let account = credential.username.clone(); + send(&mut stream, credential)?; + std::println!( + "{}", + serde_json::json!({"status":"ok","protocolVersion":1,"credentialType":"password","accountId":account}) + ); + } + Some("run") if allowed(&args[1..]) => { + let mut stream = connect()?; + let credential = receive(&mut stream)?; + drop(stream); + verify(credential.to_bot()).await?; + if args[1..] == ["account-status"] { + emit(format_args!( + "{}", + serde_json::json!({"status":"ok","accountId":credential.username}) + )); + } else { + crate::run_cli(args[1..].to_vec()).await?; + } + flush()?; + } + Some("local-health") if args.len() == 1 => { + let value = + crate::auth::get_bot_info().ok_or_else(|| anyhow::anyhow!("login required"))?; + verify(value).await?; + } + Some("local-clear") if args.len() == 1 => { + for name in ["bot.enc", "mcp_config.enc"] { + match std::fs::remove_file(crate::paths::wecom_home_dir().join(name)) { + Ok(()) => (), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(e) => return Err(e.into()), + } + } + } + _ => bail!("unsupported operation"), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn frame(value: &str) -> Vec { + let mut data = (value.len() as u32).to_be_bytes().to_vec(); + data.extend(value.as_bytes()); + data + } + #[test] + fn private_channel_roundtrip_and_duplicate_rejection() { + let mut data = Vec::new(); + send( + &mut data, + Credential { + username: "synthetic-bot".into(), + password: "synthetic-secret".into(), + }, + ) + .unwrap(); + assert_eq!( + receive(&mut data.as_slice()).unwrap().username, + "synthetic-bot" + ); + for value in [ + r#"{"protocolVersion":1,"connectorSlug":"wecom","credentialType":"password","credential":{"username":"x","password":"a","password":"b"}}"#, + r#"{"protocolVersion":1,"connectorSlug":"other","credentialType":"password","credential":{"username":"x","password":"a"}}"#, + r#"{"protocolVersion":1,"connectorSlug":"wecom","credentialType":"password","credential":{"username":"x","password":"a","token":"b"}}"#, + ] { + assert!(receive(&mut frame(value).as_slice()).is_err()); + } + assert!(receive(&mut &[0u8, 1, 0, 1][..]).is_err()); + assert!(receive(&mut &[0u8, 0, 0, 2, 123][..]).is_err()); + } + #[tokio::test] + async fn managed_source_and_capabilities_stay_in_memory() { + use base64::Engine; + let source = tempfile::tempdir().unwrap(); + unsafe { + std::env::set_var("WECOM_CLI_CONFIG_DIR", source.path()); + } + let key = [7u8; 32]; + let original = Bot { + id: "synthetic-bot".into(), + secret: "synthetic-secret".into(), + create_time: 42, + }; + let encrypted = crate::crypto::encrypt_data(&original, &key).unwrap(); + std::fs::write(source.path().join("bot.enc"), &encrypted).unwrap(); + std::fs::write( + source.path().join(".encryption_key"), + base64::prelude::BASE64_STANDARD.encode(key), + ) + .unwrap(); + assert_eq!( + crate::auth::get_bot_info().unwrap().secret, + "synthetic-secret" + ); + assert_eq!( + std::fs::read(source.path().join("bot.enc")).unwrap(), + encrypted + ); + BOT.set(Bot { + id: "synthetic-bot".into(), + secret: "synthetic-secret".into(), + create_time: 0, + }) + .unwrap(); + assert_eq!(crate::auth::get_bot_info().unwrap().id, "synthetic-bot"); + assert!(crate::auth::set_bot_info(&bot().unwrap()).is_err()); + assert!(crate::crypto::load_existing_key().is_none()); + let items = vec![McpConfigItem { + url: Some("https://work.weixin.qq.com/synthetic-capability".into()), + transport_type: Some("streamable-http".into()), + is_authed: Some(true), + biz_type: Some("msg".into()), + }]; + crate::mcp::config::save_mcp_config(&items).unwrap(); + assert_eq!(crate::mcp::config::load_mcp_config().unwrap().len(), 1); + assert!(allowed(&["doc".into(), "+create".into()])); + assert!(!allowed(&["init".into()])); + // Drive the unchanged upstream parser and JSON-RPC client against a + // synthetic loopback endpoint. Production capability validation requires HTTPS. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + CONFIG.lock().unwrap().as_mut().unwrap()[0].url = + Some(format!("http://{address}/synthetic")); + let worker = std::thread::spawn(move || { + for expected in ["tools/list", "tools/call"] { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut bytes = Vec::new(); + let mut chunk = [0; 2048]; + loop { + let count = socket.read(&mut chunk).unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&chunk[..count]); + if let Some(index) = bytes.windows(4).position(|value| value == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..index]); + let length: usize = headers + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length: ") + .map(str::to_string) + }) + .unwrap() + .parse() + .unwrap(); + if bytes.len() >= index + 4 + length { + break; + } + } + } + let request = String::from_utf8(bytes).unwrap(); + assert!(request.contains(expected)); + assert!(!request.contains("synthetic-secret")); + let body = if expected == "tools/list" { + r#"{"jsonrpc":"2.0","result":{"tools":[{"name":"synthetic_get","inputSchema":{"type":"object"}}]}}"# + } else { + r#"{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"synthetic-business-result"}]}}"# + }; + write!(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap(); + } + }); + crate::run_cli(vec!["msg".into(), "synthetic_get".into(), "{}".into()]) + .await + .unwrap(); + worker.join().unwrap(); + assert!( + OUTPUT + .lock() + .unwrap() + .0 + .contains("synthetic-business-result") + ); + emit(format_args!("synthetic-secret")); + assert!(flush().is_err()); + } +} diff --git a/plugins/wecom/README.md b/plugins/wecom/README.md index d7d6d0a..8eeb595 100644 --- a/plugins/wecom/README.md +++ b/plugins/wecom/README.md @@ -1,38 +1,28 @@ -# 企业微信插件 - -该插件将企业微信 `wecom@0.1.9` 的完整 Skills 适配为 Wegent/WeWork -可发布的 Codex 插件。 - -## 运行与授权 - -- 插件没有 Wegent Backend Connector,也不会把 Access Token、Bot ID 或 - Bot Secret 上传到服务端。 -- 首次使用时,Skill 通过 `scripts/ensure-wecom-ready.*` 检查 CLI 和本地配置。 -- PATH 中没有 `wecom-cli` 时,脚本会下载官方清单中的 0.1.9 - 平台二进制并校验 SHA-256,然后安装到当前用户目录: - - macOS/Linux:`~/.wegent-executor/tools/wecom-cli/0.1.9//wecom-cli` - - Windows:`%LOCALAPPDATA%\Wegent\tools\wecom-cli\0.1.9\win32-x64\wecom-cli.exe` -- 如果本机配置不存在或不可用,脚本执行 - `wecom-cli init --noninteractive`。CLI 会打开企业微信二维码页面,用户扫码后 - 自动取得机器人配置。 -- 官方 CLI 将 Bot 信息和 MCP 配置加密存放在 `~/.config/wecom`;加密密钥 - 优先使用系统钥匙串,并保留权限为 `0600` 的本地文件兜底。 -- 后续调用统一通过 `scripts/run-wecom-cli.*`,包装器会忽略进程环境中的 - Connector 凭据覆盖,只使用官方本机加密配置。 -- Windows PowerShell 5.1 下,安装、扫码授权和业务调用均通过原生命令兼容 - 边界,二维码进度写入 stderr 时仍以 CLI 原生退出码判断结果。 - -插件包不内置用户凭据或平台专用二进制。下载地址和 SHA-256 来自 -`wecom@0.1.9` 的官方 binary manifest。 - -Windows 可运行 `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/test-native-command.ps1` -验证非零退出码与正常 stderr 的处理。 - -## 能力 - -包含 9 个 Skills:通讯录、消息、会议、日程、待办、普通文档、在线表格、 -智能表格和智能文档。 - -Skills 与 CLI 基于企业微信官方 -[WecomTeam/wecom-cli](https://github.com/WecomTeam/wecom-cli),使用 MIT -许可证;原始许可证保留在插件根目录。 +# 企业微信 + +本地通过官方 CLI 扫码连接机器人,Wegent 同步认证后,云端可以直接使用消息、联系人、会议、日程、待办和文档能力。 + +## 认证与调用 + +连接采用 `password` 类型:用户名为 Bot ID,密码为 Bot Secret。它不是 OAuth,不需要迁移刷新权;本地原认证继续可用。适配器只通过私有认证通道导出和接收凭据。 + +在 Wegent 的原生连接界面完成扫码。业务命令通过 `scripts/run-wecom-cli.sh` 或 Windows 的 `scripts/run-wecom-cli.ps1` 执行。云端入口直接调用宿主 broker,不安装 CLI、不启动扫码、不读取来源设备文件。认证失效或权限不足时,在来源设备重新连接。 + +云端保留官方 CLI 0.1.9 的命令树和文档 helpers,通过内存机器人信息获取最新 MCP 授权地址。机器人凭据、加密密钥与 MCP 授权地址不会写入云端认证缓存;禁用日志、环境代理、重定向和工作目录 `.env` 加载。媒体下载仍正常生成用户请求的文件。 + +适配读取 CLI 0.1.9 的 `bot.enc` 和 `.encryption_key` 格式,可通过 `WECOM_CLI_CONFIG_DIR` 指定来源目录。使用其他独立 CLI 版本的账号应先在本插件原生界面连接,避免跨版本修改认证文件。 + +## 原生构建与打包 + +原生适配器基于官方源码提交 `72e14f7695f34d28f1ff23ea504ddd2210a87c13`(0.1.9),使用 Rust 1.94.1。构建入口校验上游归档 SHA-256,并在 macOS、Linux 和 Windows 的真实构建机上生成五个平台产物。 + +CI 验证成功的 XZ 压缩产物保存在 `scripts/native//`,同时记录上游版本、适配源码摘要和二进制 SHA-256。修改适配源码后,必须重新生成五个平台产物;打包器拒绝源码与产物不匹配的包。运行时仅需 Python 3.9+,不需要安装 Rust 或 Node.js。 + +```sh +uv run --no-project python .wework-build/wecom-auth/build.py --output build/native +uv run --no-project python .wework-build/wecom-auth/package.py --plugin . --output build/wecom-package.zip +``` + +构建命令只生成当前主机对应的产物。通过 `WeCom account authentication package` 工作流汇总五个平台结果,校验成功后更新 `scripts/native` 并提交。`.wework-build.json` 提供自包含打包入口,市场发布阶段无需另行下载临时 CI 文件。 + +验证使用合成凭据和隔离目录,覆盖原认证文件解密、私有通道、内存业务请求、云端路由和打包后的真实子进程退出码。真实企业账号与云端联调仍需在配套 Wegent 服务部署后验收。上游许可证与每个平台产物一同保存。 diff --git a/plugins/wecom/scripts/account-auth.py b/plugins/wecom/scripts/account-auth.py new file mode 100644 index 0000000..1f39c65 --- /dev/null +++ b/plugins/wecom/scripts/account-auth.py @@ -0,0 +1,20 @@ +"""Native private adapter entry; preserve the exact child exit status.""" + +import json +import sys +import native_runtime as native + + +def main(arguments): + if arguments == ["export"]: + with native.locked(): + return native.invoke(["__wegent", *arguments]).returncode + return native.invoke(["__wegent", *arguments]).returncode + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv[1:])) + except Exception: + print(json.dumps({"error": "plugin_auth_wecom_failed"})) + raise SystemExit(1) diff --git a/plugins/wecom/scripts/ensure-wecom-ready.ps1 b/plugins/wecom/scripts/ensure-wecom-ready.ps1 index 0b4f2b9..b05e7a1 100644 --- a/plugins/wecom/scripts/ensure-wecom-ready.ps1 +++ b/plugins/wecom/scripts/ensure-wecom-ready.ps1 @@ -1,36 +1,9 @@ +param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest - -$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path $scriptDirectory 'invoke-native-command.ps1') -$wecom = & (Join-Path $scriptDirectory 'install-wecom-cli.ps1') -PrintPath | - Select-Object -Last 1 - -foreach ($name in @('WECOM_ACCESS_TOKEN', 'WECOM_BOT_ID', 'WECOM_SECRET')) { - Remove-Item "Env:$name" -ErrorAction SilentlyContinue -} - -$probeExitCode = -1 -Invoke-NativeCommand ` - -Command { & $wecom contact --help } ` - -ExitCode ([ref]$probeExitCode) ` - -DiscardOutput -if ($probeExitCode -ne 0) { - Write-Host 'WeCom CLI has no usable local robot configuration. Opening QR authorization...' - $initExitCode = -1 - Invoke-NativeCommand ` - -Command { & $wecom init --noninteractive } ` - -ExitCode ([ref]$initExitCode) - if ($initExitCode -ne 0) { - throw 'WeCom QR authorization failed.' - } -} - -Invoke-NativeCommand ` - -Command { & $wecom contact --help } ` - -ExitCode ([ref]$probeExitCode) ` - -DiscardOutput -if ($probeExitCode -ne 0) { - throw 'WeCom QR authorization did not produce a usable local configuration.' -} -Write-Host 'WeCom CLI is installed and locally configured.' +. (Join-Path $PSScriptRoot 'invoke-native-command.ps1') +$result = -1 +Invoke-NativeCommand -Command { + & (Join-Path $PSScriptRoot 'run-python.ps1') (Join-Path $PSScriptRoot 'wecom_cli.py') '--ready' +} -ExitCode ([ref]$result) +exit $result diff --git a/plugins/wecom/scripts/ensure-wecom-ready.sh b/plugins/wecom/scripts/ensure-wecom-ready.sh index 39353bb..12d70f7 100644 --- a/plugins/wecom/scripts/ensure-wecom-ready.sh +++ b/plugins/wecom/scripts/ensure-wecom-ready.sh @@ -1,21 +1,4 @@ #!/bin/sh - set -eu - WECOM_SCRIPT_DIRECTORY="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -WECOM_EXECUTABLE="$(/bin/sh "${WECOM_SCRIPT_DIRECTORY}/install-wecom-cli.sh")" - -unset WECOM_ACCESS_TOKEN WECOM_BOT_ID WECOM_SECRET - -if ! "${WECOM_EXECUTABLE}" contact --help >/dev/null 2>&1; then - echo "WeCom CLI has no usable local robot configuration. Opening QR authorization..." >&2 - "${WECOM_EXECUTABLE}" init --noninteractive -fi - -if ! "${WECOM_EXECUTABLE}" contact --help >/dev/null 2>&1; then - echo "WeCom QR authorization did not produce a usable local configuration." >&2 - exit 20 -fi - -echo "WeCom CLI is installed and locally configured." - +exec /bin/sh "${WECOM_SCRIPT_DIRECTORY}/run-python.sh" "${WECOM_SCRIPT_DIRECTORY}/wecom_cli.py" --ready "$@" diff --git a/plugins/wecom/scripts/local-auth.ps1 b/plugins/wecom/scripts/local-auth.ps1 new file mode 100644 index 0000000..cb02472 --- /dev/null +++ b/plugins/wecom/scripts/local-auth.ps1 @@ -0,0 +1,9 @@ +param([ValidateSet('health', 'login', 'logout')][string]$Action = 'health') +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. (Join-Path $PSScriptRoot 'invoke-native-command.ps1') +$result = -1 +Invoke-NativeCommand -Command { + & (Join-Path $PSScriptRoot 'run-python.ps1') (Join-Path $PSScriptRoot 'local_auth.py') $Action +} -ExitCode ([ref]$result) +exit $result diff --git a/plugins/wecom/scripts/local-auth.sh b/plugins/wecom/scripts/local-auth.sh new file mode 100644 index 0000000..8343b87 --- /dev/null +++ b/plugins/wecom/scripts/local-auth.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +WECOM_SCRIPT_DIRECTORY="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +exec /bin/sh "${WECOM_SCRIPT_DIRECTORY}/run-python.sh" "${WECOM_SCRIPT_DIRECTORY}/local_auth.py" "$@" diff --git a/plugins/wecom/scripts/local_auth.py b/plugins/wecom/scripts/local_auth.py new file mode 100644 index 0000000..b22fcd2 --- /dev/null +++ b/plugins/wecom/scripts/local_auth.py @@ -0,0 +1,47 @@ +"""Original WeCom QR login remains on the source device.""" + +import json +import sys +import native_runtime as native +from wegent_plugin_auth import AuthError + + +def login_locked(): + if native.invoke(["__wegent", "local-health"], capture=True).returncode: + result = native.invoke(["init", "--noninteractive"], capture=True, timeout=300) + if result.returncode: + raise AuthError("plugin_auth_login_failed") + if native.invoke(["__wegent", "local-health"], capture=True).returncode: + raise AuthError("plugin_auth_login_failed") + + +def action(command): + with native.locked(): + if command == "login": + login_locked() + return "ok" + if command == "logout": + if native.invoke(["__wegent", "local-clear"], capture=True).returncode: + raise AuthError("plugin_auth_logout_failed") + return "ok" + if command == "health": + return ( + "ok" + if native.invoke(["__wegent", "local-health"], capture=True).returncode + == 0 + else "need_login" + ) + raise AuthError("plugin_auth_invalid_command") + + +if __name__ == "__main__": + try: + if len(sys.argv) != 2: + raise AuthError("plugin_auth_invalid_command") + print(json.dumps({"status": action(sys.argv[1])})) + except Exception: + print( + json.dumps( + {"status": "error", "hint": "WeCom authentication did not complete."} + ) + ) diff --git a/plugins/wecom/scripts/native/darwin-amd64/UPSTREAM-LICENSE b/plugins/wecom/scripts/native/darwin-amd64/UPSTREAM-LICENSE new file mode 100644 index 0000000..5130a67 --- /dev/null +++ b/plugins/wecom/scripts/native/darwin-amd64/UPSTREAM-LICENSE @@ -0,0 +1,21 @@ +MIT License + +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/wecom/scripts/native/darwin-amd64/wecom-account-auth.json b/plugins/wecom/scripts/native/darwin-amd64/wecom-account-auth.json new file mode 100644 index 0000000..a24baba --- /dev/null +++ b/plugins/wecom/scripts/native/darwin-amd64/wecom-account-auth.json @@ -0,0 +1,15 @@ +{ + "nativeProtocolVersion": 1, + "target": "darwin/amd64", + "upstreamVersion": "0.1.9", + "upstreamRevision": "72e14f7695f34d28f1ff23ea504ddd2210a87c13", + "upstreamSourceSha256": "b8af1eeffd346646f1a1a20dbe11cb4ab50d12c26664ddfd433661402fe8840e", + "rustVersion": "1.94.1", + "sources": { + "build.py": "277c939743178afa829e3310a53f1bc53cf2d9af568e44f4859874e8d7fad3fa", + "wegent.rs": "4a73817badbe405089c097bc263f6ba4e5477614423307fedb5e063c8e2188d5" + }, + "binaryBytes": 7454816, + "binarySha256": "f3e06325335262714c56db8f16d16f12fd7ba3f61a89c170ff564a86d8c7d9b4", + "compressedSha256": "272bab6909d0d92cba2ce9e8b4cd26f171856994d057f077ff2b99a8ddaa83b5" +} diff --git a/plugins/wecom/scripts/native/darwin-amd64/wecom-account-auth.xz b/plugins/wecom/scripts/native/darwin-amd64/wecom-account-auth.xz new file mode 100644 index 0000000..009235e Binary files /dev/null and b/plugins/wecom/scripts/native/darwin-amd64/wecom-account-auth.xz differ diff --git a/plugins/wecom/scripts/native/darwin-arm64/UPSTREAM-LICENSE b/plugins/wecom/scripts/native/darwin-arm64/UPSTREAM-LICENSE new file mode 100644 index 0000000..5130a67 --- /dev/null +++ b/plugins/wecom/scripts/native/darwin-arm64/UPSTREAM-LICENSE @@ -0,0 +1,21 @@ +MIT License + +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/wecom/scripts/native/darwin-arm64/wecom-account-auth.json b/plugins/wecom/scripts/native/darwin-arm64/wecom-account-auth.json new file mode 100644 index 0000000..0a7e3ff --- /dev/null +++ b/plugins/wecom/scripts/native/darwin-arm64/wecom-account-auth.json @@ -0,0 +1,15 @@ +{ + "nativeProtocolVersion": 1, + "target": "darwin/arm64", + "upstreamVersion": "0.1.9", + "upstreamRevision": "72e14f7695f34d28f1ff23ea504ddd2210a87c13", + "upstreamSourceSha256": "b8af1eeffd346646f1a1a20dbe11cb4ab50d12c26664ddfd433661402fe8840e", + "rustVersion": "1.94.1", + "sources": { + "build.py": "277c939743178afa829e3310a53f1bc53cf2d9af568e44f4859874e8d7fad3fa", + "wegent.rs": "4a73817badbe405089c097bc263f6ba4e5477614423307fedb5e063c8e2188d5" + }, + "binaryBytes": 6348192, + "binarySha256": "a7352735578ec56dfe494a71c6ddce76a3dd0cf45f83d7bc9c4aa7032f042a6b", + "compressedSha256": "5f8e07c73d8b886dc03a93ec0199906279050433c472aec5cd426c3ab2d517b6" +} diff --git a/plugins/wecom/scripts/native/darwin-arm64/wecom-account-auth.xz b/plugins/wecom/scripts/native/darwin-arm64/wecom-account-auth.xz new file mode 100644 index 0000000..7d86ba8 Binary files /dev/null and b/plugins/wecom/scripts/native/darwin-arm64/wecom-account-auth.xz differ diff --git a/plugins/wecom/scripts/native/linux-amd64/UPSTREAM-LICENSE b/plugins/wecom/scripts/native/linux-amd64/UPSTREAM-LICENSE new file mode 100644 index 0000000..5130a67 --- /dev/null +++ b/plugins/wecom/scripts/native/linux-amd64/UPSTREAM-LICENSE @@ -0,0 +1,21 @@ +MIT License + +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/wecom/scripts/native/linux-amd64/wecom-account-auth.json b/plugins/wecom/scripts/native/linux-amd64/wecom-account-auth.json new file mode 100644 index 0000000..fde08ba --- /dev/null +++ b/plugins/wecom/scripts/native/linux-amd64/wecom-account-auth.json @@ -0,0 +1,15 @@ +{ + "nativeProtocolVersion": 1, + "target": "linux/amd64", + "upstreamVersion": "0.1.9", + "upstreamRevision": "72e14f7695f34d28f1ff23ea504ddd2210a87c13", + "upstreamSourceSha256": "b8af1eeffd346646f1a1a20dbe11cb4ab50d12c26664ddfd433661402fe8840e", + "rustVersion": "1.94.1", + "sources": { + "build.py": "277c939743178afa829e3310a53f1bc53cf2d9af568e44f4859874e8d7fad3fa", + "wegent.rs": "4a73817badbe405089c097bc263f6ba4e5477614423307fedb5e063c8e2188d5" + }, + "binaryBytes": 8031544, + "binarySha256": "31ad88fb6b8db7b62955abc65f60a02615de390a47d6b78bd0009cb1a76d138f", + "compressedSha256": "d261a88facf8faacd6f1d1f4dff6a5343f3ef5b6c5ed91a59b709c76ef9714d6" +} diff --git a/plugins/wecom/scripts/native/linux-amd64/wecom-account-auth.xz b/plugins/wecom/scripts/native/linux-amd64/wecom-account-auth.xz new file mode 100644 index 0000000..fa3e99f Binary files /dev/null and b/plugins/wecom/scripts/native/linux-amd64/wecom-account-auth.xz differ diff --git a/plugins/wecom/scripts/native/linux-arm64/UPSTREAM-LICENSE b/plugins/wecom/scripts/native/linux-arm64/UPSTREAM-LICENSE new file mode 100644 index 0000000..5130a67 --- /dev/null +++ b/plugins/wecom/scripts/native/linux-arm64/UPSTREAM-LICENSE @@ -0,0 +1,21 @@ +MIT License + +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/wecom/scripts/native/linux-arm64/wecom-account-auth.json b/plugins/wecom/scripts/native/linux-arm64/wecom-account-auth.json new file mode 100644 index 0000000..59b2dcf --- /dev/null +++ b/plugins/wecom/scripts/native/linux-arm64/wecom-account-auth.json @@ -0,0 +1,15 @@ +{ + "nativeProtocolVersion": 1, + "target": "linux/arm64", + "upstreamVersion": "0.1.9", + "upstreamRevision": "72e14f7695f34d28f1ff23ea504ddd2210a87c13", + "upstreamSourceSha256": "b8af1eeffd346646f1a1a20dbe11cb4ab50d12c26664ddfd433661402fe8840e", + "rustVersion": "1.94.1", + "sources": { + "build.py": "277c939743178afa829e3310a53f1bc53cf2d9af568e44f4859874e8d7fad3fa", + "wegent.rs": "4a73817badbe405089c097bc263f6ba4e5477614423307fedb5e063c8e2188d5" + }, + "binaryBytes": 6503224, + "binarySha256": "bfd4df1d4affc47fc456702ce187c1083b9c4b2f8bb2cbae2165bf3f4bbc99db", + "compressedSha256": "9c731ae87097b9b7002b69041c1aba43b52ca54eeeaf5adb2da0264781ca1d32" +} diff --git a/plugins/wecom/scripts/native/linux-arm64/wecom-account-auth.xz b/plugins/wecom/scripts/native/linux-arm64/wecom-account-auth.xz new file mode 100644 index 0000000..5301262 Binary files /dev/null and b/plugins/wecom/scripts/native/linux-arm64/wecom-account-auth.xz differ diff --git a/plugins/wecom/scripts/native/windows-amd64/UPSTREAM-LICENSE b/plugins/wecom/scripts/native/windows-amd64/UPSTREAM-LICENSE new file mode 100644 index 0000000..5130a67 --- /dev/null +++ b/plugins/wecom/scripts/native/windows-amd64/UPSTREAM-LICENSE @@ -0,0 +1,21 @@ +MIT License + +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/wecom/scripts/native/windows-amd64/wecom-account-auth.json b/plugins/wecom/scripts/native/windows-amd64/wecom-account-auth.json new file mode 100644 index 0000000..8e89ddc --- /dev/null +++ b/plugins/wecom/scripts/native/windows-amd64/wecom-account-auth.json @@ -0,0 +1,15 @@ +{ + "nativeProtocolVersion": 1, + "target": "windows/amd64", + "upstreamVersion": "0.1.9", + "upstreamRevision": "72e14f7695f34d28f1ff23ea504ddd2210a87c13", + "upstreamSourceSha256": "b8af1eeffd346646f1a1a20dbe11cb4ab50d12c26664ddfd433661402fe8840e", + "rustVersion": "1.94.1", + "sources": { + "build.py": "277c939743178afa829e3310a53f1bc53cf2d9af568e44f4859874e8d7fad3fa", + "wegent.rs": "4a73817badbe405089c097bc263f6ba4e5477614423307fedb5e063c8e2188d5" + }, + "binaryBytes": 6902784, + "binarySha256": "ade4c0eaff80109cced47aa1d65d5e53c599424ec208e4e0cebd7fd1b011fc0b", + "compressedSha256": "f64cc7dd8eeb6ab9ab4d79b42a15f368b6d4187bce8652111ce42b25c0e0eae0" +} diff --git a/plugins/wecom/scripts/native/windows-amd64/wecom-account-auth.xz b/plugins/wecom/scripts/native/windows-amd64/wecom-account-auth.xz new file mode 100644 index 0000000..0e9ebd4 Binary files /dev/null and b/plugins/wecom/scripts/native/windows-amd64/wecom-account-auth.xz differ diff --git a/plugins/wecom/scripts/native_runtime.py b/plugins/wecom/scripts/native_runtime.py new file mode 100644 index 0000000..ca44eef --- /dev/null +++ b/plugins/wecom/scripts/native_runtime.py @@ -0,0 +1,163 @@ +"""Verified native package loading and serialized access to the upstream store.""" + +import contextlib +import hashlib +import json +import lzma +import os +import platform +import subprocess +import tempfile +import time +from pathlib import Path + +from wegent_plugin_auth import AuthError, local_configuration + +ROOT = Path(__file__).resolve().parent + + +def state_root(): + path = Path.home() / ".wegent-executor/plugin-auth/wecom" + path.mkdir(parents=True, exist_ok=True, mode=0o700) + return path + + +@contextlib.contextmanager +def locked(): + # All profiles share one OS keychain namespace, including custom config dirs. + with (state_root() / "source.lock").open("a+b") as stream: + if stream.tell() == 0: + stream.write(b"0") + stream.flush() + stream.seek(0) + deadline = time.monotonic() + 30 + while True: + try: + if os.name == "nt": + import msvcrt + + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except OSError: + if time.monotonic() >= deadline: + raise AuthError("plugin_auth_source_busy") from None + time.sleep(0.05) + try: + yield + finally: + stream.seek(0) + if os.name == "nt": + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(stream, fcntl.LOCK_UN) + + +def environment(source=True): + allowed = { + "HOME", + "USERPROFILE", + "PATH", + "SYSTEMROOT", + "WINDIR", + "APPDATA", + "LOCALAPPDATA", + "TEMP", + "TMP", + "LANG", + "WEGENT_EXECUTOR_HOME", + "WEGENT_PLUGIN_AUTH_PORT", + "WECOM_CLI_CONFIG_DIR", + } + result = {key: value for key, value in os.environ.items() if key in allowed} + settings = local_configuration() + if set(settings) - {"WECOM_CLI_CONFIG_DIR"}: + raise AuthError("plugin_auth_invalid_local_configuration") + result.update(settings) + if not source: + result.pop("WECOM_CLI_CONFIG_DIR", None) + return result + + +def companion(): + system = {"Darwin": "darwin", "Linux": "linux", "Windows": "windows"}.get( + platform.system() + ) + arch = { + "arm64": "arm64", + "aarch64": "arm64", + "x86_64": "amd64", + "AMD64": "amd64", + }.get(platform.machine()) + folder = ROOT / "native" / f"{system}-{arch}" + blob, metadata = ( + folder / "wecom-account-auth.xz", + folder / "wecom-account-auth.json", + ) + if blob.is_symlink() or metadata.is_symlink() or metadata.stat().st_size > 65536: + raise AuthError("plugin_auth_package_sync_required") + value = json.loads(metadata.read_text(encoding="utf-8")) + if ( + value.get("nativeProtocolVersion") != 1 + or value.get("target") != f"{system}/{arch}" + or not 0 < value.get("binaryBytes", 0) < 80 * 1024 * 1024 + ): + raise AuthError("plugin_auth_package_sync_required") + packed = blob.read_bytes() + if hashlib.sha256(packed).hexdigest() != value["compressedSha256"]: + raise AuthError("plugin_auth_package_sync_required") + cache = ( + Path( + os.environ.get( + "WEGENT_EXECUTOR_HOME", str(Path.home() / ".wegent-executor") + ) + ) + / "plugin-native/wecom" + ) + cache.mkdir(parents=True, exist_ok=True, mode=0o700) + digest = value["binarySha256"] + if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest): + raise AuthError("plugin_auth_package_sync_required") + executable = cache / (digest + (".exe" if system == "windows" else "")) + if executable.is_symlink(): + raise AuthError("plugin_auth_package_sync_required") + if ( + executable.exists() + and hashlib.sha256(executable.read_bytes()).hexdigest() == digest + ): + return executable + decoder = lzma.LZMADecompressor(memlimit=128 * 1024 * 1024) + content = decoder.decompress(packed, max_length=value["binaryBytes"] + 1) + if ( + not decoder.eof + or len(content) != value["binaryBytes"] + or hashlib.sha256(content).hexdigest() != digest + ): + raise AuthError("plugin_auth_package_sync_required") + fd, temporary = tempfile.mkstemp(dir=cache) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, 0o700) + os.replace(temporary, executable) + finally: + Path(temporary).unlink(missing_ok=True) + return executable + + +def invoke(arguments, *, capture=False, timeout=240): + source = not ( + len(arguments) > 1 and arguments[0] == "__wegent" and arguments[1] == "run" + ) + return subprocess.run( + [str(companion()), *arguments], + env=environment(source=source), + capture_output=capture, + timeout=timeout, + check=False, + ) diff --git a/plugins/wecom/scripts/run-python.ps1 b/plugins/wecom/scripts/run-python.ps1 new file mode 100644 index 0000000..9574ca0 --- /dev/null +++ b/plugins/wecom/scripts/run-python.ps1 @@ -0,0 +1,16 @@ +param( + [Parameter(Mandatory = $true)][string]$ScriptPath, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$ScriptArguments +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. (Join-Path $PSScriptRoot 'invoke-native-command.ps1') +$python = Get-Command python, py, python3 -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($null -eq $python) { throw 'Python 3.9 or newer is required for this plugin.' } +$arguments = @() +if ($python.Name -match '^py(\.exe)?$') { $arguments += '-3' } +$arguments += $ScriptPath +$arguments += $ScriptArguments +$result = -1 +Invoke-NativeCommand -Command { & $python.Source @arguments } -ExitCode ([ref]$result) +exit $result diff --git a/plugins/wecom/scripts/run-python.sh b/plugins/wecom/scripts/run-python.sh new file mode 100644 index 0000000..9feaba9 --- /dev/null +++ b/plugins/wecom/scripts/run-python.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +if command -v python3 >/dev/null 2>&1; then + exec python3 "$@" +fi +if command -v python >/dev/null 2>&1; then + exec python "$@" +fi +echo 'Python 3.9 or newer is required for this plugin.' >&2 +exit 2 diff --git a/plugins/wecom/scripts/run-wecom-cli.ps1 b/plugins/wecom/scripts/run-wecom-cli.ps1 index ae50286..e6ff41d 100644 --- a/plugins/wecom/scripts/run-wecom-cli.ps1 +++ b/plugins/wecom/scripts/run-wecom-cli.ps1 @@ -1,20 +1,9 @@ -param( - [Parameter(ValueFromRemainingArguments = $true)] - [string[]]$WeComArguments -) - +param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest - -$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path $scriptDirectory 'invoke-native-command.ps1') -$wecom = & (Join-Path $scriptDirectory 'install-wecom-cli.ps1') -PrintPath | - Select-Object -Last 1 -foreach ($name in @('WECOM_ACCESS_TOKEN', 'WECOM_BOT_ID', 'WECOM_SECRET')) { - Remove-Item "Env:$name" -ErrorAction SilentlyContinue -} -$wecomExitCode = -1 -Invoke-NativeCommand ` - -Command { & $wecom @WeComArguments } ` - -ExitCode ([ref]$wecomExitCode) -exit $wecomExitCode +. (Join-Path $PSScriptRoot 'invoke-native-command.ps1') +$result = -1 +Invoke-NativeCommand -Command { + & (Join-Path $PSScriptRoot 'run-python.ps1') (Join-Path $PSScriptRoot 'wecom_cli.py') @Arguments +} -ExitCode ([ref]$result) +exit $result diff --git a/plugins/wecom/scripts/run-wecom-cli.sh b/plugins/wecom/scripts/run-wecom-cli.sh index 9f36ed0..2c5a086 100644 --- a/plugins/wecom/scripts/run-wecom-cli.sh +++ b/plugins/wecom/scripts/run-wecom-cli.sh @@ -1,10 +1,4 @@ #!/bin/sh - set -eu - WECOM_SCRIPT_DIRECTORY="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -WECOM_EXECUTABLE="$(/bin/sh "${WECOM_SCRIPT_DIRECTORY}/install-wecom-cli.sh")" - -unset WECOM_ACCESS_TOKEN WECOM_BOT_ID WECOM_SECRET -exec "${WECOM_EXECUTABLE}" "$@" - +exec /bin/sh "${WECOM_SCRIPT_DIRECTORY}/run-python.sh" "${WECOM_SCRIPT_DIRECTORY}/wecom_cli.py" "$@" diff --git a/plugins/wecom/scripts/tests/test_runtime.py b/plugins/wecom/scripts/tests/test_runtime.py new file mode 100644 index 0000000..b832e79 --- /dev/null +++ b/plugins/wecom/scripts/tests/test_runtime.py @@ -0,0 +1,149 @@ +import contextlib +import http.server +import json +import os +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) +import wecom_cli +import local_auth +import native_runtime as native +from wegent_plugin_auth import AuthError + + +class RuntimeTests(unittest.TestCase): + def test_cloud_process_only_sends_public_broker_request(self): + requests = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + requests.append( + json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + ) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"stdout":"{\\"status\\":\\"ok\\"}"}') + + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + capabilities = home / "executor/capabilities" + capabilities.mkdir(parents=True) + (capabilities / "manifest.json").write_text( + json.dumps( + { + "plugins": { + "wecom": { + "managed": True, + "enabled": True, + "installed_plugin_id": 123, + "store_path": str(SCRIPTS.parent), + } + } + } + ) + ) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + env = { + **os.environ, + "HOME": str(home), + "USERPROFILE": str(home), + "WEGENT_EXECUTOR_HOME": str(home / "executor"), + "WEGENT_PLUGIN_AUTH_MODE": "cloud", + "WEGENT_PLUGIN_AUTH_BROKER": f"http://127.0.0.1:{server.server_port}/v1/run", + "WEGENT_PLUGIN_AUTH_BROKER_TOKEN": "a" * 64, + } + try: + for args, slug in [ + (["msg", "get_msg_chat_list", "{}"], "wecom"), + (["--ready"], "wecom"), + ]: + result = subprocess.run( + [sys.executable, str(SCRIPTS / "wecom_cli.py"), *args], + env=env, + capture_output=True, + timeout=15, + ) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(requests[-1]["connector_slug"], slug) + self.assertNotIn("credential", requests[-1]) + self.assertFalse((home / ".wegent-executor").exists()) + finally: + server.shutdown() + server.server_close() + + def test_source_lock_blocks_another_real_process(self): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + env = {**os.environ, "HOME": str(home), "USERPROFILE": str(home)} + code = f"import sys;sys.path.insert(0,{str(SCRIPTS)!r});import native_runtime as n;\nwith n.locked(): print('locked',flush=True);sys.stdin.read()" + child = subprocess.Popen( + [sys.executable, "-c", code], + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + try: + self.assertEqual(child.stdout.readline().rstrip(b"\r\n"), b"locked") + probe = subprocess.Popen( + [ + sys.executable, + "-c", + f"import sys;sys.path.insert(0,{str(SCRIPTS)!r});import native_runtime as n;\nwith n.locked(): print('entered',flush=True)", + ], + env=env, + stdout=subprocess.PIPE, + ) + time.sleep(0.15) + self.assertIsNone(probe.poll()) + child.communicate(timeout=5) + out, _ = probe.communicate(timeout=5) + self.assertEqual(out.rstrip(b"\r\n"), b"entered") + finally: + if child.poll() is None: + child.kill() + child.communicate() + + def test_cloud_disallows_login_and_configuration(self): + for arguments in (["init"], ["auth", "login"], ["cache", "clear"]): + with self.assertRaises(AuthError): + wecom_cli.business_args(arguments) + self.assertEqual(wecom_cli.business_args(["auth", "show"]), ["account-status"]) + + def test_logout_propagates_cleanup_failure(self): + with patch.object(native, "locked", contextlib.nullcontext), patch.object( + native, "invoke", return_value=subprocess.CompletedProcess([], 1) + ): + with self.assertRaises(AuthError): + local_auth.action("logout") + + def test_source_environment_drops_overrides_and_business_config(self): + with patch.dict( + os.environ, + { + "WECOM_CLI_CONFIG_DIR": "synthetic", + "WECOM_SECRET": "synthetic-secret", + "WECOM_CLI_MCP_CONFIG_ENDPOINT": "https://evil.invalid", + "HTTPS_PROXY": "https://evil.invalid", + }, + clear=True, + ): + self.assertEqual(native.environment()["WECOM_CLI_CONFIG_DIR"], "synthetic") + self.assertNotIn("WECOM_CLI_CONFIG_DIR", native.environment(source=False)) + for name in ( + "WECOM_SECRET", + "WECOM_CLI_MCP_CONFIG_ENDPOINT", + "HTTPS_PROXY", + ): + self.assertNotIn(name, native.environment()) diff --git a/plugins/wecom/scripts/wecom_cli.py b/plugins/wecom/scripts/wecom_cli.py new file mode 100644 index 0000000..7512d9f --- /dev/null +++ b/plugins/wecom/scripts/wecom_cli.py @@ -0,0 +1,50 @@ +"""Route cloud commands before touching the source device or native package.""" + +import json +import os +import sys +from pathlib import Path +import native_runtime as native +from wegent_plugin_auth import AuthError, run_account_command + +ROOT = Path(__file__).resolve().parents[1] + + +def business_args(arguments): + if arguments == ["--ready"] or arguments[:2] == ["auth", "show"]: + return ["account-status"] + if not arguments or arguments[0] not in { + "contact", + "doc", + "meeting", + "msg", + "schedule", + "todo", + }: + raise AuthError("plugin_auth_use_native_login") + return arguments + + +def main(arguments): + if os.environ.get("WEGENT_PLUGIN_AUTH_MODE") == "cloud": + sys.stdout.write(run_account_command(ROOT, "wecom", business_args(arguments))) + return 0 + with native.locked(): + if arguments == ["--ready"]: + from local_auth import login_locked + + login_locked() + print(json.dumps({"status": "ok"})) + return 0 + return native.invoke(arguments).returncode + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv[1:])) + except AuthError as error: + print(json.dumps({"error": str(error)})) + raise SystemExit(1) + except Exception: + print(json.dumps({"error": "plugin_auth_wecom_failed"})) + raise SystemExit(1) diff --git a/plugins/wecom/scripts/wegent_plugin_auth/LICENSE b/plugins/wecom/scripts/wegent_plugin_auth/LICENSE new file mode 100644 index 0000000..b8c67ae --- /dev/null +++ b/plugins/wecom/scripts/wegent_plugin_auth/LICENSE @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright 2025 Weibo, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/plugins/wecom/scripts/wegent_plugin_auth/__init__.py b/plugins/wecom/scripts/wegent_plugin_auth/__init__.py new file mode 100644 index 0000000..762bf3c --- /dev/null +++ b/plugins/wecom/scripts/wegent_plugin_auth/__init__.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Dependency-free native plugin credential transport (protocol draft 1).""" + +from .adapter import AccountAuthAdapter, AuthError, SourceChanged +from .configuration import local_configuration +from .runtime import delegate_cloud_command, run_account_command + +__version__ = "0.7.1" +__all__ = [ + "AccountAuthAdapter", + "AuthError", + "SourceChanged", + "local_configuration", + "delegate_cloud_command", + "run_account_command", +] diff --git a/plugins/wecom/scripts/wegent_plugin_auth/adapter.py b/plugins/wecom/scripts/wegent_plugin_auth/adapter.py new file mode 100644 index 0000000..9813281 --- /dev/null +++ b/plugins/wecom/scripts/wegent_plugin_auth/adapter.py @@ -0,0 +1,195 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Plugin callbacks without descriptor, framing, or envelope boilerplate.""" + +from __future__ import annotations + +import contextlib +import json +import os +from collections.abc import Callable, Sequence +from typing import Any, BinaryIO, Literal + +from .runtime import native_execution_scope +from .transport import AuthError, open_pipe, read_frame, write_frame + +Credential = dict[str, Any] +CredentialType = Literal["password", "bearer", "oauth2"] +_REQUIRED_FIELDS = { + "password": ("username", "password"), + "bearer": ("token",), + "oauth2": ("access_token",), +} + + +class SourceChanged(AuthError): + """Detach durably fenced off this transfer ID without deleting the new grant.""" + + +@contextlib.contextmanager +def _quiet_callbacks(): + # Authentication callbacks may accidentally print upstream errors or secrets. + # Business command output remains owned by the plugin's execute callback. + with open(os.devnull, "w") as sink: + with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink): + yield + + +class AccountAuthAdapter: + """Use only in a dedicated process launched by an authenticated native broker. + + OAuth tokens can be transported, but refresh ownership remains the host's + responsibility. This SDK does not authorize devices or migrate local storage. + """ + + def __init__( + self, + *, + connector_slug: str, + credential_type: CredentialType, + export: Callable[[], Credential | None], + account_id: Callable[[Credential], str], + execute: Callable[[Credential, Sequence[str]], int], + allowed_commands: Sequence[str], + validate: Callable[[Credential], None] | None = None, + authorize: Callable[[], Credential] | None = None, + refresh: Callable[[Credential], Credential] | None = None, + revoke: Callable[[Credential], None] | None = None, + detach: Callable[[str, Credential], None] | None = None, + ) -> None: + if credential_type not in _REQUIRED_FIELDS or not connector_slug: + raise AuthError("Invalid adapter definition") + self.connector_slug = connector_slug + self.credential_type = credential_type + self._export = export + self._account_id = account_id + self._execute = execute + self._validate = validate + self._allowed_commands = frozenset(allowed_commands) + if credential_type != "oauth2" and any((authorize, refresh, revoke, detach)): + raise AuthError("OAuth callbacks require an OAuth adapter") + self._authorize = authorize + self._refresh = refresh + self._revoke = revoke + self._detach = detach + + def _validate_credential(self, value: Any) -> Credential: + try: + if not isinstance(value, dict): + raise ValueError + for key in _REQUIRED_FIELDS[self.credential_type]: + if not isinstance(value.get(key), str) or not value[key].strip(): + raise ValueError + if self._validate is not None: + with _quiet_callbacks(): + self._validate(value) + return value + except (Exception, SystemExit): + raise AuthError("Invalid credential payload") from None + + def read_credential(self, stream: BinaryIO) -> Credential: + payload = read_frame(stream) + if ( + set(payload) + != {"protocolVersion", "connectorSlug", "credentialType", "credential"} + or type(payload.get("protocolVersion")) is not int + or payload["protocolVersion"] != 1 + or payload["connectorSlug"] != self.connector_slug + or payload["credentialType"] != self.credential_type + ): + raise AuthError("Invalid credential envelope") + return self._validate_credential(payload["credential"]) + + def export_credential(self, stream: BinaryIO, exporter=None) -> dict[str, Any]: + try: + with _quiet_callbacks(): + credential = self._validate_credential((exporter or self._export)()) + account_id = self._account_id(credential) + if not isinstance(account_id, str) or not account_id.strip(): + raise ValueError + except (Exception, SystemExit): + raise AuthError("Local authentication is unavailable") from None + write_frame( + stream, + { + "protocolVersion": 1, + "connectorSlug": self.connector_slug, + "credentialType": self.credential_type, + "credential": credential, + }, + ) + return { + "status": "ok", + "protocolVersion": 1, + "accountId": account_id, + "credentialType": self.credential_type, + } + + def main(self, argv: Sequence[str]) -> int: + try: + if ( + len(argv) == 2 + and argv[0] == "detach" + and self._detach is not None + and len(argv[1]) == 64 + and all(c in "0123456789abcdef" for c in argv[1]) + ): + with open_pipe("rb") as stream: + credential = self.read_credential(stream) + status = "detached" + try: + with _quiet_callbacks(): + self._detach(argv[1], credential) + except SourceChanged: + status = "source_changed" + print(json.dumps({"status": status, "protocolVersion": 1})) + return 0 + if list(argv) == ["authorize"] and self._authorize is not None: + with open_pipe("wb") as stream: + metadata = self.export_credential(stream, self._authorize) + print(json.dumps(metadata, ensure_ascii=False)) + return 0 + if list(argv) == ["refresh"] and self._refresh is not None: + with open_pipe("rwb") as stream: + credential = self.read_credential(stream) + with _quiet_callbacks(): + previous_id = self._account_id(credential) + update = self._refresh(dict(credential)) + if ( + not isinstance(update, dict) + or not update.get("access_token") + or "expires_at" not in update + ): + raise AuthError( + "OAuth refresh did not return a fresh access token" + ) + refreshed = self._validate_credential({**credential, **update}) + if self._account_id(refreshed) != previous_id: + raise AuthError("OAuth account changed during refresh") + metadata = self.export_credential(stream, lambda: refreshed) + print(json.dumps(metadata, ensure_ascii=False)) + return 0 + if list(argv) == ["revoke"] and self._revoke is not None: + with open_pipe("rb") as stream: + credential = self.read_credential(stream) + with _quiet_callbacks(): + self._revoke(credential) + print(json.dumps({"status": "ok", "protocolVersion": 1})) + return 0 + if list(argv) == ["export"]: + with open_pipe("wb") as stream: + metadata = self.export_credential(stream) + print(json.dumps(metadata, ensure_ascii=False)) + return 0 + if ( + len(argv) >= 2 + and argv[0] == "run" + and argv[1] in self._allowed_commands + ): + with open_pipe("rb") as stream: + credential = self.read_credential(stream) + with native_execution_scope(): + return self._execute(credential, argv[1:]) + raise AuthError("Unsupported adapter operation") + except (Exception, SystemExit): + print(json.dumps({"status": "error", "code": "plugin_auth_adapter_failed"})) + return 1 diff --git a/plugins/wecom/scripts/wegent_plugin_auth/configuration.py b/plugins/wecom/scripts/wegent_plugin_auth/configuration.py new file mode 100644 index 0000000..e2e206e --- /dev/null +++ b/plugins/wecom/scripts/wegent_plugin_auth/configuration.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Read host-validated, non-secret configuration for local auth callbacks.""" + +from __future__ import annotations + +import json +import os +import re + +from .transport import AuthError, _unique_object + + +def local_configuration() -> dict[str, str]: + """Return declared local settings; never merge them into process environment.""" + raw = os.environ.get("WEGENT_PLUGIN_AUTH_LOCAL_CONFIGURATION") + if raw is None: + return {} + try: + if len(raw.encode("utf-8")) > 16384: + raise ValueError + value = json.loads(raw, object_pairs_hook=_unique_object) + if ( + not isinstance(value, dict) + or len(value) > 16 + or any( + not re.fullmatch(r"[A-Z][A-Z0-9_]{0,63}", name) + or not isinstance(setting, str) + or not setting + or len(setting.encode("utf-8")) > 4096 + or "\x00" in setting + for name, setting in value.items() + ) + ): + raise ValueError + return value + except (ValueError, TypeError, RecursionError): + raise AuthError("Invalid local authentication configuration") from None diff --git a/plugins/wecom/scripts/wegent_plugin_auth/runtime.py b/plugins/wecom/scripts/wegent_plugin_auth/runtime.py new file mode 100644 index 0000000..b703176 --- /dev/null +++ b/plugins/wecom/scripts/wegent_plugin_auth/runtime.py @@ -0,0 +1,184 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Delegate cloud business commands to the local native credential broker.""" + +from __future__ import annotations + +import contextlib +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from contextvars import ContextVar +from pathlib import Path +from typing import Iterator, Optional, Sequence + +from .transport import AuthError + +MAX_RESPONSE_BYTES = 8 * 1024 * 1024 +_NATIVE_EXECUTION: ContextVar[bool] = ContextVar( + "wegent_native_execution", default=False +) + + +@contextlib.contextmanager +def native_execution_scope(): + token = _NATIVE_EXECUTION.set(True) + try: + yield + finally: + _NATIVE_EXECUTION.reset(token) + + +PUBLIC_ERRORS = frozenset( + { + "plugin_auth_device_not_granted", + "plugin_auth_refresh_in_progress", + "plugin_auth_reconnect_required", + "plugin_auth_account_selection_required", + "plugin_auth_backend_unavailable", + "plugin_auth_revision_conflict", + "plugin_auth_package_sync_required", + "plugin_auth_broker_busy", + "plugin_auth_invalid_command", + "plugin_auth_exchange_rejected", + } +) + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise AuthError("plugin_auth_broker_unavailable") + + +def _entry_roots(entry: dict, capabilities: Path) -> Iterator[Path]: + """Use only paths recorded by the host, including its runtime copies.""" + paths = [entry.get("store_path")] + runtime_paths = entry.get("runtime") + if isinstance(runtime_paths, dict): + paths.extend(runtime_paths.get(key) for key in ("codex_link", "claude_link")) + for value in paths: + if not isinstance(value, str) or not value: + continue + path = Path(value) + path = path if path.is_absolute() else capabilities / path + yield path.resolve() + + +def _installed_id(plugin_root: Path) -> int: + home = os.environ.get("WEGENT_EXECUTOR_HOME", "") + if not home: + raise AuthError("plugin_auth_package_sync_required") + capabilities = Path(home) / "capabilities" + manifest = capabilities / "manifest.json" + if manifest.is_symlink() or manifest.stat().st_size > 8 * 1024 * 1024: + raise AuthError("plugin_auth_package_sync_required") + entries = json.loads(manifest.read_text(encoding="utf-8"))["plugins"] + root = plugin_root.resolve(strict=True) + matches = [] + for entry in entries.values(): + if entry.get("managed") is not True or entry.get("enabled") is not True: + continue + if root in _entry_roots(entry, capabilities): + matches.append(entry["installed_plugin_id"]) + if len(matches) != 1 or type(matches[0]) is not int or matches[0] <= 0: + raise AuthError("plugin_auth_package_sync_required") + return matches[0] + + +def run_account_command( + plugin_root: Path, + connector_slug: str, + arguments: Sequence[str], + *, + account_id: Optional[str] = None, +) -> str: + """Return business stdout, never credentials or native provider errors.""" + try: + url = os.environ.get("WEGENT_PLUGIN_AUTH_BROKER", "") + token = os.environ.get("WEGENT_PLUGIN_AUTH_BROKER_TOKEN", "") + parsed = urllib.parse.urlsplit(url) + if ( + parsed.scheme != "http" + or parsed.hostname != "127.0.0.1" + or not parsed.port + or parsed.username + or parsed.password + or parsed.path != "/v1/run" + or parsed.query + or parsed.fragment + or len(token) != 64 + or any(c not in "0123456789abcdef" for c in token) + ): + raise AuthError("plugin_auth_broker_unavailable") + payload = json.dumps( + { + "installed_plugin_id": _installed_id(Path(plugin_root)), + "connector_slug": connector_slug, + "account_id": account_id, + "args": list(arguments), + "working_directory": str(Path.cwd()), + } + ).encode("utf-8") + if len(payload) > 65_536: + raise AuthError("plugin_auth_invalid_command") + request = urllib.request.Request( + url, + data=payload, + headers={ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + }, + method="POST", + ) + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), _NoRedirect() + ) + try: + response = opener.open(request, timeout=200) + except urllib.error.HTTPError as error: + response = error + with response: + data = response.read(MAX_RESPONSE_BYTES + 1) + if len(data) > MAX_RESPONSE_BYTES: + raise AuthError("plugin_auth_invalid_output") + result = json.loads(data) + if not isinstance(result, dict): + raise AuthError("plugin_auth_invalid_output") + if response.status != 200: + code = result.get("error") + raise AuthError( + code if code in PUBLIC_ERRORS else "plugin_auth_execution_failed" + ) + if set(result) != {"stdout"} or not isinstance(result["stdout"], str): + raise AuthError("plugin_auth_invalid_output") + return result["stdout"] + except AuthError: + raise + except Exception: + raise AuthError("plugin_auth_broker_unavailable") from None + + +def delegate_cloud_command( + plugin_root: Path, + connector_slug: str, + arguments: Sequence[str], + *, + account_id: Optional[str] = None, +) -> Optional[int]: + """Call before local auth access. None means this is an ordinary local run.""" + if _NATIVE_EXECUTION.get(): + return None + if os.environ.get("WEGENT_PLUGIN_AUTH_MODE") != "cloud" and account_id is None: + return None + try: + sys.stdout.write( + run_account_command( + plugin_root, connector_slug, arguments, account_id=account_id + ) + ) + return 0 + except AuthError as error: + print(json.dumps({"error": str(error)})) + return 1 diff --git a/plugins/wecom/scripts/wegent_plugin_auth/transport.py b/plugins/wecom/scripts/wegent_plugin_auth/transport.py new file mode 100644 index 0000000..6008070 --- /dev/null +++ b/plugins/wecom/scripts/wegent_plugin_auth/transport.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Bounded frames over a host-owned descriptor; never a model-visible channel.""" + +from __future__ import annotations + +import json +import os +import socket +import stat +import struct +import sys +from typing import Any, BinaryIO + +MAX_FRAME_BYTES = 65536 + + +class AuthError(RuntimeError): + """A failure whose message contains no credential material.""" + + +def open_pipe(mode: str) -> BinaryIO: + try: + if mode not in {"rb", "wb", "rwb"}: + raise ValueError + if "WEGENT_PLUGIN_AUTH_PORT" in os.environ: + return _open_socket(mode) + fd = int(os.environ["WEGENT_PLUGIN_AUTH_FD"]) + if fd < 3: + raise ValueError + info = os.fstat(fd) + if not (stat.S_ISFIFO(info.st_mode) or stat.S_ISSOCK(info.st_mode)): + raise ValueError + if mode == "rwb" and not stat.S_ISSOCK(info.st_mode): + raise ValueError + os.set_inheritable(fd, False) + return os.fdopen(fd, "r+b" if mode == "rwb" else mode) + except (KeyError, ValueError, OSError): + raise AuthError("A dedicated broker pipe is required") from None + + +def _open_socket(mode: str) -> BinaryIO: + """Cross-platform native transport; stdin carries a one-use capability only.""" + if "WEGENT_PLUGIN_AUTH_FD" in os.environ: + raise AuthError("Ambiguous broker transport") + port = int(os.environ.pop("WEGENT_PLUGIN_AUTH_PORT")) + if not 1 <= port <= 65535: + raise AuthError("Invalid broker transport") + nonce = _read_exact(sys.stdin.buffer, 32) + with socket.create_connection(("127.0.0.1", port), timeout=5) as connection: + connection.set_inheritable(False) + connection.sendall(nonce) + return connection.makefile(mode) + + +def _read_exact(stream: BinaryIO, length: int) -> bytes: + result = bytearray() + while len(result) < length: + chunk = stream.read(length - len(result)) + if not chunk: + raise AuthError("Incomplete credential frame") + result.extend(chunk) + return bytes(result) + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError + result[key] = value + return result + + +def read_frame(stream: BinaryIO) -> dict[str, Any]: + size = struct.unpack("!I", _read_exact(stream, 4))[0] + if not 1 <= size <= MAX_FRAME_BYTES: + raise AuthError("Invalid credential frame size") + raw = _read_exact(stream, size) + try: + payload = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_object) + if not isinstance(payload, dict): + raise ValueError + # Reject NaN/Infinity, including nested values accepted by json.loads. + json.dumps(payload, allow_nan=False) + return payload + except (ValueError, TypeError, RecursionError): + raise AuthError("Invalid credential frame") from None + + +def write_frame(stream: BinaryIO, payload: dict[str, Any]) -> None: + try: + raw = json.dumps( + payload, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + except (ValueError, TypeError, RecursionError): + raise AuthError("Invalid credential frame") from None + if not 1 <= len(raw) <= MAX_FRAME_BYTES: + raise AuthError("Invalid credential frame size") + data = struct.pack("!I", len(raw)) + raw + offset = 0 + while offset < len(data): + count = stream.write(data[offset:]) + if count is None or count <= 0: + raise AuthError("Incomplete credential frame") + offset += count + stream.flush() diff --git a/plugins/wecom/scripts/wegent_plugin_auth/vendor.json b/plugins/wecom/scripts/wegent_plugin_auth/vendor.json new file mode 100644 index 0000000..5e66d36 --- /dev/null +++ b/plugins/wecom/scripts/wegent_plugin_auth/vendor.json @@ -0,0 +1,13 @@ +{ + "sdk": "wegent-plugin-auth-python", + "version": "0.7.1", + "protocolVersion": 1, + "files": { + "LICENSE": "e7e430d6f6693d9e1fe09961d873d7c8f6938bfd57746d60d18b895c62b15760", + "__init__.py": "b6ce9a286c0a06ecfe0652d5045e488eba98bcc2aad41f5ebd50e4b3aa28c98c", + "adapter.py": "c51549ca0e1503f6d714a52bdf6ddc265e4067aa8d4470e5c6c9077d10f5d8e6", + "configuration.py": "0bb293d2c82db21c5eb19a88cea884fa758700c4350e93baa238b87c5d5e08ae", + "runtime.py": "3fbe06a3334acf36fe9687cc9dfbdc802d804982b51ddf064880ec68e3adc84d", + "transport.py": "664e4a848c9fea879f729a967191c37d7ab02a0180c0f02bced4cd62c4199c34" + } +} diff --git a/plugins/wecom/skills/wecomcli-contact/SKILL.md b/plugins/wecom/skills/wecomcli-contact/SKILL.md index 5ff1286..2892fa0 100644 --- a/plugins/wecom/skills/wecomcli-contact/SKILL.md +++ b/plugins/wecom/skills/wecomcli-contact/SKILL.md @@ -9,11 +9,11 @@ description: 通讯录成员查询技能,获取当前用户可见范围内的 获取当前用户可见范围内的通讯录成员,并在本地按姓名/别名进行筛选匹配。 -## Wegent 本地运行 +## Wegent 本地与云端运行 - 当前 `SKILL.md` 所在目录的 `../..` 是插件根目录。首次调用前,macOS/Linux 运行 `sh "<插件根目录>/scripts/ensure-wecom-ready.sh"`;Windows 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\ensure-wecom-ready.ps1"`。 - 下文的 `wecom-cli ...` 是逻辑命令。实际执行时,macOS/Linux 使用 `sh "<插件根目录>/scripts/run-wecom-cli.sh" ...`;Windows 使用 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\run-wecom-cli.ps1" ...`。 -- 未授权时初始化脚本会打开企业微信官方网页并显示二维码,只在需要用户扫码、企业权限或内网/VPN 时暂停。不要要求用户在对话中粘贴 Bot ID、Secret 或 Access Token,也不要读取、记录或上传 `~/.config/wecom` 中的本地认证配置。 +- 本地未授权时通过 Wegent 原生连接在官方浏览器页面扫码。Bot ID 和 Secret 由私有适配器同步,云端使用托管连接,无需再次扫码;云端禁止运行安装、`init` 或读取本地认证配置。认证失效时返回来源设备重新连接。不要要求用户在对话中粘贴凭据。 ## 操作 diff --git a/plugins/wecom/skills/wecomcli-doc/SKILL.md b/plugins/wecom/skills/wecomcli-doc/SKILL.md index 730a4e7..af9f82f 100644 --- a/plugins/wecom/skills/wecomcli-doc/SKILL.md +++ b/plugins/wecom/skills/wecomcli-doc/SKILL.md @@ -9,11 +9,11 @@ description: 企业微信文档(doc)管理技能。提供普通文档的新 资源型技能,负责普通doc文档的新建、内容读取与覆写。文档接口支持通过 `docid` 或 `url` 二选一定位文档。 -## Wegent 本地运行 +## Wegent 本地与云端运行 - 当前 `SKILL.md` 所在目录的 `../..` 是插件根目录。首次调用前,macOS/Linux 运行 `sh "<插件根目录>/scripts/ensure-wecom-ready.sh"`;Windows 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\ensure-wecom-ready.ps1"`。 - 下文的 `wecom-cli ...` 是逻辑命令。实际执行时,macOS/Linux 使用 `sh "<插件根目录>/scripts/run-wecom-cli.sh" ...`;Windows 使用 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\run-wecom-cli.ps1" ...`。 -- 未授权时初始化脚本会打开企业微信官方网页并显示二维码,只在需要用户扫码、企业权限或内网/VPN 时暂停。不要要求用户在对话中粘贴 Bot ID、Secret 或 Access Token,也不要读取、记录或上传 `~/.config/wecom` 中的本地认证配置。 +- 本地未授权时通过 Wegent 原生连接在官方浏览器页面扫码。Bot ID 和 Secret 由私有适配器同步,云端使用托管连接,无需再次扫码;云端禁止运行安装、`init` 或读取本地认证配置。认证失效时返回来源设备重新连接。不要要求用户在对话中粘贴凭据。 ## URL 品类识别与接口路由 diff --git a/plugins/wecom/skills/wecomcli-meeting/SKILL.md b/plugins/wecom/skills/wecomcli-meeting/SKILL.md index 6dc232c..b9a6472 100644 --- a/plugins/wecom/skills/wecomcli-meeting/SKILL.md +++ b/plugins/wecom/skills/wecomcli-meeting/SKILL.md @@ -6,11 +6,11 @@ description: 企业微信会议技能,支持创建预约会议、查询会议 > `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。 -## Wegent 本地运行 +## Wegent 本地与云端运行 - 当前 `SKILL.md` 所在目录的 `../..` 是插件根目录。首次调用前,macOS/Linux 运行 `sh "<插件根目录>/scripts/ensure-wecom-ready.sh"`;Windows 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\ensure-wecom-ready.ps1"`。 - 下文的 `wecom-cli ...` 是逻辑命令。实际执行时,macOS/Linux 使用 `sh "<插件根目录>/scripts/run-wecom-cli.sh" ...`;Windows 使用 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\run-wecom-cli.ps1" ...`。 -- 未授权时初始化脚本会打开企业微信官方网页并显示二维码,只在需要用户扫码、企业权限或内网/VPN 时暂停。不要要求用户在对话中粘贴 Bot ID、Secret 或 Access Token,也不要读取、记录或上传 `~/.config/wecom` 中的本地认证配置。 +- 本地未授权时通过 Wegent 原生连接在官方浏览器页面扫码。Bot ID 和 Secret 由私有适配器同步,云端使用托管连接,无需再次扫码;云端禁止运行安装、`init` 或读取本地认证配置。认证失效时返回来源设备重新连接。不要要求用户在对话中粘贴凭据。 ## 概述 diff --git a/plugins/wecom/skills/wecomcli-msg/SKILL.md b/plugins/wecom/skills/wecomcli-msg/SKILL.md index ab96797..ba15d8a 100644 --- a/plugins/wecom/skills/wecomcli-msg/SKILL.md +++ b/plugins/wecom/skills/wecomcli-msg/SKILL.md @@ -7,11 +7,11 @@ description: 企业微信消息技能。提供会话列表查询、消息记录 > `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。 -## Wegent 本地运行 +## Wegent 本地与云端运行 - 当前 `SKILL.md` 所在目录的 `../..` 是插件根目录。首次调用前,macOS/Linux 运行 `sh "<插件根目录>/scripts/ensure-wecom-ready.sh"`;Windows 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\ensure-wecom-ready.ps1"`。 - 下文的 `wecom-cli ...` 是逻辑命令。实际执行时,macOS/Linux 使用 `sh "<插件根目录>/scripts/run-wecom-cli.sh" ...`;Windows 使用 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\run-wecom-cli.ps1" ...`。 -- 未授权时初始化脚本会打开企业微信官方网页并显示二维码,只在需要用户扫码、企业权限或内网/VPN 时暂停。不要要求用户在对话中粘贴 Bot ID、Secret 或 Access Token,也不要读取、记录或上传 `~/.config/wecom` 中的本地认证配置。 +- 本地未授权时通过 Wegent 原生连接在官方浏览器页面扫码。Bot ID 和 Secret 由私有适配器同步,云端使用托管连接,无需再次扫码;云端禁止运行安装、`init` 或读取本地认证配置。认证失效时返回来源设备重新连接。不要要求用户在对话中粘贴凭据。 通过 `wecom-cli msg <接口名> ''` 与企业微信消息系统交互。 diff --git a/plugins/wecom/skills/wecomcli-schedule/SKILL.md b/plugins/wecom/skills/wecomcli-schedule/SKILL.md index 141a575..1a72556 100644 --- a/plugins/wecom/skills/wecomcli-schedule/SKILL.md +++ b/plugins/wecom/skills/wecomcli-schedule/SKILL.md @@ -7,11 +7,11 @@ description: 企业微信日程管理技能。适用于用户对企业微信日 > `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。 -## Wegent 本地运行 +## Wegent 本地与云端运行 - 当前 `SKILL.md` 所在目录的 `../..` 是插件根目录。首次调用前,macOS/Linux 运行 `sh "<插件根目录>/scripts/ensure-wecom-ready.sh"`;Windows 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\ensure-wecom-ready.ps1"`。 - 下文的 `wecom-cli ...` 是逻辑命令。实际执行时,macOS/Linux 使用 `sh "<插件根目录>/scripts/run-wecom-cli.sh" ...`;Windows 使用 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\run-wecom-cli.ps1" ...`。 -- 未授权时初始化脚本会打开企业微信官方网页并显示二维码,只在需要用户扫码、企业权限或内网/VPN 时暂停。不要要求用户在对话中粘贴 Bot ID、Secret 或 Access Token,也不要读取、记录或上传 `~/.config/wecom` 中的本地认证配置。 +- 本地未授权时通过 Wegent 原生连接在官方浏览器页面扫码。Bot ID 和 Secret 由私有适配器同步,云端使用托管连接,无需再次扫码;云端禁止运行安装、`init` 或读取本地认证配置。认证失效时返回来源设备重新连接。不要要求用户在对话中粘贴凭据。 通过 `wecom-cli schedule <接口名> ''` 与企业微信日程系统交互。 diff --git a/plugins/wecom/skills/wecomcli-sheet/SKILL.md b/plugins/wecom/skills/wecomcli-sheet/SKILL.md index 1ff73bf..44db51c 100644 --- a/plugins/wecom/skills/wecomcli-sheet/SKILL.md +++ b/plugins/wecom/skills/wecomcli-sheet/SKILL.md @@ -9,11 +9,11 @@ description: 企业微信在线表格(sheet)管理技能。提供在线表 资源型技能,负责**在线表格**(`/sheet/*`)的新建、内容读写以及子工作表管理。 -## Wegent 本地运行 +## Wegent 本地与云端运行 - 当前 `SKILL.md` 所在目录的 `../..` 是插件根目录。首次调用前,macOS/Linux 运行 `sh "<插件根目录>/scripts/ensure-wecom-ready.sh"`;Windows 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\ensure-wecom-ready.ps1"`。 - 下文的 `wecom-cli ...` 是逻辑命令。实际执行时,macOS/Linux 使用 `sh "<插件根目录>/scripts/run-wecom-cli.sh" ...`;Windows 使用 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\run-wecom-cli.ps1" ...`。 -- 未授权时初始化脚本会打开企业微信官方网页并显示二维码,只在需要用户扫码、企业权限或内网/VPN 时暂停。不要要求用户在对话中粘贴 Bot ID、Secret 或 Access Token,也不要读取、记录或上传 `~/.config/wecom` 中的本地认证配置。 +- 本地未授权时通过 Wegent 原生连接在官方浏览器页面扫码。Bot ID 和 Secret 由私有适配器同步,云端使用托管连接,无需再次扫码;云端禁止运行安装、`init` 或读取本地认证配置。认证失效时返回来源设备重新连接。不要要求用户在对话中粘贴凭据。 ## 调用方式 diff --git a/plugins/wecom/skills/wecomcli-smartpage/SKILL.md b/plugins/wecom/skills/wecomcli-smartpage/SKILL.md index afaf865..93afd14 100644 --- a/plugins/wecom/skills/wecomcli-smartpage/SKILL.md +++ b/plugins/wecom/skills/wecomcli-smartpage/SKILL.md @@ -9,11 +9,11 @@ description: 企业微信智能文档(原名智能主页,smartpage)管理 资源型技能,负责**智能文档**(原名智能主页,`/smartpage/*`)的创建与内容导出。 -## Wegent 本地运行 +## Wegent 本地与云端运行 - 当前 `SKILL.md` 所在目录的 `../..` 是插件根目录。首次调用前,macOS/Linux 运行 `sh "<插件根目录>/scripts/ensure-wecom-ready.sh"`;Windows 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\ensure-wecom-ready.ps1"`。 - 下文的 `wecom-cli ...` 是逻辑命令。实际执行时,macOS/Linux 使用 `sh "<插件根目录>/scripts/run-wecom-cli.sh" ...`;Windows 使用 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\run-wecom-cli.ps1" ...`。 -- 未授权时初始化脚本会打开企业微信官方网页并显示二维码,只在需要用户扫码、企业权限或内网/VPN 时暂停。不要要求用户在对话中粘贴 Bot ID、Secret 或 Access Token,也不要读取、记录或上传 `~/.config/wecom` 中的本地认证配置。 +- 本地未授权时通过 Wegent 原生连接在官方浏览器页面扫码。Bot ID 和 Secret 由私有适配器同步,云端使用托管连接,无需再次扫码;云端禁止运行安装、`init` 或读取本地认证配置。认证失效时返回来源设备重新连接。不要要求用户在对话中粘贴凭据。 ## 调用方式 diff --git a/plugins/wecom/skills/wecomcli-smartsheet/SKILL.md b/plugins/wecom/skills/wecomcli-smartsheet/SKILL.md index bd38a20..f0fe9af 100644 --- a/plugins/wecom/skills/wecomcli-smartsheet/SKILL.md +++ b/plugins/wecom/skills/wecomcli-smartsheet/SKILL.md @@ -9,11 +9,11 @@ description: 企业微信智能表格(smartsheet)管理技能。提供智能 资源型技能,负责**智能表格**(`/smartsheet/*`,doc_type=10)的新建、结构(子表、字段/列)与数据(记录)管理。所有接口支持通过 `docid` 或 `url` 二选一定位文档。 -## Wegent 本地运行 +## Wegent 本地与云端运行 - 当前 `SKILL.md` 所在目录的 `../..` 是插件根目录。首次调用前,macOS/Linux 运行 `sh "<插件根目录>/scripts/ensure-wecom-ready.sh"`;Windows 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\ensure-wecom-ready.ps1"`。 - 下文的 `wecom-cli ...` 是逻辑命令。实际执行时,macOS/Linux 使用 `sh "<插件根目录>/scripts/run-wecom-cli.sh" ...`;Windows 使用 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\run-wecom-cli.ps1" ...`。 -- 未授权时初始化脚本会打开企业微信官方网页并显示二维码,只在需要用户扫码、企业权限或内网/VPN 时暂停。不要要求用户在对话中粘贴 Bot ID、Secret 或 Access Token,也不要读取、记录或上传 `~/.config/wecom` 中的本地认证配置。 +- 本地未授权时通过 Wegent 原生连接在官方浏览器页面扫码。Bot ID 和 Secret 由私有适配器同步,云端使用托管连接,无需再次扫码;云端禁止运行安装、`init` 或读取本地认证配置。认证失效时返回来源设备重新连接。不要要求用户在对话中粘贴凭据。 ## 调用方式 diff --git a/plugins/wecom/skills/wecomcli-todo/SKILL.md b/plugins/wecom/skills/wecomcli-todo/SKILL.md index fbbca32..e494094 100644 --- a/plugins/wecom/skills/wecomcli-todo/SKILL.md +++ b/plugins/wecom/skills/wecomcli-todo/SKILL.md @@ -7,11 +7,11 @@ description: 企业微信待办事项管理技能,支持创建待办、更新 > `wecom-cli` 是企业微信提供的命令行程序,所有操作通过执行 `wecom-cli` 命令完成。 -## Wegent 本地运行 +## Wegent 本地与云端运行 - 当前 `SKILL.md` 所在目录的 `../..` 是插件根目录。首次调用前,macOS/Linux 运行 `sh "<插件根目录>/scripts/ensure-wecom-ready.sh"`;Windows 运行 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\ensure-wecom-ready.ps1"`。 - 下文的 `wecom-cli ...` 是逻辑命令。实际执行时,macOS/Linux 使用 `sh "<插件根目录>/scripts/run-wecom-cli.sh" ...`;Windows 使用 `powershell -NoProfile -ExecutionPolicy Bypass -File "<插件根目录>\scripts\run-wecom-cli.ps1" ...`。 -- 未授权时初始化脚本会打开企业微信官方网页并显示二维码,只在需要用户扫码、企业权限或内网/VPN 时暂停。不要要求用户在对话中粘贴 Bot ID、Secret 或 Access Token,也不要读取、记录或上传 `~/.config/wecom` 中的本地认证配置。 +- 本地未授权时通过 Wegent 原生连接在官方浏览器页面扫码。Bot ID 和 Secret 由私有适配器同步,云端使用托管连接,无需再次扫码;云端禁止运行安装、`init` 或读取本地认证配置。认证失效时返回来源设备重新连接。不要要求用户在对话中粘贴凭据。 ## 概述