From 706695f01c203baab760fa3679b1ff4be9caa5ab Mon Sep 17 00:00:00 2001 From: junlong5 Date: Tue, 29 Sep 2026 15:46:45 +0800 Subject: [PATCH 1/2] fix(dingtalk): preserve local auth and sync access-only cloud snapshots --- README.md | 15 +- docs/dingtalk-auth-wework-0.5.4-design.md | 174 ++++++++++++++++++ plugins/dingtalk/.codex-plugin/plugin.json | 17 +- .../.wework-build/account-auth-build.json | 22 ++- .../dingtalk/.wework-build/dws-auth/README.md | 138 +++++--------- .../dws-auth/auth-overlay/wegent_snapshot.go | 46 +++++ .../auth-overlay/wegent_snapshot_test.go | 126 +++++++++++++ .../dingtalk/.wework-build/dws-auth/build.py | 2 +- .../.wework-build/dws-auth/overlay/main.go | 8 +- .../dws-auth/overlay/provider.go | 165 +++++++---------- .../dws-auth/overlay/provider_test.go | 129 ++++++------- .../.wework-build/dws-auth/package.py | 20 +- .../dws-auth/tests/test_package.py | 27 ++- .../dingtalk/.wework-build/dws-auth/vendor.py | 6 +- .../dingtalk/.wework-build/dws-auth/verify.py | 13 +- plugins/dingtalk/scripts/dws.py | 55 ++---- plugins/dingtalk/skills/dws/SKILL.md | 24 ++- plugins/dingtalk/test_plugin.py | 9 +- tests/test_dws_account_entry.py | 64 ++++++- 19 files changed, 704 insertions(+), 356 deletions(-) create mode 100644 docs/dingtalk-auth-wework-0.5.4-design.md create mode 100644 plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_snapshot.go create mode 100644 plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_snapshot_test.go diff --git a/README.md b/README.md index 1cb335a..4db978a 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ PowerShell script and runs the native Windows authorization/exit-code tests on ## 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. +DingTalk 0.3.4 declares its build in `plugins/dingtalk/.wework-build.json` and keeps +all reviewed build 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. @@ -60,12 +60,21 @@ uv run --no-project --python 3.12 python plugins/dingtalk/.wework-build/dws-auth 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 access-only adapter is maintained in this plugin repository; do not overwrite +it with the older exclusive-migration SDK from Wegent. Refresh the local inventory +with `plugins/dingtalk/.wework-build/dws-auth/vendor.py .` after changes. 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. +0.3.4 keeps local DWS as the sole refresh owner and syncs access-only snapshots +through WeWork 0.5.4. It does not require a new desktop release. This is not an +unconditional auto-upgrade for 0.3.3 users: retire any existing exclusive cloud +connection while the old definition is still installed, then restore local login +and install the new plugin. Do not delete handoff receipts or silently leave the +old cloud refresh owner running. See the [design and release gates](docs/dingtalk-auth-wework-0.5.4-design.md). + ## Adding a plugin 1. Create `plugins//` with a valid `.codex-plugin/plugin.json`. diff --git a/docs/dingtalk-auth-wework-0.5.4-design.md b/docs/dingtalk-auth-wework-0.5.4-design.md new file mode 100644 index 0000000..97fcee9 --- /dev/null +++ b/docs/dingtalk-auth-wework-0.5.4-design.md @@ -0,0 +1,174 @@ +--- +sidebar_position: 1 +--- + +# 钉钉插件认证方案:一次授权、本地刷新、云端快照 + +状态:插件 0.3.4 已实现并完成本地合成回归及五平台预编译;未发布,真实 WeWork 0.5.4 端到端验收待完成。 +基线:Wegent `wework-v0.5.4`,提交 `273372aa125bcc274bf4208a37c1ff71ee14b3f3`;钉钉插件 0.3.3;companion 使用 DWS v1.0.58。 + +## 已确定的产品行为 + +用户只授权一次 DWS。本地保留完整凭据及刷新权,云端只获得短期 access token 快照。云端凭据过期后停止执行,等本地刷新、重新同步后恢复;不要求云端自行刷新,也不要求本地关机后云端长期运行。 + +这替代此前的双授权提案,不引入第二次钉钉授权。 + +- 本地正常调用不依赖 WeWork Backend 或认证 Broker;仍需能访问钉钉服务。 +- 同步失败不能删除、迁移、覆盖本地凭据,也不能让本地登录失败。 +- 本地 refresh token 仍有效时自动续期,不要求再次扫码;确实过期或被撤销时才重新授权。 +- 云端“等待同步”和“本地未登录”是不同状态,不能统一显示为登录失效。 + +## 数据流 + +```mermaid +flowchart LR + A[用户授权一次 DWS] --> B[本机安全存储完整凭据] + B --> C[本地 DWS 命令及刷新] + B --> D[本机原生适配器检查并刷新] + D --> E[只导出 access token 和有效期] + E --> F[Backend 加密保存快照] + F --> G[云端校验有效期并执行] + G --> H[过期则等待本地重新同步] +``` + +本地是唯一刷新所有者。云端不会获得 refresh token、persistent_code、client_secret 或其他可延长授权的字段。没有 detach,不产生新的独占交接记录。 + +## 0.5.4 已核实的可复用能力 + +| 能力 | 固定 tag 的实现 | +| --- | --- | +| 短期凭据载体 | schema 和 SDK 支持 `credentialType: bearer`,要求非空 `token` | +| 非独占登记 | 省略 `exportMode` 时 migration 调用 enroll,不调用 detach | +| 自动同步 | 本地 automation 调用 `plugin.auth.automatic`,Backend 扫描具有 accountAuth 的已安装连接 | +| 更新快照 | PluginAuthSourceSync 对导出内容做 HMAC 观察,变化时更新连接 | +| 云端授权与执行 | 复用已有设备授权、加密存储、Broker 和私有原生 adapter 通道 | +| 周期 | Broker 通常每 15 秒协调一次;同一插件/连接完成一次导出后有约 60 秒冷却,还受批次与网络影响,不保证 15 秒内同步 | + +以上针对源码基线。部署端 Backend、执行器、包版本及自动同步开关仍须实际验证。 + +## 连接声明与本地登录隔离 + +内部区分本地授权连接和云端快照连接,用户只进行一次本地授权。 + +| 内部连接 | 声明与用途 | +| --- | --- | +| dingtalk-local | localAuth、on_install,不声明 accountAuth;本地登录、health、退出不调用托管生命周期 | +| dingtalk-access | accountAuth、bearer、optional;没有第二个 localAuth 登录流程 | + +快照连接的声明示意: + +```json +{ + "slug": "dingtalk-access", + "authPolicy": "optional", + "accountAuth": { + "protocolVersion": 1, + "credentialType": "bearer", + "adapter": "scripts/account-auth.py" + } +} +``` + +实现时保留必要的非秘密本地配置声明;不声明 oauth2 或 exportMode,不存在 exportMode: copy 这个 0.5.4 字段值。 + +分开声明的原因:0.5.4 对带 accountAuth 的连接,会在本地浏览器登录完成后调用 Backend 托管生命周期;调用失败可能把成功的本地登录显示为错误。纯本地连接避开这一耦合。云端连接自动导出同一份本地授权的访问快照,不要求用户再次扫码。 + +0.5.4 界面可能显示两项内部连接;不能假设插件能隐藏任意卡片。须验证快照连接不弹第二个登录框、不阻断本地安装或使用。通过插件现有描述和状态输出解释同步状态,不依赖客户端新 UI。 + +## 本地刷新与导出 + +原生 adapter 的 export 必须: + +1. 在已选企业/用户上下文读取 DWS 官方安全存储,复用官方锁与刷新实现。 +2. 检查 access token;需要时用本地 refresh token 静默刷新,正常写回本地存储。不能只读过期 token 后反复上传。 +3. 构造严格白名单快照,通过已有私有通道交给宿主,由宿主提交 Backend;秘密不经过 stdout、argv、普通环境变量或模型上下文。 +4. 无登录、钥匙串锁定、刷新失败时返回固定错误,不启动交互登录、不 reset、不清空账号。同步错误不改变本地认证状态。 + +快照字段为 token、绝对时间 expires_at、corp_id、user_id,以及确认确有业务必要的非秘密 client_id/source。身份不可由调用者任意覆盖。禁止先序列化完整 TokenData 再删几个字段,避免新增秘密字段漏出。 + +后台协调启动及恢复连接后,通过现有周期同步;本地在线时无需用户执行业务命令也能刷新。这里“在线”要求 WeWork 本地 executor 正常运行,且能连接平台和钉钉;只有电脑开机或在终端运行 DWS 不足以自动上传。 + +正常刷新允许更新本地存储,但上传成功不能成为本地保存刷新结果的条件。上传失败时,本地仍使用已经刷新的结果。 + +导出期间固定身份,不随默认账号中途切换。现有 Backend 有连接锁和内容变化观察,但没有快照时间单调比较,不能声称已消除所有跨设备旧快照覆盖;须验证乱序、过期拒绝及后续恢复,必要时由 Backend 补新旧比较。 + +## 云端执行与过期 + +使用 bearer 表示只托管短期访问凭据。不能仍声明完整 OAuth 刷新流程却不给 refresh token,导致平台尝试刷新后把连接判为异常。 + +0.5.4 的 business_credential() 对非 OAuth 类型直接返回凭据,**不会替 bearer 校验 expires_at**。本次云端 adapter 必须自行检查有效期和身份。 + +- run 先校验有效期、身份、允许的命令和字段;拒绝刷新凭据字段。 +- 到期前保留 30 秒时钟误差余量,已覆盖过期和临近过期合成测试。 +- 有效时沿用 DWS edition 的内存 TokenProvider 执行;禁用本地凭据回退、自动刷新和交互登录。 +- 过期时在任何业务请求之前返回 dingtalk_cloud_token_expired 业务结果,提示“云端认证已过期,请打开本地 WeWork,等待 DWS 刷新同步”。这是插件新增的业务结果,不是 0.5.4 已有宿主错误码。 +- 现有 SDK 会将部分 adapter 异常压成通用错误,预期过期状态应以受控、非秘密业务 JSON 输出并由插件入口解析,不依赖修改客户端错误白名单。 +- 提供方可能提前拒绝访问凭据;不能把所有授权错误或网络错误都解释为到期。 +- 失败时不跨到本地模式自动重放写操作。 + +过期快照不应把连接永久置为 disconnected,要保留后续自动同步更新路径。0.5.4 原生 UI 可能仍显示已连接,通过业务状态表达“等待同步”,不能宣称原生 UI 已支持新状态。 + +## 本地命令与退出 + +本地默认直接使用 DWS,不因旧 wegent-transfers receipt 存在就强制进入 Broker。旧 receipt 保留用于迁移审计/恢复,不作为新本地登录的全局路由开关。 + +显式 cloud 模式走快照连接;缺 Broker 环境时报环境未就绪,不启动登录。未知模式值拒绝执行。 +空模式兼容既有终端本地入口;依赖宿主为云端设置 WEGENT_PLUGIN_AUTH_MODE=cloud, +不能声称在宿主连模式标记都未提供时仍能自动识别云端。 + +停止云端共享使用平台支持的断开/禁用同步流程,不能只停止上传,否则已有快照可能在到期前仍可使用。断开 bearer 连接不调用钉钉全局 OAuth revoke,不主动撤销本地授权。 + +本地退出后停止导出,但已上传快照不一定立即消失。若要求“退出本地即停止云端”,须同时断开云端连接;离线退出无法承诺云端即时失效,需要明确到期上限。全局撤销仍遵循钉钉实际语义。 + +## 0.3.3 升级处理 + +新的 bearer 定义不能直接替换旧 oauth2 定义并宣称无损兼容。0.5.4 精确比较 authDefinition,新快照采用新的 connector slug。 + +- 尚未交接的用户:保留本地凭据,开始非破坏性快照同步。 +- 已 detached 用户:本地刷新凭据已经被交走,升级不能找回。先通过支持的旧连接管理流程停止旧托管刷新、处理撤销和在途交接,再重新授权一次本地 DWS,之后只同步快照。 +- 旧连接撤销完成或明确进入人工恢复流程后再重新授权,避免延迟撤销影响新授权;不能删 receipt 假装恢复。 +- prepared、旧进程仍在 export/detach、新登录与旧回执并发:按原协议对齐或安全中止,不能删除新凭据。 +- 旧连接要在旧定义仍可验证时退役;删定义后可能无法完成旧管理/撤销流程。若需要 Backend 协调迁移或暂停旧包同步,这是发布前置事项,不承诺纯插件覆盖所有历史状态。 + +已受影响用户的一次恢复授权,不属于新方案的双授权流程。新安装用户只授权一次。 + +## 实施与验收 + +修改范围包括 manifest、快照原生 adapter、本地/云端路由、辅助脚本统一入口、预编译制品及定向回归。现有本地登录尽量保持官方 DWS 行为,无需用户配置 Broker Token 或运行凭据复制命令。 + +发布前验证: + +1. 固定 WeWork 0.5.4 安装包和实际 managed runtime,覆盖旧任务、新任务、重启和三种 OS。 +2. 一次授权后本地、云端可执行;快照不含刷新秘密,默认 DWS 凭据不被删除。 +3. 本地不执行业务命令,后台也能完成真实刷新及新快照同步。 +4. 关闭本地,云端到期后明确等待同步且不刷新;重新打开本地后无需扫码恢复云端。 +5. Broker 缺变量、Backend 断网、钥匙串锁定、上传失败不破坏本地凭据及刷新结果。 +6. 账号、有效期、内容变化、云端消费闭环一致;覆盖重复、并发、乱序和跨账号隔离。 +7. 0.5.4 界面只要求一次授权,云端同步失败不把本地登录判为失败。 +8. 缺有效期、提前失效、字段越权、时钟误差和长请求到期均有明确行为,写操作不自动重放。 +9. 0.2.9 升级和 0.3.3 已交接/交接中用户有序恢复,旧云端刷新所有者已退役。 + +### 已完成的本地验证(2026-09-29) + +- Python:公共入口/兼容性 29 项(28 通过,1 项因无 PowerShell 跳过)、打包与库存 18 项、共享 SDK 23 项。 +- Go:隔离存储、并发刷新及失败保留、快照白名单/有效期、真实 DWS 合成业务、共享原生通道测试通过。 +- 新声明通过固定 wework-v0.5.4 的 PluginAccountAuthDefinition schema 校验。 +- 固定 Go 1.25.9 构建五个目标;macOS arm64 运行包内真实私有通道,覆盖正常状态、过期快照、拒绝错误连接与刷新材料。 +- 构建源库存与 git diff --check 通过;没有修改 WeWork 源码或用户凭据。 + +测试包:.ci-artifacts/dingtalk-account-auth-0.3.4.zip(50,677,151 字节)。 +SHA-256:348d79dd20308f5d4ae50812c2528bd5af6d272c836dcaa015624d840a584419。 + +没有执行真实账号登录、刷新、撤销、迁移或云端任务;尚未发布插件,分支或 PR 提交不代表上线。 +Windows/Linux 本次仅交叉编译,不能代替真机运行;两项内部连接的 UI、周期同步、 +0.3.3 有序退役和账号切换/多设备乱序仍是发布门禁,测试包不能直接全量自动升级。 + +## 证据索引 + +- wework-v0.5.4 的 backend/app/schemas/plugin_account_auth.py:bearer 声明。 +- 同 tag 的 plugin_auth_migrations.py、plugin_auth_source_sync.py:字段校验及变化同步。 +- 同 tag 的 plugin_oauth_credentials.py:bearer 不经 OAuth 到期检查。 +- 同 tag 的 executor/src/plugin_account_auth/{automation,migration,execution}.rs:周期、非独占导出及执行。 +- 同 tag 的 executor/src/runtime_work/local_connector_auth.rs:本地登录与托管生命周期耦合。 +- 插件 .wework-build/plugin-auth-go/adapter.go:bearer 通道及通用错误边界。 +- [官方 DWS v1.0.58 刷新实现](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/v1.0.58/internal/auth/oauth_helpers.go):复用本地刷新,不推断跨端 refresh token 复用保证。 diff --git a/plugins/dingtalk/.codex-plugin/plugin.json b/plugins/dingtalk/.codex-plugin/plugin.json index c9e207c..2c8901b 100644 --- a/plugins/dingtalk/.codex-plugin/plugin.json +++ b/plugins/dingtalk/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dingtalk", - "version": "0.3.3", + "version": "0.3.4", "description": "通过 DWS 使用钉钉协作与办公能力。", "author": { "name": "Wegent" @@ -18,7 +18,7 @@ "skills": "./skills/", "connectors": [ { - "slug": "dingtalk", + "slug": "dingtalk-local", "authPolicy": "on_install", "localAuth": { "kind": "browser_oauth", @@ -40,16 +40,15 @@ "id": "dws", "source": "bundled" } - }, + } + }, + { + "slug": "dingtalk-access", + "authPolicy": "optional", "accountAuth": { "protocolVersion": 1, - "credentialType": "oauth2", + "credentialType": "bearer", "adapter": "scripts/account-auth.py", - "oauth2": [ - "refresh", - "revoke" - ], - "exportMode": "exclusive", "localEnvironment": { "DWS_CONFIG_DIR": { "type": "directory" diff --git a/plugins/dingtalk/.wework-build/account-auth-build.json b/plugins/dingtalk/.wework-build/account-auth-build.json index ebf4b50..d56eebc 100644 --- a/plugins/dingtalk/.wework-build/account-auth-build.json +++ b/plugins/dingtalk/.wework-build/account-auth-build.json @@ -1,25 +1,27 @@ { "schemaVersion": 1, - "source": "Wegent/sdk", + "source": "wework-plugins-public/plugins/dingtalk/.wework-build", "files": { "dws-auth/README.en.md": "d2444a6f3205d7c0a3e311531314d34d770f739907093a8cbc5b34e900231ae4", - "dws-auth/README.md": "939ba7cadf43782f54ded24b60b68ccb6a1b7a343c430af203818237201953af", + "dws-auth/README.md": "5a27eedd0b686d94aff7dbf472f8b45253f2e2d276dfe308674611643d44aac2", + "dws-auth/auth-overlay/wegent_snapshot.go": "1ccc1091024b08f1aa76275245a4fe674065bfb6e323200ffa8e871cbf03caea", + "dws-auth/auth-overlay/wegent_snapshot_test.go": "a9a6e9f22aad48ca83f032dafd8aba6cb528806564a78845df3f1dfbe35f1a05", "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/build.py": "666a3550cd1aa8a5bc8026f222e28f17b72a371608da88363f899e2c947444d8", "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/overlay/main.go": "0bf21bfc5e141d5792f5d960b6904e29e6c26ad1db650c82e11c10730901890e", + "dws-auth/overlay/provider.go": "bf53b273d7b06bf10e5320fca098afa6a8f794dbbafd5232b3b05e1f5e77ec48", + "dws-auth/overlay/provider_test.go": "64f8ebe1fcf9927d705fb82efdd4470f6310500da32d87668cb61c8beeb743f9", + "dws-auth/package.py": "bd1b4b5c7d31beabe31b4f6a70451029180642f779acc1b3572dda234dcd8182", + "dws-auth/tests/test_package.py": "36258af48fe9ae4c37807a9c76e42e69cbc8997b80e41c0eadc4371872d5d012", "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", + "dws-auth/vendor.py": "aabc9a3ce65796ff1778998a2d1cba6fa06dafd71c36978229ca3f20ec266468", + "dws-auth/verify.py": "6c95da699cca8a97331b1d5c8c5df40fd3fae4d227575854e72b55608c74edf7", "plugin-auth-go/LICENSE": "e7e430d6f6693d9e1fe09961d873d7c8f6938bfd57746d60d18b895c62b15760", "plugin-auth-go/README.en.md": "361c47abf41b0d68572abedbad7ee0b016c7a48284fec2b726b7d593dbbfcff7", "plugin-auth-go/README.md": "461633b5574774831ca63d581a2a130e53a91a3db89bf91e5bd8d45c8c5aaa06", diff --git a/plugins/dingtalk/.wework-build/dws-auth/README.md b/plugins/dingtalk/.wework-build/dws-auth/README.md index e5ba4e6..bbf88a6 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/README.md +++ b/plugins/dingtalk/.wework-build/dws-auth/README.md @@ -2,114 +2,64 @@ sidebar_position: 1 --- -# DWS 账号认证适配器 +# DWS 本地刷新与云端访问快照 -从 DWS `v1.0.58` 固定源码与 Wegent 扩展编译原生 companion。源码归档 SHA-256: +从 DWS `v1.0.58` 固定源码与插件内扩展编译原生 companion。源码归档 SHA-256: `f6b2dcf16b34492d7be25ce63fc81c7155d6a857af21c0604ae16bf4fa96f1e2`。 -`overlay/` 复用官方 `edition.Hooks`、OAuth Provider、撤销和业务命令; -`auth-overlay/` 在官方认证包中增加交接函数,使用同一把刷新锁与精确账号删除逻辑。 -共享通道来自 `../plugin-auth-go`,插件开发者无需处理套接字、nonce 或帧长度。 +共享私有通道来自 `../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 命令/刷新/撤销的合成服务测试,不使用个人钥匙串或真实钉钉账号。 - -## 唯一刷新权交接 +## 认证边界(0.3.4) -安装包通过通用 `accountAuth.localEnvironment` 声明 `DWS_CONFIG_DIR`、 -`DWS_KEYCHAIN_DIR` 目录和 `DWS_DISABLE_KEYCHAIN=1` 开关。宿主校验后仅向本机 -认证回调提供这些设置,Python SDK 入口再显式交给官方适配器,确保迁移读取用户 -实际使用的源存储。未配置时保留官方默认行为;目录必须已存在且为绝对路径。 -这些配置不上传后端,也不传给云端业务、刷新和撤销回调。 +- `dingtalk-local` 只有本地浏览器登录,正常业务直接调用本地 DWS,不依赖 Broker。 +- `dingtalk-access` 是可选 bearer 连接,只通过私有通道导出访问快照,无第二次 OAuth。 +- 不声明 `exportMode` 或 `oauth2`,不注册 detach、refresh、revoke。 +- 唯一白名单为 token、expires_at、corp_id、user_id、source、client_id。 + 不序列化完整 TokenData,refresh token、persistent code 等永不上传。 +- 本机使用官方双层锁、刷新前持久化预检和刷新保存逻辑。刷新结果先保存到本机, + 与后续 Backend 上传成败无关;同步失败不删除凭据或标记 profile 失效。 +- 云端拒绝未知/私密字段和无效到期时间;剩余有效期不超过 30 秒时返回 + `dingtalk_cloud_token_expired` 业务 JSON,不调用上游、不刷新、不回退本地。 + 公共包装器将此结果转换为非零退出码,辅助脚本不能把它当成成功。 +- 业务通过官方 edition.Hooks 的只读内存 TokenProvider 执行,隔离本机配置和钥匙串。 + 限制仍只支持官方 MCP OAuth;自定义 mcp_url 与 direct client secret 不支持同步。 -插件声明必须设置 `accountAuth.exportMode: "exclusive"`: +`accountAuth.localEnvironment` 声明本机存储目录与钥匙串配置,经宿主校验后只提供给 +本机导出。令牌不放进 stdout、命令行参数、普通环境变量或聊天上下文。 +后台同步周期由 WeWork 0.5.4 控制,同一连接约 60 秒冷却,不承诺即时同步。 -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 +uv run --no-project python plugins/dingtalk/.wework-build/dws-auth/vendor.py . +uv run --no-project python plugins/dingtalk/.wework-build/dws-auth/vendor.py --check . +uv run --no-project python plugins/dingtalk/.wework-build/dws-auth/package.py --plugin plugins/dingtalk --output .ci-artifacts/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 登录。 +此目录现由插件仓库维护;不要用 Wegent 的旧独占迁移适配器覆盖它。 +可通过 `--source-archive` 提供已下载的固定哈希源码包。打包器准备固定 Go 工具链, +在临时副本生成 macOS arm64/amd64、Linux arm64/amd64 和 Windows amd64 原生文件, +检查来源/二进制哈希,并在宿主平台执行真实私有通道检查。 +Windows Python 启动器必须等待子进程并传播退出码,不能改回 os.execv。 -## CI 与官方分发 +`--test` 使用隔离的合成安全存储及 HTTP 服务,不读取用户钥匙串。覆盖本地保留、 +刷新落盘、并发刷新、刷新失败不破坏状态、云端过期拒绝、字段白名单和官方业务命令。 +旧 `wegent_transfer*` 源码及测试仅保留作历史协议回归;新可执行入口不注册交接操作。 -插件根目录的 `.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 -``` +产物含二进制、LICENSE、NOTICE、编译源哈希和平台信息。上传/解压限制仍为 +50 MiB / 200 MiB。源码不含用户状态,构建不得读取发布凭据。 -去掉 `--dry-run` 才会发布到当前 Backend 配置的市场。批量 seed 同样自动构建。 -已有制品的维护入口 `--prebuilt --sha256` 保留,但不是日常发布的必选步骤。 +## 升级与发布限制 -## 交付边界 +0.3.3 已交接用户不能无条件自动升级:在旧定义仍安装时,先通过平台支持的流程 +停止同步、退役旧托管连接并处理在途迁移/撤销,再恢复本地 DWS 登录,然后安装新包。 +否则旧刷新所有者可能残留,且新版本无法代替旧定义完成撤销。不得删除回执假装恢复。 +本地仍有凭据的 0.2.9 用户无需第二次 OAuth。 -当前适配器只接受默认钉钉 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。 -源码可构建不代表插件已发布或用户可直接使用。 +插件根目录 `.wework-build.json` 继续接入现有 package/test/release 流程; +本次未修改 WeWork 源码,也不自动提交、发布或操作真实连接。 +合成测试和交叉编译不代替 WeWork 0.5.4 真机 UI、系统钥匙串、周期同步及真实云端验收。 diff --git a/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_snapshot.go b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_snapshot.go new file mode 100644 index 0000000..7c72a90 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_snapshot.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +package auth + +import ( + "context" + "errors" + "io" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +var errWegentSnapshot = errors.New("plugin_auth_dws_snapshot_unavailable") + +// SnapshotWegentToken keeps the local store as the sole refresh owner. +// Unlike GetTokenSnapshot, a background export failure does not mark a profile +// expired. Use the same dual lock, persistence preflight and refresh routine as +// the CLI, but never login, detach, revoke or delete credentials here. +func SnapshotWegentToken(ctx context.Context, directory string) (*TokenData, error) { + if config.GetMCPBaseURL() != config.DefaultMCPBaseURL { + return nil, errWegentSnapshot + } + lock, err := AcquireDualLock(ctx, directory) + if err != nil { + return nil, errWegentSnapshot + } + defer lock.Release() + token, err := loadOAuthTokenUnderHeldLock(directory, RuntimeProfile()) + if err != nil || token == nil || token.CorpID == "" || token.UserID == "" || + token.Source != "mcp" || token.ClientID == "" || token.AccessToken == "" { + return nil, errWegentSnapshot + } + if token.IsAccessTokenValid() { + return token, nil + } + if !token.IsRefreshTokenValid() || preflightTokenRefreshPersistence(directory, token) != nil { + return nil, errWegentSnapshot + } + provider := NewOAuthProvider(directory, nil) + provider.Output = io.Discard + refreshed, err := provider.refreshWithRefreshToken(ctx, token) + if err != nil || refreshed == nil || refreshed.CorpID != token.CorpID || + refreshed.UserID != token.UserID || !refreshed.IsAccessTokenValid() { + return nil, errWegentSnapshot + } + return refreshed, nil +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_snapshot_test.go b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_snapshot_test.go new file mode 100644 index 0000000..aa51736 --- /dev/null +++ b/plugins/dingtalk/.wework-build/dws-auth/auth-overlay/wegent_snapshot_test.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +package auth + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +type wegentSnapshotTransport func(*http.Request) (*http.Response, error) + +func (f wegentSnapshotTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func mockWegentSnapshotHTTP(t *testing.T, f wegentSnapshotTransport) { + t.Helper() + old := oauthHTTPClient + oauthHTTPClient = &http.Client{Transport: f} + t.Cleanup(func() { oauthHTTPClient = old }) +} + +func TestWegentSnapshotKeepsValidLocalGrantAndNeverDetaches(t *testing.T) { + directory, token := wegentTransferFixture(t) + t.Setenv("DWS_CONFIG_DIR", directory) + mockWegentSnapshotHTTP(t, func(*http.Request) (*http.Response, error) { + t.Fatal("valid snapshot made a network request") + return nil, errors.New("unexpected network") + }) + for i := 0; i < 2; i++ { + got, err := SnapshotWegentToken(context.Background(), directory) + if err != nil || got.RefreshToken != token.RefreshToken { + t.Fatal("snapshot changed grant") + } + } + got, err := LoadTokenData(directory) + if err != nil || got.AccessToken != token.AccessToken || got.PersistentCode != token.PersistentCode { + t.Fatal("local grant was detached") + } + if _, err := os.Stat(filepath.Join(directory, "wegent-transfers")); !os.IsNotExist(err) { + t.Fatal("snapshot created a handoff receipt") + } +} + +func TestWegentSnapshotRefreshPersistsLocallyAndConcurrentExportsReuseIt(t *testing.T) { + directory, token := wegentTransferFixture(t) + t.Setenv("DWS_CONFIG_DIR", directory) + token.ExpiresAt = time.Now().Add(-time.Minute) + if err := SaveTokenData(directory, token); err != nil { + t.Fatal(err) + } + calls := 0 + mockWegentSnapshotHTTP(t, func(request *http.Request) (*http.Response, error) { + calls++ + var body map[string]any + if request.URL.Path != "/oauth2/refreshToken" || json.NewDecoder(request.Body).Decode(&body) != nil || + body["refreshToken"] != "synthetic-refresh" || body["clientId"] != "synthetic-client" { + t.Error("wrong refresh request") + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader( + `{"accessToken":"synthetic-new-access","refreshToken":"synthetic-rotated","expiresIn":3600,"corpId":"synthetic-corp","userId":"alice"}`))}, nil + }) + var wait sync.WaitGroup + for i := 0; i < 4; i++ { + wait.Add(1) + go func() { + defer wait.Done() + got, err := SnapshotWegentToken(context.Background(), directory) + if err != nil || got.AccessToken != "synthetic-new-access" { + t.Error("snapshot refresh failed") + } + }() + } + wait.Wait() + if calls != 1 { + t.Fatalf("concurrent refresh happened %d times", calls) + } + // No backend is involved: upload failure cannot roll back the local rotation. + got, err := LoadTokenData(directory) + if err != nil || got.RefreshToken != "synthetic-rotated" || got.PersistentCode != token.PersistentCode { + t.Fatal("rotated refresh grant was not preserved locally") + } +} + +func TestWegentSnapshotRefreshFailureNeverChangesCredentialOrProfile(t *testing.T) { + for _, status := range []int{400, 401, 429, 503} { + t.Run(http.StatusText(status), func(t *testing.T) { + directory, token := wegentTransferFixture(t) + t.Setenv("DWS_CONFIG_DIR", directory) + token.ExpiresAt = time.Now().Add(-time.Minute) + if err := SaveTokenData(directory, token); err != nil { + t.Fatal(err) + } + before, err := LoadTokenData(directory) + if err != nil { + t.Fatal(err) + } + profiles, err := LoadProfiles(directory) + if err != nil { + t.Fatal(err) + } + mockWegentSnapshotHTTP(t, func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"synthetic-secret-must-not-escape"}`))}, nil + }) + if _, err := SnapshotWegentToken(context.Background(), directory); err != errWegentSnapshot { + t.Fatal("refresh failure escaped fixed error boundary") + } + after, err := LoadTokenData(directory) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatal("failed export changed grant") + } + nextProfiles, err := LoadProfiles(directory) + if err != nil || !reflect.DeepEqual(profiles, nextProfiles) { + t.Fatal("failed export changed profile status") + } + }) + } +} diff --git a/plugins/dingtalk/.wework-build/dws-auth/build.py b/plugins/dingtalk/.wework-build/dws-auth/build.py index ee2336a..eb199ce 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/build.py +++ b/plugins/dingtalk/.wework-build/dws-auth/build.py @@ -58,7 +58,7 @@ def main() -> None: shutil.copyfile(path, sdk / path.name) if args.test: subprocess.run( - ["go", "test", "./internal/auth", "-run", "^TestWegentTransfer"], + ["go", "test", "./internal/auth", "-run", "^TestWegent(Transfer|Snapshot)"], cwd=source_root, check=True, ) diff --git a/plugins/dingtalk/.wework-build/dws-auth/overlay/main.go b/plugins/dingtalk/.wework-build/dws-auth/overlay/main.go index 1a45772..41fdd1b 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/overlay/main.go +++ b/plugins/dingtalk/.wework-build/dws-auth/overlay/main.go @@ -11,10 +11,10 @@ import ( ) 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 { + // Only short-lived snapshots cross the private channel. There is deliberately + // no detach, refresh or revoke operation for the cloud connector. + provider := pluginauth.Provider{Export: export, Allowed: allowed, Run: run} + if err := pluginauth.Serve(context.Background(), "dingtalk-access", "bearer", 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 index 7837b2b..a6826a5 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/overlay/provider.go +++ b/plugins/dingtalk/.wework-build/dws-auth/overlay/provider.go @@ -6,11 +6,9 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" - "log/slog" + "math" "os" - "strconv" "strings" "time" @@ -24,30 +22,13 @@ import ( 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 == "" { + token, err := auth.SnapshotWegentToken(ctx, config.DefaultConfigDir()) + if err != nil { 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 @@ -84,56 +65,72 @@ func allowed(arguments []string) bool { return true } +// Keep this whitelist symmetric with encode: newly added upstream secret fields +// must never accidentally become part of the cloud credential. +var snapshotFields = map[string]bool{ + "token": true, "expires_at": true, "corp_id": true, "user_id": true, + "source": true, "client_id": 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) + for key := range credential { + if !snapshotFields[key] { + return nil, denied } } - if private, ok := value["provider_private"].(map[string]any); ok { - value["persistent_code"] = private["persistent_code"] + text := func(key string) string { + value, _ := credential[key].(string) + return value } - encoded, err := json.Marshal(value) - if err != nil { + var seconds float64 + switch value := credential["expires_at"].(type) { + case json.Number: + integer, err := value.Int64() + if err != nil { + return nil, denied + } + seconds = float64(integer) + case float64: + seconds = value + case int64: + seconds = float64(value) + case int: + seconds = float64(value) + default: return nil, denied } - var token auth.TokenData - if json.Unmarshal(encoded, &token) != nil || token.AccessToken == "" || token.CorpID == "" || token.UserID == "" { + if math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds <= 0 || + seconds > 253402300799 || seconds != math.Trunc(seconds) { 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 == "" { + token := &auth.TokenData{ + AccessToken: text("token"), ExpiresAt: time.Unix(int64(seconds), 0), + CorpID: text("corp_id"), UserID: text("user_id"), + Source: text("source"), ClientID: text("client_id"), + } + if strings.TrimSpace(token.AccessToken) == "" || strings.TrimSpace(token.CorpID) == "" || + strings.TrimSpace(token.UserID) == "" || token.Source != "mcp" || strings.TrimSpace(token.ClientID) == "" { return nil, denied } - return &token, nil + return token, nil } func encode(token *auth.TokenData) (pluginauth.Account, error) { - data, err := json.Marshal(token) - if err != nil { + if token == nil { return pluginauth.Account{}, denied } - var value pluginauth.Credential - if json.Unmarshal(data, &value) != nil { + value := pluginauth.Credential{ + "token": token.AccessToken, "expires_at": token.ExpiresAt.Unix(), + "corp_id": token.CorpID, "user_id": token.UserID, + "source": token.Source, "client_id": token.ClientID, + } + if _, err := decode(value); err != nil || !token.ExpiresAt.After(time.Now().Add(30*time.Second)) { 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 { +func withMemory(token *auth.TokenData, callback func(string) error) error { directory, err := os.MkdirTemp("", "wegent-dws-native-") if err != nil { return denied @@ -156,75 +153,43 @@ func withMemory(token *auth.TokenData, writable bool, callback func(string) erro 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.SaveToken = func(string, []byte) error { return denied } 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 } + hooks.TokenProvider = func(context.Context, func() (string, error)) (string, error) { + if !token.ExpiresAt.After(time.Now().Add(30 * time.Second)) { + return "", denied + } + 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 } + if !token.ExpiresAt.After(time.Now().Add(30 * time.Second)) { + // A controlled business result leaves the host connection available for + // the next source-sync update. No network call or refresh is attempted. + return json.NewEncoder(os.Stdout).Encode(map[string]any{ + "success": false, "authenticated": false, + "error": "dingtalk_cloud_token_expired", + "message": "云端认证已过期,请打开本地 WeWork,等待 DWS 刷新同步;本地使用不受影响。", + }) + } // 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 { + return withMemory(token, func(string) error { output, err := capture(func() int { original := os.Args os.Args = append([]string{"dws"}, arguments...) diff --git a/plugins/dingtalk/.wework-build/dws-auth/overlay/provider_test.go b/plugins/dingtalk/.wework-build/dws-auth/overlay/provider_test.go index 2759aa1..6082ee7 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/overlay/provider_test.go +++ b/plugins/dingtalk/.wework-build/dws-auth/overlay/provider_test.go @@ -4,11 +4,12 @@ package main import ( "context" "encoding/json" - "io" + "math" "net/http" "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -20,36 +21,79 @@ import ( ) 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"} + // The real private transport uses Decoder.UseNumber, not float64. + return pluginauth.Credential{"token": "synthetic-old-access", "expires_at": json.Number(strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)), + "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) +func TestSnapshotWhitelistNeverExportsPrivateGrants(t *testing.T) { + token, err := decode(fixture()) if err != nil { t.Fatal(err) } - if token.PersistentCode != "synthetic-persistent" { - t.Fatal("missing provider private data") - } + token.RefreshToken, token.PersistentCode = "synthetic-refresh", "synthetic-private" + token.RefreshExpAt = time.Now().Add(24 * time.Hour) account, err := encode(token) - if err != nil || account.ID != "corp:alice" { - t.Fatal("wrong account") + if err != nil || account.ID != "corp:alice" || len(account.Credential) != 6 { + t.Fatal("wrong snapshot") + } + data, _ := json.Marshal(account.Credential) + if strings.Contains(string(data), "synthetic-refresh") || strings.Contains(string(data), "synthetic-private") { + t.Fatal("export leaked a renewable credential") + } + for _, key := range []string{"refresh_token", "provider_private", "persistent_code", "client_secret", "access_token", "unexpected"} { + value := fixture() + value[key] = "synthetic-private" + if _, err := decode(value); err == nil { + t.Fatalf("accepted private/unknown field %s", key) + } } - if _, ok := account.Credential["persistent_code"]; ok { - t.Fatal("private code escaped private fields") +} + +func TestSnapshotRequiresIdentityAndFiniteIntegerExpiry(t *testing.T) { + for _, key := range []string{"token", "corp_id", "user_id", "client_id", "source", "expires_at"} { + value := fixture() + delete(value, key) + if _, err := decode(value); err == nil { + t.Fatalf("accepted missing %s", key) + } } - delete(value, "user_id") - if _, err := decode(value); err == nil { - t.Fatal("accepted ambiguous account") + for _, expiry := range []any{nil, "123", true, 0, -1, math.NaN(), math.Inf(1), 1.5, 253402300800.0, json.Number("1.5"), json.Number("9223372036854775808")} { + value := fixture() + value["expires_at"] = expiry + if _, err := decode(value); err == nil { + t.Fatalf("accepted invalid expiry %v", expiry) + } + } +} + +func TestExpiredCloudSnapshotNeverTouchesNetworkOrLocalState(t *testing.T) { + original := http.DefaultTransport + http.DefaultTransport = roundTrip(func(*http.Request) (*http.Response, error) { + t.Fatal("expired snapshot contacted provider") + return nil, denied + }) + defer func() { http.DefaultTransport = original }() + for _, expiry := range []int64{time.Now().Add(-time.Minute).Unix(), time.Now().Add(20 * time.Second).Unix()} { + for _, args := range [][]string{{"account-status"}, {"todo", "task", "get", "--task-id", "synthetic"}} { + value := fixture() + value["expires_at"] = expiry + output, err := capture(func() int { + if run(context.Background(), value, args) != nil { + return 1 + } + return 0 + }) + var result map[string]any + if err != nil || json.Unmarshal(output, &result) != nil || result["error"] != "dingtalk_cloud_token_expired" { + t.Fatal("expiry was not a recoverable business result") + } + } } } 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 @@ -78,7 +122,7 @@ func TestMemoryHooksNeverPersistCredentialsOrFallBackToKeychain(t *testing.T) { if err != nil { t.Fatal(err) } - err = withMemory(token, false, func(directory string) error { + err = withMemory(token, func(directory string) error { if config.GetMCPBaseURL() != config.DefaultMCPBaseURL { t.Fatal("business credential inherited a custom token endpoint") } @@ -121,47 +165,6 @@ 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"}) { @@ -172,14 +175,15 @@ func TestBusinessDeniesAuthenticationAndPrivateGrantMaterial(t *testing.T) { t.Fatal("accepted auth control override") } } - if run(context.Background(), fixture(), []string{product, "--help"}) == nil { + value := fixture() + value["refresh_token"] = "synthetic-refresh" + if run(context.Background(), value, []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 @@ -241,7 +245,6 @@ func TestRealUpstreamBusinessCommandUsesOnlyTheSuppliedAccessToken(t *testing.T) 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 { diff --git a/plugins/dingtalk/.wework-build/dws-auth/package.py b/plugins/dingtalk/.wework-build/dws-auth/package.py index d09c83a..fa39436 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/package.py +++ b/plugins/dingtalk/.wework-build/dws-auth/package.py @@ -54,11 +54,21 @@ def assemble(plugin: Path, output: Path, source_archive: Path | None) -> None: 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") + connectors = {item["slug"]: item for item in manifest["connectors"]} + local = connectors.get("dingtalk-local", {}) + snapshot = connectors.get("dingtalk-access", {}) + account = snapshot.get("accountAuth", {}) + if ( + not local.get("localAuth") + or "accountAuth" in local + or snapshot.get("authPolicy") != "optional" + or "localAuth" in snapshot + or account.get("credentialType") != "bearer" + or account.get("adapter") != "scripts/account-auth.py" + or "exportMode" in account + or "oauth2" in account + ): + raise ValueError("DingTalk must separate local login and access-only snapshots") if (staged / "scripts/account-auth.py").read_bytes() != ( ROOT / "entry.py" ).read_bytes(): diff --git a/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py b/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py index f50505d..20d1b71 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py +++ b/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py @@ -31,7 +31,9 @@ def setUp(self): "name": "dingtalk", "description": "钉钉认证打包", "connectors": [ - {"slug": "dingtalk", "accountAuth": {"exportMode": "exclusive"}} + {"slug": "dingtalk-local", "localAuth": {"kind": "browser_oauth"}}, + {"slug": "dingtalk-access", "authPolicy": "optional", + "accountAuth": {"credentialType": "bearer", "adapter": "scripts/account-auth.py"}} ], }, ensure_ascii=False, @@ -86,7 +88,7 @@ def test_all_targets_are_checked_before_packaging_and_source_stays_unchanged(sel ) manifest = json.loads(archive.read(".codex-plugin/plugin.json")) self.assertEqual( - manifest["connectors"][0]["accountAuth"]["exportMode"], "exclusive" + manifest["connectors"][1]["accountAuth"]["credentialType"], "bearer" ) for target in package.TARGETS: prefix = "scripts/native/" + target.replace("/", "-") + "/" @@ -110,6 +112,23 @@ def legacy_read(path, *args, **kwargs): manifest = json.loads(archive.read(".codex-plugin/plugin.json")) self.assertEqual(manifest["description"], "钉钉认证打包") + def test_exclusive_or_login_coupled_manifest_is_rejected_before_build(self): + original = json.loads(self.manifest.read_text()) + for variant in ("exclusive", "oauth2", "coupled"): + manifest = json.loads(json.dumps(original)) + snapshot = manifest["connectors"][1]["accountAuth"] + if variant == "exclusive": + snapshot["exportMode"] = "exclusive" + elif variant == "oauth2": + snapshot["oauth2"] = ["refresh"] + else: + manifest["connectors"][0]["accountAuth"] = snapshot + self.manifest.write_text(json.dumps(manifest)) + with patch.object(package.subprocess, "run") as build: + with self.assertRaisesRegex(ValueError, "separate local login"): + package.assemble(self.plugin, self.output, None) + build.assert_not_called() + def test_symlinks_are_rejected_without_following_the_target(self): (self.plugin / "scripts/external").symlink_to( self.root / "missing-external-file" @@ -200,11 +219,13 @@ def invoke(plugin, home, credential, connector): (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 + accepted = connector == "dingtalk-access" and "refresh_token" not in credential payload = { "authenticated": True, "accountId": "synthetic-corp:synthetic-user", } + if credential.get("expires_at") == 1: + payload = {"error": "dingtalk_cloud_token_expired"} return verify.subprocess.CompletedProcess( [], 0 if accepted else 1, diff --git a/plugins/dingtalk/.wework-build/dws-auth/vendor.py b/plugins/dingtalk/.wework-build/dws-auth/vendor.py index 01db4e5..c9c8498 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/vendor.py +++ b/plugins/dingtalk/.wework-build/dws-auth/vendor.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Vendor the canonical build sources so plugin CI needs only its own checkout.""" +"""Inventory plugin-owned build sources derived from the Wegent SDK.""" import argparse import hashlib @@ -31,7 +31,7 @@ def bundle(repository: Path, *, check: bool = False) -> None: files = source_files() inventory = { "schemaVersion": 1, - "source": "Wegent/sdk", + "source": "wework-plugins-public/plugins/dingtalk/.wework-build", "files": { name: hashlib.sha256(content).hexdigest() for name, content in sorted(files.items()) @@ -53,7 +53,7 @@ def bundle(repository: Path, *, check: bool = False) -> None: 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") + raise ValueError("Vendored build sources differ from the reviewed inventory") return if actual - set(files): raise ValueError("Build support destination contains unexpected files") diff --git a/plugins/dingtalk/.wework-build/dws-auth/verify.py b/plugins/dingtalk/.wework-build/dws-auth/verify.py index 335c295..02cf6d5 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/verify.py +++ b/plugins/dingtalk/.wework-build/dws-auth/verify.py @@ -89,7 +89,7 @@ def invoke( { "protocolVersion": 1, "connectorSlug": connector, - "credentialType": "oauth2", + "credentialType": "bearer", "credential": credential, } ).encode() @@ -106,7 +106,7 @@ def invoke( def verify_native_entry(plugin: Path) -> None: credential = { - "access_token": "synthetic-package-access-token", + "token": "synthetic-package-access-token", "corp_id": "synthetic-corp", "user_id": "synthetic-user", "source": "mcp", @@ -116,18 +116,21 @@ def verify_native_entry(plugin: Path) -> None: with tempfile.TemporaryDirectory(prefix="wegent-dws-verify-") as temporary: home = Path(temporary) / "home" home.mkdir() - success = invoke(plugin, home, credential, "dingtalk") + success = invoke(plugin, home, credential, "dingtalk-access") if success.returncode != 0 or json.loads(success.stdout) != { "authenticated": True, "accountId": "synthetic-corp:synthetic-user", }: raise ValueError("DWS packaged adapter health failed") + expired = invoke(plugin, home, {**credential, "expires_at": 1}, "dingtalk-access") + if expired.returncode != 0 or json.loads(expired.stdout).get("error") != "dingtalk_cloud_token_expired": + raise ValueError("DWS packaged adapter did not reject expired snapshot") rejected = [ invoke( plugin, home, {**credential, "refresh_token": "synthetic-refresh-token"}, - "dingtalk", + "dingtalk-access", ), invoke(plugin, home, credential, "wrong-connector"), ] @@ -138,7 +141,7 @@ def verify_native_entry(plugin: Path) -> None: 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] + for result in [success, expired, *rejected] ): raise ValueError("DWS packaged adapter exposed a credential") # Docker Desktop's Rosetta loader creates two empty cache directories. diff --git a/plugins/dingtalk/scripts/dws.py b/plugins/dingtalk/scripts/dws.py index edc5dc1..5e05373 100644 --- a/plugins/dingtalk/scripts/dws.py +++ b/plugins/dingtalk/scripts/dws.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Public DWS entry: business metadata goes to the shared native broker.""" +"""Local DWS stays local; cloud commands use an access-only snapshot.""" from __future__ import annotations @@ -41,34 +41,6 @@ def account_arguments(arguments: list[str]) -> tuple[str | None, list[str]]: 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 @@ -129,18 +101,29 @@ def main(arguments: list[str]) -> int: 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() - ): + mode = os.environ.get("WEGENT_PLUGIN_AUTH_MODE", "") + if mode not in {"", "local", "cloud"}: + raise AuthError("plugin_auth_runtime_unsupported") + if mode == "cloud" or account is not None: 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) + output = run_account_command( + ROOT, "dingtalk-access", arguments, account_id=account ) + sys.stdout.write(output) + # Expected snapshot expiry is a business result, not a host protocol + # failure. Preserve it for helpers without replaying the operation. + try: + result = json.loads(output) + except ValueError: + result = None + if ( + isinstance(result, dict) + and result.get("error") == "dingtalk_cloud_token_expired" + ): + return 1 return 0 return run_local(arguments, executable) except AuthError as error: diff --git a/plugins/dingtalk/skills/dws/SKILL.md b/plugins/dingtalk/skills/dws/SKILL.md index f3d58e7..222105f 100644 --- a/plugins/dingtalk/skills/dws/SKILL.md +++ b/plugins/dingtalk/skills/dws/SKILL.md @@ -13,9 +13,9 @@ metadata: ## Wegent 本地运行与授权 -账号认证迁移只能从 Wegent 原生插件界面发起。适配器通过私有通道完成加密暂存和 -唯一刷新权交接,云端仅在用户授权设备后使用账号。禁止模型读取、打印或转发 Token。 -尚未迁移的本机账号仍使用原有 DWS 登录态;云端和已迁移账号必须使用平台认证代理。 +只需授权一次本地 DWS。本地保留登录态及刷新能力,正常命令不依赖平台认证代理。 +WeWork 在线时通过私有通道同步短期访问凭据,云端只使用快照,不持有刷新凭据。 +禁止模型读取、打印或转发 Token,也不要让用户手动复制凭据。 从此 `SKILL.md` 向上两级定位插件根目录。每次新会话第一次调用钉钉能力前: @@ -25,19 +25,27 @@ 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`,先确认本机已有登录且本机和云端设备在线,等待后台同步后重试; -持续失败时报告连接状态,不要求用户在页面迁移或授权设备。遇到 +云端准备检查不会登录或下载安装原版 DWS。遇到 +`dingtalk_cloud_token_expired`,说明云端快照已到期,不代表本地退出登录; +请用户打开本地 WeWork,保持 executor 和网络在线,等待后台刷新同步,通常至少需要约一分钟。 +遇到 `plugin_auth_broker_unavailable`,报告云端执行环境未就绪,不要求重新扫码, +不读取本机凭据补发,也不自动在本地重放业务写操作。 +遇到 `plugin_auth_device_not_granted`,确认本机已登录且同步与设备授权正常; +持续失败时报告云端连接状态。遇到 `plugin_auth_account_selection_required`,用 `--account-id ` 指定平台账号。 不要通过 `auth login`、`profile switch`、`--profile` 或自定义二进制绕过这些状态。 -后文的本机 profile 管理仅适用于尚未迁移的账号。 +后文的 profile 管理只适用于本地执行;云端显式选择平台账号。 + +0.3.3 已交接用户须先由平台退役旧托管连接,再恢复本地 DWS 授权;升级本身 +不会找回已删除的凭据,不得删除旧交接记录来假装恢复。仅关闭本地不会立即撤销 +云端快照,快照在到期前仍可能可用;停止共享请使用平台断开云端连接。 本 Skill 后文所有 `dws ...` 命令都是逻辑写法,实际执行必须经过插件包装器: diff --git a/plugins/dingtalk/test_plugin.py b/plugins/dingtalk/test_plugin.py index 446d54e..726b2be 100644 --- a/plugins/dingtalk/test_plugin.py +++ b/plugins/dingtalk/test_plugin.py @@ -29,8 +29,13 @@ def test_all_platforms_and_real_native_channel(self): 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") + connector = next(c for c in manifest["connectors"] if c["slug"] == "dingtalk-access") + self.assertEqual(connector["accountAuth"]["credentialType"], "bearer") + self.assertNotIn("exportMode", connector["accountAuth"]) + self.assertNotIn("oauth2", connector["accountAuth"]) + local = next(c for c in manifest["connectors"] if c["slug"] == "dingtalk-local") + self.assertIn("localAuth", local) + self.assertNotIn("accountAuth", local) self.assertEqual(connector["accountAuth"]["adapter"], "scripts/account-auth.py") diff --git a/tests/test_dws_account_entry.py b/tests/test_dws_account_entry.py index 14bbd19..7b8441d 100644 --- a/tests/test_dws_account_entry.py +++ b/tests/test_dws_account_entry.py @@ -136,18 +136,41 @@ def test_managed_readiness_checks_the_platform_without_starting_local_auth(self) 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): + def test_legacy_receipts_never_force_local_commands_through_broker(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) + for contents in ('{"version":1,"state":"detached"}', + '{"version":1,"state":"prepared"}', 'invalid-json'): + (receipts / ("b" * 64 + ".json")).write_text(contents) + environment = {**self.environment, "WEGENT_PLUGIN_AUTH_MODE": "local"} + environment.pop("WEGENT_PLUGIN_AUTH_BROKER") + environment.pop("WEGENT_PLUGIN_AUTH_BROKER_TOKEN") + with patch.dict(os.environ, environment, 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_cloud_without_broker_never_falls_back_to_local(self): + environment = dict(self.environment) + environment.pop("WEGENT_PLUGIN_AUTH_BROKER") + environment.pop("WEGENT_PLUGIN_AUTH_BROKER_TOKEN") + with patch.dict(os.environ, environment, clear=True), patch.object( + dws, "run_local" + ) as local: + self.assertEqual(dws.main(["plugin-health"]), 1) + local.assert_not_called() + + def test_expired_cloud_snapshot_fails_without_replaying_business_command(self): + self.reply = {"stdout": json.dumps({ + "success": False, "error": "dingtalk_cloud_token_expired" + })} + result = self.invoke(["todo", "task", "create"]) + self.assertEqual(result.returncode, 1) + self.assertEqual(json.loads(result.stdout)["error"], "dingtalk_cloud_token_expired") self.assertEqual(len(self.calls), 1) + self.assertFalse((self.home / ".dws").exists()) def test_unmigrated_local_account_keeps_the_existing_cli(self): with patch.dict( @@ -159,6 +182,27 @@ def test_unmigrated_local_account_keeps_the_existing_cli(self): local.assert_called_once_with(["todo", "task", "get"], None) self.assertEqual(self.calls, []) + def test_local_entry_runs_without_any_broker_environment(self): + environment = dict(self.environment) + for key in list(environment): + if key.startswith("WEGENT_PLUGIN_AUTH_"): + environment.pop(key) + result = self.invoke([ + "--local-binary", sys.executable, "-c", + 'print("synthetic-local-dws-success")', + ], environment) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "synthetic-local-dws-success") + self.assertEqual(self.calls, []) + + def test_unknown_execution_mode_does_not_start_local_login(self): + with patch.dict( + os.environ, {**self.environment, "WEGENT_PLUGIN_AUTH_MODE": "unknown"}, + clear=True, + ), patch.object(dws, "run_local") as local: + self.assertEqual(dws.main(["plugin-health"]), 1) + local.assert_not_called() + def test_aborted_transfer_does_not_mark_the_new_source_as_managed(self): receipts = self.home / ".dws/wegent-transfers" receipts.mkdir(parents=True) @@ -200,7 +244,7 @@ def test_helper_subprocess_uses_the_same_public_entry(self): timeout=10, ) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(self.calls[0][1]["connector_slug"], "dingtalk") + self.assertEqual(self.calls[0][1]["connector_slug"], "dingtalk-access") def test_native_adapter_requires_matching_artifact_metadata(self): spec = importlib.util.spec_from_file_location( From 21e3610eff1f3fa65295f0a6029e369fc7ec7fdb Mon Sep 17 00:00:00 2001 From: junlong5 Date: Tue, 29 Sep 2026 15:58:03 +0800 Subject: [PATCH 2/2] fix(dingtalk): use UTF-8 for manifest tests on Windows --- .../dingtalk/.wework-build/account-auth-build.json | 2 +- .../.wework-build/dws-auth/tests/test_package.py | 13 +++++++++++-- plugins/dingtalk/test_plugin.py | 4 +++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/plugins/dingtalk/.wework-build/account-auth-build.json b/plugins/dingtalk/.wework-build/account-auth-build.json index d56eebc..7119362 100644 --- a/plugins/dingtalk/.wework-build/account-auth-build.json +++ b/plugins/dingtalk/.wework-build/account-auth-build.json @@ -16,7 +16,7 @@ "dws-auth/overlay/provider.go": "bf53b273d7b06bf10e5320fca098afa6a8f794dbbafd5232b3b05e1f5e77ec48", "dws-auth/overlay/provider_test.go": "64f8ebe1fcf9927d705fb82efdd4470f6310500da32d87668cb61c8beeb743f9", "dws-auth/package.py": "bd1b4b5c7d31beabe31b4f6a70451029180642f779acc1b3572dda234dcd8182", - "dws-auth/tests/test_package.py": "36258af48fe9ae4c37807a9c76e42e69cbc8997b80e41c0eadc4371872d5d012", + "dws-auth/tests/test_package.py": "2a4223254622fdb66e214a43eba63894aefef45beb205de843856a1216fa7022", "dws-auth/tests/test_toolchain.py": "c2306b7ffa0b52505f5cfff09096ad1449bb7e6c059e59dcd192a4c59b1ee889", "dws-auth/tests/test_vendor.py": "f297ebe66383355d62a335ac901407e5487d929b86c2ddcb95cf88f87bc3a2de", "dws-auth/toolchain.py": "c265669d702c9529036bd088061ce9375ceefd83b3901cc3910b95c43e941232", diff --git a/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py b/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py index 20d1b71..64ae25f 100644 --- a/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py +++ b/plugins/dingtalk/.wework-build/dws-auth/tests/test_package.py @@ -113,7 +113,7 @@ def legacy_read(path, *args, **kwargs): self.assertEqual(manifest["description"], "钉钉认证打包") def test_exclusive_or_login_coupled_manifest_is_rejected_before_build(self): - original = json.loads(self.manifest.read_text()) + original = json.loads(self.manifest.read_text(encoding="utf-8")) for variant in ("exclusive", "oauth2", "coupled"): manifest = json.loads(json.dumps(original)) snapshot = manifest["connectors"][1]["accountAuth"] @@ -123,12 +123,21 @@ def test_exclusive_or_login_coupled_manifest_is_rejected_before_build(self): snapshot["oauth2"] = ["refresh"] else: manifest["connectors"][0]["accountAuth"] = snapshot - self.manifest.write_text(json.dumps(manifest)) + self.manifest.write_text(json.dumps(manifest), encoding="utf-8") with patch.object(package.subprocess, "run") as build: with self.assertRaisesRegex(ValueError, "separate local login"): package.assemble(self.plugin, self.output, None) build.assert_not_called() + def test_manifest_rejection_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): + self.test_exclusive_or_login_coupled_manifest_is_rejected_before_build() + def test_symlinks_are_rejected_without_following_the_target(self): (self.plugin / "scripts/external").symlink_to( self.root / "missing-external-file" diff --git a/plugins/dingtalk/test_plugin.py b/plugins/dingtalk/test_plugin.py index 726b2be..c47e2c3 100644 --- a/plugins/dingtalk/test_plugin.py +++ b/plugins/dingtalk/test_plugin.py @@ -28,7 +28,9 @@ def test_all_platforms_and_real_native_channel(self): verify.verify_native_entry(ROOT) def test_cloud_authentication_is_declared(self): - manifest = json.loads((ROOT / ".codex-plugin/plugin.json").read_text()) + manifest = json.loads( + (ROOT / ".codex-plugin/plugin.json").read_text(encoding="utf-8") + ) connector = next(c for c in manifest["connectors"] if c["slug"] == "dingtalk-access") self.assertEqual(connector["accountAuth"]["credentialType"], "bearer") self.assertNotIn("exportMode", connector["accountAuth"])