diff --git a/.github/workflows/dingtalk-package.yml b/.github/workflows/dingtalk-package.yml new file mode 100644 index 0000000..dbb365e --- /dev/null +++ b/.github/workflows/dingtalk-package.yml @@ -0,0 +1,76 @@ +name: DingTalk account authentication package + +on: + pull_request: + paths: + - 'plugins/dingtalk/**' + - '.github/workflows/dingtalk-package.yml' + push: + branches: [main] + paths: + - 'plugins/dingtalk/**' + - '.github/workflows/dingtalk-package.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v6 + with: + python-version: '3.12' + - uses: actions/setup-go@v6 + with: + go-version: '1.25.9' + cache: false + - name: Verify vendored build source inventory + run: uv run --no-project python plugins/dingtalk/.wework-build/dws-auth/vendor.py --check . + - name: Test package assembly and source inventory + run: uv run --no-project python -m unittest discover -s plugins/dingtalk/.wework-build/dws-auth/tests -v + - name: Test the shared Python SDK + run: uv run --no-project python -m unittest discover -s plugins/dingtalk/.wework-build/plugin-auth/tests -v + - name: Test native provider storage and shared transport + run: uv run --no-project python plugins/dingtalk/.wework-build/dws-auth/build.py --test --output .ci-artifacts/host/dws-account-auth + - name: Build all targets and verify the real packaged entry on this OS + run: uv run --no-project python plugins/dingtalk/.wework-build/dws-auth/package.py --plugin plugins/dingtalk --output .ci-artifacts/dingtalk-account-auth.zip + - name: Retain the candidate artifact and checksum + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: dingtalk-candidate-${{ github.sha }} + path: | + .ci-artifacts/dingtalk-account-auth.zip + .ci-artifacts/dingtalk-account-auth.zip.sha256 + if-no-files-found: error + retention-days: 7 + + release-artifact: + needs: package + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/download-artifact@v4 + with: + name: dingtalk-candidate-${{ github.sha }} + path: release + - name: Verify the selected candidate checksum + working-directory: release + run: sha256sum --check dingtalk-account-auth.zip.sha256 + - name: Retain the artifact after all three OS jobs pass + uses: actions/upload-artifact@v4 + with: + name: dingtalk-account-auth-${{ github.sha }} + path: release/ + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index f802ec1..252b3b5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pycache__/ .tmp/ .idea/ .vscode/ +.ci-artifacts/ diff --git a/README.md b/README.md index d62678c..1cb335a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,25 @@ The `Windows compatibility` GitHub Actions workflow additionally parses every PowerShell script and runs the native Windows authorization/exit-code tests on `windows-latest`. +## DingTalk builds + +DingTalk 0.3.1 declares its build in `plugins/dingtalk/.wework-build.json` and keeps +all canonical SDK inputs inside `.wework-build/` in that plugin directory. +GitHub-to-internal-repository mirroring therefore retains everything needed by +the existing MR, package, test and automatic-release pipeline. No separate +account-authentication edition or manual artifact selection is needed. + +```bash +uv run --no-project --python 3.12 python plugins/dingtalk/.wework-build/dws-auth/vendor.py --check . +uv run --no-project --python 3.12 python plugins/dingtalk/.wework-build/dws-auth/package.py --plugin plugins/dingtalk --output .ci-artifacts/dingtalk-account-auth.zip +``` + +Maintain generated inputs in Wegent and refresh them with `sdk/dws-auth/vendor.py`. +The builder prepares pinned Go tools, preserves reviewed source, builds five native +targets and exercises the private adapter. The ordinary official publishing command +automatically runs this build; GitLab tests and releases the same resulting ZIP. +The GitHub workflow remains an additional cross-platform build check. + ## Adding a plugin 1. Create `plugins//` with a valid `.codex-plugin/plugin.json`. diff --git a/plugins/dingtalk/.codex-plugin/plugin.json b/plugins/dingtalk/.codex-plugin/plugin.json index 189758d..c9e207c 100644 --- a/plugins/dingtalk/.codex-plugin/plugin.json +++ b/plugins/dingtalk/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "dingtalk", - "version": "0.2.11", - "description": "通过本机已安装并完成认证的 DWS CLI,使用钉钉协作与办公能力。", + "version": "0.3.3", + "description": "通过 DWS 使用钉钉协作与办公能力。", "author": { "name": "Wegent" }, @@ -22,22 +22,55 @@ "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"], + "health": [ + "scripts/local-auth.sh", + "health" + ], + "start": [ + "scripts/local-auth.sh", + "login" + ], + "logout": [ + "scripts/local-auth.sh", + "logout" + ], "timeoutSeconds": 300, "logoutOnUninstall": false, "tool": { "id": "dws", "source": "bundled" } + }, + "accountAuth": { + "protocolVersion": 1, + "credentialType": "oauth2", + "adapter": "scripts/account-auth.py", + "oauth2": [ + "refresh", + "revoke" + ], + "exportMode": "exclusive", + "localEnvironment": { + "DWS_CONFIG_DIR": { + "type": "directory" + }, + "DWS_KEYCHAIN_DIR": { + "type": "directory" + }, + "DWS_DISABLE_KEYCHAIN": { + "type": "enum", + "values": [ + "1" + ] + } + } } } ], "interface": { "displayName": "钉钉", - "shortDescription": "通过本机 DWS 管理钉钉协作与办公能力", - "longDescription": "自动准备跨平台 DingTalk Workspace CLI,在本机浏览器完成 OAuth 授权,并支持 AI 表格、日历、通讯录、群聊、待办、审批、考勤、日志、DING、文档、云盘、AI 听记、邮箱、在线表格、知识库和开放平台文档等能力。凭据仅由本机 DWS 管理,不上传 Wegent Backend。", + "shortDescription": "通过 DWS 管理钉钉协作与办公能力", + "longDescription": "通过 DingTalk Workspace CLI 使用 AI 表格、日历、通讯录、群聊、待办、审批、考勤、日志、DING、文档、云盘、AI 听记、邮箱、在线表格、知识库和开放平台文档等能力。", "developerName": "Wegent", "category": "协作", "capabilities": [ @@ -52,7 +85,7 @@ "composerIcon": "./assets/app-icon.svg", "logo": "./assets/app-icon.svg", "defaultPrompt": [ - "请自动检查本机 DWS CLI 和钉钉登录状态,然后帮我处理钉钉中的日程、消息、文档、待办或其他办公任务。" + "请帮我处理钉钉中的日程、消息、文档、待办或其他办公任务。" ] } } diff --git a/plugins/dingtalk/.gitattributes b/plugins/dingtalk/.gitattributes new file mode 100644 index 0000000..e8c27f6 --- /dev/null +++ b/plugins/dingtalk/.gitattributes @@ -0,0 +1,4 @@ +# Preserve the exact bytes used by the vendored SDK inventories on Windows. +.wework-build/** text eol=lf +scripts/wegent_plugin_auth/** text eol=lf +scripts/account-auth.py text eol=lf diff --git a/plugins/dingtalk/.wework-build.json b/plugins/dingtalk/.wework-build.json new file mode 100644 index 0000000..ba24203 --- /dev/null +++ b/plugins/dingtalk/.wework-build.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "entrypoint": ".wework-build/dws-auth/package.py", + "outputs": [ + "scripts/native" + ] +} diff --git a/plugins/dingtalk/.wework-build/account-auth-build.json b/plugins/dingtalk/.wework-build/account-auth-build.json new file mode 100644 index 0000000..ebf4b50 --- /dev/null +++ b/plugins/dingtalk/.wework-build/account-auth-build.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 1, + "source": "Wegent/sdk", + "files": { + "dws-auth/README.en.md": "d2444a6f3205d7c0a3e311531314d34d770f739907093a8cbc5b34e900231ae4", + "dws-auth/README.md": "939ba7cadf43782f54ded24b60b68ccb6a1b7a343c430af203818237201953af", + "dws-auth/auth-overlay/wegent_transfer.go": "fe57595cb51c8eb2f06fe3a1d084213aa0e11268db49d454d77233cffddf8f23", + "dws-auth/auth-overlay/wegent_transfer_sync_unix.go": "2a6bb4db9e5047abe4c8993de22f2d54354a423e4e5916d5f8ef18d018836581", + "dws-auth/auth-overlay/wegent_transfer_sync_windows.go": "b4fc3b971f34593a66785bbaebb7366b803b1bf8ece416b63c4801e4e73d5bcc", + "dws-auth/auth-overlay/wegent_transfer_test.go": "8a0065ea875aaf99f3187690de28d7317c76cfa622df5d2cdb34ea0714323ccc", + "dws-auth/build.py": "30c4651d42e421d6609af1b3ae89e14b136d79941298f7f8ebc10ca638ae102a", + "dws-auth/entry.py": "b27e18738846468e2a7af409b069cf575950943798e61b0088b4167d01704770", + "dws-auth/overlay/main.go": "18c40aa02f77e6a3e9be14c48d91c8112f59173324abd747643052ef6233e482", + "dws-auth/overlay/provider.go": "b453ef0449b9183d211a09ad68092adb82ac3df49d29a0c212b42ffb86a7ba80", + "dws-auth/overlay/provider_test.go": "e5dafac08e23b55e70ddd2b86c8ff2039b843dbdc0dd334badacb2b4e10adfb5", + "dws-auth/package.py": "881ccb3e3c4df06b84fe67cc24b6b4b8391f81e0e35b699cc3a5db57d22242c8", + "dws-auth/tests/test_package.py": "db7584e15bd51014947d05d75478750805e5abfdd623ad23f02ec882e1ccc9db", + "dws-auth/tests/test_toolchain.py": "c2306b7ffa0b52505f5cfff09096ad1449bb7e6c059e59dcd192a4c59b1ee889", + "dws-auth/tests/test_vendor.py": "f297ebe66383355d62a335ac901407e5487d929b86c2ddcb95cf88f87bc3a2de", + "dws-auth/toolchain.py": "c265669d702c9529036bd088061ce9375ceefd83b3901cc3910b95c43e941232", + "dws-auth/vendor.py": "14b5288c8fcf43e0365876c3afab94b8249fc9bc035319c153f9c1e54e1da821", + "dws-auth/verify.py": "72dcc844f2b1089e8d2ec073fd1e5d46b643d83d51f5d0f8622a7240f759dcd4", + "plugin-auth-go/LICENSE": "e7e430d6f6693d9e1fe09961d873d7c8f6938bfd57746d60d18b895c62b15760", + "plugin-auth-go/README.en.md": "361c47abf41b0d68572abedbad7ee0b016c7a48284fec2b726b7d593dbbfcff7", + "plugin-auth-go/README.md": "461633b5574774831ca63d581a2a130e53a91a3db89bf91e5bd8d45c8c5aaa06", + "plugin-auth-go/adapter.go": "236b56b3519007cfaa878cfc918c1efa639799b992f4a8bb97845b7871129691", + "plugin-auth-go/configuration.go": "5875beffda456b1a8b04ade81b9c40043ff7abca3cbec6b9cc65efa6da6170fd", + "plugin-auth-go/configuration_test.go": "c85ff0195bf4545eb67ec966916b99ca9cc1d2f25bb9b40ee7b6cc7f6aa7678e", + "plugin-auth-go/go.mod": "fb02c6a825a34760138dc8abd7e3b0ceef11199dd2e2eb07cd4031e8f4d4428b", + "plugin-auth-go/transport.go": "ca7e8998f2c77bfc06d5f2ab49c6ffc760af03d83ae9d69ec6f51b5147a87e76", + "plugin-auth-go/transport_test.go": "c0ec7112078c8f62b7f06e6d2fbe03270a24ddf40c89cfa5ad5b5ca736480d2a", + "plugin-auth/LICENSE": "e7e430d6f6693d9e1fe09961d873d7c8f6938bfd57746d60d18b895c62b15760", + "plugin-auth/README.en.md": "beb365b9a7e53d77a9c07be21e8c0e5c90c8d7d620b88eb09cf40600fd4776cf", + "plugin-auth/README.md": "ca0c2ddd6793ea1bb4eb00eb27ab81313545896a745c80368cc771a7a133c278", + "plugin-auth/templates/account-auth.py.tmpl": "b2683e640b18fa0670b54afc58db23f9fd4af419c13cba662c15574ac839d8d0", + "plugin-auth/templates/auth_provider.py.tmpl": "11ed9b480458c2aa5ede47bb11a2ab5579ba249cf26303a2a073da4eda5da497", + "plugin-auth/templates/cli.py.tmpl": "55c607be225c4b84583e1fcd19e1f374e53fce25799cb0826a7fb3905a38c863", + "plugin-auth/templates/oauth-provider.inc": "bb72f261c81cadcc002db2d6001a5de01c829fbf6dd855fef3da2ab203d207a2", + "plugin-auth/tests/test_runtime.py": "f041ed94bf045ef6894ca04d915f07c5b4ffff768bf6b703af106cedcc9a02d9", + "plugin-auth/tests/test_sdk.py": "e7c7ec429aeeddca7e487d143017b3eff7b26c3e602a3881510e60c9561e6ced", + "plugin-auth/tool.py": "d6d07a370ffd9a120b1a72ae08ba3cde8cc2b765bbdd59bc54943c6980e5cfa0", + "plugin-auth/wegent_plugin_auth/__init__.py": "b6ce9a286c0a06ecfe0652d5045e488eba98bcc2aad41f5ebd50e4b3aa28c98c", + "plugin-auth/wegent_plugin_auth/adapter.py": "c51549ca0e1503f6d714a52bdf6ddc265e4067aa8d4470e5c6c9077d10f5d8e6", + "plugin-auth/wegent_plugin_auth/configuration.py": "0bb293d2c82db21c5eb19a88cea884fa758700c4350e93baa238b87c5d5e08ae", + "plugin-auth/wegent_plugin_auth/runtime.py": "3fbe06a3334acf36fe9687cc9dfbdc802d804982b51ddf064880ec68e3adc84d", + "plugin-auth/wegent_plugin_auth/transport.py": "664e4a848c9fea879f729a967191c37d7ab02a0180c0f02bced4cd62c4199c34" + } +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/README.en.md b/plugins/dingtalk/.wework-build/dws-auth/README.en.md new file mode 100644 index 0000000..9f6ae79 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/README.en.md @@ -0,0 +1,133 @@ +--- +sidebar_position: 1 +--- + +# DWS account authentication adapter + +The installed package declares source directories `DWS_CONFIG_DIR` and +`DWS_KEYCHAIN_DIR`, plus the public `DWS_DISABLE_KEYCHAIN=1` switch, through +`accountAuth.localEnvironment`. The host validates these settings and supplies +them only to local authentication callbacks. The Python SDK entry explicitly +passes them to the official adapter so migration reads the selected source store. +Unset settings retain upstream defaults; directories must exist and be absolute. +Settings are not uploaded to the backend or supplied to cloud business, refresh +or revocation callbacks. + +Build the native companion from pinned DWS `v1.0.58` source plus Wegent extensions. +The source archive SHA-256 is +`f6b2dcf16b34492d7be25ce63fc81c7155d6a857af21c0604ae16bf4fa96f1e2`. +`overlay/` reuses upstream edition hooks, OAuth refresh, revocation and business +commands. `auth-overlay/` adds a function inside the upstream auth package to use +its existing refresh lock and exact-account deletion. Shared framing lives in +`../plugin-auth-go`. + +```bash +uv run --project backend python sdk/dws-auth/build.py \ + --output executor/target/dws-auth/dws-account-auth --test +``` + +`--source-archive` accepts an already downloaded archive without bypassing checksum +verification. `GOOS` / `GOARCH` select cross-compilation targets. Outputs include +the binary, LICENSE, NOTICE, and a JSON inventory containing upstream/source/binary +hashes and target platform. Tests use isolated upstream storage and synthetic +HTTP providers, never personal Keychain entries or real DingTalk accounts. + +## Exclusive refresh ownership + +The plugin declaration must use `accountAuth.exportMode: "exclusive"`: + +1. Export the local MCP OAuth account into encrypted backend escrow without a + usable connection or device grant. +2. Detach compares the snapshot under the upstream refresh lock. +3. Persist a receipt containing no tokens; remove only that account and its + upstream mirrors; verify the grant is absent; persist the completed receipt. +4. The native host confirms detachment. One backend transaction activates the + connection, grants the source device and removes escrow ciphertext. + +The receipt recovers a crash after deletion; backend confirmation is idempotent. +Refresh races durably fence off the old ID under the provider lock before the +native host cancels escrow. A user retry exports current credentials with a new +ID; delayed operations cannot delete credentials or activate the old snapshot. +Changed accounts and uncertain storage states still fail closed. Only +MCP OAuth is supported; direct-mode client secrets are not imported. Business +execution receives access credentials only and uses memory-backed store hooks. + +## Build the complete plugin + +```bash +uv run --project backend python sdk/dws-auth/package.py \ + --plugin ../wework-plugins-public/plugins/dingtalk \ + --output executor/target/dws-auth/dingtalk-account-auth.zip +``` + +The source declares `accountAuth` and includes the SDK. The packager preserves +those reviewed inputs and builds macOS arm64/amd64, Linux arm64/amd64 and Windows amd64 companions. +It rejects symlinks, verifies binary hashes and license files, then runs the real +packaged private entry on the build host. Health must succeed; wrong connector +identity and business credentials containing a refresh token must fail without +creating local account state. Failure preserves any previous output artifact. + +Packages retain the existing 50 MiB upload and 200 MiB expanded limits. The public +CLI and 24 Python helpers now delegate through the public SDK. Readiness checks +for transferred accounts do not initiate DWS login. + +## CI and official distribution + +The plugin-local `.wework-build.json` declares a Python build entry and generated +directories. All inputs live under `plugins/dingtalk/.wework-build/`, so selecting +and mirroring the plugin into an internal MR retains the complete build contract. + +```bash +uv run --project backend python sdk/dws-auth/vendor.py ../wework-plugins-public +uv run --project backend python sdk/dws-auth/vendor.py --check ../wework-plugins-public +uv run --no-project python sdk/plugin-build/vendor.py ../wework-plugins +``` + +The existing `package_plugin → unit_linux → release_plugin` pipeline remains the +publication path. Packaging runs the declared builder and prepares checksum-pinned +Go tools when needed. Tests exercise the extracted ZIP and retain its tested digest. +Release uploads that exact artifact; plugins without declarations keep source packaging. + +Every reviewed source byte and mode must survive unchanged; only declared generated +directories may be added. Backend validates the protected commit, MR and input tree, +then independently verifies the successful GitLab package artifact and test digest +receipt. Missing outputs, altered sources, substituted artifacts or missing successful +tests block release. Builds receive no release credentials or personal auth environment. + +Local publication uses the same entry and automatically chooses the build: + +```bash +cd backend +uv run python scripts/publish_official_plugin.py ../../wework-plugins-public/plugins/dingtalk --slug dingtalk --visibility public --dry-run +``` + +Remove `--dry-run` to publish to the configured market. Batch seeding also builds +automatically. `--prebuilt --sha256` remains an operator artifact-import option, +not a required everyday publication step. + +## Delivery boundary + +Only the default DingTalk MCP provider is supported. A source with a custom +`mcp_url` is rejected before credential export. Memory execution also isolates +device-local DWS configuration so it cannot change the token recipient. + +Source tests cover recovery, exact-account deletion, refresh-race rejection and +real upstream business commands. macOS builds and Windows/Linux cross-compilation +have been checked; native Windows storage and real-provider acceptance remain. +The source plugin manifest does not enable `accountAuth`; artifacts produced by +the packager do. Build and official distribution entry points are connected; +a minimal business fixture built with the same packager, launcher and five native +targets passed real publishing, installation, Backend/Electron/cloud-executor account +status checks: denied before a grant, successful after granting, denied after revocation. +Separate Electron verification used upstream code to create an isolated encrypted +source store. Desktop migration removed only the selected source grant, preserved +another account and retained the connection after reload. Evidence: +`wework/test-results/ai-verify/2026-09-07T12-19-29-787Z-40328/dws-source-qa.json`. +All credentials are synthetic; no personal keychain was accessed. Real providers, +system Keychain/DPAPI, remote CI and actual publication remain unverified. +The registered desktop checkpoint also passed upstream source-store migration, +cloud grant, business execution and revocation. All 20 flags passed; evidence: +`wework/test-results/desktop-e2e/2026-09-07T12-20-59-116Z-47125/`. +Generic SDK transfer +recovery has passed real Backend/Electron/cloud-executor E2E with a synthetic provider. A source build is not +a published, user-ready plugin. diff --git a/plugins/dingtalk/.wework-build/dws-auth/README.md b/plugins/dingtalk/.wework-build/dws-auth/README.md new file mode 100644 index 0000000..e5ba4e6 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/README.md @@ -0,0 +1,115 @@ +--- +sidebar_position: 1 +--- + +# DWS 账号认证适配器 + +从 DWS `v1.0.58` 固定源码与 Wegent 扩展编译原生 companion。源码归档 SHA-256: +`f6b2dcf16b34492d7be25ce63fc81c7155d6a857af21c0604ae16bf4fa96f1e2`。 +`overlay/` 复用官方 `edition.Hooks`、OAuth Provider、撤销和业务命令; +`auth-overlay/` 在官方认证包中增加交接函数,使用同一把刷新锁与精确账号删除逻辑。 +共享通道来自 `../plugin-auth-go`,插件开发者无需处理套接字、nonce 或帧长度。 + +```bash +uv run --project backend python sdk/dws-auth/build.py \ + --output executor/target/dws-auth/dws-account-auth --test +``` + +可用 `--source-archive` 传入已下载归档,仍必须通过固定哈希校验;交叉编译遵循 +`GOOS` / `GOARCH`。产物包括二进制、LICENSE、NOTICE 和记录上游哈希、扩展源码哈希、 +目标平台、二进制哈希的 JSON。`--test` 运行隔离的官方存储测试、SDK 通道测试及 +真实 DWS 命令/刷新/撤销的合成服务测试,不使用个人钥匙串或真实钉钉账号。 + +## 唯一刷新权交接 + +安装包通过通用 `accountAuth.localEnvironment` 声明 `DWS_CONFIG_DIR`、 +`DWS_KEYCHAIN_DIR` 目录和 `DWS_DISABLE_KEYCHAIN=1` 开关。宿主校验后仅向本机 +认证回调提供这些设置,Python SDK 入口再显式交给官方适配器,确保迁移读取用户 +实际使用的源存储。未配置时保留官方默认行为;目录必须已存在且为绝对路径。 +这些配置不上传后端,也不传给云端业务、刷新和撤销回调。 + +插件声明必须设置 `accountAuth.exportMode: "exclusive"`: + +1. 本机 `export` 读取现有 MCP OAuth 账号,后端加密暂存,尚无可用连接或设备授权。 +2. `detach` 收到私有通道中的同一快照,持有官方刷新锁,比对当前身份与令牌。 +3. 本机先持久化不含令牌的交接记录,再删除该账号的 Keychain/DPAPI 存储及官方镜像; + 确认旧刷新材料已不在这些位置后,持久化完成记录。 +4. 原生宿主确认后,后端在一个事务内激活连接、授权源设备并清除暂存密文。 + +删除后的进程退出可凭同一交接记录恢复;后端确认可幂等重试。发生刷新竞争时, +适配器先在同一把锁内持久化旧 ID 的作废记录,再由原生宿主取消旧密文暂存; +用户重试会获取新 ID 和当前令牌。迟到的旧进程无法删除凭据或激活旧快照。 +账号变化或异常存储状态仍拒绝激活。交接仅支持 MCP OAuth;不读取 direct 模式的 +本地 client secret。业务进程只收到 Access Token,拒绝 Refresh Token 和 +`provider_private`,官方存储 hook 仅在内存读写。 + +## 生成完整插件包 + +```bash +uv run --project backend python sdk/dws-auth/package.py \ + --plugin ../wework-plugins-public/plugins/dingtalk \ + --output executor/target/dws-auth/dingtalk-account-auth.zip +``` + +源码声明 `accountAuth` 并包含公共 SDK;打包器在临时副本中增加五个平台的原生适配器 +(macOS arm64/amd64、Linux arm64/amd64、Windows amd64),保留许可证、来源及哈希。 +它拒绝符号链接,检查每个二进制哈希,并在当前平台实际执行包内 Python 入口和原生 +私有通道:健康检查必须成功,错误 Connector 和携带刷新令牌的业务请求必须失败, +本机认证目录不得出现。任一步失败均不替换已有输出包。 + +压缩包仍遵守 Wegent 的 50 MiB 上传与 200 MiB 解压限制。公开命令入口和 24 个 Python +辅助脚本已通过公共 SDK 委派业务;已迁移账号的准备检查不再执行 DWS 登录。 + +## CI 与官方分发 + +插件根目录的 `.wework-build.json` 声明构建入口与生成目录,构建依赖保存在 +`plugins/dingtalk/.wework-build/`。从 GitHub 提取插件目录并同步到内网 MR 时, +这些输入会一起保留,无需另行同步仓库根目录或下载专用制品。 + +```bash +uv run --project backend python sdk/dws-auth/vendor.py ../wework-plugins-public +uv run --project backend python sdk/dws-auth/vendor.py --check ../wework-plugins-public +uv run --no-project python sdk/plugin-build/vendor.py ../wework-plugins +``` + +内网仍使用原有 `package_plugin → unit_linux → release_plugin` 流程。打包任务 +自动调用声明的入口,按固定版本与哈希准备 Go 编译器,生成五平台原生文件;测试任务 +直接验证解压后的安装包,并记录实际测试的 SHA-256。发布任务只发布同一份已测试 ZIP。 +普通插件没有构建声明,继续使用原来的源码打包行为。 + +源码、声明、SDK、风险说明与文件模式必须保持不变;只能增加声明的生成目录。 +Backend 继续核对受保护分支、提交、MR 和源码身份,并向 GitLab 核实构建任务制品的 +完整哈希及测试任务的哈希回执。缺少二进制、改写源码、制品不匹配、测试失败或 +回执缺失均拒绝发布。构建过程不接收发布 Token 或个人认证环境。 + +本地发布也使用同一个入口,不需要判断包类型或手填哈希: + +```bash +cd backend +uv run python scripts/publish_official_plugin.py ../../wework-plugins-public/plugins/dingtalk --slug dingtalk --visibility public --dry-run +``` + +去掉 `--dry-run` 才会发布到当前 Backend 配置的市场。批量 seed 同样自动构建。 +已有制品的维护入口 `--prebuilt --sha256` 保留,但不是日常发布的必选步骤。 + +## 交付边界 + +当前适配器只接受默认钉钉 MCP 服务。配置了自定义 `mcp_url` 的源账号不会导出; +内存执行期间也隔离本机 DWS 配置,防止运行设备的配置改变令牌接收端。 + +当前源码测试已覆盖交接恢复、精确账号删除、刷新竞争拒绝,以及官方业务命令调用。 +macOS 本地编译与 Windows/Linux 交叉编译已验证;Windows 真机存储与真实提供方仍待验收。 +源码已声明 `accountAuth`;发布时自动生成包含原生适配器的完整安装包。 +构建与官方分发入口已接通。相同打包器、原生入口和五平台二进制的最小业务夹具, +已通过真实发布服务、安装同步、Backend / Electron / 云端 Executor 的账号状态检查, +包括授权前拒绝、授权后成功和撤销后拒绝。独立 Electron 验收进一步使用官方代码 +创建隔离的加密源存储,经桌面迁移后确认目标账号原授权已移除、另一账号不变, +重载后连接保留。证据位于 +`wework/test-results/ai-verify/2026-09-07T12-19-29-787Z-40328/dws-source-qa.json`。 +所有凭据均为合成数据,不读取个人钥匙串;真实服务方、系统钥匙串/DPAPI、远端 CI +和实际发布仍待验收。 +注册的完整桌面检查点同样通过官方源存储迁移、云端授权、业务调用与撤销, +20 项标记全部成功,证据目录为 +`wework/test-results/desktop-e2e/2026-09-07T12-20-59-116Z-47125/`。 +通用 SDK 的交接中断恢复已通过真实 Backend / Electron / 云端 Executor 合成提供方 E2E。 +源码可构建不代表插件已发布或用户可直接使用。 diff --git a/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer.go b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer.go new file mode 100644 index 0000000..11d8008 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +package auth + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +var errWegentTransfer = errors.New("plugin_auth_dws_transfer_failed") + +// ErrWegentSourceChanged confirms the old transfer ID is durably fenced off. +var ErrWegentSourceChanged = errors.New("plugin_auth_source_changed") + +type wegentReceipt struct { + Version int `json:"version"` + Fingerprint string `json:"fingerprint"` + State string `json:"state"` +} + +// DetachWegentToken is called only after the native host durably stages encrypted +// escrow. It uses the same lock and exact-profile deletion as upstream refresh. +// Receipts contain no tokens. They let a restarted host confirm a deletion whose +// acknowledgement was lost without deleting a different account or grant. +func DetachWegentToken(ctx context.Context, directory, migrationID string, expected *TokenData) error { + if len(migrationID) != 64 || strings.Trim(migrationID, "0123456789abcdef") != "" || expected == nil || expected.CorpID == "" || expected.UserID == "" || expected.Source != "mcp" || expected.RefreshToken == "" { + return errWegentTransfer + } + lock, err := AcquireDualLock(ctx, directory) + if err != nil { + return errWegentTransfer + } + defer lock.Release() + return detachWegentTokenLocked(directory, migrationID, expected) +} + +func detachWegentTokenLocked(directory, migrationID string, expected *TokenData) error { + fingerprint := wegentFingerprint(expected) + receiptPath, err := wegentReceiptPath(directory, migrationID) + if err != nil { + return errWegentTransfer + } + receipt, err := readWegentReceipt(receiptPath) + if err != nil { + return errWegentTransfer + } + if receipt != nil && (receipt.Version != 1 || receipt.Fingerprint != fingerprint || (receipt.State != "prepared" && receipt.State != "detached" && receipt.State != "aborted")) { + return errWegentTransfer + } + if receipt != nil && receipt.State == "aborted" { + return ErrWegentSourceChanged + } + selector := ProfileSelector(Profile{CorpID: expected.CorpID, UserID: expected.UserID}) + current, loadErr := loadTokenDataForProfileLocked(directory, selector) + if (receipt == nil || receipt.State == "prepared") && loadErr == nil && current != nil && current.CorpID == expected.CorpID && current.UserID == expected.UserID && wegentFingerprint(current) != fingerprint { + // Persist under the refresh lock before allowing escrow cancellation. A + // concurrent or delayed detach with this ID can never delete a later grant. + if writeWegentReceipt(receiptPath, &wegentReceipt{1, fingerprint, "aborted"}) != nil { + return errWegentTransfer + } + return ErrWegentSourceChanged + } + if receipt == nil { + if loadErr != nil || current == nil || wegentFingerprint(current) != fingerprint { + return errWegentTransfer + } + receipt = &wegentReceipt{Version: 1, Fingerprint: fingerprint, State: "prepared"} + if writeWegentReceipt(receiptPath, receipt) != nil { + return errWegentTransfer + } + } + if current != nil && loadErr == nil { + if receipt.State == "detached" || wegentFingerprint(current) != fingerprint { + return errWegentTransfer + } + if deleteTokenDataForProfileLocked(directory, selector) != nil { + return errWegentTransfer + } + } else if loadErr != nil && !errors.Is(loadErr, ErrTokenDataNotFound) && !isWegentProfileMissing(directory, selector) { + return errWegentTransfer + } + if !wegentGrantAbsent(expected) { + return errWegentTransfer + } + receipt.State = "detached" + if writeWegentReceipt(receiptPath, receipt) != nil { + return errWegentTransfer + } + return nil +} + +func isWegentProfileMissing(directory, selector string) bool { + cfg, err := tokenLoadProfiles(directory) + if err != nil || cfg == nil { + return false + } + for _, profile := range cfg.Profiles { + if ProfileSelector(profile) == selector { + return false + } + } + return true +} + +func wegentGrantAbsent(expected *TokenData) bool { + loaders := []func() (*TokenData, error){ + func() (*TokenData, error) { return tokenLoadKeychainIdentity(expected.CorpID, expected.UserID) }, + func() (*TokenData, error) { return tokenLoadKeychainForCorpID(expected.CorpID) }, + tokenLoadKeychain, + } + for _, load := range loaders { + token, err := load() + if err != nil && !errors.Is(err, ErrTokenDataNotFound) { + return false + } + if token != nil && token.CorpID == expected.CorpID && token.UserID == expected.UserID && (token.RefreshToken == expected.RefreshToken || token.AccessToken == expected.AccessToken) { + return false + } + } + return true +} + +func wegentFingerprint(token *TokenData) string { + // Expiration timestamps may lose subsecond precision in the common protocol; + // compare identity and all grant-bearing fields, not display metadata. + data, _ := json.Marshal([]string{token.CorpID, token.UserID, token.Source, token.ClientID, token.AccessToken, token.RefreshToken, token.PersistentCode}) + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func wegentReceiptPath(directory, migrationID string) (string, error) { + folder := filepath.Join(directory, "wegent-transfers") + if err := os.Mkdir(folder, 0700); err != nil && !os.IsExist(err) { + return "", err + } + info, err := os.Lstat(folder) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", errWegentTransfer + } + if syncWegentDirectory(directory) != nil { + return "", errWegentTransfer + } + return filepath.Join(folder, migrationID+".json"), nil +} + +func readWegentReceipt(path string) (*wegentReceipt, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil || !info.Mode().IsRegular() || info.Size() > 4096 { + return nil, errWegentTransfer + } + data, err := os.ReadFile(path) + if err != nil { + return nil, errWegentTransfer + } + var receipt wegentReceipt + if json.Unmarshal(data, &receipt) != nil { + return nil, errWegentTransfer + } + return &receipt, nil +} + +func writeWegentReceipt(path string, receipt *wegentReceipt) error { + data, err := json.Marshal(receipt) + if err != nil { + return errWegentTransfer + } + file, err := os.CreateTemp(filepath.Dir(path), ".transfer-*") + if err != nil { + return errWegentTransfer + } + defer os.Remove(file.Name()) + defer file.Close() + if _, err := file.Write(data); err != nil { + return errWegentTransfer + } + if file.Sync() != nil || file.Close() != nil { + return errWegentTransfer + } + return replaceWegentReceipt(file.Name(), path) +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer_sync_unix.go b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer_sync_unix.go new file mode 100644 index 0000000..2bc0643 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer_sync_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +// SPDX-License-Identifier: Apache-2.0 +package auth + +import ( + "os" + "path/filepath" +) + +func syncWegentDirectory(directory string) error { + file, err := os.Open(directory) + if err != nil { + return err + } + defer file.Close() + return file.Sync() +} + +func replaceWegentReceipt(source, target string) error { + if err := os.Rename(source, target); err != nil { + return err + } + return syncWegentDirectory(filepath.Dir(target)) +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer_sync_windows.go b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer_sync_windows.go new file mode 100644 index 0000000..904c1ee --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer_sync_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +// SPDX-License-Identifier: Apache-2.0 +package auth + +import "golang.org/x/sys/windows" + +func syncWegentDirectory(string) error { return nil } + +func replaceWegentReceipt(source, target string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(target) + if err != nil { + return err + } + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer_test.go b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer_test.go new file mode 100644 index 0000000..a5b3308 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_transfer_test.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +package auth + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" +) + +func wegentTransferFixture(t *testing.T) (string, *TokenData) { + t.Helper() + // Upstream's isolated test store: never touch the developer's OS Keychain. + t.Setenv(keychain.DisableKeychainEnv, "1") + cleanupKeychain(t) + directory := t.TempDir() + token := &TokenData{CorpID: "synthetic-corp", UserID: "alice", ClientID: "synthetic-client", Source: "mcp", + AccessToken: "synthetic-access", RefreshToken: "synthetic-refresh", PersistentCode: "synthetic-persistent", + ExpiresAt: time.Now().Add(time.Hour), RefreshExpAt: time.Now().Add(24 * time.Hour)} + if err := SaveTokenData(directory, token); err != nil { + t.Fatal(err) + } + return directory, token +} + +func TestWegentTransferDetachesOnlySelectedAccountAndIsIdempotent(t *testing.T) { + directory, token := wegentTransferFixture(t) + other := *token + other.UserID, other.AccessToken, other.RefreshToken = "bob", "synthetic-other-access", "synthetic-other-refresh" + if err := SaveTokenData(directory, &other); err != nil { + t.Fatal(err) + } + id := strings.Repeat("a", 64) + if err := DetachWegentToken(context.Background(), directory, id, token); err != nil { + t.Fatal(err) + } + if !wegentGrantAbsent(token) { + t.Fatal("source still owns refresh grant") + } + if err := DetachWegentToken(context.Background(), directory, id, token); err != nil { + t.Fatal("lost acknowledgement cannot resume") + } + remaining, err := LoadTokenDataForProfile(directory, "synthetic-corp:bob") + if err != nil || remaining.RefreshToken != other.RefreshToken { + t.Fatal("unrelated account changed") + } + data, err := os.ReadFile(filepath.Join(directory, "wegent-transfers", id+".json")) + if err != nil || strings.Contains(string(data), "synthetic-") { + t.Fatal("receipt exposes credential or identity") + } +} + +func TestWegentTransferRejectsGrantRotatedAfterExport(t *testing.T) { + directory, token := wegentTransferFixture(t) + rotated := *token + rotated.RefreshToken = "synthetic-rotated" + if err := SaveTokenData(directory, &rotated); err != nil { + t.Fatal(err) + } + id := strings.Repeat("a", 64) + if !errors.Is(DetachWegentToken(context.Background(), directory, id, token), ErrWegentSourceChanged) { + t.Fatal("stale export detached a new grant") + } + current, err := LoadTokenData(directory) + if err != nil || current.RefreshToken != rotated.RefreshToken { + t.Fatal("new source grant changed") + } + if !errors.Is(DetachWegentToken(context.Background(), directory, id, token), ErrWegentSourceChanged) { + t.Fatal("old transfer ID is not fenced") + } + if err := DetachWegentToken(context.Background(), directory, strings.Repeat("b", 64), &rotated); err != nil { + t.Fatal("fresh transfer cannot use the rotated source") + } +} + +func TestWegentTransferFencesPreparedReceiptAfterRefresh(t *testing.T) { + directory, token := wegentTransferFixture(t) + id := strings.Repeat("c", 64) + path, err := wegentReceiptPath(directory, id) + if err != nil { + t.Fatal(err) + } + if err := writeWegentReceipt(path, &wegentReceipt{1, wegentFingerprint(token), "prepared"}); err != nil { + t.Fatal(err) + } + rotated := *token + rotated.RefreshToken = "synthetic-rotated" + if err := SaveTokenData(directory, &rotated); err != nil { + t.Fatal(err) + } + if !errors.Is(DetachWegentToken(context.Background(), directory, id, token), ErrWegentSourceChanged) { + t.Fatal("prepared receipt was not fenced") + } + // Even restoring the old source cannot make a delayed detach valid again. + if err := SaveTokenData(directory, token); err != nil { + t.Fatal(err) + } + if !errors.Is(DetachWegentToken(context.Background(), directory, id, token), ErrWegentSourceChanged) { + t.Fatal("obsolete transfer resumed") + } + current, err := LoadTokenData(directory) + if err != nil || current.RefreshToken != token.RefreshToken { + t.Fatal("fenced transfer changed source") + } +} + +func TestWegentTransferResumesAfterDeletionBeforeReceiptCommit(t *testing.T) { + directory, token := wegentTransferFixture(t) + id := strings.Repeat("b", 64) + path, err := wegentReceiptPath(directory, id) + if err != nil { + t.Fatal(err) + } + if err := writeWegentReceipt(path, &wegentReceipt{1, wegentFingerprint(token), "prepared"}); err != nil { + t.Fatal(err) + } + if err := DeleteTokenDataForProfile(directory, "synthetic-corp:alice"); err != nil { + t.Fatal(err) + } + if err := DetachWegentToken(context.Background(), directory, id, token); err != nil { + t.Fatal("interrupted detach cannot recover") + } + receipt, err := readWegentReceipt(path) + if err != nil || receipt.State != "detached" { + t.Fatal("missing durable receipt") + } +} + +func TestWegentTransferRejectsMismatchedOrCorruptReceipt(t *testing.T) { + directory, token := wegentTransferFixture(t) + id := strings.Repeat("c", 64) + path, err := wegentReceiptPath(directory, id) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"version":1,"fingerprint":"wrong","state":"detached"}`), 0600); err != nil { + t.Fatal(err) + } + if DetachWegentToken(context.Background(), directory, id, token) == nil { + t.Fatal("accepted unrelated receipt") + } + if wegentGrantAbsent(token) { + t.Fatal("rejected receipt still deleted source") + } + if err := os.WriteFile(path, []byte(`not json`), 0600); err != nil { + t.Fatal(err) + } + if DetachWegentToken(context.Background(), directory, id, token) == nil { + t.Fatal("accepted corrupt receipt") + } +} + +func TestWegentTransferRequiresExistingSourceForNewReceipt(t *testing.T) { + directory, token := wegentTransferFixture(t) + if err := DeleteTokenDataForProfile(directory, "synthetic-corp:alice"); err != nil { + t.Fatal(err) + } + if DetachWegentToken(context.Background(), directory, strings.Repeat("d", 64), token) == nil { + t.Fatal("accepted unproven source handoff") + } +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/build.py b/plugins/dingtalk/.wework-build/dws-auth/build.py new file mode 100644 index 0000000..ee2336a --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/build.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Build the DWS native companion from a pinned archive plus additive sources.""" + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import tarfile +import tempfile +import urllib.request +from pathlib import Path + +VERSION = "1.0.58" +SOURCE_SHA256 = "f6b2dcf16b34492d7be25ce63fc81c7155d6a857af21c0604ae16bf4fa96f1e2" +ROOT = Path(__file__).resolve().parent + + +def prepare_source_archive(directory: Path, provided: Path | None) -> Path: + archive = provided + if archive is None: + archive = directory / "source.tar.gz" + url = f"https://codeload.github.com/DingTalk-Real-AI/dingtalk-workspace-cli/tar.gz/refs/tags/v{VERSION}" + with urllib.request.urlopen(url, timeout=30) as response: + archive.write_bytes(response.read(100 * 1024 * 1024 + 1)) + if hashlib.sha256(archive.read_bytes()).hexdigest() != SOURCE_SHA256: + raise ValueError("DWS source archive checksum mismatch") + return archive.resolve() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--source-archive", type=Path) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--test", action="store_true") + args = parser.parse_args() + output = args.output.resolve() + if ( + os.environ.get("GOOS", "windows" if os.name == "nt" else "") == "windows" + and output.suffix != ".exe" + ): + output = output.with_name(output.name + ".exe") + with tempfile.TemporaryDirectory(prefix="wegent-dws-build-") as temporary: + directory = Path(temporary) + archive = prepare_source_archive(directory, args.source_archive) + with tarfile.open(archive) as source: + source.extractall(directory, filter="data") + source_root = directory / f"dingtalk-workspace-cli-{VERSION}" + target = source_root / "cmd/wegent-account-auth" + shutil.copytree(ROOT / "overlay", target) + for path in (ROOT / "auth-overlay").glob("*.go"): + shutil.copyfile(path, source_root / "internal/auth" / path.name) + sdk = source_root / "internal/wegentpluginauth" + sdk.mkdir() + for path in (ROOT.parent / "plugin-auth-go").glob("*.go"): + shutil.copyfile(path, sdk / path.name) + if args.test: + subprocess.run( + ["go", "test", "./internal/auth", "-run", "^TestWegentTransfer"], + cwd=source_root, + check=True, + ) + subprocess.run( + [ + "go", + "test", + "./internal/wegentpluginauth", + "./cmd/wegent-account-auth", + ], + cwd=source_root, + check=True, + ) + output.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "go", + "build", + "-trimpath", + "-ldflags=-s -w", + "-o", + str(output), + "./cmd/wegent-account-auth", + ], + cwd=source_root, + check=True, + ) + for name in ("LICENSE", "NOTICE"): + shutil.copyfile( + source_root / name, output.with_name(output.name + "." + name) + ) + compiled_sources = {} + for label, folder in ( + ("overlay", ROOT / "overlay"), + ("auth-overlay", ROOT / "auth-overlay"), + ("sdk", ROOT.parent / "plugin-auth-go"), + ): + for path in sorted(folder.glob("*.go")): + if not path.name.endswith("_test.go"): + compiled_sources[f"{label}/{path.name}"] = hashlib.sha256( + path.read_bytes() + ).hexdigest() + target = subprocess.check_output( + ["go", "env", "GOOS", "GOARCH"], text=True + ).split() + metadata = { + "upstreamVersion": VERSION, + "upstreamSourceSha256": SOURCE_SHA256, + "nativeProtocolVersion": 1, + "buildFlags": ["-trimpath", "-ldflags=-s -w"], + "target": "/".join(target), + "sources": compiled_sources, + "binarySha256": hashlib.sha256(output.read_bytes()).hexdigest(), + } + output.with_name(output.name + ".json").write_text( + json.dumps(metadata, indent=2) + "\n" + ) + print(json.dumps({"binary": str(output), "target": metadata["target"]})) + + +if __name__ == "__main__": + main() diff --git a/plugins/dingtalk/.wework-build/dws-auth/entry.py b/plugins/dingtalk/.wework-build/dws-auth/entry.py new file mode 100644 index 0000000..38d9e2d --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/entry.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Execute the checksum-verified companion bundled in the installed plugin.""" + +import hashlib +import json +import os +import platform +import subprocess +import sys +from pathlib import Path + +from wegent_plugin_auth import AuthError, local_configuration + + +def companion() -> Path: + operating_system = {"Darwin": "darwin", "Linux": "linux", "Windows": "windows"}.get( + platform.system() + ) + architecture = { + "x86_64": "amd64", + "AMD64": "amd64", + "aarch64": "arm64", + "arm64": "arm64", + }.get(platform.machine()) + if not operating_system or not architecture: + raise ValueError + root = Path(__file__).resolve().parent + directory = root / "native" / f"{operating_system}-{architecture}" + executable = directory / ( + "dws-account-auth.exe" if operating_system == "windows" else "dws-account-auth" + ) + metadata = executable.with_name(executable.name + ".json") + if ( + executable.is_symlink() + or metadata.is_symlink() + or not executable.resolve().is_relative_to(root) + ): + raise ValueError + if metadata.stat().st_size > 65536 or not executable.is_file(): + raise ValueError + value = json.loads(metadata.read_text(encoding="utf-8")) + if ( + type(value["nativeProtocolVersion"]) is not int + or value["nativeProtocolVersion"] != 1 + or value["target"] != f"{operating_system}/{architecture}" + ): + raise ValueError + with executable.open("rb") as stream: + digest = hashlib.sha256() + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + if digest.hexdigest() != value["binarySha256"]: + raise ValueError + return executable + + +def launch(executable: Path, arguments: list[str]) -> None: + command = [str(executable), *arguments] + if platform.system() == "Windows": + # CPython's os.execv uses the Windows CRT overlay implementation, which + # can let the launcher exit successfully before the child has finished. + # Wait explicitly so the host observes the native adapter's real status. + completed = subprocess.run(command, check=False) + raise SystemExit(completed.returncode) + os.execv(str(executable), command) + + +if __name__ == "__main__": + try: + executable = companion() + settings = local_configuration() + # Only these declared provider settings may select the source auth store. + for name in ("DWS_CONFIG_DIR", "DWS_KEYCHAIN_DIR", "DWS_DISABLE_KEYCHAIN"): + if name in settings: + os.environ[name] = settings[name] + # The child inherits the host's private stdin nonce and socket environment. + launch(executable, sys.argv[1:]) + except (OSError, ValueError, KeyError, TypeError, AuthError): + print( + json.dumps({"status": "error", "code": "plugin_auth_package_sync_required"}) + ) + raise SystemExit(1) diff --git a/plugins/dingtalk/.wework-build/dws-auth/overlay/main.go b/plugins/dingtalk/.wework-build/dws-auth/overlay/main.go new file mode 100644 index 0000000..1a45772 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/overlay/main.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// Built inside the pinned upstream module; no upstream source patch is required. +package main + +import ( + "context" + "fmt" + "os" + + pluginauth "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/wegentpluginauth" +) + +func main() { + provider := pluginauth.Provider{Export: export, Detach: detach, Allowed: allowed, Run: run, Refresh: refresh, Revoke: revoke} + // The manifest must declare exclusive export. The host stages escrow before + // detach and activates it only after the durable source receipt is confirmed. + if err := pluginauth.Serve(context.Background(), "dingtalk", "oauth2", provider, os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "plugin_auth_dws_failed") + os.Exit(1) + } +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/overlay/provider.go b/plugins/dingtalk/.wework-build/dws-auth/overlay/provider.go new file mode 100644 index 0000000..7837b2b --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/overlay/provider.go @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + pluginauth "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/wegentpluginauth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +var denied = errors.New("plugin_auth_dws_operation_denied") + +func export(ctx context.Context) (pluginauth.Account, error) { + // The connector targets the official MCP provider. A custom CLI environment + // needs its own declaration; never move its grant to another token endpoint. + if config.GetMCPBaseURL() != config.DefaultMCPBaseURL { + return pluginauth.Account{}, denied + } + token, err := auth.LoadTokenData(config.DefaultConfigDir()) + if err != nil || token == nil || token.CorpID == "" || token.UserID == "" || token.Source != "mcp" || token.ClientID == "" || token.AccessToken == "" || token.RefreshToken == "" { + return pluginauth.Account{}, denied + } + return encode(token) +} + +func detach(ctx context.Context, migrationID string, credential pluginauth.Credential) error { + token, err := decode(credential) + if err != nil { + return denied + } + err = auth.DetachWegentToken(ctx, config.DefaultConfigDir(), migrationID, token) + if errors.Is(err, auth.ErrWegentSourceChanged) { + return pluginauth.ErrSourceChanged + } + return err +} + +func allowed(arguments []string) bool { + if len(arguments) == 1 && arguments[0] == "account-status" { + return true + } + if len(arguments) == 0 { + return false + } + products := edition.Get().VisibleProducts + if products == nil { + return false + } + found := false + for _, product := range products() { + if arguments[0] == product { + found = true + break + } + } + if !found { + return false + } + blocked := []string{"--token", "--client-id", "--client-secret", "--profile", "--debug", "--verbose", "-v", "--output", "-o", "--mock"} + for _, argument := range arguments { + name := strings.SplitN(argument, "=", 2)[0] + if strings.HasPrefix(name, "-") && !strings.HasPrefix(name, "--") && len(name) > 2 && !strings.HasPrefix(name, "-f") && strings.ContainsAny(name[1:], "vo") { + return false + } + for _, flag := range blocked { + if name == flag || (len(flag) == 2 && strings.HasPrefix(name, flag)) { + return false + } + } + } + return true +} + +func decode(credential pluginauth.Credential) (*auth.TokenData, error) { + value := make(map[string]any, len(credential)) + for key, item := range credential { + value[key] = item + } + for _, key := range []string{"expires_at", "refresh_expires_at"} { + if raw, ok := value[key]; ok { + seconds, err := strconv.ParseFloat(fmt.Sprint(raw), 64) + if err != nil || seconds <= 0 || seconds > 253402300799 { + return nil, denied + } + value[key] = time.Unix(int64(seconds), 0).UTC().Format(time.RFC3339) + } + } + if private, ok := value["provider_private"].(map[string]any); ok { + value["persistent_code"] = private["persistent_code"] + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, denied + } + var token auth.TokenData + if json.Unmarshal(encoded, &token) != nil || token.AccessToken == "" || token.CorpID == "" || token.UserID == "" { + return nil, denied + } + // The default DWS connector uses MCP-managed OAuth. Direct app credentials + // require a separate provider declaration rather than reading a local secret. + if token.Source != "mcp" || token.ClientID == "" { + return nil, denied + } + return &token, nil +} + +func encode(token *auth.TokenData) (pluginauth.Account, error) { + data, err := json.Marshal(token) + if err != nil { + return pluginauth.Account{}, denied + } + var value pluginauth.Credential + if json.Unmarshal(data, &value) != nil { + return pluginauth.Account{}, denied + } + value["expires_at"] = token.ExpiresAt.Unix() + value["refresh_expires_at"] = token.RefreshExpAt.Unix() + value["provider_private"] = map[string]any{"persistent_code": token.PersistentCode} + delete(value, "persistent_code") + return pluginauth.Account{ID: token.CorpID + ":" + token.UserID, Credential: value}, nil +} + +func withMemory(token *auth.TokenData, writable bool, callback func(string) error) error { + directory, err := os.MkdirTemp("", "wegent-dws-native-") + if err != nil { + return denied + } + defer os.RemoveAll(directory) + originalConfig, hadConfig := os.LookupEnv("DWS_CONFIG_DIR") + if os.Setenv("DWS_CONFIG_DIR", directory) != nil { + return denied + } + defer func() { + if hadConfig { + _ = os.Setenv("DWS_CONFIG_DIR", originalConfig) + } else { + _ = os.Unsetenv("DWS_CONFIG_DIR") + } + }() + original := edition.Get() + hooks := *original + hooks.Name, hooks.IsEmbedded, hooks.HideAuthLogin = "wegent", true, true + hooks.AutoPurgeToken = false + hooks.ConfigDir = func() string { return directory } + hooks.LoadToken = func(string) ([]byte, error) { return json.Marshal(token) } + hooks.SaveToken = func(_ string, data []byte) error { + if !writable { + return denied + } + var next auth.TokenData + if json.Unmarshal(data, &next) != nil || next.CorpID != token.CorpID || next.UserID != token.UserID { + return denied + } + *token = next + return nil + } + hooks.DeleteToken = func(string) error { return denied } + hooks.OnAuthError = func(string, error) error { return denied } + if !writable { + hooks.TokenProvider = func(context.Context, func() (string, error)) (string, error) { return token.AccessToken, nil } + } + edition.Override(&hooks) + defer edition.Override(original) + return callback(directory) +} + +func refresh(ctx context.Context, credential pluginauth.Credential) (pluginauth.Account, error) { + token, err := decode(credential) + if err != nil || token.RefreshToken == "" { + return pluginauth.Account{}, denied + } + err = withMemory(token, true, func(directory string) error { + provider := auth.NewOAuthProvider(directory, slog.New(slog.NewTextHandler(io.Discard, nil))) + refreshed, err := provider.GetTokenSnapshot(ctx) + if err != nil { + return denied + } + *token = *refreshed + return nil + }) + if err != nil { + return pluginauth.Account{}, denied + } + return encode(token) +} + +func revoke(ctx context.Context, credential pluginauth.Credential) error { + token, err := decode(credential) + if err != nil { + return err + } + return withMemory(token, false, func(string) error { return auth.RevokeTokenRemoteForData(ctx, token) }) +} + +func run(ctx context.Context, credential pluginauth.Credential, arguments []string) error { + if !allowed(arguments) { + return denied + } + if _, ok := credential["refresh_token"]; ok { + return denied + } + if _, ok := credential["provider_private"]; ok { + return denied + } + token, err := decode(credential) + if err != nil { + return err + } + // Execute the upstream CLI with the same business command implementation. + if len(arguments) == 1 && arguments[0] == "account-status" { + return json.NewEncoder(os.Stdout).Encode(map[string]any{"authenticated": true, "accountId": token.CorpID + ":" + token.UserID}) + } + // Its private auth store hooks never read/write the platform Keychain. + return withMemory(token, false, func(string) error { + output, err := capture(func() int { + original := os.Args + os.Args = append([]string{"dws"}, arguments...) + defer func() { os.Args = original }() + return app.Execute() + }) + if err != nil || bytes.Contains(output, []byte(token.AccessToken)) { + return denied + } + _, err = os.Stdout.Write(output) + return err + }) +} + +func capture(callback func() int) ([]byte, error) { + reader, writer, err := os.Pipe() + if err != nil { + return nil, denied + } + defer reader.Close() + original := os.Stdout + os.Stdout = writer + defer func() { os.Stdout = original; writer.Close() }() + result := make(chan []byte, 1) + go func() { data, _ := io.ReadAll(io.LimitReader(reader, 1024*1024+1)); result <- data; reader.Close() }() + code := callback() + writer.Close() + output := <-result + if code != 0 || len(output) > 1024*1024 { + return nil, denied + } + return output, nil +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/overlay/provider_test.go b/plugins/dingtalk/.wework-build/dws-auth/overlay/provider_test.go new file mode 100644 index 0000000..2759aa1 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/overlay/provider_test.go @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: Apache-2.0 +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + pluginauth "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/wegentpluginauth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func fixture() pluginauth.Credential { + return pluginauth.Credential{"access_token": "synthetic-old-access", "refresh_token": "synthetic-refresh", "expires_at": time.Now().Add(-time.Minute).Unix(), + "refresh_expires_at": time.Now().Add(time.Hour).Unix(), "source": "mcp", "client_id": "synthetic-client", "corp_id": "corp", "user_id": "alice"} +} + +func TestCredentialMappingPreservesIdentityAndHidesPersistentCode(t *testing.T) { + value := fixture() + value["provider_private"] = map[string]any{"persistent_code": "synthetic-persistent"} + token, err := decode(value) + if err != nil { + t.Fatal(err) + } + if token.PersistentCode != "synthetic-persistent" { + t.Fatal("missing provider private data") + } + account, err := encode(token) + if err != nil || account.ID != "corp:alice" { + t.Fatal("wrong account") + } + if _, ok := account.Credential["persistent_code"]; ok { + t.Fatal("private code escaped private fields") + } + delete(value, "user_id") + if _, err := decode(value); err == nil { + t.Fatal("accepted ambiguous account") + } +} + +func TestAccountHealthReturnsMetadataWithoutLocalAuthentication(t *testing.T) { + credential := fixture() + delete(credential, "refresh_token") + output, err := capture(func() int { + if run(context.Background(), credential, []string{"account-status"}) != nil { + return 1 + } + return 0 + }) + if err != nil { + t.Fatal(err) + } + var result map[string]any + if json.Unmarshal(output, &result) != nil || result["authenticated"] != true || result["accountId"] != "corp:alice" || len(result) != 2 { + t.Fatal("health must return only account metadata") + } +} + +func TestMemoryHooksNeverPersistCredentialsOrFallBackToKeychain(t *testing.T) { + custom := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", custom) + if err := os.WriteFile(filepath.Join(custom, "mcp_url"), []byte("https://custom.invalid"), 0600); err != nil { + t.Fatal(err) + } + if _, err := export(context.Background()); err == nil { + t.Fatal("custom provider was exported as the official provider") + } + token, err := decode(fixture()) + if err != nil { + t.Fatal(err) + } + err = withMemory(token, false, func(directory string) error { + if config.GetMCPBaseURL() != config.DefaultMCPBaseURL { + t.Fatal("business credential inherited a custom token endpoint") + } + loaded, err := auth.LoadTokenData(directory) + if err != nil || loaded.AccessToken != token.AccessToken { + t.Fatal("memory load failed") + } + if auth.SaveTokenData(directory, token) == nil { + t.Fatal("business code persisted a credential") + } + if edition.Get().DeleteToken(directory) == nil { + t.Fatal("business code deleted auth") + } + got, err := edition.Get().TokenProvider(context.Background(), func() (string, error) { t.Fatal("used local token fallback"); return "", nil }) + if err != nil || got != token.AccessToken { + t.Fatal("memory token failed") + } + return filepath.WalkDir(directory, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() { + contents, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(contents), "synthetic-") { + t.Fatal("secret written to auth directory") + } + } + return nil + }) + }) + if err != nil { + t.Fatal(err) + } +} + +type roundTrip func(*http.Request) (*http.Response, error) + +func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestRefreshAndRevokeUseUpstreamProviderWithInMemoryStorage(t *testing.T) { + original := http.DefaultTransport + defer func() { http.DefaultTransport = original }() + calls := 0 + http.DefaultTransport = roundTrip(func(request *http.Request) (*http.Response, error) { + calls++ + var body map[string]any + if json.NewDecoder(request.Body).Decode(&body) != nil { + t.Fatal("invalid request") + } + result := `{}` + switch request.URL.Path { + case "/oauth2/refreshToken": + if body["refreshToken"] != "synthetic-refresh" || body["clientId"] != "synthetic-client" { + t.Fatal("wrong refresh identity") + } + result = `{"accessToken":"synthetic-new-access","refreshToken":"synthetic-rotated","expiresIn":3600,"corpId":"corp","userId":"alice"}` + case "/oauth2/revokeToken": + if body["accessToken"] != "synthetic-new-access" { + t.Fatal("revoked wrong token") + } + default: + t.Fatalf("unexpected provider endpoint %s", request.URL.Path) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(result)), Header: make(http.Header)}, nil + }) + account, err := refresh(context.Background(), fixture()) + if err != nil { + t.Fatal(err) + } + if account.ID != "corp:alice" || account.Credential["refresh_token"] != "synthetic-rotated" { + t.Fatal("rotation was not preserved") + } + if revoke(context.Background(), account.Credential) != nil { + t.Fatal("revocation failed") + } + if calls != 2 { + t.Fatal("unexpected provider request count") + } +} + +func TestBusinessDeniesAuthenticationAndPrivateGrantMaterial(t *testing.T) { + product := edition.Get().VisibleProducts()[0] + if !allowed([]string{product, "--help"}) { + t.Fatal("known product help denied") + } + for _, args := range [][]string{{"auth", "login"}, {product, "--token=secret"}, {product, "--profile", "other"}, {product, "--debug"}, {product, "-o/tmp/out"}, {product, "-yv"}} { + if allowed(args) { + t.Fatal("accepted auth control override") + } + } + if run(context.Background(), fixture(), []string{product, "--help"}) == nil { + t.Fatal("business received refresh token") + } +} + +func TestRealUpstreamProductHelpRunsWithoutLocalAuthentication(t *testing.T) { + value := fixture() + delete(value, "refresh_token") + output, err := capture(func() int { + if run(context.Background(), value, []string{edition.Get().VisibleProducts()[0], "--help"}) != nil { + return 1 + } + return 0 + }) + if err != nil || len(output) == 0 || strings.Contains(string(output), "synthetic-") { + t.Fatal("upstream help failed or leaked credential") + } +} + +func TestRealUpstreamBusinessCommandUsesOnlyTheSuppliedAccessToken(t *testing.T) { + // Trust only the isolated fixture; production retains DWS HTTPS/domain checks. + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1") + t.Setenv("DWS_TRUSTED_DOMAINS", "127.0.0.1") + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var rpc map[string]any + if json.NewDecoder(request.Body).Decode(&rpc) != nil { + http.Error(response, "invalid RPC", 400) + return + } + response.Header().Set("Content-Type", "application/json") + var result any = map[string]any{} + switch rpc["method"] { + case "initialize": + result = map[string]any{"protocolVersion": "2025-03-26", "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "synthetic-provider", "version": "1"}} + case "notifications/initialized": + response.WriteHeader(202) + return + case "tools/call": + calls++ + headers, _ := json.Marshal(request.Header) + params, _ := json.Marshal(rpc["params"]) + if !strings.Contains(string(headers), "synthetic-old-access") { + t.Error("business request omitted supplied access token") + } + if !strings.Contains(string(params), "synthetic-task") { + t.Error("business request lost task input") + } + result = map[string]any{"content": []any{map[string]any{"type": "text", "text": `{"taskId":"synthetic-task","subject":"native-dws-account-read"}`}}} + default: + t.Errorf("unexpected RPC method %v", rpc["method"]) + http.Error(response, "unexpected", 400) + return + } + json.NewEncoder(response).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc["id"], "result": result}) + })) + defer server.Close() + original := edition.Get() + hooks := *original + hooks.StaticServers = func() []edition.ServerInfo { + servers := original.StaticServers() + for index := range servers { + servers[index].Endpoint = server.URL + } + return servers + } + edition.Override(&hooks) + defer edition.Override(original) + value := fixture() + delete(value, "refresh_token") + value["expires_at"] = time.Now().Add(time.Hour).Unix() + output, err := capture(func() int { + if run(context.Background(), value, []string{"todo", "task", "get", "--task-id", "synthetic-task", "--format", "json"}) != nil { + return 1 + } + return 0 + }) + if err != nil { + t.Fatal("upstream business command failed") + } + if calls != 1 || !strings.Contains(string(output), "native-dws-account-read") || strings.Contains(string(output), "synthetic-old-access") { + t.Fatal("business did not use native auth safely") + } +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/package.py b/plugins/dingtalk/.wework-build/dws-auth/package.py new file mode 100644 index 0000000..d09c83a --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/package.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Build a self-contained DingTalk plugin with all supported native companions.""" + +import argparse +import hashlib +import json +import os +import platform +import shutil +import stat +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + +from build import prepare_source_archive +from toolchain import ensure_go +from verify import verify_artifacts, verify_native_entry + +ROOT = Path(__file__).resolve().parent +TARGETS = ( + "darwin/arm64", + "darwin/amd64", + "linux/amd64", + "linux/arm64", + "windows/amd64", +) + + +def assemble(plugin: Path, output: Path, source_archive: Path | None) -> None: + with tempfile.TemporaryDirectory(prefix="wegent-dws-package-") as temporary: + staged = Path(temporary) / "dingtalk" + shutil.copytree( + plugin, + staged, + symlinks=True, + ignore=shutil.ignore_patterns( + "__pycache__", "*.pyc", ".DS_Store", "native" + ), + ) + for path in staged.rglob("*"): + if path.is_symlink() or path.name in {".env", ".git"}: + raise ValueError( + "Plugin source must not contain links or private state" + ) + manifest = json.loads( + (staged / ".codex-plugin/plugin.json").read_text(encoding="utf-8") + ) + if ( + manifest.get("name") != "dingtalk" + or not (staged / "scripts/account-auth.py").is_file() + ): + raise ValueError( + "Expected the DingTalk plugin with its native adapter entry" + ) + connector = next( + item for item in manifest["connectors"] if item["slug"] == "dingtalk" + ) + if connector.get("accountAuth", {}).get("exportMode") != "exclusive": + raise ValueError("DingTalk source must declare its account authentication") + if (staged / "scripts/account-auth.py").read_bytes() != ( + ROOT / "entry.py" + ).read_bytes(): + raise ValueError("DingTalk launcher differs from the reviewed build input") + ensure_go(Path(temporary)) + source_archive = prepare_source_archive(Path(temporary), source_archive) + for target in TARGETS: + operating_system, architecture = target.split("/") + destination = ( + staged + / "scripts/native" + / target.replace("/", "-") + / "dws-account-auth" + ) + command = [ + sys.executable, + str(ROOT / "build.py"), + "--output", + str(destination), + ] + host_os = {"Darwin": "darwin", "Linux": "linux", "Windows": "windows"}.get( + platform.system() + ) + host_arch = { + "x86_64": "amd64", + "AMD64": "amd64", + "aarch64": "arm64", + "arm64": "arm64", + }.get(platform.machine()) + if target == f"{host_os}/{host_arch}": + command.append("--test") + command.extend(["--source-archive", str(source_archive)]) + environment = { + **os.environ, + "GOOS": operating_system, + "GOARCH": architecture, + "CGO_ENABLED": "0", + } + subprocess.run(command, env=environment, check=True) + verify_artifacts(staged, TARGETS) + verify_native_entry(staged) + output.parent.mkdir(parents=True, exist_ok=True) + candidate = Path(temporary) / "dingtalk.zip" + files = sorted(path for path in staged.rglob("*") if path.is_file()) + if sum(path.stat().st_size for path in files) > 200 * 1024 * 1024: + raise ValueError("Expanded plugin exceeds Wegent's package limit") + with zipfile.ZipFile( + candidate, "w", zipfile.ZIP_DEFLATED, compresslevel=9 + ) as archive: + for path in files: + entry = zipfile.ZipInfo( + path.relative_to(staged).as_posix(), (1980, 1, 1, 0, 0, 0) + ) + entry.create_system = 3 + entry.external_attr = ( + stat.S_IFREG | (0o755 if path.stat().st_mode & 0o111 else 0o644) + ) << 16 + entry.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(entry, path.read_bytes(), compresslevel=9) + if candidate.stat().st_size > 50 * 1024 * 1024: + raise ValueError("Plugin archive exceeds Wegent's upload limit") + shutil.copyfile(candidate, output) + checksum = hashlib.sha256(candidate.read_bytes()).hexdigest() + output.with_name(output.name + ".sha256").write_text( + checksum + " " + output.name + "\n", encoding="utf-8" + ) + print( + json.dumps( + { + "plugin": str(output.resolve()), + "targets": TARGETS, + "bytes": output.stat().st_size, + "sha256": checksum, + } + ) + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plugin", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--source-archive", type=Path) + arguments = parser.parse_args() + assemble(arguments.plugin, arguments.output, arguments.source_archive) + + +if __name__ == "__main__": + main() diff --git a/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py b/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py new file mode 100644 index 0000000..f50505d --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py @@ -0,0 +1,236 @@ +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "plugin-auth")) +import package +import entry +import verify + + +class PackageTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.plugin = self.root / "dingtalk" + (self.plugin / ".codex-plugin").mkdir(parents=True) + (self.plugin / "scripts").mkdir() + self.manifest = self.plugin / ".codex-plugin/plugin.json" + self.manifest.write_text( + json.dumps( + { + "name": "dingtalk", + "description": "钉钉认证打包", + "connectors": [ + {"slug": "dingtalk", "accountAuth": {"exportMode": "exclusive"}} + ], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + (self.plugin / "scripts/account-auth.py").write_bytes( + (package.ROOT / "entry.py").read_bytes() + ) + self.output = self.root / "plugin.zip" + source = patch.object( + package, "prepare_source_archive", return_value=self.root / "source.tar.gz" + ) + toolchain = patch.object(package, "ensure_go") + toolchain.start() + self.addCleanup(toolchain.stop) + source.start() + self.addCleanup(source.stop) + + def fake_build(self, command, **kwargs): + if "--output" not in command: + return + environment = kwargs["env"] + target = environment["GOOS"] + "/" + environment["GOARCH"] + binary = Path(command[command.index("--output") + 1]) + if environment["GOOS"] == "windows": + binary = binary.with_suffix(".exe") + binary.parent.mkdir(parents=True) + binary.write_bytes(target.encode()) + metadata = { + "target": target, + "nativeProtocolVersion": 1, + "binarySha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + } + binary.with_name(binary.name + ".json").write_text(json.dumps(metadata)) + for suffix in ("LICENSE", "NOTICE"): + binary.with_name(binary.name + "." + suffix).write_text("test fixture") + + def test_all_targets_are_checked_before_packaging_and_source_stays_unchanged(self): + original = self.manifest.read_bytes() + with ( + patch.object(package.subprocess, "run", side_effect=self.fake_build), + patch.object(package, "verify_native_entry") as native, + ): + package.assemble(self.plugin, self.output, None) + native.assert_called_once() + self.assertEqual(self.manifest.read_bytes(), original) + with zipfile.ZipFile(self.output) as archive: + self.assertEqual( + archive.read("scripts/account-auth.py"), + (package.ROOT / "entry.py").read_bytes(), + ) + manifest = json.loads(archive.read(".codex-plugin/plugin.json")) + self.assertEqual( + manifest["connectors"][0]["accountAuth"]["exportMode"], "exclusive" + ) + for target in package.TARGETS: + prefix = "scripts/native/" + target.replace("/", "-") + "/" + self.assertEqual( + sum(name.startswith(prefix) for name in archive.namelist()), 4 + ) + + def test_utf8_manifest_does_not_depend_on_the_windows_code_page(self): + read_text = Path.read_text + + def legacy_read(path, *args, **kwargs): + return read_text(path, *args, **{"encoding": "cp1252", **kwargs}) + + with ( + patch.object(Path, "read_text", autospec=True, side_effect=legacy_read), + patch.object(package.subprocess, "run", side_effect=self.fake_build), + patch.object(package, "verify_native_entry"), + ): + package.assemble(self.plugin, self.output, None) + with zipfile.ZipFile(self.output) as archive: + manifest = json.loads(archive.read(".codex-plugin/plugin.json")) + self.assertEqual(manifest["description"], "钉钉认证打包") + + def test_symlinks_are_rejected_without_following_the_target(self): + (self.plugin / "scripts/external").symlink_to( + self.root / "missing-external-file" + ) + with patch.object(package.subprocess, "run") as build: + with self.assertRaisesRegex(ValueError, "links or private state"): + package.assemble(self.plugin, self.output, None) + build.assert_not_called() + self.assertFalse(self.output.exists()) + + def test_package_digest_is_reproducible_across_source_timestamps(self): + with ( + patch.object(package.subprocess, "run", side_effect=self.fake_build), + patch.object(package, "verify_native_entry"), + ): + package.assemble(self.plugin, self.output, None) + original = self.output.read_bytes() + os.utime(self.manifest, (1_700_000_000, 1_700_000_000)) + package.assemble(self.plugin, self.output, None) + self.assertEqual(self.output.read_bytes(), original) + expected = hashlib.sha256(original).hexdigest() + self.assertEqual( + self.output.with_name("plugin.zip.sha256").read_text(), + expected + " plugin.zip\n", + ) + + def test_manifest_symlink_is_rejected_before_reading_json(self): + self.manifest.unlink() + self.manifest.symlink_to(self.root / "not-a-manifest") + with self.assertRaisesRegex(ValueError, "links or private state"): + package.assemble(self.plugin, self.output, None) + + def test_private_state_is_rejected_before_build(self): + (self.plugin / ".env").write_text("synthetic fixture") + with patch.object(package.subprocess, "run") as build: + with self.assertRaisesRegex(ValueError, "links or private state"): + package.assemble(self.plugin, self.output, None) + build.assert_not_called() + + def test_native_verification_failure_never_replaces_previous_artifact(self): + self.output.write_bytes(b"previous artifact") + with ( + patch.object(package.subprocess, "run", side_effect=self.fake_build), + patch.object( + package, + "verify_native_entry", + side_effect=ValueError("native entry failed"), + ), + ): + with self.assertRaisesRegex(ValueError, "native entry failed"): + package.assemble(self.plugin, self.output, None) + self.assertEqual(self.output.read_bytes(), b"previous artifact") + + def test_binary_tampering_is_rejected(self): + binary = self.plugin / "scripts/native/linux-amd64/dws-account-auth" + self.fake_build( + ["--output", str(binary)], env={"GOOS": "linux", "GOARCH": "amd64"} + ) + binary.write_bytes(b"changed binary") + with self.assertRaisesRegex(ValueError, "provenance mismatch"): + verify.verify_artifacts(self.plugin, ("linux/amd64",)) + + +class EntryTests(unittest.TestCase): + def test_windows_launcher_waits_for_and_propagates_native_exit_code(self): + executable = Path("synthetic/dws-account-auth.exe") + with ( + patch.object(entry.platform, "system", return_value="Windows"), + patch.object( + entry.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 7), + ) as run, + self.assertRaises(SystemExit) as stopped, + ): + entry.launch(executable, ["run", "account-status"]) + self.assertEqual(stopped.exception.code, 7) + run.assert_called_once_with( + [str(executable), "run", "account-status"], check=False + ) + + +class VerificationStateTests(unittest.TestCase): + def invoke_with_state(self, state): + def invoke(plugin, home, credential, connector): + (home / ".cache/rosetta").mkdir(parents=True, exist_ok=True) + if state == "file": + (home / ".cache/rosetta/state").write_bytes(b"synthetic") + elif state == "provider": + (home / ".dws").mkdir(exist_ok=True) + accepted = connector == "dingtalk" and "refresh_token" not in credential + payload = { + "authenticated": True, + "accountId": "synthetic-corp:synthetic-user", + } + return verify.subprocess.CompletedProcess( + [], + 0 if accepted else 1, + json.dumps(payload if accepted else {}).encode(), + b"", + ) + + return invoke + + def test_empty_emulator_directories_do_not_count_as_provider_state(self): + with patch.object( + verify, "invoke", side_effect=self.invoke_with_state("empty") + ): + verify.verify_native_entry(Path("unused-synthetic-plugin")) + + def test_files_and_provider_directories_are_still_rejected(self): + for state in ("file", "provider"): + with ( + self.subTest(state=state), + patch.object( + verify, "invoke", side_effect=self.invoke_with_state(state) + ), + ): + with self.assertRaisesRegex(ValueError, "local account state"): + verify.verify_native_entry(Path("unused-synthetic-plugin")) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/dingtalk/.wework-build/dws-auth/tests/test_toolchain.py b/plugins/dingtalk/.wework-build/dws-auth/tests/test_toolchain.py new file mode 100644 index 0000000..43ec737 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/tests/test_toolchain.py @@ -0,0 +1,75 @@ +"""Pinned compiler bootstrapping must not inherit another Go installation.""" + +import hashlib +import io +import os +import subprocess +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import toolchain + + +class ToolchainTests(unittest.TestCase): + def test_installed_pinned_compiler_resolves_its_own_standard_library(self): + with ( + tempfile.TemporaryDirectory() as temporary, + patch.dict(os.environ, {"GOROOT": "/older/go"}), + patch.object(toolchain.shutil, "which", return_value="/new/go/bin/go"), + patch.object( + toolchain.subprocess, + "run", + return_value=subprocess.CompletedProcess( + [], 0, stdout=f"{toolchain.GO_VERSION}\n/new/go\n" + ), + ) as probe, + patch.object(toolchain.urllib.request, "urlopen") as download, + ): + toolchain.ensure_go(Path(temporary)) + self.assertNotIn("GOROOT", probe.call_args.kwargs["env"]) + self.assertEqual(probe.call_args.kwargs["env"]["GOENV"], "off") + self.assertEqual(os.environ["GOROOT"], "/new/go") + self.assertEqual(os.environ["GOTOOLCHAIN"], "local") + download.assert_not_called() + + def test_bootstrap_replaces_an_inherited_standard_library_root(self): + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w:gz") as writer: + info = tarfile.TarInfo("go/bin/go") + content = b"synthetic pinned compiler" + info.size = len(content) + info.mode = 0o755 + writer.addfile(info, io.BytesIO(content)) + data = archive.getvalue() + release = { + "filename": "go-test.linux-amd64.tar.gz", + "sha256": hashlib.sha256(data).hexdigest(), + } + with ( + tempfile.TemporaryDirectory() as temporary, + patch.dict(os.environ, {"GOROOT": "/older/go", "PATH": "/older/go/bin"}), + patch.object(toolchain.shutil, "which", return_value=None), + patch.object(toolchain.platform, "system", return_value="Linux"), + patch.object(toolchain.platform, "machine", return_value="x86_64"), + patch.dict(toolchain.ARCHIVES, {"linux/amd64": release}), + patch.object( + toolchain.urllib.request, "urlopen", return_value=io.BytesIO(data) + ), + ): + root = Path(temporary) + toolchain.ensure_go(root) + self.assertEqual(os.environ["GOROOT"], str(root / "go")) + self.assertEqual( + os.environ["PATH"].split(os.pathsep)[0], str(root / "go/bin") + ) + self.assertEqual(os.environ["GOTOOLCHAIN"], "local") + self.assertEqual((root / "go/bin/go").read_bytes(), content) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/dingtalk/.wework-build/dws-auth/tests/test_vendor.py b/plugins/dingtalk/.wework-build/dws-auth/tests/test_vendor.py new file mode 100644 index 0000000..1d957ba --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/tests/test_vendor.py @@ -0,0 +1,54 @@ +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import vendor + + +class BuildVendorTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.repository = Path(self.temporary.name) + + def test_bundle_has_all_build_inputs_and_detects_drift(self): + vendor.bundle(self.repository) + vendor.bundle(self.repository, check=True) + target = self.repository / "plugins/dingtalk/.wework-build" + self.assertTrue((target / "plugin-auth/tool.py").is_file()) + self.assertTrue((target / "plugin-auth-go/adapter.go").is_file()) + self.assertTrue((target / "dws-auth/auth-overlay/wegent_transfer.go").is_file()) + (target / "plugin-auth-go/adapter.go").write_text("modified") + with self.assertRaisesRegex(ValueError, "differ"): + vendor.bundle(self.repository, check=True) + + def test_unknown_files_are_preserved_and_rejected(self): + vendor.bundle(self.repository) + unknown = self.repository / "plugins/dingtalk/.wework-build/hand-written.txt" + unknown.write_text("keep") + with self.assertRaisesRegex(ValueError, "unexpected"): + vendor.bundle(self.repository) + self.assertEqual(unknown.read_text(), "keep") + + def test_inventory_is_independent_of_platform_path_order(self): + files = vendor.source_files() + vendor.bundle(self.repository) + with patch.object( + vendor, "source_files", return_value=dict(reversed(files.items())) + ): + vendor.bundle(self.repository, check=True) + + def test_symlink_destination_is_rejected(self): + (self.repository / "plugins/dingtalk").mkdir(parents=True, exist_ok=True) + (self.repository / "plugins/dingtalk/.wework-build").symlink_to( + self.repository / "missing", target_is_directory=True + ) + with self.assertRaisesRegex(ValueError, "symbolic link"): + vendor.bundle(self.repository) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/dingtalk/.wework-build/dws-auth/toolchain.py b/plugins/dingtalk/.wework-build/dws-auth/toolchain.py new file mode 100644 index 0000000..e6834eb --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/toolchain.py @@ -0,0 +1,86 @@ +"""Use the pinned Go compiler, bootstrapping the Linux CI image when needed.""" + +import hashlib +import os +import platform +import shutil +import subprocess +import tarfile +import urllib.request +import zipfile +from pathlib import Path + +GO_VERSION = "go1.25.9" +ARCHIVES = { + "darwin/amd64": { + "filename": "go1.25.9.darwin-amd64.tar.gz", + "sha256": "92cb78fba4796e218c1accb0ea0a214ef2094c382049a244ad6505505d015fbe", + }, + "darwin/arm64": { + "filename": "go1.25.9.darwin-arm64.tar.gz", + "sha256": "9528be7329b9770631a6bd09ca2f3a73ed7332bec01d87435e75e92d8f130363", + }, + "linux/amd64": { + "filename": "go1.25.9.linux-amd64.tar.gz", + "sha256": "00859d7bd6defe8bf84d9db9e57b9a4467b2887c18cd93ae7460e713db774bc1", + }, + "linux/arm64": { + "filename": "go1.25.9.linux-arm64.tar.gz", + "sha256": "ec342e7389b7f489564ed5463c63b16cf8040023dabc7861256677165a8c0e2b", + }, + "windows/amd64": { + "filename": "go1.25.9.windows-amd64.zip", + "sha256": "a7a710e225467b34e9e09fb432b829c86c9b2da5821ee5418f7eb2e8ae1a22cc", + }, +} + + +def ensure_go(directory: Path) -> None: + executable = shutil.which("go") + if executable: + probe_environment = {k: v for k, v in os.environ.items() if k != "GOROOT"} + probe_environment.update({"GOENV": "off", "GOTOOLCHAIN": "local"}) + result = subprocess.run( + [executable, "env", "GOVERSION", "GOROOT"], + env=probe_environment, + capture_output=True, + text=True, + check=True, + ) + installed = result.stdout.strip().splitlines() + if len(installed) == 2 and installed[0] == GO_VERSION: + os.environ["GOROOT"] = installed[1] + os.environ["GOTOOLCHAIN"] = "local" + return + system = {"Darwin": "darwin", "Linux": "linux", "Windows": "windows"}.get( + platform.system() + ) + arch = { + "x86_64": "amd64", + "AMD64": "amd64", + "aarch64": "arm64", + "arm64": "arm64", + }.get(platform.machine()) + release = ARCHIVES.get(f"{system}/{arch}") + if release is None: + raise ValueError(f"Unsupported Go build host: {system}/{arch}") + archive = directory / release["filename"] + with urllib.request.urlopen( + f"https://go.dev/dl/{release['filename']}", timeout=60 + ) as response: + data = response.read(80 * 1024 * 1024 + 1) + if hashlib.sha256(data).hexdigest() != release["sha256"]: + raise ValueError("Go toolchain checksum mismatch") + archive.write_bytes(data) + if archive.suffix == ".zip": + with zipfile.ZipFile(archive) as source: + source.extractall(directory) + else: + with tarfile.open(archive) as source: + source.extractall(directory, filter="data") + os.environ["PATH"] = ( + str(directory / "go/bin") + os.pathsep + os.environ.get("PATH", "") + ) + # The selected compiler and standard library must come from the same archive. + os.environ["GOROOT"] = str(directory / "go") + os.environ["GOTOOLCHAIN"] = "local" diff --git a/plugins/dingtalk/.wework-build/dws-auth/vendor.py b/plugins/dingtalk/.wework-build/dws-auth/vendor.py new file mode 100644 index 0000000..01db4e5 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/vendor.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Vendor the canonical build sources so plugin CI needs only its own checkout.""" + +import argparse +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +COMPONENTS = ("plugin-auth", "plugin-auth-go", "dws-auth") +SUFFIXES = {".py", ".go", ".md", ".tmpl", ".inc"} +INVENTORY = "account-auth-build.json" + + +def source_files() -> dict[str, bytes]: + files = {} + for component in COMPONENTS: + for path in sorted((ROOT / component).rglob("*")): + if "__pycache__" in path.parts: + continue + if path.is_symlink(): + raise ValueError("Build sources must not contain symbolic links") + if path.is_file() and ( + path.suffix in SUFFIXES or path.name in {"LICENSE", "NOTICE", "go.mod"} + ): + files[path.relative_to(ROOT).as_posix()] = path.read_bytes() + return files + + +def bundle(repository: Path, *, check: bool = False) -> None: + files = source_files() + inventory = { + "schemaVersion": 1, + "source": "Wegent/sdk", + "files": { + name: hashlib.sha256(content).hexdigest() + for name, content in sorted(files.items()) + }, + } + files[INVENTORY] = (json.dumps(inventory, indent=2) + "\n").encode() + target = repository / "plugins/dingtalk/.wework-build" + if target.is_symlink(): + raise ValueError("Build support destination must not be a symbolic link") + actual = set() + for path in target.rglob("*"): + if "__pycache__" in path.parts: + continue + if path.is_symlink(): + raise ValueError("Build support destination contains a symbolic link") + if path.is_file(): + actual.add(path.relative_to(target).as_posix()) + if check: + if actual != set(files) or any( + (target / name).read_bytes() != content for name, content in files.items() + ): + raise ValueError("Vendored build sources differ from the canonical SDK") + return + if actual - set(files): + raise ValueError("Build support destination contains unexpected files") + for name, content in files.items(): + destination = target / name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repository", type=Path) + parser.add_argument("--check", action="store_true") + arguments = parser.parse_args() + bundle(arguments.repository, check=arguments.check) + + +if __name__ == "__main__": + main() diff --git a/plugins/dingtalk/.wework-build/dws-auth/verify.py b/plugins/dingtalk/.wework-build/dws-auth/verify.py new file mode 100644 index 0000000..335c295 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/verify.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Verify packaged DWS binaries and the real private adapter entry offline.""" + +import hashlib +import json +import os +import socket +import struct +import subprocess +import sys +import tempfile +from pathlib import Path + + +def verify_artifacts(plugin: Path, targets: tuple[str, ...]) -> None: + for target in targets: + executable = plugin / "scripts/native" / target.replace("/", "-") + executable /= ( + "dws-account-auth.exe" + if target.startswith("windows/") + else "dws-account-auth" + ) + metadata = json.loads( + executable.with_name(executable.name + ".json").read_text(encoding="utf-8") + ) + if ( + metadata.get("target") != target + or type(metadata.get("nativeProtocolVersion")) is not int + or metadata.get("nativeProtocolVersion") != 1 + or metadata.get("binarySha256") + != hashlib.sha256(executable.read_bytes()).hexdigest() + ): + raise ValueError("DWS artifact provenance mismatch") + for suffix in ("LICENSE", "NOTICE"): + if not executable.with_name(executable.name + "." + suffix).is_file(): + raise ValueError("DWS artifact license is missing") + + +def invoke( + plugin: Path, home: Path, credential: dict, connector: str +) -> subprocess.CompletedProcess: + nonce = os.urandom(32) + environment = { + key: value + for key, value in os.environ.items() + if key in {"PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP"} + } + environment.update( + HOME=str(home), + USERPROFILE=str(home), + DWS_CONFIG_DIR=str(home / ".dws"), + PYTHONDONTWRITEBYTECODE="1", + ) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(15) + environment["WEGENT_PLUGIN_AUTH_PORT"] = str(listener.getsockname()[1]) + process = subprocess.Popen( + [ + sys.executable, + str(plugin / "scripts/account-auth.py"), + "run", + "account-status", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + ) + try: + process.stdin.write(nonce) + process.stdin.close() + process.stdin = None + connection, _ = listener.accept() + with connection: + connection.settimeout(15) + received = b"" + while len(received) < len(nonce): + chunk = connection.recv(len(nonce) - len(received)) + if not chunk: + raise ValueError( + "DWS private channel closed before authentication" + ) + received += chunk + if received != nonce: + raise ValueError("DWS private channel authentication failed") + payload = json.dumps( + { + "protocolVersion": 1, + "connectorSlug": connector, + "credentialType": "oauth2", + "credential": credential, + } + ).encode() + connection.sendall(struct.pack(">I", len(payload)) + payload) + stdout, stderr = process.communicate(timeout=15) + return subprocess.CompletedProcess( + process.args, process.returncode, stdout, stderr + ) + finally: + if process.poll() is None: + process.kill() + process.communicate() + + +def verify_native_entry(plugin: Path) -> None: + credential = { + "access_token": "synthetic-package-access-token", + "corp_id": "synthetic-corp", + "user_id": "synthetic-user", + "source": "mcp", + "client_id": "synthetic-client", + "expires_at": 253402300798, + } + with tempfile.TemporaryDirectory(prefix="wegent-dws-verify-") as temporary: + home = Path(temporary) / "home" + home.mkdir() + success = invoke(plugin, home, credential, "dingtalk") + if success.returncode != 0 or json.loads(success.stdout) != { + "authenticated": True, + "accountId": "synthetic-corp:synthetic-user", + }: + raise ValueError("DWS packaged adapter health failed") + rejected = [ + invoke( + plugin, + home, + {**credential, "refresh_token": "synthetic-refresh-token"}, + "dingtalk", + ), + invoke(plugin, home, credential, "wrong-connector"), + ] + if any(result.returncode == 0 for result in rejected): + raise ValueError( + "DWS packaged adapter accepted an invalid business credential" + ) + if any( + token in result.stdout + result.stderr + for token in (b"synthetic-package-access-token", b"synthetic-refresh-token") + for result in [success, *rejected] + ): + raise ValueError("DWS packaged adapter exposed a credential") + # Docker Desktop's Rosetta loader creates two empty cache directories. + # No file, link, provider directory or other state is permitted. + for path in home.rglob("*"): + if ( + path.relative_to(home).as_posix() not in {".cache", ".cache/rosetta"} + or path.is_symlink() + or not path.is_dir() + ): + raise ValueError( + "DWS packaged business adapter wrote local account state" + ) + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plugin", required=True, type=Path) + arguments = parser.parse_args() + verify_native_entry(arguments.plugin.resolve()) + print( + json.dumps( + { + "nativeEntry": True, + "invalidCredentialsRejected": True, + "localStateUnchanged": True, + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/dingtalk/.wework-build/plugin-auth-go/LICENSE b/plugins/dingtalk/.wework-build/plugin-auth-go/LICENSE new file mode 100644 index 0000000..b8c67ae --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth-go/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/dingtalk/.wework-build/plugin-auth-go/README.en.md b/plugins/dingtalk/.wework-build/plugin-auth-go/README.en.md new file mode 100644 index 0000000..1481a0b --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth-go/README.en.md @@ -0,0 +1,45 @@ +--- +sidebar_position: 1 +--- + +# Go plugin authentication SDK + +Version `0.3.0` implements the native private channel for `accountAuth` draft v1. +Implement `Provider` callbacks and call `Serve`; the DWS companion is the first +integration. The SDK owns the nonce handshake, loopback transport, framing and +identity checks, suppressed auth output and fixed errors. Providers own public +business output and must reject credential overrides, auth commands and debugging +through `Allowed`. + +Refresh returns a complete `Account`. Unlike Python's incremental merge API, Go +providers preserve unchanged account fields and non-rotated refresh tokens. The +backend also validates account identity. Keep extra grant-management material in +the `provider_private` object, which is excluded from business credentials along +with refresh tokens, client secrets and persistent codes. + +Optional `Detach(ctx, migrationID, credential)` runs only after durable encrypted +escrow. Return nil only after an idempotent, recoverable source-store handoff. +Never delete source credentials in `Export`. The declaration +`exportMode: "exclusive"` selects stage → detach → activate in the native host. +Do not declare this capability without a supported source-store transfer API. + +Return `ErrSourceChanged` only after durably fencing off the old migration ID +under the provider lock, preventing every delayed detach from deleting source +credentials. The host then cancels obsolete escrow; a user retry exports current +credentials with a new ID. Persistence failures or uncertain source state must +return an ordinary error and retain recoverable escrow. + +Declare custom source directories and public switches as `directory` or bounded +`enum` entries in `accountAuth.localEnvironment`. Read host-validated settings +with `LocalConfiguration()` in `Export`, `Authorize` or `Detach`. They are not +merged into the process environment or provided to `Run`, `Refresh` or `Revoke`. +Do not include local paths in exported credentials. See the +[Python SDK](../plugin-auth/README.en.md) for the declaration and limits. + +The SDK does not yet include public CLI broker delegation and does not own +Keychain storage, device grants or provider HTTP protocols. + +```bash +cd sdk/plugin-auth-go +go test -race ./... +``` diff --git a/plugins/dingtalk/.wework-build/plugin-auth-go/README.md b/plugins/dingtalk/.wework-build/plugin-auth-go/README.md new file mode 100644 index 0000000..3257d4a --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth-go/README.md @@ -0,0 +1,38 @@ +--- +sidebar_position: 1 +--- + +# Go 插件认证 SDK + +版本 `0.3.0`,实现 `accountAuth` 草案 v1 的原生私有通道。提供方实现 +`Provider` 的 `Export` / `Authorize` / `Refresh` / `Revoke` / `Allowed` / `Run` +回调,调用 `Serve` 即可;DWS companion 是首个接入实例。 + +SDK 负责一次性 nonce、仅回环 TCP、长度和身份校验、认证回调输出抑制、固定错误。 +`Run` 的公开业务输出由插件负责,`Allowed` 必须拒绝认证管理、令牌覆盖与调试参数。 +刷新回调返回完整 `Account`;与 Python SDK 的增量合并接口不同,Go 提供方必须 +保留稳定账号字段与未轮换的 Refresh Token,后端还会校验账号未改变。 + +`Detach(ctx, migrationID, credential)` 是独占迁移的可选回调:只能在后端已持久化 +暂存后由原生宿主调用,必须完成可恢复、幂等的源存储交接才返回 nil。 +不要在 `Export` 内删除源凭据。声明 `exportMode: "exclusive"` 后,宿主会走 +暂存→detach→激活流程;没有受支持的迁移接口时不要声明该能力。 + +源凭据轮换时,只有在提供方锁内持久化旧迁移 ID 的作废记录、阻止所有迟到操作 +删除凭据后,才能返回 `ErrSourceChanged`。宿主随后取消旧暂存,用户可使用新 ID +迁移最新凭据。记录无法持久化或源状态不明时返回普通错误,保留可恢复暂存。 + +源存储有自定义目录或公开开关时,在 `accountAuth.localEnvironment` 声明 +`directory` 或有限 `enum`,通过 `LocalConfiguration()` 读取宿主校验后的配置。 +配置仅提供给 `Export`、`Authorize` 和 `Detach`,不会自动合并到进程环境; +`Run`、`Refresh` 和 `Revoke` 不接收源设备配置。不要把本机目录加入导出的凭据。 +声明格式和边界见 [Python SDK](../plugin-auth/README.md)。 + +仅刷新、授权、撤销需要的额外材料放在 `provider_private` 对象内,后端不会向 +业务回调下发该对象、Refresh Token、`client_secret` 或 `persistent_code`。 +该库暂不提供公开 CLI 的 broker 委派接口,也不负责钥匙串、设备授权或提供方网络协议。 + +```bash +cd sdk/plugin-auth-go +go test -race ./... +``` diff --git a/plugins/dingtalk/.wework-build/plugin-auth-go/adapter.go b/plugins/dingtalk/.wework-build/plugin-auth-go/adapter.go new file mode 100644 index 0000000..d863aff --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth-go/adapter.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +package pluginauth + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" +) + +var ErrProvider = errors.New("plugin_auth_provider_failed") +var ErrUnsupported = errors.New("plugin_auth_operation_unsupported") + +// ErrSourceChanged is valid only after Detach durably prevents this transfer ID +// from deleting source credentials. It authorizes clearing the obsolete escrow. +var ErrSourceChanged = errors.New("plugin_auth_source_changed") + +type Account struct { + ID string + Credential Credential +} + +// Provider callbacks own provider semantics; transport and secret error handling are shared. +type Provider struct { + Export func(context.Context) (Account, error) + Authorize func(context.Context) (Account, error) + Refresh func(context.Context, Credential) (Account, error) + Revoke func(context.Context, Credential) error + Detach func(context.Context, string, Credential) error + Allowed func([]string) bool + Run func(context.Context, Credential, []string) error +} + +func validate(value Credential, kind string) error { + fields := map[string][]string{"password": {"username", "password"}, "bearer": {"token"}, "oauth2": {"access_token"}} + required, ok := fields[kind] + if !ok || value == nil { + return ErrProtocol + } + for _, key := range required { + text, ok := value[key].(string) + if !ok || strings.TrimSpace(text) == "" { + return ErrProtocol + } + } + return nil +} + +// Serve runs once per native process. It is intentionally not goroutine-safe: +// auth callback stdout/stderr are redirected process-wide, just as in the Python SDK. +func Serve(ctx context.Context, connector, kind string, provider Provider, arguments []string) (err error) { + defer func() { + if recover() != nil { + err = ErrProvider + } + }() + if len(arguments) == 0 { + return ErrUnsupported + } + operation := arguments[0] + switch operation { + case "export": + if provider.Export == nil || len(arguments) != 1 { + return ErrUnsupported + } + case "authorize": + if kind != "oauth2" || provider.Authorize == nil || len(arguments) != 1 { + return ErrUnsupported + } + case "refresh": + if kind != "oauth2" || provider.Refresh == nil || len(arguments) != 1 { + return ErrUnsupported + } + case "revoke": + if kind != "oauth2" || provider.Revoke == nil || len(arguments) != 1 { + return ErrUnsupported + } + case "detach": + if kind != "oauth2" || provider.Detach == nil || len(arguments) != 2 || len(arguments[1]) != 64 || strings.Trim(arguments[1], "0123456789abcdef") != "" { + return ErrUnsupported + } + case "run": + if provider.Run == nil || provider.Allowed == nil || !provider.Allowed(arguments[1:]) { + return ErrUnsupported + } + default: + return ErrUnsupported + } + channel, err := Connect(connector, kind) + if err != nil { + return err + } + defer channel.Close() + var credential Credential + if operation == "run" || operation == "refresh" || operation == "revoke" || operation == "detach" { + credential, err = channel.Receive() + if err != nil { + return err + } + } + if operation == "run" { + channel.Close() + if provider.Run(ctx, credential, arguments[1:]) != nil { + return ErrProvider + } + return nil + } + var account Account + err = quiet(func() error { + switch operation { + case "export": + account, err = provider.Export(ctx) + case "authorize": + account, err = provider.Authorize(ctx) + case "refresh": + account, err = provider.Refresh(ctx, credential) + case "revoke": + err = provider.Revoke(ctx, credential) + case "detach": + err = provider.Detach(ctx, arguments[1], credential) + } + return err + }) + if err != nil { + if operation == "detach" && errors.Is(err, ErrSourceChanged) { + return json.NewEncoder(os.Stdout).Encode(map[string]any{"status": "source_changed", "protocolVersion": 1}) + } + return ErrProvider + } + if operation == "revoke" { + return json.NewEncoder(os.Stdout).Encode(map[string]any{"status": "ok", "protocolVersion": 1}) + } + if operation == "detach" { + return json.NewEncoder(os.Stdout).Encode(map[string]any{"status": "detached", "protocolVersion": 1}) + } + if strings.TrimSpace(account.ID) == "" || len(account.ID) > 256 { + return ErrProvider + } + if err := channel.Send(account.Credential); err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{"status": "ok", "protocolVersion": 1, "credentialType": kind, "accountId": account.ID}) +} + +func quiet(callback func() error) error { + sink, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return ErrProvider + } + stdout, stderr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = sink, sink + defer func() { os.Stdout, os.Stderr = stdout, stderr; sink.Close() }() + return callback() +} diff --git a/plugins/dingtalk/.wework-build/plugin-auth-go/configuration.go b/plugins/dingtalk/.wework-build/plugin-auth-go/configuration.go new file mode 100644 index 0000000..e38f65e --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth-go/configuration.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +package pluginauth + +import ( + "errors" + "os" + "regexp" + "strings" +) + +var ErrLocalConfiguration = errors.New("plugin_auth_invalid_local_configuration") + +// LocalConfiguration returns host-validated source settings without modifying +// the interpreter environment. Run, refresh and revoke receive no source settings. +func LocalConfiguration() (map[string]string, error) { + raw, present := os.LookupEnv("WEGENT_PLUGIN_AUTH_LOCAL_CONFIGURATION") + if !present { + return map[string]string{}, nil + } + if len(raw) > 16384 { + return nil, ErrLocalConfiguration + } + value, err := strictJSON([]byte(raw)) + object, ok := value.(map[string]any) + if err != nil || !ok || len(object) > 16 { + return nil, ErrLocalConfiguration + } + result := make(map[string]string, len(object)) + for name, value := range object { + validName, _ := regexp.MatchString(`^[A-Z][A-Z0-9_]{0,63}$`, name) + setting, ok := value.(string) + if !validName || !ok || setting == "" || len(setting) > 4096 || strings.ContainsRune(setting, 0) { + return nil, ErrLocalConfiguration + } + result[name] = setting + } + return result, nil +} diff --git a/plugins/dingtalk/.wework-build/plugin-auth-go/configuration_test.go b/plugins/dingtalk/.wework-build/plugin-auth-go/configuration_test.go new file mode 100644 index 0000000..de6fc63 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth-go/configuration_test.go @@ -0,0 +1,23 @@ +package pluginauth + +import ( + "os" + "strings" + "testing" +) + +func TestLocalConfigurationIsBoundedAndNeverOverridesEnvironment(t *testing.T) { + const key = "WEGENT_PLUGIN_AUTH_LOCAL_CONFIGURATION" + originalPath := os.Getenv("PATH") + t.Setenv(key, `{"PATH":"/synthetic/provider"}`) + value, err := LocalConfiguration() + if err != nil || value["PATH"] != "/synthetic/provider" || os.Getenv("PATH") != originalPath { + t.Fatal("configuration was not isolated") + } + for _, raw := range []string{`[]`, `{"MODE":1}`, `{"MODE":"a","MODE":"b"}`, `{"mixedCase":"a"}`, strings.Repeat("x", 16385)} { + t.Setenv(key, raw) + if _, err := LocalConfiguration(); err != ErrLocalConfiguration { + t.Fatal("invalid configuration was accepted") + } + } +} diff --git a/plugins/dingtalk/.wework-build/plugin-auth-go/go.mod b/plugins/dingtalk/.wework-build/plugin-auth-go/go.mod new file mode 100644 index 0000000..40a9a63 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth-go/go.mod @@ -0,0 +1,3 @@ +module github.com/wegent/plugin-auth-go + +go 1.23 diff --git a/plugins/dingtalk/.wework-build/plugin-auth-go/transport.go b/plugins/dingtalk/.wework-build/plugin-auth-go/transport.go new file mode 100644 index 0000000..b2610e6 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth-go/transport.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +// Package pluginauth implements the native accountAuth channel for embedded CLIs. +package pluginauth + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "io" + "math" + "net" + "os" + "strconv" + "time" + "unicode/utf8" +) + +const MaxFrameBytes = 65536 +const Version = "0.3.0" + +var ErrProtocol = errors.New("plugin_auth_invalid_transport") + +type Credential map[string]any + +// Channel carries provider credentials only on a capability-authenticated socket. +type Channel struct { + net.Conn + Connector, CredentialType string +} + +func Connect(connector, credentialType string) (*Channel, error) { + if os.Getenv("WEGENT_PLUGIN_AUTH_FD") != "" { + return nil, ErrProtocol + } + port, err := strconv.ParseUint(os.Getenv("WEGENT_PLUGIN_AUTH_PORT"), 10, 16) + if err != nil || port == 0 { + return nil, ErrProtocol + } + nonce := make([]byte, 32) + if _, err := io.ReadFull(os.Stdin, nonce); err != nil { + return nil, ErrProtocol + } + conn, err := net.DialTimeout("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port))), 5*time.Second) + if err != nil { + return nil, ErrProtocol + } + if err := conn.SetDeadline(time.Now().Add(5 * time.Minute)); err != nil { + conn.Close() + return nil, ErrProtocol + } + if err := writeAll(conn, nonce); err != nil { + conn.Close() + return nil, ErrProtocol + } + return &Channel{conn, connector, credentialType}, nil +} + +func (c *Channel) Receive() (Credential, error) { + var length uint32 + if binary.Read(c.Conn, binary.BigEndian, &length) != nil || length == 0 || length > MaxFrameBytes { + return nil, ErrProtocol + } + data := make([]byte, length) + if _, err := io.ReadFull(c.Conn, data); err != nil { + return nil, ErrProtocol + } + value, err := strictJSON(data) + frame, ok := value.(map[string]any) + if err != nil || !ok || len(frame) != 4 || frame["protocolVersion"] != json.Number("1") || frame["connectorSlug"] != c.Connector || frame["credentialType"] != c.CredentialType { + return nil, ErrProtocol + } + credential, ok := frame["credential"].(map[string]any) + if !ok || validate(Credential(credential), c.CredentialType) != nil { + return nil, ErrProtocol + } + return Credential(credential), nil +} + +func (c *Channel) Send(credential Credential) error { + if err := validate(credential, c.CredentialType); err != nil { + return err + } + data, err := json.Marshal(map[string]any{"protocolVersion": 1, "connectorSlug": c.Connector, "credentialType": c.CredentialType, "credential": credential}) + if err != nil || len(data) == 0 || len(data) > MaxFrameBytes { + return ErrProtocol + } + var prefix [4]byte + binary.BigEndian.PutUint32(prefix[:], uint32(len(data))) + if writeAll(c.Conn, prefix[:]) != nil || writeAll(c.Conn, data) != nil { + return ErrProtocol + } + return nil +} + +func writeAll(writer io.Writer, data []byte) error { + for len(data) > 0 { + n, err := writer.Write(data) + if err != nil { + return err + } + if n <= 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} + +func strictJSON(data []byte) (any, error) { + if !utf8.Valid(data) { + return nil, ErrProtocol + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + value, err := decodeValue(decoder, 0) + if err != nil { + return nil, ErrProtocol + } + if _, err := decoder.Token(); err != io.EOF { + return nil, ErrProtocol + } + return value, nil +} + +func decodeValue(decoder *json.Decoder, depth int) (any, error) { + if depth > 64 { + return nil, ErrProtocol + } + token, err := decoder.Token() + if err != nil { + return nil, err + } + switch token { + case json.Delim('{'): + object := make(map[string]any) + for decoder.More() { + keyToken, err := decoder.Token() + key, ok := keyToken.(string) + if err != nil || !ok { + return nil, ErrProtocol + } + if _, exists := object[key]; exists { + return nil, ErrProtocol + } + value, err := decodeValue(decoder, depth+1) + if err != nil { + return nil, err + } + object[key] = value + } + if end, err := decoder.Token(); err != nil || end != json.Delim('}') { + return nil, ErrProtocol + } + return object, nil + case json.Delim('['): + array := make([]any, 0) + for decoder.More() { + value, err := decodeValue(decoder, depth+1) + if err != nil { + return nil, err + } + array = append(array, value) + } + if end, err := decoder.Token(); err != nil || end != json.Delim(']') { + return nil, ErrProtocol + } + return array, nil + default: + if number, ok := token.(json.Number); ok { + value, err := number.Float64() + if err != nil || math.IsNaN(value) || math.IsInf(value, 0) { + return nil, ErrProtocol + } + } + if _, delimiter := token.(json.Delim); delimiter { + return nil, ErrProtocol + } + return token, nil + } +} diff --git a/plugins/dingtalk/.wework-build/plugin-auth-go/transport_test.go b/plugins/dingtalk/.wework-build/plugin-auth-go/transport_test.go new file mode 100644 index 0000000..e758b30 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth-go/transport_test.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 +package pluginauth + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestStrictJSONRejectsConfusedFrames(t *testing.T) { + for _, input := range []string{`{"a":1,"a":2}`, `{"a":{"b":1,"b":2}}`, `{"a":NaN}`, `{} {}`, `{"a":[1,]}`, `{"a":1e999}`, string([]byte{'"', 255, '"'})} { + if _, err := strictJSON([]byte(input)); err == nil { + t.Fatal("accepted invalid JSON") + } + } +} + +func TestFrameRejectsWrongIdentityAndOversize(t *testing.T) { + for _, body := range []string{ + `{"protocolVersion":true,"connectorSlug":"test","credentialType":"oauth2","credential":{"access_token":"s"}}`, + `{"protocolVersion":1,"connectorSlug":"other","credentialType":"oauth2","credential":{"access_token":"s"}}`, + `{"protocolVersion":1,"connectorSlug":"test","credentialType":"oauth2","credential":{"access_token":""}}`, + strings.Repeat("x", MaxFrameBytes+1), + } { + left, right := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer right.Close() + binary.Write(right, binary.BigEndian, uint32(len(body))) + right.Write([]byte(body)) + }() + channel := Channel{left, "test", "oauth2"} + if _, err := channel.Receive(); err == nil { + t.Fatal("accepted confused frame") + } + left.Close() + <-done + } +} + +func TestNativeProcessRoundTrip(t *testing.T) { + for _, mode := range []string{"export", "refresh", "run", "revoke", "detach", "source_changed"} { + t.Run(mode, func(t *testing.T) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + listener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)) + cmd := exec.Command(os.Args[0], "-test.run=^TestChildProvider$") + nonce := bytes.Repeat([]byte{7}, 32) + cmd.Stdin = bytes.NewReader(nonce) + cmd.Env = append(os.Environ(), "WEGENT_PLUGIN_AUTH_FD=", "WEGENT_PLUGIN_AUTH_PORT="+fmt.Sprint(listener.Addr().(*net.TCPAddr).Port), "WEGENT_SDK_CHILD="+mode) + var output, diagnostic bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &diagnostic + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + socket, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + defer socket.Close() + socket.SetDeadline(time.Now().Add(10 * time.Second)) + received := make([]byte, 32) + if _, err := io.ReadFull(socket, received); err != nil || !bytes.Equal(received, nonce) { + t.Fatal("capability handshake failed") + } + channel := Channel{socket, "test", "oauth2"} + if mode != "export" { + credential := Credential{"access_token": "synthetic-old-access"} + if mode != "run" { + credential["refresh_token"] = "synthetic-refresh" + } + if err := channel.Send(credential); err != nil { + t.Fatal(err) + } + socket.(*net.TCPConn).CloseWrite() + } + if mode == "export" || mode == "refresh" { + credential, err := channel.Receive() + if err != nil || credential["access_token"] != "synthetic-new-access" { + t.Fatal("native credential response failed") + } + } + if err := cmd.Wait(); err != nil { + t.Fatal("child failed", diagnostic.String()) + } + if strings.Contains(output.String()+diagnostic.String(), "synthetic-") { + t.Fatal("credential escaped private channel") + } + var metadata map[string]any + if json.Unmarshal(output.Bytes(), &metadata) != nil { + t.Fatal("missing public result") + } + if mode == "run" { + if metadata["account"] != "alice" { + t.Fatal("wrong business account") + } + } else if mode == "source_changed" { + if metadata["status"] != "source_changed" { + t.Fatal("missing source fence confirmation") + } + } else if mode == "detach" && metadata["status"] != "detached" { + t.Fatal("missing durable detach confirmation") + } else if mode != "detach" && metadata["status"] != "ok" { + t.Fatal("missing success") + } + }) + } +} + +func TestChildProvider(t *testing.T) { + mode := os.Getenv("WEGENT_SDK_CHILD") + if mode == "" { + return + } + account := func(context.Context) (Account, error) { + fmt.Fprintln(os.Stdout, "synthetic-export-noise") + fmt.Fprintln(os.Stderr, "synthetic-error-noise") + return Account{"alice", Credential{"access_token": "synthetic-new-access", "refresh_token": "synthetic-refresh"}}, nil + } + provider := Provider{Export: account, + Refresh: func(ctx context.Context, value Credential) (Account, error) { + if value["refresh_token"] != "synthetic-refresh" { + return Account{}, ErrProvider + } + return account(ctx) + }, + Revoke: func(context.Context, Credential) error { return nil }, + Detach: func(_ context.Context, id string, value Credential) error { + if id != strings.Repeat("a", 64) || value["refresh_token"] != "synthetic-refresh" { + return ErrProvider + } + fmt.Fprintln(os.Stdout, "synthetic-detach-noise") + if mode == "source_changed" { + return ErrSourceChanged + } + return nil + }, + Allowed: func(args []string) bool { return len(args) == 1 && args[0] == "read" }, + Run: func(ctx context.Context, value Credential, args []string) error { + if _, ok := value["refresh_token"]; ok { + return ErrProvider + } + fmt.Fprintln(os.Stdout, `{"account":"alice"}`) + return nil + }, + } + args := []string{mode} + if mode == "run" { + args = append(args, "read") + } + if mode == "source_changed" { + args = []string{"detach"} + } + if mode == "detach" || mode == "source_changed" { + args = append(args, strings.Repeat("a", 64)) + } + if Serve(context.Background(), "test", "oauth2", provider, args) != nil { + os.Exit(1) + } + os.Exit(0) +} diff --git a/plugins/dingtalk/.wework-build/plugin-auth/LICENSE b/plugins/dingtalk/.wework-build/plugin-auth/LICENSE new file mode 100644 index 0000000..b8c67ae --- /dev/null +++ b/plugins/dingtalk/.wework-build/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/dingtalk/.wework-build/plugin-auth/README.en.md b/plugins/dingtalk/.wework-build/plugin-auth/README.en.md new file mode 100644 index 0000000..5fe25f1 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/README.en.md @@ -0,0 +1,175 @@ +--- +sidebar_position: 1 +title: Python plugin account authentication SDK +--- + +# Python plugin account authentication SDK + +This dependency-free Python 3.9+ SDK implements `accountAuth` draft protocol v1. +SDK version `0.7.1` is vendored into each plugin. The company email adapter uses it. + +Optional `accountAuth.localEnvironment` declares non-secret source settings, for +example `{"DWS_CONFIG_DIR":{"type":"directory"},"DWS_DISABLE_KEYCHAIN":{"type":"enum","values":["1"]}}`. +Names match `[A-Z][A-Z0-9_]{0,63}`; at most 16 settings are allowed. Directories must +exist and be absolute on the source device. Enums contain 1–16 distinct public values +matching `[A-Za-z0-9_.-]{1,64}`. Missing/empty variables preserve provider defaults; +invalid values fail closed. Passwords, tokens and arbitrary strings are not supported. +Call `local_configuration()` from export/authorize/detach (Go: `LocalConfiguration()`). +The SDK returns a dictionary without overriding interpreter variables. An embedded CLI +adapter maps its own declared settings explicitly. The bounded 16 KiB payload stays on +the source device; never put it in exported credentials. Business, refresh and revoke +callbacks do not receive source settings. New plugins need no host-specific env whitelist. +Native export/run and public business dispatch are connected to desktop and +standalone Backend Runners. Native OAuth authorization and leased refresh are connected. Real backend/desktop tests pass with a synthetic OAuth provider. Persistent provider +revocation is connected; real-provider and native Windows acceptance remain pending. +The protocol remains a draft. + +The SDK resolves installation identity using the host capabilities manifest entries +`store_path`, `runtime.codex_link`, and `runtime.claude_link`, including runtime copies. +Unregistered, disabled, unmanaged, or ambiguous paths cannot dispatch account requests. +The native host still verifies package checksums and device grants. + +## Scaffold a provider + +From the Wegent repository root: + +```bash +uv run --no-project python sdk/plugin-auth/tool.py scaffold my-service \ + --parent ../plugins --credential-type password +``` + +Types are `password`, `bearer`, and `oauth2`. The scaffold contains a connector +manifest, `scripts/account-auth.py`, provider callbacks in `scripts/auth_provider.py`, +and the SDK with its license and checksums. It fails closed until configured; +it creates no login UI, Skill, MCP, or active connection. Keep existing localAuth +and business entry points. Never expose the native adapter as a model tool. + +Implement these provider-specific parts: + +| Interface | Responsibility | +| -------------------------------- | ---------------------------------------------------------------------------------- | +| `export_local()` | Read existing authentication into a JSON dictionary without changing local storage | +| `account_id(credential)` | Return a stable public account identifier, never a secret | +| `ALLOWED_COMMANDS` | Explicit business commands, excluding authentication management | +| `execute(credential, arguments)` | Execute using process-memory credentials and restore scoped state | +| `validate(credential)` | Optional provider-specific validation beyond the SDK checks | + +Required nonempty fields: password uses `username` and `password`; bearer uses +`token`; oauth2 uses `access_token`. Additional JSON configuration is allowed. +OS keychain databases and encrypted OS blobs are not portable credentials. + +OAuth scaffolds declare `accountAuth.oauth2` operations and generate `authorize()`, +`refresh(credential)`, and `revoke(credential)` callbacks. Authorization uses a +local one-use intent. Platform refresh leases select one native actor; business +commands receive only Access Tokens after the rotated result is committed. +Return absolute Unix `expires_at` and a new `refresh_token` when rotated. The SDK +preserves unchanged identity fields and non-rotated refresh tokens. Do not retry +uncertain provider refresh requests. Late results cannot undo device revocation, +runtime replacement, disconnection, or reauthorization. + +Put additional grant-management secrets in `provider_private`; business execution +does not receive that object, `refresh_token`, `client_secret` or `persistent_code`. +A CLI with a supported exclusive handoff can declare `exportMode: "exclusive"` +and implement `detach(migration_id, credential)`. The host stages encrypted escrow, +calls detach, then activates. Persist a recoverable receipt, remove only the +matching old grant, handle duplicate confirmation idempotently and raise on failure. +Never delete source credentials inside `export`. Password/API Key adapters do not +need detach. + +If source credentials rotated after export, detach may raise `SourceChanged` only +after durably fencing off that migration ID under the provider storage lock. A +delayed or restarted operation with that ID must never delete credentials. The +SDK returns fixed `source_changed` metadata; the native host cancels old escrow. +A new user migration receives a new ID and exports current credentials. When +fencing cannot be proven, raise an ordinary error and retain recoverable escrow. + +Disconnect immediately removes business access and queues encrypted revoke-only work. +Online native workers retry outages with backoff; successful callbacks erase retained +credentials. Revocation callbacks must be idempotent. The UI distinguishes native +provider receipts from user confirmation of external revocation. Existing third-party CLIs may still +refresh their original grant independently: export requires a supported exclusive +ownership transfer; otherwise obtain a separate Wegent grant. Provider callbacks +own browser authorization, state validation and PKCE S256. DWS requires its own +supported adapter. +JavaScript and PowerShell native SDKs are not included. + +## Bundle and update + +```bash +uv run --no-project python sdk/plugin-auth/tool.py vendor ../plugins/my-service +uv run --no-project python sdk/plugin-auth/tool.py vendor ../plugins/my-service --check +``` + +Maintain this canonical source, test it, then vendor it into plugins. `--check` +compares file contents, file lists, version metadata, and the license. The bundled +`vendor.json` also allows integrity checks inside a plugin repository; checksums +are not signatures and do not establish provenance. Do not edit vendored copies +or import a sibling development checkout at runtime. Follow the plugin repository's +version and release rules when distributing updates; this work is not published. + +## Transport boundaries + +The native host uses the authenticated loopback socket described below. Explicit FD +hosts may supply an inherited pipe FD >= 3 in `WEGENT_PLUGIN_AUTH_FD`, never a +secret environment value. Standard streams and regular files are rejected. Each +frame has a four-byte big-endian length plus UTF-8 JSON, bounded to 65536 bytes +including the envelope. Exact fields: integer `protocolVersion: 1`, `connectorSlug`, +`credentialType`, `credential`. Duplicate keys, nonfinite numbers, truncation, +invalid credentials, and oversized frames fail closed. + +Export sends credentials only through the pipe and prints account metadata. +Run closes the pipe before executing the callback; the SDK does not write an auth +store. Authentication callback Python output is suppressed and caught exceptions +are sanitized. Plugins still own business output and must not print credentials, +raw upstream errors, or secret-bearing subprocess output. + +The host owns authentication, provenance, permissions, grant revisions, deadlines, +and process cleanup. Pipes do not isolate hostile code under the same OS user. +Native sockets avoid platform-specific descriptor handoff. Windows process-tree cleanup +is implemented using the shared Executor guard; native Windows acceptance is pending. + +## Verification + +```bash +uv run --no-project python -m unittest discover -s sdk/plugin-auth/tests -v +``` + +Synthetic tests cover real authenticated socket subprocesses, isolated ZIP extraction, +credential types, failure redaction, frame limits, and vendor integrity. The +`plugin-auth-sdk.yml` workflow configures Linux/macOS/Windows on Python 3.9/3.12. Remote CI +and real cloud integration must be verified separately. + +## Native cross-platform transport (SDK 0.4.0) + +The native runner binds a random port on 127.0.0.1 and supplies only the port in +`WEGENT_PLUGIN_AUTH_PORT`. A cryptographically random 32-byte one-use capability +is transferred on child stdin. The SDK submits it to the listener before any +credential frame is sent. Provider credentials never travel on stdin or in env. +PORT and FD together fail; no transport fallback occurs. Explicit FD hosts remain +supported, while the native runner uses sockets to avoid Windows CRT inheritance. +Business commands cannot use stdin interactively in this mode. + +NativeAdapter verifies the installed connector/adapter against the expected +backend definition. Callers must resolve roots from managed installation records, +never arbitrary model-provided paths. Credentials have no Debug or Serialize. +The native gateway uses dedicated events on the authenticated device socket. +Stdout is bounded to 1 MiB, errors are fixed codes, and execution is bounded. +Unix cancellation kills the dedicated process group. Windows process-tree cleanup +uses the shared Executor guard and awaits native Windows verification. Desktop migration +and cloud CLI dispatch have passed real backend/desktop tests using synthetic passwords +and OAuth providers. DWS and real providers require separate acceptance. + +## Integrating an existing CLI + +Call `delegate_cloud_command(plugin_root, connector_slug, argv, account_id=None)` +before reading local credentials. A returned integer is the exit code; `None` +means continue the ordinary local command. The scaffold includes `scripts/cli.py`. +The runner supplies a loopback business capability and device mode; SDK resolves +the managed installed ID. Cloud errors never downgrade to local login. Ordinary +local commands work offline; an explicit account ID selects an authorized account +connection in either mode. Working directories preserve relative business paths. + +The native adapter establishes a scoped recursion guard when calling existing +business code. Provider credentials never enter this public HTTP API, environment +or stdout. The loopback capability only authorizes business execution. Plugins +must not print authentication fields in their business output. diff --git a/plugins/dingtalk/.wework-build/plugin-auth/README.md b/plugins/dingtalk/.wework-build/plugin-auth/README.md new file mode 100644 index 0000000..36b7337 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/README.md @@ -0,0 +1,190 @@ +--- +sidebar_position: 1 +title: Python 插件账号认证 SDK +--- + +# Python 插件账号认证 SDK + +这是 `accountAuth` 协议草案 v1 的公共 Python 实现,SDK 版本为 `0.7.1`。 +支持 Python 3.9+,只使用标准库;SDK 源码随插件打包,不依赖运行时安装。 +邮箱插件已使用此实现。当前提供原生 `export`、`run` 和公开业务命令委托, +已接入桌面与独立设备的 Backend Runner。OAuth 授权、刷新回调已接入原生链路;真实后端/桌面已通过合成 OAuth 服务的端到端验证,提供方撤销已接入持久化任务;真实服务方与 Windows 实机验收待完成,协议继续作为草案。 + +SDK 根据宿主 capabilities 清单中的 `store_path`、`runtime.codex_link` 和 +`runtime.claude_link` 识别安装身份,支持运行时复制目录。未登记、禁用、非托管 +或映射不唯一的路径不能发起账号代理请求;原生宿主继续校验包哈希与设备权限。 + +## 新插件接入 + +从 Wegent 仓库根目录执行: + +```bash +uv run --no-project python sdk/plugin-auth/tool.py scaffold my-service \ + --parent ../plugins --credential-type password +``` + +`--credential-type` 可选 `password`、`bearer`、`oauth2`。生成的目录包含: + +- `.codex-plugin/plugin.json`:connector 与 `accountAuth` 声明。 +- `scripts/account-auth.py`:原生代理入口,通常无需修改。 +- `scripts/auth_provider.py`:开发者实现的提供方回调。 +- `scripts/cli.py`:公开业务命令入口,云端自动委托原生执行。 +- `scripts/wegent_plugin_auth/`:SDK、许可证、版本及 SHA-256 清单。 + +模板是开发骨架,默认拒绝导出与执行,不会自动启用连接,也不会新增登录 UI、 +Skill 或 MCP。现有插件的 `localAuth` 和业务入口由插件维护;不要把原生适配器 +暴露为模型工具或在 Skill 中让模型执行。 + +开发者只需要填写以下内容,不需要手写管道和帧协议: + +| 接口 | 职责 | +| -------------------------------- | ---------------------------------------------------------- | +| `export_local()` | 读取已有本机认证并返回 JSON 字典;不删除、重置或替换原认证 | +| `account_id(credential)` | 返回稳定的公开账号标识,绝不能返回密码、Token | +| `ALLOWED_COMMANDS` | 明确允许的业务命令,排除登录、登出、认证导出等管理命令 | +| `execute(credential, arguments)` | 在进程内使用凭据执行现有业务;通过上下文管理器恢复状态 | +| `validate(credential)` | 可选的服务方字段校验;不需要重复 SDK 的基础校验 | + +基础字段约定:password 为非空 `username`、`password`;bearer 为非空 `token`; +oauth2 为非空 `access_token`。提供方可以携带服务器地址等额外 JSON 字段, +但不能用钥匙串数据库或操作系统加密文件代替可用凭据。 + +OAuth 模板生成 `authorize()`、`refresh(credential)`、`revoke(credential)` 回调, +并通过 `accountAuth.oauth2` 声明支持的操作。授权通过本机一次性意图启动;刷新 +由平台租约限制为一台原生设备执行,结果提交后业务代码仅收到 Access Token。 +回调返回绝对时间 `expires_at`,轮换时返回新的 `refresh_token`;SDK 自动保留 +未变化的账号字段与未轮换的 Refresh Token。提供方回调不要重试不确定的刷新。 +设备权限变更、重建、账号断开与重新授权不会被迟到的刷新结果覆盖。 + +授权、刷新或撤销专用的其他秘密放入 `provider_private` 对象;后端不会向业务 +回调下发该对象、`refresh_token`、`client_secret` 和 `persistent_code`。 +已有 CLI 若能安全转移唯一刷新权,可声明 `exportMode: "exclusive"`,并提供 +`detach(migration_id, credential)` 回调。宿主先加密暂存,再调用此回调,最后激活。 +回调必须持久化可恢复记录、只移除对应旧授权、幂等处理确认丢失,并在异常时抛错。 +`export` 不能自行删除源凭据。普通密码和 API Key 接入无需实现 `detach`。 + +如果导出后源凭据已轮换,`detach` 可抛出 `SourceChanged`,但必须先在提供方存储锁内 +持久化该迁移 ID 的作废记录,保证迟到或重启的同 ID 操作永远不能删除凭据。 +SDK 仅返回固定 `source_changed` 元数据;原生宿主随后取消旧暂存,用户再次迁移会 +取得新 ID 和最新凭据。无法证明旧 ID 已作废时应抛普通异常,保留暂存以便恢复。 + +`revoke` 已接入持久化任务:断开立即停止业务访问,在线原生设备领取撤销任务, +回调成功后擦除保留凭据。回调必须幂等;网络中断与确认丢失后可能重试。界面区分 +平台断开、服务方回执及用户自行撤销的确认。外部 CLI 原有 OAuth 授权仍可能 +自行刷新;只有提供方支持转移唯一刷新管理权时才能迁移该授权,否则应创建独立 +的 Wegent 授权。插件授权回调负责提供方支持的浏览器流程、state 和 PKCE S256。 +DWS 等自行管理认证的 CLI 还需要受支持的专用迁移/执行适配,不能直接套用 +密码模板。JavaScript/PowerShell 原生 SDK 尚未提供。 + +## 本机认证存储配置 + +已有 CLI 若通过环境变量选择认证目录,可在 `accountAuth` 中添加可选声明: + +```json +{ + "localEnvironment": { + "DWS_CONFIG_DIR": { "type": "directory" }, + "DWS_KEYCHAIN_DIR": { "type": "directory" }, + "DWS_DISABLE_KEYCHAIN": { "type": "enum", "values": ["1"] } + } +} +``` + +变量名只允许大写字母、数字和下划线,最多 64 字符、16 项。`directory` 必须是 +本机存在的绝对目录;枚举最多 16 个不同的公开固定值,每值最多 64 字符,仅包含 +字母、数字、点、下划线和短横线。未设置或空值使用提供方原有默认行为;非法值 +直接失败。不得用此字段传递密码、Token、秘密客户端配置或任意字符串。 + +`export_local`、`authorize`、`detach` 回调调用 `local_configuration()` 取得字典。 +SDK 不会直接修改进程环境;适配第三方 CLI 时,由适配器明确映射需要的设置。 +配置通过宿主限定的本机配置通道提供,总大小不超过 16 KiB,不上传后端,不写入 +账号凭据。`run`、`refresh`、`revoke` 不接收源设备配置,避免云端依赖本机路径。 +Go SDK 对应接口为 `LocalConfiguration()`。Wegent 仅处理统一声明和类型验证, +新增插件无需在设备逻辑中增加环境变量白名单。 + +## 给现有插件打包与升级 SDK + +```bash +uv run --no-project python sdk/plugin-auth/tool.py vendor ../plugins/my-service +uv run --no-project python sdk/plugin-auth/tool.py vendor ../plugins/my-service --check +``` + +SDK 的唯一维护源位于本目录。修改此处、运行测试,再使用 `vendor` 更新插件。 +`--check` 会逐字节比较源码、版本、文件列表和许可证;`vendor.json` 支持插件 +仓库独立校验包内文件是否被修改,但它不是密码学签名,也不替代来源信任。 +不要手工修改插件内的 SDK 副本,不要从插件跨目录导入 Wegent 开发仓库。 +升级 SDK 后按插件仓库规则提升插件版本并发布;本次开发尚未发布软件包。 + +## 传输与错误边界 + +- 默认使用下述认证 loopback Socket;原生宿主也可通过 `WEGENT_PLUGIN_AUTH_FD` + 传递私有 FD(≥3)。凭据不走环境变量、命令参数或 stdout;拒绝普通文件和标准流。 +- 帧为四字节大端长度 + UTF-8 JSON,完整 envelope 上限为 65536 字节。 + 顶层字段严格为 `protocolVersion`(整数 1)、`connectorSlug`、`credentialType`、 + `credential`。拒绝重复 JSON key、非有限数值、截断及超限数据。 +- `export` 将凭据写入管道,只在 stdout 返回账号元数据。`run` 读取并关闭管道, + 执行业务回调;SDK 不写任何认证存储。授权校验必须在宿主启动进程前完成。 +- SDK 抑制认证回调的 Python 标准输出和错误输出,并隐藏捕获异常的内容; + 业务输出由插件负责,不能打印凭据、原始上游错误或通过子进程输出秘密。 +- 宿主负责超时、进程清理、设备身份、插件来源、授权版本与操作权限。 + 管道不隔离同一 OS 用户的恶意代码;Windows 复用原生 Executor 的进程树终止器,实机验证仍待执行。 + +## 验证 + +```bash +uv run --no-project python -m unittest discover -s sdk/plugin-auth/tests -v +``` + +测试使用合成凭据,包含真实子进程认证 Socket、ZIP 解包后独立运行、密码/API +Key/OAuth token 校验、异常脱敏、消息边界与 SDK 副本一致性。 +`.github/workflows/plugin-auth-sdk.yml` 配置 Linux/macOS/Windows、Python 3.9/3.12 测试。 +远端 CI 和真实云端认证闭环需要分别验证。 + +## 原生跨平台通道(SDK 0.4.0) + +Executor 原生适配器执行器使用仅监听 `127.0.0.1` 的随机端口,通过 +`WEGENT_PLUGIN_AUTH_PORT` 告知子进程地址。父进程用 CSPRNG 生成 32 字节的 +一次性通道令牌,只经子进程 stdin 交接;SDK 连接后先提交令牌,验证通过 +才传输凭据帧。stdin 不传输真实提供方凭据。端口变量不包含秘密。 + +SDK 负责选用显式指定的通道。PORT 与 FD 同时出现会失败,不会自动降级到 +另一通道。原来的专用 FD 协议仍可用于明确配置的宿主;新的原生执行器使用 +Socket,因此不依赖 Windows CRT 文件描述符继承。进程 stdin 此时由宿主 +保留,业务命令不能再把 stdin 当作交互输入。 + +NativeAdapter 只接受已安装包中与后端授权相同的 connector/accountAuth 定义; +调用方必须从受管理的安装记录解析包目录,不能接受模型传入的任意路径。 +原生读取及登记使用已有设备 Socket 的专用事件,Credential 不实现 Debug +或 Serialize。业务 stdout 有 1 MiB 上限,错误只返回固定代码,进程有期限。 +Unix 超时/取消会终止专用进程组;Windows 复用 Executor 的进程树清理,原生验证仍待执行。 + +原生运行器、桌面迁移入口和云端 CLI 委托已有实现及真实子进程测试, +真实后端/桌面检查点已验证合成密码和 OAuth 授权、刷新、撤销链路。真实提供方 +与 DWS 仍需分别验收,不能据此宣称所有插件均已完成云端免认证交付。 + +## 现有业务 CLI 接入 + +在读取本机认证之前调用 SDK;CLI 仍执行同一份业务代码,不在插件里实现网络凭据交换。 + +```python +from pathlib import Path +from wegent_plugin_auth import delegate_cloud_command + +def main(argv): + delegated = delegate_cloud_command( + Path(__file__).resolve().parents[1], "my-service", argv, + account_id=None, # Pass a public account ID when more than one is granted. + ) + if delegated is not None: + return delegated + return existing_local_main(argv) +``` + +Runner 将业务代理能力和设备模式注入任务环境,SDK 从受管理安装记录解析插件 ID。 +云端业务只接受授权连接;失败不会降级到本机认证或触发云端登录。本机普通命令 +不依赖此代理;显式传入 `account_id` 时,本机也可以使用已授权的账号连接。 +工作目录随请求传递,正文文件与附件目录仍相对于原任务目录解析。 + +原生适配器调用公开 CLI 时,SDK 的作用域标记阻止循环委托,并在结束后恢复。 +业务代理令牌只授权本机业务调用,不能读取凭据;提供方密码和 Token 不进入 +此 HTTP API、环境变量或 stdout。业务输出允许包含业务数据,插件不能主动打印认证字段。 diff --git a/plugins/dingtalk/.wework-build/plugin-auth/templates/account-auth.py.tmpl b/plugins/dingtalk/.wework-build/plugin-auth/templates/account-auth.py.tmpl new file mode 100644 index 0000000..1c3bcc2 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/templates/account-auth.py.tmpl @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Native broker entry point. Never expose this script as a model tool.""" + +import sys + +import auth_provider +from wegent_plugin_auth import AccountAuthAdapter + +adapter = AccountAuthAdapter( + connector_slug="__CONNECTOR_SLUG__", + credential_type="__CREDENTIAL_TYPE__", + export=auth_provider.export_local, + account_id=auth_provider.account_id, + execute=auth_provider.execute, + validate=auth_provider.validate, + allowed_commands=auth_provider.ALLOWED_COMMANDS, +__OAUTH_CALLBACKS__ +) + +if __name__ == "__main__": + raise SystemExit(adapter.main(sys.argv[1:])) diff --git a/plugins/dingtalk/.wework-build/plugin-auth/templates/auth_provider.py.tmpl b/plugins/dingtalk/.wework-build/plugin-auth/templates/auth_provider.py.tmpl new file mode 100644 index 0000000..e0164ff --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/templates/auth_provider.py.tmpl @@ -0,0 +1,42 @@ +"""Implement provider behavior here; transport is supplied by the bundled SDK. + +Expected credential fields: __REQUIRED_FIELDS__. +Use extra JSON fields for provider configuration, never opaque OS keychain blobs. +For a custom local auth directory, declare accountAuth.localEnvironment and call +wegent_plugin_auth.local_configuration() in export_local/authorize/detach. +Do not copy these local paths into exported credentials or business configuration. +""" + +from __future__ import annotations + +from typing import Any, Sequence + +from wegent_plugin_auth import AuthError + +# Explicitly list existing business commands. Never include login or logout. +ALLOWED_COMMANDS: tuple[str, ...] = () + + +def export_local() -> dict[str, Any] | None: + """Read existing local authentication without deleting or changing it.""" + raise AuthError("Provider export is not configured") + + +def account_id(credential: dict[str, Any]) -> str: + """Return a stable public account identifier; never a password or token.""" + raise AuthError("Provider account identity is not configured") + + +def validate(credential: dict[str, Any]) -> None: + """Validate provider-specific fields; core credential fields are checked by SDK.""" + + +def execute(credential: dict[str, Any], arguments: Sequence[str]) -> int: + """Run existing business commands using credentials in memory only. + + Restore scoped state in finally/context managers. Never persist delegated + secrets, auto-login on failure, print credentials, or log upstream exceptions. + """ + raise AuthError("Provider execution is not configured") + +__OAUTH_PROVIDER__ diff --git a/plugins/dingtalk/.wework-build/plugin-auth/templates/cli.py.tmpl b/plugins/dingtalk/.wework-build/plugin-auth/templates/cli.py.tmpl new file mode 100644 index 0000000..026819e --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/templates/cli.py.tmpl @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Public business CLI. The native adapter calls execute directly to avoid recursion.""" + +import json +import sys +from pathlib import Path + +import auth_provider +from wegent_plugin_auth import AuthError, delegate_cloud_command + + +def main(arguments): + delegated = delegate_cloud_command(Path(__file__).resolve().parents[1], "__CONNECTOR_SLUG__", arguments) + if delegated is not None: + return delegated + try: + if not arguments or arguments[0] not in auth_provider.ALLOWED_COMMANDS: + raise AuthError("plugin_auth_invalid_command") + credential = auth_provider.export_local() + if credential is None: + raise AuthError("plugin_auth_local_login_required") + auth_provider.validate(credential) + return auth_provider.execute(credential, arguments) + except Exception: + print(json.dumps({"error": "plugin_auth_local_execution_failed"})) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/plugins/dingtalk/.wework-build/plugin-auth/templates/oauth-provider.inc b/plugins/dingtalk/.wework-build/plugin-auth/templates/oauth-provider.inc new file mode 100644 index 0000000..0cd9ee9 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/templates/oauth-provider.inc @@ -0,0 +1,33 @@ +def authorize() -> dict[str, Any]: + """Obtain a new provider grant using its supported native authorization flow. + + Open the system browser, validate state and redirect URI, and use PKCE S256. + Keep the verifier and tokens in memory; return only the credential mapping. + Set expires_at to absolute Unix seconds. Never reuse a different CLI's grant + unless it supports transferring exclusive refresh ownership to this broker. + Put additional renewal/revocation secrets in provider_private; the backend + excludes that object from business execution credentials. + """ + raise AuthError("Provider authorization is not configured") + + +def refresh(credential: dict[str, Any]) -> dict[str, Any]: + """Refresh once; never retry an uncertain provider request or persist tokens. + + Return access_token and expires_at, plus refresh_token when rotated. + The SDK preserves unchanged account fields and non-rotated refresh tokens. + The host commits the result under a platform lease before business dispatch. + """ + raise AuthError("Provider refresh is not configured") + + +def revoke(credential: dict[str, Any]) -> None: + """Revoke the provider grant; return only after provider confirmation. + + Use the provider's documented revocation endpoint. A failure must raise; + never turn a network timeout into apparent successful revocation. + This callback must be idempotent: the native worker retries after outages + or a lost acknowledgement. A provider-confirmed already-revoked grant is + success. Never revoke an unrelated account or a newly authorized grant. + """ + raise AuthError("Provider revocation is not configured") diff --git a/plugins/dingtalk/.wework-build/plugin-auth/tests/test_runtime.py b/plugins/dingtalk/.wework-build/plugin-auth/tests/test_runtime.py new file mode 100644 index 0000000..5910da3 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/tests/test_runtime.py @@ -0,0 +1,197 @@ +# SPDX-License-Identifier: Apache-2.0 +"""No provider or keychain access. Exercise public cloud dispatch boundaries.""" + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from wegent_plugin_auth import ( + AuthError, + delegate_cloud_command, + run_account_command, + runtime, +) + + +class RuntimeTests(unittest.TestCase): + def test_local_command_does_not_require_a_broker_or_managed_package(self): + with patch.dict(os.environ, {}, clear=True): + self.assertIsNone( + delegate_cloud_command(Path("/missing"), "mail", ["read"]) + ) + + def test_cloud_failure_never_downgrades_to_local_auth(self): + output = io.StringIO() + with ( + patch.dict(os.environ, {"WEGENT_PLUGIN_AUTH_MODE": "cloud"}, clear=True), + contextlib.redirect_stdout(output), + ): + self.assertEqual( + delegate_cloud_command(Path("/missing"), "mail", ["read"]), 1 + ) + self.assertEqual( + json.loads(output.getvalue()), {"error": "plugin_auth_broker_unavailable"} + ) + + def test_explicit_account_requires_broker_even_in_local_mode(self): + with ( + patch.dict(os.environ, {}, clear=True), + contextlib.redirect_stdout(io.StringIO()), + ): + self.assertEqual( + delegate_cloud_command( + Path("/missing"), "mail", ["read"], account_id="alice" + ), + 1, + ) + + def test_native_execution_context_prevents_recursion_and_is_reset(self): + with patch.dict(os.environ, {"WEGENT_PLUGIN_AUTH_MODE": "cloud"}, clear=True): + with runtime.native_execution_scope(): + self.assertIsNone( + delegate_cloud_command( + Path("/missing"), "mail", ["read"], account_id="alice" + ) + ) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual( + delegate_cloud_command(Path("/missing"), "mail", ["read"]), 1 + ) + + def test_only_literal_loopback_broker_addresses_are_allowed(self): + for url in ( + "https://example.com/v1/run", + "http://localhost:123/v1/run", + "http://127.0.0.1:123/v1/run?token=private", + "http://user@127.0.0.1:123/v1/run", + ): + with ( + self.subTest(url=url), + patch.dict( + os.environ, + { + "WEGENT_PLUGIN_AUTH_BROKER": url, + "WEGENT_PLUGIN_AUTH_BROKER_TOKEN": "a" * 64, + }, + clear=True, + ), + ): + with self.assertRaisesRegex( + AuthError, "^plugin_auth_broker_unavailable$" + ): + run_account_command(Path("/missing"), "mail", ["read"]) + + def test_resolves_only_one_enabled_managed_installation(self): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + capabilities = home / "capabilities" + root = capabilities / "store/plugins/mail" + root.mkdir(parents=True) + manifest = capabilities / "manifest.json" + entry = { + "installed_plugin_id": 42, + "enabled": True, + "managed": True, + "store_path": "store/plugins/mail", + } + with patch.dict(os.environ, {"WEGENT_EXECUTOR_HOME": str(home)}): + manifest.write_text(json.dumps({"plugins": {"mail": entry}})) + self.assertEqual(runtime._installed_id(root), 42) + manifest.write_text( + json.dumps({"plugins": {"mail": entry, "duplicate": entry}}) + ) + with self.assertRaises(AuthError): + runtime._installed_id(root) + entry["enabled"] = False + manifest.write_text(json.dumps({"plugins": {"mail": entry}})) + with self.assertRaises(AuthError): + runtime._installed_id(root) + + def test_runtime_copies_use_host_mapping_and_reject_unregistered_paths(self): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + capabilities = home / "capabilities" + store = capabilities / "store/plugins/42-mail-1.0" + codex = home / "codex/plugins/cache/market/mail/1.0" + claude = home / "claude/plugins/cache/market/mail/1.0" + old = home / "codex/plugins/cache/market/mail/0.9" + arbitrary = home / "workspace/mail/1.0" + for root in (store, codex, claude, old, arbitrary): + root.mkdir(parents=True) + entry = { + "installed_plugin_id": 42, + "enabled": True, + "managed": True, + "store_path": str(store), + "runtime": {"codex_link": str(codex), "claude_link": str(claude)}, + } + manifest = capabilities / "manifest.json" + with patch.dict(os.environ, {"WEGENT_EXECUTOR_HOME": str(home)}): + manifest.write_text(json.dumps({"plugins": {"mail": entry}})) + for root in (store, codex, claude): + with self.subTest(root=root): + self.assertEqual(runtime._installed_id(root), 42) + for root in (old, arbitrary): + with self.subTest(root=root), self.assertRaises(AuthError): + runtime._installed_id(root) + for field in ("enabled", "managed"): + changed = {**entry, field: False} + manifest.write_text(json.dumps({"plugins": {"mail": changed}})) + with self.subTest(field=field), self.assertRaises(AuthError): + runtime._installed_id(codex) + manifest.write_text( + json.dumps({"plugins": {"mail": entry, "other": entry}}) + ) + with self.assertRaises(AuthError): + runtime._installed_id(codex) + + def test_runtime_copy_dispatches_managed_id_to_broker(self): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + capabilities = home / "capabilities" + root = home / "codex/plugins/cache/market/mail/1.0" + root.mkdir(parents=True) + capabilities.mkdir() + (capabilities / "manifest.json").write_text( + json.dumps( + { + "plugins": { + "mail": { + "installed_plugin_id": 42, + "managed": True, + "enabled": True, + "store_path": "store/plugins/mail", + "runtime": {"codex_link": str(root)}, + } + } + } + ) + ) + response = io.BytesIO(b'{"stdout":"synthetic mailbox result"}') + response.status = 200 + with ( + patch.dict( + os.environ, + { + "WEGENT_EXECUTOR_HOME": str(home), + "WEGENT_PLUGIN_AUTH_BROKER": "http://127.0.0.1:1234/v1/run", + "WEGENT_PLUGIN_AUTH_BROKER_TOKEN": "a" * 64, + }, + clear=True, + ), + patch.object(runtime.urllib.request, "build_opener") as build, + ): + build.return_value.open.return_value = response + result = run_account_command(root, "mail", ["list", "--limit", "1"]) + request = build.return_value.open.call_args.args[0] + payload = json.loads(request.data) + self.assertEqual(payload["installed_plugin_id"], 42) + self.assertEqual(payload["args"], ["list", "--limit", "1"]) + self.assertEqual(result, "synthetic mailbox result") diff --git a/plugins/dingtalk/.wework-build/plugin-auth/tests/test_sdk.py b/plugins/dingtalk/.wework-build/plugin-auth/tests/test_sdk.py new file mode 100644 index 0000000..47a0cd0 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/tests/test_sdk.py @@ -0,0 +1,428 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Synthetic credentials only. No provider access or local keychain operations.""" + +from __future__ import annotations + +import contextlib +import io +import json +import os +import socket +import struct +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +import tool # noqa: E402 - load canonical source without installing the package +from wegent_plugin_auth import ( # noqa: E402 + AccountAuthAdapter, + AuthError, +) +from wegent_plugin_auth import adapter as adapter_module # noqa: E402 +from wegent_plugin_auth import ( # noqa: E402 + local_configuration, +) +from wegent_plugin_auth.transport import ( # noqa: E402 + MAX_FRAME_BYTES, + open_pipe, + read_frame, + write_frame, +) + +SECRET = "synthetic-private-secret" + + +def make_adapter(credential_type="password", **overrides): + values = dict( + connector_slug="sample", + credential_type=credential_type, + export=lambda: {"username": "alice", "password": SECRET}, + account_id=lambda value: "alice", + execute=lambda value, args: 0, + allowed_commands=("read",), + ) + values.update(overrides) + return AccountAuthAdapter(**values) + + +def envelope(credential=None, **changes): + result = dict( + protocolVersion=1, + connectorSlug="sample", + credentialType="password", + credential=( + {"username": "alice", "password": SECRET} + if credential is None + else credential + ), + ) + result.update(changes) + return result + + +def frame(payload): + result = io.BytesIO() + write_frame(result, payload) + result.seek(0) + return result + + +class SDKTests(unittest.TestCase): + def test_local_configuration_is_explicit_bounded_and_does_not_override_environment( + self, + ): + key = "WEGENT_PLUGIN_AUTH_LOCAL_CONFIGURATION" + with patch.dict( + os.environ, {key: '{"PATH":"/synthetic/provider"}'}, clear=True + ): + self.assertEqual(local_configuration(), {"PATH": "/synthetic/provider"}) + self.assertNotIn("PATH", os.environ) + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(local_configuration(), {}) + for raw in ( + "[]", + '{"MODE":1}', + '{"MODE":"a","MODE":"b"}', + '{"mixedCase":"a"}', + '"' + SECRET + '"', + "x" * 16385, + ): + with self.subTest(raw=raw[:30]), patch.dict(os.environ, {key: raw}): + with self.assertRaises(AuthError) as error: + local_configuration() + self.assertNotIn(SECRET, str(error.exception)) + + def test_oauth_refresh_preserves_identity_and_non_rotated_refresh_token(self): + class Duplex: + def __init__(self): + self.input = frame( + envelope( + { + "access_token": "old", + "refresh_token": SECRET, + "account": "alice", + }, + credentialType="oauth2", + ) + ) + self.output = io.BytesIO() + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, size): + return self.input.read(size) + + def write(self, value): + return self.output.write(value) + + def flush(self): + pass + + def refresh(value): + print(SECRET) + value["account"] = "mutated-copy" + return {"access_token": "new", "expires_at": 9999999999} + + stream = Duplex() + output = io.StringIO() + adapter = make_adapter( + "oauth2", + refresh=refresh, + account_id=lambda value: value["account"], + validate=lambda value: self.assertEqual(value["account"], "alice"), + ) + with patch.object(adapter_module, "open_pipe", return_value=stream) as pipe: + with contextlib.redirect_stdout(output): + self.assertEqual(adapter.main(["refresh"]), 0) + pipe.assert_called_once_with("rwb") + stream.output.seek(0) + result = read_frame(stream.output)["credential"] + self.assertEqual(result["refresh_token"], SECRET) + self.assertEqual(result["account"], "alice") + self.assertEqual(result["access_token"], "new") + self.assertNotIn(SECRET, output.getvalue()) + + def test_oauth_callbacks_are_opt_in(self): + with self.assertRaises(AuthError): + make_adapter(refresh=lambda value: value) + for operation in ("authorize", "refresh", "revoke"): + with self.subTest(operation=operation): + with patch.object(adapter_module, "open_pipe") as pipe: + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(make_adapter("oauth2").main([operation]), 1) + pipe.assert_not_called() + + def test_credential_types_and_custom_validation(self): + for kind, credential in ( + ("password", {"username": "alice", "password": SECRET}), + ("bearer", {"token": SECRET}), + ("oauth2", {"access_token": SECRET, "refresh_token": "refresh"}), + ): + with self.subTest(kind=kind): + adapter = make_adapter(kind, export=lambda: credential) + stream = io.BytesIO() + metadata = adapter.export_credential(stream) + self.assertNotIn(SECRET, json.dumps(metadata)) + stream.seek(0) + self.assertEqual(adapter.read_credential(stream), credential) + for value in ({}, None, {next(iter(credential)): ""}): + with self.assertRaises(AuthError): + adapter.read_credential( + frame( + dict(envelope(), credentialType=kind, credential=value) + ) + ) + + def reject(value): + raise ValueError(SECRET) + + with self.assertRaisesRegex(AuthError, "Invalid credential payload"): + make_adapter(validate=reject).read_credential(frame(envelope())) + + def test_rejects_envelope_confusion(self): + for changes in ( + {"protocolVersion": True}, + {"protocolVersion": 2}, + {"connectorSlug": "other"}, + {"credentialType": "bearer"}, + {"extra": SECRET}, + {"credential": []}, + ): + with self.subTest(changes=changes), self.assertRaises(AuthError): + make_adapter().read_credential(frame(envelope(**changes))) + + def test_rejects_malformed_frames_before_execution(self): + invalid = [b"", b"\x00", struct.pack("!I", 0), struct.pack("!I", 65537)] + for raw in (b"{", b"[]", b'{"a":1,"a":2}', b'{"a":NaN}', b"\xff", b"[" * 2000): + invalid.append(struct.pack("!I", len(raw)) + raw) + invalid.append(struct.pack("!I", 2) + b"{") + for data in invalid: + with self.subTest(data=data[:20]), self.assertRaises(AuthError): + read_frame(io.BytesIO(data)) + + def test_size_limit_and_short_reads_writes(self): + class Fragmented(io.BytesIO): + def read(self, size=-1): + return super().read(min(size, 3)) + + def write(self, data): + return super().write(data[:3]) + + stream = Fragmented() + payload = {"x": "a" * (MAX_FRAME_BYTES - len(b'{"x":""}'))} + write_frame(stream, payload) + stream.seek(0) + self.assertEqual(read_frame(stream), payload) + with self.assertRaises(AuthError): + write_frame(io.BytesIO(), {"x": payload["x"] + "a"}) + with self.assertRaises(AuthError): + write_frame(io.BytesIO(), {"x": float("nan")}) + + def test_auth_callback_noise_and_errors_are_sanitized(self): + def noisy_export(): + print(SECRET) + print(SECRET, file=sys.stderr) + return {"username": "alice", "password": SECRET} + + output, errors = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): + make_adapter(export=noisy_export).export_credential(io.BytesIO()) + with patch.object( + adapter_module, "open_pipe", side_effect=RuntimeError(SECRET) + ): + self.assertEqual(make_adapter().main(["export"]), 1) + self.assertNotIn(SECRET, output.getvalue() + errors.getvalue()) + self.assertEqual( + json.loads(output.getvalue())["code"], "plugin_auth_adapter_failed" + ) + + def test_provider_system_exit_cannot_print_credentials(self): + def export(): + raise SystemExit(SECRET) + + output, errors = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): + with patch.object(adapter_module, "open_pipe", return_value=io.BytesIO()): + self.assertEqual(make_adapter(export=export).main(["export"]), 1) + self.assertNotIn(SECRET, output.getvalue() + errors.getvalue()) + self.assertEqual( + json.loads(output.getvalue())["code"], "plugin_auth_adapter_failed" + ) + + def test_rejects_standard_descriptors_and_regular_files(self): + for fd in ("0", "1", "2", "-1", "invalid"): + with ( + patch.dict(os.environ, {"WEGENT_PLUGIN_AUTH_FD": fd}), + self.assertRaises(AuthError), + ): + open_pipe("rb") + with open(__file__, "rb") as source: + with ( + patch.dict(os.environ, {"WEGENT_PLUGIN_AUTH_FD": str(source.fileno())}), + self.assertRaises(AuthError), + ): + open_pipe("rb") + + def test_socket_transport_rejects_ambiguity_and_truncated_capability(self): + from types import SimpleNamespace + + for environment in ( + {"WEGENT_PLUGIN_AUTH_PORT": "5000", "WEGENT_PLUGIN_AUTH_FD": "3"}, + {"WEGENT_PLUGIN_AUTH_PORT": "5000"}, + {"WEGENT_PLUGIN_AUTH_PORT": "0"}, + {"WEGENT_PLUGIN_AUTH_PORT": "65536"}, + ): + with patch.dict(os.environ, environment, clear=True): + with patch( + "wegent_plugin_auth.transport.sys.stdin", + SimpleNamespace(buffer=io.BytesIO(b"short")), + ): + with self.assertRaises(AuthError): + open_pipe("rb") + + def test_disallowed_command_never_reads_credentials(self): + with patch.object(adapter_module, "open_pipe") as pipe: + with contextlib.redirect_stdout(io.StringIO()): + for args in ([], ["refresh"], ["run", "login"], ["run", "logout"]): + self.assertEqual(make_adapter().main(args), 1) + pipe.assert_not_called() + + +class PackageTests(unittest.TestCase): + def test_scaffold_and_vendor_integrity(self): + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + for kind in ("password", "bearer", "oauth2"): + plugin = tool.scaffold(parent, kind, kind) + manifest = json.loads( + (plugin / ".codex-plugin/plugin.json").read_text() + ) + self.assertEqual(manifest["name"], plugin.name) + self.assertEqual( + manifest["connectors"][0]["accountAuth"]["credentialType"], kind + ) + declaration = manifest["connectors"][0]["accountAuth"] + self.assertEqual( + declaration.get("oauth2"), + ["authorize", "refresh", "revoke"] if kind == "oauth2" else None, + ) + for script in (plugin / "scripts").glob("*.py"): + compile(script.read_text(), str(script), "exec") + tool.bundle(plugin, check=True) + with self.assertRaises(FileExistsError): + tool.scaffold(parent, kind, kind) + (plugin / "scripts/wegent_plugin_auth/adapter.py").write_text( + "modified" + ) + with self.assertRaises(ValueError): + tool.bundle(plugin, check=True) + for name in ("../escape", "Bad Name", "x" * 65): + with self.assertRaises(ValueError): + tool.scaffold(parent, name, "password") + + def test_inventory_is_independent_of_directory_enumeration(self): + with tempfile.TemporaryDirectory() as directory: + plugin = Path(directory) + files = list((tool.ROOT / tool.PACKAGE).glob("*.py")) + tool.bundle(plugin) + with patch.object(Path, "glob", return_value=iter(reversed(files))): + tool.bundle(plugin, check=True) + + def test_zip_extracted_plugin_uses_authenticated_socket_without_source_tree(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plugin = tool.scaffold(root / "source", "sample", "password") + # A synthetic provider exercises the public extension surface in a child. + (plugin / "scripts/auth_provider.py").write_text( + """ +import json +import sys +ALLOWED_COMMANDS = ("read", "fail") +def export_local(): + print("synthetic-private-secret") + print("synthetic-private-secret", file=sys.stderr) + return {"username": "alice", "password": "synthetic-private-secret"} +def account_id(value): + return value["username"] +def validate(value): + assert value["password"] == "synthetic-private-secret" +def execute(value, arguments): + if arguments[0] == "fail": + raise RuntimeError(value["password"]) + print(json.dumps({"account": value["username"], "command": arguments[0]})) + return 0 +""" + ) + archive = root / "plugin.zip" + with zipfile.ZipFile(archive, "w") as output: + for source in plugin.rglob("*"): + if source.is_file(): + output.write(source, source.relative_to(plugin)) + extracted = root / "isolated" + with zipfile.ZipFile(archive) as package: + package.extractall(extracted) + command = [sys.executable, "-E", str(extracted / "scripts/account-auth.py")] + + credential = None + for operation, expected in (("export", 0), ("read", 0), ("fail", 1)): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(10) + nonce = os.urandom(32) + env = dict( + os.environ, + WEGENT_PLUGIN_AUTH_PORT=str(listener.getsockname()[1]), + ) + env.pop("WEGENT_PLUGIN_AUTH_FD", None) + arguments = ( + ["export"] if operation == "export" else ["run", operation] + ) + with subprocess.Popen( + command + arguments, + cwd=extracted, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as process: + process.stdin.write(nonce) + process.stdin.close() + process.stdin = None + connection, _ = listener.accept() + with connection: + connection.settimeout(10) + with connection.makefile("rwb") as stream: + self.assertEqual(stream.read(32), nonce) + if operation == "export": + credential = make_adapter().read_credential(stream) + else: + write_frame(stream, envelope(credential)) + stdout, stderr = process.communicate(timeout=10) + self.assertEqual(process.returncode, expected, stderr) + self.assertNotIn(SECRET.encode(), stdout + stderr) + if operation == "export": + self.assertEqual(json.loads(stdout)["accountId"], "alice") + elif expected == 0: + self.assertEqual( + json.loads(stdout), + {"account": "alice", "command": "read"}, + ) + else: + self.assertEqual( + json.loads(stdout)["code"], "plugin_auth_adapter_failed" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/dingtalk/.wework-build/plugin-auth/tool.py b/plugins/dingtalk/.wework-build/plugin-auth/tool.py new file mode 100644 index 0000000..bfc5817 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/tool.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Scaffold plugins and vendor/check the exact dependency-free SDK source.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path + +from wegent_plugin_auth import __version__ + +ROOT = Path(__file__).resolve().parent +PACKAGE = "wegent_plugin_auth" + + +def bundle(plugin: Path, *, check: bool = False) -> None: + source = ROOT / PACKAGE + files = {p.name: p.read_bytes() for p in source.glob("*.py")} + files["LICENSE"] = (ROOT / "LICENSE").read_bytes() + lock = { + "sdk": "wegent-plugin-auth-python", + "version": __version__, + "protocolVersion": 1, + "files": { + name: hashlib.sha256(data).hexdigest() + for name, data in sorted(files.items()) + }, + } + files["vendor.json"] = (json.dumps(lock, indent=2) + "\n").encode() + target = plugin / "scripts" / PACKAGE + if target.is_symlink() or (plugin / "scripts").is_symlink(): + raise ValueError("SDK destination must not use symlinks") + if check: + actual = ( + {p.name for p in target.iterdir() if p.name != "__pycache__"} + if target.is_dir() + else set() + ) + if actual != set(files) or any( + (target / name).is_symlink() or (target / name).read_bytes() != data + for name, data in files.items() + ): + raise ValueError("Bundled SDK differs from canonical source") + else: + target.mkdir(parents=True, exist_ok=True) + unexpected = ( + set(p.name for p in target.iterdir()) - set(files) - {"__pycache__"} + ) + if unexpected or target.is_symlink(): + raise ValueError("SDK destination contains unexpected files or a symlink") + for name, data in files.items(): + destination = target / name + if destination.is_symlink(): + raise ValueError("SDK destination must not contain symlinks") + destination.write_bytes(data) + + +def scaffold(parent: Path, name: str, credential_type: str) -> Path: + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) or len(name) > 64: + raise ValueError( + "Use a lowercase hyphenated plugin name, at most 64 characters" + ) + fields = { + "password": "username, password", + "bearer": "token", + "oauth2": "access_token, expires_at (Unix seconds), refresh_token for renewable grants", + } + required_fields = fields[credential_type] + plugin = parent / name + plugin.mkdir(parents=True, exist_ok=False) + (plugin / ".codex-plugin").mkdir() + (plugin / "scripts").mkdir() + manifest = { + "name": name, + "version": "0.1.0", + "description": "Native account authentication adapter development scaffold", + "license": "Apache-2.0", + "author": {"name": "Plugin Developer"}, + "interface": { + "displayName": name, + "shortDescription": "Account authentication adapter scaffold", + "longDescription": "Development scaffold; configure the provider before use.", + "developerName": "Plugin Developer", + "category": "Development", + "capabilities": [], + "defaultPrompt": [], + }, + "connectors": [ + { + "slug": name, + "authPolicy": "optional", + "accountAuth": { + "protocolVersion": 1, + "credentialType": credential_type, + "adapter": "scripts/account-auth.py", + }, + } + ], + } + if credential_type == "oauth2": + manifest["connectors"][0]["accountAuth"]["oauth2"] = [ + "authorize", + "refresh", + "revoke", + ] + (plugin / ".codex-plugin/plugin.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + for source in (ROOT / "templates").glob("*.tmpl"): + content = source.read_text(encoding="utf-8") + for key, value in { + "__CONNECTOR_SLUG__": name, + "__CREDENTIAL_TYPE__": credential_type, + "__REQUIRED_FIELDS__": required_fields, + "__OAUTH_CALLBACKS__": ( + " authorize=auth_provider.authorize,\n" + " refresh=auth_provider.refresh,\n" + " revoke=auth_provider.revoke,\n" + if credential_type == "oauth2" + else "" + ), + "__OAUTH_PROVIDER__": ( + (ROOT / "templates/oauth-provider.inc").read_text(encoding="utf-8") + if credential_type == "oauth2" + else "" + ), + }.items(): + content = content.replace(key, value) + (plugin / "scripts" / source.stem).write_text(content, encoding="utf-8") + bundle(plugin) + return plugin + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + vendor = commands.add_parser("vendor") + vendor.add_argument("plugin", type=Path) + vendor.add_argument("--check", action="store_true") + create = commands.add_parser("scaffold") + create.add_argument("name") + create.add_argument("--parent", type=Path, required=True) + create.add_argument( + "--credential-type", choices=["password", "bearer", "oauth2"], required=True + ) + args = parser.parse_args() + if args.command == "vendor": + if not (args.plugin / ".codex-plugin/plugin.json").is_file(): + parser.error("Destination must be an existing plugin") + bundle(args.plugin, check=args.check) + else: + print(scaffold(args.parent, args.name, args.credential_type)) + + +if __name__ == "__main__": + main() diff --git a/plugins/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/__init__.py b/plugins/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/__init__.py new file mode 100644 index 0000000..762bf3c --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/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/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/adapter.py b/plugins/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/adapter.py new file mode 100644 index 0000000..9813281 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/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/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/configuration.py b/plugins/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/configuration.py new file mode 100644 index 0000000..e2e206e --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/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/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/runtime.py b/plugins/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/runtime.py new file mode 100644 index 0000000..b703176 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/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/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/transport.py b/plugins/dingtalk/.wework-build/plugin-auth/wegent_plugin_auth/transport.py new file mode 100644 index 0000000..6008070 --- /dev/null +++ b/plugins/dingtalk/.wework-build/plugin-auth/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/dingtalk/scripts/DOWNLOAD_SOURCE.md b/plugins/dingtalk/scripts/DOWNLOAD_SOURCE.md new file mode 100644 index 0000000..4072aa6 --- /dev/null +++ b/plugins/dingtalk/scripts/DOWNLOAD_SOURCE.md @@ -0,0 +1,30 @@ +# Managed DWS download source + +The installers accept `DWS_DOWNLOAD_BASE_URL` for a trusted HTTPS artifact mirror. +The mirror must preserve the pinned release archives byte-for-byte and expose: + +```text +/v1.0.58/dws-darwin-amd64.tar.gz +/v1.0.58/dws-darwin-arm64.tar.gz +/v1.0.58/dws-linux-amd64.tar.gz +/v1.0.58/dws-linux-arm64.tar.gz +/v1.0.58/dws-windows-amd64.zip +``` + +Configure the variable in the executor/container environment. Both POSIX and +PowerShell installers retain their pinned SHA-256 hashes and fail when the +selected mirror fails or returns a different archive. URLs containing embedded +credentials, query strings, or fragments are rejected. + +For cloud images, preinstall the verified binary and set `DWS_BINARY_PATH` to its +absolute path. Existing binary discovery and version-specific installation cache +run before any download. This avoids first-task downloads entirely. + +Without the variable the installer still uses GitHub. No company mirror is +provisioned or configured by this change. The variable names an artifact base, +not a generic GitHub proxy or a Gitee repository page. + +Account authentication is separate from binary distribution. DWS 1.0.58 rejects +portable export on Windows and on macOS when using its default Keychain backend; +do not reset authentication, copy only app.json, or claim cross-platform auth +migration merely because the binary was installed successfully. diff --git a/plugins/dingtalk/scripts/account-auth.py b/plugins/dingtalk/scripts/account-auth.py new file mode 100644 index 0000000..38d9e2d --- /dev/null +++ b/plugins/dingtalk/scripts/account-auth.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Execute the checksum-verified companion bundled in the installed plugin.""" + +import hashlib +import json +import os +import platform +import subprocess +import sys +from pathlib import Path + +from wegent_plugin_auth import AuthError, local_configuration + + +def companion() -> Path: + operating_system = {"Darwin": "darwin", "Linux": "linux", "Windows": "windows"}.get( + platform.system() + ) + architecture = { + "x86_64": "amd64", + "AMD64": "amd64", + "aarch64": "arm64", + "arm64": "arm64", + }.get(platform.machine()) + if not operating_system or not architecture: + raise ValueError + root = Path(__file__).resolve().parent + directory = root / "native" / f"{operating_system}-{architecture}" + executable = directory / ( + "dws-account-auth.exe" if operating_system == "windows" else "dws-account-auth" + ) + metadata = executable.with_name(executable.name + ".json") + if ( + executable.is_symlink() + or metadata.is_symlink() + or not executable.resolve().is_relative_to(root) + ): + raise ValueError + if metadata.stat().st_size > 65536 or not executable.is_file(): + raise ValueError + value = json.loads(metadata.read_text(encoding="utf-8")) + if ( + type(value["nativeProtocolVersion"]) is not int + or value["nativeProtocolVersion"] != 1 + or value["target"] != f"{operating_system}/{architecture}" + ): + raise ValueError + with executable.open("rb") as stream: + digest = hashlib.sha256() + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + if digest.hexdigest() != value["binarySha256"]: + raise ValueError + return executable + + +def launch(executable: Path, arguments: list[str]) -> None: + command = [str(executable), *arguments] + if platform.system() == "Windows": + # CPython's os.execv uses the Windows CRT overlay implementation, which + # can let the launcher exit successfully before the child has finished. + # Wait explicitly so the host observes the native adapter's real status. + completed = subprocess.run(command, check=False) + raise SystemExit(completed.returncode) + os.execv(str(executable), command) + + +if __name__ == "__main__": + try: + executable = companion() + settings = local_configuration() + # Only these declared provider settings may select the source auth store. + for name in ("DWS_CONFIG_DIR", "DWS_KEYCHAIN_DIR", "DWS_DISABLE_KEYCHAIN"): + if name in settings: + os.environ[name] = settings[name] + # The child inherits the host's private stdin nonce and socket environment. + launch(executable, sys.argv[1:]) + except (OSError, ValueError, KeyError, TypeError, AuthError): + print( + json.dumps({"status": "error", "code": "plugin_auth_package_sync_required"}) + ) + raise SystemExit(1) diff --git a/plugins/dingtalk/scripts/dws.py b/plugins/dingtalk/scripts/dws.py new file mode 100644 index 0000000..edc5dc1 --- /dev/null +++ b/plugins/dingtalk/scripts/dws.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Public DWS entry: business metadata goes to the shared native broker.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +from wegent_plugin_auth import AuthError, run_account_command + +ROOT = Path(__file__).resolve().parents[1] + + +def account_arguments(arguments: list[str]) -> tuple[str | None, list[str]]: + account = None + forwarded = [] + index = 0 + while index < len(arguments): + value = arguments[index] + if value == "--": + forwarded.extend(arguments[index:]) + break + if value == "--account-id" or value.startswith("--account-id="): + if account is not None: + raise AuthError("plugin_auth_account_selection_required") + if value == "--account-id": + index += 1 + if index >= len(arguments): + raise AuthError("plugin_auth_account_selection_required") + account = arguments[index] + else: + account = value.split("=", 1)[1] + if not account.strip() or len(account) > 256: + raise AuthError("plugin_auth_account_selection_required") + else: + forwarded.append(value) + index += 1 + return account, forwarded + + +def source_is_managed() -> bool: + """Receipts are public handoff metadata; never inspect the DWS token store.""" + directory = Path(os.environ.get("DWS_CONFIG_DIR") or Path.home() / ".dws") + receipts = directory / "wegent-transfers" + if not receipts.exists(): + return False + # Once handoff started, uncertainty must not trigger another local login. + if receipts.is_symlink() or not receipts.is_dir(): + raise AuthError("plugin_auth_reconnect_required") + for path in receipts.glob("*.json"): + if path.is_symlink() or not path.is_file() or path.stat().st_size > 4096: + raise AuthError("plugin_auth_reconnect_required") + try: + value = json.loads(path.read_text(encoding="utf-8")) + if value.get("version") != 1 or value.get("state") not in { + "prepared", + "detached", + "aborted", + }: + raise ValueError + except (ValueError, AttributeError): + raise AuthError("plugin_auth_reconnect_required") from None + if value["state"] == "aborted": + continue + return True + return False + + +def local_environment() -> dict[str, str]: + environment = dict(os.environ) + # A user may put this public wrapper on PATH. Installer probes must reject + # it rather than recursively discovering another copy of the same wrapper. + environment["WEGENT_DWS_RESOLVING"] = "1" + return environment + + +def run_local(arguments: list[str], executable: str | None = None) -> int: + environment = local_environment() + if arguments == ["plugin-health"]: + command = ( + [ + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(ROOT / "scripts/local-dws-ready.ps1"), + ] + if os.name == "nt" + else ["/bin/sh", str(ROOT / "scripts/local-dws-ready.sh")] + ) + return subprocess.call(command, env=environment) + if executable is not None: + return subprocess.call([executable, *arguments], env=environment) + if os.name == "nt": + command = [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + str(ROOT / "scripts/install-dws.ps1"), + "-PrintPath", + ] + else: + command = ["/bin/sh", str(ROOT / "scripts/install-dws.sh")] + result = subprocess.run( + command, env=environment, stdout=subprocess.PIPE, text=True, check=False + ) + if result.returncode: + return result.returncode + lines = result.stdout.strip().splitlines() + if not lines or not Path(lines[-1]).is_file(): + raise AuthError("plugin_auth_runtime_unsupported") + return subprocess.call([lines[-1], *arguments], env=environment) + + +def main(arguments: list[str]) -> int: + try: + if os.environ.get("WEGENT_DWS_RESOLVING") == "1": + return 126 + executable = None + if arguments[:1] == ["--local-binary"]: + if len(arguments) < 3: + raise AuthError("plugin_auth_invalid_command") + executable, arguments = arguments[1], arguments[2:] + account, arguments = account_arguments(arguments) + if ( + os.environ.get("WEGENT_PLUGIN_AUTH_MODE") == "cloud" + or account is not None + or source_is_managed() + ): + if executable is not None: + raise AuthError("plugin_auth_invalid_command") + if arguments == ["plugin-health"]: + arguments = ["account-status"] + sys.stdout.write( + run_account_command(ROOT, "dingtalk", arguments, account_id=account) + ) + return 0 + return run_local(arguments, executable) + except AuthError as error: + print(json.dumps({"error": str(error)})) + return 1 + except (OSError, ValueError): + print(json.dumps({"error": "plugin_auth_execution_failed"})) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/plugins/dingtalk/scripts/ensure-dws-ready.ps1 b/plugins/dingtalk/scripts/ensure-dws-ready.ps1 index 47c5592..a8e9626 100644 --- a/plugins/dingtalk/scripts/ensure-dws-ready.ps1 +++ b/plugins/dingtalk/scripts/ensure-dws-ready.ps1 @@ -1,87 +1,9 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest - $scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path . (Join-Path $scriptDirectory 'invoke-dws.ps1') -$dws = & (Join-Path $scriptDirectory 'install-dws.ps1') -PrintPath | - Select-Object -Last 1 - -function Test-AuthenticatedStatus { - $result = Invoke-DwsCommand -Executable $dws ` - -Arguments @('auth', 'status', '--format', 'json') ` - -OutputMode stdout - $statusText = $result.Output - if ([string]::IsNullOrWhiteSpace($statusText)) { - return $false - } - try { - $status = $statusText | ConvertFrom-Json - return $status.authenticated -eq $true - } catch { - return $false - } -} - -function Wait-AuthenticatedStatus { - param( - [int]$Attempts = 10, - [int]$DelayMilliseconds = 500 - ) - - for ($attempt = 1; $attempt -le $Attempts; $attempt++) { - if (Test-AuthenticatedStatus) { - return $true - } - if ($attempt -lt $Attempts) { - Start-Sleep -Milliseconds $DelayMilliseconds - } - } - return $false -} - -function Get-AuthProbeResult { - $result = Invoke-DwsCommand -Executable $dws ` - -Arguments @('contact', 'user', 'get-self', '--format', 'json') ` - -OutputMode all - if ($result.ExitCode -eq 0) { - return 'authenticated' - } - $probeText = $result.Output - if ($probeText -match 'not_authenticated|AUTH_TOKEN_EXPIRED|USER_TOKEN_ILLEGAL|未登录|Token验证失败') { - return 'unauthenticated' - } - return 'unavailable' -} - -$loginRequired = -not (Test-AuthenticatedStatus) -if (-not $loginRequired) { - $probe = Get-AuthProbeResult - if ($probe -eq 'unauthenticated') { - $loginRequired = $true - } elseif ($probe -eq 'unavailable') { - Write-Warning 'DWS reports a local login; the read-only auth probe was unavailable for a non-authentication reason.' - } -} - -if ($loginRequired) { - Write-Host 'DWS is not authenticated. Opening DingTalk browser authorization...' - # Complete local OAuth login without blocking on operation-specific PAT - # permissions, which DWS handles when a command requires them. - $result = Invoke-DwsCommand -Executable $dws ` - -Arguments @('auth', 'login', '--format', 'json') - if (-not (Wait-AuthenticatedStatus)) { - if ($result.ExitCode -ne 0) { - throw "DWS browser authorization failed (exit code $($result.ExitCode))." - } - throw 'DWS authorization did not produce a usable local login.' - } -} - -if (-not (Wait-AuthenticatedStatus)) { - throw 'DWS authorization did not produce a usable local login.' -} -$finalProbe = Get-AuthProbeResult -if ($finalProbe -eq 'unauthenticated') { - throw 'DWS authorization completed, but the local token is still rejected.' -} -Write-Host 'DWS is installed and authenticated.' +$dwsExitCode = -1 +Invoke-NativeCommand ` + -Command { & (Join-Path $scriptDirectory 'run-dws.ps1') plugin-health } ` + -ExitCode ([ref]$dwsExitCode) +exit $dwsExitCode diff --git a/plugins/dingtalk/scripts/ensure-dws-ready.sh b/plugins/dingtalk/scripts/ensure-dws-ready.sh index c2aa866..3e6f9a5 100644 --- a/plugins/dingtalk/scripts/ensure-dws-ready.sh +++ b/plugins/dingtalk/scripts/ensure-dws-ready.sh @@ -1,88 +1,4 @@ #!/bin/sh - set -eu - DWS_SCRIPT_DIRECTORY="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -DWS_EXECUTABLE="$(/bin/sh "${DWS_SCRIPT_DIRECTORY}/install-dws.sh")" - -auth_status_is_authenticated() { - DWS_AUTH_STATUS="$("${DWS_EXECUTABLE}" auth status --format json 2>/dev/null || true)" - printf '%s\n' "${DWS_AUTH_STATUS}" | - grep -E '"authenticated"[[:space:]]*:[[:space:]]*true' >/dev/null 2>&1 -} - -wait_for_authenticated_status() { - DWS_AUTH_ATTEMPT=1 - while [ "${DWS_AUTH_ATTEMPT}" -le 10 ]; do - if auth_status_is_authenticated; then - return 0 - fi - if [ "${DWS_AUTH_ATTEMPT}" -lt 10 ]; then - sleep 0.5 - fi - DWS_AUTH_ATTEMPT=$((DWS_AUTH_ATTEMPT + 1)) - done - return 1 -} - -auth_probe_result() { - DWS_AUTH_PROBE_OUTPUT="$("${DWS_EXECUTABLE}" contact user get-self --format json 2>&1)" && - return 0 - if printf '%s\n' "${DWS_AUTH_PROBE_OUTPUT}" | - grep -E 'not_authenticated|AUTH_TOKEN_EXPIRED|USER_TOKEN_ILLEGAL|未登录|Token验证失败' >/dev/null 2>&1; then - return 1 - fi - return 2 -} - -DWS_LOGIN_REQUIRED=false -if ! auth_status_is_authenticated; then - DWS_LOGIN_REQUIRED=true -else - if auth_probe_result; then - DWS_INITIAL_PROBE_STATUS=0 - else - DWS_INITIAL_PROBE_STATUS=$? - fi - if [ "${DWS_INITIAL_PROBE_STATUS}" -eq 1 ]; then - DWS_LOGIN_REQUIRED=true - elif [ "${DWS_INITIAL_PROBE_STATUS}" -eq 2 ]; then - echo "DWS reports a local login; the read-only auth probe was unavailable for a non-authentication reason." >&2 - fi -fi - -if [ "${DWS_LOGIN_REQUIRED}" = true ]; then - echo "DWS is not authenticated. Opening DingTalk browser authorization..." >&2 - # Complete local OAuth login without blocking on operation-specific PAT - # permissions, which DWS handles when a command requires them. - if "${DWS_EXECUTABLE}" auth login --format json; then - DWS_LOGIN_EXIT_CODE=0 - else - DWS_LOGIN_EXIT_CODE=$? - fi - if ! wait_for_authenticated_status; then - if [ "${DWS_LOGIN_EXIT_CODE}" -ne 0 ]; then - echo "DWS browser authorization failed (exit code ${DWS_LOGIN_EXIT_CODE})." >&2 - exit 20 - fi - echo "DWS authorization did not produce a usable local login." >&2 - exit 20 - fi -fi - -if ! wait_for_authenticated_status; then - echo "DWS authorization did not produce a usable local login." >&2 - exit 20 -fi - -if auth_probe_result; then - DWS_FINAL_PROBE_STATUS=0 -else - DWS_FINAL_PROBE_STATUS=$? -fi -if [ "${DWS_FINAL_PROBE_STATUS}" -eq 1 ]; then - echo "DWS authorization completed, but the local token is still rejected." >&2 - exit 21 -fi - -echo "DWS is installed and authenticated." +exec /bin/sh "${DWS_SCRIPT_DIRECTORY}/run-dws.sh" plugin-health diff --git a/plugins/dingtalk/scripts/install-dws.ps1 b/plugins/dingtalk/scripts/install-dws.ps1 index 4c0ccfc..a16601a 100644 --- a/plugins/dingtalk/scripts/install-dws.ps1 +++ b/plugins/dingtalk/scripts/install-dws.ps1 @@ -63,6 +63,12 @@ if ($env:PROCESSOR_ARCHITECTURE.ToUpperInvariant() -ne 'AMD64') { } $archiveUrl = 'https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-windows-amd64.zip' +if (-not [string]::IsNullOrWhiteSpace($env:DWS_DOWNLOAD_BASE_URL)) { + if ($env:DWS_DOWNLOAD_BASE_URL -cnotmatch '^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~%/-]*)?\z') { + throw 'DWS_DOWNLOAD_BASE_URL must be an HTTPS artifact base without credentials, query or fragment.' + } + $archiveUrl = $env:DWS_DOWNLOAD_BASE_URL.TrimEnd('/') + "/v$dwsVersion/dws-windows-amd64.zip" +} $expectedHash = 'b8c50d9111115eafdb466978f1dd8f9421bcc2d5fac848023108353dc5a236cb' # Older Windows PowerShell 5.1 hosts may otherwise negotiate TLS 1.0. [Net.ServicePointManager]::SecurityProtocol = ` @@ -76,7 +82,7 @@ try { $lastError = $null for ($attempt = 1; $attempt -le 4; $attempt++) { try { - Invoke-WebRequest -Uri $archiveUrl -OutFile $archivePath -UseBasicParsing + Invoke-WebRequest -Uri $archiveUrl -OutFile $archivePath -UseBasicParsing -TimeoutSec 120 $lastError = $null break } catch { diff --git a/plugins/dingtalk/scripts/install-dws.sh b/plugins/dingtalk/scripts/install-dws.sh index d19d945..51e3ce3 100644 --- a/plugins/dingtalk/scripts/install-dws.sh +++ b/plugins/dingtalk/scripts/install-dws.sh @@ -37,6 +37,7 @@ download_file() { if command -v curl >/dev/null 2>&1; then curl --fail --show-error --location \ --retry 3 --retry-all-errors --connect-timeout 15 \ + --max-time 120 --retry-max-time 240 \ --output "${DWS_DESTINATION}" "${DWS_SOURCE_URL}" elif command -v wget >/dev/null 2>&1; then wget --tries=4 --timeout=15 --output-document="${DWS_DESTINATION}" \ @@ -111,6 +112,23 @@ resolve_release() { return 1 ;; esac + + # Mirrors must serve the same immutable archives; pinned hashes stay authoritative. + if [ -n "${DWS_DOWNLOAD_BASE_URL:-}" ]; then + case "${DWS_DOWNLOAD_BASE_URL}" in + *[!a-zA-Z0-9:/._~%-]*) + echo "DWS_DOWNLOAD_BASE_URL contains unsupported URL characters." >&2 + return 1 + ;; + esac + if ! printf '%s\n' "${DWS_DOWNLOAD_BASE_URL}" | + LC_ALL=C grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~%/-]*)?$'; then + echo "DWS_DOWNLOAD_BASE_URL must be an HTTPS artifact base without credentials, query or fragment." >&2 + return 1 + fi + DWS_ARCHIVE_NAME="${DWS_ARCHIVE_URL##*/}" + DWS_ARCHIVE_URL="${DWS_DOWNLOAD_BASE_URL%/}/v${DWS_RELEASE_VERSION}/${DWS_ARCHIVE_NAME}" + fi } if DWS_EXECUTABLE="$(find_working_dws)"; then diff --git a/plugins/dingtalk/scripts/local-dws-ready.ps1 b/plugins/dingtalk/scripts/local-dws-ready.ps1 new file mode 100644 index 0000000..47c5592 --- /dev/null +++ b/plugins/dingtalk/scripts/local-dws-ready.ps1 @@ -0,0 +1,87 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDirectory 'invoke-dws.ps1') +$dws = & (Join-Path $scriptDirectory 'install-dws.ps1') -PrintPath | + Select-Object -Last 1 + +function Test-AuthenticatedStatus { + $result = Invoke-DwsCommand -Executable $dws ` + -Arguments @('auth', 'status', '--format', 'json') ` + -OutputMode stdout + $statusText = $result.Output + if ([string]::IsNullOrWhiteSpace($statusText)) { + return $false + } + try { + $status = $statusText | ConvertFrom-Json + return $status.authenticated -eq $true + } catch { + return $false + } +} + +function Wait-AuthenticatedStatus { + param( + [int]$Attempts = 10, + [int]$DelayMilliseconds = 500 + ) + + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + if (Test-AuthenticatedStatus) { + return $true + } + if ($attempt -lt $Attempts) { + Start-Sleep -Milliseconds $DelayMilliseconds + } + } + return $false +} + +function Get-AuthProbeResult { + $result = Invoke-DwsCommand -Executable $dws ` + -Arguments @('contact', 'user', 'get-self', '--format', 'json') ` + -OutputMode all + if ($result.ExitCode -eq 0) { + return 'authenticated' + } + $probeText = $result.Output + if ($probeText -match 'not_authenticated|AUTH_TOKEN_EXPIRED|USER_TOKEN_ILLEGAL|未登录|Token验证失败') { + return 'unauthenticated' + } + return 'unavailable' +} + +$loginRequired = -not (Test-AuthenticatedStatus) +if (-not $loginRequired) { + $probe = Get-AuthProbeResult + if ($probe -eq 'unauthenticated') { + $loginRequired = $true + } elseif ($probe -eq 'unavailable') { + Write-Warning 'DWS reports a local login; the read-only auth probe was unavailable for a non-authentication reason.' + } +} + +if ($loginRequired) { + Write-Host 'DWS is not authenticated. Opening DingTalk browser authorization...' + # Complete local OAuth login without blocking on operation-specific PAT + # permissions, which DWS handles when a command requires them. + $result = Invoke-DwsCommand -Executable $dws ` + -Arguments @('auth', 'login', '--format', 'json') + if (-not (Wait-AuthenticatedStatus)) { + if ($result.ExitCode -ne 0) { + throw "DWS browser authorization failed (exit code $($result.ExitCode))." + } + throw 'DWS authorization did not produce a usable local login.' + } +} + +if (-not (Wait-AuthenticatedStatus)) { + throw 'DWS authorization did not produce a usable local login.' +} +$finalProbe = Get-AuthProbeResult +if ($finalProbe -eq 'unauthenticated') { + throw 'DWS authorization completed, but the local token is still rejected.' +} +Write-Host 'DWS is installed and authenticated.' diff --git a/plugins/dingtalk/scripts/local-dws-ready.sh b/plugins/dingtalk/scripts/local-dws-ready.sh new file mode 100644 index 0000000..c2aa866 --- /dev/null +++ b/plugins/dingtalk/scripts/local-dws-ready.sh @@ -0,0 +1,88 @@ +#!/bin/sh + +set -eu + +DWS_SCRIPT_DIRECTORY="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +DWS_EXECUTABLE="$(/bin/sh "${DWS_SCRIPT_DIRECTORY}/install-dws.sh")" + +auth_status_is_authenticated() { + DWS_AUTH_STATUS="$("${DWS_EXECUTABLE}" auth status --format json 2>/dev/null || true)" + printf '%s\n' "${DWS_AUTH_STATUS}" | + grep -E '"authenticated"[[:space:]]*:[[:space:]]*true' >/dev/null 2>&1 +} + +wait_for_authenticated_status() { + DWS_AUTH_ATTEMPT=1 + while [ "${DWS_AUTH_ATTEMPT}" -le 10 ]; do + if auth_status_is_authenticated; then + return 0 + fi + if [ "${DWS_AUTH_ATTEMPT}" -lt 10 ]; then + sleep 0.5 + fi + DWS_AUTH_ATTEMPT=$((DWS_AUTH_ATTEMPT + 1)) + done + return 1 +} + +auth_probe_result() { + DWS_AUTH_PROBE_OUTPUT="$("${DWS_EXECUTABLE}" contact user get-self --format json 2>&1)" && + return 0 + if printf '%s\n' "${DWS_AUTH_PROBE_OUTPUT}" | + grep -E 'not_authenticated|AUTH_TOKEN_EXPIRED|USER_TOKEN_ILLEGAL|未登录|Token验证失败' >/dev/null 2>&1; then + return 1 + fi + return 2 +} + +DWS_LOGIN_REQUIRED=false +if ! auth_status_is_authenticated; then + DWS_LOGIN_REQUIRED=true +else + if auth_probe_result; then + DWS_INITIAL_PROBE_STATUS=0 + else + DWS_INITIAL_PROBE_STATUS=$? + fi + if [ "${DWS_INITIAL_PROBE_STATUS}" -eq 1 ]; then + DWS_LOGIN_REQUIRED=true + elif [ "${DWS_INITIAL_PROBE_STATUS}" -eq 2 ]; then + echo "DWS reports a local login; the read-only auth probe was unavailable for a non-authentication reason." >&2 + fi +fi + +if [ "${DWS_LOGIN_REQUIRED}" = true ]; then + echo "DWS is not authenticated. Opening DingTalk browser authorization..." >&2 + # Complete local OAuth login without blocking on operation-specific PAT + # permissions, which DWS handles when a command requires them. + if "${DWS_EXECUTABLE}" auth login --format json; then + DWS_LOGIN_EXIT_CODE=0 + else + DWS_LOGIN_EXIT_CODE=$? + fi + if ! wait_for_authenticated_status; then + if [ "${DWS_LOGIN_EXIT_CODE}" -ne 0 ]; then + echo "DWS browser authorization failed (exit code ${DWS_LOGIN_EXIT_CODE})." >&2 + exit 20 + fi + echo "DWS authorization did not produce a usable local login." >&2 + exit 20 + fi +fi + +if ! wait_for_authenticated_status; then + echo "DWS authorization did not produce a usable local login." >&2 + exit 20 +fi + +if auth_probe_result; then + DWS_FINAL_PROBE_STATUS=0 +else + DWS_FINAL_PROBE_STATUS=$? +fi +if [ "${DWS_FINAL_PROBE_STATUS}" -eq 1 ]; then + echo "DWS authorization completed, but the local token is still rejected." >&2 + exit 21 +fi + +echo "DWS is installed and authenticated." diff --git a/plugins/dingtalk/scripts/run-dws.ps1 b/plugins/dingtalk/scripts/run-dws.ps1 index 3f2b386..3450001 100644 --- a/plugins/dingtalk/scripts/run-dws.ps1 +++ b/plugins/dingtalk/scripts/run-dws.ps1 @@ -8,10 +8,8 @@ Set-StrictMode -Version Latest $scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path . (Join-Path $scriptDirectory 'invoke-dws.ps1') -$dws = & (Join-Path $scriptDirectory 'install-dws.ps1') -PrintPath | - Select-Object -Last 1 $dwsExitCode = -1 Invoke-NativeCommand ` - -Command { & $dws @DwsArguments } ` + -Command { & (Join-Path $scriptDirectory 'run-python.ps1') -ScriptPath (Join-Path $scriptDirectory 'dws.py') @DwsArguments } ` -ExitCode ([ref]$dwsExitCode) exit $dwsExitCode diff --git a/plugins/dingtalk/scripts/run-dws.sh b/plugins/dingtalk/scripts/run-dws.sh index 0710787..589812e 100644 --- a/plugins/dingtalk/scripts/run-dws.sh +++ b/plugins/dingtalk/scripts/run-dws.sh @@ -3,6 +3,4 @@ set -eu DWS_SCRIPT_DIRECTORY="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -DWS_EXECUTABLE="$(/bin/sh "${DWS_SCRIPT_DIRECTORY}/install-dws.sh")" -exec "${DWS_EXECUTABLE}" "$@" - +exec /bin/sh "${DWS_SCRIPT_DIRECTORY}/run-python.sh" "${DWS_SCRIPT_DIRECTORY}/dws.py" "$@" diff --git a/plugins/dingtalk/scripts/run-python.ps1 b/plugins/dingtalk/scripts/run-python.ps1 index eeaddc4..d232529 100644 --- a/plugins/dingtalk/scripts/run-python.ps1 +++ b/plugins/dingtalk/scripts/run-python.ps1 @@ -11,9 +11,6 @@ Set-StrictMode -Version Latest $scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path . (Join-Path $scriptDirectory 'invoke-dws.ps1') -$dws = & (Join-Path $scriptDirectory 'install-dws.ps1') -PrintPath | - Select-Object -Last 1 -$env:PATH = "$(Split-Path -Parent $dws);$env:PATH" $python = Get-Command python3 -ErrorAction SilentlyContinue $pythonPrefix = @() diff --git a/plugins/dingtalk/scripts/run-python.sh b/plugins/dingtalk/scripts/run-python.sh index 15fff19..06e1126 100644 --- a/plugins/dingtalk/scripts/run-python.sh +++ b/plugins/dingtalk/scripts/run-python.sh @@ -8,8 +8,6 @@ if [ "$#" -lt 1 ]; then fi DWS_SCRIPT_DIRECTORY="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -DWS_EXECUTABLE="$(/bin/sh "${DWS_SCRIPT_DIRECTORY}/install-dws.sh")" -DWS_BIN_DIRECTORY="$(dirname -- "${DWS_EXECUTABLE}")" if command -v python3 >/dev/null 2>&1; then DWS_PYTHON="$(command -v python3)" @@ -20,5 +18,4 @@ else exit 3 fi -PATH="${DWS_BIN_DIRECTORY}:${PATH}" exec "${DWS_PYTHON}" "$@" - +exec "${DWS_PYTHON}" "$@" diff --git a/plugins/dingtalk/scripts/test-local-auth.ps1 b/plugins/dingtalk/scripts/test-local-auth.ps1 index 45f837e..2c05c39 100644 --- a/plugins/dingtalk/scripts/test-local-auth.ps1 +++ b/plugins/dingtalk/scripts/test-local-auth.ps1 @@ -33,7 +33,7 @@ function Invoke-AuthTest( [int]$WaitAttempts = 10, [int]$LoginWaitAttempts = 100, [int]$WaitDelayMilliseconds = 10, - [int]$StatusTimeoutMilliseconds = 200 + [int]$StatusTimeoutMilliseconds = 5000 ) { $env:DWS_MOCK_STATE_DIR = $StateDirectory $env:WEGENT_LOCAL_AUTH_TOOL = $mockDws diff --git a/plugins/dingtalk/scripts/wegent_plugin_auth/LICENSE b/plugins/dingtalk/scripts/wegent_plugin_auth/LICENSE new file mode 100644 index 0000000..b8c67ae --- /dev/null +++ b/plugins/dingtalk/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/dingtalk/scripts/wegent_plugin_auth/__init__.py b/plugins/dingtalk/scripts/wegent_plugin_auth/__init__.py new file mode 100644 index 0000000..762bf3c --- /dev/null +++ b/plugins/dingtalk/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/dingtalk/scripts/wegent_plugin_auth/adapter.py b/plugins/dingtalk/scripts/wegent_plugin_auth/adapter.py new file mode 100644 index 0000000..9813281 --- /dev/null +++ b/plugins/dingtalk/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/dingtalk/scripts/wegent_plugin_auth/configuration.py b/plugins/dingtalk/scripts/wegent_plugin_auth/configuration.py new file mode 100644 index 0000000..e2e206e --- /dev/null +++ b/plugins/dingtalk/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/dingtalk/scripts/wegent_plugin_auth/runtime.py b/plugins/dingtalk/scripts/wegent_plugin_auth/runtime.py new file mode 100644 index 0000000..b703176 --- /dev/null +++ b/plugins/dingtalk/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/dingtalk/scripts/wegent_plugin_auth/transport.py b/plugins/dingtalk/scripts/wegent_plugin_auth/transport.py new file mode 100644 index 0000000..6008070 --- /dev/null +++ b/plugins/dingtalk/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/dingtalk/scripts/wegent_plugin_auth/vendor.json b/plugins/dingtalk/scripts/wegent_plugin_auth/vendor.json new file mode 100644 index 0000000..5e66d36 --- /dev/null +++ b/plugins/dingtalk/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/dingtalk/skills/dws/SKILL.md b/plugins/dingtalk/skills/dws/SKILL.md index 974059b..f3d58e7 100644 --- a/plugins/dingtalk/skills/dws/SKILL.md +++ b/plugins/dingtalk/skills/dws/SKILL.md @@ -13,8 +13,9 @@ metadata: ## Wegent 本地运行与授权 -本插件不使用 Wegent Backend Connector,不允许向 Backend 上传、同步或保存 -钉钉 Token。DWS CLI 和 OAuth 登录态均位于当前用户的本机环境。 +账号认证迁移只能从 Wegent 原生插件界面发起。适配器通过私有通道完成加密暂存和 +唯一刷新权交接,云端仅在用户授权设备后使用账号。禁止模型读取、打印或转发 Token。 +尚未迁移的本机账号仍使用原有 DWS 登录态;云端和已迁移账号必须使用平台认证代理。 从此 `SKILL.md` 向上两级定位插件根目录。每次新会话第一次调用钉钉能力前: @@ -24,13 +25,20 @@ metadata: `powershell -NoProfile -ExecutionPolicy Bypass -File "\scripts\ensure-dws-ready.ps1"`。 安装器优先复用 PATH 中可用的 `dws`;没有时下载官方 DWS CLI,并在安装前 -校验官方清单中的 SHA-256。未登录时,准备脚本会执行 +校验官方清单中的 SHA-256。仅尚未迁移的本机账号,未登录时准备脚本会执行 `dws auth login --format json`,由本机浏览器完成 OAuth 授权,并在确认登录态可用后 结束准备流程。推荐 PAT 权限属于具体操作,不在安装或准备阶段预先申请;后续命令 需要额外权限时按 DWS 返回的授权要求处理。只有浏览器授权、 企业管理员权限、网络/VPN 等必须由用户处理时才暂停并说明所需操作。禁止要求 用户在聊天中粘贴 Token,禁止读取、打印或上传本机 DWS 凭据文件。 +云端或已迁移账号的准备检查不会再次登录或下载安装原版 DWS。遇到 +`plugin_auth_device_not_granted`,先确认本机已有登录且本机和云端设备在线,等待后台同步后重试; +持续失败时报告连接状态,不要求用户在页面迁移或授权设备。遇到 +`plugin_auth_account_selection_required`,用 `--account-id ` 指定平台账号。 +不要通过 `auth login`、`profile switch`、`--profile` 或自定义二进制绕过这些状态。 +后文的本机 profile 管理仅适用于尚未迁移的账号。 + 本 Skill 后文所有 `dws ...` 命令都是逻辑写法,实际执行必须经过插件包装器: - macOS/Linux: @@ -40,7 +48,7 @@ metadata: 后文 `python scripts/.py ...` 也必须使用相应的 `/scripts/run-python.sh` 或 `run-python.ps1` 包装器,以便本地 -安装的 DWS 对辅助脚本可见。不要直接调用 PATH 中来源不明的命令,也不要绕过 +辅助脚本使用同一账号认证入口。不要直接调用 PATH 中来源不明的命令,也不要绕过 包装器改用 curl、HTTP API 或浏览器自动化。 ### 跨平台命令硬规则 diff --git a/plugins/dingtalk/skills/dws/scripts/aitable_export_via_task.py b/plugins/dingtalk/skills/dws/scripts/aitable_export_via_task.py index caa66f8..b9f0d70 100644 --- a/plugins/dingtalk/skills/dws/scripts/aitable_export_via_task.py +++ b/plugins/dingtalk/skills/dws/scripts/aitable_export_via_task.py @@ -25,6 +25,7 @@ from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen +from dws_entry import command as dws_command RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$") ALLOWED_FORMATS = {"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"} @@ -35,7 +36,7 @@ def validate_resource_id(resource_id: str) -> bool: def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]: - cmd = [dws_bin] + args + cmd = dws_command(dws_bin) + args try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec) return result.returncode, result.stdout.strip(), result.stderr.strip() diff --git a/plugins/dingtalk/skills/dws/scripts/aitable_import_via_task.py b/plugins/dingtalk/skills/dws/scripts/aitable_import_via_task.py index ad24de8..9e5ab40 100644 --- a/plugins/dingtalk/skills/dws/scripts/aitable_import_via_task.py +++ b/plugins/dingtalk/skills/dws/scripts/aitable_import_via_task.py @@ -23,6 +23,7 @@ from typing import Any, Dict, Optional, Tuple from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +from dws_entry import command as dws_command RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$") ALLOWED_EXTENSIONS = {".csv", ".xlsx", ".xls"} @@ -33,7 +34,7 @@ def validate_resource_id(resource_id: str) -> bool: def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]: - cmd = [dws_bin] + args + cmd = dws_command(dws_bin) + args try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec) return result.returncode, result.stdout.strip(), result.stderr.strip() diff --git a/plugins/dingtalk/skills/dws/scripts/attendance_my_record.py b/plugins/dingtalk/skills/dws/scripts/attendance_my_record.py index 8989850..22d7c32 100644 --- a/plugins/dingtalk/skills/dws/scripts/attendance_my_record.py +++ b/plugins/dingtalk/skills/dws/scripts/attendance_my_record.py @@ -15,6 +15,7 @@ import re from datetime import datetime from typing import List, Any, Optional +from dws_entry import command as dws_command DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$') @@ -22,7 +23,7 @@ def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/attendance_report_common.py b/plugins/dingtalk/skills/dws/scripts/attendance_report_common.py index 34dce90..fa24871 100644 --- a/plugins/dingtalk/skills/dws/scripts/attendance_report_common.py +++ b/plugins/dingtalk/skills/dws/scripts/attendance_report_common.py @@ -103,7 +103,7 @@ def run_dws(args: list[str]) -> Any: if "--format" not in args: args = args + ["--format", "json"] - cmd = ["dws"] + args + cmd = [*dws_command()] + args try: result = subprocess.run( cmd, @@ -747,6 +747,7 @@ def resolve_user_names(user_ids: list[str]) -> dict[str, str]: # 考勤结果单元格条件配色(参考钉钉 previewStyleByValue 配置) # 规则按优先级排列,首个匹配命中即停止;无 color 键表示不填充背景色。 import re as _re +from dws_entry import command as dws_command _ATTEND_RESULT_STYLE_RULES: list[tuple["_re.Pattern[str]", str | None]] = [ # 白底(红字加粗由字体控制):周末 — POI index 9 WHITE (255,255,255) diff --git a/plugins/dingtalk/skills/dws/scripts/attendance_team_shift.py b/plugins/dingtalk/skills/dws/scripts/attendance_team_shift.py index bf91ad3..9d66344 100644 --- a/plugins/dingtalk/skills/dws/scripts/attendance_team_shift.py +++ b/plugins/dingtalk/skills/dws/scripts/attendance_team_shift.py @@ -15,12 +15,13 @@ import argparse from datetime import datetime, timedelta from typing import List, Any, Optional +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/bulk_add_fields.py b/plugins/dingtalk/skills/dws/scripts/bulk_add_fields.py index 98e0a9c..7fac616 100644 --- a/plugins/dingtalk/skills/dws/scripts/bulk_add_fields.py +++ b/plugins/dingtalk/skills/dws/scripts/bulk_add_fields.py @@ -24,6 +24,7 @@ import re from pathlib import Path from typing import Union, List, Dict, Any, Optional, Tuple +from dws_entry import command as dws_command JsonData = Union[List[Any], Dict[str, Any]] @@ -148,7 +149,7 @@ def run_dws(args: List[str]) -> Optional[Dict[str, Any]]: print('错误:空命令') return None - cmd = ['dws'] + args + cmd = [*dws_command()] + args try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=60 diff --git a/plugins/dingtalk/skills/dws/scripts/calendar_free_slot_finder.py b/plugins/dingtalk/skills/dws/scripts/calendar_free_slot_finder.py index fbab129..cef8f4d 100644 --- a/plugins/dingtalk/skills/dws/scripts/calendar_free_slot_finder.py +++ b/plugins/dingtalk/skills/dws/scripts/calendar_free_slot_finder.py @@ -21,6 +21,7 @@ import argparse from datetime import datetime, timedelta, timezone from typing import List, Dict, Any, Optional, Tuple +from dws_entry import command as dws_command TZ = timezone(timedelta(hours=8)) SLOT_STEP_MIN = 30 @@ -29,7 +30,7 @@ def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/calendar_schedule_meeting.py b/plugins/dingtalk/skills/dws/scripts/calendar_schedule_meeting.py index 54dd920..47c693a 100644 --- a/plugins/dingtalk/skills/dws/scripts/calendar_schedule_meeting.py +++ b/plugins/dingtalk/skills/dws/scripts/calendar_schedule_meeting.py @@ -20,6 +20,7 @@ import argparse from datetime import datetime, timedelta, timezone from typing import List, Dict, Any, Optional +from dws_entry import command as dws_command TZ = timezone(timedelta(hours=8)) @@ -27,7 +28,7 @@ def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return {'dry_run': True} diff --git a/plugins/dingtalk/skills/dws/scripts/calendar_today_agenda.py b/plugins/dingtalk/skills/dws/scripts/calendar_today_agenda.py index 76dd550..212763b 100644 --- a/plugins/dingtalk/skills/dws/scripts/calendar_today_agenda.py +++ b/plugins/dingtalk/skills/dws/scripts/calendar_today_agenda.py @@ -15,6 +15,7 @@ import subprocess from datetime import datetime, timedelta, timezone from typing import List, Dict, Any, Optional +from dws_entry import command as dws_command TZ = timezone(timedelta(hours=8)) @@ -22,7 +23,7 @@ def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/chat_export_messages.py b/plugins/dingtalk/skills/dws/scripts/chat_export_messages.py index 5432460..4aea9c0 100644 --- a/plugins/dingtalk/skills/dws/scripts/chat_export_messages.py +++ b/plugins/dingtalk/skills/dws/scripts/chat_export_messages.py @@ -19,12 +19,13 @@ import subprocess import argparse from typing import List, Any, Optional +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/chat_history_with_user.py b/plugins/dingtalk/skills/dws/scripts/chat_history_with_user.py index 4c92627..52ba7bc 100644 --- a/plugins/dingtalk/skills/dws/scripts/chat_history_with_user.py +++ b/plugins/dingtalk/skills/dws/scripts/chat_history_with_user.py @@ -18,13 +18,14 @@ import subprocess import argparse from typing import List, Any, Optional +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: """执行 dws 命令并解析 JSON 输出""" - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/contact_dept_members.py b/plugins/dingtalk/skills/dws/scripts/contact_dept_members.py index 9413656..0291d50 100644 --- a/plugins/dingtalk/skills/dws/scripts/contact_dept_members.py +++ b/plugins/dingtalk/skills/dws/scripts/contact_dept_members.py @@ -12,12 +12,13 @@ import subprocess import argparse from typing import List, Any, Optional +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/doc_create_and_write.py b/plugins/dingtalk/skills/dws/scripts/doc_create_and_write.py index fbf52f8..f63dd56 100644 --- a/plugins/dingtalk/skills/dws/scripts/doc_create_and_write.py +++ b/plugins/dingtalk/skills/dws/scripts/doc_create_and_write.py @@ -24,12 +24,13 @@ import argparse from pathlib import Path from typing import List, Any, Optional +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return {'dry_run': True} diff --git a/plugins/dingtalk/skills/dws/scripts/drive_tree_list.py b/plugins/dingtalk/skills/dws/scripts/drive_tree_list.py index a7e3439..09102ba 100644 --- a/plugins/dingtalk/skills/dws/scripts/drive_tree_list.py +++ b/plugins/dingtalk/skills/dws/scripts/drive_tree_list.py @@ -14,12 +14,13 @@ import subprocess import argparse from typing import List, Any, Optional +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/dws_entry.py b/plugins/dingtalk/skills/dws/scripts/dws_entry.py new file mode 100644 index 0000000..2751834 --- /dev/null +++ b/plugins/dingtalk/skills/dws/scripts/dws_entry.py @@ -0,0 +1,12 @@ +"""Run the plugin entry directly, preserving argument boundaries on every OS.""" + +import sys +from pathlib import Path + + +def command(executable: str = "dws") -> list[str]: + prefix = [ + sys.executable, + str(Path(__file__).resolve().parents[3] / "scripts/dws.py"), + ] + return prefix if executable == "dws" else [*prefix, "--local-binary", executable] diff --git a/plugins/dingtalk/skills/dws/scripts/import_records.py b/plugins/dingtalk/skills/dws/scripts/import_records.py index 020b3ca..a0fa2e4 100644 --- a/plugins/dingtalk/skills/dws/scripts/import_records.py +++ b/plugins/dingtalk/skills/dws/scripts/import_records.py @@ -21,6 +21,7 @@ import re from pathlib import Path from typing import Union, List, Dict, Any, Optional, Tuple +from dws_entry import command as dws_command JsonData = Union[List[Any], Dict[str, Any]] RecordDict = Dict[str, str] @@ -147,7 +148,7 @@ def run_dws(args: List[str]) -> Optional[Dict[str, Any]]: if not args: print('错误:空命令') return None - cmd = ['dws'] + args + cmd = [*dws_command()] + args try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=120 diff --git a/plugins/dingtalk/skills/dws/scripts/minutes_extract_todos.py b/plugins/dingtalk/skills/dws/scripts/minutes_extract_todos.py index 7ca3d65..b818e31 100644 --- a/plugins/dingtalk/skills/dws/scripts/minutes_extract_todos.py +++ b/plugins/dingtalk/skills/dws/scripts/minutes_extract_todos.py @@ -21,12 +21,13 @@ sys.path.insert(0, str(_scripts_dir)) from minutes_list_parse import uuid_title_pairs_from_payload +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/minutes_recent_summary.py b/plugins/dingtalk/skills/dws/scripts/minutes_recent_summary.py index 24e8ed4..0651f5c 100644 --- a/plugins/dingtalk/skills/dws/scripts/minutes_recent_summary.py +++ b/plugins/dingtalk/skills/dws/scripts/minutes_recent_summary.py @@ -21,12 +21,13 @@ sys.path.insert(0, str(_scripts_dir)) from minutes_list_parse import uuid_title_pairs_from_payload +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/oa_batch_approve.py b/plugins/dingtalk/skills/dws/scripts/oa_batch_approve.py index 88b5eef..622f2d4 100644 --- a/plugins/dingtalk/skills/dws/scripts/oa_batch_approve.py +++ b/plugins/dingtalk/skills/dws/scripts/oa_batch_approve.py @@ -15,12 +15,13 @@ import argparse from datetime import datetime, timedelta from typing import List, Any, Optional +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return {'dry_run': True} diff --git a/plugins/dingtalk/skills/dws/scripts/oa_pending_review.py b/plugins/dingtalk/skills/dws/scripts/oa_pending_review.py index 440f83c..1fa14ae 100644 --- a/plugins/dingtalk/skills/dws/scripts/oa_pending_review.py +++ b/plugins/dingtalk/skills/dws/scripts/oa_pending_review.py @@ -14,12 +14,13 @@ import argparse from datetime import datetime, timedelta from typing import List, Any, Optional +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/report_inbox_today.py b/plugins/dingtalk/skills/dws/scripts/report_inbox_today.py index 05f5985..5ee4c2c 100644 --- a/plugins/dingtalk/skills/dws/scripts/report_inbox_today.py +++ b/plugins/dingtalk/skills/dws/scripts/report_inbox_today.py @@ -14,12 +14,13 @@ import argparse from datetime import datetime, timedelta from typing import List, Any, Optional +from dws_entry import command as dws_command def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/todo_batch_create.py b/plugins/dingtalk/skills/dws/scripts/todo_batch_create.py index ae021fd..11bf343 100644 --- a/plugins/dingtalk/skills/dws/scripts/todo_batch_create.py +++ b/plugins/dingtalk/skills/dws/scripts/todo_batch_create.py @@ -30,6 +30,7 @@ from datetime import datetime from pathlib import Path from typing import List, Dict, Any, Optional +from dws_entry import command as dws_command ALLOWED_PRIORITIES = {10, 20, 30, 40} DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$') @@ -39,7 +40,7 @@ def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Dict[str, Any]]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return {'dry_run': True} diff --git a/plugins/dingtalk/skills/dws/scripts/todo_daily_summary.py b/plugins/dingtalk/skills/dws/scripts/todo_daily_summary.py index 3fb7538..350f627 100644 --- a/plugins/dingtalk/skills/dws/scripts/todo_daily_summary.py +++ b/plugins/dingtalk/skills/dws/scripts/todo_daily_summary.py @@ -15,6 +15,7 @@ import subprocess from datetime import datetime, timedelta from typing import List, Dict, Any, Optional +from dws_entry import command as dws_command PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'} PAGE_SIZE = 50 @@ -22,7 +23,7 @@ def run_dws(args: List[str], dry_run: bool = False) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/todo_overdue_check.py b/plugins/dingtalk/skills/dws/scripts/todo_overdue_check.py index f25471f..923382b 100644 --- a/plugins/dingtalk/skills/dws/scripts/todo_overdue_check.py +++ b/plugins/dingtalk/skills/dws/scripts/todo_overdue_check.py @@ -12,6 +12,7 @@ import subprocess from datetime import datetime from typing import List, Dict, Any, Optional +from dws_entry import command as dws_command PAGE_SIZE = 50 MAX_PAGES = 10 @@ -21,7 +22,7 @@ def run_dws( args: List[str], dry_run: bool = False, ) -> Optional[Any]: - cmd = ['dws'] + args + cmd = [*dws_command()] + args if dry_run: print(f"[dry-run] {' '.join(cmd)}") return None diff --git a/plugins/dingtalk/skills/dws/scripts/upload_attachment.py b/plugins/dingtalk/skills/dws/scripts/upload_attachment.py index 5b816a7..34339bd 100644 --- a/plugins/dingtalk/skills/dws/scripts/upload_attachment.py +++ b/plugins/dingtalk/skills/dws/scripts/upload_attachment.py @@ -28,6 +28,7 @@ from typing import Optional, Dict, Any from urllib.request import Request, urlopen from urllib.error import HTTPError, URLError +from dws_entry import command as dws_command RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$') MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB @@ -45,7 +46,7 @@ def detect_mime_type(file_path: Path) -> str: def run_dws(args: list) -> Optional[Dict[str, Any]]: """调用 dws 命令并返回解析后的 JSON 结果。""" - cmd = ['dws'] + args + cmd = [*dws_command()] + args try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: diff --git a/plugins/dingtalk/test_plugin.py b/plugins/dingtalk/test_plugin.py new file mode 100644 index 0000000..446d54e --- /dev/null +++ b/plugins/dingtalk/test_plugin.py @@ -0,0 +1,38 @@ +"""Run from the extracted release ZIP to exercise the shipped native entry.""" + +import importlib.util +import json +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +class PackagedPluginTests(unittest.TestCase): + def test_all_platforms_and_real_native_channel(self): + spec = importlib.util.spec_from_file_location( + "dws_packaged_verify", ROOT / ".wework-build/dws-auth/verify.py" + ) + verify = importlib.util.module_from_spec(spec) + spec.loader.exec_module(verify) + verify.verify_artifacts( + ROOT, + ( + "darwin/arm64", + "darwin/amd64", + "linux/amd64", + "linux/arm64", + "windows/amd64", + ), + ) + verify.verify_native_entry(ROOT) + + def test_cloud_authentication_is_declared(self): + manifest = json.loads((ROOT / ".codex-plugin/plugin.json").read_text()) + connector = next(c for c in manifest["connectors"] if c["slug"] == "dingtalk") + self.assertEqual(connector["accountAuth"]["exportMode"], "exclusive") + self.assertEqual(connector["accountAuth"]["adapter"], "scripts/account-auth.py") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dws_account_entry.py b/tests/test_dws_account_entry.py new file mode 100644 index 0000000..14bbd19 --- /dev/null +++ b/tests/test_dws_account_entry.py @@ -0,0 +1,235 @@ +import hashlib +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] / "plugins/dingtalk" +sys.path.insert(0, str(ROOT / "scripts")) +import dws + + +class PublicEntryTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + capabilities = self.home / "capabilities" + capabilities.mkdir() + (capabilities / "manifest.json").write_text( + json.dumps( + { + "plugins": { + "dingtalk": { + "managed": True, + "enabled": True, + "store_path": str(ROOT), + "installed_plugin_id": 41, + } + } + } + ) + ) + self.calls = [] + self.reply = {"stdout": '{"result":"business-result"}'} + self.status = 200 + fixture = self + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + fixture.calls.append( + ( + self.path, + json.loads( + self.rfile.read(int(self.headers["Content-Length"])) + ), + ) + ) + self.send_response(fixture.status) + self.end_headers() + self.wfile.write(json.dumps(fixture.reply).encode()) + + def log_message(self, *args): + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.addCleanup(self.server.server_close) + self.addCleanup(self.server.shutdown) + self.environment = { + **os.environ, + "HOME": str(self.home), + "USERPROFILE": str(self.home), + "DWS_CONFIG_DIR": str(self.home / ".dws"), + "WEGENT_EXECUTOR_HOME": str(self.home), + "WEGENT_PLUGIN_AUTH_MODE": "cloud", + "WEGENT_PLUGIN_AUTH_BROKER": f"http://127.0.0.1:{self.server.server_port}/v1/run", + "WEGENT_PLUGIN_AUTH_BROKER_TOKEN": "a" * 64, + } + self.environment.pop("WEGENT_DWS_RESOLVING", None) + + def invoke(self, args, environment=None): + return subprocess.run( + [sys.executable, str(ROOT / "scripts/dws.py"), *args], + env=environment or self.environment, + text=True, + capture_output=True, + timeout=10, + ) + + def test_cloud_command_preserves_arguments_and_only_returns_business_output(self): + arguments = [ + "todo", + "task", + "get", + "--task-id", + "value & echo unwanted", + "--account-id", + "corp:alice", + ] + result = self.invoke(arguments) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), {"result": "business-result"}) + path, body = self.calls[0] + self.assertEqual(path, "/v1/run") + self.assertEqual(body["args"], arguments[:-2]) + self.assertEqual(body["account_id"], "corp:alice") + self.assertEqual(body["installed_plugin_id"], 41) + self.assertEqual( + set(body), + { + "installed_plugin_id", + "connector_slug", + "account_id", + "args", + "working_directory", + }, + ) + + def test_cloud_failures_never_start_local_install_or_login(self): + self.status = 400 + self.reply = {"error": "plugin_auth_device_not_granted"} + result = self.invoke(["todo", "task", "get"]) + self.assertEqual(result.returncode, 1) + self.assertIn("plugin_auth_device_not_granted", result.stdout) + self.assertFalse((self.home / ".dws").exists()) + environment = { + **self.environment, + "WEGENT_PLUGIN_AUTH_BROKER": "https://untrusted.invalid/v1/run", + } + result = self.invoke(["auth", "login"], environment) + self.assertEqual(result.returncode, 1) + self.assertNotIn("untrusted.invalid", result.stdout + result.stderr) + self.assertEqual(len(self.calls), 1) + + def test_managed_readiness_checks_the_platform_without_starting_local_auth(self): + result = self.invoke(["plugin-health"]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.calls[0][1]["args"], ["account-status"]) + self.assertFalse((self.home / ".dws").exists()) + + def test_detached_local_account_uses_the_broker_without_source_credentials(self): + receipts = self.home / ".dws/wegent-transfers" + receipts.mkdir(parents=True) + (receipts / ("b" * 64 + ".json")).write_text( + json.dumps({"version": 1, "state": "detached", "fingerprint": "c" * 64}) + ) + result = self.invoke( + ["todo", "task", "get"], + {**self.environment, "WEGENT_PLUGIN_AUTH_MODE": "local"}, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(self.calls), 1) + + def test_unmigrated_local_account_keeps_the_existing_cli(self): + with patch.dict( + os.environ, + {**self.environment, "WEGENT_PLUGIN_AUTH_MODE": "local"}, + clear=True, + ), patch.object(dws, "run_local", return_value=7) as local: + self.assertEqual(dws.main(["todo", "task", "get"]), 7) + local.assert_called_once_with(["todo", "task", "get"], None) + self.assertEqual(self.calls, []) + + def test_aborted_transfer_does_not_mark_the_new_source_as_managed(self): + receipts = self.home / ".dws/wegent-transfers" + receipts.mkdir(parents=True) + (receipts / ("b" * 64 + ".json")).write_text( + json.dumps({"version": 1, "state": "aborted", "fingerprint": "c" * 64}) + ) + with patch.dict( + os.environ, + {**self.environment, "WEGENT_PLUGIN_AUTH_MODE": "local"}, + clear=True, + ), patch.object(dws, "run_local", return_value=0) as local: + self.assertEqual(dws.main(["todo", "task", "get"]), 0) + local.assert_called_once() + self.assertEqual(self.calls, []) + + def test_local_binary_override_cannot_bypass_a_cloud_grant(self): + result = self.invoke(["--local-binary", "/untrusted/dws", "todo", "get"]) + self.assertEqual(result.returncode, 1) + self.assertIn("plugin_auth_invalid_command", result.stdout) + self.assertEqual(self.calls, []) + + def test_account_selection_rejects_duplicates_and_empty_values(self): + for arguments in ( + ["--account-id"], + ["--account-id="], + ["--account-id=a", "--account-id=b"], + ): + self.assertEqual(self.invoke(arguments).returncode, 1) + self.assertEqual(self.calls, []) + + def test_helper_subprocess_uses_the_same_public_entry(self): + helper = ROOT / "skills/dws/scripts/todo_batch_create.py" + code = "import sys;sys.path.insert(0,sys.argv[1]);from todo_batch_create import run_dws;assert run_dws(['todo','task','get']) == {'result':'business-result'}" + result = subprocess.run( + [sys.executable, "-c", code, str(helper.parent)], + env=self.environment, + capture_output=True, + text=True, + timeout=10, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.calls[0][1]["connector_slug"], "dingtalk") + + def test_native_adapter_requires_matching_artifact_metadata(self): + spec = importlib.util.spec_from_file_location( + "dws_native_adapter", ROOT / "scripts/account-auth.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + scripts = self.home / "plugin/scripts" + directory = scripts / "native/linux-amd64" + directory.mkdir(parents=True) + binary = directory / "dws-account-auth" + binary.write_bytes(b"synthetic-binary") + metadata = binary.with_name(binary.name + ".json") + value = { + "nativeProtocolVersion": 1, + "target": "linux/amd64", + "binarySha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + } + metadata.write_text(json.dumps(value)) + with patch.object( + module, "__file__", str(scripts / "account-auth.py") + ), patch.object(module.platform, "system", return_value="Linux"), patch.object( + module.platform, "machine", return_value="x86_64" + ): + self.assertEqual(module.companion(), binary.resolve()) + binary.write_bytes(b"changed-binary") + with self.assertRaises(ValueError): + module.companion() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dws_download_source.py b/tests/test_dws_download_source.py new file mode 100644 index 0000000..d7ad04b --- /dev/null +++ b/tests/test_dws_download_source.py @@ -0,0 +1,68 @@ +"""Execute the installer resolver without network requests or user installation.""" + +import os +import shutil +import subprocess +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "plugins/dingtalk/scripts/install-dws.sh" + + +class DwsDownloadSourceTests(unittest.TestCase): + def resolve(self, mirror, system="Linux", arch="x86_64"): + shell = shutil.which("sh") + self.assertIsNotNone(shell, "A POSIX shell is required for installer tests") + functions = SCRIPT.read_text().split("\nif DWS_EXECUTABLE=", 1)[0] + harness = functions + ''' +uname() { + if [ "$1" = "-s" ]; then printf '%s\\n' "$TEST_SYSTEM"; + else printf '%s\\n' "$TEST_ARCH"; fi +} +resolve_release +printf '%s\\n' "$DWS_ARCHIVE_URL" "$DWS_EXPECTED_SHA" +''' + environment = dict(os.environ, TEST_SYSTEM=system, TEST_ARCH=arch) + environment.pop("DWS_DOWNLOAD_BASE_URL", None) + if mirror is not None: + environment["DWS_DOWNLOAD_BASE_URL"] = mirror + return subprocess.run([shell], input=harness, text=True, capture_output=True, + env=environment, timeout=10) + + def test_mirror_changes_only_source_and_preserves_all_pinned_checksums(self): + for system, arch, asset in ( + ("Linux", "x86_64", "linux-amd64"), + ("Linux", "aarch64", "linux-arm64"), + ("Darwin", "x86_64", "darwin-amd64"), + ("Darwin", "arm64", "darwin-arm64"), + ): + with self.subTest(system=system, arch=arch): + upstream = self.resolve(None, system, arch) + mirror = self.resolve("https://artifacts.example.test/dws/", system, arch) + self.assertEqual(upstream.returncode, 0, upstream.stderr) + self.assertEqual(mirror.returncode, 0, mirror.stderr) + url, checksum = mirror.stdout.splitlines() + self.assertEqual(url, f"https://artifacts.example.test/dws/v1.0.58/dws-{asset}.tar.gz") + self.assertEqual(checksum, upstream.stdout.splitlines()[1]) + + def test_invalid_sources_fail_without_falling_back_to_github(self): + for source in ("http://mirror.test", "https://user:password@mirror.test", + "https://mirror.test?token=value", "https://mirror.test#fragment", + "https://mirror.test\nhttps://other.test", "https://mirror.test\n", + "https://mirror.test/$(command)"): + with self.subTest(source=source): + result = self.resolve(source) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("github.com", result.stdout) + self.assertNotIn("password", result.stderr) + + def test_unconfigured_source_remains_the_pinned_upstream_release(self): + result = self.resolve(None) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines()[0], + "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz") + + +if __name__ == "__main__": + unittest.main()