Compare commits

...
Author SHA1 Message Date
chichuan 43b1936b65 Merge pull request #950 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.3
docs: seal v1.0.58-beta.3 changelog
2026-08-11 17:50:03 +08:00
chichuan dbe47d58fb docs: seal v1.0.58-beta.3 changelog 2026-08-11 17:45:44 +08:00
github-actions[bot] ec7593dabb Merge pull request #936 from wxianfeng/feature/aone85277391-event-runtime-token-handoff
fix(event): securely hand off runtime token to detached bus
2026-08-11 09:32:24 +00:00
chichuan 1df4cc95a6 Merge branch 'main' into feature/aone85277391-event-runtime-token-handoff 2026-08-11 17:15:15 +08:00
github-actions[bot] d052c104d9 Merge pull request #948 from cywan1998/docs/sync-calendar-skill-mono-multi
docs(skills): sync calendar reference between mono and multi layouts
2026-08-11 08:39:55 +00:00
fengbai fdf3e8cc3b docs(skills): sync calendar reference between mono and multi layouts 2026-08-11 16:21:20 +08:00
github-actions[bot] 0fb332c3f3 Merge pull request #934 from DingTalk-Real-AI/feat/eval-dispatch
ci: add /eval PR comment dispatch for internal MCP evaluation
2026-08-11 16:15:19 +08:00
chichuan 27b16b190f Merge branch 'main' into feat/eval-dispatch 2026-08-11 15:47:54 +08:00
github-actions[bot] de1e1aaf6c Merge pull request #913 from DingTalk-Real-AI/codex/fix-im-reliability
fix(chat): harden IM search, card updates, and message workflows
2026-08-11 15:47:27 +08:00
chichuan 20d1f7c614 feat(eval-dispatch): optional sha= for own-PR dispatch; structural cases ref validation
- /eval on one's own PR may omit sha=: the guard auto-pins the
  dispatch-time head (commenter == PR author leaves no third-party
  swap window); dispatching another author's PR still requires the
  explicit reviewed SHA (keeps the P1-2 TOCTOU remedy where the
  threat lives)
- cases= is now validated structurally per git check-ref-format
  semantics (leading/trailing//double slashes, '..', dot-leading
  components, .lock suffixes) and rejects '-'-leading values to
  prevent git fetch option injection (review P2)
2026-08-11 15:46:16 +08:00
chichuan 233e0359e4 chore(eval-dispatch): seed allowlist with 53 internal contributors 2026-08-11 15:45:33 +08:00
chichuan ad6837d694 feat(eval-dispatch): allowlist tier for self-service PR evaluation
Users listed in .github/eval-allowlist.txt (default branch, PR-reviewed)
may dispatch /eval for their own PRs only; write/maintain/admin retain
dispatch for any PR. Fail-closed on permission API 404/network errors.
2026-08-11 15:45:33 +08:00
克谨 7849116a69 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-11 15:17:59 +08:00
克谨 b082135e6e test(chat): cover scoped search branches 2026-08-11 15:17:48 +08:00
github-actions[bot] 9ae0191270 Merge pull request #932 from abucraft/codex/aitable-workflow-run-history
feat: add aitable workflow run and history commands
2026-08-11 07:08:34 +00:00
chichuan 211a5fa393 Merge branch 'main' into codex/aitable-workflow-run-history 2026-08-11 14:33:14 +08:00
chichuan 8619d90119 Merge remote-tracking branch 'origin/main' into feat/eval-dispatch 2026-08-11 14:16:33 +08:00
克谨 af199e73e2 Merge origin/main into codex/fix-im-reliability 2026-08-11 14:10:05 +08:00
github-actions[bot] fd24619437 Merge pull request #935 from xiaoji121/fix/json-output-doc-export-drive-download
fix: return JSON receipts for exports and downloads
2026-08-11 14:08:11 +08:00
chichuan 42e764a7a8 fix(ci): harden eval dispatch authorization 2026-08-11 13:57:42 +08:00
Dongming Ji 6337058d15 Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-11 13:51:46 +08:00
克谨 d38868c8aa Merge origin/main into codex/fix-im-reliability 2026-08-11 13:51:27 +08:00
github-actions[bot] de8040ecc2 Merge pull request #938 from xlb1130/feat/im-page-all-pagination
docs(chat): expose typed message pagination help
2026-08-11 13:36:43 +08:00
Dongming Ji 96f406be6b Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-11 13:34:55 +08:00
克谨 0d99d18acc test(chat): align update-card selection copy 2026-08-11 12:44:13 +08:00
xlb1130 9377abc5f6 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 12:38:16 +08:00
克谨 e8ca78fe49 Merge origin/main into codex/fix-im-reliability 2026-08-11 12:07:27 +08:00
github-actions[bot] b923f522d5 Merge pull request #912 from aqruan/fix/minutes-permission-apply-policy-int
fix(minutes): type permission apply --policy as int
2026-08-11 04:01:32 +00:00
克谨 28bc577e88 Merge origin/main into codex/fix-im-reliability 2026-08-11 11:50:22 +08:00
克谨 82dfee7291 fix(chat): preserve layered IM workflow contracts 2026-08-11 11:48:39 +08:00
wxianfeng bab7c8879b Merge remote-tracking branch 'upstream/main' into feature/aone85277391-event-runtime-token-handoff 2026-08-11 11:48:08 +08:00
Dongming Ji 1d2edbaa9f Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-11 11:45:16 +08:00
xlb1130 25a5f5b7d2 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 11:44:24 +08:00
wxianfeng 82b17ced32 Merge upstream/main into feature/aone85277391-event-runtime-token-handoff 2026-08-11 11:37:37 +08:00
chichuan 0b43905697 Merge branch 'main' into fix/minutes-permission-apply-policy-int 2026-08-11 11:29:04 +08:00
李晟 28227b19c7 Merge branch 'main' into codex/aitable-workflow-run-history 2026-08-11 11:28:44 +08:00
github-actions[bot] 622632908e Merge pull request #943 from DingTalk-Real-AI/codex/fix-helper-ci-sharding
ci: shard helper changes through full suite
2026-08-11 11:27:09 +08:00
wxianfeng 63dbf98cdf test(event): cover runtime token rejection on Windows to #85277391 2026-08-11 11:22:48 +08:00
chichuan 69cef74e1d Merge branch 'main' into feat/eval-dispatch 2026-08-11 11:10:05 +08:00
chichuan 2ec25ebb98 Merge branch 'main' into fix/minutes-permission-apply-policy-int 2026-08-11 11:08:54 +08:00
Dongming Ji bccc9eb056 Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-11 11:03:13 +08:00
xlb1130 2274fd96f0 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 10:55:10 +08:00
chichuan 10fe258e4b ci: shard helper changes through full suite 2026-08-11 10:54:42 +08:00
github-actions[bot] 22ab166c9b Merge pull request #905 from wxianfeng/feat/dws-event-oa
feat(event): support personal OA approval events
2026-08-11 02:46:51 +00:00
阮知夏 d3e444cb56 docs(changelog): move the Minutes policy notes into Unreleased
The two Minutes notes (permission apply --policy int typing and the skill
reference updates) landed in the released 1.0.58-beta.2 section after the
branch merged main. That rewrites published release notes and would drop
both notes from the next release generated out of Unreleased. Move them
verbatim into a Changed subsection under Unreleased; the beta.2 section is
byte-identical to main again.
2026-08-11 10:43:51 +08:00
克谨 0148ad1800 test(chat): cover scoped search error fallbacks 2026-08-11 10:29:53 +08:00
炳昱 4a4a1e0407 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa 2026-08-11 10:18:55 +08:00
aqruan e38fd9ab93 Merge branch 'main' into fix/minutes-permission-apply-policy-int 2026-08-11 10:09:53 +08:00
长真 fb33a0b9e0 Merge remote-tracking branch 'origin/feat/im-page-all-pagination' into feat/im-page-all-pagination 2026-08-11 09:59:50 +08:00
长真 e94c7063ed fix(helpers): sync paged aggregate cursors 2026-08-11 09:59:10 +08:00
克谨 d6b51a04f4 fix(chat): preserve scoped search preflight errors 2026-08-11 09:55:19 +08:00
xlb1130 cd3a09e153 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 09:32:01 +08:00
李晟 2f925d29fd Merge branch 'main' into codex/aitable-workflow-run-history 2026-08-11 09:26:07 +08:00
克谨 68483f05b2 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-11 09:17:02 +08:00
修雨 730d3fa27f Merge pull request #941 from DingTalk-Real-AI/codex/issue-940-stdio-idempotency-race-budget
test(transport): widen stdio idempotency race budget
2026-08-11 09:06:58 +08:00
长真 6eb3efa065 fix(helpers): stop paged commands at max items 2026-08-11 08:41:18 +08:00
chichuan 783e1eeef9 fix(ci): stabilize minutes coverage contracts 2026-08-11 00:13:48 +08:00
xlb1130 9e0a67f728 Merge branch 'main' into feat/im-page-all-pagination 2026-08-11 00:00:33 +08:00
长真 19f9285f8c fix(helpers): propagate paged output errors 2026-08-10 23:50:55 +08:00
修雨 c295027e84 Merge main into test/transport race budget candidate 2026-08-10 23:47:12 +08:00
克谨 3817ac230d Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 23:46:54 +08:00
chichuan 75b54a9467 Merge branch 'main' into fix/minutes-permission-apply-policy-int 2026-08-10 23:44:46 +08:00
github-actions[bot] 24437fc1a5 Merge pull request #921 from DingTalk-Real-AI/codex/interface-migration-governance
ci: govern exact CLI flag migrations
2026-08-10 23:43:19 +08:00
Dongming Ji 851d491d2a Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-10 23:31:35 +08:00
chichuan b55f243780 ci(test): shard helper changes in full suite 2026-08-10 23:25:52 +08:00
克谨 257ac94fb1 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 22:31:57 +08:00
xlb1130 9834a84888 Merge branch 'main' into feat/im-page-all-pagination 2026-08-10 22:31:18 +08:00
chichuan a68c06540c Merge remote-tracking branch 'origin/main' into fix-912-conflict
# Conflicts:
#	CHANGELOG.md
2026-08-10 22:26:01 +08:00
长真 44c5ef13b4 test(chat): cover conversation pagination edges 2026-08-10 22:24:27 +08:00
炳昱 6f73e5187a Merge official main into feat/dws-event-oa 2026-08-10 22:21:25 +08:00
Dongming Ji b70e109e89 Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-10 22:13:31 +08:00
克谨 3fc144a699 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 21:19:53 +08:00
长真 4d274c9da3 fix(chat): merge conversation message pagination 2026-08-10 20:34:43 +08:00
如椽 7ffb48c9ae test: cover JSON export and download receipts 2026-08-10 19:57:37 +08:00
如椽 a24fd542c0 Merge remote-tracking branch 'upstream/main' into fix/json-output-doc-export-drive-download
# Conflicts:
#	CHANGELOG.md
2026-08-10 19:28:55 +08:00
长真 b8418b6a5f test(chat): cover paged command edge cases 2026-08-10 18:59:24 +08:00
修雨 af71efd253 test(transport): widen stdio idempotency race budget
Refs #940

Authority: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/issues/940

Assignment: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/issues/940#issuecomment-5239203628
2026-08-10 18:58:21 +08:00
xlb1130 8c19b0048b Merge branch 'main' into feat/im-page-all-pagination 2026-08-10 18:24:07 +08:00
长真 a9751fa74d docs(changelog): drop typed pagination entry from branch 2026-08-10 18:23:41 +08:00
镜玄 8a0bd34e13 Merge remote-tracking branch 'upstream/main' into codex/aitable-workflow-run-history
# Conflicts:
#	CHANGELOG.md
2026-08-10 18:17:15 +08:00
长真 5a160cefd8 docs(chat): expose typed message pagination help 2026-08-10 17:59:38 +08:00
炳昱 a9c0e0409c Merge remote-tracking branch 'official/main' into feat/dws-event-oa 2026-08-10 17:58:42 +08:00
炳昱 9616441e54 fix(skill): migrate retired shared skill 2026-08-10 17:58:33 +08:00
如椽 08ee5dc573 fix: emit JSON receipts for exports and downloads 2026-08-10 17:21:54 +08:00
镜玄 5c45bd57da test: cover aitable workflow validation branches 2026-08-10 17:13:58 +08:00
克谨 349537e336 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 17:13:12 +08:00
克谨 b243b38d65 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 16:32:20 +08:00
镜玄 819355b31f feat: add aitable workflow run and history commands 2026-08-10 16:20:22 +08:00
克谨 c3d4de52a7 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 16:09:52 +08:00
wxianfeng 5004ed8ae6 fix(event): securely hand off runtime token to detached bus to #85277391 2026-08-10 15:59:11 +08:00
炳昱 0e14f69aae Merge commit '6575301a3a7fef264f0550185a0bee13087be729' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa 2026-08-10 15:43:37 +08:00
炳昱 9f14035483 test(event): cover subscription and migration failures 2026-08-10 15:42:44 +08:00
chichuan a5672152a7 ci: add /eval comment dispatch workflow for internal MCP evaluation (Aone JSON trigger contract) 2026-08-10 15:34:01 +08:00
chichuan 2d38abe681 feat(ci): PR 评论 /eval 触发内网 MCP 评测的 dispatch workflow
- issue_comment 触发,author_association ∈ OWNER/MEMBER/COLLABORATOR 门控
- 不 checkout、不执行 PR 代码;触发通道与凭证全部经 secrets 注入
- scripts/ci/eval_comment_parse.py 解析 /eval <products> [cases=<ref>](10 个单测)
2026-08-10 15:34:01 +08:00
阮知夏 f478b7d3e1 Merge remote-tracking branch 'origin/main' into fix/minutes-permission-apply-policy-int
# Conflicts:
#	CHANGELOG.md
2026-08-10 15:27:38 +08:00
克谨 d84c73e8b2 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 15:27:09 +08:00
炳昱 8daf5c71cd Merge commit '93a20718372f434f9eda84850df816a7c29c34fc' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa 2026-08-10 15:03:39 +08:00
克谨 431f64be85 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 14:13:46 +08:00
克谨 d979d86fa3 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability
# Conflicts:
#	internal/helpers/chat.go
2026-08-10 12:21:37 +08:00
阮知夏 b150911da9 docs(minutes): scope permission member-uids rule and add apply routing 2026-08-10 11:09:08 +08:00
阮知夏 37d6a4ea2e Merge remote-tracking branch 'origin/main' into fix/minutes-permission-apply-policy-int
# Conflicts:
#	CHANGELOG.md
2026-08-10 10:32:21 +08:00
克谨 20c8e0dfec Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability
# Conflicts:
#	scripts/policy/schema-compat/main.go
2026-08-10 10:22:06 +08:00
长真 cde050f146 test(chat): cover paged command delay sleep 2026-08-10 10:10:16 +08:00
克谨 e02410dae6 fix(ci): review card confirmation hardening 2026-08-10 01:02:28 +08:00
克谨 74d31566ff fix(chat): align native card update confirmation 2026-08-10 00:42:27 +08:00
克谨 6765a74d83 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability
# Conflicts:
#	internal/shortcut/smart/compatibility_coverage_test.go
#	internal/shortcut/smart/search_msg.go
#	internal/shortcut/smart/search_msg_execution_test.go
#	skills/multi/dingtalk-chat/references/contracts.md
2026-08-10 00:30:24 +08:00
克谨 13e5914638 test(chat): close changed-code coverage gaps 2026-08-10 00:07:53 +08:00
阮知夏 8fcc6baee0 Merge remote-tracking branch 'origin/main' into fix/minutes-permission-apply-policy-int
# Conflicts:
#	CHANGELOG.md
2026-08-10 00:07:42 +08:00
阮知夏 6774d423b7 docs(minutes): drop hot-word delete references from skill docs 2026-08-09 23:43:51 +08:00
长真 8156528c05 fix(chat): harden IM pagination cursor mapping 2026-08-09 20:20:20 +08:00
长真 e5a60386c6 feat(chat): add typed IM message pagination 2026-08-09 17:18:53 +08:00
wxianfeng 1ee37ec4c2 fix(event): harden subscription reuse and skill migration 2026-08-07 18:46:25 +08:00
阮知夏 06b0a9eef3 docs(minutes): drop hot-word delete intent routing 2026-08-07 17:56:32 +08:00
克谨 83f72377a7 fix(chat): satisfy IM contract and compatibility gates 2026-08-07 17:41:25 +08:00
克谨 50712d3305 Merge remote-tracking branch 'origin/main' into codex/fix-im-search-conversation-scope 2026-08-07 17:02:16 +08:00
wxianfeng 7e27fa384a Merge remote-tracking branch 'upstream/main' into feat/dws-event-oa 2026-08-07 16:47:17 +08:00
wxianfeng 8bf6c15fad feat(event): restore standalone event skill 2026-08-07 16:38:30 +08:00
阮知夏 f79a6fc707 fix(minutes): type permission apply --policy as int 2026-08-07 16:25:38 +08:00
克谨 5a09204bf5 fix(chat): complete resource reference downloads 2026-08-07 16:23:20 +08:00
克谨 0d11b2be45 fix(chat): preserve resource filenames in message refs 2026-08-07 16:07:56 +08:00
克谨 effe7c829e fix(chat): align message workflows and diagnostics 2026-08-07 15:48:29 +08:00
克谨 7c76dfea4b fix(chat): fail closed for scoped search and card updates 2026-08-07 15:30:09 +08:00
炳昱 c803cf7eeb fix(event): validate reused OA subscriptions in dry-run 2026-08-07 15:30:03 +08:00
炳昱 832d3ab886 test(event): cover OA validation branches 2026-08-07 14:58:52 +08:00
wxianfeng 47f303d3fc Merge remote-tracking branch 'origin/feat/dws-event-oa' into feat/dws-event-oa 2026-08-07 14:56:28 +08:00
wxianfeng 1199240a36 Merge remote-tracking branch 'upstream/main' into feat/dws-event-oa
# Conflicts:
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-misc/references/event-oa.md
#	skills/multi/dingtalk-misc/references/event.md
2026-08-07 14:48:37 +08:00
炳昱 354d39a6f1 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa
# Conflicts:
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-misc/references/event-oa.md
#	skills/multi/dingtalk-misc/references/event.md
2026-08-07 14:23:12 +08:00
炳昱 6704eda83a fix(event): switch personal event defaults to production 2026-08-07 10:22:32 +08:00
wxianfeng b581426488 Merge remote-tracking branch 'upstream/main' into feat/dws-event-oa
# Conflicts:
#	internal/app/event_personal_command.go
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/cli/schema_hints/index.json
#	internal/cli/schema_hints/reference-review.json
#	skills/mono/SKILL.md
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
2026-08-05 22:43:36 +08:00
炳昱 703406df13 feat(event): publish typed OA approval schemas 2026-07-29 22:23:13 +08:00
炳昱 753d538140 feat(event): complete personal OA approval events 2026-07-28 21:38:17 +08:00
wxianfeng f890dda7e7 feat(event): add personal OA approval events
Use the Event-specific pre-release control and stream ticket endpoints by default.
2026-07-28 16:15:18 +08:00
wxianfeng c870d2ebdc Merge remote-tracking branch 'upstream/main' 2026-07-28 10:52:41 +08:00
146 changed files with 17304 additions and 1023 deletions
+61
View File
@@ -0,0 +1,61 @@
# /eval 自助触发允许名单
#
# 名单内的 GitHub 登录名可对【自己创建的 PR】触发 /eval 评测;
# 对任意 PR 触发仍需仓库 write/maintain/admin 权限(维护者背书)。
# 授权读取的始终是默认分支上的本文件,PR 无法修改自身授权。
#
# 变更本文件必须走 PR 评审。每行一个 GitHub login,# 开头为注释。
aftersss
notable-open
EdgarWang0925
ayunya
yutongshe
qingyang1014
caiTriumph
xlb1130
Anonymity-0
FuShu-Yang
guimingyue
AlwaysLee
TaoJikun
zengyoulingzyl-stack
liyuan333
huangyoo
lifeihong
nitonitori
cywan1998
gangwn
junlonghuo2
aqruan
Freda0909
ShawnWhite777
PeterGuy326
abucraft
pengzhihan47-star
rainyak8
gongrongyun
huangyuanzhuo-coder
ybcstudy
bigqy
liwang-ai
meng93
wxianfeng
Patrick-Star-CN
rossluo28-hz
dxy704330469
gtezg30062
Neige-Premaire
zhuoyu20
avicii-chen
typefield
Haofeng0705
Huwenjiao
liuzeyang
maoqxxmm
FloralTide
lingyun9833
dxb121
C0922
xiaoji121
H3java
+1
View File
@@ -148,6 +148,7 @@ jobs:
filename === '.github/actionlint.yaml' ||
filename.startsWith('scripts/') ||
filename.startsWith('verify/') ||
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/generator/') ||
filename.startsWith('internal/cli/schema') ||
filename.startsWith('internal/interfacesnapshot/') ||
+139
View File
@@ -0,0 +1,139 @@
name: PR Eval Dispatch
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 触发内网评测流水线,报告由内网 bot 回贴。
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
# 的当前 head(作者自背书,无第三方偷换窗口);内网 CI 另以
# FETCH_HEAD 校验兜底派发后的变更。
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
# 触发通道与凭证全部经 secrets 注入,文件内不出现任何内网信息。
on:
issue_comment:
types:
- created
permissions: {}
concurrency:
group: eval-dispatch-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
dispatch:
name: Dispatch internal evaluation
if: >-
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/eval')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: read
steps:
- name: Check out default branch tooling
uses: actions/checkout@v4
- name: Verify commenter dispatch authorization
env:
GH_TOKEN: ${{ github.token }}
COMMENTER: ${{ github.event.comment.user.login }}
PR_AUTHOR: ${{ github.event.issue.user.login }}
EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt
run: |
# 不用 --fail:非协作者查权限返回 404 错误体,交由 guard 走名单分支;硬网络错误降级为空对象同样 fail-closed
permission_json="$(curl --silent --show-error \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/collaborators/${COMMENTER}/permission")" || permission_json='{}'
printf '%s' "$permission_json" | python3 scripts/ci/eval_dispatch_guard.py permission
- name: Parse /eval command
id: parse
continue-on-error: true
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: python3 scripts/ci/eval_comment_parse.py
- name: Reply usage on parse failure
if: steps.parse.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
PARSE_ERROR: ${{ steps.parse.outputs.error }}
run: |
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
exit 1
- name: Verify reviewed PR head
id: pr
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
EXPECTED_PR_NUMBER: ${{ github.event.issue.number }}
REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }}
COMMENTER: ${{ github.event.comment.user.login }}
run: |
pr_json="$(curl --fail --silent --show-error \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")"
printf '%s' "$pr_json" \
| python3 scripts/ci/eval_dispatch_guard.py head \
>> "$GITHUB_OUTPUT"
- name: Trigger internal evaluation pipeline
env:
EVAL_TRIGGER_TOKEN: ${{ secrets.EVAL_TRIGGER_TOKEN }}
EVAL_TRIGGER_URL: ${{ secrets.EVAL_TRIGGER_URL }}
PR_NUMBER: ${{ github.event.issue.number }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
run: |
if [ -z "$EVAL_TRIGGER_TOKEN" ] || [ -z "$EVAL_TRIGGER_URL" ]; then
echo "EVAL_TRIGGER_URL / EVAL_TRIGGER_TOKEN not configured; cannot dispatch." >&2
exit 1
fi
jq -n \
--arg pr "$PR_NUMBER" \
--arg sha "$PR_HEAD_SHA" \
--arg products "$PRODUCTS" \
--arg cases "$CASES_REF" \
'{branch: "main", params: {pr_number: $pr, pr_head_sha: $sha, products: $products, cases_ref: $cases}}' \
| curl --fail --silent --show-error \
-X POST \
-H "private-token: ${EVAL_TRIGGER_TOKEN}" \
-H "Content-Type: application/json" \
--data @- \
"$EVAL_TRIGGER_URL"
echo "Internal evaluation dispatched."
- name: Acknowledge on PR
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
run: |
cases_note=""
if [ -n "$CASES_REF" ]; then
cases_note=",用例版本 \`${CASES_REF}\`"
fi
body="🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。内网评测流水线运行结束后将由 bot 回贴报告(首行为基线对比头条)。"
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
+49
View File
@@ -6,12 +6,57 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58-beta.3] - 2026-08-11
### Added
- **Aitable workflow execution and history** — adds `dws aitable workflow run` for confirmed asynchronous execution of scheduled or record-triggered workflows, plus `dws aitable workflow history` for status-, time-, and page-filtered execution records. The commands map directly to `aitable/run_workflow` and `aitable/get_flow_record_list`, validate trigger-specific arguments locally, and document the `executionId` / `instanceId` correlation.
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
targets to the initial card-creation request and prepending its returned
`atTag` to the automatic streaming update.
- **Personal OA approval events** — personal event consumers now support task
creation, completion, redirection, instance start, termination, and
completion events, with typed output and matching usage documentation.
### Fixed
- **Machine-readable export and download receipts** — `dws doc export`,
`dws drive download`, and `dws drive download --version` now keep progress
logs on stderr under `--format json` and emit one JSON result on stdout after
a successful local write. The result includes the saved path and byte size;
document exports additionally report the node, requested format, job/task
ID, and final status.
- **IM search and card-write safety** — conversation-scoped search now fails
closed when the target cannot be verified, and streaming-card updates require
business evidence rather than a transport-only success response.
- **Document shortcut reliability** — document write, readback verification,
pagination, template/version discovery, export, media, and local-file
workflows now preserve compatibility while rejecting ambiguous write results.
- **Event runtime-token handoff** — personal `event consume`, `status`,
`stop`, and `+listen-im` honor the root `--token` without falling back to a
stale OAuth profile. Detached buses negotiate an owner-only, memory-only IPC
credential channel; tokens are never placed in child argv, environment,
profiles, logs, or run-state files.
### Changed
- **Minutes `permission apply --policy` type** — `--policy` is now declared as
an `int` flag and its required check uses `Flags().Changed`, matching the
numeric-parameter convention. `--help` reports `int` instead of `string`;
accepted values (2/3/4) and gateway behavior are unchanged.
- **Minutes skill references** — document `permission apply` in both Minutes
skill references: list it in the command trees, describe its policy values and
how it differs from `permission add`, and add its intent routing.
- **Chat paging guidance** — typed chat message commands now document
`--page-all`, aggregate result shapes, and cursor behavior in CLI Help and
Agent selection examples.
- **Calendar skill parity** — mono and multi Calendar references are aligned to
prevent documentation drift without changing CLI behavior.
- **Release engineering** — CI now shards helper-package changes through the
full race suite, widens a flaky stdio idempotency test budget, governs exact
reviewed CLI/Schema type migrations, and lets authorized maintainers trigger
internal MCP evaluation with a reviewed `/eval` PR comment.
## [1.0.58-beta.2] - 2026-08-10
@@ -41,6 +86,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
### Fixed
- **Event runtime-token handoff** — personal `event consume`, `status`, `stop`, and `+listen-im` now honor the existing root `--token` instead of falling back to a stale local OAuth profile. Detached personal-event buses negotiate the credential only after an additive capability handshake, receive and rotate it through owner-only local IPC, and keep it in memory; the token is never forwarded through child argv, environment variables, profiles, logs, or run-state files. Existing OAuth and multi-profile behavior is unchanged when `--token` is absent. A new client refuses to send a runtime token to an older bus and leaves its existing consumers and subscriptions untouched; the recovery message asks users to inspect `event status --as user`, preview `event stop --as user --all --dry-run`, and explicitly confirm `event stop --as user --all --yes` before retrying.
## [1.0.58-beta.1] - 2026-08-07
### Added
+26 -16
View File
@@ -471,7 +471,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
@@ -481,28 +481,33 @@ For an event-focused installation, use the official convenience installer:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# Or install the standalone multi skill from an existing dws installation
dws skill setup --mode multi -s event
```
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# Listen for messages that mention the current user
dws event consume user_im_message_receive_at --flatten -f ndjson
dws event +listen-im --kind at-me -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# Listen for messages from a specified sender
dws event +listen-im --kind sender --user <userId> -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
# Listen for all one-to-one or all group messages
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -f ndjson
# Listen for a specified group's title changes, member changes, or disband event
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
@@ -510,14 +515,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# Listen for multiple events for the same user in one process
# Listen for messages, reads, and recalls from the same sender in one process
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# Listen for all six public OA approval events in one process
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
# Inspect local consumers and cancel a subscription
dws event status
@@ -536,7 +546,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
</details>
+26 -16
View File
@@ -465,7 +465,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
@@ -475,28 +475,33 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# 或在已有 dws 环境中安装独立的 multi skill
dws skill setup --mode multi -s event
```
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# 监听当前用户被 @ 的消息
dws event consume user_im_message_receive_at --flatten -f ndjson
dws event +listen-im --kind at-me -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# 监听指定发送人的消息
dws event +listen-im --kind sender --user <userId> -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
# 监听所有单聊或所有群消息
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -f ndjson
# 监听指定群标题变更、成员进退群或群解散
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
@@ -504,14 +509,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# 一个进程监听同一用户的多个事件
# 一个进程监听同一发送人的消息、已读和撤回
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# 一个进程监听全部六个公开 OA 审批事件
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
@@ -530,7 +540,7 @@ dws event stop <subscribe_id>
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
</details>
+41 -1
View File
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
orchestrator can determine when the consumer is ready, stop it cleanly,
and machine-read why it exited.
Scope of this branch: the five **contract** items below. Reconnect
Scope of this branch: the six **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
tracked separately and intentionally out of scope here.
@@ -159,6 +159,46 @@ marker; reconnecting an established Stream remains a separate mechanism.
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
operational recovery instructions.
### 6. Host runtime-token handoff
When the root command carries an explicit host-supplied `--token`, personal
event control requests and the foreground Stream use that token with higher
priority than local OAuth. A detached bus receives it only through the
owner-only local IPC transport:
1. The child starts in runtime-token mode with non-sensitive identity and
ticket metadata only; neither its argv nor environment contains the token.
2. The consumer sends `Hello` with `credential_mode=runtime_token`.
3. The bus advertises the additive `runtime_token_v1` capability and its
in-memory credential generation in `HelloAck`.
4. Only after that capability is confirmed does the consumer send a bounded
`credential_update` frame. The bus applies it with generation CAS, replies
with `credential_update_ack`, and registers the consumer only on success.
The bus blocks ticket acquisition until the first runtime credential arrives.
A later invocation may rotate Token A to Token B on a compatible existing bus;
the current WebSocket remains connected and the next ticket request or natural
reconnect uses B. If a 401 rejects the current runtime token, only an already
installed newer generation is retried; the runtime path never refreshes or
falls back to a local OAuth profile and never suggests `dws auth login`.
Clients do not send a token to a bus that lacks the capability, do not stop
other consumers automatically, and fail before printing the ready marker. With
no explicit `--token`, the original OAuth, refresh, profile, and old-client to
new-bus protocol behavior remains unchanged.
**Verification**
- T6a: a stale local Token A and root Token B produce control and ticket
requests authenticated only with B.
- T6b: compatible bus reuse supports A-to-B rotation and generation conflicts;
401 retries only an already-installed newer runtime token.
- T6c: an old bus receives no credential and remains running; the new consumer
exits before its ready marker.
- T6d: a canary credential is absent from child argv/environment, dry-run,
stdout/stderr, `bus.meta`, `bus.log`, run state, and returned errors.
- T6e: no-token OAuth, refresh, multi-profile, marker/cache, and bus-reuse tests
continue to pass.
## Out of scope (next branch)
**Reconnect resilience** — today `personal source` retries only
@@ -0,0 +1,84 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
func TestCrossPlatformCoverageChatMessageReceiptActionsBindToRunnableCommands(t *testing.T) {
testCases := []struct {
name string
payload map[string]any
}{
{
name: "send receipt awaiting status",
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
"openTaskId": "task-pending",
}),
},
{
name: "send receipt ready for message actions",
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
"openTaskId": "task-ready",
"openMessageId": "message-ready",
"openConversationId": "conversation-ready",
}),
},
{
name: "send status awaiting message reference",
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
"status": "PENDING",
}, "task-pending"),
},
{
name: "send status ready for message actions",
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
"openTaskId": "task-ready",
"openMessageId": "message-ready",
"openConversationId": "conversation-ready",
"status": "SUCCESS",
}, "task-ready"),
},
}
root := NewRootCommand()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actions, ok := testCase.payload["nextActions"].([]map[string]any)
if !ok || len(actions) == 0 {
t.Fatalf("nextActions = %#v, want non-empty []map[string]any", testCase.payload["nextActions"])
}
for index, action := range actions {
cliPath, ok := action["cliPath"].(string)
if !ok || strings.TrimSpace(cliPath) == "" {
t.Fatalf("nextActions[%d].cliPath = %#v, want non-empty string", index, action["cliPath"])
}
command, remaining, err := root.Find(strings.Fields(cliPath))
if err != nil {
t.Fatalf("nextActions[%d].cliPath %q does not bind: %v", index, cliPath, err)
}
if command == nil || len(remaining) != 0 || !command.Runnable() {
t.Fatalf("nextActions[%d].cliPath %q resolved to command=%v remaining=%v runnable=%v", index, cliPath, command, remaining, command != nil && command.Runnable())
}
arguments, ok := action["arguments"].(map[string]any)
if !ok {
t.Fatalf("nextActions[%d].arguments = %#v, want map[string]any", index, action["arguments"])
}
for name := range arguments {
if command.Flags().Lookup(name) == nil && command.InheritedFlags().Lookup(name) == nil {
t.Errorf("nextActions[%d] argument %q is not a flag of runnable command %q", index, name, cliPath)
}
}
}
})
}
}
+95 -36
View File
@@ -38,6 +38,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
@@ -74,18 +75,18 @@ var (
// newEventCommand returns the `event` parent command and all its subcommands.
// Wired into root.go's utilityCommands list.
func newEventCommand() *cobra.Command {
func newEventCommand(globalFlags ...*GlobalFlags) *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/event.json
// products.event). Catalog assembly stamps provenance contract_final.
contract.RegisterProductDecl(contract.ProductDecl{
ID: "event",
Selection: contract.ProductSelectionDecl{
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
AgentSummary: "实时监听当前用户相关的个人 IM 与 OA 审批事件,并管理订阅生命周期",
UseWhen: []string{
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
"需要实时监听未来发生的个人消息、消息动作、群生命周期或 OA 审批任务/实例事件,或管理个人事件订阅生命周期",
},
AvoidWhen: []string{
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
"查历史聊天或主动发消息用 chat;查询或处理审批实例/任务用 oa;配置开放平台应用事件回调用 dev app event",
},
},
})
@@ -99,12 +100,12 @@ func newEventCommand() *cobra.Command {
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventListenIMCommand(),
newEventConsumeCommand(),
newEventListenIMCommand(globalFlags...),
newEventConsumeCommand(globalFlags...),
newEventListCommand(),
newEventSchemaCommand(),
newEventStatusCommand(),
newEventStopCommand(),
newEventStatusCommandWithFlags(globalFlags...),
newEventStopCommandWithFlags(globalFlags...),
newEventBusCommand(),
)
return cmd
@@ -114,7 +115,7 @@ func newEventCommand() *cobra.Command {
// event consume
// ─────────────────────────────────────────────────────────────────────
func newEventConsumeCommand() *cobra.Command {
func newEventConsumeCommand(globalFlags ...*GlobalFlags) *cobra.Command {
var (
eventTypes []string
filter string
@@ -170,6 +171,8 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
return err
}
if as == "user" {
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
personalOpts.EventKeys = dedupePersonalEventKeys(args)
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
personalOpts.Flatten = flatten
@@ -333,7 +336,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.BoolVar(&force, "force", false,
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
f.BoolVar(&dryRun, "dry-run", false,
"仅打印解析后的配置,不连接 bus / 云端")
"仅打印解析后的配置;不创建订阅、不连接 bus;复用 --subscribe-id 时会只读查询控制面")
f.BoolVar(&foreground, "foreground", false,
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
@@ -398,22 +401,21 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
},
Selection: contract.SelectionSpec{
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
UseWhen: []string{
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件",
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
},
AvoidWhen: []string{
"普通 @我、指定发送人/群、全部单聊/群聊及 message/reaction/read/recall 监听优先使用 event +listen-im",
"只查历史聊天记录时用 chat 查询命令",
"查询、同意、拒绝、转交、撤销或发起审批时用 oa;配置应用事件回调时用 dev app event",
"只看事件目录/字段时用 event list / event schema",
},
Examples: []string{
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson",
"dws event consume user_oa_approval_task_created user_oa_approval_instance_finished --flatten --duration 10m --format ndjson",
"dws event consume user_im_group_member_added --group cid-example --flatten --max-events 1 --format ndjson",
},
},
},
@@ -564,6 +566,8 @@ func newEventBusCommand() *cobra.Command {
clientIDOverride string
idleTimeout time.Duration
sourceKindRaw string
runtimeTokenMode bool
identityHashFlag string
streamOpts eventStreamTicketOptions
)
cmd := &cobra.Command{
@@ -600,23 +604,45 @@ func newEventBusCommand() *cobra.Command {
sourceKind = dwsevent.SourceKindAppStream
}
if sourceKind == dwsevent.SourceKindPersonalStream {
identity, err := eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
var (
identity personal.Identity
identityHash string
)
if runtimeTokenMode {
identityHash = strings.TrimSpace(identityHashFlag)
if !validPersonalIdentityHash(identityHash) {
return failEarly(errors.New("event _bus: --identity-hash must be a 16-character hexadecimal identity hash in runtime token mode"))
}
if strings.TrimSpace(clientIDOverride) == "" {
return failEarly(errors.New("event _bus: --client-id is required in runtime token mode"))
}
identity = personal.Identity{
ClientID: strings.TrimSpace(clientIDOverride),
SourceID: personalEventStreamSourceID(streamOpts.SourceID),
}
} else {
var err error
identity, err = eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
}
if clientIDOverride != "" {
identity.ClientID = clientIDOverride
}
identityHash = dwsevent.IdentityHash(identity.Key())
}
if clientIDOverride != "" {
identity.ClientID = clientIDOverride
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
credentialBroker := newPersonalCredentialBroker(configDir, runtimeTokenMode, runtimeTokenMode)
src, err := eventNewPersonalSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: streamOpts.Mode,
TicketURL: streamOpts.TicketURL,
ClientIDOverride: clientIDOverride,
CredentialBroker: credentialBroker,
RuntimeTokenMode: runtimeTokenMode,
})
if err != nil {
return failEarly(err)
@@ -629,17 +655,18 @@ func newEventBusCommand() *cobra.Command {
}
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
IdleTimeout: idleTimeout,
ReadyPipe: readyPipe,
Logger: slog.Default(),
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
IdleTimeout: idleTimeout,
ReadyPipe: readyPipe,
Logger: slog.Default(),
CredentialBroker: credentialBroker,
}
bus.ApplyEnvTuning(&busCfg)
return eventBusRun(ctx, busCfg)
@@ -699,12 +726,18 @@ func newEventBusCommand() *cobra.Command {
"exit after this long with zero consumers (0 = disabled)")
cmd.Flags().StringVar(&sourceKindRaw, "source-kind", string(dwsevent.SourceKindAppStream),
"event source kind: app_stream|personal_stream")
cmd.Flags().BoolVar(&runtimeTokenMode, "runtime-token-mode", false,
"use an owner-injected in-memory runtime credential")
cmd.Flags().StringVar(&identityHashFlag, "identity-hash", "",
"pre-resolved non-sensitive personal identity hash")
cmd.Flags().StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
"用户 Stream 建联模式:空=SDK app credential;normal/custom=portal 取票")
cmd.Flags().StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"用户 Stream sourceId;personal_stream 开源版默认 open")
cmd.Flags().StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
"用户 Stream 取票 URL;personal_stream 默认由 MCP base URL 派生")
_ = cmd.Flags().MarkHidden("runtime-token-mode")
_ = cmd.Flags().MarkHidden("identity-hash")
return cmd
}
@@ -823,6 +856,10 @@ func newEventListCommand() *cobra.Command {
// ─────────────────────────────────────────────────────────────────────
func newEventStatusCommand() *cobra.Command {
return newEventStatusCommandWithFlags()
}
func newEventStatusCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
var (
all bool
allEditions bool
@@ -848,6 +885,8 @@ func newEventStatusCommand() *cobra.Command {
return fmt.Errorf("event status: %w", err)
}
personalOpts.Format = formatRaw
personalOpts.ExplicitToken = eventExplicitToken(globalFlags)
personalOpts.ClientIDOverride = eventExplicitClientID(globalFlags)
return eventRunPersonalStatus(c, personalOpts)
}
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
@@ -1139,6 +1178,10 @@ func renderStatusBlock(w io.Writer, qs busctl.EntryStatus) {
}
func newEventStopCommand() *cobra.Command {
return newEventStopCommandWithFlags()
}
func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
var asIdentity string
var opts personalStopOptions
cmd := &cobra.Command{
@@ -1159,6 +1202,8 @@ func newEventStopCommand() *cobra.Command {
}
if as == "user" {
opts.SubscribeID = firstArg(args)
opts.ExplicitToken = eventExplicitToken(globalFlags)
opts.ClientIDOverride = eventExplicitClientID(globalFlags)
if eventStopDryRun(c) {
return writeEventStopDryRun(c, as, opts)
}
@@ -1262,6 +1307,20 @@ func eventStopDryRun(cmd *cobra.Command) bool {
return value
}
func eventExplicitToken(globalFlags []*GlobalFlags) string {
if len(globalFlags) == 0 || globalFlags[0] == nil {
return ""
}
return strings.TrimSpace(globalFlags[0].Token)
}
func eventExplicitClientID(globalFlags []*GlobalFlags) string {
if len(globalFlags) == 0 || globalFlags[0] == nil {
return ""
}
return strings.TrimSpace(globalFlags[0].ClientID)
}
func writeEventStopDryRun(cmd *cobra.Command, identity string, opts personalStopOptions) error {
payload := map[string]any{
"dry_run": true,
+6 -4
View File
@@ -65,13 +65,13 @@ func (eventTargetReader) CallMCPData(product, tool string, params map[string]any
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
func newEventListenIMCommand() *cobra.Command {
func newEventListenIMCommand(globalFlags ...*GlobalFlags) *cobra.Command {
var opts listenIMOptions
cmd := &cobra.Command{
Use: "+listen-im",
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期;本命令只处理 IM,不接收 OA 审批事件。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
@@ -91,6 +91,8 @@ func newEventListenIMCommand() *cobra.Command {
StreamTicketMode: opts.StreamTicketMode,
StreamTicketURL: opts.StreamTicketURL,
StreamSourceID: opts.StreamSourceID,
ExplicitToken: eventExplicitToken(globalFlags),
ClientIDOverride: eventExplicitClientID(globalFlags),
Common: commonConsumeOptions{
FormatRaw: "ndjson",
MaxEvents: opts.MaxEvents,
@@ -143,12 +145,12 @@ func newEventListenIMCommand() *cobra.Command {
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
AgentSummary: "按 @我、发送人、群或全量范围监听普通 IM message/reaction/read/recall 事件",
UseWhen: []string{
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
},
AvoidWhen: []string{
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
"OA 审批事件、群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层控制使用 event consume;只查历史消息使用 chat 查询入口",
},
Examples: []string{
"dws event +listen-im --kind at-me --max-events 1",
+20
View File
@@ -28,6 +28,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -184,6 +185,25 @@ func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
return nil
}
// releaseRuntimeTokenFailure releases the in-flight claim without recording a
// cross-invocation hold. A host may supply a fresh token on the very next
// command, which must be allowed to retry immediately.
func (r *personalSubscriptionAttemptReservation) releaseRuntimeTokenFailure() error {
if r == nil {
return runtimecred.ErrRuntimeTokenRejected
}
if r.store == nil || r.claim == nil {
return personalSubscriptionGuardError(errors.Join(
runtimecred.ErrRuntimeTokenRejected,
errors.New("personal event: subscription attempt reservation is incomplete"),
))
}
if err := r.store.Release(r.claim); err != nil {
return personalSubscriptionGuardError(errors.Join(runtimecred.ErrRuntimeTokenRejected, err))
}
return runtimecred.ErrRuntimeTokenRejected
}
func (r *personalSubscriptionAttemptReservation) completeFailure(
ctx context.Context,
failedIndex int,
+2 -2
View File
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
}
}
if publicCount != 16 {
t.Fatalf("public personal events = %d, want 16", publicCount)
if publicCount != 22 {
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
}
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
if !ruleTypes[ruleType] {
+528 -72
View File
@@ -14,6 +14,7 @@
package app
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -26,6 +27,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"text/tabwriter"
"time"
@@ -39,6 +41,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -81,6 +84,8 @@ type personalConsumeOptions struct {
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
ExplicitToken string
ClientIDOverride string
}
type personalListOptions struct {
@@ -91,19 +96,23 @@ type personalListOptions struct {
}
type personalStatusOptions struct {
EventKey string
Status string
SubscribeID string
Format string
ControlBaseURL string
StreamSourceID string
EventKey string
Status string
SubscribeID string
Format string
ControlBaseURL string
StreamSourceID string
ExplicitToken string
ClientIDOverride string
}
type personalStopOptions struct {
SubscribeID string
All bool
ControlBaseURL string
StreamSourceID string
SubscribeID string
All bool
ControlBaseURL string
StreamSourceID string
ExplicitToken string
ClientIDOverride string
}
type personalStreamSourceOptions struct {
@@ -112,6 +121,8 @@ type personalStreamSourceOptions struct {
TicketMode string
TicketURL string
ClientIDOverride string
CredentialBroker *runtimecred.Broker
RuntimeTokenMode bool
}
var (
@@ -141,10 +152,19 @@ var (
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken
personalLoadTokenData = authpkg.LoadTokenData
personalLoadProfiles = authpkg.LoadProfiles
personalClientID = authpkg.ClientID
personalRuntimeEventClientID = runtimePersonalEventClientID
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
)
func runtimePersonalEventClientID() string {
if clientID := strings.TrimSpace(edition.Get().AuthClientID); clientID != "" {
return clientID
}
return strings.TrimSpace(os.Getenv("DWS_CLIENT_ID"))
}
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
@@ -201,12 +221,15 @@ func newEventSchemaCommand() *cobra.Command {
},
Selection: contract.SelectionSpec{
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"},
AvoidWhen: []string{
"查询 CLI 命令参数契约时用顶层 dws schema",
"要实际收事件时用 event consume",
},
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
Examples: []string{
"dws event schema user_im_message_receive_at --flatten --format json",
"dws event schema user_oa_approval_task_created --flatten --format json",
},
},
},
})
@@ -262,6 +285,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalSubscriptionValidationError(err)
}
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
@@ -276,7 +302,7 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -284,23 +310,41 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
spawnProfileSelector := ""
if strings.TrimSpace(opts.ExplicitToken) == "" {
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
}
spawnArgs := personalBusSpawnArgsForToken(
identity,
identityHash,
opts.StreamTicketMode,
opts.StreamTicketURL,
spawnProfileSelector,
opts.ExplicitToken,
)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
} else {
_, eventKey, _, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
opts.EventKey = eventKey
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -327,7 +371,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
SpawnExtraArgs: spawnArgs,
RuntimeToken: strings.TrimSpace(opts.ExplicitToken),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -356,20 +401,31 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
}
var foregroundSource *source.PersonalSource
var (
foregroundSource *source.PersonalSource
foregroundBroker *runtimecred.Broker
)
if opts.Common.Foreground {
explicitToken := strings.TrimSpace(opts.ExplicitToken)
foregroundBroker = newPersonalCredentialBroker(configDir, explicitToken != "", false)
if explicitToken != "" {
if _, err := foregroundBroker.Update(0, explicitToken); err != nil {
return personalSubscriptionValidationError(err)
}
}
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
CredentialBroker: foregroundBroker,
RuntimeTokenMode: explicitToken != "",
})
if err != nil {
return personalSubscriptionValidationError(err)
}
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
var attempt *personalSubscriptionAttemptReservation
if strings.TrimSpace(opts.SubscribeID) == "" {
attempt, err = reservePersonalSubscriptionAttempts(
@@ -385,6 +441,10 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(err) {
err = attempt.releaseRuntimeTokenFailure()
return fmt.Errorf("event consume --as user: %w", err)
}
err = attempt.completeFailure(ctx, 0, 0, err, nil)
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -408,9 +468,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
)
return fmt.Errorf("event consume --as user: %w", err)
}
cleanup := func(cleanupCtx context.Context) {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
ownsSubscription := selfCreated || opts.Ephemeral
var cleanupOnce sync.Once
cleanupOwnedSubscription := func(cleanupCtx context.Context) {
if !ownsSubscription {
return
}
cleanupOnce.Do(func() {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
})
}
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
@@ -421,19 +489,19 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
IdentityHash: identityHash,
}); err != nil {
wrapped := fmt.Errorf("save run state: %w", err)
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
if attempt != nil {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
classification := personalSubscriptionLocalFailure()
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
cleanup(cleanupCtx)
}
cleanupOwnedSubscription(cleanupCtx)
return fmt.Errorf("event consume --as user: %w", wrapped)
}
if err := attempt.completeSuccess(); err != nil {
cleanup(context.Background())
cleanupOwnedSubscription(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
// Ownership-based cleanup: a subscription this run CREATED is
@@ -442,9 +510,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
// leaks server-side. A subscription REUSED via --subscribe-id is left
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
// either way.
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
if opts.Ephemeral || selfCreated {
defer cleanup(context.Background())
if ownsSubscription {
defer cleanupOwnedSubscription(context.Background())
}
cfg.EventKey = eventKey
@@ -456,27 +523,20 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
if opts.Common.Foreground {
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: foregroundSource,
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: foregroundSource,
CredentialBroker: foregroundBroker,
}
bus.ApplyEnvTuning(&busCfg)
err = personalBusRun(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
return personalBusRun(ctx, busCfg)
}
err = personalConsumeRun(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
return personalConsumeRun(ctx, cfg)
}
type personalMultiSubscription struct {
@@ -505,7 +565,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
ctx := c.Context()
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
@@ -513,7 +573,10 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
spawnProfileSelector := ""
if strings.TrimSpace(opts.ExplicitToken) == "" {
spawnProfileSelector = personalBusProfileSelector(configDir, identity)
}
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
@@ -522,7 +585,7 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
@@ -548,8 +611,9 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
return nil
}
baseCfg.RuntimeToken = strings.TrimSpace(opts.ExplicitToken)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
attempt, err := reservePersonalSubscriptionAttempts(
workDir,
client,
@@ -586,6 +650,10 @@ func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions)
if personalSubscriptionCanceled(ctx, cause) {
cleanupCtx = ctx
}
if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(cause) {
cleanup(cleanupCtx)
return attempt.releaseRuntimeTokenFailure()
}
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
// Persist the hold (or release a canceled claim) before any potentially
// slow remote rollback. Otherwise the attempt lease can expire while
@@ -688,6 +756,9 @@ func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsume
if !def.Public {
return nil, personal.PublicAvailabilityError(eventKey)
}
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
return nil, err
}
switch def.RuleType {
case "singleChat", "sender":
hasUserScope = true
@@ -814,6 +885,9 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
}
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return err
}
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -826,6 +900,37 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
return err
}
func validatePersonalOAOptions(eventKey string, opts personalConsumeOptions) error {
changed := personalOAOptionNames(opts)
if len(changed) == 0 {
return nil
}
def, ok := personalLookupDefinition(strings.TrimSpace(eventKey))
if !ok || def.Category != "oa" {
return nil
}
return fmt.Errorf("%s not supported for OA event %s", strings.Join(changed, ", "), eventKey)
}
func personalOAOptionNames(opts personalConsumeOptions) []string {
var changed []string
for _, item := range []struct {
name string
value string
}{
{name: "--user", value: opts.UserID},
{name: "--open-dingtalk-id", value: opts.OpenDingTalkID},
{name: "--group", value: opts.GroupID},
{name: "--query", value: opts.QueryCSV},
{name: "--filter-json", value: opts.FilterJSON},
} {
if strings.TrimSpace(item.value) != "" {
changed = append(changed, item.name)
}
}
return changed
}
type personalPreparedSubscription struct {
EventKey string
RuleType string
@@ -839,6 +944,9 @@ func preparePersonalSubscription(identity personal.Identity, opts personalConsum
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalPreparedSubscription{}, err
}
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return personalPreparedSubscription{}, err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -885,13 +993,32 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
if err != nil {
return nil, "", "", err
}
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
if sub == nil {
return nil, "", "", errors.New("personal event: server returned an empty subscription")
}
requestedEventKey := strings.TrimSpace(opts.EventKey)
actualEventKey := strings.TrimSpace(sub.EventKey)
if requestedEventKey != "" && actualEventKey != "" && requestedEventKey != actualEventKey {
return nil, "", "", fmt.Errorf(
"event_key %q does not match reused subscription %q event_key %q",
requestedEventKey,
strings.TrimSpace(opts.SubscribeID),
actualEventKey,
)
}
eventKey := actualEventKey
if eventKey == "" {
eventKey = requestedEventKey
}
if eventKey == "" {
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
}
if err := ensurePublicPersonalEvent(eventKey); err != nil {
return nil, "", "", err
}
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
return nil, "", "", err
}
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
if ruleType == "" {
if def, ok := personal.Lookup(eventKey); ok {
@@ -914,7 +1041,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
return err
}
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
@@ -946,7 +1073,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
if status == "" || status == "all" {
status = ""
}
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken), ctx, personal.ListOptions{
Status: status,
EventKey: opts.EventKey,
SubscribeID: opts.SubscribeID,
@@ -967,6 +1094,15 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
return nil
}
func personalRuntimeTokenControlRejection(err error) bool {
var apiErr *personal.APIError
if !errors.As(err, &apiErr) || apiErr == nil {
return false
}
return apiErr.HTTPStatus == http.StatusUnauthorized ||
strings.EqualFold(strings.TrimSpace(apiErr.Code), "RUNTIME_TOKEN_REJECTED")
}
func ensurePublicPersonalEvent(eventKey string) error {
eventKey = strings.TrimSpace(eventKey)
if eventKey == "" {
@@ -1049,7 +1185,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
}
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
@@ -1061,7 +1197,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken)
for _, id := range subscribeIDs {
if err := personalDeleteSubscription(client, ctx, id); err != nil {
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
@@ -1177,6 +1313,138 @@ func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, bu
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
}
func resolvePersonalEventIdentityForToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
explicitToken = strings.TrimSpace(explicitToken)
if explicitToken == "" {
return personalResolveEventIdentity(ctx, configDir, sourceIDOverride)
}
clientIDOverride := ""
if len(clientIDOverrides) > 0 {
clientIDOverride = strings.TrimSpace(clientIDOverrides[0])
}
return resolvePersonalEventIdentityWithToken(ctx, configDir, sourceIDOverride, explicitToken, clientIDOverride)
}
// resolvePersonalEventIdentityWithToken resolves only non-sensitive identity
// metadata around a caller-supplied bearer token. It intentionally does not
// call LoadTokenData or any refresh-capable token resolver: an explicit root
// --token must never be replaced with, persisted into, or used to refresh a
// local OAuth profile.
func resolvePersonalEventIdentityWithToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) {
explicitToken = strings.TrimSpace(explicitToken)
if explicitToken == "" {
return resolvePersonalEventIdentity(ctx, configDir, sourceIDOverride)
}
if strings.Contains(strings.TrimSpace(authpkg.RuntimeProfile()), ",") {
return personal.Identity{}, fmt.Errorf("personal events require exactly one --profile")
}
corpID := resolveRuntimeDefault(ctx, "$corpId")
userID := resolveRuntimeDefault(ctx, "$currentUserId")
clientID := ""
if len(clientIDOverrides) > 0 {
clientID = strings.TrimSpace(clientIDOverrides[0])
}
if clientID == "" {
// An edition hook or explicit environment value is runtime identity,
// not persisted app state. Resolve it before profiles.json so a complete
// host context never depends on local OAuth metadata health.
clientID = strings.TrimSpace(personalRuntimeEventClientID())
}
explicitProfile := strings.TrimSpace(authpkg.RuntimeProfile()) != ""
if explicitProfile || corpID == "" || userID == "" || clientID == "" {
profile, err := personalEventProfileMetadata(configDir)
if err != nil {
// A user-selected --profile remains a strict contract. Without an
// explicit selector, profiles.json is optional metadata for a
// host-managed bearer: malformed or stale persisted state must not
// override complete runtime defaults or prevent the later global
// client-id fallback.
if explicitProfile {
return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err)
}
profile = nil
}
if profile != nil {
if corpID == "" {
corpID = strings.TrimSpace(profile.CorpID)
}
if userID == "" {
userID = strings.TrimSpace(profile.UserID)
}
if clientID == "" {
clientID = strings.TrimSpace(profile.ClientID)
}
}
}
if clientID == "" {
// Persisted/global app credentials are only a fallback after the
// selected profile, so an old app config cannot override profile.ClientID.
clientID = strings.TrimSpace(personalClientID())
}
if clientID == "" {
if id, _, _, _, resolveErr := personalResolveAppCredentialsStrict(configDir); resolveErr == nil {
clientID = strings.TrimSpace(id)
}
}
if clientID == "" {
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
}
sourceID := strings.TrimSpace(sourceIDOverride)
if sourceID == "" {
sourceID = personalEventStreamSourceID("")
}
localSubject := ""
if corpID == "" || userID == "" {
localSubject = personalTokenSubject("access", explicitToken)
}
return personal.Identity{
LocalSubject: localSubject,
CorpID: corpID,
UserID: userID,
ClientID: clientID,
SourceID: sourceID,
}, nil
}
func personalEventProfileMetadata(configDir string) (*authpkg.Profile, error) {
cfg, err := personalLoadProfiles(configDir)
if err != nil {
return nil, err
}
selector := strings.TrimSpace(authpkg.RuntimeProfile())
explicitSelector := selector != ""
if strings.Contains(selector, ",") {
return nil, fmt.Errorf("personal events require exactly one --profile")
}
if cfg == nil || len(cfg.Profiles) == 0 {
if explicitSelector {
return nil, fmt.Errorf("profile %q not found", selector)
}
return nil, nil
}
if selector == "" {
selector = strings.TrimSpace(cfg.CurrentProfile)
}
if selector == "" {
return nil, nil
}
profile, err := selectPersonalEventProfileMetadata(cfg, selector, make(map[string]struct{}))
if err != nil && !explicitSelector {
// A stale persisted CurrentProfile must not make a host-provided bearer
// unusable. Runtime defaults and the one-way local subject are sufficient
// to isolate the event bus without consulting local OAuth credentials.
return nil, nil
}
return profile, err
}
func selectPersonalEventProfileMetadata(cfg *authpkg.ProfilesConfig, selector string, visited map[string]struct{}) (*authpkg.Profile, error) {
_ = visited // retained for the focused compatibility seam used by app tests.
return authpkg.ResolveProfileMetadata(cfg, strings.TrimSpace(selector))
}
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "")
if err != nil {
@@ -1231,7 +1499,11 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
}, nil
}
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity, explicitTokens ...string) *personal.Client {
explicitToken := ""
if len(explicitTokens) > 0 {
explicitToken = strings.TrimSpace(explicitTokens[0])
}
identity.AccessToken = ""
client := personal.NewClient(baseURL, identity)
version := strings.TrimSpace(RawVersion())
@@ -1240,12 +1512,146 @@ func newPersonalEventControlClient(configDir, baseURL string, identity personal.
}
client.ClientVersion = version
client.UserAgent = "dws-cli/" + version
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
if explicitToken != "" {
client.AccessTokenProvider = func(context.Context) (string, error) { return explicitToken, nil }
client.HTTPClient.Transport = runtimeTokenControlTransport{base: http.DefaultTransport, token: explicitToken}
client.HTTPClient.CheckRedirect = runtimeTokenRedirectPolicy
} else {
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
}
}
return client
}
// runtimeTokenRedirectPolicy prevents Go's redirect machinery from copying
// DWS's custom x-user-access-token header to another authority. Returning
// ErrUseLastResponse keeps the 3xx response available to the caller without a
// url.Error that could echo an attacker-controlled Location value.
func runtimeTokenRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) == 0 || req == nil || req.URL == nil || via[0] == nil || via[0].URL == nil {
return http.ErrUseLastResponse
}
origin := via[0].URL
if !strings.EqualFold(strings.TrimSpace(req.URL.Host), strings.TrimSpace(origin.Host)) {
return http.ErrUseLastResponse
}
if strings.EqualFold(origin.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
return http.ErrUseLastResponse
}
return nil
}
const runtimeTokenControlErrorBody = `{"code":"RUNTIME_TOKEN_REJECTED","message":"event runtime token was rejected; retry with a fresh host credential"}`
// runtimeTokenControlTransport scrubs an explicit bearer from every response
// body and diagnostic header before the control client decodes or logs it. A
// 401 is replaced with a fixed rejection envelope so untrusted response text
// can never escape through stderr or debug logs.
type runtimeTokenControlTransport struct {
base http.RoundTripper
token string
}
func (t runtimeTokenControlTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
resp, err := base.RoundTrip(req)
if err != nil {
if token := strings.TrimSpace(t.token); token != "" && strings.Contains(err.Error(), token) {
return nil, errors.New("personal event: runtime-token control request failed")
}
return nil, err
}
if resp == nil {
return resp, err
}
token := strings.TrimSpace(t.token)
for key, values := range resp.Header {
for i := range values {
if token != "" {
values[i] = strings.ReplaceAll(values[i], token, "<redacted-runtime-token>")
}
}
resp.Header[key] = values
}
var responseBody []byte
if resp.Body != nil {
responseBody, err = io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
_ = resp.Body.Close()
if err != nil {
return nil, errors.New("personal event: read runtime-token control response")
}
}
if resp.StatusCode == http.StatusUnauthorized {
responseBody = []byte(runtimeTokenControlErrorBody)
} else if token != "" {
responseBody = redactRuntimeTokenResponseBody(responseBody, token)
}
resp.Body = io.NopCloser(bytes.NewReader(responseBody))
resp.ContentLength = int64(len(responseBody))
if resp.Header == nil {
resp.Header = make(http.Header)
}
resp.Header.Set("Content-Type", "application/json")
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(responseBody)))
return resp, nil
}
func redactRuntimeTokenResponseBody(data []byte, token string) []byte {
token = strings.TrimSpace(token)
if len(data) == 0 || token == "" {
return data
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
var decoded any
if err := decoder.Decode(&decoded); err == nil {
var trailing any
if trailingErr := decoder.Decode(&trailing); errors.Is(trailingErr, io.EOF) {
if redacted, changed := redactRuntimeTokenJSONValue(decoded, token); changed {
if encoded, marshalErr := json.Marshal(redacted); marshalErr == nil {
return encoded
}
}
}
}
return bytes.ReplaceAll(data, []byte(token), []byte("<redacted-runtime-token>"))
}
func redactRuntimeTokenJSONValue(value any, token string) (any, bool) {
switch typed := value.(type) {
case string:
redacted := strings.ReplaceAll(typed, token, "<redacted-runtime-token>")
return redacted, redacted != typed
case []any:
changed := false
for i := range typed {
var itemChanged bool
typed[i], itemChanged = redactRuntimeTokenJSONValue(typed[i], token)
changed = changed || itemChanged
}
return typed, changed
case map[string]any:
changed := false
redactedMap := make(map[string]any, len(typed))
for key, item := range typed {
redactedKey := strings.ReplaceAll(key, token, "<redacted-runtime-token>")
redacted, itemChanged := redactRuntimeTokenJSONValue(item, token)
redactedMap[redactedKey] = redacted
changed = changed || itemChanged || redactedKey != key
}
if !changed {
return typed, false
}
return redactedMap, true
default:
return value, false
}
}
func personalTokenSubject(kind, token string) string {
token = strings.TrimSpace(token)
if token == "" {
@@ -1255,6 +1661,15 @@ func personalTokenSubject(kind, token string) string {
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
}
func validPersonalIdentityHash(value string) bool {
value = strings.TrimSpace(value)
if len(value) != 16 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func resolveRuntimeDefault(ctx context.Context, key string) string {
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
if fn := fnMap()[key]; fn != nil {
@@ -1292,20 +1707,42 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
}
clientSecret = secret
}
credentialBroker := opts.CredentialBroker
if credentialBroker == nil {
credentialBroker = newPersonalCredentialBroker(opts.ConfigDir, false, false)
}
httpClient := &http.Client{Timeout: 30 * time.Second}
if opts.RuntimeTokenMode {
httpClient.CheckRedirect = runtimeTokenRedirectPolicy
}
_ = ctx
return source.NewPersonal(source.PersonalConfig{
AccessTokenProvider: func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "")
return credentialBroker.Resolve(ctx)
},
ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) {
return personalForceRefreshRejectedToken(ctx, opts.ConfigDir, rejectedToken)
return credentialBroker.RefreshRejected(ctx, rejectedToken)
},
ClassifyRetryReject: credentialBroker.ClassifyRejectedAfterRetry,
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
TicketURL: ticketURL,
TicketMode: mode,
HTTPClient: httpClient,
})
}
func newPersonalCredentialBroker(configDir string, requireSeed, requireActivation bool) *runtimecred.Broker {
return runtimecred.New(runtimecred.Config{
RequireSeed: requireSeed,
RequireActivation: requireActivation,
LocalResolve: func(ctx context.Context) (string, error) {
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
},
LocalRefresh: func(ctx context.Context, rejectedToken string) (string, error) {
return personalForceRefreshRejectedToken(ctx, configDir, rejectedToken)
},
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
TicketURL: ticketURL,
TicketMode: mode,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
}
@@ -1370,6 +1807,25 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
return args
}
func personalBusSpawnArgsForToken(identity personal.Identity, identityHash, ticketMode, ticketURL, profileSelector, explicitToken string) []string {
if strings.TrimSpace(explicitToken) == "" {
return personalBusSpawnArgs(identity, ticketMode, ticketURL, profileSelector)
}
args := []string{
"--source-kind", string(dwsevent.SourceKindPersonalStream),
"--runtime-token-mode",
"--identity-hash", strings.TrimSpace(identityHash),
"--stream-source-id", strings.TrimSpace(identity.SourceID),
}
if strings.TrimSpace(ticketMode) != "" {
args = append(args, "--stream-ticket-mode", strings.TrimSpace(ticketMode))
}
if strings.TrimSpace(ticketURL) != "" {
args = append(args, "--stream-ticket-url", strings.TrimSpace(ticketURL))
}
return args
}
func personalEventTypes(eventKey string, explicit []string) []string {
if len(explicit) > 0 {
return explicit
@@ -18,6 +18,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -36,18 +37,20 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
}
}
oldGet := personalGetSubscription
oldCreate := personalCreateSubscription
t.Cleanup(func() {
personalGetSubscription = oldGet
personalCreateSubscription = oldCreate
})
testseam.Protect(t, &personalGetSubscription)
testseam.Protect(t, &personalCreateSubscription)
client := personal.NewClient("https://example.test", personal.Identity{})
wantErr := errors.New("subscription")
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("get subscription error = %v", err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return nil, nil
}
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil || !strings.Contains(err.Error(), "empty subscription") {
t.Fatalf("nil subscription = %v", err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{}, nil
}
+627
View File
@@ -0,0 +1,627 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/spf13/cobra"
)
func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
list := newEventListCommand()
list.SilenceUsage = true
list.SilenceErrors = true
var listOut bytes.Buffer
list.SetOut(&listOut)
list.SetArgs([]string{"--category", "oa"})
if err := list.Execute(); err != nil {
t.Fatalf("event list --category oa error = %v", err)
}
tests := []struct {
eventKey string
properties []string
}{
{
eventKey: personal.EventOAApprovalTaskCreated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalTaskFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalTaskRedirected,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceStarted,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceTerminated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "result", "create_time", "finish_time",
"event_time",
},
},
}
for _, tt := range tests {
eventKey := tt.eventKey
if !strings.Contains(listOut.String(), eventKey) {
t.Fatalf("OA event list missing %s:\n%s", eventKey, listOut.String())
}
schema := newEventSchemaCommand()
schema.SilenceUsage = true
schema.SilenceErrors = true
var schemaOut bytes.Buffer
schema.SetOut(&schemaOut)
schema.SetArgs([]string{eventKey, "--flatten"})
if err := schema.Execute(); err != nil {
t.Fatalf("event schema %s --flatten error = %v", eventKey, err)
}
var doc map[string]any
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
t.Fatalf("decode schema for %s: %v\n%s", eventKey, err, schemaOut.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "all" || doc["jq_root_path"] != "." {
t.Fatalf("schema document for %s = %#v", eventKey, doc)
}
schemaBody, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema body for %s = %#v", eventKey, doc["schema"])
}
properties, ok := schemaBody["properties"].(map[string]any)
if !ok || len(properties) != len(tt.properties) {
t.Fatalf("schema properties for %s = %#v, want %d fields", eventKey, schemaBody["properties"], len(tt.properties))
}
for _, name := range tt.properties {
if _, ok := properties[name].(map[string]any); !ok {
t.Fatalf("schema property %s for %s = %#v", name, eventKey, properties[name])
}
}
if _, ok := properties["payload"]; ok {
t.Fatalf("schema for %s exposed generic payload: %#v", eventKey, properties)
}
}
if strings.Contains(listOut.String(), personal.EventMention) {
t.Fatalf("OA category list leaked IM event:\n%s", listOut.String())
}
}
func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldGet := personalGetSubscription
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalGetSubscription = oldGet
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}, nil
}
personalGetSubscription = func(_ *personal.Client, _ context.Context, subscribeID string) (*personal.Subscription, error) {
switch subscribeID {
case "oa-sub-task":
return &personal.Subscription{
SubscribeID: subscribeID,
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
case "im-sub-at":
return &personal.Subscription{
SubscribeID: subscribeID,
EventKey: personal.EventMention,
RuleType: "at",
}, nil
default:
t.Fatalf("unexpected subscription lookup %q", subscribeID)
return nil, nil
}
}
oaEvents := []string{
personal.EventOAApprovalTaskCreated,
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
for _, eventKey := range oaEvents {
t.Run(eventKey+"/dry-run", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{eventKey, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("OA dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "event_types : "+eventKey) {
t.Fatalf("OA dry-run does not select %s:\n%s", eventKey, stderr.String())
}
})
for _, args := range [][]string{
{"--user", "user-1"},
{"--open-dingtalk-id", "open-user-1"},
{"--group", "cid-1"},
{"--query", "urgent"},
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
} {
name := strings.TrimPrefix(args[0], "--")
t.Run(eventKey+"/reject-"+name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(append([]string{eventKey}, append(args, "--dry-run")...))
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("OA consume %s error = %v, want unsupported option", args[0], err)
}
})
}
}
t.Run("multi-dry-run", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs(append(append([]string(nil), oaEvents...), "--dry-run"))
if err := cmd.Execute(); err != nil {
t.Fatalf("multi OA dry-run error = %v", err)
}
for _, eventKey := range oaEvents {
want := "event_key=" + eventKey + " rule_type=all rule_param={}"
if !strings.Contains(stderr.String(), want) {
t.Fatalf("multi OA dry-run missing %q:\n%s", want, stderr.String())
}
}
})
reuseOverrides := [][]string{
{"--user", "user-1"},
{"--open-dingtalk-id", "open-user-1"},
{"--group", "cid-1"},
{"--query", "urgent"},
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
}
t.Run("reuse-dry-run/implicit-event-key/resolves-oa-event", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("implicit reused OA dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "event_types : "+personal.EventOAApprovalTaskCreated) {
t.Fatalf("implicit reused OA dry-run did not resolve event key:\n%s", stderr.String())
}
})
for _, explicitEventKey := range []bool{true, false} {
mode := "implicit-event-key"
if explicitEventKey {
mode = "explicit-event-key"
}
for _, override := range reuseOverrides {
flag := override[0]
t.Run("reuse-dry-run/"+mode+"/"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
args := make([]string, 0, 6)
if explicitEventKey {
args = append(args, personal.EventOAApprovalTaskCreated)
}
args = append(args, "--subscribe-id", "oa-sub-task", flag, override[1], "--dry-run")
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), flag+" not supported for OA event") {
t.Fatalf("%s reused OA dry-run %s error = %v", mode, flag, err)
}
})
}
}
t.Run("reuse-dry-run/implicit-im-remains-supported", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--subscribe-id", "im-sub-at", "--query", "urgent", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("implicit reused IM dry-run error = %v", err)
}
})
for _, override := range reuseOverrides {
flag, value := override[0], override[1]
t.Run("multi-reject-"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
args := append([]string(nil), oaEvents...)
args = append(args, flag, value, "--dry-run")
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported for OA event") {
t.Fatalf("multi OA consume %s error = %v", flag, err)
}
})
}
for _, test := range []struct {
name string
args []string
}{
{
name: "message query remains supported",
args: []string{personal.EventMention, "--query", "urgent", "--dry-run"},
},
{
name: "single group lifecycle filter remains supported",
args: []string{
personal.EventGroupUpdated,
"--group", "cid-1",
"--filter-json", `{"field":"future","op":"eq","value":"value"}`,
"--dry-run",
},
},
} {
t.Run(test.name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(test.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("existing IM consume behavior changed: %v", err)
}
})
}
}
func TestCrossPlatformCoveragePersonalOAValidationBranches(t *testing.T) {
invalid := personalConsumeOptions{
EventKey: personal.EventOAApprovalTaskCreated,
UserID: "user-1",
}
if err := validatePersonalSubscriptionOptions(invalid); err == nil ||
!strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("validatePersonalSubscriptionOptions() error = %v", err)
}
if _, err := preparePersonalSubscription(personal.Identity{}, invalid); err == nil ||
!strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("preparePersonalSubscription() error = %v", err)
}
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "oa-sub-without-event-key",
RuleType: "all",
}, nil
}
_, _, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{
SubscribeID: "oa-sub-without-event-key",
EventKey: personal.EventOAApprovalTaskCreated,
UserID: "user-1",
},
)
if err == nil || !strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("ensurePersonalSubscription() error = %v", err)
}
}
func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *testing.T) {
restoreMany := installPersonalManySeams(t)
defer restoreMany()
oldCreate := personalCreateSubscription
defer func() { personalCreateSubscription = oldCreate }()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
identity := personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return identity, nil
}
var requests []personal.CreateSubscriptionRequest
personalCreateSubscription = func(_ *personal.Client, _ context.Context, req personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
requests = append(requests, req)
return &personal.Subscription{SubscribeID: "sub-" + req.EventKey}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
var states []personal.RunState
personalUpsertRunState = func(_ string, state personal.RunState) error {
states = append(states, state)
return nil
}
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
runManyCalls := 0
var gotSpecs []consume.ConsumerSpec
personalConsumeRunMany = func(_ context.Context, _ consume.Config, specs []consume.ConsumerSpec) error {
runManyCalls++
gotSpecs = append([]consume.ConsumerSpec(nil), specs...)
return nil
}
eventKeys := []string{
personal.EventOAApprovalTaskCreated,
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
if err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: eventKeys,
Flatten: true,
}); err != nil {
t.Fatalf("multi OA consume error = %v", err)
}
if runManyCalls != 1 {
t.Fatalf("RunMany calls = %d, want one shared-bus consume call", runManyCalls)
}
if len(requests) != len(eventKeys) || len(states) != len(eventKeys) || len(gotSpecs) != len(eventKeys) {
t.Fatalf("requests=%d states=%d specs=%d, want %d each", len(requests), len(states), len(gotSpecs), len(eventKeys))
}
for i, eventKey := range eventKeys {
req := requests[i]
if req.EventKey != eventKey || req.RuleType != "all" || req.RuleParam == nil || len(req.RuleParam) != 0 || req.Filter != nil {
t.Fatalf("subscription request[%d] = %#v, want %s all/{}", i, req, eventKey)
}
if states[i].EventKey != eventKey || states[i].RuleType != "all" {
t.Fatalf("run state[%d] = %#v", i, states[i])
}
wantSpec := consume.ConsumerSpec{
EventKey: eventKey,
EventTypes: []string{eventKey},
SubscribeID: "sub-" + eventKey,
ReadySubscribeID: "sub-" + eventKey,
}
if !reflect.DeepEqual(gotSpecs[i], wantSpec) {
t.Fatalf("consumer spec[%d] = %#v, want %#v", i, gotSpecs[i], wantSpec)
}
}
}
func TestPersonalOAReusedSubscriptionRejectsDefinitionOverridesAtRuntime(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
getCalls := 0
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
getCalls++
return &personal.Subscription{
SubscribeID: "oa-sub-task",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
tests := []struct {
name string
set func(*personalConsumeOptions)
}{
{name: "user", set: func(opts *personalConsumeOptions) { opts.UserID = "user-1" }},
{name: "open-dingtalk-id", set: func(opts *personalConsumeOptions) { opts.OpenDingTalkID = "open-user-1" }},
{name: "group", set: func(opts *personalConsumeOptions) { opts.GroupID = "cid-1" }},
{name: "query", set: func(opts *personalConsumeOptions) { opts.QueryCSV = "urgent" }},
{name: "filter-json", set: func(opts *personalConsumeOptions) { opts.FilterJSON = `{"field":"content","op":"eq","value":"urgent"}` }},
}
for _, explicitEventKey := range []bool{true, false} {
mode := "implicit-event-key"
if explicitEventKey {
mode = "explicit-event-key"
}
for _, test := range tests {
t.Run(mode+"/"+test.name, func(t *testing.T) {
opts := personalConsumeOptions{SubscribeID: "oa-sub-task"}
if explicitEventKey {
opts.EventKey = personal.EventOAApprovalTaskCreated
}
test.set(&opts)
before := getCalls
_, _, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
opts,
)
if err == nil || !strings.Contains(err.Error(), "--"+test.name+" not supported for OA event") {
t.Fatalf("reused OA subscription %s error = %v", test.name, err)
}
if getCalls != before+1 {
t.Fatalf("subscription lookup calls = %d, want %d", getCalls, before+1)
}
})
}
}
}
func TestPersonalOAImplicitReuseRuntimeLooksUpEventBeforeValidation(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldGet := personalGetSubscription
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalGetSubscription = oldGet
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}, nil
}
getCalls := 0
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
getCalls++
return &personal.Subscription{
SubscribeID: "oa-sub-task",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--group", "cid-1"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--group not supported for OA event "+personal.EventOAApprovalTaskCreated) {
t.Fatalf("implicit reused OA runtime error = %v", err)
}
if getCalls != 1 {
t.Fatalf("subscription lookup calls = %d, want 1", getCalls)
}
}
func TestPersonalIMReusedSubscriptionWithExistingOverridesRemainsSupported(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "im-sub",
EventKey: personal.EventSingleChat,
RuleType: "singleChat",
}, nil
}
sub, eventKey, ruleType, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{
SubscribeID: "im-sub",
EventKey: personal.EventSingleChat,
UserID: "user-1",
QueryCSV: "urgent",
FilterJSON: `{"field":"content","op":"eq","value":"urgent"}`,
},
)
if err != nil {
t.Fatalf("reused IM subscription error = %v", err)
}
if sub.SubscribeID != "im-sub" || eventKey != personal.EventSingleChat || ruleType != "singleChat" {
t.Fatalf("reused IM subscription = %#v, event=%q rule=%q", sub, eventKey, ruleType)
}
}
func TestPersonalOAStatusAndStopCommandWiring(t *testing.T) {
oldStatus := eventRunPersonalStatus
oldStop := eventRunPersonalStop
t.Cleanup(func() {
eventRunPersonalStatus = oldStatus
eventRunPersonalStop = oldStop
})
var statusOpts personalStatusOptions
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
statusOpts = opts
return nil
}
status := newEventStatusCommand()
status.SilenceUsage = true
status.SilenceErrors = true
status.SetOut(io.Discard)
status.SetErr(io.Discard)
status.SetArgs([]string{
"--event", personal.EventOAApprovalTaskCreated,
"--subscribe-id", "oa-sub-task",
"--status", "all",
})
if err := status.Execute(); err != nil {
t.Fatalf("OA event status error = %v", err)
}
if statusOpts.EventKey != personal.EventOAApprovalTaskCreated ||
statusOpts.SubscribeID != "oa-sub-task" ||
statusOpts.Status != "all" {
t.Fatalf("OA status options = %#v", statusOpts)
}
var stopOpts personalStopOptions
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
stopOpts = opts
return nil
}
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.PersistentFlags().Bool("yes", false, "")
event := &cobra.Command{Use: "event"}
event.AddCommand(newEventStopCommand())
root.AddCommand(event)
root.SetArgs([]string{"event", "stop", "oa-sub-task", "--yes"})
if err := root.Execute(); err != nil {
t.Fatalf("OA event stop error = %v", err)
}
if stopOpts.SubscribeID != "oa-sub-task" || stopOpts.All {
t.Fatalf("OA stop options = %#v", stopOpts)
}
}
@@ -0,0 +1,427 @@
// Copyright 2026 Alibaba Group
// 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.
package app
import (
"context"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
)
func TestPersonalConsumeCleanupOwnershipRuntimeMatrix(t *testing.T) {
runErr := errors.New("runtime failed")
for _, foreground := range []bool{false, true} {
for _, selfCreated := range []bool{false, true} {
for _, ephemeral := range []bool{false, true} {
for _, failRuntime := range []bool{false, true} {
name := strings.Join([]string{
map[bool]string{false: "background", true: "foreground"}[foreground],
map[bool]string{false: "reused", true: "self-created"}[selfCreated],
map[bool]string{false: "persistent", true: "ephemeral"}[ephemeral],
map[bool]string{false: "success", true: "error"}[failRuntime],
}, "/")
t.Run(name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldNewSource := personalNewStreamSource
oldBusRun := personalBusRun
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
personalEnsureSubscription = func(
context.Context,
*personal.Client,
personal.Identity,
personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalUpsertRunState = func(string, personal.RunState) error { return nil }
deleteCalls := 0
removeCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
personalRemoveRunStates = func(string, []string) error {
removeCalls++
return nil
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, nil
}
personalBusRun = func(context.Context, bus.Config) error {
if failRuntime {
return runErr
}
return nil
}
personalConsumeRun = func(context.Context, consume.Config) error {
if failRuntime {
return runErr
}
return nil
}
opts := personalConsumeOptions{
EventKey: personal.EventMention,
Ephemeral: ephemeral,
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
Foreground: foreground,
},
}
if !selfCreated {
opts.SubscribeID = "sub-one"
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts)
if failRuntime {
if !errors.Is(err, runErr) {
t.Fatalf("runtime error = %v, want %v", err, runErr)
}
} else if err != nil {
t.Fatalf("consume error = %v", err)
}
wantCleanup := 0
if selfCreated || ephemeral {
wantCleanup = 1
}
if deleteCalls != wantCleanup || removeCalls != wantCleanup {
t.Fatalf(
"cleanup delete/remove = %d/%d, want %d/%d",
deleteCalls,
removeCalls,
wantCleanup,
wantCleanup,
)
}
})
}
}
}
}
}
func TestPersonalConsumeCleanupOwnershipOnRunStateFailure(t *testing.T) {
stateErr := errors.New("save state failed")
for _, test := range []struct {
name string
selfCreated bool
ephemeral bool
wantCleanup int
}{
{name: "self-created", selfCreated: true, wantCleanup: 1},
{name: "reused persistent", wantCleanup: 0},
{name: "reused ephemeral", ephemeral: true, wantCleanup: 1},
} {
t.Run(test.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = func(
context.Context,
*personal.Client,
personal.Identity,
personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalUpsertRunState = func(string, personal.RunState) error { return stateErr }
deleteCalls := 0
removeCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
personalRemoveRunStates = func(string, []string) error {
removeCalls++
return nil
}
opts := personalConsumeOptions{
EventKey: personal.EventMention,
Ephemeral: test.ephemeral,
ControlBaseURL: "https://mcp.example.test/dws",
}
if !test.selfCreated {
opts.SubscribeID = "sub-one"
}
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts); !errors.Is(err, stateErr) {
t.Fatalf("state error = %v, want %v", err, stateErr)
}
if deleteCalls != test.wantCleanup || removeCalls != test.wantCleanup {
t.Fatalf(
"cleanup delete/remove = %d/%d, want %d/%d",
deleteCalls,
removeCalls,
test.wantCleanup,
test.wantCleanup,
)
}
})
}
}
func TestPersonalReusedSubscriptionEventKeyResolution(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
for _, test := range []struct {
name string
requested string
actual string
wantKey string
wantErr bool
}{
{
name: "matching key uses actual",
requested: personal.EventMention,
actual: personal.EventMention,
wantKey: personal.EventMention,
},
{
name: "implicit key uses actual",
actual: personal.EventOAApprovalTaskCreated,
wantKey: personal.EventOAApprovalTaskCreated,
},
{
name: "missing actual falls back to requested",
requested: personal.EventOAApprovalTaskCreated,
wantKey: personal.EventOAApprovalTaskCreated,
},
{
name: "requested IM mismatches actual OA",
requested: personal.EventMention,
actual: personal.EventOAApprovalTaskCreated,
wantErr: true,
},
{
name: "requested OA mismatches actual IM",
requested: personal.EventOAApprovalTaskCreated,
actual: personal.EventMention,
wantErr: true,
},
} {
t.Run(test.name, func(t *testing.T) {
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-one",
EventKey: test.actual,
}, nil
}
_, eventKey, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{SubscribeID: "sub-one", EventKey: test.requested},
)
if test.wantErr {
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
t.Fatalf("mismatch error = %v", err)
}
if !strings.Contains(err.Error(), test.requested) || !strings.Contains(err.Error(), test.actual) {
t.Fatalf("mismatch error does not identify both keys: %v", err)
}
return
}
if err != nil {
t.Fatalf("resolve reused subscription: %v", err)
}
if eventKey != test.wantKey {
t.Fatalf("resolved event key = %q, want %q", eventKey, test.wantKey)
}
})
}
}
func TestPersonalReusedSubscriptionMismatchStopsDryRunAndRuntime(t *testing.T) {
for _, mode := range []struct {
name string
dryRun bool
foreground bool
}{
{name: "dry-run", dryRun: true},
{name: "background"},
{name: "foreground", foreground: true},
} {
t.Run(mode.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGet := personalGetSubscription
oldNewSource := personalNewStreamSource
oldBusRun := personalBusRun
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalGetSubscription = oldGet
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-one",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
upsertCalls := 0
consumeCalls := 0
busCalls := 0
personalUpsertRunState = func(string, personal.RunState) error {
upsertCalls++
return nil
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, nil
}
personalBusRun = func(context.Context, bus.Config) error {
busCalls++
return nil
}
personalConsumeRun = func(context.Context, consume.Config) error {
consumeCalls++
return nil
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
SubscribeID: "sub-one",
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
DryRun: mode.dryRun,
Foreground: mode.foreground,
},
})
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
t.Fatalf("mismatch error = %v", err)
}
if upsertCalls != 0 || consumeCalls != 0 || busCalls != 0 {
t.Fatalf(
"mismatch reached upsert/consumer/bus = %d/%d/%d",
upsertCalls,
consumeCalls,
busCalls,
)
}
})
}
}
func TestPersonalReusedSubscriptionUsesActualKeyInDryRunAndRuntime(t *testing.T) {
for _, dryRun := range []bool{true, false} {
name := map[bool]string{false: "runtime", true: "dry-run"}[dryRun]
t.Run(name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGet := personalGetSubscription
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalGetSubscription = oldGet
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-oa",
EventKey: personal.EventOAApprovalInstanceFinished,
RuleType: "all",
}, nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalUpsertRunState = func(_ string, state personal.RunState) error {
if state.EventKey != personal.EventOAApprovalInstanceFinished {
t.Fatalf("run state event key = %q", state.EventKey)
}
return nil
}
var got consume.Config
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
got = cfg
return nil
}
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-oa",
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
DryRun: dryRun,
},
}); err != nil {
t.Fatalf("reuse subscription: %v", err)
}
if got.EventKey != personal.EventOAApprovalInstanceFinished ||
len(got.EventTypes) != 1 || got.EventTypes[0] != personal.EventOAApprovalInstanceFinished ||
got.SubscribeID != "sub-oa" {
t.Fatalf("consume config = %#v", got)
}
})
}
}
func TestEventConsumeDryRunHelpDescribesReuseLookup(t *testing.T) {
usage := newEventConsumeCommand().Flags().Lookup("dry-run").Usage
for _, want := range []string{"不创建订阅", "不连接 bus", "复用 --subscribe-id", "只读查询控制面"} {
if !strings.Contains(usage, want) {
t.Fatalf("dry-run help %q missing %q", usage, want)
}
}
}
@@ -0,0 +1,396 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
const (
runtimeTokenDetachedChildEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_CHILD"
runtimeTokenDetachedWorkDirEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_WORKDIR"
runtimeTokenDetachedEndpointEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_ENDPOINT"
runtimeTokenDetachedEvidenceEnv = "DWS_EVENT_RUNTIME_TOKEN_E2E_EVIDENCE"
runtimeTokenDetachedCanaryA = "dws-runtime-e2e-A-9f34c8d10b7e"
runtimeTokenDetachedCanaryB = "dws-runtime-e2e-B-2ad761e5c490"
runtimeTokenDetachedClientID = "runtime-e2e-client"
runtimeTokenDetachedIdentityHash = "90abcdef12345678"
runtimeTokenDetachedSourceID = "runtime-e2e-source"
)
// runRuntimeTokenDetachedE2EChild is called at the very start of TestMain.
// busctl.Spawn executes this test binary with production-style `event _bus`
// arguments; the env marker lets the child run a real bus daemon before the Go
// test runner attempts to parse those CLI arguments.
func runRuntimeTokenDetachedE2EChild() (int, bool) {
if os.Getenv(runtimeTokenDetachedChildEnv) != "1" {
return 0, false
}
workDir := strings.TrimSpace(os.Getenv(runtimeTokenDetachedWorkDirEnv))
endpoint := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEndpointEnv))
evidence := strings.TrimSpace(os.Getenv(runtimeTokenDetachedEvidenceEnv))
if workDir == "" || endpoint == "" || evidence == "" {
return 91, true
}
argvClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Args, "\x00"))
envClean := !runtimeTokenDetachedContainsCanary(strings.Join(os.Environ(), "\x00"))
if err := appendRuntimeTokenDetachedEvidence(evidence,
fmt.Sprintf("child_start argv_clean=%t env_clean=%t", argvClean, envClean)); err != nil {
return 92, true
}
if !argvClean || !envClean {
return 93, true
}
logFile, err := os.OpenFile(filepath.Join(workDir, "bus.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return 94, true
}
defer logFile.Close()
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
err = bus.Run(ctx, bus.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: runtimeTokenDetachedClientID,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: runtimeTokenDetachedIdentityHash,
SourceID: runtimeTokenDetachedSourceID,
Edition: "open",
SDKVersion: "runtime-e2e",
Source: &runtimeTokenDetachedSource{broker: broker, evidence: evidence},
CredentialBroker: broker,
ReadyPipe: busctl.ReadyFDFromEnv(),
Logger: slog.New(slog.NewTextHandler(logFile, nil)),
})
if err != nil && !errors.Is(err, context.Canceled) {
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=false")
return 95, true
}
_ = appendRuntimeTokenDetachedEvidence(evidence, "bus_exit clean=true")
return 0, true
}
type runtimeTokenDetachedSource struct {
broker *runtimecred.Broker
evidence string
}
// Start models the credential-sensitive part of a reconnecting Stream source
// without network access. It resolves A for the first connection, waits until a
// second consumer rotates the broker to B, then exercises the exact 401 path:
// RefreshRejected(A) must return B and must not fall back to local OAuth.
func (s *runtimeTokenDetachedSource) Start(ctx context.Context, _ dwsevent.EmitFn) error {
first, err := s.broker.Resolve(ctx)
if err != nil {
return errors.New("runtime e2e: initial credential unavailable")
}
if first != runtimeTokenDetachedCanaryA || s.broker.Generation() != 1 {
return errors.New("runtime e2e: initial credential mismatch")
}
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "resolved_a=true generation=1"); err != nil {
return errors.New("runtime e2e: record initial connection")
}
ticker := time.NewTicker(5 * time.Millisecond)
defer ticker.Stop()
for s.broker.Generation() < 2 {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
rotated, err := s.broker.RefreshRejected(ctx, first)
if err != nil || rotated != runtimeTokenDetachedCanaryB {
return errors.New("runtime e2e: rotated credential unavailable")
}
if err := appendRuntimeTokenDetachedEvidence(s.evidence, "rejected_a=true resolved_b=true reconnect=true generation=2"); err != nil {
return errors.New("runtime e2e: record reconnect")
}
<-ctx.Done()
return ctx.Err()
}
func appendRuntimeTokenDetachedEvidence(path, line string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintln(f, line)
return err
}
func runtimeTokenDetachedContainsCanary(value string) bool {
return strings.Contains(value, runtimeTokenDetachedCanaryA) ||
strings.Contains(value, runtimeTokenDetachedCanaryB)
}
func TestCrossPlatformCoverageUnixDetachedRuntimeTokenLifecycleAndCanaryLeakScan(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("real detached-process lifecycle is Unix-only; Windows named-pipe code is cross-compiled separately")
}
root, err := os.MkdirTemp("/tmp", "dws-runtime-token-e2e-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(root) })
workDir := filepath.Join(root, "events", "open", string(dwsevent.SourceKindPersonalStream), runtimeTokenDetachedIdentityHash)
if err := os.MkdirAll(workDir, 0o700); err != nil {
t.Fatal(err)
}
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindPersonalStream, runtimeTokenDetachedIdentityHash)
evidencePath := filepath.Join(workDir, "runtime-e2e.evidence")
identity := personal.Identity{
ClientID: runtimeTokenDetachedClientID,
SourceID: runtimeTokenDetachedSourceID,
CorpID: "runtime-e2e-corp",
UserID: "runtime-e2e-user",
}
spawnArgs := personalBusSpawnArgsForToken(identity, runtimeTokenDetachedIdentityHash, "", "", "corp:user", runtimeTokenDetachedCanaryA)
assertRuntimeTokenDetachedClean(t, "spawn argv", []byte(strings.Join(spawnArgs, "\x00")))
childEnv := append([]string{}, os.Environ()...)
childEnv = append(childEnv,
runtimeTokenDetachedChildEnv+"=1",
runtimeTokenDetachedWorkDirEnv+"="+workDir,
runtimeTokenDetachedEndpointEnv+"="+endpoint,
runtimeTokenDetachedEvidenceEnv+"="+evidencePath,
)
assertRuntimeTokenDetachedClean(t, "spawn environment", []byte(strings.Join(childEnv, "\x00")))
pid, err := busctl.Spawn(busctl.SpawnConfig{
ExecPath: os.Args[0],
ClientID: runtimeTokenDetachedClientID,
ExtraArgs: spawnArgs,
Env: childEnv,
})
if err != nil {
failRuntimeTokenDetachedError(t, "spawn detached runtime bus", err)
}
stopped := false
t.Cleanup(func() {
if !stopped {
_ = busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 2 * time.Second})
if proc, findErr := os.FindProcess(pid); findErr == nil {
_ = proc.Kill()
}
}
})
waitRuntimeTokenDetachedFile(t, evidencePath, "child_start argv_clean=true env_clean=true", 3*time.Second)
var stdoutA, stderrA bytes.Buffer
err = consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
workDir, endpoint, "sub-runtime-a", runtimeTokenDetachedCanaryA, 500*time.Millisecond, &stdoutA, &stderrA,
))
if err != nil {
failRuntimeTokenDetachedError(t, "consume token A", err)
}
waitRuntimeTokenDetachedFile(t, evidencePath, "resolved_a=true generation=1", 3*time.Second)
if err := personal.UpsertRunState(workDir, personal.RunState{
SubscribeID: "sub-runtime-b",
EventKey: personal.EventMention,
ClientID: runtimeTokenDetachedClientID,
SourceID: runtimeTokenDetachedSourceID,
IdentityHash: runtimeTokenDetachedIdentityHash,
}); err != nil {
failRuntimeTokenDetachedError(t, "persist non-sensitive run state", err)
}
var stdoutB, stderrB bytes.Buffer
consumeDone := make(chan error, 1)
go func() {
consumeDone <- consume.Run(context.Background(), runtimeTokenDetachedConsumeConfig(
workDir, endpoint, "sub-runtime-b", runtimeTokenDetachedCanaryB, 5*time.Second, &stdoutB, &stderrB,
))
}()
status := waitRuntimeTokenDetachedStatus(t, endpoint, "sub-runtime-b", 3*time.Second)
if status.Bus.PID != pid || status.Bus.IdentityHash != runtimeTokenDetachedIdentityHash {
t.Fatalf("status bus identity = %#v, want pid=%d identity=%s", status.Bus, pid, runtimeTokenDetachedIdentityHash)
}
waitRuntimeTokenDetachedFile(t, evidencePath, "rejected_a=true resolved_b=true reconnect=true generation=2", 3*time.Second)
stopResp, err := busctl.StopConsumers(endpoint, []string{"sub-runtime-b"})
if err != nil {
failRuntimeTokenDetachedError(t, "targeted consumer stop", err)
}
if len(stopResp.Stopped) != 1 || stopResp.Stopped[0] != "sub-runtime-b" {
t.Fatalf("targeted stop response = %#v", stopResp)
}
select {
case err := <-consumeDone:
if err != nil {
failRuntimeTokenDetachedError(t, "consume token B after targeted stop", err)
}
case <-time.After(3 * time.Second):
t.Fatal("token B consumer did not exit after targeted stop")
}
status = waitRuntimeTokenDetachedStatus(t, endpoint, "", 3*time.Second)
if len(status.Consumers) != 0 {
t.Fatalf("status consumers after stop = %#v", status.Consumers)
}
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir, Timeout: 4 * time.Second}); err != nil {
failRuntimeTokenDetachedError(t, "stop detached bus", err)
}
stopped = true
waitRuntimeTokenDetachedFile(t, evidencePath, "bus_exit clean=true", 3*time.Second)
statusJSON, err := json.Marshal(status)
if err != nil {
t.Fatal(err)
}
stopJSON, err := json.Marshal(stopResp)
if err != nil {
t.Fatal(err)
}
for name, artifact := range map[string][]byte{
"consume A stdout": stdoutA.Bytes(),
"consume A stderr": stderrA.Bytes(),
"consume B stdout": stdoutB.Bytes(),
"consume B stderr": stderrB.Bytes(),
"status response": statusJSON,
"stop response": stopJSON,
} {
assertRuntimeTokenDetachedClean(t, name, artifact)
}
assertRuntimeTokenDetachedTreeClean(t, root)
for _, required := range []string{
filepath.Join(workDir, bus.MetaFileName),
filepath.Join(workDir, "bus.log"),
filepath.Join(workDir, personal.StateFileName),
evidencePath,
} {
if info, statErr := os.Stat(required); statErr != nil || !info.Mode().IsRegular() {
t.Fatalf("expected runtime artifact %s: info=%v err=%v", required, info, statErr)
}
}
}
func runtimeTokenDetachedConsumeConfig(workDir, endpoint, subscribeID, token string, duration time.Duration, stdout, stderr *bytes.Buffer) consume.Config {
return consume.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: runtimeTokenDetachedClientID,
RuntimeToken: token,
EventTypes: []string{personal.EventMention},
EventKey: personal.EventMention,
SubscribeID: subscribeID,
ReadySubscribeID: subscribeID,
Duration: duration,
Format: consume.FormatNDJSON,
Stdout: stdout,
Stderr: stderr,
}
}
func waitRuntimeTokenDetachedFile(t *testing.T, path, want string, timeout time.Duration) string {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil && strings.Contains(string(data), want) {
return string(data)
}
time.Sleep(10 * time.Millisecond)
}
data, err := os.ReadFile(path)
if runtimeTokenDetachedContainsCanary(string(data)) {
t.Fatalf("runtime credential leaked into child evidence while waiting for %q", want)
}
t.Fatalf("evidence %s missing %q: data=%q err=%v", path, want, data, err)
return ""
}
func waitRuntimeTokenDetachedStatus(t *testing.T, endpoint, subscribeID string, timeout time.Duration) *transport.StatusResp {
t.Helper()
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
status, err := busctl.QueryStatus(endpoint)
if err == nil {
if subscribeID == "" && len(status.Consumers) == 0 {
return status
}
for _, consumer := range status.Consumers {
if consumer.SubscribeID == subscribeID {
return status
}
}
}
lastErr = err
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("status never reached subscribe_id=%q: %v", subscribeID, lastErr)
return nil
}
func assertRuntimeTokenDetachedTreeClean(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || !entry.Type().IsRegular() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
assertRuntimeTokenDetachedClean(t, path, data)
return nil
})
if err != nil {
t.Fatalf("scan runtime artifacts: %v", err)
}
}
func assertRuntimeTokenDetachedClean(t *testing.T, name string, artifact []byte) {
t.Helper()
if runtimeTokenDetachedContainsCanary(string(artifact)) {
t.Fatalf("runtime credential leaked into %s", name)
}
}
func failRuntimeTokenDetachedError(t *testing.T, step string, err error) {
t.Helper()
if err != nil && runtimeTokenDetachedContainsCanary(err.Error()) {
t.Fatalf("%s failed and exposed a runtime credential", step)
}
t.Fatalf("%s: %v", step, err)
}
@@ -0,0 +1,335 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageRuntimeTokenBusRejectsIncompleteIdentity(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "invalid identity hash",
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "not-a-hash", "--client-id", "client"},
want: "16-character hexadecimal identity hash",
},
{
name: "missing client id",
args: []string{"--source-kind", "personal_stream", "--runtime-token-mode", "--identity-hash", "0123456789abcdef"},
want: "--client-id is required",
},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := newEventBusCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute() error = %v, want %q", err, tc.want)
}
})
}
}
type eventRuntimeTokenReleaseErrorStore struct {
err error
}
func (*eventRuntimeTokenReleaseErrorStore) Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error) {
return nil, nil
}
func (*eventRuntimeTokenReleaseErrorStore) CompleteSuccess(*personal.AttemptClaim) error {
return nil
}
func (*eventRuntimeTokenReleaseErrorStore) CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error) {
return personal.AttemptHold{}, nil
}
func (s *eventRuntimeTokenReleaseErrorStore) Release(*personal.AttemptClaim) error {
return s.err
}
func TestCrossPlatformCoverageRuntimeTokenAttemptReleaseGuardEdges(t *testing.T) {
var nilReservation *personalSubscriptionAttemptReservation
if err := nilReservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("nil reservation error = %v", err)
}
incomplete := &personalSubscriptionAttemptReservation{}
if err := incomplete.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
!strings.Contains(err.Error(), "reservation is incomplete") {
t.Fatalf("incomplete reservation error = %v", err)
}
wantErr := errors.New("release failed")
reservation := &personalSubscriptionAttemptReservation{
store: &eventRuntimeTokenReleaseErrorStore{err: wantErr},
claim: &personal.AttemptClaim{AttemptID: "attempt"},
}
if err := reservation.releaseRuntimeTokenFailure(); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) ||
!errors.Is(err, wantErr) {
t.Fatalf("release failure error = %v", err)
}
}
func TestCrossPlatformCoverageRuntimeTokenConsumeRejectionAndOversizeEdges(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldProfile := authpkg.RuntimeProfile()
oldLoadProfiles := personalLoadProfiles
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
oldAttemptStore := personalNewSubscriptionAttemptStore
oldEnsure := personalEnsureSubscription
t.Cleanup(func() {
edition.Override(oldEdition)
authpkg.SetRuntimeProfile(oldProfile)
personalLoadProfiles = oldLoadProfiles
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
personalNewSubscriptionAttemptStore = oldAttemptStore
personalEnsureSubscription = oldEnsure
})
edition.Override(&edition.Hooks{})
authpkg.SetRuntimeProfile("")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
oversized := strings.Repeat("x", runtimecred.DefaultMaxTokenBytes+1)
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
ExplicitToken: oversized,
ClientIDOverride: "runtime-client",
Common: commonConsumeOptions{Foreground: true},
})
if !errors.Is(err, runtimecred.ErrTokenTooLarge) {
t.Fatalf("oversized foreground token error = %v", err)
}
rejection := &personal.APIError{
Code: "RUNTIME_TOKEN_REJECTED",
HTTPStatus: http.StatusUnauthorized,
}
if personalRuntimeTokenControlRejection(errors.New("ordinary failure")) {
t.Fatal("ordinary error classified as runtime-token rejection")
}
singleStore := &personalRecordingAttemptStore{}
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return singleStore }
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
return nil, "", "", rejection
}
err = runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
ExplicitToken: "runtime-token-single",
ClientIDOverride: "runtime-client",
ControlBaseURL: "https://control.example.test",
})
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || singleStore.releaseCalls != 1 || singleStore.failureCalls != 0 {
t.Fatalf("single rejection = %v, release=%d failure=%d", err, singleStore.releaseCalls, singleStore.failureCalls)
}
manyStore := &personalRecordingAttemptStore{}
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore { return manyStore }
err = runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
ExplicitToken: "runtime-token-many",
ClientIDOverride: "runtime-client",
ControlBaseURL: "https://control.example.test",
})
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) || manyStore.releaseCalls != 1 || manyStore.failureCalls != 0 {
t.Fatalf("multi rejection = %v, release=%d failure=%d", err, manyStore.releaseCalls, manyStore.failureCalls)
}
}
func TestCrossPlatformCoverageRuntimeTokenIdentityFallbackEdges(t *testing.T) {
configDir := t.TempDir()
oldEdition := edition.Get()
oldProfile := authpkg.RuntimeProfile()
oldResolveIdentity := personalResolveEventIdentity
oldResolveAuxiliary := personalResolveAuxiliaryAccessToken
oldLoadTokenData := personalLoadTokenData
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldClientID := personalClientID
oldResolveCredentials := personalResolveAppCredentialsStrict
t.Cleanup(func() {
edition.Override(oldEdition)
authpkg.SetRuntimeProfile(oldProfile)
personalResolveEventIdentity = oldResolveIdentity
personalResolveAuxiliaryAccessToken = oldResolveAuxiliary
personalLoadTokenData = oldLoadTokenData
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
personalClientID = oldClientID
personalResolveAppCredentialsStrict = oldResolveCredentials
})
legacy := personal.Identity{ClientID: "legacy-client", SourceID: "legacy-source"}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return legacy, nil }
identity, err := resolvePersonalEventIdentityForToken(context.Background(), configDir, "", " ")
if err != nil || identity.ClientID != legacy.ClientID {
t.Fatalf("wrapper empty-token fallback = %#v, %v", identity, err)
}
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
return "legacy-access", nil
}
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
return &authpkg.TokenData{
CorpID: "legacy-corp", UserID: "legacy-user", ClientID: "direct-client",
}, nil
}
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", " ")
if err != nil || identity.ClientID != "direct-client" {
t.Fatalf("direct empty-token fallback = %#v, %v", identity, err)
}
edition.Override(&edition.Hooks{})
personalRuntimeEventClientID = func() string { return "" }
personalClientID = func() string { return "" }
wantMetadataErr := errors.New("profiles unreadable")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, wantMetadataErr }
authpkg.SetRuntimeProfile("corp:user")
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "token", "runtime-client"); !errors.Is(err, wantMetadataErr) {
t.Fatalf("explicit profile metadata error = %v", err)
}
authpkg.SetRuntimeProfile("")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "app-client", "", "", "", nil
}
identity, err = resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token")
if err != nil || identity.ClientID != "app-client" || !strings.HasPrefix(identity.LocalSubject, "access:") {
t.Fatalf("app-credential fallback identity = %#v, %v", identity, err)
}
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
return "", "", "", "", errors.New("missing app credentials")
}
if _, err := resolvePersonalEventIdentityWithToken(context.Background(), configDir, "", "runtime-token"); err == nil || !strings.Contains(err.Error(), "cannot resolve OAuth client_id") {
t.Fatalf("missing client ID error = %v", err)
}
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{
CurrentProfile: "stale",
Profiles: []authpkg.Profile{{Name: "other", CorpID: "corp", UserID: "user"}},
}, nil
}
profile, err := personalEventProfileMetadata(configDir)
if err != nil || profile != nil {
t.Fatalf("stale implicit current profile = %#v, %v", profile, err)
}
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{Name: "other"}}}, nil
}
profile, err = personalEventProfileMetadata(configDir)
if err != nil || profile != nil {
t.Fatalf("empty implicit selector = %#v, %v", profile, err)
}
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
if _, err := personalEventProfileMetadata(configDir); err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
t.Fatalf("multi-profile metadata error = %v", err)
}
}
type eventRuntimeTokenReadErrorBody struct{}
func (eventRuntimeTokenReadErrorBody) Read([]byte) (int, error) {
return 0, errors.New("body read failed")
}
func (eventRuntimeTokenReadErrorBody) Close() error { return nil }
func TestCrossPlatformCoverageRuntimeTokenControlTransportErrorEdges(t *testing.T) {
const token = "runtime-control-edge-canary"
unsupported, err := http.NewRequest(http.MethodGet, "unsupported://control.example.test/path", nil)
if err != nil {
t.Fatal(err)
}
if _, err := (runtimeTokenControlTransport{}).RoundTrip(unsupported); err == nil {
t.Fatal("nil base unexpectedly accepted an unsupported protocol")
}
wantTransportErr := errors.New("ordinary transport failure")
ordinary := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, wantTransportErr
})}
if _, err := ordinary.RoundTrip(unsupported); !errors.Is(err, wantTransportErr) {
t.Fatalf("ordinary transport error = %v", err)
}
leaking := runtimeTokenControlTransport{
token: token,
base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("reflected " + token)
}),
}
if _, err := leaking.RoundTrip(unsupported); err == nil || strings.Contains(err.Error(), token) ||
err.Error() != "personal event: runtime-token control request failed" {
t.Fatalf("redacted transport error = %v", err)
}
nilResponse := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, nil
})}
if resp, err := nilResponse.RoundTrip(unsupported); resp != nil || err != nil {
t.Fatalf("nil response = %#v, %v", resp, err)
}
readFailure := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Header: make(http.Header),
Body: eventRuntimeTokenReadErrorBody{},
Request: req,
}, nil
})}
if _, err := readFailure.RoundTrip(unsupported); err == nil || !strings.Contains(err.Error(), "read runtime-token control response") {
t.Fatalf("body read error = %v", err)
}
nilHeader := runtimeTokenControlTransport{base: eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Request: req}, nil
})}
resp, err := nilHeader.RoundTrip(unsupported)
if err != nil || resp == nil || resp.Header == nil || resp.Header.Get("Content-Type") != "application/json" {
t.Fatalf("nil-header response = %#v, %v", resp, err)
}
if got := redactRuntimeTokenResponseBody(nil, token); len(got) != 0 {
t.Fatalf("empty response redaction = %q", got)
}
value, changed := redactRuntimeTokenJSONValue([]any{"plain", "prefix-" + token}, token)
items, ok := value.([]any)
if !ok || !changed || len(items) != 2 || strings.Contains(items[1].(string), token) {
t.Fatalf("array redaction = %#v changed=%t", value, changed)
}
}
@@ -0,0 +1,917 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageEventCommandsWireTrimmedRootRuntimeToken(t *testing.T) {
oldConsume := eventRunPersonalConsume
oldStatus := eventRunPersonalStatus
oldStop := eventRunPersonalStop
t.Cleanup(func() {
eventRunPersonalConsume = oldConsume
eventRunPersonalStatus = oldStatus
eventRunPersonalStop = oldStop
})
flags := &GlobalFlags{Token: " runtime-canary ", ClientID: " root-client "}
assertIdentity := func(token, clientID string) {
t.Helper()
if token != "runtime-canary" || clientID != "root-client" {
t.Fatalf("runtime identity = token %q client %q", token, clientID)
}
}
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
return nil
}
consumeCmd := newEventConsumeCommand(flags)
if err := consumeCmd.RunE(consumeCmd, []string{personal.EventMention}); err != nil {
t.Fatalf("consume RunE() error = %v", err)
}
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
return nil
}
statusCmd := newEventStatusCommandWithFlags(flags)
if err := statusCmd.RunE(statusCmd, nil); err != nil {
t.Fatalf("status RunE() error = %v", err)
}
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
assertIdentity(opts.ExplicitToken, opts.ClientIDOverride)
return nil
}
stopCmd := newEventStopCommandWithFlags(flags)
stopRoot := &cobra.Command{Use: "dws"}
stopRoot.PersistentFlags().Bool("yes", true, "")
stopRoot.AddCommand(stopCmd)
if err := stopCmd.RunE(stopCmd, []string{"sub-runtime"}); err != nil {
t.Fatalf("stop RunE() error = %v", err)
}
listenCmd := newEventListenIMCommand(flags)
if err := listenCmd.RunE(listenCmd, nil); err != nil {
t.Fatalf("listen-im RunE() error = %v", err)
}
}
func TestCrossPlatformCoverageEventConsumeParsesRootRuntimeTokenBeforeAndAfterSubcommand(t *testing.T) {
oldConsume := eventRunPersonalConsume
t.Cleanup(func() { eventRunPersonalConsume = oldConsume })
for _, tc := range []struct {
name string
args []string
}{
{name: "before", args: []string{"--token", "runtime-before", "event", "consume", personal.EventMention}},
{name: "after", args: []string{"event", "consume", personal.EventMention, "--token", "runtime-after"}},
} {
t.Run(tc.name, func(t *testing.T) {
flags := &GlobalFlags{}
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
bindPersistentFlags(root, flags)
root.AddCommand(newEventCommand(flags))
var got string
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
got = opts.ExplicitToken
return nil
}
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
want := "runtime-" + tc.name
if got != want {
t.Fatalf("ExplicitToken = %q, want %q", got, want)
}
})
}
}
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenUsesMetadataOnly(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldLoadToken := personalLoadTokenData
oldAux := personalResolveAuxiliaryAccessToken
oldClientID := personalClientID
oldCredentials := personalResolveAppCredentialsStrict
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalLoadTokenData = oldLoadToken
personalResolveAuxiliaryAccessToken = oldAux
personalClientID = oldClientID
personalResolveAppCredentialsStrict = oldCredentials
authpkg.SetRuntimeProfile(previousProfile)
})
personalLoadTokenData = func(string) (*authpkg.TokenData, error) {
t.Fatal("explicit token identity read sensitive TokenData")
return nil, nil
}
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) {
t.Fatal("explicit token identity resolved local OAuth")
return "", nil
}
personalClientID = func() string {
t.Fatal("explicit root client ID was not preferred")
return ""
}
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
t.Fatal("explicit root client ID unexpectedly fell back to app credentials")
return "", "", "", "", nil
}
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{
Version: 2,
Profiles: []authpkg.Profile{{
Name: "Runtime profile",
CorpID: "profile-corp",
CorpName: "Runtime Org",
UserID: "profile-user",
UserName: "Runtime User",
ClientID: "profile-client",
}},
}, nil
}
authpkg.SetRuntimeProfile("Runtime Org:Runtime User")
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "", false },
}
}})
identity, err := resolvePersonalEventIdentityWithToken(
context.Background(), "unused", "runtime-source", " runtime-canary ", "root-client",
)
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "profile-user" || identity.ClientID != "root-client" {
t.Fatalf("identity metadata = %#v", identity)
}
if identity.AccessToken != "" {
t.Fatalf("identity retained raw runtime token: %q", identity.AccessToken)
}
if identity.LocalSubject != "" {
t.Fatalf("complete identity LocalSubject = %q, want empty", identity.LocalSubject)
}
}
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithCompleteRuntimeMetadataSkipsProfiles(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
})
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
t.Fatal("complete host metadata unexpectedly read profiles.json")
return nil, nil
}
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
personalRuntimeEventClientID = func() string { return "edition-client" }
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "root-client" {
t.Fatalf("identity = %#v", identity)
}
}
func TestCrossPlatformCoverageRuntimeEventClientIDPrefersEditionBeforeEnvironment(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
t.Setenv("DWS_CLIENT_ID", "environment-client")
edition.Override(&edition.Hooks{AuthClientID: "edition-client"})
if got := runtimePersonalEventClientID(); got != "edition-client" {
t.Fatalf("runtime client ID = %q, want edition hook", got)
}
edition.Override(&edition.Hooks{})
if got := runtimePersonalEventClientID(); got != "environment-client" {
t.Fatalf("runtime client ID = %q, want environment fallback", got)
}
}
func TestCrossPlatformCoverageCompleteRuntimeIdentityUsesEditionClientBeforeProfiles(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
})
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
t.Fatal("complete host metadata unexpectedly read profiles.json")
return nil, errors.New("unreachable")
}
personalRuntimeEventClientID = func() string { return "edition-client" }
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "edition-client" {
t.Fatalf("identity = %#v", identity)
}
}
func TestCrossPlatformCoverageSelectedProfileClientPrecedesPersistedGlobalClient(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldClientID := personalClientID
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
personalClientID = oldClientID
authpkg.SetRuntimeProfile(previousProfile)
})
edition.Override(&edition.Hooks{})
personalRuntimeEventClientID = func() string { return "" }
personalClientID = func() string { return "stale-global-client" }
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{
Version: 3,
CurrentProfile: "corp:user",
Profiles: []authpkg.Profile{{
Name: "Selected", CorpID: "corp", UserID: "user", ClientID: "profile-client",
}},
}, nil
}
authpkg.SetRuntimeProfile("corp:user")
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.ClientID != "profile-client" {
t.Fatalf("ClientID = %q, want selected profile client", identity.ClientID)
}
}
func TestCrossPlatformCoverageMalformedPersistedProfilesDoNotBlockRuntimeDefaultsAndGlobalClient(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldClientID := personalClientID
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
personalClientID = oldClientID
authpkg.SetRuntimeProfile(previousProfile)
})
authpkg.SetRuntimeProfile("")
personalRuntimeEventClientID = func() string { return "" }
personalClientID = func() string { return "global-client" }
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return nil, errors.New("malformed persisted profiles")
}
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
identity, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary")
if err != nil {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
if identity.CorpID != "runtime-corp" || identity.UserID != "runtime-user" || identity.ClientID != "global-client" {
t.Fatalf("identity = %#v", identity)
}
}
func TestCrossPlatformCoverageResolvePersonalEventIdentityWithTokenRejectsMultipleProfilesBeforeMetadata(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
previousProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
authpkg.SetRuntimeProfile(previousProfile)
})
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
t.Fatal("multiple runtime profiles unexpectedly reached metadata loading")
return nil, nil
}
authpkg.SetRuntimeProfile("corp-a:user-a,corp-b:user-b")
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
_, err := resolvePersonalEventIdentityWithToken(context.Background(), "unused", "source", "canary", "root-client")
if err == nil || !strings.Contains(err.Error(), "exactly one --profile") {
t.Fatalf("multiple-profile error = %v", err)
}
}
func TestCrossPlatformCoverageExplicitProfileRequiresMetadataRegistry(t *testing.T) {
oldLoadProfiles := personalLoadProfiles
defer func() { personalLoadProfiles = oldLoadProfiles }()
oldProfile := authpkg.RuntimeProfile()
authpkg.SetRuntimeProfile("missing-profile")
defer authpkg.SetRuntimeProfile(oldProfile)
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{}, nil
}
_, err := personalEventProfileMetadata(t.TempDir())
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
t.Fatalf("personalEventProfileMetadata() error = %v", err)
}
}
func TestCrossPlatformCoverageCompleteRuntimeIdentityStillValidatesExplicitProfile(t *testing.T) {
oldEdition := edition.Get()
oldLoadProfiles := personalLoadProfiles
oldRuntimeClientID := personalRuntimeEventClientID
oldProfile := authpkg.RuntimeProfile()
t.Cleanup(func() {
edition.Override(oldEdition)
personalLoadProfiles = oldLoadProfiles
personalRuntimeEventClientID = oldRuntimeClientID
authpkg.SetRuntimeProfile(oldProfile)
})
authpkg.SetRuntimeProfile("missing-profile")
personalLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
return &authpkg.ProfilesConfig{}, nil
}
personalRuntimeEventClientID = func() string { return "runtime-client" }
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
_, err := resolvePersonalEventIdentityWithToken(context.Background(), t.TempDir(), "source", "canary")
if err == nil || !strings.Contains(err.Error(), `profile "missing-profile" not found`) {
t.Fatalf("resolvePersonalEventIdentityWithToken() error = %v", err)
}
}
func TestCrossPlatformCoveragePersonalProfileMetadataOrganizationCurrentBeatsUnresolved(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
Version: 3,
Profiles: []authpkg.Profile{
{Name: "Historical", CorpID: "corp-1"},
{Name: "Exact", CorpID: "corp-1", UserID: "user-1"},
},
OrgCurrentProfiles: map[string]string{"corp-1": "corp-1:user-1"},
}
profile, err := selectPersonalEventProfileMetadata(cfg, "corp-1", make(map[string]struct{}))
if err != nil {
t.Fatalf("selectPersonalEventProfileMetadata() error = %v", err)
}
if profile == nil || profile.UserID != "user-1" {
t.Fatalf("selected profile = %#v, want organization current account", profile)
}
}
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsReflected401(t *testing.T) {
const token = "runtime-control-canary"
oldLogger := slog.Default()
var logs bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
wrapped, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport)
if !ok {
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
}
var authorization string
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
authorization = req.Header.Get("Authorization")
body := `{"code":"UNAUTHORIZED","message":"rejected ` + token + `"}`
header := make(http.Header)
header.Set("X-Request-Id", "request-"+token)
header.Set("X-Trace-Id", "trace-"+token)
return &http.Response{
StatusCode: http.StatusUnauthorized,
Header: header,
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
if err == nil {
t.Fatal("ListSubscriptions() unexpectedly succeeded")
}
if authorization != "Bearer "+token {
t.Fatalf("Authorization = %q", authorization)
}
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
}
if !strings.Contains(err.Error(), "RUNTIME_TOKEN_REJECTED") {
t.Fatalf("error = %q, want fixed runtime token rejection", err)
}
}
func TestCrossPlatformCoverageRuntimeTokenRedirectGuardDoesNotForwardCustomHeader(t *testing.T) {
const token = "runtime-redirect-canary"
var controlTargetHits, ticketTargetHits atomic.Int32
controlTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
controlTargetHits.Add(1)
if r.Header.Get("x-user-access-token") == token {
t.Error("control redirect forwarded runtime token")
}
w.WriteHeader(http.StatusNoContent)
}))
defer controlTarget.Close()
controlOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-user-access-token") != token {
t.Error("control origin did not receive runtime token")
}
http.Redirect(w, r, controlTarget.URL, http.StatusFound)
}))
defer controlOrigin.Close()
client := newPersonalEventControlClient("unused", controlOrigin.URL, personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
_, controlErr := client.ListSubscriptions(context.Background(), personal.ListOptions{})
if controlErr == nil {
t.Fatal("cross-host control redirect unexpectedly succeeded")
}
if controlTargetHits.Load() != 0 || strings.Contains(controlErr.Error(), token) {
t.Fatalf("control redirect hits=%d error=%q", controlTargetHits.Load(), controlErr)
}
ticketTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ticketTargetHits.Add(1)
if r.Header.Get("x-user-access-token") == token {
t.Error("ticket redirect forwarded runtime token")
}
w.WriteHeader(http.StatusNoContent)
}))
defer ticketTarget.Close()
ticketOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-user-access-token") != token {
t.Error("ticket origin did not receive runtime token")
}
http.Redirect(w, r, ticketTarget.URL, http.StatusFound)
}))
defer ticketOrigin.Close()
broker := runtimecred.New(runtimecred.Config{RequireSeed: true})
if _, err := broker.Update(0, token); err != nil {
t.Fatal(err)
}
src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{
ConfigDir: "unused",
Identity: personal.Identity{ClientID: "client", SourceID: "source"},
TicketURL: ticketOrigin.URL,
CredentialBroker: broker,
RuntimeTokenMode: true,
})
if err != nil {
t.Fatal(err)
}
err = src.Start(context.Background(), func(*dwsevent.RawEvent) {})
if err == nil {
t.Fatal("cross-host ticket redirect unexpectedly succeeded")
}
if ticketTargetHits.Load() != 0 || strings.Contains(err.Error(), token) {
t.Fatalf("ticket redirect hits=%d error=%q", ticketTargetHits.Load(), err)
}
}
func TestCrossPlatformCoverageRuntimeTokenRedirectPolicyBranches(t *testing.T) {
origin, _ := http.NewRequest(http.MethodGet, "https://control.example/start", nil)
sameHost, _ := http.NewRequest(http.MethodGet, "https://control.example/next", nil)
if err := runtimeTokenRedirectPolicy(sameHost, []*http.Request{origin}); err != nil {
t.Fatalf("same-host HTTPS redirect rejected: %v", err)
}
for name, request := range map[string]*http.Request{
"cross-host": func() *http.Request {
r, _ := http.NewRequest(http.MethodGet, "https://other.example/next", nil)
return r
}(),
"downgrade": func() *http.Request {
r, _ := http.NewRequest(http.MethodGet, "http://control.example/next", nil)
return r
}(),
} {
if err := runtimeTokenRedirectPolicy(request, []*http.Request{origin}); !errors.Is(err, http.ErrUseLastResponse) {
t.Fatalf("%s redirect policy error = %v", name, err)
}
}
if err := runtimeTokenRedirectPolicy(nil, nil); !errors.Is(err, http.ErrUseLastResponse) {
t.Fatalf("empty redirect chain error = %v", err)
}
}
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsEveryErrorEnvelope(t *testing.T) {
const token = "runtime-control-all-status-canary"
tests := []struct {
name string
status int
body string
}{
{name: "bad-request", status: http.StatusBadRequest, body: `{"code":"BAD_REQUEST","message":"` + token + `"}`},
{name: "server-error", status: http.StatusInternalServerError, body: `{"code":"INTERNAL","message":"` + token + `"}`},
{name: "success-false", status: http.StatusOK, body: `{"success":false,"errorCode":"DENIED","errorMsg":"` + token + `"}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
oldLogger := slog.Default()
var logs bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
header := make(http.Header)
header.Set("X-Request-Id", "request-"+token)
header.Set("X-Trace-Id", "trace-"+token)
return &http.Response{
StatusCode: tc.status,
Header: header,
Body: io.NopCloser(strings.NewReader(tc.body)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
_, err := client.ListSubscriptions(context.Background(), personal.ListOptions{})
if err == nil {
t.Fatal("ListSubscriptions() unexpectedly succeeded")
}
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
t.Fatalf("runtime token leaked: error=%q logs=%q", err, logs.String())
}
})
}
}
func TestCrossPlatformCoverageExplicitTokenControlTransportPreservesSuccessfulResponse(t *testing.T) {
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, "runtime-success-canary")
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[],"total":0}}`)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
if _, err := client.ListSubscriptions(context.Background(), personal.ListOptions{}); err != nil {
t.Fatalf("ListSubscriptions() successful response error = %v", err)
}
}
func TestCrossPlatformCoverageExplicitTokenControlClientRedactsJSONEscapedToken(t *testing.T) {
const token = "runtime<escaped>&canary"
body, err := json.Marshal(map[string]any{"code": "BAD_REQUEST", "message": "rejected " + token})
if err != nil {
t.Fatal(err)
}
if bytes.Contains(body, []byte(token)) {
t.Fatalf("fixture was not JSON-escaped: %s", body)
}
oldLogger := slog.Default()
var logs bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
client := newPersonalEventControlClient("unused", "https://control.invalid", personal.Identity{
ClientID: "client", SourceID: "source",
}, token)
wrapped := client.HTTPClient.Transport.(runtimeTokenControlTransport)
wrapped.base = eventRuntimeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(body)),
Request: req,
}, nil
})
client.HTTPClient.Transport = wrapped
_, err = client.ListSubscriptions(context.Background(), personal.ListOptions{})
if err == nil {
t.Fatal("ListSubscriptions() unexpectedly succeeded")
}
if strings.Contains(err.Error(), token) || strings.Contains(logs.String(), token) {
t.Fatalf("escaped runtime token leaked: error=%q logs=%q", err, logs.String())
}
}
func TestCrossPlatformCoverageRuntimeTokenBusModeSkipsLocalOAuthIdentity(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldResolve := eventResolvePersonal
oldSource := eventNewPersonalSource
oldRun := eventBusRun
t.Cleanup(func() {
eventResolvePersonal = oldResolve
eventNewPersonalSource = oldSource
eventBusRun = oldRun
})
resolvedLocal := false
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) {
resolvedLocal = true
return personal.Identity{}, nil
}
var sourceOpts personalStreamSourceOptions
eventNewPersonalSource = func(_ context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
sourceOpts = opts
return nil, nil
}
var busCfg bus.Config
eventBusRun = func(_ context.Context, cfg bus.Config) error {
busCfg = cfg
return nil
}
cmd := newEventBusCommand()
cmd.SetArgs([]string{
"--source-kind", "personal_stream",
"--runtime-token-mode",
"--identity-hash", "0123456789abcdef",
"--client-id", "runtime-client",
"--stream-source-id", "runtime-source",
"--idle-timeout", "0",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("event _bus runtime mode error = %v", err)
}
if resolvedLocal {
t.Fatal("runtime token bus resolved local OAuth identity")
}
if sourceOpts.CredentialBroker == nil || busCfg.CredentialBroker != sourceOpts.CredentialBroker {
t.Fatal("personal source and bus did not share one credential broker")
}
if busCfg.IdentityHash != "0123456789abcdef" || busCfg.ClientID != "runtime-client" || busCfg.SourceID != "runtime-source" {
t.Fatalf("bus identity = %#v", busCfg)
}
generation, err := sourceOpts.CredentialBroker.Update(0, "detached-activation-canary")
if err != nil {
t.Fatalf("seed detached broker: %v", err)
}
waitCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := sourceOpts.CredentialBroker.Resolve(waitCtx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("detached broker resolved before consumer activation: %v", err)
}
if _, err := sourceOpts.CredentialBroker.Activate(generation); err != nil {
t.Fatalf("activate detached broker: %v", err)
}
if resolved, err := sourceOpts.CredentialBroker.Resolve(context.Background()); err != nil || resolved == "" {
t.Fatalf("detached broker did not resolve after activation: %v", err)
}
}
func TestCrossPlatformCoverageForegroundRuntimeBrokerDoesNotRequireActivation(t *testing.T) {
broker := newPersonalCredentialBroker(t.TempDir(), true, false)
if _, err := broker.Update(0, "foreground-activation-canary"); err != nil {
t.Fatalf("seed foreground broker: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if resolved, err := broker.Resolve(ctx); err != nil || resolved == "" {
t.Fatalf("foreground broker unexpectedly waited for activation: %v", err)
}
}
func TestCrossPlatformCoveragePersonalRuntimeBusSpawnArgsContainNoSecretOrProfile(t *testing.T) {
const token = "runtime-spawn-canary"
args := personalBusSpawnArgsForToken(personal.Identity{
ClientID: "client", SourceID: "source", CorpID: "corp", UserID: "user",
}, "identity-hash", "normal", "https://ticket.invalid", "corp:user", token)
joined := strings.Join(args, " ")
for _, forbidden := range []string{token, "--profile", "corp:user"} {
if strings.Contains(joined, forbidden) {
t.Fatalf("spawn args leaked %q: %q", forbidden, joined)
}
}
for _, required := range []string{"--runtime-token-mode", "--identity-hash", "identity-hash", "--stream-source-id", "source"} {
if !strings.Contains(joined, required) {
t.Fatalf("spawn args %q missing %q", joined, required)
}
}
}
func TestCrossPlatformCoverageUnsupportedOldBusDoesNotDeleteReusedSubscription(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldConsume := personalConsumeRun
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
t.Cleanup(func() {
edition.Override(oldEdition)
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
personalConsumeRun = oldConsume
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
})
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
deleteCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
var removed []string
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
if strings.TrimSpace(cfg.RuntimeToken) == "" {
t.Fatal("runtime token was not wired to consume")
}
return &consume.RuntimeTokenUnsupportedError{BusPID: 72}
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-existing",
ExplicitToken: "old-bus-cleanup-canary",
ClientIDOverride: "runtime-client",
})
if !errors.Is(err, consume.ErrRuntimeTokenUnsupported) {
t.Fatalf("consume error = %v", err)
}
if deleteCalls != 0 {
t.Fatalf("reused remote subscription was deleted %d time(s)", deleteCalls)
}
if len(removed) != 0 {
t.Fatalf("reused local run-state was removed: %#v", removed)
}
}
func TestCrossPlatformCoverageRuntimeTokenReusedDryRunUsesExplicitControlCredential(t *testing.T) {
const token = "runtime-dry-run-control-canary"
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldEdition := edition.Get()
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldConsume := personalConsumeRun
oldBusRun := personalBusRun
t.Cleanup(func() {
edition.Override(oldEdition)
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalConsumeRun = oldConsume
personalBusRun = oldBusRun
})
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
return map[string]edition.RuntimeDefaultFn{
"$corpId": func(context.Context) (string, bool) { return "runtime-corp", true },
"$currentUserId": func(context.Context) (string, bool) { return "runtime-user", true },
}
}})
personalEnsureSubscription = func(ctx context.Context, client *personal.Client, _ personal.Identity, _ personalConsumeOptions) (*personal.Subscription, string, string, error) {
if _, ok := client.HTTPClient.Transport.(runtimeTokenControlTransport); !ok {
t.Fatalf("control transport = %T, want runtimeTokenControlTransport", client.HTTPClient.Transport)
}
got, err := client.AccessTokenProvider(ctx)
if err != nil || got != token {
t.Fatalf("control token = %q, %v", got, err)
}
return &personal.Subscription{SubscribeID: "sub-existing"}, personal.EventMention, "at", nil
}
personalUpsertRunState = func(string, personal.RunState) error {
t.Fatal("dry-run unexpectedly persisted run state")
return nil
}
consumeCalls := 0
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
consumeCalls++
if !cfg.DryRun {
t.Fatal("consume config is not dry-run")
}
if strings.Contains(strings.Join(cfg.SpawnExtraArgs, " "), token) {
t.Fatal("dry-run spawn args leaked runtime token")
}
return nil
}
personalBusRun = func(context.Context, bus.Config) error {
t.Fatal("dry-run unexpectedly started a bus")
return nil
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-existing",
ExplicitToken: token,
ClientIDOverride: "runtime-client",
Common: commonConsumeOptions{DryRun: true},
})
if err != nil {
t.Fatalf("dry-run consume error = %v", err)
}
if consumeCalls != 1 {
t.Fatalf("dry-run consume calls = %d, want 1", consumeCalls)
}
}
func TestCrossPlatformCoverageRuntimeTokenControlRejectionReleasesSubscriptionClaim(t *testing.T) {
store := &personalRecordingAttemptStore{}
reservation := &personalSubscriptionAttemptReservation{
store: store,
claim: &personal.AttemptClaim{AttemptID: "runtime-token-attempt"},
items: []personalSubscriptionAttemptItem{{eventKey: personal.EventMention, fingerprint: strings.Repeat("a", 64)}},
}
cause := &personal.APIError{
Code: "RUNTIME_TOKEN_REJECTED",
Message: "event runtime token was rejected; retry with a fresh host credential",
HTTPStatus: http.StatusUnauthorized,
}
if !personalRuntimeTokenControlRejection(cause) {
t.Fatal("runtime token control rejection was not classified")
}
err := reservation.releaseRuntimeTokenFailure()
if err == nil || !strings.Contains(err.Error(), "runtime token was rejected") {
t.Fatalf("releaseRuntimeTokenFailure() error = %v", err)
}
if store.releaseCalls != 1 || store.failureCalls != 0 {
t.Fatalf("attempt store release=%d failure=%d, want release only", store.releaseCalls, store.failureCalls)
}
}
type eventRuntimeRoundTripFunc func(*http.Request) (*http.Response, error)
func (f eventRuntimeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
@@ -0,0 +1,74 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestCrossPlatformCoverageEventAgentSelectionBoundaries(t *testing.T) {
_ = NewRootCommand()
eventProduct, ok := contract.LookupProductDecl("event")
if !ok {
t.Fatal("event ProductDecl is not registered")
}
assertSelectionContains(t, "event product", eventProduct.Selection.AgentSummary,
[]string{"IM", "OA"})
assertSelectionContains(t, "event product use_when", strings.Join(eventProduct.Selection.UseWhen, "\n"),
[]string{"消息", "群生命周期", "OA"})
assertSelectionContains(t, "event product avoid_when", strings.Join(eventProduct.Selection.AvoidWhen, "\n"),
[]string{"chat", "oa", "dev app event"})
listenMeta, ok := cli.ResolveMeta("event +listen-im")
if !ok {
t.Fatal("event +listen-im metadata is not registered")
}
assertSelectionContains(t, "event.listen_im use_when", strings.Join(listenMeta.Selection.UseWhen, "\n"),
[]string{"@我", "message/reaction/read/recall"})
assertSelectionContains(t, "event.listen_im avoid_when", strings.Join(listenMeta.Selection.AvoidWhen, "\n"),
[]string{"OA 审批事件", "群标题", "Filter DSL", "event consume", "历史消息"})
consumeMeta, ok := cli.ResolveMeta("event consume")
if !ok {
t.Fatal("event consume metadata is not registered")
}
consumeUse := strings.Join(consumeMeta.Selection.UseWhen, "\n")
assertSelectionContains(t, "event.consume use_when", consumeUse,
[]string{"OA", "群", "EventKey", "Filter DSL", "subscribe_id", "transport envelope", "高级多事件"})
consumeAvoid := strings.Join(consumeMeta.Selection.AvoidWhen, "\n")
assertSelectionContains(t, "event.consume avoid_when", consumeAvoid,
[]string{"event +listen-im", "历史聊天", "oa", "dev app event"})
schemaMeta, ok := cli.ResolveMeta("event schema")
if !ok {
t.Fatal("event schema metadata is not registered")
}
assertSelectionContains(t, "event.schema use_when", strings.Join(schemaMeta.Selection.UseWhen, "\n"),
[]string{"IM", "OA", "--flatten"})
for productID, want := range map[string]string{
"chat": "event +listen-im",
"oa": "event consume",
} {
decl, found := contract.LookupProductDecl(productID)
if !found {
t.Fatalf("%s ProductDecl is not registered", productID)
}
assertSelectionContains(t, productID+" avoid_when", strings.Join(decl.Selection.AvoidWhen, "\n"), []string{want})
}
}
func assertSelectionContains(t *testing.T, label, text string, fragments []string) {
t.Helper()
for _, fragment := range fragments {
if !strings.Contains(text, fragment) {
t.Errorf("%s = %q, want fragment %q", label, text, fragment)
}
}
}
+1 -1
View File
@@ -472,7 +472,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
newConfigCommand(),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(),
newEventCommand(flags),
newAuditCommand(),
newCompletionCommand(root),
newUpgradeCommand(),
@@ -26,6 +26,14 @@ func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
wants := []finalSchemaSafetyWant{
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared},
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
// Card update intentionally layers confirmation: the atomic typed command
// preserves its original contract, while the Agent-facing shortcut owns
// the outer confirmation boundary.
{canonical: "chat.update_streaming_card", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send_by_webhook", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "chat.shortcut_messages_update_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
@@ -81,6 +81,7 @@ func newServerFailureAPIError(
apperrors.WithReason(fallbackReason),
apperrors.WithServerKey(serverKey),
apperrors.WithHint(fallbackHint),
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
@@ -95,6 +95,9 @@ func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
t.Fatalf("unexpected fallback classification: %#v", typed)
}
if len(typed.Actions) == 0 || !strings.Contains(typed.Actions[0], "dws doctor") {
t.Fatalf("fallback error has no stable troubleshooting entry: %#v", typed.Actions)
}
}
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
+577 -1
View File
@@ -51,6 +51,8 @@ var (
skillSetupInstallMono = installSkillToHomes
skillSetupInstallMulti = installMultiSkillToHomes
skillSetupCopyDir = copyDir
skillSetupMkdirTemp = os.MkdirTemp
skillSetupRename = os.Rename
skillSetupRunForm = (*huh.Form).Run
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
@@ -136,6 +138,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
var multiSkillNames []string
var foldedEventMiscTargets []string
var migrateEventMiscTargets []string
var installsEventMiscCompanion bool
if mode == skillSetupModeMulti {
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
if listErr != nil {
@@ -151,6 +156,30 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// dingtalk-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
foldedEventMiscTargets = findFoldedEventMiscTargets(dests)
if len(foldedEventMiscTargets) > 0 {
hasEvent := containsSkillName(multiSkillNames, multiEventSkill)
hasMisc := containsSkillName(multiSkillNames, multiMiscSkill)
switch {
case normalizedSkillListContains(excludeRaw, multiEventSkill):
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能显式 --exclude event,请先完成 dingtalk-event 迁移")
case hasMisc && !hasEvent:
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能只覆盖 dingtalk-misc,必须同时迁移 dingtalk-event")
case hasEvent:
if normalizedSkillListContains(excludeRaw, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;本次安装 dingtalk-event 必须同时迁移 dingtalk-misc,不能显式 --exclude misc")
}
if !containsSkillName(allMultiSkillNames, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由,但当前 multi 源缺少迁移所需的 %s", multiMiscSkill)
}
if err := validateEventMiscMigrationSource(skillSrc); err != nil {
return err
}
migrateEventMiscTargets = append(migrateEventMiscTargets, foldedEventMiscTargets...)
installsEventMiscCompanion = !hasMisc
}
}
}
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
@@ -162,11 +191,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
}
return nil
}
if !autoYes {
if mode == skillSetupModeMulti {
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
}
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
return err
@@ -182,7 +215,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
case skillSetupModeMono:
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
case skillSetupModeMulti:
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
installed, skipped, err = installMultiSkillsWithEventMigration(
skillSrc,
multiSkillNames,
dests,
migrateEventMiscTargets,
out,
errOut,
)
default:
return fmt.Errorf("内部错误:未知 mode %q", mode)
}
@@ -191,6 +231,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
return nil
}
@@ -204,6 +245,226 @@ const multiSkillPrefix = "dingtalk-"
// dingtalk-shared that was never installed.
const multiSharedSkill = "dingtalk-shared"
// legacyMultiSharedSkill is the retired name shipped by older multi-skill
// bundles. Once the replacement has been installed successfully, remove this
// exact directory so Agent discovery cannot load both routing contracts.
const legacyMultiSharedSkill = "dws-shared"
const (
multiEventSkill = "dingtalk-event"
multiMiscSkill = "dingtalk-misc"
)
var eventMigrationRequiredReferences = []string{
"event-im.md",
"event-im-keys.md",
"event-im-lifecycle.md",
"event-im-operations.md",
"event-im-output.md",
"event-oa.md",
}
func containsSkillName(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
func normalizedSkillListContains(raw []string, want string) bool {
for _, name := range raw {
if normalizeMultiSkillName(name) == want {
return true
}
}
return false
}
// findFoldedEventMiscTargets identifies the short-lived multi-skill layout in
// which personal Event routing lived inside dingtalk-misc. Both markers are
// required so an unrelated misc install is never treated as a migration target.
func findFoldedEventMiscTargets(dests []string) []string {
var targets []string
for _, dest := range dests {
miscRoot := filepath.Join(dest, multiMiscSkill)
skillBody, err := os.ReadFile(filepath.Join(miscRoot, "SKILL.md"))
if err != nil || !containsPersonalEventRoute(skillBody) {
continue
}
eventRef, err := skillSetupStat(filepath.Join(miscRoot, "references", "event.md"))
if err != nil || eventRef.IsDir() {
continue
}
targets = append(targets, dest)
}
sort.Strings(targets)
return targets
}
func containsPersonalEventRoute(skillBody []byte) bool {
body := strings.ToLower(string(skillBody))
for _, marker := range []string{
"dws event",
"个人 event",
"个人 im 事件",
"个人 im/oa",
"personal event",
} {
if strings.Contains(body, marker) {
return true
}
}
return false
}
func printEventMiscMigrationPreview(out io.Writer, targets []string, installsCompanion bool) {
if len(targets) == 0 {
return
}
action := "将原子切换 dingtalk-event 与本次已选择的干净 dingtalk-misc"
if installsCompanion {
action = "将原子切换 dingtalk-event,并额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)"
}
fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action)
for _, target := range targets {
fmt.Fprintf(out, " - %s\n", target)
}
}
func validateEventMiscMigrationSource(src string) error {
if err := validateEventMigrationSkillRoot(filepath.Join(src, multiEventSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateMigrationSkillRoot(filepath.Join(src, multiMiscSkill), multiMiscSkill, nil); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateCleanEventMiscRoot(filepath.Join(src, multiMiscSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
return nil
}
func validateEventMigrationSkillRoot(root string) error {
required := make([]string, 0, len(eventMigrationRequiredReferences))
for _, name := range eventMigrationRequiredReferences {
required = append(required, filepath.Join("references", name))
}
return validateMigrationSkillRoot(root, multiEventSkill, required)
}
func validateMigrationSkillRoot(root, expectedName string, requiredFiles []string) error {
skillPath := filepath.Join(root, "SKILL.md")
skillBody, err := os.ReadFile(skillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", skillPath, err)
}
name, err := parseMigrationSkillFrontmatter(skillBody)
if err != nil {
return fmt.Errorf("%s 无效: %w", skillPath, err)
}
if name != expectedName {
return fmt.Errorf("%s 的 name=%q,期望 %q", skillPath, name, expectedName)
}
for _, rel := range requiredFiles {
path := filepath.Join(root, rel)
info, statErr := skillSetupStat(path)
if statErr != nil || info.IsDir() {
if statErr == nil {
statErr = errors.New("is a directory")
}
return fmt.Errorf("缺少有效文件 %s: %w", path, statErr)
}
body, readErr := os.ReadFile(path)
if readErr != nil {
return fmt.Errorf("无法读取 %s: %w", path, readErr)
}
if strings.TrimSpace(string(body)) == "" {
return fmt.Errorf("文件为空 %s", path)
}
}
return nil
}
func parseMigrationSkillFrontmatter(body []byte) (string, error) {
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
lines := strings.Split(normalized, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return "", errors.New("缺少 YAML frontmatter")
}
name := ""
description := ""
closingLine := -1
for i := 1; i < len(lines); i++ {
rawLine := lines[i]
line := strings.TrimSpace(rawLine)
if line == "---" {
closingLine = i
break
}
// Only inspect top-level frontmatter keys. Nested metadata may legally
// contain its own `name` without changing the Skill identity.
if strings.TrimLeft(rawLine, " \t") != rawLine {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.Trim(strings.TrimSpace(value), "\"'")
switch strings.TrimSpace(key) {
case "name":
if name != "" {
return "", errors.New("frontmatter 含重复 name")
}
name = value
case "description":
description = value
}
}
if closingLine < 0 {
return "", errors.New("YAML frontmatter 未闭合")
}
if name == "" {
return "", errors.New("frontmatter 缺少 name")
}
if description == "" {
return "", errors.New("frontmatter 缺少 description")
}
if strings.TrimSpace(strings.Join(lines[closingLine+1:], "\n")) == "" {
return "", errors.New("SKILL.md 正文为空")
}
return name, nil
}
func validateCleanEventMiscRoot(miscRoot string) error {
miscSkillPath := filepath.Join(miscRoot, "SKILL.md")
miscBody, err := os.ReadFile(miscSkillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", miscSkillPath, err)
}
if containsPersonalEventRoute(miscBody) {
return fmt.Errorf("%s 仍包含个人 Event 路由", miscSkillPath)
}
refsRoot := filepath.Join(miscRoot, "references")
entries, err := skillSetupReadDir(refsRoot)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("无法检查 %s: %w", refsRoot, err)
}
for _, entry := range entries {
name := strings.ToLower(entry.Name())
if !entry.IsDir() && strings.HasPrefix(name, "event") && strings.HasSuffix(name, ".md") {
return fmt.Errorf("%s 仍存在折叠 Event 参考页", filepath.Join(refsRoot, entry.Name()))
}
}
return nil
}
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
func ensureMandatorySharedSkill(selected, all []string) []string {
@@ -607,6 +868,21 @@ func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
}
}
func cleanupLegacyMultiSharedSkill(dest string, out, errOut io.Writer) {
legacyPath := filepath.Join(dest, legacyMultiSharedSkill)
if _, err := skillSetupStat(legacyPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(errOut, " ⚠️ 无法检查已退役 Skill 残留 %s: %v\n", legacyPath, err)
}
return
}
if err := skillSetupRemoveAll(legacyPath); err != nil {
fmt.Fprintf(errOut, " ⚠️ 已退役 Skill 清理失败(已安装 %s) %s: %v\n", multiSharedSkill, legacyPath, err)
return
}
fmt.Fprintf(out, " × 已清理已退役 Skill 残留 %s\n", legacyPath)
}
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
sort.Strings(dests)
for _, dest := range dests {
@@ -634,6 +910,299 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
return installed, skipped, nil
}
func installMultiSkillsWithEventMigration(
src string,
skillNames []string,
dests []string,
migrationTargets []string,
out, errOut io.Writer,
) (installed, skipped int, err error) {
if len(migrationTargets) == 0 {
return skillSetupInstallMulti(src, skillNames, dests, out, errOut)
}
migrationSet := make(map[string]struct{}, len(migrationTargets))
for _, dest := range migrationTargets {
migrationSet[dest] = struct{}{}
}
var ordinaryTargets []string
for _, dest := range dests {
if _, migrates := migrationSet[dest]; !migrates {
ordinaryTargets = append(ordinaryTargets, dest)
}
}
if len(ordinaryTargets) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
}
}
// The folded pair is excluded from the ordinary best-effort installer. All
// other selected skills (especially dingtalk-shared) must succeed before the
// old Event route is touched.
for _, dest := range migrationTargets {
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
}
var prerequisiteNames []string
for _, name := range skillNames {
if name != multiEventSkill && name != multiMiscSkill {
prerequisiteNames = append(prerequisiteNames, name)
}
}
if len(prerequisiteNames) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("event Skill 迁移前置安装不完整(skipped=%d);已保留折叠版 Event/misc", nSkipped)
}
}
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
installed += migrated
if migrationErr != nil {
return installed, skipped, migrationErr
}
return installed, skipped, nil
}
type eventMiscMigration struct {
dest string
stageRoot string
stagedEvent string
stagedMisc string
backupEvent string
backupMisc string
eventPath string
miscPath string
eventBackedUp bool
miscBackedUp bool
newEventEnabled bool
newMiscEnabled bool
}
func prepareEventMiscMigration(src, dest string) (*eventMiscMigration, error) {
stageRoot, err := skillSetupMkdirTemp(dest, ".dws-event-migration-")
if err != nil {
return nil, fmt.Errorf("无法在目标文件系统创建 Event Skill 迁移 staging %s: %w", dest, err)
}
migration := &eventMiscMigration{
dest: dest,
stageRoot: stageRoot,
stagedEvent: filepath.Join(stageRoot, "new-event"),
stagedMisc: filepath.Join(stageRoot, "new-misc"),
backupEvent: filepath.Join(stageRoot, "old-event"),
backupMisc: filepath.Join(stageRoot, "old-misc"),
eventPath: filepath.Join(dest, multiEventSkill),
miscPath: filepath.Join(dest, multiMiscSkill),
}
cleanupOnError := func(cause error) (*eventMiscMigration, error) {
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
cause = errors.Join(cause, fmt.Errorf("清理 staging %s 失败: %w", stageRoot, cleanupErr))
}
return nil, cause
}
if err := skillSetupCopyDir(filepath.Join(src, multiEventSkill), migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-event 失败 %s: %w", dest, err))
}
if err := skillSetupCopyDir(filepath.Join(src, multiMiscSkill), migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-misc 失败 %s: %w", dest, err))
}
if err := validateEventMigrationSkillRoot(migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedEvent, err))
}
if err := validateMigrationSkillRoot(migration.stagedMisc, multiMiscSkill, nil); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
if err := validateCleanEventMiscRoot(migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
return migration, nil
}
func migrateEventMiscAtomically(src string, dests []string, out, errOut io.Writer) (int, error) {
sortedDests := append([]string(nil), dests...)
sort.Strings(sortedDests)
migrations := make([]*eventMiscMigration, 0, len(sortedDests))
// Stage every target before switching any target. This prevents a source or
// copy failure on a later Agent home from leaving earlier homes upgraded.
for _, dest := range sortedDests {
migration, err := prepareEventMiscMigration(src, dest)
if err != nil {
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
migrations = append(migrations, migration)
}
committed := make([]*eventMiscMigration, 0, len(migrations))
for _, migration := range migrations {
if err := commitEventMiscMigration(migration); err != nil {
rollbackErr := rollbackEventMiscMigrations(committed)
if rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("已切换目标回滚失败: %w", rollbackErr))
}
var recoveryRoots []string
for _, candidate := range migrations {
if eventMiscMigrationNeedsRecovery(candidate) {
recoveryRoots = append(recoveryRoots, candidate.stageRoot)
}
}
if len(recoveryRoots) > 0 {
err = errors.Join(err, fmt.Errorf("回滚不完整,已保留恢复目录(请勿删除): %s", strings.Join(recoveryRoots, ", ")))
}
if cleanupErr := cleanupEventMiscStages(migrations, true, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
committed = append(committed, migration)
}
for _, migration := range migrations {
fmt.Fprintf(out, " ✓ %s\n", migration.eventPath)
fmt.Fprintf(out, " ✓ %s(Event 原子迁移)\n", migration.miscPath)
}
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
fmt.Fprintf(errOut, " ⚠️ Event Skill 迁移已完成,但 staging 清理不完整: %v\n", cleanupErr)
}
return len(migrations) * 2, nil
}
func cleanupEventMiscStages(migrations []*eventMiscMigration, preserveRecovery bool, errOut io.Writer) error {
var cleanupErr error
for _, migration := range migrations {
if preserveRecovery && eventMiscMigrationNeedsRecovery(migration) {
fmt.Fprintf(errOut, " ⚠️ 已保留 Event Skill 恢复目录 %s\n", migration.stageRoot)
continue
}
if err := skillSetupRemoveAll(migration.stageRoot); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("清理 Event Skill staging %s 失败: %w", migration.stageRoot, err))
}
}
return cleanupErr
}
func eventMiscMigrationNeedsRecovery(migration *eventMiscMigration) bool {
return migration.eventBackedUp || migration.miscBackedUp || migration.newEventEnabled || migration.newMiscEnabled
}
func commitEventMiscMigration(migration *eventMiscMigration) error {
eventExists, err := skillSetupPathExists(migration.eventPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-event %s: %w", migration.dest, err)
}
miscExists, err := skillSetupPathExists(migration.miscPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-misc %s: %w", migration.dest, err)
}
if !miscExists {
return fmt.Errorf("event Skill 迁移中止:折叠版 dingtalk-misc 已不存在 %s", migration.dest)
}
rollbackFailure := func(cause error) error {
if rollbackErr := rollbackEventMiscMigration(migration); rollbackErr != nil {
return errors.Join(cause, fmt.Errorf("回滚 Event/misc 失败 %s: %w", migration.dest, rollbackErr))
}
return cause
}
if eventExists {
if err := skillSetupRename(migration.eventPath, migration.backupEvent); err != nil {
return fmt.Errorf("备份旧 dingtalk-event 失败 %s: %w", migration.dest, err)
}
migration.eventBackedUp = true
}
if err := skillSetupRename(migration.stagedEvent, migration.eventPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-event 失败 %s: %w", migration.dest, err))
}
migration.newEventEnabled = true
if err := skillSetupRename(migration.miscPath, migration.backupMisc); err != nil {
return rollbackFailure(fmt.Errorf("备份旧 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.miscBackedUp = true
if err := skillSetupRename(migration.stagedMisc, migration.miscPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.newMiscEnabled = true
return nil
}
func rollbackEventMiscMigrations(migrations []*eventMiscMigration) error {
var rollbackErr error
for i := len(migrations) - 1; i >= 0; i-- {
if err := rollbackEventMiscMigration(migrations[i]); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func rollbackEventMiscMigration(migration *eventMiscMigration) error {
move := func(enabled *bool, from, to, label string) error {
if !*enabled {
return nil
}
if err := skillSetupRename(from, to); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
*enabled = false
return nil
}
// Stop at the first rollback failure. In particular, do not remove the
// already-working standalone Event while the folded misc route has not been
// restored: even an incomplete rollback must leave at least one Event entry
// point live and preserve the remaining assets in staging for recovery.
steps := []struct {
enabled *bool
from string
to string
label string
}{
{&migration.newMiscEnabled, migration.miscPath, migration.stagedMisc, "移出新 dingtalk-misc"},
{&migration.miscBackedUp, migration.backupMisc, migration.miscPath, "恢复旧 dingtalk-misc"},
{&migration.newEventEnabled, migration.eventPath, migration.stagedEvent, "移出新 dingtalk-event"},
{&migration.eventBackedUp, migration.backupEvent, migration.eventPath, "恢复旧 dingtalk-event"},
}
for _, step := range steps {
if err := move(step.enabled, step.from, step.to, step.label); err != nil {
return err
}
}
return nil
}
func skillSetupPathExists(path string) (bool, error) {
_, err := skillSetupStat(path)
switch {
case err == nil:
return true, nil
case errors.Is(err, os.ErrNotExist):
return false, nil
default:
return false, err
}
}
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
// dest as a sibling skill directory. installed/skipped is counted per
// (agent-home × sub-skill) pair so the user sees granular progress.
@@ -649,6 +1218,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
continue
}
sharedInstalled := false
for _, name := range skillNames {
subSrc := filepath.Join(src, name)
subDest := filepath.Join(dest, name)
@@ -664,6 +1234,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
}
fmt.Fprintf(out, " ✓ %s\n", subDest)
installed++
if name == multiSharedSkill {
sharedInstalled = true
}
}
if sharedInstalled {
cleanupLegacyMultiSharedSkill(dest, out, errOut)
}
}
return installed, skipped, nil
+10 -2
View File
@@ -58,8 +58,9 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
}
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
// contains both the shared routing skill and misc (including folded PAT docs). Structured
// Schema hints are build inputs and must not become a third installable mode.
// contains the standalone Event skill, shared routing skill, and clean misc
// (including PAT docs). Structured Schema hints are build inputs and must not
// become a third installable mode.
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
if err != nil {
@@ -71,6 +72,8 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
}
for _, rel := range []string{
filepath.Join("dingtalk-event", "SKILL.md"),
filepath.Join("dingtalk-event", "references", "event-oa.md"),
filepath.Join("dingtalk-shared", "SKILL.md"),
filepath.Join("dingtalk-misc", "SKILL.md"),
filepath.Join("dingtalk-misc", "references", "pat.md"),
@@ -79,6 +82,11 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "dingtalk-misc", "references", "event.md")); err == nil {
t.Fatal("embedded misc must not retain the folded personal Event reference")
} else if !os.IsNotExist(err) {
t.Fatalf("stat embedded misc event reference: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
t.Fatal("embedded multi skill must not contain build-only schema-hints")
} else if !os.IsNotExist(err) {
@@ -7,9 +7,11 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
@@ -120,12 +122,112 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
}
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
home := filepath.Join(t.TempDir(), "skills")
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
customPath := filepath.Join(home, "custom-skill")
for _, path := range []string{legacyPath, customPath} {
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("legacy or custom\n"), 0o644); err != nil {
t.Fatal(err)
}
}
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
src,
[]string{multiSharedSkill, "dingtalk-chat"},
[]string{home},
&out,
&errOut,
)
if err != nil || installed != 2 || skipped != 0 {
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("legacy shared skill still exists: %v", err)
}
if _, err := os.Stat(filepath.Join(home, multiSharedSkill, "SKILL.md")); err != nil {
t.Fatalf("replacement shared skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
t.Fatalf("unrelated custom skill changed: %v", err)
}
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
t.Fatalf("legacy cleanup was not reported: %s", out.String())
}
t.Run("failed replacement preserves legacy", func(t *testing.T) {
missingSource := t.TempDir()
failureHome := filepath.Join(t.TempDir(), "skills")
failureLegacy := filepath.Join(failureHome, legacyMultiSharedSkill)
if err := os.MkdirAll(failureLegacy, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(failureLegacy, "SKILL.md"), []byte("legacy\n"), 0o644); err != nil {
t.Fatal(err)
}
var failureOut, failureErr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
missingSource,
[]string{multiSharedSkill},
[]string{failureHome},
&failureOut,
&failureErr,
)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
}
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
}
})
}
func TestCrossPlatformCoverageSkillSetupLegacySharedCleanupFailures(t *testing.T) {
fail := errors.New("legacy cleanup failure")
t.Run("missing legacy is a no-op", func(t *testing.T) {
var out, errOut bytes.Buffer
cleanupLegacyMultiSharedSkill(t.TempDir(), &out, &errOut)
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("missing legacy emitted output: stdout=%q stderr=%q", out.String(), errOut.String())
}
})
t.Run("stat failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
var out, errOut bytes.Buffer
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
if out.Len() != 0 || !strings.Contains(errOut.String(), "无法检查已退役 Skill 残留") {
t.Fatalf("stat failure output: stdout=%q stderr=%q", out.String(), errOut.String())
}
})
t.Run("remove failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
return skillSetupFileInfo{name: filepath.Base(path), mode: os.ModeDir}, nil
})
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return fail })
var out, errOut bytes.Buffer
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
if out.Len() != 0 || !strings.Contains(errOut.String(), "已退役 Skill 清理失败") {
t.Fatalf("remove failure output: stdout=%q stderr=%q", out.String(), errOut.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) {
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
t.Cleanup(func() {
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
@@ -133,6 +235,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
})
fail := errors.New("failure")
@@ -310,3 +413,236 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
}
_ = fs.ValidPath("path")
}
func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing.T) {
fail := errors.New("injected failure")
validSkill := func(name string) []byte {
return []byte("---\nname: " + name + "\ndescription: valid migration skill\n---\n\n# Skill\n")
}
t.Run("folded discovery rejects directory reference", func(t *testing.T) {
dest := t.TempDir()
miscRoot := filepath.Join(dest, multiMiscSkill)
if err := os.MkdirAll(miscRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("dws event\n"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if strings.HasSuffix(path, filepath.Join("references", "event.md")) {
return skillSetupFileInfo{name: "event.md", mode: os.ModeDir}, nil
}
return os.Stat(path)
})
if got := findFoldedEventMiscTargets([]string{dest}); len(got) != 0 {
t.Fatalf("directory event reference accepted: %#v", got)
}
})
t.Run("migration root validation failures", func(t *testing.T) {
if err := validateMigrationSkillRoot(filepath.Join(t.TempDir(), "missing"), multiEventSkill, nil); err == nil {
t.Fatal("missing SKILL.md succeeded")
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "references", "directory.md"), 0o755); err != nil {
t.Fatal(err)
}
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{filepath.Join("references", "directory.md")}); err == nil || !strings.Contains(err.Error(), "is a directory") {
t.Fatalf("directory required file = %v", err)
}
missing := filepath.Join("references", "missing.md")
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(root, missing) {
return skillSetupFileInfo{name: "missing.md"}, nil
}
return os.Stat(path)
})
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{missing}); err == nil || !strings.Contains(err.Error(), "无法读取") {
t.Fatalf("unreadable required file = %v", err)
}
})
t.Run("frontmatter validation branches", func(t *testing.T) {
validWithIgnoredLine := []byte("---\nignored line\nname: dingtalk-event\ndescription: valid\n---\n\nbody\n")
if name, err := parseMigrationSkillFrontmatter(validWithIgnoredLine); err != nil || name != multiEventSkill {
t.Fatalf("ignored frontmatter line = %q, %v", name, err)
}
for name, body := range map[string][]byte{
"duplicate name": []byte("---\nname: one\nname: two\ndescription: valid\n---\nbody\n"),
"unclosed": []byte("---\nname: one\ndescription: valid\nbody\n"),
"missing name": []byte("---\ndescription: valid\n---\nbody\n"),
"missing desc": []byte("---\nname: one\n---\nbody\n"),
"empty body": []byte("---\nname: one\ndescription: valid\n---\n \n"),
} {
t.Run(name, func(t *testing.T) {
if _, err := parseMigrationSkillFrontmatter(body); err == nil {
t.Fatal("invalid frontmatter succeeded")
}
})
}
})
t.Run("clean misc validation branches", func(t *testing.T) {
if err := validateCleanEventMiscRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing misc root succeeded")
}
routed := t.TempDir()
if err := os.WriteFile(filepath.Join(routed, "SKILL.md"), append(validSkill(multiMiscSkill), []byte("dws event\n")...), 0o644); err != nil {
t.Fatal(err)
}
if err := validateCleanEventMiscRoot(routed); err == nil || !strings.Contains(err.Error(), "仍包含个人 Event 路由") {
t.Fatalf("routed misc = %v", err)
}
clean := t.TempDir()
if err := os.WriteFile(filepath.Join(clean, "SKILL.md"), validSkill(multiMiscSkill), 0o644); err != nil {
t.Fatal(err)
}
if err := validateCleanEventMiscRoot(clean); err != nil {
t.Fatalf("missing references should be clean: %v", err)
}
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, fail })
if err := validateCleanEventMiscRoot(clean); !errors.Is(err, fail) {
t.Fatalf("read-dir failure = %v", err)
}
})
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("ordinary install failure = %v", err)
}
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("prerequisite install failure = %v", err)
}
})
t.Run("preparation cleanup and staged misc validation", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
dest := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return fail })
cleanupFail := errors.New("cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
if _, err := prepareEventMiscMigration(src, dest); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
t.Fatalf("joined preparation cleanup error = %v", err)
}
})
t.Run("invalid staged misc root", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
t.Fatal(err)
}
if _, err := prepareEventMiscMigration(src, t.TempDir()); err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
t.Fatalf("invalid staged misc = %v", err)
}
})
t.Run("later staging failure joins cleanup error", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
first := filepath.Join(t.TempDir(), "a")
second := filepath.Join(t.TempDir(), "b")
if err := os.MkdirAll(first, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(second, 0o755); err != nil {
t.Fatal(err)
}
originalMkdirTemp := skillSetupMkdirTemp
calls := 0
testseam.Swap(t, &skillSetupMkdirTemp, func(dir, pattern string) (string, error) {
calls++
if calls == 2 {
return "", fail
}
return originalMkdirTemp(dir, pattern)
})
cleanupFail := errors.New("stage cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
t.Fatalf("later preparation failure = %v", err)
}
})
t.Run("successful migration reports cleanup warning", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
cleanupFail := errors.New("final cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
var stderr bytes.Buffer
installed, err := migrateEventMiscAtomically(src, []string{home}, io.Discard, &stderr)
if err != nil || installed != 2 || !strings.Contains(stderr.String(), cleanupFail.Error()) {
t.Fatalf("successful migration cleanup warning: installed=%d err=%v stderr=%s", installed, err, stderr.String())
}
})
t.Run("commit rollback and cleanup failures are joined", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
root := t.TempDir()
first := filepath.Join(root, "a", "skills")
second := filepath.Join(root, "b", "skills")
for _, home := range []string{first, second} {
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
}
commitFail := errors.New("second commit failure")
rollbackFail := errors.New("first rollback failure")
originalRename := skillSetupRename
testseam.Swap(t, &skillSetupRename, func(oldPath, newPath string) error {
if filepath.Base(oldPath) == "new-misc" && newPath == filepath.Join(second, multiMiscSkill) {
return commitFail
}
if filepath.Base(oldPath) == "old-misc" && newPath == filepath.Join(first, multiMiscSkill) {
return rollbackFail
}
return originalRename(oldPath, newPath)
})
cleanupFail := errors.New("post-rollback cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, commitFail) || !errors.Is(err, rollbackFail) || !errors.Is(err, cleanupFail) {
t.Fatalf("joined commit/rollback/cleanup error = %v", err)
}
})
t.Run("commit preflight and rollback aggregation", func(t *testing.T) {
migration := &eventMiscMigration{dest: "dest", eventPath: "event", miscPath: "misc"}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
t.Fatalf("event stat failure = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == migration.eventPath {
return skillSetupFileInfo{name: "event", mode: os.ModeDir}, nil
}
return nil, fail
})
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
t.Fatalf("misc stat failure = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
if err := commitEventMiscMigration(migration); err == nil || !strings.Contains(err.Error(), "已不存在") {
t.Fatalf("missing misc = %v", err)
}
migration.newMiscEnabled = true
testseam.Swap(t, &skillSetupRename, func(string, string) error { return fail })
if err := rollbackEventMiscMigrations([]*eventMiscMigration{migration}); !errors.Is(err, fail) {
t.Fatalf("rollback aggregation = %v", err)
}
})
}
+796 -1
View File
@@ -2,6 +2,9 @@ package app
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -184,12 +187,20 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
skillBody := "---\nname: " + n + "\ndescription: test skill\n---\n\n# " + n + "\n"
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte(skillBody), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
t.Fatal(err)
}
if n == multiEventSkill {
for _, ref := range eventMigrationRequiredReferences {
if err := os.WriteFile(filepath.Join(sub, "references", ref), []byte("ref "+ref+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
}
return root
}
@@ -549,3 +560,787 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
t.Fatalf("expected %s, got %s", multiDir, got)
}
}
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
t.Helper()
originalTargets := skillSetupResolveTargets
skillSetupResolveTargets = func(string, string) ([]string, error) {
return append([]string(nil), dests...), nil
}
t.Cleanup(func() { skillSetupResolveTargets = originalTargets })
cmd := newSkillSetupCommand()
cmd.Flags().Bool("dry-run", false, "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
baseArgs := []string{"--mode", "multi", "--source", src}
cmd.SetArgs(append(baseArgs, args...))
err := cmd.Execute()
return stdout.String(), stderr.String(), err
}
func writeFoldedEventMisc(t *testing.T, agentHome string) {
t.Helper()
miscRoot := filepath.Join(agentHome, multiMiscSkill)
if err := os.MkdirAll(filepath.Join(miscRoot, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("personal IM route: dws event consume\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(miscRoot, "references", "event.md"), []byte("folded event docs\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func writeOldStandaloneEvent(t *testing.T, agentHome string) {
t.Helper()
eventRoot := filepath.Join(agentHome, multiEventSkill)
if err := os.MkdirAll(eventRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(eventRoot, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func assertOldEventMiscPair(t *testing.T, agentHome string) {
t.Helper()
eventBody, err := os.ReadFile(filepath.Join(agentHome, multiEventSkill, "SKILL.md"))
if err != nil || string(eventBody) != "old standalone event\n" {
t.Fatalf("old standalone event was not restored: body=%q err=%v", eventBody, err)
}
miscBody, err := os.ReadFile(filepath.Join(agentHome, multiMiscSkill, "SKILL.md"))
if err != nil || !strings.Contains(string(miscBody), "dws event") {
t.Fatalf("folded misc was not restored: body=%q err=%v", miscBody, err)
}
if _, err := os.Stat(filepath.Join(agentHome, multiMiscSkill, "references", "event.md")); err != nil {
t.Fatalf("folded Event reference was not restored: %v", err)
}
}
func assertNoEventMigrationStages(t *testing.T, agentHome string) {
t.Helper()
entries, err := os.ReadDir(agentHome)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
t.Fatalf("unexpected leftover Event migration stage %s", filepath.Join(agentHome, entry.Name()))
}
}
}
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
writeFoldedEventMisc(t, foldedHome)
if err := os.MkdirAll(filepath.Join(foldedHome, multiEventSkill), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(foldedHome, "dingtalk-chat"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
if err != nil {
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
}
if !strings.Contains(stdout, "重新加载 Skills") {
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
}
for _, home := range []string{freshHome, foldedHome} {
for _, name := range []string{multiSharedSkill, multiEventSkill} {
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
t.Errorf("%s missing from %s: %v", name, home, err)
}
}
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc")); !os.IsNotExist(err) {
t.Errorf("unselected doc appeared in %s: %v", home, err)
}
}
if _, err := os.Stat(filepath.Join(freshHome, multiMiscSkill)); !os.IsNotExist(err) {
t.Fatalf("fresh selective target must not receive misc, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
t.Fatalf("folded event reference survived clean misc replacement, stat err=%v", err)
}
eventBody, err := os.ReadFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"))
if err != nil || strings.Contains(string(eventBody), "old standalone") {
t.Fatalf("old standalone event was not replaced: body=%q err=%v", eventBody, err)
}
siblingBody, err := os.ReadFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"))
if err != nil || string(siblingBody) != "keep sibling\n" {
t.Fatalf("unrelated sibling changed: body=%q err=%v", siblingBody, err)
}
// A second selective run sees the already-clean misc, does not plan another
// migration, and leaves that unselected sibling in place.
stdout, stderr, err = executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("idempotent event setup failed: %v\nstderr=%s", err, stderr)
}
if strings.Contains(stdout, "迁移伴侣") {
t.Fatalf("clean second run should not re-detect folded misc: %s", stdout)
}
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "SKILL.md")); err != nil {
t.Fatalf("second selective run removed clean misc: %v", err)
}
}
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
t.Run("dry run reports companion without writes", func(t *testing.T) {
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--dry-run", "--yes")
if err != nil {
t.Fatalf("dry run failed: %v\nstderr=%s", err, stderr)
}
if !strings.Contains(stdout, "DRY-RUN") || !strings.Contains(stdout, "迁移伴侣") {
t.Fatalf("dry run did not expose migration: %s", stdout)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("dry run changed folded misc: body=%q err=%v", body, readErr)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("dry run installed event, stat err=%v", statErr)
}
})
t.Run("excluding required misc fails before writes", func(t *testing.T) {
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--exclude", "misc", "--yes")
if err == nil || !strings.Contains(err.Error(), "不能显式 --exclude misc") {
t.Fatalf("expected clear migration exclusion error, got %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("failed migration installed event, stat err=%v", statErr)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("failed migration changed misc: body=%q err=%v", body, readErr)
}
})
}
func TestSkillSetupEventMigrationRequiresCleanMiscInSource(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "当前 multi 源缺少") {
t.Fatalf("expected missing migration companion error, got %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("failed preflight installed event, stat err=%v", statErr)
}
}
func TestSkillSetupEventMigrationAcceptsShippedMultiBundle(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
src := filepath.Clean(filepath.Join(wd, "..", "..", "skills", "multi"))
if err := validateEventMiscMigrationSource(src); err != nil {
t.Fatalf("shipped multi bundle is not a valid Event migration source: %v", err)
}
}
func TestSkillSetupEventMigrationRejectsInvalidSkillBundlesBeforeWrites(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, src string)
}{
{
name: "empty event root",
mutate: func(t *testing.T, src string) {
t.Helper()
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), nil, 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "wrong event name",
mutate: func(t *testing.T, src string) {
t.Helper()
body := "---\nname: dingtalk-chat\ndescription: wrong skill\n---\n\n# Wrong\n"
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "missing event reference",
mutate: func(t *testing.T, src string) {
t.Helper()
if err := os.Remove(filepath.Join(src, multiEventSkill, "references", "event-oa.md")); err != nil {
t.Fatal(err)
}
},
},
{
name: "empty event reference",
mutate: func(t *testing.T, src string) {
t.Helper()
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "references", "event-im.md"), nil, 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "wrong misc name",
mutate: func(t *testing.T, src string) {
t.Helper()
body := "---\nname: dingtalk-event\ndescription: wrong skill\n---\n\n# Wrong\n"
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
test.mutate(t, src)
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "迁移源无效") {
t.Fatalf("invalid migration source was accepted: %v", err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("invalid migration source reported success: %s", stdout)
}
assertOldEventMiscPair(t, home)
if _, statErr := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(statErr) {
t.Fatalf("invalid source wrote shared skill: %v", statErr)
}
})
}
}
func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("misc migration companion ran after a primary install skip")
}
return 1, 1, nil
}
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
t.Fatalf("expected preserved-fallback error, got %v", err)
}
if calls != 1 {
t.Fatalf("install calls = %d, want 1", calls)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("partial migration reported success: %s", stdout)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("primary skip changed folded misc: body=%q err=%v", body, readErr)
}
if _, statErr := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); statErr != nil {
t.Fatalf("primary skip removed folded Event reference: %v", statErr)
}
}
func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
writeFoldedEventMisc(t, foldedHome)
writeOldStandaloneEvent(t, foldedHome)
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("folded target prerequisites ran after fresh target failure")
}
return 1, 1, nil
}
stdout, _, err := executeMultiSkillSetupTest(
t,
src,
[]string{foldedHome, freshHome},
"--skill", "event",
"--yes",
)
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
t.Fatalf("fresh target failure did not block migration: %v", err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("partial mixed-target install reported success: %s", stdout)
}
assertOldEventMiscPair(t, foldedHome)
assertNoEventMigrationStages(t, foldedHome)
}
func TestSkillSetupUnrelatedSelectiveInstallLeavesFoldedPairUntouched(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "doc", "--yes")
if err != nil {
t.Fatalf("unrelated selective install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if strings.Contains(stdout, "Event Skill 迁移") || strings.Contains(stdout, "Event 原子迁移") {
t.Fatalf("unrelated selective install planned Event migration: %s", stdout)
}
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc", "SKILL.md")); err != nil {
t.Fatalf("selected doc was not installed: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupSelectiveEventAtomicStageFailurePreservesFoldedPair(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if strings.HasSuffix(dest, "new-misc") {
return errors.New("injected stage failure")
}
return originalCopyDir(src, dest)
}
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "预备 dingtalk-misc 失败") {
t.Fatalf("expected atomic staging error, got %v", err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("partial atomic migration reported success: %s", stdout)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("stage failure changed folded misc: body=%q err=%v", body, readErr)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("stage failure installed standalone event, stat err=%v", statErr)
}
}
func TestSkillSetupEventMigrationPreparationFailuresPreserveFoldedPair(t *testing.T) {
t.Run("same-filesystem staging creation", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalMkdirTemp := skillSetupMkdirTemp
t.Cleanup(func() { skillSetupMkdirTemp = originalMkdirTemp })
skillSetupMkdirTemp = func(dir, pattern string) (string, error) {
if dir != home {
t.Fatalf("staging dir = %s, want target filesystem root %s", dir, home)
}
return "", errors.New("injected mkdir-temp failure")
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "injected mkdir-temp failure") {
t.Fatalf("staging creation failure was not returned: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
})
t.Run("event staging copy", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if strings.HasSuffix(dest, "new-event") {
return errors.New("injected event copy failure")
}
return originalCopyDir(src, dest)
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "injected event copy failure") {
t.Fatalf("event staging failure was not returned: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
})
}
func TestSkillSetupSelectiveEventRejectsCorruptStagedMisc(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if err := originalCopyDir(src, dest); err != nil {
return err
}
if strings.HasSuffix(dest, "new-misc") {
return os.WriteFile(filepath.Join(dest, "references", "event-partial.md"), []byte("corrupt\n"), 0o644)
}
return nil
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
t.Fatalf("corrupt staged misc was accepted: %v", err)
}
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
t.Fatalf("staging validation failure changed folded misc: body=%q err=%v", miscBody, readErr)
}
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
t.Fatalf("staging validation failure installed event: %v", err)
}
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupSelectiveEventRejectsIncompleteStagedEvent(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if err := originalCopyDir(src, dest); err != nil {
return err
}
if strings.HasSuffix(dest, "new-event") {
return os.Remove(filepath.Join(dest, "references", "event-oa.md"))
}
return nil
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
t.Fatalf("incomplete staged Event was accepted: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupFullEventMigrationIsAtomicAndPreservesSiblings(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
sibling := filepath.Join(home, "dingtalk-private-sibling")
if err := os.MkdirAll(sibling, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sibling, "SKILL.md"), []byte("keep\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--yes")
if err != nil {
t.Fatalf("full setup migration failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if !strings.Contains(stdout, "Event 原子迁移") || !strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("full setup did not report atomic migration success: %s", stdout)
}
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill, "dingtalk-doc"} {
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
t.Fatalf("full setup missing %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
t.Fatalf("full setup retained folded Event reference: %v", err)
}
body, err := os.ReadFile(filepath.Join(sibling, "SKILL.md"))
if err != nil || string(body) != "keep\n" {
t.Fatalf("full setup changed unrelated sibling: body=%q err=%v", body, err)
}
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupEventMigrationWithoutSharedStillCleansMonoLeftover(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
monoLeftover := filepath.Join(home, "dws")
if err := os.MkdirAll(monoLeftover, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("migration without shared failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
t.Fatalf("migration without prerequisites retained mono leftover: %v", err)
}
for _, name := range []string{multiEventSkill, multiMiscSkill} {
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
t.Fatalf("migration without shared missing %s: %v", name, err)
}
}
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupFoldedEventMigrationSelectionPreflight(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
tests := []struct {
name string
args []string
want string
}{
{name: "misc only", args: []string{"--skill", "misc", "--yes"}, want: "不能只覆盖 dingtalk-misc"},
{name: "explicitly excludes event", args: []string{"--exclude", "event", "--yes"}, want: "不能显式 --exclude event"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, tt.args...)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("preflight error = %v, want %q", err, tt.want)
}
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
t.Fatalf("preflight failure wrote shared skill: %v", err)
}
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
t.Fatalf("preflight failure wrote event skill: %v", err)
}
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
t.Fatalf("preflight failure changed folded misc: body=%q err=%v", miscBody, readErr)
}
})
}
}
func TestSkillSetupEventMigrationRejectsEveryFoldedReferenceVariant(t *testing.T) {
for _, filename := range []string{"event.md", "event-im.md", "event-oa.md", "EVENT-legacy.MD"} {
t.Run(filename, func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "references", filename), []byte("stale\n"), 0o644); err != nil {
t.Fatal(err)
}
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "仍存在折叠 Event 参考页") {
t.Fatalf("source with %s was accepted: %v", filename, err)
}
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
t.Fatalf("invalid source wrote shared skill: %v", err)
}
})
}
}
func TestSkillSetupEventMigrationRenameFailuresRollbackPair(t *testing.T) {
for failAt := 1; failAt <= 4; failAt++ {
t.Run(fmt.Sprintf("rename_%d", failAt), func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
renameCalls := 0
skillSetupRename = func(oldPath, newPath string) error {
renameCalls++
if renameCalls == failAt {
return errors.New("injected rename failure")
}
return originalRename(oldPath, newPath)
}
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "injected rename failure") {
t.Fatalf("rename failure %d was not returned: %v", failAt, err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("rename failure %d reported success: %s", failAt, stdout)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
})
}
}
func TestSkillSetupEventMigrationFailureRollsBackEarlierTargets(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
root := t.TempDir()
firstHome := filepath.Join(root, "a", "skills")
secondHome := filepath.Join(root, "b", "skills")
for _, home := range []string{firstHome, secondHome} {
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
}
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
failed := false
skillSetupRename = func(oldPath, newPath string) error {
if !failed && oldPath == filepath.Join(secondHome, multiMiscSkill) {
failed = true
return errors.New("second target failure")
}
return originalRename(oldPath, newPath)
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{secondHome, firstHome}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "second target failure") {
t.Fatalf("second target failure was not returned: %v", err)
}
for _, home := range []string{firstHome, secondHome} {
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
}
}
func TestSkillSetupEventMigrationRollbackFailurePreservesRecoveryDirectory(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
skillSetupRename = func(oldPath, newPath string) error {
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("commit failure")
}
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("rollback restore failure")
}
return originalRename(oldPath, newPath)
}
_, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "回滚不完整") || !strings.Contains(err.Error(), "恢复目录") {
t.Fatalf("rollback failure did not expose recovery directory: %v", err)
}
entries, readErr := os.ReadDir(home)
if readErr != nil {
t.Fatal(readErr)
}
var recoveryRoot string
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
recoveryRoot = filepath.Join(home, entry.Name())
break
}
}
if recoveryRoot == "" {
t.Fatal("rollback failure deleted the only recovery directory")
}
if !strings.Contains(err.Error(), recoveryRoot) || !strings.Contains(stderr, recoveryRoot) {
t.Fatalf("recovery directory was not reported: err=%v stderr=%s", err, stderr)
}
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-misc", "SKILL.md")); err != nil {
t.Fatalf("old folded misc backup is missing from recovery directory: %v", err)
}
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-event", "SKILL.md")); err != nil {
t.Fatalf("old standalone Event backup is missing from recovery directory: %v", err)
}
if _, err := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); err != nil {
t.Fatalf("rollback failure removed the live standalone Event entry: %v", err)
}
}
func TestSkillSetupEventMigrationRollbackFailureKeepsNewEventWithoutOldStandalone(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
skillSetupRename = func(oldPath, newPath string) error {
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("commit failure")
}
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("rollback restore failure")
}
return originalRename(oldPath, newPath)
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "回滚不完整") {
t.Fatalf("rollback failure was not returned: %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); statErr != nil {
t.Fatalf("rollback failure removed the only live Event entry: %v", statErr)
}
entries, readErr := os.ReadDir(home)
if readErr != nil {
t.Fatal(readErr)
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
continue
}
if _, statErr := os.Stat(filepath.Join(home, entry.Name(), "old-misc", "SKILL.md")); statErr != nil {
t.Fatalf("rollback failure lost the folded misc recovery copy: %v", statErr)
}
return
}
t.Fatal("rollback failure did not preserve a recovery directory")
}
+4
View File
@@ -44,6 +44,10 @@ import (
// test binary never launches a page on the developer's machine; tests that
// need to assert the URL can still replace openBrowserFunc locally.
func TestMain(m *testing.M) {
if code, ok := runRuntimeTokenDetachedE2EChild(); ok {
os.Exit(code)
}
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
if err != nil {
panic("create test keychain tempdir: " + err.Error())
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package auth
import "testing"
func TestCrossPlatformCoverageResolveProfileMetadataUsesSelectorGrammarAndReturnsCopy(t *testing.T) {
cfg := &ProfilesConfig{
Version: 3,
Profiles: []Profile{
{Name: "Historical", CorpID: "corp-1", CorpName: "Example Org"},
{Name: "Exact", CorpID: "corp-1", CorpName: "Example Org", UserID: "user-1", UserName: "Example User", ClientID: "client-1"},
},
OrgCurrentProfiles: map[string]string{"corp-1": "corp-1:user-1"},
}
for _, selector := range []string{"corp-1", "Example Org", "corp-1:Example User", "Example Org:Example User"} {
profile, err := ResolveProfileMetadata(cfg, selector)
if err != nil {
t.Fatalf("ResolveProfileMetadata(%q) error = %v", selector, err)
}
if profile == nil || profile.UserID != "user-1" || profile.ClientID != "client-1" {
t.Fatalf("ResolveProfileMetadata(%q) = %#v", selector, profile)
}
}
profile, err := ResolveProfileMetadata(cfg, "corp-1:user-1")
if err != nil {
t.Fatalf("ResolveProfileMetadata(exact) error = %v", err)
}
profile.Name = "mutated copy"
if cfg.Profiles[1].Name != "Exact" {
t.Fatalf("ResolveProfileMetadata returned registry-owned pointer")
}
if _, err := ResolveProfileMetadata(cfg, "missing"); err == nil {
t.Fatal("ResolveProfileMetadata(missing) unexpectedly succeeded")
}
}
+14
View File
@@ -738,6 +738,20 @@ func ResolveProfileWithScope(configDir, selector string) (*Profile, bool, error)
return result, exact, err
}
// ResolveProfileMetadata applies the public profile-selector grammar to an
// already-loaded, non-sensitive profiles registry. It performs no migration,
// keychain access, token loading, or persistence, making it suitable for
// callers that carry an externally managed bearer credential and need only
// corp/user/client identity metadata.
func ResolveProfileMetadata(cfg *ProfilesConfig, selector string) (*Profile, error) {
profile, _, err := resolveProfileSelection("", cfg, selector)
if err != nil || profile == nil {
return nil, err
}
copy := *profile
return &copy, nil
}
func resolveProfileWithScopeLocked(configDir, selector string) (*Profile, bool, error) {
if err := profilesEnsureMigration(configDir); err != nil {
return nil, false, err
@@ -256,6 +256,10 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"chat.list_message_favorites --max-items": "Reviewed helper-only pagination control: --max-items caps aggregated CLI output for chat.list_message_favorites and is not sent to the remote adapter.",
"chat.list_message_favorites --page-all": "Reviewed helper-only pagination control: --page-all enables CLI-side looping for chat.list_message_favorites and is not sent to the remote adapter.",
"chat.list_message_favorites --page-delay": "Reviewed helper-only pagination control: --page-delay controls local delay between chat.list_message_favorites pages and is not sent to the remote adapter.",
"chat.list_message_favorites --page-limit": "Reviewed helper-only pagination control: --page-limit caps CLI-side page fetches for chat.list_message_favorites and is not sent to the remote adapter.",
"chat.list_message_favorites --size": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --size is a CLI wrapper input and does not publish a direct interface property.",
"chat.query_msg_read_status --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
+1 -1
View File
@@ -41,7 +41,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
t.Fatalf("PrintJSON friendly diagnostics = %q, %v", out.String(), err)
}
out.Reset()
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "开启地址") {
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "处理入口") {
t.Fatalf("PrintHuman friendly diagnostics = %q, %v", out.String(), err)
}
out.Reset()
+16 -2
View File
@@ -19,6 +19,7 @@ import (
stderrors "errors"
"fmt"
"io"
"net/url"
"strings"
"time"
@@ -460,7 +461,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
lines = append(lines, tui.Cyan("Hint: "+friendlyHint))
}
if actionURL != "" {
lines = append(lines, tui.White("Action: 开启地址: "+actionURL))
lines = append(lines, tui.White("Action: 处理入口: "+actionURL))
}
}
@@ -540,7 +541,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
func serverGuidance(diag ServerDiagnostics) (string, string) {
friendlyHint := strings.TrimSpace(diag.FriendlyHint)
actionURL := strings.TrimSpace(diag.ActionURL)
actionURL := safeServerActionURL(diag.ActionURL)
if friendlyHint == "" || actionURL == "" {
switch diag.ServerErrorCode {
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
@@ -555,6 +556,19 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
return friendlyHint, actionURL
}
func safeServerActionURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || !strings.EqualFold(parsed.Scheme, "https") ||
parsed.Hostname() == "" || parsed.User != nil {
return ""
}
return parsed.String()
}
func category(err error) string {
var typed *Error
if stderrors.As(err, &typed) {
+30 -1
View File
@@ -336,11 +336,40 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
if !strings.Contains(got, "Hint: 请联系管理员开通消息搜索权益") {
t.Fatalf("expected server guidance in output, got %q", got)
}
if !strings.Contains(got, "Action: 开启地址: https://example.test/enable-search") {
if !strings.Contains(got, "Action: 处理入口: https://example.test/enable-search") {
t.Fatalf("expected server action URL in output, got %q", got)
}
}
func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) {
t.Parallel()
for _, actionURL := range []string{
"http://example.test/help",
"javascript:alert(1)",
"https://user:secret@example.test/help",
"not a url",
} {
var human strings.Builder
err := NewAPI("server error", WithServerDiag(ServerDiagnostics{
FriendlyHint: "保留 Trace ID 后排查",
ActionURL: actionURL,
}))
if printErr := PrintHuman(&human, err); printErr != nil {
t.Fatal(printErr)
}
if strings.Contains(human.String(), actionURL) || strings.Contains(human.String(), "处理入口") {
t.Fatalf("unsafe action URL %q leaked to human output: %q", actionURL, human.String())
}
var jsonOutput strings.Builder
if printErr := PrintJSON(&jsonOutput, err); printErr != nil {
t.Fatal(printErr)
}
if strings.Contains(jsonOutput.String(), `"action_url"`) {
t.Fatalf("unsafe action URL %q leaked to JSON output: %q", actionURL, jsonOutput.String())
}
}
}
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
t.Parallel()
+16
View File
@@ -17,6 +17,7 @@ import (
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
@@ -324,6 +325,21 @@ func TestCrossPlatformCoverageRunStartupAndSourceEdges(t *testing.T) {
if err := Run(context.Background(), base); !errors.Is(err, errBusInjected) {
t.Fatalf("source error = %v", err)
}
runtimeWorkDir := shortTempDir(t)
base.WorkDir = runtimeWorkDir
base.IPCEndpoint = dwsevent.IPCEndpoint(
runtimeWorkDir,
"open",
dwsevent.SourceKindPersonalStream,
dwsevent.IdentityHash(runtimeWorkDir),
)
base.Source = edgeSource{start: func(context.Context, dwsevent.EmitFn) error {
return runtimecred.ErrRuntimeTokenRejected
}}
if err := Run(context.Background(), base); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("runtime source error = %v", err)
}
}
type scriptedListener struct {
@@ -0,0 +1,275 @@
package bus
import (
"context"
"errors"
"io"
"log/slog"
"net"
"runtime"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func eventCoreDaemon(broker *runtimecred.Broker) *daemon {
return &daemon{
cfg: Config{CredentialBroker: broker, IdleTimeout: time.Second},
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
hub: NewHub(4),
started: time.Now(),
idleStop: make(chan struct{}),
}
}
func eventCoreConnection(d *daemon, wrap func(net.Conn) net.Conn) (net.Conn, *transport.Writer, *transport.Reader, <-chan struct{}) {
server, client := net.Pipe()
if wrap != nil {
server = wrap(server)
}
done := make(chan struct{})
go func() {
d.handleConnection(context.Background(), server)
close(done)
}()
return client, transport.NewWriter(client), transport.NewReader(client), done
}
func eventCoreWaitDone(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection handler did not stop")
}
}
type eventCoreWriteHookConn struct {
net.Conn
writes int
hook func(int)
}
func (c *eventCoreWriteHookConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
c.writes++
if err == nil && c.hook != nil {
c.hook(c.writes)
}
return n, err
}
func TestCrossPlatformCoverageEventCoreDaemonHandshakeEdges(t *testing.T) {
t.Run("incompatible ack write failure", func(t *testing.T) {
d := eventCoreDaemon(nil)
client, w, _, done := eventCoreConnection(d, nil)
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: "unsupported"}); err != nil {
t.Fatal(err)
}
_ = client.Close()
eventCoreWaitDone(t, done)
})
t.Run("runtime ack write failure", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
client, w, _, done := eventCoreConnection(d, nil)
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
_ = client.Close()
eventCoreWaitDone(t, done)
})
t.Run("terminal runtime hello", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
d.setTerminalReason(transport.ByeReasonRuntimeTokenRejected)
client, w, r, done := eventCoreConnection(d, nil)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil || ack.TerminalReason != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("terminal ack = %#v, %v", ack, err)
}
eventCoreWaitDone(t, done)
})
t.Run("malformed credential update", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
client, w, r, done := eventCoreConnection(d, nil)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if _, err := client.Write([]byte("{\n")); err != nil {
t.Fatal(err)
}
eventCoreWaitDone(t, done)
})
t.Run("unexpected credential update", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
client, w, r, done := eventCoreConnection(d, nil)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.Heartbeat{Type: transport.FrameTypeHeartbeat}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil || updateAck.ErrorCode != transport.CredentialErrorInvalid {
t.Fatalf("unexpected-frame ack = %#v, %v", updateAck, err)
}
eventCoreWaitDone(t, done)
})
t.Run("credential ack write failure", func(t *testing.T) {
d := eventCoreDaemon(runtimecred.New(runtimecred.Config{}))
client, w, r, done := eventCoreConnection(d, nil)
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate, ExpectedGeneration: ack.CredentialGeneration, Token: "token",
}); err != nil {
t.Fatal(err)
}
_ = client.Close()
eventCoreWaitDone(t, done)
})
t.Run("activation conflict", func(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
d := eventCoreDaemon(broker)
client, w, r, done := eventCoreConnection(d, func(conn net.Conn) net.Conn {
return &eventCoreWriteHookConn{Conn: conn, hook: func(write int) {
if write == 2 {
_, _ = broker.Update(1, "newer-token")
}
}}
})
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, CredentialMode: transport.CredentialModeRuntimeToken}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.CredentialUpdate{Type: transport.FrameTypeCredentialUpdate, Token: "first-token"}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil || !updateAck.Accepted {
t.Fatalf("credential ack = %#v, %v", updateAck, err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil || bye.Reason != "runtime_credential_activation_failed" {
t.Fatalf("activation failure bye = %#v, %v", bye, err)
}
eventCoreWaitDone(t, done)
})
}
func TestCrossPlatformCoverageEventCoreDaemonWriterStopEdges(t *testing.T) {
originalProcs := runtime.GOMAXPROCS(1)
t.Cleanup(func() { runtime.GOMAXPROCS(originalProcs) })
run := func(t *testing.T, queueEvent bool) {
t.Helper()
d := eventCoreDaemon(nil)
client, w, r, done := eventCoreConnection(d, nil)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, SubscribeID: "writer-stop"}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for d.hub.Len() != 1 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
time.Sleep(5 * time.Millisecond)
d.hub.mu.RLock()
var consumer *Consumer
for _, candidate := range d.hub.consumers {
consumer = candidate
}
d.hub.mu.RUnlock()
if consumer == nil {
t.Fatal("consumer not registered")
}
if queueEvent {
consumer.SendCh <- transport.Heartbeat{Type: transport.FrameTypeHeartbeat}
}
consumer.StopCh <- "writer-stop"
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil || bye.Reason != "writer-stop" {
t.Fatalf("writer stop bye = %#v, %v", bye, err)
}
eventCoreWaitDone(t, done)
}
t.Run("recheck after event", func(t *testing.T) { run(t, true) })
t.Run("blocked stop select", func(t *testing.T) { run(t, false) })
}
func TestCrossPlatformCoverageEventCoreDaemonHelpersAndStopAll(t *testing.T) {
var nilDaemon *daemon
nilDaemon.setTerminalReason("ignored")
if nilDaemon.getTerminalReason() != "" {
t.Fatal("nil daemon returned terminal reason")
}
d := eventCoreDaemon(nil)
d.setTerminalReason("ignored")
if d.getTerminalReason() != "" {
t.Fatal("invalid terminal reason was stored")
}
d.setTerminalReason(transport.ByeReasonRuntimeTokenRejected)
if d.getTerminalReason() != transport.ByeReasonRuntimeTokenRejected {
t.Fatal("terminal reason was not stored")
}
if code, _ := classifyCredentialUpdateError(runtimecred.ErrEmptyToken); code != transport.CredentialErrorInvalid {
t.Fatalf("empty-token classification = %q", code)
}
if code, message := classifyCredentialUpdateError(errors.New("internal detail")); code != transport.CredentialErrorInternal || message != "runtime credential update failed" {
t.Fatalf("internal classification = %q, %q", code, message)
}
hub := NewHub(1)
consumer, err := hub.Register(transport.Hello{})
if err != nil {
t.Fatal(err)
}
if stopped := hub.StopAll(" "); stopped != 1 {
t.Fatalf("StopAll = %d", stopped)
}
select {
case reason := <-consumer.StopCh:
if reason != "shutdown" {
t.Fatalf("default stop reason = %q", reason)
}
case <-time.After(time.Second):
t.Fatal("default stop reason not delivered")
}
hub.Unregister(consumer.ID)
}
+251 -35
View File
@@ -30,6 +30,7 @@ import (
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/dedup"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -78,6 +79,11 @@ type Config struct {
// Source is the cloud adapter. Required.
Source SourceAdapter
// CredentialBroker enables additive runtime-token handoff over the
// owner-only local IPC transport. Nil preserves the original protocol and
// does not advertise runtime-token support.
CredentialBroker *runtimecred.Broker
// IdleTimeout: bus self-exits after this long with zero consumers.
// Zero disables (bus runs until SIGTERM).
IdleTimeout time.Duration
@@ -106,11 +112,13 @@ type Config struct {
}
var (
daemonMkdirAll = os.MkdirAll
daemonAcquire = Acquire
daemonWriteMeta = WriteMeta
daemonListen = transport.Listen
daemonShutdownTimeout = 2 * time.Second
daemonMkdirAll = os.MkdirAll
daemonAcquire = Acquire
daemonWriteMeta = WriteMeta
daemonListen = transport.Listen
daemonShutdownTimeout = 2 * time.Second
daemonByeDrainTimeout = 100 * time.Millisecond
daemonCredentialHandshakeTimeout = 10 * time.Second
)
// Run starts the bus daemon. Lifecycle (plan §4 invariant #6):
@@ -237,12 +245,22 @@ func Run(ctx context.Context, cfg Config) error {
// 6. Wait for shutdown trigger.
var exitErr error
shutdownReason := "shutdown"
select {
case <-ctx.Done():
log.Info("bus: shutdown requested by ctx", "reason", ctx.Err())
case err := <-srcErr:
log.Error("bus: source exited", "err", err)
exitErr = err
shutdownReason = sourceShutdownReason(err)
if shutdownReason == transport.ByeReasonRuntimeTokenRejected {
// A runtime token can fail immediately after Broker.Update. Serialize
// terminal publication with that handshake so the initiating consumer
// is registered (or receives a terminal HelloAck) before shutdown.
d.credentialHandoffMu.Lock()
d.setTerminalReason(shutdownReason)
d.credentialHandoffMu.Unlock()
}
case <-d.idleStop:
log.Info("bus: idle timeout reached, shutting down")
}
@@ -252,7 +270,7 @@ func Run(ctx context.Context, cfg Config) error {
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
// sync.WaitGroup forbids a positive Add racing with Wait.
cancelRun()
d.shutdown(acceptDone)
d.shutdown(acceptDone, shutdownReason)
<-idleDone
<-dropWarnDone
@@ -274,6 +292,10 @@ type daemon struct {
shutdownMu sync.Mutex
shuttingDown atomic.Bool
idleStop chan struct{}
credentialHandoffMu sync.Mutex
terminalMu sync.RWMutex
terminalReason string
}
// closeOnceConn makes every connection close path idempotent. A live consumer
@@ -330,7 +352,8 @@ func (d *daemon) acceptLoop(ctx context.Context) {
}
// handleConnection processes one IPC connection's full lifecycle: read
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
// Hello → optional runtime credential negotiation → register with Hub → spawn
// writer goroutine → read until EOF/Bye.
// Always Unregisters and Closes on exit (plan invariant #5).
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
conn = ensureCloseOnce(conn)
@@ -372,29 +395,141 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
return
}
// Regular consumer registration
// HelloAck — credentials_source fields are filled in by the daemon
// runner (which knows from the strict resolver) and exposed via the
// adapter for forward-compat. v1 leaves them empty here; daemon.Run
// passes them through future config if the caller wishes.
ack := d.helloAck()
handoffLocked := false
runtimeGeneration := uint64(0)
defer func() {
if handoffLocked {
d.credentialHandoffMu.Unlock()
}
}()
// Runtime credentials use a two-phase additive handshake. The first ack
// proves capability before the client sends any secret. Only a successful
// CAS and credential ack permit Hub registration.
if hello.CredentialMode != "" {
if hello.CredentialMode != transport.CredentialModeRuntimeToken || d.cfg.CredentialBroker == nil {
ack.Capabilities = nil
ack.CredentialGeneration = 0
if err := w.WriteJSON(ack); err != nil {
d.log.Warn("bus: incompatible helloack write failed", "err", err)
}
return
}
d.credentialHandoffMu.Lock()
handoffLocked = true
// Terminal state may have been published while this Hello waited for a
// concurrent credential handoff. Rebuild the ack while holding the gate.
ack = d.helloAck()
if err := w.WriteJSON(ack); err != nil {
d.log.Warn("bus: runtime helloack write failed", "err", err)
return
}
if ack.TerminalReason == transport.ByeReasonRuntimeTokenRejected {
return
}
var update transport.CredentialUpdate
_ = conn.SetReadDeadline(time.Now().Add(daemonCredentialHandshakeTimeout))
if err := r.ReadJSON(&update); err != nil {
// Do not include the decoder error: malformed JSON may contain
// fragments of the credential.
d.log.Warn("bus: malformed runtime credential update")
return
}
_ = conn.SetReadDeadline(time.Time{})
if update.Type != transport.FrameTypeCredentialUpdate {
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
CredentialGeneration: d.cfg.CredentialBroker.Generation(),
ErrorCode: transport.CredentialErrorInvalid,
Error: "unexpected credential update frame",
})
return
}
// Validate registration before applying the credential or sending an
// accepted ack. Hub.Register performs the same deterministic compile
// before mutating the Hub; this preflight keeps invalid filters from
// producing a ready marker after credential negotiation.
if _, err := compileMatcher(hello.EventTypes, hello.Filter, hello.SubscribeID); err != nil {
update.Token = ""
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
CredentialGeneration: d.cfg.CredentialBroker.Generation(),
ErrorCode: transport.CredentialErrorRegistration,
Error: "consumer registration validation failed",
})
d.log.Warn("bus: runtime consumer registration validation failed")
return
}
generation, updateErr := d.cfg.CredentialBroker.Update(update.ExpectedGeneration, update.Token)
runtimeGeneration = generation
update.Token = ""
credentialAck := transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: updateErr == nil,
CredentialGeneration: generation,
}
if updateErr != nil {
credentialAck.ErrorCode, credentialAck.Error = classifyCredentialUpdateError(updateErr)
}
if err := w.WriteJSON(credentialAck); err != nil {
d.log.Warn("bus: credential update ack write failed", "err", err)
return
}
if updateErr != nil {
d.log.Warn("bus: runtime credential update rejected", "error_code", credentialAck.ErrorCode)
return
}
}
// Regular consumer registration. Local clients retain the original
// register-before-HelloAck ordering; runtime clients were already acked by
// the additive handshake above.
c, err := d.hub.Register(hello)
if err != nil {
d.log.Warn("bus: register failed", "err", err, "pid", hello.ConsumerPID)
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "register_failed: " + err.Error()})
return
}
// HelloAck — credentials_source fields are filled in by the daemon
// runner (which knows from the strict resolver) and exposed via the
// adapter for forward-compat. v1 leaves them empty here; daemon.Run
// passes them through future config if the caller wishes.
idleSecs := int(d.cfg.IdleTimeout / time.Second)
if err := w.WriteJSON(transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: os.Getpid(),
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
StateSource: "inferred",
IdleTimeoutSecs: idleSecs,
}); err != nil {
d.log.Warn("bus: helloack write failed", "err", err)
if handoffLocked {
// The runtime broker deliberately keeps the seed pending until the
// initiating consumer is registered. This prevents ticket acquisition
// (and an immediate 401) from racing ahead of the only connection that
// can observe the typed terminal reason.
if _, activateErr := d.cfg.CredentialBroker.Activate(runtimeGeneration); activateErr != nil {
d.log.Error("bus: runtime credential activation failed")
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "runtime_credential_activation_failed"})
d.hub.Unregister(c.ID)
return
}
}
// A local/legacy consumer can arrive after terminal publication but after
// StopAll took its snapshot. Refuse it synchronously so it cannot observe a
// clean EOF for a runtime-token rejection.
if terminalReason := d.getTerminalReason(); terminalReason != "" {
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: terminalReason})
d.hub.Unregister(c.ID)
return
}
if handoffLocked {
d.credentialHandoffMu.Unlock()
handoffLocked = false
}
if hello.CredentialMode == "" {
if err := w.WriteJSON(ack); err != nil {
d.log.Warn("bus: helloack write failed", "err", err)
d.hub.Unregister(c.ID)
return
}
}
// Writer goroutine pulls from SendCh and writes to the wire.
writerDone := make(chan struct{})
@@ -416,6 +551,16 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
if !ok {
return
}
// A stop may have arrived while both channels were ready and the
// scheduler selected the buffered event. Re-check before starting a
// potentially blocking event write so terminal reasons stay prompt.
select {
case reason := <-c.StopCh:
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
_ = conn.Close()
return
default:
}
if err := w.WriteJSON(frame); err != nil {
return
}
@@ -454,6 +599,51 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
_ = ctx // for future use (writer ctx-cancel propagation)
}
func (d *daemon) helloAck() transport.HelloAck {
ack := transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: os.Getpid(),
SourceState: "connected", // best-effort; full state machine pushed via SourceState frames
StateSource: "inferred",
IdleTimeoutSecs: int(d.cfg.IdleTimeout / time.Second),
}
if d.cfg.CredentialBroker != nil {
ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
ack.CredentialGeneration = d.cfg.CredentialBroker.Generation()
}
ack.TerminalReason = d.getTerminalReason()
return ack
}
func (d *daemon) setTerminalReason(reason string) {
if d == nil || reason != transport.ByeReasonRuntimeTokenRejected {
return
}
d.terminalMu.Lock()
d.terminalReason = reason
d.terminalMu.Unlock()
}
func (d *daemon) getTerminalReason() string {
if d == nil {
return ""
}
d.terminalMu.RLock()
defer d.terminalMu.RUnlock()
return d.terminalReason
}
func classifyCredentialUpdateError(err error) (string, string) {
var conflict *runtimecred.GenerationConflictError
if errors.As(err, &conflict) {
return transport.CredentialErrorGenerationConflict, conflict.Error()
}
if errors.Is(err, runtimecred.ErrEmptyToken) || errors.Is(err, runtimecred.ErrTokenTooLarge) {
return transport.CredentialErrorInvalid, err.Error()
}
return transport.CredentialErrorInternal, "runtime credential update failed"
}
func (d *daemon) handleConsumerStopRPC(w *transport.Writer, r *transport.Reader) {
var req transport.ConsumerStopReq
if err := r.ReadJSON(&req); err != nil {
@@ -577,38 +767,64 @@ func (d *daemon) triggerShutdown(reason string) {
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
// 5. close all accepted connections and wait for handlers to drain
// 6. lock + meta cleanup via Run's defers
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
func (d *daemon) shutdown(acceptDone <-chan struct{}, reasons ...string) {
d.shutdownMu.Lock()
defer d.shutdownMu.Unlock()
if !d.shuttingDown.CompareAndSwap(false, true) {
return
}
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
reason := normalizedShutdownReason(reasons...)
if reason == transport.ByeReasonRuntimeTokenRejected {
d.hub.StopAll(reason)
} else {
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
}
_ = d.listener.Close()
<-acceptDone
// Force-close all open IPC connections so any reader goroutine blocked
// on Read() returns with a network error and exits cleanly. Without
// this the consumerWG never drains and Run hangs forever.
d.conns.Range(func(k, _ any) bool {
if c, ok := k.(net.Conn); ok {
_ = c.Close()
}
return true
})
// Give consumers a brief moment to drain final frames before we tear
// down their channels.
// Let local consumers drain the final Bye before force-closing their
// connections. This short grace period is what makes typed shutdown
// reasons (notably runtime_token_rejected) observable instead of racing
// with EOF. Consumers close their side immediately after reading Bye.
doneCh := make(chan struct{})
go func() {
d.consumerWG.Wait()
close(doneCh)
}()
select {
case <-doneCh:
return
case <-time.After(daemonByeDrainTimeout):
}
// A wedged/old consumer may not close after Bye. Force-close remaining
// connections so the daemon still has a bounded shutdown.
d.conns.Range(func(k, _ any) bool {
if c, ok := k.(net.Conn); ok {
_ = c.Close()
}
return true
})
select {
case <-doneCh:
case <-time.After(daemonShutdownTimeout):
d.log.Warn("bus: shutdown: consumer goroutines did not drain within 2s")
}
}
func sourceShutdownReason(err error) string {
if errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
return transport.ByeReasonRuntimeTokenRejected
}
return "shutdown"
}
func normalizedShutdownReason(reasons ...string) string {
if len(reasons) > 0 && reasons[0] == transport.ByeReasonRuntimeTokenRejected {
return transport.ByeReasonRuntimeTokenRejected
}
return "shutdown"
}
// signalReady writes a single 'R' byte to the ready pipe (if provided) and
// closes it. The parent process (busctl/spawn) reads one byte and proceeds.
func signalReady(p *os.File) {
+25
View File
@@ -246,6 +246,31 @@ func (h *Hub) StopConsumers(subscribeIDs []string, reason string) []string {
return out
}
// StopAll requests a high-priority graceful close for every live consumer.
// Unlike Broadcast(Bye), this uses the writer's priority StopCh and therefore
// cannot sit behind a full event buffer during a terminal source failure.
func (h *Hub) StopAll(reason string) int {
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "shutdown"
}
h.mu.RLock()
consumers := make([]*Consumer, 0, len(h.consumers))
for _, consumer := range h.consumers {
consumers = append(consumers, consumer)
}
h.mu.RUnlock()
stopped := 0
for _, consumer := range consumers {
select {
case consumer.StopCh <- reason:
stopped++
default:
}
}
return stopped
}
// Unregister removes a consumer by ID and closes its sendCh. Idempotent —
// calling twice or on an unknown ID is a no-op. closeSend shares the same
// per-consumer lock as Deliver/Broadcast, so a stale Hub snapshot cannot send
+21
View File
@@ -346,6 +346,27 @@ func TestHub_StopConsumersCoalescesQueuedStop(t *testing.T) {
}
}
func TestCrossPlatformCoverageHubStopAllBypassesFullEventBuffer(t *testing.T) {
hub := NewHub(1)
consumer, err := hub.Register(transport.Hello{Type: transport.FrameTypeHello})
if err != nil {
t.Fatal(err)
}
consumer.SendCh <- transport.Event{Type: transport.FrameTypeEvent}
if stopped := hub.StopAll(transport.ByeReasonRuntimeTokenRejected); stopped != 1 {
t.Fatalf("StopAll() = %d, want 1", stopped)
}
select {
case reason := <-consumer.StopCh:
if reason != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("StopCh reason = %q", reason)
}
default:
t.Fatal("terminal stop was blocked behind the full event buffer")
}
}
func TestHub_ConcurrentStopConsumersRegisterUnregister(t *testing.T) {
h := NewHub(4)
const workers = 32
@@ -0,0 +1,407 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package bus
import (
"bytes"
"context"
"errors"
"io"
"log/slog"
"net"
"path/filepath"
"strings"
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
type runtimeCredentialRejectSource struct {
broker *runtimecred.Broker
}
func (s *runtimeCredentialRejectSource) Start(ctx context.Context, _ dwsevent.EmitFn) error {
if _, err := s.broker.Resolve(ctx); err != nil {
return err
}
return runtimecred.ErrRuntimeTokenRejected
}
func runtimeCredentialDaemon(broker *runtimecred.Broker, logOutput io.Writer) *daemon {
if logOutput == nil {
logOutput = io.Discard
}
return &daemon{
cfg: Config{CredentialBroker: broker},
log: slog.New(slog.NewTextHandler(logOutput, nil)),
hub: NewHub(2),
started: time.Now(),
idleStop: make(chan struct{}),
}
}
func runRuntimeCredentialConnection(t *testing.T, d *daemon) (net.Conn, *transport.Writer, *transport.Reader, <-chan struct{}) {
t.Helper()
server, client := net.Pipe()
done := make(chan struct{})
go func() {
d.handleConnection(context.Background(), server)
close(done)
}()
return client, transport.NewWriter(client), transport.NewReader(client), done
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialHandshakeBeforeRegister(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
d := runtimeCredentialDaemon(broker, nil)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: 42,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
if !hasTransportCapability(helloAck.Capabilities, transport.CapabilityRuntimeTokenV1) || helloAck.CredentialGeneration != 0 {
t.Fatalf("hello ack = %#v", helloAck)
}
if d.hub.Len() != 0 {
t.Fatalf("consumer registered before credential update: %d", d.hub.Len())
}
const canary = "ipc-canary-runtime-token"
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: helloAck.CredentialGeneration,
Token: canary,
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
t.Fatal(err)
}
if !updateAck.Accepted || updateAck.CredentialGeneration != 1 {
t.Fatalf("credential ack = %#v", updateAck)
}
deadline := time.Now().Add(time.Second)
for d.hub.Len() != 1 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if d.hub.Len() != 1 {
t.Fatal("consumer was not registered after credential ack")
}
if token, err := broker.Resolve(context.Background()); err != nil || token != canary {
t.Fatalf("broker did not resolve installed runtime token: %v", err)
}
if err := w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"}); err != nil {
t.Fatal(err)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection did not close")
}
}
func TestCrossPlatformCoverageDaemonCompatibleBusRotatesRuntimeCredentialAcrossConnections(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
d := runtimeCredentialDaemon(broker, nil)
handshake := func(token string, wantHelloGeneration, wantAckGeneration uint64) {
t.Helper()
client, w, r, done := runRuntimeCredentialConnection(t, d)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
if helloAck.CredentialGeneration != wantHelloGeneration {
t.Fatalf("hello generation = %d, want %d", helloAck.CredentialGeneration, wantHelloGeneration)
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: helloAck.CredentialGeneration,
Token: token,
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
t.Fatal(err)
}
if !updateAck.Accepted || updateAck.CredentialGeneration != wantAckGeneration {
t.Fatalf("credential ack accepted=%v generation=%d, want true/%d", updateAck.Accepted, updateAck.CredentialGeneration, wantAckGeneration)
}
if err := w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"}); err != nil {
t.Fatal(err)
}
_ = client.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection did not close")
}
}
handshake("runtime-token-a", 0, 1)
if token, err := broker.Resolve(context.Background()); err != nil || token != "runtime-token-a" {
t.Fatalf("broker did not retain first token: %v", err)
}
handshake("runtime-token-b", 1, 2)
if token, err := broker.Resolve(context.Background()); err != nil || token != "runtime-token-b" {
t.Fatalf("broker did not rotate to second token: %v", err)
}
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialMissingCapabilityDoesNotRegister(t *testing.T) {
d := runtimeCredentialDaemon(nil, nil)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
if hasTransportCapability(ack.Capabilities, transport.CapabilityRuntimeTokenV1) {
t.Fatalf("unsupported daemon advertised capability: %#v", ack)
}
if d.hub.Len() != 0 {
t.Fatalf("unsupported daemon registered consumer: %d", d.hub.Len())
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("unsupported connection did not close")
}
}
func TestCrossPlatformCoverageDaemonTerminalStateRejectsLateLegacyConsumer(t *testing.T) {
d := runtimeCredentialDaemon(nil, nil)
d.setTerminalReason(transport.ByeReasonRuntimeTokenRejected)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, ConsumerPID: 7}); err != nil {
t.Fatal(err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
t.Fatal(err)
}
if bye.Type != transport.FrameTypeBye || bye.Reason != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("late consumer frame = %#v", bye)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("late consumer connection did not close")
}
if d.hub.Len() != 0 {
t.Fatalf("late terminal consumer remained registered: %d", d.hub.Len())
}
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialConflictDoesNotLeakOrRegister(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{})
if _, err := broker.Update(0, "installed-secret"); err != nil {
t.Fatal(err)
}
var logs bytes.Buffer
d := runtimeCredentialDaemon(broker, &logs)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
const canary = "rejected-canary-secret"
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: 0,
Token: canary,
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
t.Fatal(err)
}
if updateAck.Accepted || updateAck.ErrorCode != transport.CredentialErrorGenerationConflict || updateAck.CredentialGeneration != 1 {
t.Fatalf("conflict ack = %#v", updateAck)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("rejected connection did not close")
}
if strings.Contains(updateAck.Error, canary) || strings.Contains(logs.String(), canary) {
t.Fatal("credential appeared in acknowledgement or logs")
}
if d.hub.Len() != 0 {
t.Fatalf("rejected consumer registered: %d", d.hub.Len())
}
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialInvalidFilterRejectedBeforeUpdate(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{})
d := runtimeCredentialDaemon(broker, nil)
client, w, r, done := runRuntimeCredentialConnection(t, d)
defer client.Close()
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
Filter: "[",
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: helloAck.CredentialGeneration,
Token: "filter-canary-secret",
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
t.Fatal(err)
}
if updateAck.Accepted || updateAck.ErrorCode != transport.CredentialErrorRegistration {
t.Fatalf("invalid filter ack = %#v", updateAck)
}
if broker.Generation() != 0 || d.hub.Len() != 0 {
t.Fatalf("invalid filter mutated state: generation=%d consumers=%d", broker.Generation(), d.hub.Len())
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("invalid filter connection did not close")
}
}
func TestCrossPlatformCoverageRuntimeCredentialShutdownReason(t *testing.T) {
if got := sourceShutdownReason(runtimecred.ErrRuntimeTokenRejected); got != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("runtime source shutdown reason = %q", got)
}
if got := sourceShutdownReason(errors.New("local source failed")); got != "shutdown" {
t.Fatalf("local source shutdown reason = %q", got)
}
if got := normalizedShutdownReason(transport.ByeReasonRuntimeTokenRejected); got != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("normalized runtime shutdown reason = %q", got)
}
for _, reasons := range [][]string{nil, {"peer-controlled"}} {
if got := normalizedShutdownReason(reasons...); got != "shutdown" {
t.Fatalf("normalized untrusted shutdown reason = %q", got)
}
}
}
func TestCrossPlatformCoverageDaemonRuntimeCredentialSourceRejectionBroadcastsTypedBye(t *testing.T) {
skipOnWindows(t, "uses Unix socket dial")
workDir := shortTempDir(t)
sockPath := filepath.Join(workDir, "bus.sock")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
broker := runtimecred.New(runtimecred.Config{RequireSeed: true, RequireActivation: true})
runDone := make(chan error, 1)
go func() {
runDone <- Run(ctx, Config{
WorkDir: workDir,
IPCEndpoint: sockPath,
ClientID: "runtime-client",
Edition: "open",
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: "0123456789abcdef",
SourceID: "open",
Source: &runtimeCredentialRejectSource{broker: broker},
CredentialBroker: broker,
})
}()
waitForFile(t, sockPath, 2*time.Second)
conn, err := transport.Dial(sockPath)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
r, w := transport.NewReader(conn), transport.NewWriter(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
CredentialMode: transport.CredentialModeRuntimeToken,
}); err != nil {
t.Fatal(err)
}
var helloAck transport.HelloAck
if err := r.ReadJSON(&helloAck); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: helloAck.CredentialGeneration,
Token: "runtime-rejected-canary",
}); err != nil {
t.Fatal(err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil || !updateAck.Accepted {
t.Fatalf("credential update ack = %#v, %v", updateAck, err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
t.Fatalf("read typed shutdown: %v", err)
}
if bye.Type != transport.FrameTypeBye || bye.Reason != transport.ByeReasonRuntimeTokenRejected {
t.Fatalf("shutdown frame = %#v", bye)
}
_ = conn.Close()
select {
case err := <-runDone:
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("Run() error = %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("runtime credential bus did not stop")
}
}
func hasTransportCapability(capabilities []string, want string) bool {
for _, capability := range capabilities {
if capability == want {
return true
}
}
return false
}
@@ -0,0 +1,101 @@
package consume
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func eventCoreCredentialReader(t *testing.T, frame any) *transport.Reader {
t.Helper()
var buffer bytes.Buffer
if err := transport.NewWriter(&buffer).WriteJSON(frame); err != nil {
t.Fatal(err)
}
return transport.NewReader(&buffer)
}
func TestCrossPlatformCoverageEventCoreRuntimeNegotiationEdges(t *testing.T) {
capable := transport.HelloAck{Capabilities: []string{transport.CapabilityRuntimeTokenV1}}
if err := negotiateRuntimeToken(
transport.NewWriter(io.Discard),
transport.NewReader(strings.NewReader("")),
transport.HelloAck{TerminalReason: transport.ByeReasonRuntimeTokenRejected},
"token",
); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("terminal hello error = %v", err)
}
wantWriteErr := errors.New("write failed")
if err := negotiateRuntimeToken(
transport.NewWriter(errorWriter{err: wantWriteErr}),
transport.NewReader(strings.NewReader("")),
capable,
"token",
); !errors.Is(err, wantWriteErr) {
t.Fatalf("credential write error = %v", err)
}
if err := negotiateRuntimeToken(
transport.NewWriter(io.Discard),
transport.NewReader(strings.NewReader("")),
capable,
"token",
); !errors.Is(err, io.EOF) {
t.Fatalf("credential ack read error = %v", err)
}
if err := negotiateRuntimeToken(
transport.NewWriter(io.Discard),
eventCoreCredentialReader(t, transport.Heartbeat{Type: transport.FrameTypeHeartbeat}),
capable,
"token",
); err == nil || !strings.Contains(err.Error(), "unexpected runtime credential response") {
t.Fatalf("unexpected credential frame error = %v", err)
}
if err := negotiateRuntimeToken(
transport.NewWriter(io.Discard),
eventCoreCredentialReader(t, transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
ErrorCode: transport.CredentialErrorRuntimeRejected,
}),
capable,
"token",
); !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("runtime rejected ack error = %v", err)
}
for _, code := range []string{
transport.CredentialErrorGenerationConflict,
transport.CredentialErrorInvalid,
transport.CredentialErrorRegistration,
transport.CredentialErrorRuntimeRejected,
transport.CredentialErrorInternal,
} {
if got := safeCredentialErrorCode(code); got != code {
t.Fatalf("safeCredentialErrorCode(%q) = %q", code, got)
}
}
if got := safeCredentialErrorCode("peer-controlled"); got != transport.CredentialErrorInternal {
t.Fatalf("unknown credential error code = %q", got)
}
}
func TestCrossPlatformCoverageEventCoreRunManyHandshakeFailure(t *testing.T) {
bus := newManyFakeBus(901, nil)
installManyDiscover(t, bus)
cfg := manyTestConfig(io.Discard, io.Discard)
cfg.RuntimeToken = "runtime-token"
err := RunMany(context.Background(), cfg, manyTestSpecs())
if !errors.Is(err, ErrRuntimeTokenUnsupported) || !strings.Contains(err.Error(), "runtime credential handshake") {
t.Fatalf("RunMany handshake error = %v", err)
}
}
+103
View File
@@ -21,9 +21,11 @@ import (
"io"
"net"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
@@ -45,6 +47,11 @@ type Config struct {
// personal_stream.
SpawnExtraArgs []string
// RuntimeToken is a host-supplied credential handed to a compatible bus
// only after capability negotiation over owner-only local IPC. It is never
// included in dry-run output, child argv, environment, or persisted state.
RuntimeToken string `json:"-" yaml:"-"`
// EventTypes / Filter / Compact are forwarded to the bus via Hello
// for server-side pushdown filtering.
EventTypes []string
@@ -132,6 +139,36 @@ type Config struct {
var discoverBus = busctl.Discover
var (
ErrRuntimeTokenUnsupported = errors.New("consume: event bus does not support secure runtime-token handoff")
ErrRuntimeTokenUpdate = errors.New("consume: event bus rejected runtime-token update")
)
// RuntimeTokenUnsupportedError is returned before the token is sent when the
// connected bus lacks the runtime_token_v1 capability.
type RuntimeTokenUnsupportedError struct {
BusPID int
}
func (e *RuntimeTokenUnsupportedError) Error() string {
return fmt.Sprintf("consume: running event bus (pid %d) does not support secure runtime-token handoff; let existing consumers exit, inspect with `dws event status --as user --format json`, preview cleanup with `dws event stop --as user --all --dry-run`, then confirm with `dws event stop --as user --all --yes` and retry", e.BusPID)
}
func (e *RuntimeTokenUnsupportedError) Unwrap() error { return ErrRuntimeTokenUnsupported }
// RuntimeTokenUpdateError reports a rejected credential CAS without carrying
// either the credential or peer-provided free-form error text.
type RuntimeTokenUpdateError struct {
Code string
Generation uint64
}
func (e *RuntimeTokenUpdateError) Error() string {
return fmt.Sprintf("consume: event bus rejected runtime-token update (code=%s, generation=%d)", e.Code, e.Generation)
}
func (e *RuntimeTokenUpdateError) Unwrap() error { return ErrRuntimeTokenUpdate }
// Run dials the bus (forking one if necessary), sends Hello, and writes
// each received Event frame as one NDJSON line to stdout. Blocks until
// ctx is cancelled, MaxEvents is reached, the bus sends Bye, or the
@@ -144,6 +181,7 @@ func Run(ctx context.Context, cfg Config) error {
if cfg.WorkDir == "" || cfg.IPCEndpoint == "" || cfg.ClientID == "" {
return errors.New("consume: WorkDir, IPCEndpoint, and ClientID are required")
}
cfg.RuntimeToken = strings.TrimSpace(cfg.RuntimeToken)
if cfg.Stdout == nil {
cfg.Stdout = os.Stdout
}
@@ -225,6 +263,9 @@ func Run(ctx context.Context, cfg Config) error {
SubscribeID: cfg.SubscribeID,
Compact: cfg.Compact,
}
if cfg.RuntimeToken != "" {
hello.CredentialMode = transport.CredentialModeRuntimeToken
}
if err := w.WriteJSON(hello); err != nil {
return fmt.Errorf("consume: write hello: %w", err)
}
@@ -236,6 +277,9 @@ func Run(ctx context.Context, cfg Config) error {
if ack.Type != transport.FrameTypeHelloAck {
return fmt.Errorf("consume: unexpected first frame type %q", ack.Type)
}
if err := negotiateRuntimeToken(w, r, ack, cfg.RuntimeToken); err != nil {
return err
}
if !cfg.Quiet {
// Contract: a fixed ready line on stderr BEFORE any stdout event.
// Parents block on stderr until this appears, then read stdout.
@@ -331,6 +375,9 @@ func Run(ctx context.Context, cfg Config) error {
case transport.FrameTypeBye:
var bye transport.Bye
_ = json.Unmarshal(raw, &bye)
if bye.Reason == transport.ByeReasonRuntimeTokenRejected {
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
}
if !cfg.Quiet {
fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason)
}
@@ -350,6 +397,62 @@ func Run(ctx context.Context, cfg Config) error {
}
}
func negotiateRuntimeToken(w *transport.Writer, r *transport.Reader, ack transport.HelloAck, token string) error {
if token == "" {
return nil
}
if ack.TerminalReason == transport.ByeReasonRuntimeTokenRejected {
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
}
if !hasCapability(ack.Capabilities, transport.CapabilityRuntimeTokenV1) {
return &RuntimeTokenUnsupportedError{BusPID: ack.BusPID}
}
if err := w.WriteJSON(transport.CredentialUpdate{
Type: transport.FrameTypeCredentialUpdate,
ExpectedGeneration: ack.CredentialGeneration,
Token: token,
}); err != nil {
return fmt.Errorf("consume: write runtime credential update: %w", err)
}
var updateAck transport.CredentialUpdateAck
if err := r.ReadJSON(&updateAck); err != nil {
return fmt.Errorf("consume: read runtime credential update ack: %w", err)
}
if updateAck.Type != transport.FrameTypeCredentialUpdateAck {
return errors.New("consume: unexpected runtime credential response frame")
}
if !updateAck.Accepted {
code := safeCredentialErrorCode(updateAck.ErrorCode)
if code == transport.CredentialErrorRuntimeRejected {
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
}
return &RuntimeTokenUpdateError{Code: code, Generation: updateAck.CredentialGeneration}
}
return nil
}
func safeCredentialErrorCode(code string) string {
switch code {
case transport.CredentialErrorGenerationConflict,
transport.CredentialErrorInvalid,
transport.CredentialErrorRegistration,
transport.CredentialErrorRuntimeRejected,
transport.CredentialErrorInternal:
return code
default:
return transport.CredentialErrorInternal
}
}
func hasCapability(capabilities []string, want string) bool {
for _, capability := range capabilities {
if capability == want {
return true
}
}
return false
}
// closeOnContext spawns a goroutine that closes conn when ctx is done.
// This unblocks any pending Read on conn so the main loop can return.
func closeOnContext(ctx context.Context, conn net.Conn) {
+18 -2
View File
@@ -16,6 +16,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
@@ -53,6 +54,7 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
if cfg.WorkDir == "" || cfg.IPCEndpoint == "" || cfg.ClientID == "" {
return errors.New("consume: WorkDir, IPCEndpoint, and ClientID are required")
}
cfg.RuntimeToken = strings.TrimSpace(cfg.RuntimeToken)
if len(specs) < 2 {
return errors.New("consume: RunMany requires at least two consumers")
}
@@ -127,14 +129,18 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
}
sessions = append(sessions, session)
closeOnContext(ctx, session.conn)
if err := session.w.WriteJSON(transport.Hello{
hello := transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
EventTypes: spec.EventTypes,
Filter: spec.Filter,
SubscribeID: spec.SubscribeID,
Compact: cfg.Compact,
}); err != nil {
}
if cfg.RuntimeToken != "" {
hello.CredentialMode = transport.CredentialModeRuntimeToken
}
if err := session.w.WriteJSON(hello); err != nil {
return fmt.Errorf("consume: write hello for %s: %w", spec.EventKey, err)
}
if err := session.r.ReadJSON(&session.ack); err != nil {
@@ -143,9 +149,16 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
if session.ack.Type != transport.FrameTypeHelloAck {
return fmt.Errorf("consume: unexpected first frame type %q for %s", session.ack.Type, spec.EventKey)
}
// Verify that every connection reached the same bus before handing a
// runtime credential to it. Discovery is expected to converge on one
// daemon, but a stale endpoint/race must not propagate the host token to
// an unrelated process merely so we can report the PID mismatch later.
if len(sessions) > 1 && session.ack.BusPID != sessions[0].ack.BusPID {
return fmt.Errorf("consume: consumers connected to different bus processes (%d and %d)", sessions[0].ack.BusPID, session.ack.BusPID)
}
if err := negotiateRuntimeToken(session.w, session.r, session.ack, cfg.RuntimeToken); err != nil {
return fmt.Errorf("consume: runtime credential handshake for %s: %w", spec.EventKey, err)
}
}
if !cfg.Quiet {
@@ -225,6 +238,9 @@ func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
case transport.FrameTypeBye:
var bye transport.Bye
_ = json.Unmarshal(frame.raw, &bye)
if bye.Reason == transport.ByeReasonRuntimeTokenRejected {
return fmt.Errorf("consume: %w", runtimecred.ErrRuntimeTokenRejected)
}
if bye.Reason == transport.ByeReasonSubscriptionStopped {
delete(active, frame.index)
_ = sessions[frame.index].conn.Close()
+117 -12
View File
@@ -16,6 +16,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
@@ -37,23 +38,25 @@ func (b *synchronizedBuffer) String() string {
}
type manyFakeBus struct {
client net.Conn
server net.Conn
hello chan transport.Hello
acked chan struct{}
ackGate <-chan struct{}
ack transport.HelloAck
send chan any
client net.Conn
server net.Conn
hello chan transport.Hello
credentialUpdate chan transport.CredentialUpdate
acked chan struct{}
ackGate <-chan struct{}
ack transport.HelloAck
send chan any
}
func newManyFakeBus(busPID int, ackGate <-chan struct{}) *manyFakeBus {
client, server := net.Pipe()
f := &manyFakeBus{
client: client,
server: server,
hello: make(chan transport.Hello, 1),
acked: make(chan struct{}),
ackGate: ackGate,
client: client,
server: server,
hello: make(chan transport.Hello, 1),
credentialUpdate: make(chan transport.CredentialUpdate, 1),
acked: make(chan struct{}),
ackGate: ackGate,
ack: transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: busPID,
@@ -85,6 +88,23 @@ func (f *manyFakeBus) serve() {
if err := w.WriteJSON(f.ack); err != nil {
return
}
if hello.CredentialMode == transport.CredentialModeRuntimeToken {
if !hasCapability(f.ack.Capabilities, transport.CapabilityRuntimeTokenV1) {
return
}
var update transport.CredentialUpdate
if err := r.ReadJSON(&update); err != nil {
return
}
f.credentialUpdate <- update
if err := w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: true,
CredentialGeneration: f.ack.CredentialGeneration + 1,
}); err != nil {
return
}
}
close(f.acked)
go func() {
for {
@@ -221,6 +241,91 @@ func TestRunManyWaitsForAllConsumersAndStopsOneAtATime(t *testing.T) {
}
}
func TestCrossPlatformCoverageRunManyRuntimeTokenUsesEachConnectionGeneration(t *testing.T) {
const canary = "many-runtime-canary"
busA := newManyFakeBus(111, nil)
busB := newManyFakeBus(111, nil)
busA.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
busB.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
busA.ack.CredentialGeneration = 2
busB.ack.CredentialGeneration = 8
installManyDiscover(t, busA, busB)
cfg := manyTestConfig(io.Discard, io.Discard)
cfg.RuntimeToken = canary
done := make(chan error, 1)
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
updateA := <-busA.credentialUpdate
updateB := <-busB.credentialUpdate
if updateA.Token != canary || updateA.ExpectedGeneration != 2 {
t.Fatal("first connection used the wrong credential or generation")
}
if updateB.Token != canary || updateB.ExpectedGeneration != 8 {
t.Fatal("second connection used the wrong credential or generation")
}
busA.send <- transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not stop")
}
}
func TestCrossPlatformCoverageRunManyRuntimeTokenRejectsDifferentBusBeforeSecondCredential(t *testing.T) {
const canary = "many-mismatched-bus-canary"
busA := newManyFakeBus(111, nil)
busB := newManyFakeBus(222, nil)
busA.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
busB.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
installManyDiscover(t, busA, busB)
cfg := manyTestConfig(io.Discard, io.Discard)
cfg.RuntimeToken = canary
err := RunMany(context.Background(), cfg, manyTestSpecs())
if err == nil || !strings.Contains(err.Error(), "different bus processes") {
t.Fatalf("RunMany() error = %v", err)
}
if update := <-busA.credentialUpdate; update.Token != canary {
t.Fatal("first bus did not receive the negotiated credential")
}
select {
case update := <-busB.credentialUpdate:
t.Fatalf("mismatched second bus received credential: generation=%d", update.ExpectedGeneration)
default:
}
}
func TestCrossPlatformCoverageRunManyRuntimeTokenRejectedByeReturnsTypedError(t *testing.T) {
busA := newManyFakeBus(333, nil)
busB := newManyFakeBus(333, nil)
busA.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
busB.ack.Capabilities = []string{transport.CapabilityRuntimeTokenV1}
installManyDiscover(t, busA, busB)
cfg := manyTestConfig(io.Discard, io.Discard)
cfg.RuntimeToken = "many-runtime-rejected-canary"
done := make(chan error, 1)
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
<-busA.credentialUpdate
<-busB.credentialUpdate
busA.send <- transport.Bye{
Type: transport.FrameTypeBye,
Reason: transport.ByeReasonRuntimeTokenRejected,
}
select {
case err := <-done:
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("RunMany() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not return runtime credential rejection")
}
}
func TestRunManyMaxEventsIsSharedAcrossConsumers(t *testing.T) {
busA := newManyFakeBus(202, nil)
busB := newManyFakeBus(202, nil)
@@ -0,0 +1,242 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package consume
import (
"bytes"
"context"
"errors"
"io"
"net"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func installRuntimeCredentialDiscover(t *testing.T, serve func(net.Conn)) {
t.Helper()
oldDiscover := discoverBus
done := make(chan struct{})
discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer close(done)
defer server.Close()
serve(server)
}()
return client, nil
}
t.Cleanup(func() {
discoverBus = oldDiscover
select {
case <-done:
case <-time.After(time.Second):
t.Error("fake runtime credential bus did not stop")
}
})
}
func TestCrossPlatformCoverageRunRuntimeTokenNegotiatesBeforeReady(t *testing.T) {
const canary = "consume-runtime-canary"
helloSeen := make(chan transport.Hello, 1)
updateSeen := make(chan transport.CredentialUpdate, 1)
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
helloSeen <- hello
_ = w.WriteJSON(transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: 71,
Capabilities: []string{transport.CapabilityRuntimeTokenV1},
CredentialGeneration: 3,
})
var update transport.CredentialUpdate
if err := r.ReadJSON(&update); err != nil {
return
}
updateSeen <- update
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: true,
CredentialGeneration: 4,
})
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"})
})
var stderr bytes.Buffer
cfg := validRunConfig()
cfg.RuntimeToken = " " + canary + " "
cfg.Stderr = &stderr
if err := Run(context.Background(), cfg); err != nil {
t.Fatal(err)
}
hello := <-helloSeen
if hello.CredentialMode != transport.CredentialModeRuntimeToken {
t.Fatalf("credential mode = %q", hello.CredentialMode)
}
update := <-updateSeen
if update.ExpectedGeneration != 3 || update.Token != canary {
t.Fatal("credential update used the wrong token or generation")
}
if !strings.Contains(stderr.String(), "[event] ready bus_pid=71") {
t.Fatalf("ready marker missing: %s", stderr.String())
}
}
func TestCrossPlatformCoverageRunRuntimeTokenMissingCapabilityFailsBeforeSendingSecretOrReady(t *testing.T) {
const canary = "unsupported-canary-secret"
peerBytes := make(chan string, 1)
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
_ = w.WriteJSON(transport.HelloAck{Type: transport.FrameTypeHelloAck, BusPID: 72})
raw, _ := io.ReadAll(conn)
peerBytes <- string(raw)
})
var stderr bytes.Buffer
cfg := validRunConfig()
cfg.RuntimeToken = canary
cfg.Stderr = &stderr
err := Run(context.Background(), cfg)
if !errors.Is(err, ErrRuntimeTokenUnsupported) {
t.Fatalf("Run error = %v", err)
}
if strings.Contains(err.Error(), canary) {
t.Fatal("unsupported-bus error contained runtime token")
}
for _, recoveryStep := range []string{
"dws event status --as user --format json",
"dws event stop --as user --all --dry-run",
"dws event stop --as user --all --yes",
} {
if !strings.Contains(err.Error(), recoveryStep) {
t.Fatalf("unsupported-bus error missing recovery step %q: %v", recoveryStep, err)
}
}
if strings.Contains(stderr.String(), "[event] ready") {
t.Fatal("ready marker was written before capability negotiation succeeded")
}
if got := <-peerBytes; strings.Contains(got, canary) || got != "" {
t.Fatal("client sent data after unsupported capability acknowledgement")
}
}
func TestCrossPlatformCoverageRunRuntimeTokenRejectedDoesNotSurfacePeerText(t *testing.T) {
const canary = "rejected-canary-secret"
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
_ = w.WriteJSON(transport.HelloAck{
Type: transport.FrameTypeHelloAck,
Capabilities: []string{transport.CapabilityRuntimeTokenV1},
})
var update transport.CredentialUpdate
if err := r.ReadJSON(&update); err != nil {
return
}
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: false,
ErrorCode: "malicious-code-" + update.Token,
Error: "malicious echo " + update.Token,
})
})
cfg := validRunConfig()
cfg.RuntimeToken = canary
err := Run(context.Background(), cfg)
if !errors.Is(err, ErrRuntimeTokenUpdate) {
t.Fatalf("Run error = %v", err)
}
if strings.Contains(err.Error(), canary) {
t.Fatal("credential update error contained peer-provided token text")
}
}
func TestCrossPlatformCoverageRuntimeTokenNeverAppearsInDryRun(t *testing.T) {
const canary = "dry-run-canary-secret"
var output bytes.Buffer
cfg := validRunConfig()
cfg.RuntimeToken = canary
PrintDryRun(&output, cfg)
if strings.Contains(output.String(), canary) || strings.Contains(output.String(), "RuntimeToken") {
t.Fatal("dry-run output contained runtime-token data")
}
}
func TestCrossPlatformCoverageRunWhitespaceRuntimeTokenUsesLegacyHandshake(t *testing.T) {
helloSeen := make(chan transport.Hello, 1)
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
helloSeen <- hello
_ = w.WriteJSON(transport.HelloAck{Type: transport.FrameTypeHelloAck})
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "done"})
})
cfg := validRunConfig()
cfg.RuntimeToken = " "
if err := Run(context.Background(), cfg); err != nil {
t.Fatal(err)
}
if hello := <-helloSeen; hello.CredentialMode != "" {
t.Fatalf("whitespace token enabled mode %q", hello.CredentialMode)
}
}
func TestCrossPlatformCoverageRunRuntimeTokenRejectedByeReturnsTypedError(t *testing.T) {
installRuntimeCredentialDiscover(t, func(conn net.Conn) {
r, w := transport.NewReader(conn), transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
_ = w.WriteJSON(transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: 73,
Capabilities: []string{transport.CapabilityRuntimeTokenV1},
})
var update transport.CredentialUpdate
if err := r.ReadJSON(&update); err != nil {
return
}
_ = w.WriteJSON(transport.CredentialUpdateAck{
Type: transport.FrameTypeCredentialUpdateAck,
Accepted: true,
CredentialGeneration: 1,
})
_ = w.WriteJSON(transport.Bye{
Type: transport.FrameTypeBye,
Reason: transport.ByeReasonRuntimeTokenRejected,
})
})
var stderr bytes.Buffer
cfg := validRunConfig()
cfg.RuntimeToken = "runtime-rejected-canary"
cfg.Stderr = &stderr
err := Run(context.Background(), cfg)
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("Run() error = %v", err)
}
if strings.Contains(stderr.String(), "reason: bus_shutdown") {
t.Fatalf("runtime rejection was reported as a successful exit: %s", stderr.String())
}
}
+3 -2
View File
@@ -113,8 +113,9 @@ func IsValidationError(err error) bool {
// human-readable block. Called by Run when cfg.DryRun is true. Format
// avoids JSON so users can `dws event consume --dry-run | head` cleanly.
//
// Secret-bearing fields are never present in Config (credentials never
// reach this layer), so no redaction is required here.
// RuntimeToken is the only secret-bearing Config field and is deliberately
// not read or rendered here. Keep this function allowlist-based: never switch
// it to generic struct serialization.
func PrintDryRun(w io.Writer, cfg Config) {
if w == nil {
return
+6
View File
@@ -128,6 +128,12 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_o2o_all", EventAllSingleChat, RuleOptions{}, map[string]any{}},
{"receive_group_all", EventAllGroupChat, RuleOptions{}, map[string]any{}},
{"oa_approval_task_created", EventOAApprovalTaskCreated, RuleOptions{}, map[string]any{}},
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
+241 -2
View File
@@ -121,6 +121,94 @@ type GroupLifecycleEventOutput struct {
Payload map[string]any `json:"payload" description:"群生命周期事件业务数据,字段以服务端实际推送为准" additional_properties:"true"`
}
type OAApprovalTaskCreatedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
TaskID string `json:"task_id" description:"审批任务 ID"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批任务状态"`
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
}
type OAApprovalTaskFinishedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
TaskID string `json:"task_id" description:"审批任务 ID"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批任务状态"`
Result string `json:"result" description:"审批任务处理结果,值以服务端实际推送为准"`
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"审批任务完成时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
}
type OAApprovalTaskRedirectedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
TaskID string `json:"task_id" description:"原审批任务 ID"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"原审批任务状态"`
Result string `json:"result" description:"审批任务转交结果,值以服务端实际推送为准"`
CreateTime int64 `json:"create_time" description:"原审批任务创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"原审批任务转交完成时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批任务转交事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceStartedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例状态"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceTerminatedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例状态"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"审批实例终止时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批实例终止事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceFinishedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例状态"`
Result string `json:"result" description:"审批实例处理结果,值以服务端实际推送为准"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"审批实例完成时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
}
type GroupMemberEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
@@ -230,6 +318,22 @@ type personalGroupMemberRecord struct {
OpenDingTalkID string `json:"openDingTalkId"`
}
type personalOAApprovalPayload struct {
EventTime int64 `json:"event_time"`
Body personalOAApprovalBody `json:"body"`
}
type personalOAApprovalBody struct {
ProcessInstanceID string `json:"processInstanceId"`
ProcessCode string `json:"processCode"`
TaskID string `json:"taskId"`
Title string `json:"title"`
Status string `json:"status"`
Result string `json:"result"`
CreateTime int64 `json:"createTime"`
FinishTime int64 `json:"finishTime"`
}
func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
// encoding/json otherwise falls back to case-insensitive field matching.
// Read this protocol field from a map so only operOpenDingtalkId is accepted.
@@ -343,7 +447,7 @@ func ProjectOutput(ev transport.Event) (any, error) {
case isGroupMemberEvent(eventType):
return projectGroupMemberEvent(ev, base, data.Payload)
case isGroupLifecycleEvent(eventType):
payload, err := decodeGroupLifecyclePayload(data.Payload)
payload, err := decodeConservativePayload(data.Payload)
if err != nil {
return ev, fmt.Errorf("decode personal group lifecycle payload: %w", err)
}
@@ -354,6 +458,8 @@ func ProjectOutput(ev transport.Event) (any, error) {
SubscribeID: base.SubscribeID,
Payload: payload,
}, nil
case isOAEvent(eventType):
return projectOAApprovalEvent(ev, base, data.Payload)
default:
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
}
@@ -370,7 +476,7 @@ func projectMessageEventContext(message personalMessageContext) MessageEventCont
}
}
func decodeGroupLifecyclePayload(raw json.RawMessage) (map[string]any, error) {
func decodeConservativePayload(raw json.RawMessage) (map[string]any, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, fmt.Errorf("payload is missing")
@@ -489,6 +595,112 @@ func projectGroupMemberEvent(ev transport.Event, base baseEventOutput, raw json.
}, nil
}
func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalOAApprovalPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal OA payload: %w", err)
}
if strings.TrimSpace(payload.Body.ProcessInstanceID) == "" {
return ev, fmt.Errorf("decode personal OA payload: processInstanceId is required")
}
if isOAApprovalTaskEvent(base.Type) && strings.TrimSpace(payload.Body.TaskID) == "" {
return ev, fmt.Errorf("decode personal OA payload: taskId is required for %s", base.Type)
}
switch base.Type {
case EventOAApprovalTaskCreated:
return OAApprovalTaskCreatedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
TaskID: payload.Body.TaskID,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalTaskFinished:
return OAApprovalTaskFinishedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
TaskID: payload.Body.TaskID,
Title: payload.Body.Title,
Status: payload.Body.Status,
Result: payload.Body.Result,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalTaskRedirected:
return OAApprovalTaskRedirectedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
TaskID: payload.Body.TaskID,
Title: payload.Body.Title,
Status: payload.Body.Status,
Result: payload.Body.Result,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceStarted:
return OAApprovalInstanceStartedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceTerminated:
return OAApprovalInstanceTerminatedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceFinished:
return OAApprovalInstanceFinishedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
Result: payload.Body.Result,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
default:
return ev, fmt.Errorf("unsupported personal OA event type %q", base.Type)
}
}
func decodeRequiredPayload(raw json.RawMessage, target any) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
@@ -651,6 +863,18 @@ func outputTypeForEvent(eventKey string) reflect.Type {
return reflect.TypeOf(GroupMemberEventOutput{})
case isGroupLifecycleEvent(eventKey):
return reflect.TypeOf(GroupLifecycleEventOutput{})
case eventKey == EventOAApprovalTaskCreated:
return reflect.TypeOf(OAApprovalTaskCreatedOutput{})
case eventKey == EventOAApprovalTaskFinished:
return reflect.TypeOf(OAApprovalTaskFinishedOutput{})
case eventKey == EventOAApprovalTaskRedirected:
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
case eventKey == EventOAApprovalInstanceStarted:
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
case eventKey == EventOAApprovalInstanceTerminated:
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
case eventKey == EventOAApprovalInstanceFinished:
return reflect.TypeOf(OAApprovalInstanceFinishedOutput{})
default:
return reflect.TypeOf(baseEventOutput{})
}
@@ -677,6 +901,21 @@ func isGroupLifecycleEvent(eventKey string) bool {
eventKey == EventGroupDisbanded
}
func isOAEvent(eventKey string) bool {
return eventKey == EventOAApprovalTaskCreated ||
eventKey == EventOAApprovalTaskFinished ||
eventKey == EventOAApprovalTaskRedirected ||
eventKey == EventOAApprovalInstanceStarted ||
eventKey == EventOAApprovalInstanceTerminated ||
eventKey == EventOAApprovalInstanceFinished
}
func isOAApprovalTaskEvent(eventKey string) bool {
return eventKey == EventOAApprovalTaskCreated ||
eventKey == EventOAApprovalTaskFinished ||
eventKey == EventOAApprovalTaskRedirected
}
func schemaType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
+304
View File
@@ -150,6 +150,59 @@ func personalGroupMemberData(eventKey string) string {
}`, eventKey)
}
func personalOAData(eventKey string) string {
body := map[string]any{
"processInstanceId": "process-instance-1",
"createTime": int64(1785229100000),
"processCode": "PROC-TEST-1",
"title": "测试审批",
}
switch eventKey {
case EventOAApprovalTaskCreated:
body["taskId"] = "approval-task-1"
body["status"] = "RUNNING"
case EventOAApprovalTaskFinished:
body["taskId"] = "approval-task-1"
body["status"] = "FINISHED"
body["result"] = "agree"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalTaskRedirected:
body["taskId"] = "approval-task-1"
body["status"] = "FINISHED"
body["result"] = "redirect"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceStarted:
body["status"] = "RUNNING"
case EventOAApprovalInstanceTerminated:
body["status"] = "TERMINATED"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceFinished:
body["status"] = "FINISHED"
body["result"] = "agree"
body["finishTime"] = int64(1785229199000)
}
data := map[string]any{
"eventId": "oa-event",
"eventKey": eventKey,
"occurredAtMs": int64(1785229200123),
"subId": "oa-data-sub",
"payload": map[string]any{
"uid": 100001,
"CORPID": "internal-corp",
"clientId": "internal-client",
"filterSubId": "internal-filter",
"bizid": "internal-biz",
"orgId": 100002,
"sourceId": "open",
"body": body,
"event_time": int64(1785229199000),
"futureField": map[string]any{"nested": true},
},
}
encoded, _ := json.Marshal(data)
return string(encoded)
}
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} {
t.Run(eventKey, func(t *testing.T) {
@@ -396,6 +449,170 @@ func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) {
}
}
func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
tests := []struct {
eventKey string
want any
}{
{
eventKey: EventOAApprovalTaskCreated,
want: OAApprovalTaskCreatedOutput{
Type: EventOAApprovalTaskCreated,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalTaskFinished,
want: OAApprovalTaskFinishedOutput{
Type: EventOAApprovalTaskFinished,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "FINISHED",
Result: "agree",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalTaskRedirected,
want: OAApprovalTaskRedirectedOutput{
Type: EventOAApprovalTaskRedirected,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "FINISHED",
Result: "redirect",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceStarted,
want: OAApprovalInstanceStartedOutput{
Type: EventOAApprovalInstanceStarted,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
want: OAApprovalInstanceTerminatedOutput{
Type: EventOAApprovalInstanceTerminated,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "TERMINATED",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceFinished,
want: OAApprovalInstanceFinishedOutput{
Type: EventOAApprovalInstanceFinished,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "FINISHED",
Result: "agree",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
}
for _, tt := range tests {
t.Run(tt.eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventBornTime: 11,
EventType: tt.eventKey,
SubscribeID: "outer-sub",
Data: personalOAData(tt.eventKey),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
if !reflect.DeepEqual(projected, tt.want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, tt.want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputRejectsUnsupportedOAType(t *testing.T) {
ev := transport.Event{EventID: "outer-event", EventType: "user_oa_approval_unknown"}
projected, err := projectOAApprovalEvent(
ev,
baseEventOutput{Type: ev.EventType, EventID: ev.EventID},
json.RawMessage(`{"body":{"processInstanceId":"process-instance-1"},"event_time":1}`),
)
if err == nil || !strings.Contains(err.Error(), `unsupported personal OA event type "user_oa_approval_unknown"`) {
t.Fatalf("projectOAApprovalEvent() error = %v", err)
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("projectOAApprovalEvent() fallback = %#v, want %#v", projected, ev)
}
}
func TestCrossPlatformCoverageProjectOutputOADecodesDoublyWrappedJSONString(t *testing.T) {
once, err := json.Marshal(personalOAData(EventOAApprovalTaskCreated))
if err != nil {
t.Fatal(err)
}
twice, err := json.Marshal(string(once))
if err != nil {
t.Fatal(err)
}
projected, err := ProjectOutput(transport.Event{Data: string(twice)})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got, ok := projected.(OAApprovalTaskCreatedOutput)
if !ok {
t.Fatalf("ProjectOutput() type = %T, want OAApprovalTaskCreatedOutput", projected)
}
if got.Type != EventOAApprovalTaskCreated || got.EventID != "oa-event" || got.SubscribeID != "oa-data-sub" {
t.Fatalf("ProjectOutput() = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputGroupMemberEvents(t *testing.T) {
for _, eventKey := range []string{EventGroupMemberAdded, EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
@@ -549,6 +766,93 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupLifecyclePayloads(
}
}
func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T) {
tests := []struct {
name string
payload string
}{
{name: "missing"},
{name: "null", payload: `,"payload":null`},
{name: "empty object", payload: `,"payload":{}`},
{name: "array", payload: `,"payload":[]`},
{name: "string", payload: `,"payload":"invalid"`},
{name: "missing body", payload: `,"payload":{"event_time":1}`},
{name: "null body", payload: `,"payload":{"body":null,"event_time":1}`},
{name: "empty body", payload: `,"payload":{"body":{},"event_time":1}`},
}
for _, eventKey := range []string{
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
for _, tt := range tests {
t.Run(eventKey+"/"+tt.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: eventKey,
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, tt.payload),
}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want OA payload validation error")
}
if !strings.Contains(err.Error(), "decode personal OA payload") {
t.Fatalf("ProjectOutput() error = %v, want OA payload context", err)
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
}
func TestCrossPlatformCoverageProjectOutputRejectsOAWithoutStableIDs(t *testing.T) {
tests := []struct {
name string
eventKey string
body string
want string
}{
{
name: "missing process instance",
eventKey: EventOAApprovalInstanceStarted,
body: `{"status":"RUNNING"}`,
want: "processInstanceId is required",
},
{
name: "missing task",
eventKey: EventOAApprovalTaskCreated,
body: `{"processInstanceId":"process-instance-1","status":"RUNNING"}`,
want: "taskId is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: tt.eventKey,
Data: fmt.Sprintf(
`{"eventKey":%q,"payload":{"body":%s,"event_time":1}}`,
tt.eventKey,
tt.body,
),
}
projected, err := ProjectOutput(ev)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ProjectOutput() error = %v, want %q", err, tt.want)
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
if err != nil {
+88 -16
View File
@@ -23,22 +23,28 @@ import (
)
const (
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventAllSingleChat = "user_im_message_receive_o2o_all"
EventAllGroupChat = "user_im_message_receive_group_all"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
EventGroupUpdated = "user_im_group_updated"
EventGroupMemberAdded = "user_im_group_member_added"
EventGroupMemberExited = "user_im_group_member_exited"
EventGroupDisbanded = "user_im_group_disbanded"
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventAllSingleChat = "user_im_message_receive_o2o_all"
EventAllGroupChat = "user_im_message_receive_group_all"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
EventGroupUpdated = "user_im_group_updated"
EventGroupMemberAdded = "user_im_group_member_added"
EventGroupMemberExited = "user_im_group_member_exited"
EventGroupDisbanded = "user_im_group_disbanded"
EventOAApprovalTaskCreated = "user_oa_approval_task_created"
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
)
const (
@@ -273,6 +279,72 @@ var definitions = []Definition{
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalTaskCreated,
DisplayName: "审批任务创建",
Description: "审批任务创建,发送给审批人",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalTaskFinished,
DisplayName: "审批任务完成",
Description: "审批任务已完成",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalTaskRedirected,
DisplayName: "审批人任务转交",
Description: "审批任务已转交",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceStarted,
DisplayName: "发起审批单",
Description: "审批实例已发起",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceTerminated,
DisplayName: "审批单终止",
Description: "审批实例已终止",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceFinished,
DisplayName: "审批单完成",
Description: "审批实例完成,发送给审批单发起人",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
}
func targetUIDConstraints() *ParameterConstraints {
+147 -1
View File
@@ -46,12 +46,47 @@ func TestCatalogEnabledEvents(t *testing.T) {
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
if !reflect.DeepEqual(keys, want) {
t.Fatalf("keys = %#v, want %#v", keys, want)
}
}
func TestOAEventCatalogDefinitions(t *testing.T) {
items := Catalog("oa", true, false)
wantKeys := []string{
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
if len(items) != len(wantKeys) {
t.Fatalf("Catalog(oa) = %#v, want %d events", items, len(wantKeys))
}
for i, item := range items {
if item.EventKey != wantKeys[i] {
t.Fatalf("Catalog(oa)[%d].event_key = %q, want %q", i, item.EventKey, wantKeys[i])
}
if item.Category != "oa" || item.RuleType != "all" || item.Status != StatusEnabled || !item.Public {
t.Fatalf("Catalog(oa)[%d] = %#v, want public enabled oa/all event", i, item)
}
if len(item.RequiredParams) != 0 || item.Constraints != nil {
t.Fatalf("Catalog(oa)[%d] parameters = %#v/%#v, want none", i, item.RequiredParams, item.Constraints)
}
if item.Auth["identity"] != "user" {
t.Fatalf("Catalog(oa)[%d].auth = %#v, want user identity", i, item.Auth)
}
}
}
func TestEventFromUserIsPublic(t *testing.T) {
if _, ok := Lookup(EventFromUser); !ok {
t.Fatalf("Lookup(%q) failed", EventFromUser)
@@ -119,6 +154,12 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
@@ -433,6 +474,96 @@ func TestGroupLifecycleSchemaDocumentsUseConservativePayload(t *testing.T) {
}
}
func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
tests := []struct {
eventKey string
properties []string
}{
{
eventKey: EventOAApprovalTaskCreated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalTaskFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: EventOAApprovalTaskRedirected,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceStarted,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "finish_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "result", "create_time", "finish_time",
"event_time",
},
},
}
for _, tt := range tests {
t.Run(tt.eventKey, func(t *testing.T) {
def, ok := Lookup(tt.eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", tt.eventKey)
}
doc := BuildSchemaDocumentForMode(def, true)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
props, ok := doc.Schema["properties"].(map[string]any)
if !ok || len(props) != len(tt.properties) {
t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(tt.properties))
}
for _, name := range tt.properties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
eventType := props["type"].(map[string]any)
if !reflect.DeepEqual(eventType["enum"], []string{tt.eventKey}) {
t.Fatalf("schema.properties.type.enum = %#v, want %q", eventType["enum"], tt.eventKey)
}
if _, ok := props["payload"]; ok {
t.Fatalf("schema.properties exposed generic payload: %#v", props)
}
for _, name := range []string{"timestamp", "create_time", "finish_time", "event_time"} {
property, exists := props[name].(map[string]any)
if !exists {
continue
}
if property["type"] != "integer" || property["format"] != "timestamp_ms" {
t.Fatalf("schema.properties.%s = %#v, want timestamp_ms integer", name, property)
}
}
})
}
}
func TestGroupMemberSchemaDocumentsMatchOutputDTO(t *testing.T) {
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
@@ -495,7 +626,16 @@ func TestBuildRuleParamMention(t *testing.T) {
}
func TestBuildRuleParamAllEvents(t *testing.T) {
for _, eventKey := range []string{EventAllSingleChat, EventAllGroupChat} {
for _, eventKey := range []string{
EventAllSingleChat,
EventAllGroupChat,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
t.Run(eventKey, func(t *testing.T) {
rule, param, err := BuildRuleParam(eventKey, RuleOptions{})
if err != nil {
@@ -701,6 +841,12 @@ func TestSupportsMessageFilter(t *testing.T) {
EventReadO2O,
EventReactionGroup,
EventGroupUpdated,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
"unknown_event",
} {
if SupportsMessageFilter(eventKey) {
+300
View File
@@ -0,0 +1,300 @@
// Copyright 2026 Alibaba Group
// 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.
// Package runtimecred provides an in-memory credential broker for event bus
// processes. Runtime credentials are never persisted by this package.
package runtimecred
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
// DefaultMaxTokenBytes bounds credentials accepted over the local IPC
// channel. Access tokens are normally only a few KiB; the larger limit leaves
// ample room for future token formats while avoiding accidental large secret
// allocations.
const DefaultMaxTokenBytes = 64 << 10
var (
ErrEmptyToken = errors.New("runtime credential: token is empty")
ErrTokenTooLarge = errors.New("runtime credential: token exceeds size limit")
ErrCredentialUnavailable = errors.New("runtime credential: no credential resolver is available")
ErrLocalRefreshUnavailable = errors.New("runtime credential: no local refresh callback is available")
ErrRuntimeTokenRejected = &RuntimeTokenRejectedError{}
)
// RuntimeTokenRejectedError means the currently installed runtime token was
// rejected and no newer runtime token is available. It deliberately carries
// no token or server response data so it is safe to surface to users and logs.
type RuntimeTokenRejectedError struct{}
func (*RuntimeTokenRejectedError) Error() string {
return "event runtime token was rejected; retry with a fresh host credential"
}
func (*RuntimeTokenRejectedError) Is(target error) bool {
_, ok := target.(*RuntimeTokenRejectedError)
return ok
}
// GenerationConflictError reports a failed compare-and-swap update.
type GenerationConflictError struct {
Expected uint64
Actual uint64
}
func (e *GenerationConflictError) Error() string {
return fmt.Sprintf("runtime credential: generation conflict (expected %d, actual %d)", e.Expected, e.Actual)
}
// ResolveFunc resolves the existing local OAuth credential when no runtime
// credential has been installed.
type ResolveFunc func(context.Context) (string, error)
// RefreshFunc refreshes a rejected local OAuth credential. It is never called
// after a runtime credential has been installed.
type RefreshFunc func(context.Context, string) (string, error)
type Config struct {
LocalResolve ResolveFunc
LocalRefresh RefreshFunc
RequireSeed bool
// RequireActivation keeps the first installed runtime credential pending
// until Activate is called. Detached buses use it to register the consumer
// before ticket acquisition can emit or fail.
RequireActivation bool
MaxTokenBytes int
}
// Broker holds at most one runtime credential. All state, including the
// credential generation, is process-local and concurrency-safe.
type Broker struct {
localResolve ResolveFunc
localRefresh RefreshFunc
requireSeed bool
requireActivation bool
maxBytes int
mu sync.Mutex
token string
generation uint64
active bool
changed chan struct{}
}
func New(cfg Config) *Broker {
maxBytes := cfg.MaxTokenBytes
if maxBytes <= 0 {
maxBytes = DefaultMaxTokenBytes
}
return &Broker{
localResolve: cfg.LocalResolve,
localRefresh: cfg.LocalRefresh,
requireSeed: cfg.RequireSeed,
requireActivation: cfg.RequireActivation,
maxBytes: maxBytes,
active: !cfg.RequireActivation,
changed: make(chan struct{}),
}
}
// Generation returns the current runtime credential generation. Generation 0
// means that no runtime credential has been installed yet.
func (b *Broker) Generation() uint64 {
if b == nil {
return 0
}
b.mu.Lock()
defer b.mu.Unlock()
return b.generation
}
// Update atomically installs token when expectedGeneration matches the
// current generation. Reinstalling the same token is idempotent, including
// when another concurrent writer already installed it.
func (b *Broker) Update(expectedGeneration uint64, token string) (uint64, error) {
if b == nil {
return 0, ErrCredentialUnavailable
}
normalized, err := b.validate(token)
if err != nil {
return b.Generation(), err
}
b.mu.Lock()
defer b.mu.Unlock()
if b.token == normalized {
return b.generation, nil
}
if expectedGeneration != b.generation {
return b.generation, &GenerationConflictError{Expected: expectedGeneration, Actual: b.generation}
}
b.token = normalized
b.generation++
if b.active {
b.signalChangedLocked()
}
return b.generation, nil
}
// Activate publishes a pending first runtime credential after the bus has
// registered the initiating consumer. It is an idempotent generation-checked
// no-op for brokers that do not require activation.
func (b *Broker) Activate(expectedGeneration uint64) (uint64, error) {
if b == nil {
return 0, ErrCredentialUnavailable
}
b.mu.Lock()
defer b.mu.Unlock()
if expectedGeneration != b.generation {
return b.generation, &GenerationConflictError{Expected: expectedGeneration, Actual: b.generation}
}
if b.token == "" {
return b.generation, ErrCredentialUnavailable
}
if b.active {
return b.generation, nil
}
b.active = true
b.signalChangedLocked()
return b.generation, nil
}
// Resolve returns the runtime credential when installed. In RequireSeed mode
// it waits until Update installs one; otherwise it preserves the existing
// local resolver behavior until a runtime credential arrives.
func (b *Broker) Resolve(ctx context.Context) (string, error) {
if b == nil {
return "", ErrCredentialUnavailable
}
for {
token, wait, requireSeed, active := b.snapshot()
if token != "" && active {
return token, nil
}
if requireSeed || (token != "" && !active) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-wait:
continue
}
}
if b.localResolve == nil {
return "", ErrCredentialUnavailable
}
local, err := b.localResolve(ctx)
if err != nil {
return "", err
}
if runtime, _, _, runtimeActive := b.snapshot(); runtime != "" && runtimeActive {
return runtime, nil
}
return validateResolved(local)
}
}
// RefreshRejected returns a newer runtime token if one was installed after
// rejectedToken was used. If the installed runtime token itself was rejected,
// it returns RuntimeTokenRejectedError and never invokes local OAuth refresh.
func (b *Broker) RefreshRejected(ctx context.Context, rejectedToken string) (string, error) {
if b == nil {
return "", ErrCredentialUnavailable
}
for {
token, wait, requireSeed, active := b.snapshot()
if token != "" && active {
if token != rejectedToken {
return token, nil
}
return "", &RuntimeTokenRejectedError{}
}
if requireSeed || (token != "" && !active) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-wait:
continue
}
}
if b.localRefresh == nil {
return "", ErrLocalRefreshUnavailable
}
refreshed, err := b.localRefresh(ctx, rejectedToken)
if err != nil {
return "", err
}
if runtime, _, _, runtimeActive := b.snapshot(); runtime != "" && runtimeActive {
if runtime != rejectedToken {
return runtime, nil
}
return "", &RuntimeTokenRejectedError{}
}
return validateResolved(refreshed)
}
}
// ClassifyRejectedAfterRetry is called when a token returned by
// RefreshRejected was itself rejected. When a still newer runtime credential
// is already installed, superseded is true so the source may reconnect and
// resolve that generation without a second in-attempt retry. When the rejected
// token is still current, the fixed typed rejection is returned. With no
// runtime credential installed it preserves local OAuth behavior by returning
// (false, nil).
func (b *Broker) ClassifyRejectedAfterRetry(rejectedToken string) (superseded bool, err error) {
if b == nil {
return false, ErrCredentialUnavailable
}
token, _, _, active := b.snapshot()
if token == "" || !active {
return false, nil
}
if token != strings.TrimSpace(rejectedToken) {
return true, nil
}
return false, &RuntimeTokenRejectedError{}
}
func (b *Broker) snapshot() (string, <-chan struct{}, bool, bool) {
b.mu.Lock()
defer b.mu.Unlock()
return b.token, b.changed, b.requireSeed, b.active
}
func (b *Broker) signalChangedLocked() {
close(b.changed)
b.changed = make(chan struct{})
}
func (b *Broker) validate(token string) (string, error) {
normalized := strings.TrimSpace(token)
if normalized == "" {
return "", ErrEmptyToken
}
if len(normalized) > b.maxBytes {
return "", ErrTokenTooLarge
}
return normalized, nil
}
func validateResolved(token string) (string, error) {
token = strings.TrimSpace(token)
if token == "" {
return "", ErrCredentialUnavailable
}
return token, nil
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package runtimecred
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestCrossPlatformCoverageBrokerLocalFallbackAndRuntimePrecedence(t *testing.T) {
var localCalls atomic.Int32
b := New(Config{LocalResolve: func(context.Context) (string, error) {
localCalls.Add(1)
return "local", nil
}})
if got, err := b.Resolve(context.Background()); err != nil || got != "local" {
t.Fatalf("local Resolve = %q, %v", got, err)
}
gen, err := b.Update(0, " runtime ")
if err != nil || gen != 1 {
t.Fatalf("Update = %d, %v", gen, err)
}
if got, err := b.Resolve(context.Background()); err != nil || got != "runtime" {
t.Fatalf("runtime Resolve = %q, %v", got, err)
}
if localCalls.Load() != 1 {
t.Fatalf("local resolver calls = %d", localCalls.Load())
}
}
func TestCrossPlatformCoverageBrokerRequireSeedWaitsAndCancels(t *testing.T) {
b := New(Config{RequireSeed: true})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { _, err := b.Resolve(ctx); done <- err }()
cancel()
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatalf("Resolve error = %v", err)
}
resolved := make(chan string, 1)
go func() {
token, _ := b.Resolve(context.Background())
resolved <- token
}()
select {
case <-resolved:
t.Fatal("Resolve returned before seed")
case <-time.After(20 * time.Millisecond):
}
if _, err := b.Update(0, "seed"); err != nil {
t.Fatal(err)
}
if got := <-resolved; got != "seed" {
t.Fatalf("Resolve = %q", got)
}
}
func TestCrossPlatformCoverageBrokerUpdateCASIdempotenceAndConcurrentConflict(t *testing.T) {
b := New(Config{})
if gen, err := b.Update(0, "same"); err != nil || gen != 1 {
t.Fatalf("first update = %d, %v", gen, err)
}
if gen, err := b.Update(0, "same"); err != nil || gen != 1 {
t.Fatalf("idempotent stale update = %d, %v", gen, err)
}
if _, err := b.Update(0, "different"); err == nil {
t.Fatal("expected generation conflict")
} else {
var conflict *GenerationConflictError
if !errors.As(err, &conflict) || conflict.Actual != 1 {
t.Fatalf("conflict = %#v, %v", conflict, err)
}
}
b = New(Config{})
start := make(chan struct{})
var successes atomic.Int32
var conflicts atomic.Int32
var wg sync.WaitGroup
for _, token := range []string{"a", "b"} {
wg.Add(1)
go func(token string) {
defer wg.Done()
<-start
_, err := b.Update(0, token)
if err == nil {
successes.Add(1)
return
}
var conflict *GenerationConflictError
if errors.As(err, &conflict) {
conflicts.Add(1)
}
}(token)
}
close(start)
wg.Wait()
if successes.Load() != 1 || conflicts.Load() != 1 {
t.Fatalf("successes=%d conflicts=%d", successes.Load(), conflicts.Load())
}
}
func TestCrossPlatformCoverageBrokerRefreshRejectedRuntimeNeverFallsBack(t *testing.T) {
var refreshCalls atomic.Int32
b := New(Config{LocalRefresh: func(context.Context, string) (string, error) {
refreshCalls.Add(1)
return "local-new", nil
}})
if _, err := b.Update(0, "runtime-a"); err != nil {
t.Fatal(err)
}
if _, err := b.RefreshRejected(context.Background(), "runtime-a"); !errors.Is(err, ErrRuntimeTokenRejected) {
t.Fatalf("same token refresh error = %v", err)
}
if refreshCalls.Load() != 0 {
t.Fatalf("local refresh called %d times", refreshCalls.Load())
}
if _, err := b.Update(1, "runtime-b"); err != nil {
t.Fatal(err)
}
if got, err := b.RefreshRejected(context.Background(), "runtime-a"); err != nil || got != "runtime-b" {
t.Fatalf("rotated refresh = %q, %v", got, err)
}
if superseded, err := b.ClassifyRejectedAfterRetry("runtime-b"); superseded || !errors.Is(err, ErrRuntimeTokenRejected) {
t.Fatalf("current retry rejection = superseded %v, error %v", superseded, err)
}
if _, err := b.Update(2, "runtime-c"); err != nil {
t.Fatal(err)
}
if superseded, err := b.ClassifyRejectedAfterRetry("runtime-b"); !superseded || err != nil {
t.Fatalf("newer generation classification = superseded %v, error %v", superseded, err)
}
localOnly := New(Config{})
if superseded, err := localOnly.ClassifyRejectedAfterRetry("local"); superseded || err != nil {
t.Fatalf("local-only classification = superseded %v, error %v", superseded, err)
}
}
func TestCrossPlatformCoverageBrokerDefersSeedUntilActivation(t *testing.T) {
b := New(Config{RequireSeed: true, RequireActivation: true})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resolved := make(chan string, 1)
errs := make(chan error, 1)
go func() {
token, err := b.Resolve(ctx)
if err != nil {
errs <- err
return
}
resolved <- token
}()
generation, err := b.Update(0, "runtime-a")
if err != nil || generation != 1 {
t.Fatalf("Update() = generation %d, err %v", generation, err)
}
select {
case token := <-resolved:
t.Fatalf("Resolve() returned before activation: %q", token)
case err := <-errs:
t.Fatalf("Resolve() failed before activation: %v", err)
case <-time.After(20 * time.Millisecond):
}
if _, err := b.Activate(0); err == nil {
t.Fatal("Activate() with stale generation unexpectedly succeeded")
}
if activeGeneration, err := b.Activate(generation); err != nil || activeGeneration != generation {
t.Fatalf("Activate() = generation %d, err %v", activeGeneration, err)
}
select {
case token := <-resolved:
if token != "runtime-a" {
t.Fatalf("Resolve() token = %q", token)
}
case err := <-errs:
t.Fatalf("Resolve() failed after activation: %v", err)
case <-time.After(time.Second):
t.Fatal("Resolve() remained blocked after activation")
}
}
func TestCrossPlatformCoverageBrokerRejectsInvalidTokensWithoutEcho(t *testing.T) {
b := New(Config{MaxTokenBytes: 4})
for _, token := range []string{" ", "secret-token"} {
_, err := b.Update(0, token)
if err == nil {
t.Fatalf("Update(%q) succeeded", token)
}
if strings.Contains(err.Error(), token) {
t.Fatal("validation error contained rejected token")
}
}
}
@@ -0,0 +1,150 @@
package runtimecred
import (
"context"
"errors"
"testing"
"time"
)
func TestCrossPlatformCoverageEventCoreBrokerEdges(t *testing.T) {
rejected := &RuntimeTokenRejectedError{}
if rejected.Error() == "" || !errors.Is(rejected, ErrRuntimeTokenRejected) || rejected.Is(errors.New("other")) {
t.Fatal("runtime rejection error contract failed")
}
conflict := &GenerationConflictError{Expected: 2, Actual: 3}
if conflict.Error() == "" {
t.Fatal("generation conflict error is empty")
}
var nilBroker *Broker
if nilBroker.Generation() != 0 {
t.Fatal("nil broker generation is non-zero")
}
if _, err := nilBroker.Update(0, "token"); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil Update error = %v", err)
}
if _, err := nilBroker.Activate(0); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil Activate error = %v", err)
}
if _, err := nilBroker.Resolve(context.Background()); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil Resolve error = %v", err)
}
if _, err := nilBroker.RefreshRejected(context.Background(), "token"); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil RefreshRejected error = %v", err)
}
if superseded, err := nilBroker.ClassifyRejectedAfterRetry("token"); superseded || !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("nil classification = %v, %v", superseded, err)
}
b := New(Config{})
if _, err := b.Activate(0); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("empty Activate error = %v", err)
}
if _, err := b.Update(0, "token"); err != nil {
t.Fatal(err)
}
if generation, err := b.Activate(1); err != nil || generation != 1 {
t.Fatalf("active Activate = %d, %v", generation, err)
}
if _, err := New(Config{}).Resolve(context.Background()); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("missing local resolver error = %v", err)
}
wantResolveErr := errors.New("resolve failed")
if _, err := New(Config{LocalResolve: func(context.Context) (string, error) {
return "", wantResolveErr
}}).Resolve(context.Background()); !errors.Is(err, wantResolveErr) {
t.Fatalf("local resolve error = %v", err)
}
if _, err := New(Config{LocalResolve: func(context.Context) (string, error) {
return " ", nil
}}).Resolve(context.Background()); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("empty resolved credential error = %v", err)
}
var racingResolve *Broker
racingResolve = New(Config{LocalResolve: func(context.Context) (string, error) {
_, err := racingResolve.Update(0, "runtime-wins")
return "local", err
}})
if token, err := racingResolve.Resolve(context.Background()); err != nil || token != "runtime-wins" {
t.Fatalf("runtime precedence after resolve = %q, %v", token, err)
}
seed := New(Config{RequireSeed: true})
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := seed.Resolve(canceled); !errors.Is(err, context.Canceled) {
t.Fatalf("seed cancellation = %v", err)
}
if _, err := New(Config{}).RefreshRejected(context.Background(), "local"); !errors.Is(err, ErrLocalRefreshUnavailable) {
t.Fatalf("missing local refresh error = %v", err)
}
wantRefreshErr := errors.New("refresh failed")
if _, err := New(Config{LocalRefresh: func(context.Context, string) (string, error) {
return "", wantRefreshErr
}}).RefreshRejected(context.Background(), "local"); !errors.Is(err, wantRefreshErr) {
t.Fatalf("local refresh error = %v", err)
}
if _, err := New(Config{LocalRefresh: func(context.Context, string) (string, error) {
return " ", nil
}}).RefreshRejected(context.Background(), "local"); !errors.Is(err, ErrCredentialUnavailable) {
t.Fatalf("empty refreshed credential error = %v", err)
}
var racingRefresh *Broker
racingRefresh = New(Config{LocalRefresh: func(context.Context, string) (string, error) {
_, err := racingRefresh.Update(0, "runtime-new")
return "local-new", err
}})
if token, err := racingRefresh.RefreshRejected(context.Background(), "runtime-old"); err != nil || token != "runtime-new" {
t.Fatalf("runtime precedence after refresh = %q, %v", token, err)
}
var sameRefresh *Broker
sameRefresh = New(Config{LocalRefresh: func(context.Context, string) (string, error) {
_, err := sameRefresh.Update(0, "same")
return "local-new", err
}})
if _, err := sameRefresh.RefreshRejected(context.Background(), "same"); !errors.Is(err, ErrRuntimeTokenRejected) {
t.Fatalf("same runtime after refresh error = %v", err)
}
pending := New(Config{RequireSeed: true, RequireActivation: true})
generation, err := pending.Update(0, "pending")
if err != nil {
t.Fatal(err)
}
waitCtx, waitCancel := context.WithCancel(context.Background())
waitCancel()
if _, err := pending.RefreshRejected(waitCtx, "pending"); !errors.Is(err, context.Canceled) {
t.Fatalf("pending refresh cancellation = %v", err)
}
refreshDone := make(chan error, 1)
go func() {
_, err := pending.RefreshRejected(context.Background(), "pending")
refreshDone <- err
}()
// Let RefreshRejected take the pending credential's changed-channel path
// before activation publishes it.
time.Sleep(10 * time.Millisecond)
if _, err := pending.Activate(generation); err != nil {
t.Fatal(err)
}
select {
case err := <-refreshDone:
if !errors.Is(err, ErrRuntimeTokenRejected) {
t.Fatalf("activated pending refresh error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("pending refresh did not resume")
}
inactive := New(Config{RequireActivation: true})
if _, err := inactive.Update(0, "inactive"); err != nil {
t.Fatal(err)
}
if superseded, err := inactive.ClassifyRejectedAfterRetry("other"); superseded || err != nil {
t.Fatalf("inactive classification = %v, %v", superseded, err)
}
}
@@ -0,0 +1,40 @@
package source
import (
"context"
"io"
"net/http"
"strings"
"testing"
)
func TestCrossPlatformCoverageEventCorePersonalSupersededRetry(t *testing.T) {
source, err := NewPersonal(PersonalConfig{
AccessToken: "stale",
ForceRefreshToken: func(context.Context, string) (string, error) {
return "rotated", nil
},
ClassifyRetryReject: func(token string) (bool, error) {
if token != "rotated" {
t.Fatalf("classified token = %q", token)
}
return true, nil
},
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(strings.NewReader("rejected")),
Header: make(http.Header),
}, nil
})},
})
if err != nil {
t.Fatal(err)
}
if _, err := source.fetchTicket(context.Background()); !isRetryablePersonalError(err) {
t.Fatalf("superseded retry error = %v", err)
}
}
+20 -1
View File
@@ -45,6 +45,7 @@ type PersonalConfig struct {
AccessToken string
AccessTokenProvider AccessTokenProvider
ForceRefreshToken ForceRefreshTokenFn
ClassifyRetryReject RetryRejectClassifier
ClientID string
ClientSecret string
SourceID string
@@ -66,6 +67,12 @@ type AccessTokenProvider func(context.Context) (string, error)
// when nil a 401 stays fatal, matching the previous behavior.
type ForceRefreshTokenFn func(ctx context.Context, rejectedToken string) (string, error)
// RetryRejectClassifier classifies a 401 from the one refreshed-token retry.
// superseded means a newer credential is already available and the outer
// reconnect loop should start a fresh attempt; err is a terminal typed
// rejection. A nil callback preserves the historical local OAuth behavior.
type RetryRejectClassifier func(rejectedToken string) (superseded bool, err error)
type PersonalSource struct {
cfg PersonalConfig
machine *Machine
@@ -223,7 +230,19 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
return nil, refreshErr
}
// Retry once with the freshly rotated token; a second 401 stays fatal.
ticket, _, err = s.fetchTicketAttempt(ctx, refreshed)
var retryStatus int
var retryErr error
ticket, retryStatus, retryErr = s.fetchTicketAttempt(ctx, refreshed)
if retryStatus == http.StatusUnauthorized && s.cfg.ClassifyRetryReject != nil {
superseded, classifyErr := s.cfg.ClassifyRetryReject(refreshed)
if classifyErr != nil {
return nil, classifyErr
}
if superseded {
return nil, retryPersonal(retryErr)
}
}
err = retryErr
}
return ticket, err
}
@@ -14,6 +14,7 @@ import (
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred"
"github.com/gorilla/websocket"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/payload"
)
@@ -316,6 +317,46 @@ func TestCrossPlatformCoveragePersonalFetchTicket401RefreshFailureStaysFatal(t *
}
}
func TestCrossPlatformCoveragePersonalRuntimeTokenAtoBSecond401IsTyped(t *testing.T) {
broker := runtimecred.New(runtimecred.Config{})
if _, err := broker.Update(0, "runtime-a"); err != nil {
t.Fatal(err)
}
var attemptTokens []string
src, err := NewPersonal(PersonalConfig{
AccessTokenProvider: broker.Resolve,
ForceRefreshToken: broker.RefreshRejected,
ClassifyRetryReject: broker.ClassifyRejectedAfterRetry,
ClientID: "client",
SourceID: "source",
TicketURL: "https://ticket.test",
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
token := req.Header.Get("x-user-access-token")
attemptTokens = append(attemptTokens, token)
if token == "runtime-a" {
if _, err := broker.Update(1, "runtime-b"); err != nil {
t.Fatalf("rotate runtime credential: %v", err)
}
}
return &http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(strings.NewReader("must-not-surface")),
Header: make(http.Header),
}, nil
})},
})
if err != nil {
t.Fatal(err)
}
_, err = src.fetchTicket(context.Background())
if !errors.Is(err, runtimecred.ErrRuntimeTokenRejected) {
t.Fatalf("fetchTicket() error = %v", err)
}
if len(attemptTokens) != 2 || attemptTokens[0] != "runtime-a" || attemptTokens[1] != "runtime-b" {
t.Fatalf("attempt tokens = %v", attemptTokens)
}
}
// brokenBody simulates a response body that fails mid-read, e.g. the server
// closing the connection before the error payload is fully written.
type brokenBody struct{}
+60 -12
View File
@@ -26,16 +26,28 @@ import (
type FrameType string
const (
FrameTypeHello FrameType = "hello" // consume → bus
FrameTypeHelloAck FrameType = "hello_ack" // bus → consume
FrameTypeEvent FrameType = "event" // bus → consume
FrameTypeHeartbeat FrameType = "heartbeat" // bidirectional
FrameTypeSourceState FrameType = "source_state" // bus → consume
FrameTypeBye FrameType = "bye" // bidirectional
FrameTypeStatusReq FrameType = "status_req" // consume/ad-hoc → bus
FrameTypeStatusResp FrameType = "status_resp" // bus → consume/ad-hoc
FrameTypeConsumerStopReq FrameType = "consumer_stop_req" // ad-hoc → bus
FrameTypeConsumerStopResp FrameType = "consumer_stop_resp" // bus → ad-hoc
FrameTypeHello FrameType = "hello" // consume → bus
FrameTypeHelloAck FrameType = "hello_ack" // bus → consume
FrameTypeEvent FrameType = "event" // bus → consume
FrameTypeHeartbeat FrameType = "heartbeat" // bidirectional
FrameTypeSourceState FrameType = "source_state" // bus → consume
FrameTypeBye FrameType = "bye" // bidirectional
FrameTypeStatusReq FrameType = "status_req" // consume/ad-hoc → bus
FrameTypeStatusResp FrameType = "status_resp" // bus → consume/ad-hoc
FrameTypeConsumerStopReq FrameType = "consumer_stop_req" // ad-hoc → bus
FrameTypeConsumerStopResp FrameType = "consumer_stop_resp" // bus → ad-hoc
FrameTypeCredentialUpdate FrameType = "credential_update" // consume → bus
FrameTypeCredentialUpdateAck FrameType = "credential_update_ack" // bus → consume
)
// CredentialMode declares that a consumer needs an additive credential
// negotiation before it can register for events. The zero value preserves the
// original protocol.
type CredentialMode string
const (
CredentialModeRuntimeToken CredentialMode = "runtime_token"
CapabilityRuntimeTokenV1 = "runtime_token_v1"
)
// Hello is the first frame a consumer sends after dialing the bus. The bus
@@ -51,7 +63,8 @@ type Hello struct {
// Role distinguishes a real consumer (registered for events) from an
// ad-hoc tooling connection (status/list/stop). Ad-hoc connections do
// NOT register with the Hub.
Role HelloRole `json:"role,omitempty"`
Role HelloRole `json:"role,omitempty"`
CredentialMode CredentialMode `json:"credential_mode,omitempty"`
}
// HelloRole tags the purpose of a Hello connection.
@@ -76,6 +89,38 @@ type HelloAck struct {
ClientIDSource string `json:"client_id_source"` // auth.CredentialSource string
ClientSecretSource string `json:"client_secret_source"` // auth.CredentialSource string
IdleTimeoutSecs int `json:"idle_timeout_secs,omitempty"` // bus's IdleTimeout for diagnostics
Capabilities []string `json:"capabilities,omitempty"`
// CredentialGeneration is process-local and contains no secret data.
CredentialGeneration uint64 `json:"credential_generation"`
// TerminalReason is a fixed, non-sensitive bus terminal state. A runtime
// client checks it before sending credential material.
TerminalReason string `json:"terminal_reason,omitempty"`
}
// CredentialUpdate installs a host-supplied runtime token into a compatible
// bus after the bus has advertised CapabilityRuntimeTokenV1. Token is carried
// only over the owner-only local IPC connection and must never be logged.
type CredentialUpdate struct {
Type FrameType `json:"type"`
ExpectedGeneration uint64 `json:"expected_generation"`
Token string `json:"token"`
}
const (
CredentialErrorGenerationConflict = "generation_conflict"
CredentialErrorInvalid = "invalid_credential"
CredentialErrorRegistration = "registration_failed"
CredentialErrorRuntimeRejected = "runtime_token_rejected"
CredentialErrorInternal = "internal_error"
)
// CredentialUpdateAck reports the CAS result without echoing any credential.
type CredentialUpdateAck struct {
Type FrameType `json:"type"`
Accepted bool `json:"accepted"`
CredentialGeneration uint64 `json:"credential_generation"`
ErrorCode string `json:"error_code,omitempty"`
Error string `json:"error,omitempty"`
}
// Event wraps one delivered RawEvent for the wire. We keep the payload as
@@ -126,7 +171,10 @@ type Bye struct {
Reason string `json:"reason"`
}
const ByeReasonSubscriptionStopped = "subscription_stopped"
const (
ByeReasonSubscriptionStopped = "subscription_stopped"
ByeReasonRuntimeTokenRejected = "runtime_token_rejected"
)
// ConsumerStopReq asks the bus to close consumers whose exact personal
// subscription IDs match. It is an additive local IPC control operation;
+72 -31
View File
@@ -23,21 +23,23 @@ import (
// to guard against accidental wire-format changes (the JSON field tags
// double as part of the cross-version protocol contract).
func TestFrameType_StableWireValues(t *testing.T) {
func TestCrossPlatformCoverageFrameTypeStableWireValues(t *testing.T) {
// If any of these strings change we've made a protocol-breaking
// change. The test value list is duplicated here on purpose so a
// reviewer renaming a constant is forced to also update the test.
wants := map[FrameType]string{
FrameTypeHello: "hello",
FrameTypeHelloAck: "hello_ack",
FrameTypeEvent: "event",
FrameTypeHeartbeat: "heartbeat",
FrameTypeSourceState: "source_state",
FrameTypeBye: "bye",
FrameTypeStatusReq: "status_req",
FrameTypeStatusResp: "status_resp",
FrameTypeConsumerStopReq: "consumer_stop_req",
FrameTypeConsumerStopResp: "consumer_stop_resp",
FrameTypeHello: "hello",
FrameTypeHelloAck: "hello_ack",
FrameTypeEvent: "event",
FrameTypeHeartbeat: "heartbeat",
FrameTypeSourceState: "source_state",
FrameTypeBye: "bye",
FrameTypeStatusReq: "status_req",
FrameTypeStatusResp: "status_resp",
FrameTypeConsumerStopReq: "consumer_stop_req",
FrameTypeConsumerStopResp: "consumer_stop_resp",
FrameTypeCredentialUpdate: "credential_update",
FrameTypeCredentialUpdateAck: "credential_update_ack",
}
for ft, want := range wants {
if string(ft) != want {
@@ -104,54 +106,91 @@ func roundTrip(t *testing.T, in any, dst any) {
}
}
func TestHello_Roundtrip(t *testing.T) {
func TestCrossPlatformCoverageHelloRoundtrip(t *testing.T) {
in := Hello{
Type: FrameTypeHello,
ConsumerPID: 42,
EventTypes: []string{"im.*", "approval.task"},
Filter: `^im\.`,
Compact: true,
Role: HelloRoleStatus,
Type: FrameTypeHello,
ConsumerPID: 42,
EventTypes: []string{"im.*", "approval.task"},
Filter: `^im\.`,
Compact: true,
Role: HelloRoleStatus,
CredentialMode: CredentialModeRuntimeToken,
}
var out Hello
roundTrip(t, in, &out)
if out.Type != in.Type || out.ConsumerPID != in.ConsumerPID || out.Filter != in.Filter ||
out.Compact != in.Compact || out.Role != in.Role || len(out.EventTypes) != len(in.EventTypes) {
out.Compact != in.Compact || out.Role != in.Role || out.CredentialMode != in.CredentialMode || len(out.EventTypes) != len(in.EventTypes) {
t.Fatalf("roundtrip mismatch: %+v != %+v", out, in)
}
}
func TestHello_OmitemptyForDefaults(t *testing.T) {
func TestCrossPlatformCoverageHelloOmitemptyForDefaults(t *testing.T) {
// Default values should NOT appear in the wire form so old/new readers
// stay tolerant of each other (each new field comes in with its
// zero value by default).
in := Hello{Type: FrameTypeHello, ConsumerPID: 1}
b, _ := json.Marshal(in)
s := string(b)
for _, k := range []string{`"event_types"`, `"filter"`, `"compact"`, `"role"`} {
for _, k := range []string{`"event_types"`, `"filter"`, `"compact"`, `"role"`, `"credential_mode"`} {
if strings.Contains(s, k) {
t.Errorf("zero-value field %s leaked into wire form: %s", k, s)
}
}
}
func TestHelloAck_Roundtrip(t *testing.T) {
func TestCrossPlatformCoverageHelloAckRoundtrip(t *testing.T) {
in := HelloAck{
Type: FrameTypeHelloAck,
BusPID: 12345,
SourceState: "connected",
StateSource: "inferred",
ClientIDSource: "env",
ClientSecretSource: "env",
IdleTimeoutSecs: 300,
Type: FrameTypeHelloAck,
BusPID: 12345,
SourceState: "connected",
StateSource: "inferred",
ClientIDSource: "env",
ClientSecretSource: "env",
IdleTimeoutSecs: 300,
Capabilities: []string{CapabilityRuntimeTokenV1},
CredentialGeneration: 7,
}
var out HelloAck
roundTrip(t, in, &out)
if out != in {
if out.Type != in.Type || out.BusPID != in.BusPID || out.SourceState != in.SourceState ||
out.StateSource != in.StateSource || out.ClientIDSource != in.ClientIDSource ||
out.ClientSecretSource != in.ClientSecretSource || out.IdleTimeoutSecs != in.IdleTimeoutSecs ||
out.CredentialGeneration != in.CredentialGeneration || strings.Join(out.Capabilities, ",") != CapabilityRuntimeTokenV1 {
t.Fatalf("HelloAck roundtrip: %+v != %+v", out, in)
}
}
func TestCrossPlatformCoverageCredentialFramesRoundtripWithoutTokenInAck(t *testing.T) {
update := CredentialUpdate{
Type: FrameTypeCredentialUpdate,
ExpectedGeneration: 4,
Token: "canary-secret",
}
var decodedUpdate CredentialUpdate
roundTrip(t, update, &decodedUpdate)
if decodedUpdate != update {
t.Fatal("CredentialUpdate roundtrip mismatch")
}
ack := CredentialUpdateAck{
Type: FrameTypeCredentialUpdateAck,
Accepted: true,
CredentialGeneration: 5,
}
var decodedAck CredentialUpdateAck
roundTrip(t, ack, &decodedAck)
if decodedAck != ack {
t.Fatalf("CredentialUpdateAck roundtrip = %#v", decodedAck)
}
b, err := json.Marshal(ack)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), update.Token) {
t.Fatal("credential acknowledgement contained token")
}
}
func TestEvent_Roundtrip(t *testing.T) {
in := Event{
Type: FrameTypeEvent,
@@ -245,7 +284,7 @@ func TestStatusResp_Roundtrip(t *testing.T) {
// PeekType is used by the daemon to dispatch incoming frames before
// fully decoding into the typed struct. Behaviour at boundaries matters.
func TestPeekType_EachFrameVariant(t *testing.T) {
func TestCrossPlatformCoveragePeekTypeEachFrameVariant(t *testing.T) {
cases := []struct {
v any
typ FrameType
@@ -258,6 +297,8 @@ func TestPeekType_EachFrameVariant(t *testing.T) {
{Bye{Type: FrameTypeBye, Reason: "x"}, FrameTypeBye},
{StatusReq{Type: FrameTypeStatusReq}, FrameTypeStatusReq},
{StatusResp{Type: FrameTypeStatusResp}, FrameTypeStatusResp},
{CredentialUpdate{Type: FrameTypeCredentialUpdate}, FrameTypeCredentialUpdate},
{CredentialUpdateAck{Type: FrameTypeCredentialUpdateAck}, FrameTypeCredentialUpdateAck},
}
for _, c := range cases {
b, _ := json.Marshal(c.v)
+178 -2
View File
@@ -16,6 +16,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
@@ -125,6 +126,70 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
return dsl, nil
}
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
tableID, _ := cmd.Flags().GetString("table-id")
tableID = strings.TrimSpace(tableID)
recordIDs, _ := cmd.Flags().GetStringSlice("record-ids")
cleaned := make([]string, 0, len(recordIDs))
seen := make(map[string]struct{}, len(recordIDs))
for _, recordID := range recordIDs {
recordID = strings.TrimSpace(recordID)
if recordID == "" {
continue
}
if _, ok := seen[recordID]; ok {
return apperrors.NewValidation(fmt.Sprintf("--record-ids 不能包含重复值 %q", recordID))
}
seen[recordID] = struct{}{}
cleaned = append(cleaned, recordID)
}
if cmd.Flags().Changed("table-id") && tableID == "" {
return apperrors.NewValidation("--table-id 不能为空")
}
if cmd.Flags().Changed("record-ids") && len(cleaned) == 0 {
return apperrors.NewValidation("--record-ids 必须包含 1 到 5 个非空记录 ID")
}
if len(cleaned) > 5 {
return apperrors.NewValidation(fmt.Sprintf("--record-ids 最多支持 5 个记录 ID,got %d", len(cleaned)))
}
if (tableID != "") != (len(cleaned) > 0) {
return apperrors.NewValidation("--table-id 与 --record-ids 必须同时提供;定时触发工作流则两者都不传")
}
return nil
}
func validateWorkflowHistoryFlags(cmd *cobra.Command, _ []string) error {
if cmd.Flags().Changed("page") {
page, _ := cmd.Flags().GetInt("page")
if page < 0 {
return apperrors.NewValidation(fmt.Sprintf("--page 必须 >= 0,got %d", page))
}
}
if cmd.Flags().Changed("size") {
size, _ := cmd.Flags().GetInt("size")
if size < 1 || size > 100 {
return apperrors.NewValidation(fmt.Sprintf("--size 必须在 [1, 100] 范围内,got %d", size))
}
}
for _, name := range []string{"after-time", "before-time"} {
if !cmd.Flags().Changed(name) {
continue
}
value, _ := cmd.Flags().GetInt(name)
if value < 0 {
return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 >= 0 的 Unix 毫秒时间戳,got %d", name, value))
}
}
if cmd.Flags().Changed("after-time") && cmd.Flags().Changed("before-time") {
afterTime, _ := cmd.Flags().GetInt("after-time")
beforeTime, _ := cmd.Flags().GetInt("before-time")
if afterTime >= beforeTime {
return apperrors.NewValidation(fmt.Sprintf("--after-time 必须小于 --before-time,got %d >= %d", afterTime, beforeTime))
}
}
return nil
}
// recordQueryFetchAll implements --all auto-pagination for record query.
// It prints only a complete result. A page limit, empty/invalid response,
// transport failure, or cursor cycle returns a non-zero structured error whose
@@ -1038,7 +1103,7 @@ func newAitableCommand() *cobra.Command {
dws aitable form [list|delete|update] 表单管理
dws aitable form field [list|update|hide] 表单字段管理
dws aitable form share [get|update|notify] 表单分享管理
dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理
dws aitable workflow [edit-example|create|update|enable|disable|run|history|get|list] 自动化工作流管理
dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理
dws aitable chart [get|create|update|delete|widgets-example] 图表管理
dws aitable export data 数据导出
@@ -4878,7 +4943,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
workflowCmd := &cobra.Command{
Use: "workflow",
Short: "自动化工作流管理(创建 / 更新 / 启停 / 查看 / 列表)",
Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)",
RunE: groupRunE,
}
@@ -5223,6 +5288,116 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
},
})
workflowRunCmd := NewLeafCommand(LeafSpec{
Use: "run",
Short: "执行指定自动化工作流",
Long: `立即执行指定 Base 中的自动化工作流。此命令会启动真实的异步执行,并可能产生该工作流配置的消息发送、记录写入等副作用,因此执行前需要确认;CLI 不自动重试。
记录类触发器必须同时提供 --table-id 与 --record-ids;--table-id 必须与触发器绑定的数据表一致,--record-ids 接受 1 到 5 个不重复记录 ID。定时触发器不传这两个参数。
返回每条记录的提交状态;提交成功项包含 executionId,可用 workflow history 返回项的 instanceId 匹配执行记录。`,
Example: ` dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID --table-id TABLE_ID --record-ids RECORD_ID_1,RECORD_ID_2
dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID`,
Tool: "run_workflow",
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Validate: validateWorkflowRunFlags,
Flags: []LeafFlag{
{Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}},
{Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "workflowId", Trim: true, Required: true},
{Name: "table-id", Usage: "记录类触发器绑定的 Table ID;定时触发器不传", Bind: "tableId", Trim: true, OmitEmpty: true, RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"},
{Name: "record-ids", Usage: "触发工作流的记录 ID,逗号分隔;记录类触发器必填,1 到 5 个且不可重复", Kind: LeafStringSlice, Bind: "recordIds", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"},
},
Constraints: []LeafConstraint{{
Kind: corecmd.Custom,
Flags: []string{"table-id", "record-ids"},
Description: "记录类触发器必须同时提供 --table-id 与 --record-ids;定时触发器两者都不传",
}},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "workflow_run",
CanonicalPath: "aitable.workflow_run",
CLIPath: "aitable workflow run",
PrimaryCLIPath: "aitable workflow run",
},
Description: "立即执行 AI 表格自动化工作流,并返回异步执行提交结果。",
Interface: aitableMCPInterface("run_workflow"),
Selection: contract.SelectionSpec{
AgentSummary: "立即执行已知 AI 表格自动化工作流,并获取 executionId。",
UseWhen: []string{"用户明确要求立即执行已知工作流,已确认真实 base-id、workflow-id、触发类型及可能产生的业务副作用;记录类触发器还需确认绑定的 table-id 和 1 到 5 个真实 record-id"},
AvoidWhen: []string{"仅开启后续自动触发用 workflow enable;查询工作流定义用 workflow get;查询既有执行结果用 workflow history;返回 executionId 后应以 history 的 instanceId 核对,不要在结果不确定时直接重复执行"},
Examples: []string{
"dws aitable workflow run --base-id <BASE_ID> --workflow-id <WORKFLOW_ID> --table-id <TABLE_ID> --record-ids <RECORD_ID>",
"dws aitable workflow run --base-id <BASE_ID> --workflow-id <WORKFLOW_ID>",
},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"},
{Name: "workflow-id", Property: "workflowId", Required: boolPtr(true), InterfaceType: "string"},
{Name: "table-id", Property: "tableId", InterfaceType: "string", RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"},
{Name: "record-ids", Property: "recordIds", InterfaceType: "array", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"},
},
},
})
workflowHistoryCmd := NewLeafCommand(LeafSpec{
Use: "history",
Short: "查询工作流执行历史",
Long: `分页查询指定 AI 表格工作流的执行历史。
可按状态和 Unix 毫秒时间范围筛选;同时提供 --after-time 与 --before-time 时,前者必须小于后者。--page 从 0 开始,--size 默认 20、最大 100。
返回 totalCount 与 list;run 返回的 executionId 可与历史项 instanceId 匹配。`,
Example: ` dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID
dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID --status failed --after-time 1786000000000 --before-time 1787000000000 --page 0 --size 50`,
Tool: "get_flow_record_list",
Safety: aitableSafetyRead(),
Validate: validateWorkflowHistoryFlags,
Flags: []LeafFlag{
{Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}},
{Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "flowId", Trim: true, Required: true},
{Name: "status", Usage: "执行状态筛选;不传表示全部", Bind: "status", Trim: true, OmitEmpty: true, Enum: []string{"success", "failed", "running", "break", "untrigger"}},
{Name: "after-time", Usage: "开始时间(Unix 毫秒)", Kind: LeafInt, Bind: "afterTime"},
{Name: "before-time", Usage: "结束时间(Unix 毫秒)", Kind: LeafInt, Bind: "beforeTime"},
{Name: "page", Usage: "页码,从 0 开始", Kind: LeafInt, Default: "0", Bind: "page"},
{Name: "size", Usage: "每页条数 [1, 100]", Kind: LeafInt, Default: "20", Bind: "size"},
},
Constraints: []LeafConstraint{{
Kind: corecmd.Custom,
Flags: []string{"after-time", "before-time"},
Description: "同时提供 --after-time 与 --before-time 时,--after-time 必须小于 --before-time",
}},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "workflow_history",
CanonicalPath: "aitable.workflow_history",
CLIPath: "aitable workflow history",
PrimaryCLIPath: "aitable workflow history",
},
Description: "分页查询 AI 表格自动化工作流执行历史。",
Interface: aitableMCPInterface("get_flow_record_list"),
Selection: contract.SelectionSpec{
AgentSummary: "按状态、时间和分页条件查询工作流执行历史。",
UseWhen: []string{"需要核对工作流是否执行、执行结果或定位 run 返回的 executionId 时;executionId 与历史项 instanceId 相同,running 为非终态"},
AvoidWhen: []string{"查询工作流定义用 workflow get;列出工作流用 workflow list;立即发起执行用 workflow run"},
Examples: []string{
"dws aitable workflow history --base-id <BASE_ID> --workflow-id <WORKFLOW_ID>",
"dws aitable workflow history --base-id <BASE_ID> --workflow-id <WORKFLOW_ID> --status failed --page 0 --size 50",
},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"},
{Name: "workflow-id", Property: "flowId", Required: boolPtr(true), InterfaceType: "string"},
{Name: "status", Property: "status", InterfaceType: "string", Enum: []string{"success", "failed", "running", "break", "untrigger"}},
{Name: "after-time", Property: "afterTime", InterfaceType: "number"},
{Name: "before-time", Property: "beforeTime", InterfaceType: "number"},
{Name: "page", Property: "page", InterfaceType: "number"},
{Name: "size", Property: "size", InterfaceType: "number"},
},
},
})
// ── dashboard: 仪表盘管理 ────────────────────────────────────
dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}
@@ -7424,6 +7599,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
workflowCmd.AddCommand(
workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd,
workflowEnableCmd, workflowDisableCmd,
workflowRunCmd, workflowHistoryCmd,
workflowGetCmd, workflowListCmd,
)
+162 -9
View File
@@ -12,6 +12,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -40,26 +41,23 @@ func (*aitableWorkflowCaller) JQ() string { return "" }
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
t.Helper()
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
testseam.Protect(t, &os.Args)
caller := &aitableWorkflowCaller{}
InitDeps(caller)
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
cmd := newAitableCommand()
cmd.PersistentFlags().String("format", "json", "output format")
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append([]string{"workflow"}, args...))
if stdin != nil {
cmd.SetIn(stdin)
if stdin == nil {
stdin = strings.NewReader("")
}
cmd.SetIn(stdin)
return caller, cmd.Execute()
}
@@ -197,3 +195,158 @@ func TestAitableWorkflowWriteRejectsInvalidInput(t *testing.T) {
})
}
}
func TestCrossPlatformCoverageAitableWorkflowRunMapsRecordTrigger(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil,
"run",
"--base-id", "base-run",
"--workflow-id", "workflow-run",
"--table-id", "table-run",
"--record-ids", "record-1,record-2",
"--yes",
)
if err != nil {
t.Fatalf("workflow run returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.productID != "aitable" || call.toolName != "run_workflow" {
t.Fatalf("tool call = %s/%s, want aitable/run_workflow", call.productID, call.toolName)
}
wantArgs := map[string]any{
"baseId": "base-run",
"workflowId": "workflow-run",
"tableId": "table-run",
"recordIds": []string{"record-1", "record-2"},
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
func TestCrossPlatformCoverageAitableWorkflowRunMapsScheduledTrigger(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil,
"run", "--base", "base-scheduled", "--workflow-id", "workflow-scheduled", "--yes",
)
if err != nil {
t.Fatalf("scheduled workflow run returned error: %v", err)
}
wantArgs := map[string]any{
"baseId": "base-scheduled",
"workflowId": "workflow-scheduled",
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
t.Fatalf("calls = %#v, want one scheduled invocation %#v", caller.calls, wantArgs)
}
}
func TestCrossPlatformCoverageAitableWorkflowRunRejectsUnsafeOrInvalidInput(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "confirmation", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow"}, want: "用户确认"},
{name: "blank table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", " ", "--yes"}, want: "--table-id 不能为空"},
{name: "blank records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", " , ", "--yes"}, want: "--record-ids 必须包含"},
{name: "table without records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--yes"}, want: "必须同时提供"},
{name: "records without table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", "record", "--yes"}, want: "必须同时提供"},
{name: "duplicate records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "record,record", "--yes"}, want: "不能包含重复值"},
{name: "too many records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "r1,r2,r3,r4,r5,r6", "--yes"}, want: "最多支持 5 个"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) {
t.Fatalf("error = %v, want substring %q", err, tc.want)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid run reached MCP: %#v", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsFilters(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil,
"history",
"--base-id", "base-history",
"--workflow-id", "workflow-history",
"--status", "failed",
"--after-time", "1786000000000",
"--before-time", "1787000000000",
"--page", "2",
"--size", "50",
)
if err != nil {
t.Fatalf("workflow history returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.productID != "aitable" || call.toolName != "get_flow_record_list" {
t.Fatalf("tool call = %s/%s, want aitable/get_flow_record_list", call.productID, call.toolName)
}
wantArgs := map[string]any{
"baseId": "base-history",
"flowId": "workflow-history",
"status": "failed",
"afterTime": 1786000000000,
"beforeTime": 1787000000000,
"page": 2,
"size": 50,
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsSingleTimeFilter(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil,
"history",
"--base-id", "base-history",
"--workflow-id", "workflow-history",
"--after-time", "1786000000000",
)
if err != nil {
t.Fatalf("workflow history returned error: %v", err)
}
wantArgs := map[string]any{
"baseId": "base-history",
"flowId": "workflow-history",
"afterTime": 1786000000000,
"size": 20,
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
t.Fatalf("calls = %#v, want one history invocation %#v", caller.calls, wantArgs)
}
}
func TestCrossPlatformCoverageAitableWorkflowHistoryRejectsInvalidFilters(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "status", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--status", "unknown"}, want: "允许值"},
{name: "negative page", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--page", "-1"}, want: "--page 必须 >= 0"},
{name: "zero size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "0"}, want: "--size 必须在"},
{name: "large size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "101"}, want: "--size 必须在"},
{name: "negative after", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "-1"}, want: "Unix 毫秒时间戳"},
{name: "reversed range", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "200", "--before-time", "100"}, want: "必须小于"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want substring %q", err, tc.want)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid history query reached MCP: %#v", caller.calls)
}
})
}
}
+763 -268
View File
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
// Copyright 2026 Alibaba Group
// 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.
package helpers
import (
"errors"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func runNativeCardUpdate(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
installScriptedCaller(t, caller)
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "skip confirmation")
}
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) {
t.Run("atomic command preserves no-extra-confirmation contract", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-confirm","updated":true}}`}}}
if err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-confirm",
"--content", "原子更新",
"--flow-status", "3",
); err != nil {
t.Fatal(err)
}
wantArgs := map[string]any{
"bizId": "biz-confirm",
"msgContent": "原子更新",
"flowStatus": 3,
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" || !reflect.DeepEqual(caller.args, wantArgs) {
t.Fatalf("atomic call = count:%d server:%q tool:%q args:%#v", caller.calls, caller.server, caller.tool, caller.args)
}
})
t.Run("explicit evidence succeeds", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-1","updated":true}}`}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-1",
"--content", "完成",
"--flow-status", "3",
)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" {
t.Fatalf("call = count:%d server:%q tool:%q", caller.calls, caller.server, caller.tool)
}
if caller.args["bizId"] != "biz-1" {
t.Fatalf("args = %#v", caller.args)
}
})
t.Run("generic success is unverified", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "not-a-real-card",
"--content", "完成",
"--flow-status", "3",
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
}
})
t.Run("lower write error is preserved", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("write unavailable")}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-1",
"--content", "完成",
"--flow-status", "3",
)
if err == nil {
t.Fatal("lower write error was ignored")
}
})
for _, test := range []struct {
name string
response string
wantReason string
}{
{name: "empty response", response: "", wantReason: "streaming_card_update_unverified"},
{name: "invalid response", response: "{", wantReason: "streaming_card_update_response_invalid"},
{name: "not applied", response: `{"result":{"updated":false}}`, wantReason: "streaming_card_update_not_applied"},
{name: "biz id drift", response: `{"result":{"bizId":"biz-other","updated":true}}`, wantReason: "streaming_card_update_biz_id_mismatch"},
} {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: test.response}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-1",
"--content", "完成",
"--flow-status", "3",
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != test.wantReason {
t.Fatalf("error = %#v, want reason %q", err, test.wantReason)
}
})
}
t.Run("invalid arguments make no call", func(t *testing.T) {
for _, args := range [][]string{
{"message", "update-card", "--biz-id", "<bizId>", "--content", "完成", "--flow-status", "3"},
{"message", "update-card", "--biz-id", "biz-1", "--content", "完成", "--flow-status", "6"},
} {
caller := &scriptedToolCaller{}
if err := runNativeCardUpdate(t, caller, args...); err == nil {
t.Fatalf("args %v unexpectedly succeeded", args)
}
if caller.calls != 0 {
t.Fatalf("args %v made %d calls", args, caller.calls)
}
}
})
t.Run("dry run publishes unverified plan without write", func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
"--biz-id", "biz-preview",
"--content", "完成",
"--flow-status", "3",
"--dry-run",
)
if err != nil {
t.Fatal(err)
}
if caller.calls != 0 {
t.Fatalf("dry-run made %d calls", caller.calls)
}
})
}
+1 -1
View File
@@ -25,7 +25,7 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
root.SetArgs(append(append([]string(nil), args...), "--yes"))
return root.ExecuteContext(context.Background())
}
@@ -0,0 +1,267 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type chatMessagePaginationCaller struct {
steps []scriptedToolStep
calls []pagedCommandCall
}
func (c *chatMessagePaginationCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := map[string]any{}
for k, v := range args {
copied[k] = v
}
c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied})
if len(c.steps) == 0 {
return textToolResult(`{"result":{"messages":[],"items":[],"hasMore":false,"nextCursor":"0"}}`), nil
}
step := c.steps[len(c.calls)-1]
if step.err != nil {
return nil, step.err
}
return textToolResult(step.text), nil
}
func (*chatMessagePaginationCaller) Format() string { return "json" }
func (*chatMessagePaginationCaller) DryRun() bool { return false }
func (*chatMessagePaginationCaller) Fields() string { return "" }
func (*chatMessagePaginationCaller) JQ() string { return "" }
func executeChatMessagePaginationCommand(t *testing.T, caller *chatMessagePaginationCaller, args ...string) (map[string]any, error) {
t.Helper()
oldDeps := deps
oldSleep := helperSleep
t.Cleanup(func() {
deps = oldDeps
helperSleep = oldSleep
})
InitDeps(caller)
out := &bytes.Buffer{}
deps.Out.w = out
deps.Out.errW = io.Discard
helperSleep = func(d time.Duration) {}
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
err := root.ExecuteContext(context.Background())
if out.Len() == 0 {
return nil, err
}
var parsed map[string]any
if unmarshalErr := json.Unmarshal(out.Bytes(), &parsed); unmarshalErr != nil {
t.Fatalf("stdout JSON = %q, err = %v", out.String(), unmarshalErr)
}
return parsed, err
}
func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
tests := []struct {
name string
args []string
server string
tool string
want map[string]any
}{
{
name: "list-all",
args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"},
server: "",
tool: "search_messages_by_time_range",
want: map[string]any{"startTime": "2026-08-01 00:00:00", "endTime": "2026-08-02 00:00:00", "limit": 50, "cursor": "0"},
},
{
name: "list-by-sender",
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "",
tool: "search_messages_by_sender",
want: map[string]any{"senderUserId": "u1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"},
},
{
name: "list-mentions",
args: []string{"message", "list-mentions", "--group", "cid1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "",
tool: "search_at_me_message",
want: map[string]any{"openConversationId": "cid1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"},
},
{
name: "list-focused",
args: []string{"message", "list-focused"},
server: "",
tool: "list_special_focus_messages",
want: map[string]any{"limit": 50},
},
{
name: "search",
args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "",
tool: "search_messages_by_keyword",
want: map[string]any{"keyword": "发布", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
},
{
name: "search-advanced",
args: []string{"message", "search-advanced", "--query", "周报", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "im",
tool: "search_messages",
want: map[string]any{"keyword": "周报", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
},
{
name: "list-favorites",
args: []string{"message", "list-favorites"},
server: "im",
tool: "list_message_favorites",
want: map[string]any{"cursor": int64(0), "size": "20"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{}
args := append([]string{}, tt.args...)
args = append(args, "--page-limit", "2", "--max-items", "1", "--page-delay", "0")
_, err := executeChatMessagePaginationCommand(t, caller, args...)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one fallback call", caller.calls)
}
got := caller.calls[0]
if got.server != tt.server || got.tool != tt.tool || !argsEqual(got.args, tt.want) {
t.Fatalf("call = %#v, want server=%s tool=%s args=%#v", got, tt.server, tt.tool, tt.want)
}
})
}
}
func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
tests := []struct {
name string
args []string
server string
tool string
itemPath string
cursorOne any
cursorTwo any
pageOne string
pageTwo string
}{
{
name: "list-all", args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"},
server: "chat", tool: "search_messages_by_time_range", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "list-by-sender", args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "chat", tool: "search_messages_by_sender", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "list-mentions", args: []string{"message", "list-mentions", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "chat", tool: "search_at_me_message", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "list-focused", args: []string{"message", "list-focused"},
server: "chat", tool: "list_special_focus_messages", itemPath: "messages", cursorOne: nil, cursorTwo: int64(2),
pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":2}}`,
pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":0}}`,
},
{
name: "search", args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
server: "chat", tool: "search_messages_by_keyword", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "search-advanced", args: []string{"message", "search-advanced", "--query", "周报"},
server: "im", tool: "search_messages", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
},
{
name: "list-favorites", args: []string{"message", "list-favorites"},
server: "im", tool: "list_message_favorites", itemPath: "items", cursorOne: int64(0), cursorTwo: int64(20),
pageOne: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`,
pageTwo: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessagePaginationCaller{steps: []scriptedToolStep{{text: tt.pageOne}, {text: tt.pageTwo}}}
args := append([]string{}, tt.args...)
args = append(args, "--page-all", "--page-delay", "0")
got, err := executeChatMessagePaginationCommand(t, caller, args...)
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)[tt.itemPath].([]any)
if tt.itemPath == "conversationMessagesList" {
messages := items[0].(map[string]any)["messages"].([]any)
if len(items) != 1 || len(messages) != 2 {
t.Fatalf("conversation items = %#v", items)
}
} else if len(items) != 2 {
t.Fatalf("items = %#v", items)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %#v, want two pages", caller.calls)
}
if caller.calls[0].server != tt.server || caller.calls[0].tool != tt.tool {
t.Fatalf("first call = %#v", caller.calls[0])
}
if !reflect.DeepEqual(caller.calls[0].args["cursor"], tt.cursorOne) {
t.Fatalf("first cursor = %#v, want %#v", caller.calls[0].args["cursor"], tt.cursorOne)
}
if !reflect.DeepEqual(caller.calls[1].args["cursor"], tt.cursorTwo) {
t.Fatalf("second cursor = %#v, want %#v", caller.calls[1].args["cursor"], tt.cursorTwo)
}
paging := got["paging"].(map[string]any)
if paging["pages"].(float64) != 2 || paging["total"].(float64) != 2 || paging["truncated"] != false {
t.Fatalf("paging = %#v", paging)
}
})
}
}
func argsEqual(got, want map[string]any) bool {
if len(got) != len(want) {
return false
}
for key, wantValue := range want {
gotValue, ok := got[key]
if !ok {
return false
}
switch w := wantValue.(type) {
case float64:
g, ok := gotValue.(int64)
if !ok || float64(g) != w {
return false
}
default:
if !reflect.DeepEqual(gotValue, wantValue) {
return false
}
}
}
return true
}
+565 -21
View File
@@ -16,6 +16,7 @@ package helpers
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"reflect"
@@ -23,8 +24,10 @@ import (
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type chatMessageSearchCall struct {
@@ -34,12 +37,41 @@ type chatMessageSearchCall struct {
}
type chatMessageSearchCaller struct {
calls []chatMessageSearchCall
calls []chatMessageSearchCall
searchResponse string
searchResponses []string
searchCalls int
searchError error
failPreflight bool
preflightError error
}
func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
text := `{}`
if toolName == "get_conversation_info" {
if c.preflightError != nil {
return nil, c.preflightError
}
if c.failPreflight {
return nil, errors.New("conversation not found")
}
text = `{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`
}
if toolName == "search_messages_by_keyword" || toolName == "search_messages" {
if c.searchError != nil {
c.searchCalls++
return nil, c.searchError
}
text = `{"result":{"messages":[],"hasMore":false}}`
if c.searchCalls < len(c.searchResponses) {
text = c.searchResponses[c.searchCalls]
} else if c.searchResponse != "" {
text = c.searchResponse
}
c.searchCalls++
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (*chatMessageSearchCaller) Format() string { return "json" }
@@ -72,6 +104,7 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
productID string
toolName string
wantToolArg map[string]any
preflight []string
}{
{
name: "keyword search",
@@ -79,13 +112,13 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
productID: "chat",
toolName: "search_messages_by_keyword",
wantToolArg: map[string]any{
"keyword": "categoryName",
"openConversationId": "cid-1",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
"keyword": "categoryName",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
},
preflight: []string{"cid-1"},
},
{
name: "advanced search",
@@ -93,16 +126,16 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
productID: "im",
toolName: "search_messages",
wantToolArg: map[string]any{
"keyword": "categoryName",
"openConversationIds": []string{"cid-1", "cid-2"},
"messageType": "text",
"onlyRobotMessages": true,
"searchConvType": "group",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
"keyword": "categoryName",
"messageType": "text",
"onlyRobotMessages": true,
"searchConvType": "group",
"startTime": startTime.UnixMilli(),
"endTime": endTime.UnixMilli(),
"limit": 100,
"cursor": "0",
},
preflight: []string{"cid-1", "cid-2"},
},
}
@@ -116,14 +149,21 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
cmd := newChatCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(io.Discard)
cmd.SetArgs(tt.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("chat search returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
if len(caller.calls) != len(tt.preflight)+1 {
t.Fatalf("tool calls = %#v", caller.calls)
}
call := caller.calls[0]
for index, conversationID := range tt.preflight {
call := caller.calls[index]
if call.productID != "chat" || call.toolName != "get_conversation_info" || call.args["openConversationId"] != conversationID {
t.Fatalf("preflight[%d] = %#v", index, call)
}
}
call := caller.calls[len(caller.calls)-1]
if call.productID != tt.productID || call.toolName != tt.toolName {
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName)
}
@@ -134,6 +174,507 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
}
}
func executeNativeScopedSearch(t *testing.T, caller *chatMessageSearchCaller, args ...string) (map[string]any, error) {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := newChatCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
var output strings.Builder
cmd.SetOut(&output)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
return nil, err
}
return payload, nil
}
func TestNativeScopedSearchFiltersGlobalResultsForBothEntries(t *testing.T) {
start := "2026-07-09T00:00:00+08:00"
end := "2026-07-11T00:00:00+08:00"
for _, tt := range []struct {
name string
args []string
tool string
scopeParam string
}{
{
name: "keyword search",
args: []string{"message", "search", "--query", "周报", "--group", "cid-target", "--start", start, "--end", end},
tool: "search_messages_by_keyword",
scopeParam: "openConversationId",
},
{
name: "advanced search",
args: []string{"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target"},
tool: "search_messages",
scopeParam: "openConversationIds",
},
} {
t.Run(tt.name, func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","title":"目标群","messages":[{"openMessageId":"m-target","content":"目标"}]},
{"openConversationId":"cid-other","title":"其他群","messages":[{"openMessageId":"m-other","content":"越界"}]}
],
"hasMore": false
}
}`}
payload, err := executeNativeScopedSearch(t, caller, tt.args...)
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
groups, _ := result["conversationMessagesList"].([]any)
if len(groups) != 1 {
t.Fatalf("result = %#v", result)
}
group, _ := groups[0].(map[string]any)
if group["openConversationId"] != "cid-target" {
t.Fatalf("group = %#v", group)
}
scope, _ := payload["scope"].(map[string]any)
if scope["targetsValidated"] != true || scope["resultsWithinScope"] != true || scope["filterMode"] != "client" {
t.Fatalf("scope = %#v", scope)
}
searchCall := caller.calls[len(caller.calls)-1]
if searchCall.toolName != tt.tool {
t.Fatalf("search call = %#v", searchCall)
}
if _, exists := searchCall.args[tt.scopeParam]; exists {
t.Fatalf("global fallback unexpectedly forwarded %s: %#v", tt.scopeParam, searchCall.args)
}
})
}
}
func TestNativeScopedSearchInvalidCIDStopsBeforeSearch(t *testing.T) {
caller := &chatMessageSearchCaller{failPreflight: true}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-invalid")
if err == nil {
t.Fatal("invalid CID unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_invalid" {
t.Fatalf("error = %#v", err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestNativeScopedSearchPreservesPreflightAuthError(t *testing.T) {
want := &CLIError{Code: CodeAuthNotConfigured, Message: "当前未登录"}
caller := &chatMessageSearchCaller{preflightError: want}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err == nil {
t.Fatal("auth failure unexpectedly succeeded")
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthNotConfigured {
t.Fatalf("error = %#v", err)
}
}
func TestCrossPlatformCoverageNativeScopedSearchPreservesAmbiguousMCPToolErrors(t *testing.T) {
for _, test := range []struct {
name string
want *CLIError
}{
{
name: "rate limited",
want: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`,
},
},
{
name: "permission denied",
want: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
},
},
} {
t.Run(test.name, func(t *testing.T) {
caller := &chatMessageSearchCaller{preflightError: test.want}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err != test.want {
t.Fatalf("error = %#v, want original %#v", err, test.want)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
t.Fatalf("calls = %#v", caller.calls)
}
})
}
}
func TestNativeScopedSearchScansUntilTargetConversationAppears(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponses: []string{
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-other","messages":[{"openMessageId":"m-other"}]}],"hasMore":true,"nextCursor":"c2"}}`,
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-target","messages":[{"openMessageId":"m-target"}]}],"hasMore":false}}`,
}}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
if result["pagesFetched"] != float64(2) || result["complete"] != true {
t.Fatalf("result = %#v", result)
}
groups, _ := result["conversationMessagesList"].([]any)
if len(groups) != 1 {
t.Fatalf("groups = %#v", groups)
}
searchCalls := make([]chatMessageSearchCall, 0, 2)
for _, call := range caller.calls {
if call.toolName == "search_messages" {
searchCalls = append(searchCalls, call)
}
}
if len(searchCalls) != 2 || searchCalls[1].args["cursor"] != "c2" {
t.Fatalf("search calls = %#v", searchCalls)
}
}
func TestCrossPlatformCoverageNativeScopedSearchPageAllOptions(t *testing.T) {
t.Run("page limit preserves continuation", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","messages":[{"openMessageId":"m1"}]}
],
"hasMore": true,
"nextCursor": "c2"
}
}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
"--page-all", "--page-limit", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
paging, _ := payload["paging"].(map[string]any)
if paging["pages"] != float64(1) || paging["total"] != float64(1) || paging["truncated"] != true {
t.Fatalf("paging = %#v", paging)
}
if caller.searchCalls != 1 {
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
}
})
t.Run("max items truncates within filtered page", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"conversationMessagesList": [
{"openConversationId":"cid-target","messages":[
{"openMessageId":"m1"},
{"openMessageId":"m2"}
]}
],
"hasMore": false
}
}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
"--page-all", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
groups, _ := result["conversationMessagesList"].([]any)
group, _ := groups[0].(map[string]any)
messages, _ := group["messages"].([]any)
if len(messages) != 1 {
t.Fatalf("messages = %#v", messages)
}
paging, _ := payload["paging"].(map[string]any)
if paging["total"] != float64(1) || paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
t.Fatalf("paging = %#v", paging)
}
})
}
func TestNativeScopedSearchMissingConversationIdentityFailsClosed(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":false}}`}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err == nil {
t.Fatal("unverifiable scoped result unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" {
t.Fatalf("error = %#v", err)
}
}
func TestNativeScopedSearchValidEmptyResultIsComplete(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-empty")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
if result["complete"] != true || result["hasMore"] != false {
t.Fatalf("result = %#v", result)
}
scope, _ := payload["scope"].(map[string]any)
if scope["targetsValidated"] != true || scope["sourceComplete"] != true {
t.Fatalf("scope = %#v", scope)
}
}
func TestCrossPlatformCoverageNativeScopedSearchFailureAndPaginationBranches(t *testing.T) {
t.Run("empty scope uses the native search call", func(t *testing.T) {
caller := &chatMessageSearchCaller{}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := &cobra.Command{Use: "search"}
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{"keyword": "周报"},
[]string{"", " "},
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "search_messages" {
t.Fatalf("calls = %#v", caller.calls)
}
})
t.Run("invalid page options fail before preflight", func(t *testing.T) {
caller := &chatMessageSearchCaller{}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
cmd := &cobra.Command{Use: "search"}
AddPagedMCPFlags(cmd)
if err := cmd.Flags().Set("page-all", "true"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("page-limit", "0"); err != nil {
t.Fatal(err)
}
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{"keyword": "周报"},
[]string{"cid-target"},
)
if err == nil || !strings.Contains(err.Error(), "--page-limit must be between 1 and 500") {
t.Fatalf("error = %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid paging made calls: %#v", caller.calls)
}
})
t.Run("cancelled context interrupts page delay", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
"hasMore": true,
"nextCursor": "c2"
}
}`}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
cmd := &cobra.Command{Use: "search"}
AddPagedMCPFlags(cmd)
if err := cmd.Flags().Set("page-all", "true"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("page-delay", "60000"); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd.SetContext(ctx)
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{"keyword": "周报", "limit": 100, "cursor": "0"},
[]string{"cid-target"},
)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context canceled", err)
}
if caller.searchCalls != 1 {
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
}
})
t.Run("lower search error", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchError: errors.New("search unavailable")}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err == nil {
t.Fatal("lower search error was ignored")
}
})
t.Run("stalled cursor fails closed", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
"hasMore": true
}
}`}
_, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_cursor_stalled" {
t.Fatalf("error = %#v", err)
}
})
t.Run("result limit preserves continuation", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponse: `{
"result": {
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
"hasMore": true,
"nextCursor": "c2"
}
}`}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target", "--limit", "1")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
if result["complete"] != false || result["hasMore"] != true || result["nextCursor"] != "c2" {
t.Fatalf("result = %#v", result)
}
})
t.Run("duplicate message ids are removed across pages", func(t *testing.T) {
caller := &chatMessageSearchCaller{searchResponses: []string{
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"}],"hasMore":true,"nextCursor":"c2"}}`,
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"},{"openMessageId":"m2","openConversationId":"cid-target"}],"hasMore":false}}`,
}}
payload, err := executeNativeScopedSearch(t, caller,
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
if err != nil {
t.Fatal(err)
}
result, _ := payload["result"].(map[string]any)
groups, _ := result["conversationMessagesList"].([]any)
group, _ := groups[0].(map[string]any)
messages, _ := group["messages"].([]any)
if len(messages) != 2 {
t.Fatalf("deduplicated messages = %#v", messages)
}
})
if got := uniqueNonEmptyStrings([]string{" cid ", "", "cid"}); !reflect.DeepEqual(got, []string{"cid"}) {
t.Fatalf("uniqueNonEmptyStrings = %#v", got)
}
for _, test := range []struct {
value any
want int
}{
{value: int64(7), want: 7},
{value: json.Number("8"), want: 8},
{value: float64(9), want: 9},
{value: int64(0), want: 11},
} {
if got := positiveSearchLimit(test.value, 11); got != test.want {
t.Errorf("positiveSearchLimit(%#v) = %d, want %d", test.value, got, test.want)
}
}
if cleanSearchCursor(nil) != "" || cleanSearchCursor(" null ") != "" || cleanSearchCursor(" c2 ") != "c2" {
t.Fatal("cleanSearchCursor did not normalize sentinel values")
}
}
func TestCrossPlatformCoverageNativeScopedSearchDryRunShowsCompositePlanWithoutCallingTools(t *testing.T) {
caller := &chatMessageSearchCaller{}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
cmd := &cobra.Command{Use: "search"}
cmd.Flags().Bool("dry-run", true, "")
AddPagedMCPFlags(cmd)
for name, value := range map[string]string{
"page-all": "true",
"page-limit": "7",
"max-items": "9",
"page-delay": "11",
} {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
var output strings.Builder
cmd.SetOut(&output)
err := runConversationScopedMessageSearch(
cmd,
"im",
"search_messages",
"openConversationIds",
map[string]any{
"keyword": "周报",
"openConversationIds": []string{"cid-target"},
"limit": 100,
"cursor": "0",
},
[]string{"cid-target"},
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run made calls: %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
t.Fatal(err)
}
plan, _ := payload["plan"].([]any)
if payload["dry_run"] != true || payload["executed"] != false || len(plan) != 3 {
t.Fatalf("payload = %#v", payload)
}
searchStage, _ := plan[1].(map[string]any)
arguments, _ := searchStage["arguments"].(map[string]any)
if _, exists := arguments["openConversationIds"]; exists {
t.Fatalf("dry-run global search still carries scope: %#v", searchStage)
}
if searchStage["pageAll"] != true ||
searchStage["pageLimit"] != float64(7) ||
searchStage["maxItems"] != float64(9) ||
searchStage["pageDelay"] != float64(11) {
t.Fatalf("dry-run paging = %#v", searchStage)
}
}
type chatChangedContractCaller struct {
calls []chatMessageSearchCall
resolveUsers bool
@@ -160,9 +701,12 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
InitDeps(caller)
deps.Out.w = io.Discard
cmd := newChatCommand()
if cmd.PersistentFlags().Lookup("yes") == nil {
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
}
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
cmd.SetArgs(append(append([]string(nil), args...), "--yes"))
return cmd.Execute()
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 Alibaba Group
// 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.
package helpers
import (
"encoding/json"
"errors"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// NormalizeSearchConversationScopeError maps only errors that prove the
// requested conversation does not exist or that openConversationId itself is
// invalid. Unknown MCP tool failures must remain unchanged: the legacy
// CodeMCPToolError bucket also carries permission, throttling, and transient
// backend failures, none of which proves that the caller supplied a bad CID.
func NormalizeSearchConversationScopeError(conversationID string, err error) error {
if err == nil || !isDefinitiveInvalidSearchConversationError(err) {
return err
}
return apperrors.NewValidation(
fmt.Sprintf("无法验证会话 CID %q;已停止搜索,避免过滤失效后返回其他会话消息", conversationID),
apperrors.WithReason("search_conversation_scope_invalid"),
apperrors.WithDetails(map[string]any{"conversationId": conversationID}),
apperrors.WithRetryable(false),
apperrors.WithHint("确认 openConversationId 存在且当前账号可访问后重试"),
apperrors.WithCause(err),
)
}
func isDefinitiveInvalidSearchConversationError(err error) bool {
var cliErr *CLIError
if errors.As(err, &cliErr) {
switch cliErr.Code {
case CodeResourceNotFound, CodeInvalidParam:
return true
}
code, message := searchConversationErrorFacts(cliErr.Message)
if isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message) {
return true
}
}
// Transport errors can survive below a legacy CLIError in the cause chain.
// Inspect their structured diagnostics, but require conversation-specific
// evidence before treating a generic PARAM_ERROR as an invalid CID.
var appErr *apperrors.Error
if errors.As(err, &appErr) {
code := strings.TrimSpace(appErr.ServerDiag.ServerErrorCode)
message := strings.Join([]string{
appErr.Message,
appErr.ServerDiag.TechnicalDetail,
appErr.Reason,
appErr.FailureStage,
}, " ")
return isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message)
}
return false
}
func searchConversationErrorFacts(raw string) (string, string) {
var body map[string]any
if json.Unmarshal([]byte(raw), &body) != nil {
return "", raw
}
code := firstSearchConversationErrorString(body, "errorCode", "error_code", "code")
message := firstSearchConversationErrorString(body, "errorMsg", "error_msg", "message", "error")
return code, message
}
func firstSearchConversationErrorString(body map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := body[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func isExplicitInvalidConversationCode(code string) bool {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "INVALID_OPEN_CONVERSATION_ID", "OPEN_CONVERSATION_NOT_FOUND", "CONVERSATION_NOT_FOUND":
return true
default:
return false
}
}
func isConversationParameterError(code, message string) bool {
normalizedCode := strings.ToUpper(strings.TrimSpace(code))
if normalizedCode != "PARAM_ERROR" && normalizedCode != "PARAMETER_ERROR" && normalizedCode != "INVALID_ARGUMENT" {
return false
}
normalizedMessage := strings.ToLower(strings.TrimSpace(message))
mentionsConversationID := strings.Contains(normalizedMessage, "openconversationid") ||
strings.Contains(normalizedMessage, "open conversation id") ||
strings.Contains(normalizedMessage, "conversation id") ||
strings.Contains(normalizedMessage, "cid")
if !mentionsConversationID {
return false
}
for _, marker := range []string{
"invalid", "illegal", "malformed", "required", "missing", "not found", "不存在", "无效", "非法", "缺少", "必填",
} {
if strings.Contains(normalizedMessage, marker) {
return true
}
}
return false
}
@@ -0,0 +1,123 @@
// Copyright 2026 Alibaba Group
// 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.
package helpers
import (
"errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestCrossPlatformCoverageNormalizeSearchConversationScopeError(t *testing.T) {
if got := NormalizeSearchConversationScopeError("cid", nil); got != nil {
t.Fatalf("nil error normalized to %#v", got)
}
invalidCases := []struct {
name string
err error
}{
{
name: "classified resource not found",
err: &CLIError{Code: CodeResourceNotFound, Message: "conversation not found"},
},
{
name: "explicit conversation error code",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"INVALID_OPEN_CONVERSATION_ID","errorMsg":"invalid conversation"}`,
},
},
{
name: "conversation specific parameter error",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId is invalid"}`,
},
},
{
name: "transport diagnostic proves invalid conversation",
err: apperrors.NewAPI(
"conversation validation failed",
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
ServerErrorCode: "INVALID_OPEN_CONVERSATION_ID",
}),
),
},
}
for _, test := range invalidCases {
t.Run(test.name, func(t *testing.T) {
got := NormalizeSearchConversationScopeError("cid-invalid", test.err)
var typed *apperrors.Error
if !errors.As(got, &typed) || typed.Reason != "search_conversation_scope_invalid" {
t.Fatalf("normalized error = %#v", got)
}
if !typed.RetryableSet || typed.Retryable {
t.Fatalf("retryable = (%t, set=%t), want false and set", typed.Retryable, typed.RetryableSet)
}
if typed.Details["conversationId"] != "cid-invalid" || !errors.Is(got, test.err) {
t.Fatalf("normalized error lost details or cause: %#v", typed)
}
})
}
preservedCases := []struct {
name string
err error
}{
{
name: "rate limit",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down","retryable":true}`,
},
},
{
name: "permission denied",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
},
},
{
name: "generic parameter error without CID evidence",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"未找到指定工具"}`,
},
},
{
name: "structured error without recognized facts",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"retryable":true}`,
},
},
{
name: "parameter error mentions CID without invalid evidence",
err: &CLIError{
Code: CodeMCPToolError,
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId could not be processed"}`,
},
},
}
for _, test := range preservedCases {
t.Run(test.name, func(t *testing.T) {
if got := NormalizeSearchConversationScopeError("cid-target", test.err); got != test.err {
t.Fatalf("error = %#v, want original %#v", got, test.err)
}
})
}
}
+2 -2
View File
@@ -101,7 +101,7 @@ func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) {
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "location",
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1")
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1", "--yes")
if err != nil {
t.Fatal(err)
}
@@ -128,7 +128,7 @@ func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) {
caller = &guardedMutationCaller{}
err = executeGuardedMutationCommand(t, caller, newChatCommand,
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123")
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123", "--yes")
if err != nil {
t.Fatal(err)
}
+4 -4
View File
@@ -94,7 +94,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理",
},
AvoidWhen: []string{
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
"个人 IM/OA 实时事件监听使用 event;开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
},
},
})
@@ -293,7 +293,7 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "列出或搜索应用可订阅的事件",
UseWhen: []string{"需要查事件码、事件名称或当前事件列表时"},
AvoidWhen: []string{"订阅或退订事件使用对应写命令"},
AvoidWhen: []string{"订阅或退订应用回调事件使用对应写命令;监听当前用户个人 IM/OA 事件使用 event"},
Examples: []string{`dws dev app event list --unified-app-id <unifiedAppId> --keyword "审批" --page-size 20`},
},
},
@@ -329,7 +329,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "为应用订阅指定事件码",
UseWhen: []string{"已确认事件码并需要新增事件订阅时"},
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list"},
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list;个人 IM/OA 事件长连接监听使用 event"},
Examples: []string{"dws dev app event subscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
},
},
@@ -365,7 +365,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "取消应用的指定事件订阅",
UseWhen: []string{"需要停止接收一个或多个已订阅事件时"},
AvoidWhen: []string{"只是查看事件订阅时使用 dev app event list"},
AvoidWhen: []string{"只是查看应用事件订阅时使用 dev app event list;停止个人事件监听使用 event stop"},
Examples: []string{"dws dev app event unsubscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
},
},
+33 -5
View File
@@ -3668,6 +3668,17 @@ CLI 内部自动完成全部流程:
}
if deps.Caller.DryRun() {
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "doc_export",
"nodeId": node,
"exportFormat": format,
"savedPath": outputPath,
})
}
deps.Out.PrintKeyValue("操作", "导出文档(提交+轮询+下载)")
deps.Out.PrintKeyValue("文档", node)
deps.Out.PrintKeyValue("输出", outputPath)
@@ -3678,7 +3689,7 @@ CLI 内部自动完成全部流程:
ctx := context.Background()
// ── Step 1: 提交导出任务 ──
deps.Out.PrintInfo("[1/3] 提交导出任务...")
printJSONSafeInfo("[1/3] 提交导出任务...")
submitText, err := callMCPToolReturnText(ctx, "submit_export_job", submitArgs)
if err != nil {
return fmt.Errorf("提交导出任务失败: %w", err)
@@ -3693,10 +3704,10 @@ CLI 内部自动完成全部流程:
deps.Out.PrintRaw(submitText)
return fmt.Errorf("提交导出任务成功但未返回 jobId")
}
deps.Out.PrintInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID))
printJSONSafeInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID))
// ── Step 2: 渐进式退避轮询 ──
deps.Out.PrintInfo("[2/3] 等待导出完成...")
printJSONSafeInfo("[2/3] 等待导出完成...")
downloadURL, err := pollDocExportJob(ctx, jobID)
if err != nil {
return err
@@ -3715,11 +3726,28 @@ CLI 内部自动完成全部流程:
outputPath = filepath.Join(outputPath, filename)
}
deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath))
printJSONSafeInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath))
if err := httpGetFile(ctx, downloadURL, nil, outputPath); err != nil {
return fmt.Errorf("文件下载失败 (jobId=%s): %w", jobID, err)
}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
info, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("读取导出产物信息失败 (jobId=%s): %w", jobID, err)
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"nodeId": node,
"exportFormat": format,
"jobId": jobID,
"taskId": jobID,
"status": "SUCCESS",
"savedPath": outputPath,
"sizeBytes": info.Size(),
})
}
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
return nil
},
@@ -4657,7 +4685,7 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er
for attempt := 1; attempt <= maxPolls; attempt++ {
interval := pollInterval(attempt)
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
printJSONSafeInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
select {
case <-ctx.Done():
+53 -6
View File
@@ -619,10 +619,20 @@ func newDriveCommand() *cobra.Command {
return err
}
dlOpts.logf = func(format string, a ...any) {
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
printJSONSafeInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "drive_download",
"nodeId": fileID,
"savedPath": outputPath,
})
}
deps.Out.PrintKeyValue("操作", "下载钉盘文件")
deps.Out.PrintKeyValue("文件ID", fileID)
deps.Out.PrintKeyValue("输出", outputPath)
@@ -632,7 +642,7 @@ func newDriveCommand() *cobra.Command {
ctx := cmd.Context()
// Step 1: 获取下载 URL 和签名请求头
deps.Out.PrintInfo("[1/2] 获取下载链接...")
printJSONSafeInfo("[1/2] 获取下载链接...")
text, err := callMCPToolReturnText(ctx, "download_file", argsMap)
if err != nil {
return err
@@ -654,7 +664,7 @@ func newDriveCommand() *cobra.Command {
}
// Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试)
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = parseDownloadFileVersion(text)
@@ -684,6 +694,19 @@ func newDriveCommand() *cobra.Command {
return err
}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
info, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("读取下载产物信息失败: %w", err)
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"nodeId": fileID,
"version": dlOpts.version,
"savedPath": outputPath,
"sizeBytes": info.Size(),
})
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
return nil
},
@@ -772,10 +795,21 @@ func newDriveCommand() *cobra.Command {
return err
}
dlOpts.logf = func(format string, a ...any) {
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
printJSONSafeInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "drive_download_version",
"nodeId": fileID,
"version": versionNum,
"savedPath": outputPath,
})
}
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
deps.Out.PrintKeyValue("节点ID", fileID)
deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum))
@@ -784,7 +818,7 @@ func newDriveCommand() *cobra.Command {
}
ctx := cmd.Context()
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
printJSONSafeInfo("[1/2] 获取历史版本下载链接...")
dlArgsMap := map[string]any{
"nodeId": fileID,
"version": versionNum,
@@ -804,7 +838,7 @@ func newDriveCommand() *cobra.Command {
}
outputPath = filepath.Join(outputPath, filename)
}
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = versionNum
@@ -833,6 +867,19 @@ func newDriveCommand() *cobra.Command {
}
return err
}
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
info, err := os.Stat(outputPath)
if err != nil {
return fmt.Errorf("读取下载产物信息失败: %w", err)
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"nodeId": fileID,
"version": versionNum,
"savedPath": outputPath,
"sizeBytes": info.Size(),
})
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
return nil
},
@@ -0,0 +1,198 @@
// Copyright 2026 Alibaba Group
// 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.
package helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func executeJSONOutputContractCommand(t *testing.T, caller *scriptedToolCaller, build func() *cobra.Command, args ...string) (string, string, error) {
t.Helper()
testseam.Protect(t, &deps)
testseam.Protect(t, &os.Args)
InitDeps(caller)
var stdout, stderr bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = &stderr
root := build()
installExampleGlobalFlags(root)
os.Args = append([]string{"dws", root.Name()}, args...)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
if err := root.Execute(); err != nil {
return stdout.String(), stderr.String(), err
}
return stdout.String(), stderr.String(), nil
}
func assertJSONOutputPayload(t *testing.T, stdout string) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
}
return payload
}
func TestCrossPlatformCoverageJSONOutputContractForCompletedFileTransfers(t *testing.T) {
testseam.Swap(t, &httpGetFile, func(_ context.Context, _ string, _ map[string]string, destination string) error {
return os.WriteFile(destination, []byte("payload"), 0o600)
})
t.Run("drive latest download", func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "latest.txt")
stdout, stderr, err := executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}}},
newDriveCommand,
"download", "--node", "node-latest", "--output", outputPath)
if err != nil {
t.Fatal(err)
}
payload := assertJSONOutputPayload(t, stdout)
if payload["nodeId"] != "node-latest" || payload["savedPath"] != outputPath || payload["sizeBytes"] != float64(7) || payload["version"] != float64(9) {
t.Fatalf("payload = %#v", payload)
}
if !strings.Contains(stderr, "下载完成") && !strings.Contains(stderr, "下载文件到") {
t.Fatalf("expected progress on stderr, got %q", stderr)
}
})
t.Run("drive historical download through compatibility flag", func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "versioned.txt")
stdout, _, err := executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}}},
newDriveCommand,
"download", "--node", "node-versioned", "--version", "4", "--output", outputPath)
if err != nil {
t.Fatal(err)
}
payload := assertJSONOutputPayload(t, stdout)
if payload["nodeId"] != "node-versioned" || payload["version"] != float64(4) || payload["sizeBytes"] != float64(7) {
t.Fatalf("payload = %#v", payload)
}
})
t.Run("doc export", func(t *testing.T) {
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
})
outputPath := filepath.Join(t.TempDir(), "export.md")
stdout, stderr, err := executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"jobId":"export-job-1"}`},
{text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`},
}},
newDocCommand,
"export", "--node", "doc-node", "--export-format", "markdown", "--output", outputPath)
if err != nil {
t.Fatal(err)
}
payload := assertJSONOutputPayload(t, stdout)
if payload["nodeId"] != "doc-node" || payload["exportFormat"] != "markdown" || payload["jobId"] != "export-job-1" || payload["taskId"] != "export-job-1" || payload["status"] != "SUCCESS" || payload["sizeBytes"] != float64(7) {
t.Fatalf("payload = %#v", payload)
}
if !strings.Contains(stderr, "提交导出任务") {
t.Fatalf("expected export progress on stderr, got %q", stderr)
}
})
}
func TestCrossPlatformCoverageJSONOutputContractDryRunIsMachineReadable(t *testing.T) {
stdout, _, err := executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", dry: true},
newDriveCommand,
"download", "--node", "node-dry-run", "--output", filepath.Join(t.TempDir(), "out.txt"), "--dry-run")
if err != nil {
t.Fatal(err)
}
payload := assertJSONOutputPayload(t, stdout)
if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "node-dry-run" {
t.Fatalf("payload = %#v", payload)
}
stdout, _, err = executeJSONOutputContractCommand(t,
&scriptedToolCaller{format: "json", dry: true},
newDocCommand,
"export", "--node", "doc-dry-run", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md"), "--dry-run")
if err != nil {
t.Fatal(err)
}
payload = assertJSONOutputPayload(t, stdout)
if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "doc-dry-run" || payload["operation"] != "doc_export" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageJSONOutputContractReportsMissingLocalArtifact(t *testing.T) {
testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error {
return nil
})
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
})
tests := []struct {
name string
build func() *cobra.Command
args []string
steps []scriptedToolStep
}{
{
name: "latest drive download",
build: newDriveCommand,
args: []string{"download", "--node", "node-latest", "--output", filepath.Join(t.TempDir(), "latest.txt")},
steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}},
},
{
name: "versioned drive download",
build: newDriveCommand,
args: []string{"download", "--node", "node-versioned", "--version", "4", "--output", filepath.Join(t.TempDir(), "versioned.txt")},
steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}},
},
{
name: "doc export",
build: newDocCommand,
args: []string{"export", "--node", "doc-node", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md")},
steps: []scriptedToolStep{
{text: `{"jobId":"export-job-1"}`},
{text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := executeJSONOutputContractCommand(t, &scriptedToolCaller{format: "json", steps: tt.steps}, tt.build, tt.args...)
if err == nil || !strings.Contains(err.Error(), "读取") {
t.Fatalf("expected missing local artifact error, got %v", err)
}
})
}
}
+7 -6
View File
@@ -1346,7 +1346,7 @@ func newMinutesCommand() *cobra.Command {
Short: "批量删除个人热词",
Long: `批量删除听记个人热词。
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
Example: ` dws minutes hot-word delete --words "钉钉"
Example: ` dws minutes hot-word delete --words "天气"
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "words"); err != nil {
@@ -1384,7 +1384,7 @@ func newMinutesCommand() *cobra.Command {
"不确定现有热词时先用 hot-word list",
},
Examples: []string{
"dws minutes hot-word delete --words \"钉钉\"",
"dws minutes hot-word delete --words \"天气\"",
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
},
},
@@ -1883,11 +1883,12 @@ func newMinutesCommand() *cobra.Command {
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "policy"); err != nil {
return err
// 手动校验必填参数(避免 validateRequiredFlags 对 Int 的误判)
if !cmd.Flags().Changed("policy") {
return fmt.Errorf("missing required flag --policy")
}
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
policyID, err := cmd.Flags().GetInt("policy")
if err != nil || policyID < 2 || policyID > 4 {
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
}
@@ -1942,7 +1943,7 @@ func newMinutesCommand() *cobra.Command {
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
permissionApplyCmd.Flags().Int("policy", 0, "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
+1 -1
View File
@@ -63,7 +63,7 @@ func newOaCommand() *cobra.Command {
"查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时",
},
AvoidWhen: []string{
"不要用于普通待办任务或工作日志;需要创建审批实例时先确认当前命令面是否支持",
"不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume",
},
},
})
+11
View File
@@ -151,6 +151,17 @@ func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n",
func (f *Formatter) PrintProgress(msg string) { fmt.Fprintf(f.errW, "%s\n", msg) }
func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) }
// printJSONSafeInfo keeps command progress out of stdout when the caller
// requested JSON. A successful JSON command must leave stdout parseable; its
// human-readable progress belongs on stderr instead.
func printJSONSafeInfo(msg string) {
if deps != nil && deps.Caller != nil && strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
deps.Out.PrintProgress("[INFO] " + msg)
return
}
deps.Out.PrintInfo(msg)
}
func (f *Formatter) PrintKeyValue(key, value string) {
fmt.Fprintf(f.w, "%-16s%s\n", key+":", value)
}
+521
View File
@@ -0,0 +1,521 @@
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
const (
defaultPagedCommandPageLimit = 50
maxPagedCommandPageLimit = 500
defaultPagedCommandDelayMS = 200
)
type PagedCursorKind int
const (
PagedCursorString PagedCursorKind = iota
PagedCursorInt64
)
type PagedAggregationMode int
const (
PagedAggregationArray PagedAggregationMode = iota
PagedAggregationConversationMessages
)
type PagedMCPCommandConfig struct {
ServerID string
ToolName string
ItemPath string
CursorPath string
HasMorePath string
CursorArg string
CursorKind PagedCursorKind
AggregationMode PagedAggregationMode
BuildArgs func(*cobra.Command) (map[string]any, error)
Fallback func(map[string]any) error
}
type pagedCommandOptions struct {
pageAll bool
pageLimit int
maxItems int
delayMS int
}
func AddPagedMCPFlags(cmd *cobra.Command) {
cmd.Flags().Bool("page-all", false, "自动按 nextCursor 拉取所有分页;未设置时保持单页调用")
cmd.Flags().Int("page-limit", defaultPagedCommandPageLimit, "自动翻页最多请求页数(默认 50,范围 1-500;仅 --page-all 生效)")
cmd.Flags().Int("max-items", 0, "自动翻页最多返回条数(默认 0 表示不限制;仅 --page-all 生效)")
cmd.Flags().Int("page-delay", defaultPagedCommandDelayMS, "自动翻页每页之间等待毫秒数(默认 200;0 表示不等待;仅 --page-all 生效)")
}
func RunPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig) error {
args, err := cfg.BuildArgs(cmd)
if err != nil {
return err
}
opts, err := readPagedCommandOptions(cmd)
if err != nil {
return err
}
if !opts.pageAll {
return cfg.Fallback(args)
}
if err := validatePagedConfig(cfg); err != nil {
return err
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"request": map[string]any{
"server": cfg.ServerID,
"name": cfg.ToolName,
"args": args,
},
"paging": map[string]any{
"pageAll": true,
"pageLimit": opts.pageLimit,
"maxItems": opts.maxItems,
"pageDelay": opts.delayMS,
},
})
}
return runPagedMCPCommand(cmd, cfg, opts, args)
}
func readPagedCommandOptions(cmd *cobra.Command) (pagedCommandOptions, error) {
pageAll, _ := cmd.Flags().GetBool("page-all")
opts := pagedCommandOptions{pageAll: pageAll}
if !pageAll {
return opts, nil
}
opts.pageLimit, _ = cmd.Flags().GetInt("page-limit")
if opts.pageLimit < 1 || opts.pageLimit > maxPagedCommandPageLimit {
return opts, fmt.Errorf("--page-limit must be between 1 and 500")
}
opts.maxItems, _ = cmd.Flags().GetInt("max-items")
if opts.maxItems < 0 {
return opts, fmt.Errorf("--max-items must be greater than or equal to 0")
}
opts.delayMS, _ = cmd.Flags().GetInt("page-delay")
if opts.delayMS < 0 {
return opts, fmt.Errorf("--page-delay must be greater than or equal to 0")
}
return opts, nil
}
func validatePagedConfig(cfg PagedMCPCommandConfig) error {
switch {
case strings.TrimSpace(cfg.ServerID) == "":
return fmt.Errorf("paged command server is required")
case strings.TrimSpace(cfg.ToolName) == "":
return fmt.Errorf("paged command tool is required")
case strings.TrimSpace(cfg.ItemPath) == "":
return fmt.Errorf("paged command item path is required")
case strings.TrimSpace(cfg.CursorPath) == "":
return fmt.Errorf("paged command cursor path is required")
case strings.TrimSpace(cfg.HasMorePath) == "":
return fmt.Errorf("paged command hasMore path is required")
case strings.TrimSpace(cfg.CursorArg) == "":
return fmt.Errorf("paged command cursor arg is required")
case cfg.BuildArgs == nil || cfg.Fallback == nil:
return fmt.Errorf("paged command callbacks are required")
default:
return nil
}
}
func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts pagedCommandOptions, args map[string]any) error {
var envelope map[string]any
ctx := cmd.Context()
items := newPagedCollection(cfg)
seenCursors := map[string]bool{}
currentCursor := cursorValueKey(args[cfg.CursorArg], cfg.CursorKind)
lastCursor := args[cfg.CursorArg]
hasMore := true
for page := 1; page <= opts.pageLimit && hasMore; page++ {
pageCursor := args[cfg.CursorArg]
seenCursors[currentCursor] = true
text, err := callMCPToolReturnTextOnServer(ctx, cfg.ServerID, cfg.ToolName, args)
if err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err)
}
parsed, pageItems, nextCursor, more, err := parsePagedCommandPage(text, cfg)
if err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err)
}
if envelope == nil {
envelope = parsed
}
if err := items.Add(pageItems); err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err)
}
hasMore = more
if opts.maxItems > 0 && items.Total() > opts.maxItems {
items.Truncate(opts.maxItems)
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: true,
HasMore: true,
LastCursor: pageCursor,
Pages: page,
Total: items.Total(),
TruncatedWithinPage: true,
})
}
lastCursor = nextCursor
if opts.maxItems > 0 && items.Total() == opts.maxItems && hasMore {
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: true,
HasMore: true,
LastCursor: lastCursor,
Pages: page,
Total: items.Total(),
})
}
if !hasMore {
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: false,
HasMore: false,
LastCursor: lastCursor,
Pages: page,
Total: items.Total(),
})
}
nextKey := cursorValueKey(nextCursor, cfg.CursorKind)
if nextKey == "" || nextKey == currentCursor || seenCursors[nextKey] {
err := fmt.Errorf("pagination cursor did not advance: %s", nextKey)
return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err)
}
normalizedCursor, err := normalizeCursorArg(nextCursor, cfg.CursorKind)
if err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err)
}
currentCursor = nextKey
args[cfg.CursorArg] = normalizedCursor
if opts.delayMS > 0 {
if err := sleepPagedCommandDelay(ctx, time.Duration(opts.delayMS)*time.Millisecond); err != nil {
return handlePagedCommandError(cmd, envelope, cfg, items, page+1, currentCursor, err)
}
}
}
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: hasMore,
HasMore: hasMore,
LastCursor: lastCursor,
Pages: opts.pageLimit,
Total: items.Total(),
})
}
func parsePagedCommandPage(text string, cfg PagedMCPCommandConfig) (map[string]any, []any, any, bool, error) {
var parsed map[string]any
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
return nil, nil, nil, false, fmt.Errorf("parse paged response JSON: %w", err)
}
rawHasMore, ok := getJSONPath(parsed, cfg.HasMorePath)
if !ok {
return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.HasMorePath)
}
hasMore, ok := rawHasMore.(bool)
if !ok {
return nil, nil, nil, false, fmt.Errorf("paged response %s must be boolean", cfg.HasMorePath)
}
rawItems, ok := getJSONPath(parsed, cfg.ItemPath)
if !ok && cfg.AggregationMode == PagedAggregationConversationMessages && !hasMore {
rawItems = []any{}
ok = true
}
if !ok {
return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.ItemPath)
}
items, ok := rawItems.([]any)
if !ok {
return nil, nil, nil, false, fmt.Errorf("paged response %s must be array", cfg.ItemPath)
}
nextCursor, ok := getJSONPath(parsed, cfg.CursorPath)
if hasMore && !ok {
return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.CursorPath)
}
return parsed, items, nextCursor, hasMore, nil
}
type pagingMetadata struct {
Truncated bool
HasMore bool
LastCursor any
Pages int
Total int
TruncatedWithinPage bool
Partial bool
FailedPage int
FailedCursor string
PagesFetched int
ItemsFetched int
Error string
}
func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, failedPage int, failedCursor string, err error) error {
if envelope == nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "pagination stopped at page %d: %v\n", failedPage, err)
if outputErr := writePagedCommandResult(envelope, cfg, items, pagingMetadata{
Truncated: true,
HasMore: true,
LastCursor: failedCursor,
Pages: failedPage - 1,
Total: items.Total(),
Partial: true,
FailedPage: failedPage,
FailedCursor: failedCursor,
PagesFetched: failedPage - 1,
ItemsFetched: items.Total(),
Error: err.Error(),
}); outputErr != nil {
return errors.Join(err, outputErr)
}
return err
}
func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, meta pagingMetadata) error {
_ = setJSONPath(envelope, cfg.ItemPath, items.Values())
_ = setJSONPath(envelope, cfg.HasMorePath, meta.HasMore)
_ = setJSONPath(envelope, cfg.CursorPath, meta.LastCursor)
paging := map[string]any{
"truncated": meta.Truncated,
"hasMore": meta.HasMore,
"lastCursor": meta.LastCursor,
"pages": meta.Pages,
"total": meta.Total,
}
if meta.Partial {
paging["partial"] = true
paging["failedPage"] = meta.FailedPage
paging["failedCursor"] = meta.FailedCursor
paging["pagesFetched"] = meta.PagesFetched
paging["itemsFetched"] = meta.ItemsFetched
paging["error"] = meta.Error
}
if meta.TruncatedWithinPage {
paging["truncatedWithinPage"] = true
paging["resumeCursorReliable"] = false
}
envelope["paging"] = paging
return deps.Out.PrintJSON(envelope)
}
func sleepPagedCommandDelay(ctx context.Context, delay time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-helperAfter(delay):
return nil
}
}
type pagedCollection struct {
mode PagedAggregationMode
items []any
conversationIndex map[string]int
total int
}
func newPagedCollection(cfg PagedMCPCommandConfig) *pagedCollection {
return &pagedCollection{
mode: cfg.AggregationMode,
conversationIndex: map[string]int{},
}
}
func (c *pagedCollection) Add(items []any) error {
if c.mode != PagedAggregationConversationMessages {
c.items = append(c.items, items...)
c.total = len(c.items)
return nil
}
for _, item := range items {
if err := c.addConversation(item); err != nil {
return err
}
}
return nil
}
func (c *pagedCollection) Values() []any {
if c.items == nil {
return []any{}
}
return c.items
}
func (c *pagedCollection) Total() int {
return c.total
}
func (c *pagedCollection) Truncate(maxItems int) bool {
if maxItems <= 0 || c.total <= maxItems {
return false
}
if c.mode != PagedAggregationConversationMessages {
c.items = c.items[:maxItems]
c.total = len(c.items)
return true
}
c.truncateConversationMessages(maxItems)
return true
}
func (c *pagedCollection) addConversation(item any) error {
conversation, ok := item.(map[string]any)
if !ok {
return fmt.Errorf("paged response conversation item must be object")
}
key, _ := conversation["openConversationId"].(string)
if strings.TrimSpace(key) == "" {
return fmt.Errorf("paged response conversation item missing openConversationId")
}
messages, err := conversationMessages(conversation)
if err != nil {
return err
}
if idx, ok := c.conversationIndex[key]; ok {
existing := c.items[idx].(map[string]any)
existingMessages, err := conversationMessages(existing)
if err != nil {
return err
}
existing["messages"] = append(existingMessages, messages...)
c.total += len(messages)
return nil
}
c.conversationIndex[key] = len(c.items)
c.items = append(c.items, conversation)
c.total += len(messages)
return nil
}
func (c *pagedCollection) truncateConversationMessages(maxItems int) {
remaining := maxItems
for i, item := range c.items {
conversation := item.(map[string]any)
messages, _ := conversationMessages(conversation)
if remaining >= len(messages) {
remaining -= len(messages)
continue
}
if remaining == 0 {
c.items = c.items[:i]
c.total = maxItems
return
}
conversation["messages"] = messages[:remaining]
c.items = c.items[:i+1]
c.total = maxItems
return
}
}
func conversationMessages(conversation map[string]any) ([]any, error) {
raw, ok := conversation["messages"]
if !ok {
return []any{}, nil
}
messages, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("paged response conversation messages must be array")
}
return messages, nil
}
func getJSONPath(root map[string]any, path string) (any, bool) {
var current any = root
for _, part := range strings.Split(path, ".") {
obj, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = obj[part]
if !ok {
return nil, false
}
}
return current, true
}
func setJSONPath(root map[string]any, path string, value any) bool {
parts := strings.Split(path, ".")
current := root
for _, part := range parts[:len(parts)-1] {
next, ok := current[part].(map[string]any)
if !ok {
return false
}
current = next
}
current[parts[len(parts)-1]] = value
return true
}
func cursorValueKey(value any, kind PagedCursorKind) string {
switch kind {
case PagedCursorInt64:
switch v := value.(type) {
case int64:
return strconv.FormatInt(v, 10)
case int:
return strconv.Itoa(v)
case float64:
return strconv.FormatInt(int64(v), 10)
case string:
return strings.TrimSpace(v)
default:
return ""
}
default:
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
}
func normalizeCursorArg(value any, kind PagedCursorKind) (any, error) {
if kind != PagedCursorInt64 {
if value == nil {
return "", nil
}
return fmt.Sprint(value), nil
}
switch v := value.(type) {
case int64:
return v, nil
case int:
return int64(v), nil
case float64:
converted := int64(v)
if float64(converted) != v {
return nil, fmt.Errorf("paged response cursor must be an integer, got %v", v)
}
return converted, nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err == nil {
return parsed, nil
}
return nil, fmt.Errorf("paged response cursor must be a base-10 int64 string, got %q", v)
}
return nil, fmt.Errorf("paged response cursor must be int64-compatible, got %T", value)
}
+973
View File
@@ -0,0 +1,973 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type pagedCommandCall struct {
server string
tool string
args map[string]any
ctx context.Context
}
type pagedCommandCaller struct {
steps []scriptedToolStep
calls []pagedCommandCall
format string
dry bool
}
func (c *pagedCommandCaller) CallTool(ctx context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
copied := map[string]any{}
for k, v := range args {
copied[k] = v
}
c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied, ctx: ctx})
if len(c.steps) == 0 {
return textToolResult(`{"result":{"messages":[],"hasMore":false}}`), nil
}
step := c.steps[len(c.calls)-1]
if step.err != nil {
return nil, step.err
}
return textToolResult(step.text), nil
}
func (c *pagedCommandCaller) Format() string { return c.format }
func (c *pagedCommandCaller) DryRun() bool { return c.dry }
func (*pagedCommandCaller) Fields() string { return "" }
func (*pagedCommandCaller) JQ() string { return "" }
func runPagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, args ...string) (map[string]any, string, error) {
t.Helper()
return runPagedCommandTestWithSleep(t, caller, cfg, func(time.Duration) {}, args...)
}
func runPagedCommandTestWithSleep(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), args ...string) (map[string]any, string, error) {
t.Helper()
out, stderr, err := executePagedCommandTest(t, caller, cfg, sleep, &bytes.Buffer{}, args...)
if strings.TrimSpace(out) == "" {
return nil, stderr, err
}
var parsed map[string]any
if unmarshalErr := json.Unmarshal([]byte(out), &parsed); unmarshalErr != nil {
t.Fatalf("stdout JSON = %q, err = %v", out, unmarshalErr)
}
return parsed, stderr, err
}
func executePagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) {
t.Helper()
return executePagedCommandTestWithContext(t, context.Background(), caller, cfg, sleep, stdout, args...)
}
func executePagedCommandTestWithContext(t *testing.T, ctx context.Context, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) {
t.Helper()
oldDeps := deps
oldSleep := helperSleep
oldAfter := helperAfter
t.Cleanup(func() {
deps = oldDeps
helperSleep = oldSleep
helperAfter = oldAfter
})
InitDeps(caller)
out := stdout
errOut := &bytes.Buffer{}
deps.Out.w = out
deps.Out.errW = errOut
if sleep != nil {
helperSleep = sleep
helperAfter = func(d time.Duration) <-chan time.Time {
sleep(d)
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
}
}
cmd := &cobra.Command{
Use: "paged",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return RunPagedMCPCommand(cmd, cfg)
},
}
cmd.SetContext(ctx)
cmd.Flags().String("cursor", "0", "")
AddPagedMCPFlags(cmd)
cmd.SetErr(errOut)
cmd.SetArgs(args)
err := cmd.Execute()
if buf, ok := out.(*bytes.Buffer); ok {
return buf.String(), errOut.String(), err
}
return "", errOut.String(), err
}
func pagedCommandMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig {
if fallback == nil {
fallback = func(map[string]any) error { return nil }
}
return PagedMCPCommandConfig{
ServerID: "chat",
ToolName: "search_messages_by_time_range",
ItemPath: "result.messages",
CursorPath: "result.nextCursor",
HasMorePath: "result.hasMore",
CursorArg: "cursor",
CursorKind: PagedCursorString,
BuildArgs: func(cmd *cobra.Command) (map[string]any, error) {
cursor, _ := cmd.Flags().GetString("cursor")
return map[string]any{"cursor": cursor, "limit": 2}, nil
},
Fallback: fallback,
}
}
func pagedCommandConversationMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig {
cfg := pagedCommandMessagesConfig(fallback)
cfg.ItemPath = "result.conversationMessagesList"
cfg.AggregationMode = PagedAggregationConversationMessages
return cfg
}
func TestPagedMCPCommandDefaultUsesFallbackOnly(t *testing.T) {
caller := &pagedCommandCaller{}
fallbackCalls := 0
cfg := pagedCommandMessagesConfig(func(args map[string]any) error {
fallbackCalls++
if args["cursor"] != "0" {
t.Fatalf("fallback args = %#v", args)
}
return nil
})
_, _, err := runPagedCommandTest(t, caller, cfg, "--page-limit", "2", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
if fallbackCalls != 1 || len(caller.calls) != 0 {
t.Fatalf("fallback=%d remote=%d, want fallback only", fallbackCalls, len(caller.calls))
}
}
func TestPagedMCPCommandRejectsInvalidConfigWhenPageAll(t *testing.T) {
caller := &pagedCommandCaller{}
cfg := pagedCommandMessagesConfig(nil)
cfg.ServerID = " "
got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all")
if err == nil || !strings.Contains(err.Error(), "server is required") {
t.Fatalf("result=%#v err=%v, want config error", got, err)
}
if got != nil {
t.Fatalf("result=%#v, want no stdout", got)
}
if len(caller.calls) != 0 {
t.Fatalf("calls=%#v, want no remote call", caller.calls)
}
}
func TestPagedMCPCommandDryRunPrintsRequestAndSkipsRemote(t *testing.T) {
caller := &pagedCommandCaller{dry: true}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "3", "--max-items", "7", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls=%#v, want no remote call", caller.calls)
}
if got["dry_run"] != true {
t.Fatalf("dry_run=%#v, want true", got["dry_run"])
}
request := got["request"].(map[string]any)
if request["server"] != "chat" || request["name"] != "search_messages_by_time_range" {
t.Fatalf("request=%#v", request)
}
args := request["args"].(map[string]any)
if args["cursor"] != "0" || args["limit"].(float64) != 2 {
t.Fatalf("args=%#v", args)
}
paging := got["paging"].(map[string]any)
if paging["pageAll"] != true || paging["pageLimit"].(float64) != 3 || paging["maxItems"].(float64) != 7 || paging["pageDelay"].(float64) != 0 {
t.Fatalf("paging=%#v", paging)
}
}
func TestPagedMCPCommandValidateConfigRejectsMissingRequiredFields(t *testing.T) {
tests := []struct {
name string
edit func(*PagedMCPCommandConfig)
want string
}{
{
name: "server",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.ServerID = ""
},
want: "server is required",
},
{
name: "tool",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.ToolName = ""
},
want: "tool is required",
},
{
name: "item path",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.ItemPath = ""
},
want: "item path is required",
},
{
name: "cursor path",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.CursorPath = ""
},
want: "cursor path is required",
},
{
name: "hasMore path",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.HasMorePath = ""
},
want: "hasMore path is required",
},
{
name: "cursor arg",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.CursorArg = ""
},
want: "cursor arg is required",
},
{
name: "build args callback",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.BuildArgs = nil
},
want: "callbacks are required",
},
{
name: "fallback callback",
edit: func(cfg *PagedMCPCommandConfig) {
cfg.Fallback = nil
},
want: "callbacks are required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := pagedCommandMessagesConfig(nil)
tt.edit(&cfg)
err := validatePagedConfig(cfg)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("err=%v, want %q", err, tt.want)
}
})
}
}
func TestPagedMCPCommandStringCursorAggregatesAndPageLimit(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":true,"nextCursor":"c3"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "2", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
result := got["result"].(map[string]any)
paging := got["paging"].(map[string]any)
if len(items) != 2 || paging["truncated"] != true || paging["pages"].(float64) != 2 {
t.Fatalf("result = %#v", got)
}
if result["hasMore"] != true || result["nextCursor"] != "c3" {
t.Fatalf("result=%#v, want final page-limit cursor state", result)
}
if caller.calls[0].args["cursor"] != "0" || caller.calls[1].args["cursor"] != "c2" {
t.Fatalf("call args = %#v", caller.calls)
}
}
func TestPagedMCPCommandStringCursorAggregatesAndSyncsCompletionFields(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
result := got["result"].(map[string]any)
items := result["messages"].([]any)
if len(items) != 2 || result["hasMore"] != false || result["nextCursor"] != "" {
t.Fatalf("result=%#v, want complete aggregate with final cursor state", result)
}
paging := got["paging"].(map[string]any)
if paging["truncated"] != false || paging["hasMore"] != false || paging["lastCursor"] != "" {
t.Fatalf("paging=%#v, want complete pagination metadata", paging)
}
}
func TestPagedMCPCommandConversationMessagesMergeSameConversation(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"ignored","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
if len(conversations) != 1 {
t.Fatalf("conversations=%#v, want one merged conversation", conversations)
}
conversation := conversations[0].(map[string]any)
messages := conversation["messages"].([]any)
if conversation["title"] != "群1" || len(messages) != 2 {
t.Fatalf("conversation=%#v, want preserved title and two messages", conversation)
}
paging := got["paging"].(map[string]any)
if paging["total"].(float64) != 2 {
t.Fatalf("paging=%#v, want total message count 2", paging)
}
}
func TestPagedMCPCommandConversationMessagesPreserveFirstConversationOrder(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid2","messages":[{"id":"m2"}]},{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid3","messages":[{"id":"m3"}]}],"hasMore":false,"nextCursor":""}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
gotIDs := []string{
conversations[0].(map[string]any)["openConversationId"].(string),
conversations[1].(map[string]any)["openConversationId"].(string),
conversations[2].(map[string]any)["openConversationId"].(string),
}
if strings.Join(gotIDs, ",") != "cid2,cid1,cid3" {
t.Fatalf("conversation order=%v", gotIDs)
}
}
func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesMessages(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "3", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
if len(conversations) != 2 {
t.Fatalf("conversations=%#v, want two conversations", conversations)
}
secondMessages := conversations[1].(map[string]any)["messages"].([]any)
paging := got["paging"].(map[string]any)
if len(secondMessages) != 1 || paging["total"].(float64) != 3 || paging["truncated"] != true {
t.Fatalf("result=%#v", got)
}
}
func TestPagedMCPCommandConversationMessagesMissingListOnFinalPageIsEmpty(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"hasMore":false,"nextCursor":""}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
if len(conversations) != 0 {
t.Fatalf("conversations=%#v, want empty", conversations)
}
}
func TestPagedMCPCommandConversationMessagesLaterFailureOutputsPartial(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`},
{err: errors.New("page failed")},
}}
got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(stderr, "pagination stopped") {
t.Fatalf("err=%v stderr=%q", err, stderr)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
paging := got["paging"].(map[string]any)
if len(conversations) != 1 || paging["partial"] != true || paging["itemsFetched"].(float64) != 1 {
t.Fatalf("result=%#v", got)
}
}
func TestPagedMCPCommandConversationMessagesAddErrorsOutputPartial(t *testing.T) {
tests := []struct {
name string
response string
want string
}{
{
name: "conversation item not object",
response: `{"result":{"conversationMessagesList":["bad"],"hasMore":false,"nextCursor":""}}`,
want: "conversation item must be object",
},
{
name: "conversation missing openConversationId",
response: `{"result":{"conversationMessagesList":[{"messages":[]}],"hasMore":false,"nextCursor":""}}`,
want: "missing openConversationId",
},
{
name: "conversation messages not array",
response: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":"bad"}],"hasMore":false,"nextCursor":""}}`,
want: "conversation messages must be array",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}}
got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want)
}
if !strings.Contains(stderr, "pagination stopped at page 1") {
t.Fatalf("stderr=%q", stderr)
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["itemsFetched"].(float64) != 0 {
t.Fatalf("paging=%#v", paging)
}
})
}
}
func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesAtConversationBoundary(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
paging := got["paging"].(map[string]any)
if len(conversations) != 1 || paging["total"].(float64) != 2 || paging["truncated"] != true {
t.Fatalf("result=%#v", got)
}
}
func TestPagedMCPCommandConversationMessagesAcceptsMissingMessages(t *testing.T) {
messages, err := conversationMessages(map[string]any{"openConversationId": "cid1"})
if err != nil {
t.Fatal(err)
}
if len(messages) != 0 {
t.Fatalf("messages=%#v, want empty", messages)
}
}
func TestPagedMCPCommandConversationMessagesRejectsCorruptExistingMessages(t *testing.T) {
collection := newPagedCollection(PagedMCPCommandConfig{AggregationMode: PagedAggregationConversationMessages})
collection.items = []any{map[string]any{"openConversationId": "cid1", "messages": "bad"}}
collection.conversationIndex["cid1"] = 0
err := collection.Add([]any{map[string]any{"openConversationId": "cid1", "messages": []any{map[string]any{"id": "m2"}}}})
if err == nil || !strings.Contains(err.Error(), "conversation messages must be array") {
t.Fatalf("err=%v, want corrupt existing messages error", err)
}
}
func TestPagedMCPCommandResponseShapeErrors(t *testing.T) {
tests := []struct {
name string
response string
want string
}{
{
name: "items not array",
response: `{"result":{"messages":"bad","hasMore":false}}`,
want: "result.messages must be array",
},
{
name: "missing hasMore",
response: `{"result":{"messages":[]}}`,
want: "missing result.hasMore",
},
{
name: "hasMore not bool",
response: `{"result":{"messages":[],"hasMore":"yes"}}`,
want: "result.hasMore must be boolean",
},
{
name: "missing next cursor",
response: `{"result":{"messages":[{"id":"m1"}],"hasMore":true}}`,
want: "missing result.nextCursor",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want)
}
if got != nil {
t.Fatalf("result=%#v, want no partial stdout", got)
}
})
}
}
func TestPagedMCPCommandPageDelayControlsSleep(t *testing.T) {
tests := []struct {
name string
delay string
wantSleeps []time.Duration
}{
{
name: "non zero delay sleeps between pages",
delay: "200",
wantSleeps: []time.Duration{200 * time.Millisecond},
},
{
name: "zero delay skips sleep",
delay: "0",
wantSleeps: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`},
}}
var sleeps []time.Duration
got, _, err := runPagedCommandTestWithSleep(t, caller, pagedCommandMessagesConfig(nil), func(d time.Duration) {
sleeps = append(sleeps, d)
}, "--page-all", "--page-delay", tt.delay)
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
if len(items) != 2 || len(caller.calls) != 2 {
t.Fatalf("items=%#v calls=%#v", items, caller.calls)
}
if len(sleeps) != len(tt.wantSleeps) {
t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps)
}
for i := range tt.wantSleeps {
if sleeps[i] != tt.wantSleeps[i] {
t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps)
}
}
})
}
}
func TestPagedMCPCommandMaxItemsTruncatesPrecisely(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
paging := got["paging"].(map[string]any)
if len(items) != 1 || paging["total"].(float64) != 1 || paging["truncated"] != true {
t.Fatalf("result = %#v", got)
}
}
func TestPagedMCPCommandMaxItemsStopsWhenPageExactlyReachesLimit(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
{err: errors.New("second page should not run")},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
result := got["result"].(map[string]any)
paging := got["paging"].(map[string]any)
if len(caller.calls) != 1 || len(items) != 2 {
t.Fatalf("calls=%#v items=%#v, want one full page", caller.calls, items)
}
if paging["truncated"] != true || paging["hasMore"] != true || paging["lastCursor"] != "c2" {
t.Fatalf("paging=%#v, want safe page-boundary cursor", paging)
}
if result["hasMore"] != true || result["nextCursor"] != "c2" {
t.Fatalf("result=%#v, want safe page-boundary cursor fields", result)
}
if _, ok := paging["truncatedWithinPage"]; ok {
t.Fatalf("paging=%#v, want no within-page truncation marker", paging)
}
}
func TestPagedMCPCommandMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["messages"].([]any)
result := got["result"].(map[string]any)
paging := got["paging"].(map[string]any)
if len(items) != 1 || paging["lastCursor"] != "0" {
t.Fatalf("result=%#v, want current-page cursor after within-page truncation", got)
}
if result["hasMore"] != true || result["nextCursor"] != "0" {
t.Fatalf("result=%#v, want unreliable current-page cursor fields", result)
}
if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
t.Fatalf("paging=%#v, want unreliable resume marker", paging)
}
}
func TestPagedCollectionTruncateReturnsFalseWhenLimitDoesNotTrim(t *testing.T) {
collection := newPagedCollection(PagedMCPCommandConfig{})
if err := collection.Add([]any{map[string]any{"id": "m1"}}); err != nil {
t.Fatal(err)
}
if collection.Truncate(0) {
t.Fatal("Truncate(0) should not trim")
}
if collection.Truncate(1) {
t.Fatal("Truncate(total) should not trim")
}
if collection.Total() != 1 || len(collection.Values()) != 1 {
t.Fatalf("collection=%#v, want unchanged single item", collection.Values())
}
}
func TestPagedMCPCommandConversationMessagesMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]}],"hasMore":true,"nextCursor":"c2"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
messages := conversations[0].(map[string]any)["messages"].([]any)
paging := got["paging"].(map[string]any)
if len(messages) != 1 || paging["lastCursor"] != "0" {
t.Fatalf("result=%#v, want truncated conversation with current-page cursor", got)
}
if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
t.Fatalf("paging=%#v, want unreliable resume marker", paging)
}
}
func TestPagedMCPCommandPassesCommandContextToCaller(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`},
}}
out, _, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, &bytes.Buffer{}, "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) == "" || len(caller.calls) != 1 {
t.Fatalf("stdout=%q calls=%#v, want one successful call", out, caller.calls)
}
if caller.calls[0].ctx != ctx || caller.calls[0].ctx.Err() != context.Canceled {
t.Fatalf("call ctx=%#v err=%v, want canceled command context", caller.calls[0].ctx, caller.calls[0].ctx.Err())
}
}
func TestPagedMCPCommandPageDelayStopsWhenContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`},
}}
var out bytes.Buffer
stdout, stderr, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), nil, &out, "--page-all", "--page-delay", "10")
if strings.TrimSpace(stdout) == "" {
t.Fatal("stdout is empty, want partial pagination JSON")
}
if err == nil || !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v, want context canceled", err)
}
if !strings.Contains(stderr, "pagination stopped at page 2") || len(caller.calls) != 1 {
t.Fatalf("stderr=%q calls=%#v, want cancellation before second call", stderr, caller.calls)
}
var got map[string]any
if unmarshalErr := json.Unmarshal([]byte(stdout), &got); unmarshalErr != nil {
t.Fatalf("stdout JSON = %q, err = %v", stdout, unmarshalErr)
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 {
t.Fatalf("paging=%#v, want partial cancellation metadata", paging)
}
}
func TestPagedMCPCommandPropagatesAggregatedOutputErrors(t *testing.T) {
tests := []struct {
name string
steps []scriptedToolStep
args []string
wantStderr string
}{
{
name: "normal end",
steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`},
},
args: []string{"--page-all", "--page-delay", "0"},
},
{
name: "max items truncation",
steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
},
args: []string{"--page-all", "--max-items", "1", "--page-delay", "0"},
},
{
name: "page limit truncation",
steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
},
args: []string{"--page-all", "--page-limit", "1", "--page-delay", "0"},
},
{
name: "partial result after later failure",
steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{err: errors.New("page failed")},
},
args: []string{"--page-all", "--page-delay", "0"},
wantStderr: "pagination stopped at page 2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// TC-output-error: aggregate stdout write failures must fail the command.
caller := &pagedCommandCaller{steps: tt.steps}
_, stderr, err := executePagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, failingWriter{}, tt.args...)
if err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("err=%v, want propagated write failure", err)
}
if tt.wantStderr != "" && !strings.Contains(stderr, tt.wantStderr) {
t.Fatalf("stderr=%q, want %q", stderr, tt.wantStderr)
}
})
}
}
func TestPagedMCPCommandInt64CursorAndItemsPath(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`},
{text: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`},
}}
cfg := PagedMCPCommandConfig{
ServerID: "im",
ToolName: "list_message_favorites",
ItemPath: "result.items",
CursorPath: "result.nextCursor",
HasMorePath: "result.hasMore",
CursorArg: "cursor",
CursorKind: PagedCursorInt64,
BuildArgs: func(cmd *cobra.Command) (map[string]any, error) {
return map[string]any{"cursor": int64(0), "size": "20"}, nil
},
Fallback: func(map[string]any) error { return nil },
}
got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0")
if err != nil {
t.Fatal(err)
}
items := got["result"].(map[string]any)["items"].([]any)
if len(items) != 2 || caller.calls[1].args["cursor"] != int64(20) {
t.Fatalf("items=%#v calls=%#v", items, caller.calls)
}
}
func TestPagedMCPCommandInt64CursorRejectsNonNumericNextCursor(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":"not-a-number"}}`},
}}
cfg := PagedMCPCommandConfig{
ServerID: "im",
ToolName: "list_message_favorites",
ItemPath: "result.items",
CursorPath: "result.nextCursor",
HasMorePath: "result.hasMore",
CursorArg: "cursor",
CursorKind: PagedCursorInt64,
BuildArgs: func(cmd *cobra.Command) (map[string]any, error) {
return map[string]any{"cursor": int64(0), "size": "20"}, nil
},
Fallback: func(map[string]any) error { return nil },
}
got, stderr, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(err.Error(), "base-10 int64 string") {
t.Fatalf("err=%v, want invalid int64 cursor error", err)
}
if !strings.Contains(stderr, "pagination stopped at page 2") {
t.Fatalf("stderr=%q", stderr)
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["failedCursor"] != "not-a-number" || paging["pagesFetched"].(float64) != 1 {
t.Fatalf("paging = %#v", paging)
}
if len(caller.calls) != 1 {
t.Fatalf("calls=%#v, want no second call with cursor 0", caller.calls)
}
}
func TestPagedMCPCommandFirstPageFailureReturnsNoPartial(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all")
if err == nil || got != nil {
t.Fatalf("result=%#v err=%v, want first-page error without stdout", got, err)
}
}
func TestPagedMCPCommandLaterFailureOutputsPartial(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
{err: errors.New("page failed")},
}}
got, stderr, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil || !strings.Contains(stderr, "pagination stopped") {
t.Fatalf("err=%v stderr=%q", err, stderr)
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 {
t.Fatalf("paging = %#v", paging)
}
}
func TestPagedMCPCommandCursorCycleOutputsPartial(t *testing.T) {
caller := &pagedCommandCaller{steps: []scriptedToolStep{
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"0"}}`},
}}
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
if err == nil {
t.Fatal("cursor cycle should return error")
}
paging := got["paging"].(map[string]any)
if paging["partial"] != true || paging["pagesFetched"].(float64) != 1 {
t.Fatalf("paging = %#v", paging)
}
}
func TestPagedMCPCommandSetJSONPathRejectsNonObjectIntermediate(t *testing.T) {
root := map[string]any{"result": "not-object"}
if setJSONPath(root, "result.messages", []any{}) {
t.Fatal("setJSONPath should reject a non-object intermediate")
}
if root["result"] != "not-object" {
t.Fatalf("root=%#v, want original intermediate preserved", root)
}
}
func TestPagedMCPCommandCursorValueKeyCoversBoundaryKinds(t *testing.T) {
if got := cursorValueKey(7, PagedCursorInt64); got != "7" {
t.Fatalf("int cursor key=%q, want 7", got)
}
if got := cursorValueKey(nil, PagedCursorString); got != "" {
t.Fatalf("nil string cursor key=%q, want empty", got)
}
}
func TestPagedMCPCommandNormalizeCursorArgCoversBoundaryKinds(t *testing.T) {
tests := []struct {
name string
value any
kind PagedCursorKind
want any
wantErr string
}{
{
name: "nil string cursor",
value: nil,
kind: PagedCursorString,
want: "",
},
{
name: "int64 cursor",
value: int64(9),
kind: PagedCursorInt64,
want: int64(9),
},
{
name: "int cursor",
value: 10,
kind: PagedCursorInt64,
want: int64(10),
},
{
name: "numeric string cursor",
value: " 11 ",
kind: PagedCursorInt64,
want: int64(11),
},
{
name: "fractional float cursor",
value: 1.5,
kind: PagedCursorInt64,
wantErr: "must be an integer",
},
{
name: "unsupported cursor type",
value: []string{"bad"},
kind: PagedCursorInt64,
wantErr: "int64-compatible",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeCursorArg(tt.value, tt.kind)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("value=%#v err=%v, want %q", tt.value, err, tt.wantErr)
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("value=%#v got=%#v, want %#v", tt.value, got, tt.want)
}
})
}
}
@@ -31,6 +31,17 @@ func executePR868Command(t *testing.T, root *cobra.Command, args ...string) erro
}
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
t.Run("permission add requires explicit policy", func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
err := executePR868Command(t, newMinutesCommand(), "permission", "add", "--ids", "task-1", "--member-uids", "user-1")
if err == nil || !strings.Contains(err.Error(), "--policy") {
t.Fatalf("permission add without --policy error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("permission add called MCP %d times before required policy validation", caller.calls)
}
})
t.Run("hot-word delete dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
@@ -91,6 +102,21 @@ func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
t.Fatal("expected missing policy")
}
})
t.Run("permission apply policy flag is int", func(t *testing.T) {
// 数值参数声明为 int 类型 flag;必填校验走 cmd.Flags().Changed,
// 不能用 validateRequiredFlags(它把 int 零值当成未传)。
cmd, _, err := newMinutesCommand().Find([]string{"permission", "apply"})
if err != nil {
t.Fatalf("find permission apply: %v", err)
}
flag := cmd.Flags().Lookup("policy")
if flag == nil {
t.Fatal("flag --policy not found")
}
if flag.Value.Type() != "int" {
t.Fatalf("flag --policy type = %q, want %q", flag.Value.Type(), "int")
}
})
t.Run("audio-memo list default", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
@@ -11,6 +11,7 @@ import (
"time"
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -157,7 +158,15 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
deps.Out.errW = io.Discard
// Product examples execute real RunE paths; whiteboard insert retries must
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
// Sheet export uses helperAfter for progressive polling, so keep that clock
// deterministic too; context deadlines alone still accumulate heavily under
// Windows coverage instrumentation.
whiteboardSleep = func(time.Duration) {}
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
})
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
if destPath == "" {
+124 -20
View File
@@ -15,6 +15,7 @@ package chat
import (
"encoding/json"
"errors"
"fmt"
"math"
"path/filepath"
@@ -1081,6 +1082,10 @@ func DownloadMessageResources(
continue
}
preferredName := resourceDownloadPreferredName(data)
if preferredName == "" {
preferredName, _ = resource["name"].(string)
preferredName = strings.TrimSpace(preferredName)
}
filename := resourceDownloadFilename(resourceURL, preferredName)
filename = disambiguateResourceDownloadFilename(filename, downloadedNames)
output := filepath.Join(outputDir, filename)
@@ -1203,9 +1208,10 @@ func uniqueShortcutStrings(values []string) []string {
var MessagesQuerySendStatus = shortcut.Shortcut{
Service: "chat",
Command: "+messages-query-send-status",
Aliases: []string{"+messages-send-status"},
Product: "im",
Description: "查询消息发送状态",
Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。",
Description: "查询消息投递状态并衔接后续消息操作",
Intent: "当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -1218,17 +1224,18 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
CanonicalPath: "chat.shortcut_messages_query_send_status",
CLIPath: "chat +messages-query-send-status",
PrimaryCLIPath: "chat +messages-query-send-status",
Aliases: []string{"chat +messages-send-status"},
},
Description: "查询消息发送状态",
Description: "查询消息投递状态并衔接后续消息操作",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询消息发送状态",
UseWhen: []string{"当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
AgentSummary: "查询消息投递状态并衔接后续消息操作",
UseWhen: []string{"当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。"},
AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"},
Examples: []string{"dws chat +messages-query-send-status --open-task-id <openTaskId>"},
},
},
@@ -1237,7 +1244,12 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-query-send-status --open-task-id <openTaskId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")})
taskID := rt.Str("open-task-id")
data, err := rt.CallMCPData("im", "query_message_send_status", map[string]any{"openTaskId": taskID})
if err != nil {
return err
}
return rt.Output(chatmsg.ProjectMessageSendStatus(data, taskID))
},
}
@@ -1541,7 +1553,28 @@ var MessagesSendCard = shortcut.Shortcut{
}
content := rt.Str("content")
if content == "" {
return rt.CallMCP("create_and_send_card", params)
if rt.DryRun() {
return rt.Output(map[string]any{
"contractVersion": chatmsg.StreamingCardContractVersion,
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 1,
"actions": []map[string]any{{
"tool": "create_and_send_card",
"arguments": params,
}},
})
}
created, err := rt.CallMCPWriteData("im", "create_and_send_card", params)
if err != nil {
return err
}
bizID := findCardBizID(created)
if bizID == "" {
return cardCreateMissingBizIDError(created)
}
return rt.Output(chatmsg.ProjectStreamingCardReceipt(created, bizID))
}
status := rt.Int("flow-status")
if rt.DryRun() {
@@ -1592,14 +1625,14 @@ var MessagesSendCard = shortcut.Shortcut{
if err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
}
return rt.Output(map[string]any{
"contractVersion": currentCardWorkflowContract.Version,
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
})
if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err))
}
payload := chatmsg.ProjectStreamingCardReceipt(created, bizID)
payload["bizId"] = bizID
payload["flowStatus"] = status
payload["updated"] = updated
return rt.Output(payload)
},
}
@@ -1694,7 +1727,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
Selection: contract.SelectionSpec{
AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
UseWhen: []string{"当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
AvoidWhen: []string{"需要底层原始响应、未公开参数,或由调用方自行管理确认与更新节奏时,改用 chat message update-card"},
Examples: []string{"dws chat +messages-update-card --biz-id <bizId> --content \"内容\" --flow-status 3"},
},
},
@@ -1708,20 +1741,91 @@ var MessagesUpdateCard = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-update-card --biz-id <bizId> --content "内容" --flow-status 3`},
Validate: func(rt *shortcut.RuntimeContext) error {
if _, err := chatmsg.NormalizeCardBizID(rt.Str("biz-id")); err != nil {
return err
}
if !validCardFlowStatus(rt.Int("flow-status")) {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_streaming_card", map[string]any{
"bizId": rt.Str("biz-id"),
// Validate has already normalized and rejected empty, placeholder, and
// whitespace-containing values before Execute is entered.
bizID, _ := chatmsg.NormalizeCardBizID(rt.Str("biz-id"))
params := map[string]any{
"bizId": bizID,
"msgContent": rt.Str("content"),
"flowStatus": rt.Int("flow-status"),
})
}
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"verified": false,
"action": map[string]any{
"product": "im",
"tool": "update_streaming_card",
"arguments": params,
},
})
}
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", params)
if err != nil {
return err
}
proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
if err != nil {
return cardUpdateVerificationError(bizID, err)
}
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof))
},
}
func cardCreateMissingBizIDError(created map[string]any) error {
return apperrors.NewAPI(
"卡片可能已经创建,但服务端未返回后续更新所需的 bizId;CLI 无法确认卡片工作流可继续",
apperrors.WithOperation("create_and_send_card"),
apperrors.WithServerKey("im"),
apperrors.WithOrigin("client_postcondition"),
apperrors.WithFailureStage("verify_card_reference"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithReason("streaming_card_reference_missing"),
apperrors.WithHint("不要盲目重试创建;请保留 trace_id 并推动服务端返回 bizId、openMessageId 和 openConversationId"),
apperrors.WithDetails(map[string]any{"created": created}),
)
}
func cardUpdateVerificationError(bizID string, verifyErr error) error {
reason := "streaming_card_update_unverified"
message := "服务端未返回卡片实际更新的证据;为避免假成功,CLI 已将本次操作判为失败"
hint := "请检查服务端是否返回 updated=true、affectedCount>0 或等价的明确更新结果"
switch {
case errors.Is(verifyErr, chatmsg.ErrCardUpdateNotApplied):
reason = "streaming_card_update_not_applied"
message = "服务端明确表示流式卡片没有被更新"
hint = "请确认 bizId 来自 send-card、当前账号有权限且卡片仍允许该状态转换"
case errors.Is(verifyErr, chatmsg.ErrCardUpdateBizIDDrift):
reason = "streaming_card_update_biz_id_mismatch"
message = "服务端返回的 bizId 与本次请求不一致;无法确认目标卡片已更新"
hint = "请保留 trace_id 并检查 update_streaming_card 的响应映射"
}
return apperrors.NewAPI(
message,
apperrors.WithOperation("update_streaming_card"),
apperrors.WithServerKey("im"),
apperrors.WithOrigin("client_postcondition"),
apperrors.WithFailureStage("verify_update_result"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
apperrors.WithHint(hint),
apperrors.WithDetails(map[string]any{"bizId": bizID}),
apperrors.WithCause(verifyErr),
)
}
// MessagesResourceURL gets a message resource download url (get_resource_download_url, im).
var MessagesResourceURL = shortcut.Shortcut{
Service: "chat",
@@ -360,7 +360,9 @@ func TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) {
})
}
helpers.InitDeps(&larkAlignmentCaller{})
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"result":{"updated":true}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"})
if err := root.Execute(); err != nil {
+279
View File
@@ -20,6 +20,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
type chatOutputErrorWriter struct {
@@ -124,6 +125,159 @@ func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeB
}
}
func TestCrossPlatformCoverageMessagesSendStatusAliasPublishesWorkflowReceipt(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/query_message_send_status": `{"result":{"status":"SUCCESS","openTaskId":"task-1","openMessageId":"msg-1","openConversationId":"cid-1"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+messages-send-status", "--open-task-id", "task-1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "query_message_send_status" || fake.calls[0].args["openTaskId"] != "task-1" {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["contractVersion"] != chatmsg.MessageSendStatusContractVersion || payload["readyForMessageActions"] != true {
t.Fatalf("payload = %#v", payload)
}
ref, _ := payload["messageRef"].(map[string]any)
if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
t.Fatalf("messageRef = %#v", ref)
}
}
func TestCrossPlatformCoverageMessageWorkflowFailureAndPreviewBranches(t *testing.T) {
t.Run("send status lower error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "im/query_message_send_status"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-query-send-status", "--open-task-id", "task-1"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "fixture lower call failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("create only dry run", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card", "--group", "cid", "--dry-run", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("dry-run made calls: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["actionCount"] != float64(1) || payload["executed"] != false {
t.Fatalf("payload = %#v", payload)
}
})
for _, test := range []struct {
name string
fake *larkAlignmentCaller
wantError string
}{
{
name: "create only lower error",
fake: &larkAlignmentCaller{failProductTool: "im/create_and_send_card"},
wantError: "fixture lower call failed",
},
{
name: "create only missing biz id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"created":true}}`,
}},
wantError: "未返回后续更新所需的 bizId",
},
} {
t.Run(test.name, func(t *testing.T) {
helpers.InitDeps(test.fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-send-card", "--group", "cid", "--yes"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("error = %v, want substring %q", err, test.wantError)
}
})
}
t.Run("update card lower error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "im/update_streaming_card"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "biz-1", "--content", "完成", "--flow-status", "3", "--yes",
})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "fixture lower call failed") {
t.Fatalf("error = %v", err)
}
})
createErr := cardCreateMissingBizIDError(map[string]any{"created": true})
var typed *apperrors.Error
if !errors.As(createErr, &typed) || typed.Reason != "streaming_card_reference_missing" {
t.Fatalf("create error = %#v", createErr)
}
for _, test := range []struct {
cause error
wantReason string
}{
{cause: chatmsg.ErrCardUpdateNotApplied, wantReason: "streaming_card_update_not_applied"},
{cause: chatmsg.ErrCardUpdateBizIDDrift, wantReason: "streaming_card_update_biz_id_mismatch"},
} {
typed = nil
err := cardUpdateVerificationError("biz-1", test.cause)
if !errors.As(err, &typed) || typed.Reason != test.wantReason {
t.Errorf("cardUpdateVerificationError(%v) = %#v", test.cause, err)
}
}
}
func TestCrossPlatformCoverageMessagesSendPublishesStatusQueryReceipt(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/send_personal_message": `{"result":{"openTaskId":"task-send-1"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send", "--as", "user", "--chat-id", "cid-1",
"--text", "hello", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
receipt, _ := payload["sendReceipt"].(map[string]any)
if receipt["contractVersion"] != chatmsg.MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-send-1" {
t.Fatalf("sendReceipt = %#v", receipt)
}
actions, _ := receipt["nextActions"].([]any)
if len(actions) != 1 {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
@@ -895,6 +1049,14 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
},
wantError: "biz-preserved",
},
{
name: "unverified update preserves id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}},
wantError: "biz-unverified",
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
@@ -929,6 +1091,120 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
}
func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) {
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"result":{"bizId":"biz-confirm","updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetIn(strings.NewReader(""))
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "biz-confirm",
"--content", "高层更新",
"--flow-status", "3",
})
err := root.Execute()
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
t.Fatalf("error = %#v, want confirmation_required", err)
}
if len(fake.calls) != 0 {
t.Fatalf("unconfirmed shortcut reached MCP: %#v", fake.calls)
}
})
t.Run("generic success is unverified", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "中文乱串",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
err := root.Execute()
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("explicit update evidence succeeds", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"result":{"bizId":"biz-verified","updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "biz-verified",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
})
t.Run("placeholder fails before write", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "<bizId>",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "占位符") {
t.Fatalf("error = %v, want placeholder validation", err)
}
if len(fake.calls) != 0 {
t.Fatalf("invalid placeholder made calls: %#v", fake.calls)
}
})
t.Run("dry run only publishes plan", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-update-card",
"--biz-id", "biz-preview",
"--content", "完成",
"--flow-status", "3",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("dry-run made calls: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["executed"] != false || payload["verified"] != false {
t.Fatalf("dry-run payload = %#v", payload)
}
})
}
func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
for _, tc := range []struct {
value any
@@ -1090,6 +1366,9 @@ func TestCrossPlatformCoverageMessageFileResourceDownloadUsesDriveAndPreservesNa
fake.calls[0].args["fileId"] != "drive-file" {
t.Fatalf("drive call = %#v", fake.calls)
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque"}}`,
}})
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
@@ -75,6 +75,8 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
text = string(payload)
case "im/create_group_conversation":
text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}`
case "im/create_and_send_card":
text = `{"result":{"bizId":"biz-created"}}`
case "im/list_messages_by_ids":
text = `{"result":[{"openMessageId":"msg","openConversationId":"cid","senderOpenDingTalkId":"D-inferred","content":"{\"mediaId\":\"@image\"}"}]}`
case "im/list_conversations_by_category":
+11 -4
View File
@@ -19,6 +19,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
@@ -372,12 +373,16 @@ func executeUnifiedMessageWrite(rt *shortcut.RuntimeContext, product, tool strin
if err != nil {
return err
}
return rt.Output(map[string]any{
payload := map[string]any{
"ok": true,
"identity": messagesSendIdentity(rt),
"tool": tool,
"result": data,
})
}
if messagesSendIdentity(rt) == "user" && tool == "send_personal_message" {
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
}
return rt.Output(payload)
}
func messagesSendIdentity(rt *shortcut.RuntimeContext) string {
@@ -612,7 +617,7 @@ func executeMessagesSendUserFile(
if err != nil {
return err
}
return rt.Output(map[string]any{
payload := map[string]any{
"ok": true,
"identity": "user",
"tool": "send_personal_message",
@@ -624,7 +629,9 @@ func executeMessagesSendUserFile(
"sizeBytes": meta.FileSize,
},
"result": data,
})
}
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
return rt.Output(payload)
}
func addMessagesSendUserTarget(params map[string]any, group, openID string) {
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chatmsg
import "strings"
// StreamingCardContractVersion identifies the additive card receipt emitted by
// high-level card shortcuts.
const StreamingCardContractVersion = "im.streaming-card.v1"
// ProjectStreamingCardReceipt publishes every server-returned identifier in a
// single cardRef. referencePairAvailable means this response contained both
// the update identifier and the visible message identifiers; it does not claim
// that older messages can be resolved without server-side mapping support.
func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[string]any {
bizID = strings.TrimSpace(bizID)
messageID := firstSendStatusString(created, "openMessageId", "messageId", "msgId")
conversationID := firstSendStatusString(created, "openConversationId", "conversationId", "openCid")
cardRef := map[string]any{}
if bizID != "" {
cardRef["bizId"] = bizID
}
if messageID != "" {
cardRef["openMessageId"] = messageID
}
if conversationID != "" {
cardRef["openConversationId"] = conversationID
}
pairAvailable := bizID != "" && messageID != "" && conversationID != ""
payload := map[string]any{
"contractVersion": StreamingCardContractVersion,
"ok": true,
"cardRef": cardRef,
"referencePairAvailable": pairAvailable,
"created": created,
"nextActions": []map[string]any{},
}
if bizID != "" {
payload["nextActions"] = []map[string]any{{
"cliPath": "chat +messages-update-card",
"arguments": map[string]any{
"biz-id": bizID,
},
"requiredArguments": []string{"content", "flow-status"},
"ready": false,
}}
}
if !pairAvailable {
payload["capabilityGap"] = "服务端尚未同时返回 bizId、openMessageId 和 openConversationId;CLI 只能保留本次响应,不能据此承诺从历史消息反向恢复 bizId"
}
return payload
}
// ProjectStreamingCardUpdate preserves the lower response while making the
// verified target explicit for downstream consumers.
func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any {
payload := cloneSendStatusMap(updated)
payload["contractVersion"] = StreamingCardContractVersion
payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)}
payload["verified"] = true
payload["verificationEvidence"] = proof
return payload
}
@@ -0,0 +1,42 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chatmsg
import "testing"
func TestCrossPlatformCoverageProjectStreamingCardReceipt(t *testing.T) {
complete := ProjectStreamingCardReceipt(map[string]any{
"result": map[string]any{
"bizId": "biz-1",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
},
}, "biz-1")
if complete["contractVersion"] != StreamingCardContractVersion || complete["referencePairAvailable"] != true {
t.Fatalf("complete receipt = %#v", complete)
}
ref, _ := complete["cardRef"].(map[string]any)
if ref["bizId"] != "biz-1" || ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
t.Fatalf("cardRef = %#v", ref)
}
if _, exists := complete["capabilityGap"]; exists {
t.Fatalf("complete receipt has capability gap: %#v", complete)
}
partial := ProjectStreamingCardReceipt(map[string]any{"result": map[string]any{"bizId": "biz-2"}}, "biz-2")
if partial["referencePairAvailable"] != false || partial["capabilityGap"] == "" {
t.Fatalf("partial receipt = %#v", partial)
}
}
func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) {
payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", "updated=true")
if payload["contractVersion"] != StreamingCardContractVersion || payload["verified"] != true || payload["verificationEvidence"] != "updated=true" {
t.Fatalf("payload = %#v", payload)
}
if _, exists := payload["result"]; !exists {
t.Fatal("lower response was not preserved")
}
}
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2026 Alibaba Group
// 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.
package chatmsg
import (
"encoding/json"
"errors"
"fmt"
"strings"
"unicode"
)
var (
ErrCardUpdateNotApplied = errors.New("streaming card update was not applied")
ErrCardUpdateUnverified = errors.New("streaming card update could not be verified")
ErrCardUpdateBizIDDrift = errors.New("streaming card update returned a different bizId")
)
// NormalizeCardBizID performs only format-independent checks. bizId is an
// opaque server-issued identifier; a stricter character or prefix contract
// must not be invented by the CLI without an authoritative API declaration.
func NormalizeCardBizID(raw string) (string, error) {
bizID := strings.TrimSpace(raw)
if bizID == "" {
return "", fmt.Errorf("--biz-id 不能为空")
}
for _, r := range bizID {
if unicode.IsControl(r) || unicode.IsSpace(r) {
return "", fmt.Errorf("--biz-id 必须是 send-card 返回的单个不透明 ID,不能包含空白或控制字符")
}
}
if isCardBizIDPlaceholder(bizID) {
return "", fmt.Errorf("--biz-id 仍是占位符 %q;请传入 send-card 实际返回的 bizId", bizID)
}
return bizID, nil
}
func isCardBizIDPlaceholder(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
case "bizid", "biz-id", "your-biz-id", "your_biz_id",
"<bizid>", "<biz-id>", "<your-biz-id>",
"{bizid}", "{biz-id}", "${bizid}", "${biz-id}":
return true
default:
return false
}
}
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
// write took effect. A transport-level {success:true,errorCode:null} only says
// that the RPC returned normally and is deliberately not accepted as proof of
// a card update.
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
requestedBizID = strings.TrimSpace(requestedBizID)
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
observeCardUpdate(response, &observation)
for responseBizID := range observation.bizIDs {
if requestedBizID != "" && responseBizID != requestedBizID {
return "", fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID)
}
}
if observation.positiveEvidence != "" && observation.negativeEvidence != "" {
return "", fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, observation.positiveEvidence, observation.negativeEvidence)
}
if observation.positiveEvidence != "" {
return observation.positiveEvidence, nil
}
if observation.negativeEvidence != "" {
return "", fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence)
}
return "", ErrCardUpdateUnverified
}
type cardUpdateObservation struct {
bizIDs map[string]struct{}
positiveEvidence string
negativeEvidence string
}
func observeCardUpdate(value any, observation *cardUpdateObservation) {
switch typed := value.(type) {
case map[string]any:
observeCardUpdateMap(typed, observation)
case []any:
for _, child := range typed {
observeCardUpdate(child, observation)
}
case bool:
if typed {
setPositiveCardUpdateEvidence(observation, "result=true")
} else {
setNegativeCardUpdateEvidence(observation, "result=false")
}
}
}
func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservation) {
for _, key := range []string{"bizId", "bizID", "biz_id"} {
if candidate, ok := value[key].(string); ok && strings.TrimSpace(candidate) != "" {
observation.bizIDs[strings.TrimSpace(candidate)] = struct{}{}
}
}
for _, key := range []string{"updated", "applied"} {
if applied, ok := value[key].(bool); ok {
if applied {
setPositiveCardUpdateEvidence(observation, key+"=true")
} else {
setNegativeCardUpdateEvidence(observation, key+"=false")
}
}
}
for _, key := range []string{"affectedCount", "updatedCount", "modifiedCount"} {
if count, ok := cardUpdateCount(value[key]); ok {
if count > 0 {
setPositiveCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count))
} else if count == 0 {
setNegativeCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count))
}
}
}
// Only documented response envelopes are traversed. This prevents an
// unrelated extension field containing "updated":true from proving the
// business write.
for _, key := range []string{"result", "data", "response", "card"} {
if child, exists := value[key]; exists {
observeCardUpdate(child, observation)
}
}
}
func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
if observation.positiveEvidence == "" {
observation.positiveEvidence = evidence
}
}
func setNegativeCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
if observation.negativeEvidence == "" {
observation.negativeEvidence = evidence
}
}
func cardUpdateCount(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int32:
return int64(typed), true
case int64:
return typed, true
case float32:
return int64(typed), float32(int64(typed)) == typed
case float64:
return int64(typed), float64(int64(typed)) == typed
case json.Number:
count, err := typed.Int64()
return count, err == nil
default:
return 0, false
}
}
@@ -0,0 +1,107 @@
// Copyright 2026 Alibaba Group
// 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.
package chatmsg
import (
"encoding/json"
"errors"
"testing"
)
func TestCrossPlatformCoverageNormalizeCardBizID(t *testing.T) {
for _, test := range []struct {
name string
raw string
want string
wantErr bool
}{
{name: "opaque id", raw: " card-token-1 ", want: "card-token-1"},
{name: "opaque unicode remains server owned", raw: "中文乱串", want: "中文乱串"},
{name: "empty", raw: " ", wantErr: true},
{name: "placeholder", raw: "<bizId>", wantErr: true},
{name: "internal space", raw: "card token", wantErr: true},
{name: "control", raw: "card\ntoken", wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
got, err := NormalizeCardBizID(test.raw)
if test.wantErr {
if err == nil {
t.Fatalf("NormalizeCardBizID(%q) unexpectedly succeeded", test.raw)
}
return
}
if err != nil || got != test.want {
t.Fatalf("NormalizeCardBizID(%q) = %q, %v; want %q", test.raw, got, err, test.want)
}
})
}
}
func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
for _, test := range []struct {
name string
response map[string]any
wantProof string
wantErrIs error
}{
{name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, wantProof: "updated=true"},
{name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, wantProof: "affectedCount=1"},
{name: "boolean result", response: map[string]any{"result": true}, wantProof: "result=true"},
{name: "boolean false result", response: map[string]any{"result": false}, wantErrIs: ErrCardUpdateNotApplied},
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"},
{name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified},
{name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied},
{name: "false success has no write proof", response: map[string]any{"success": true, "errorCode": nil}, wantErrIs: ErrCardUpdateUnverified},
{name: "explicitly not updated", response: map[string]any{"result": map[string]any{"updated": false}}, wantErrIs: ErrCardUpdateNotApplied},
{name: "mismatched id", response: map[string]any{"result": map[string]any{"bizId": "biz-2", "updated": true}}, wantErrIs: ErrCardUpdateBizIDDrift},
{name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified},
} {
t.Run(test.name, func(t *testing.T) {
proof, err := VerifyStreamingCardUpdate("biz-1", test.response)
if test.wantErrIs != nil {
if !errors.Is(err, test.wantErrIs) {
t.Fatalf("VerifyStreamingCardUpdate error = %v, want errors.Is(_, %v)", err, test.wantErrIs)
}
return
}
if err != nil || proof != test.wantProof {
t.Fatalf("VerifyStreamingCardUpdate = %q, %v; want %q", proof, err, test.wantProof)
}
})
}
}
func TestCrossPlatformCoverageCardUpdateCountScalarVariants(t *testing.T) {
for _, test := range []struct {
value any
want int64
ok bool
}{
{value: int(1), want: 1, ok: true},
{value: int32(2), want: 2, ok: true},
{value: int64(3), want: 3, ok: true},
{value: float32(4), want: 4, ok: true},
{value: float32(4.5), want: 4, ok: false},
{value: float64(5), want: 5, ok: true},
{value: float64(5.5), want: 5, ok: false},
{value: json.Number("6"), want: 6, ok: true},
{value: json.Number("6.5"), ok: false},
{value: "7", ok: false},
} {
got, ok := cardUpdateCount(test.value)
if got != test.want || ok != test.ok {
t.Errorf("cardUpdateCount(%#v) = (%d, %v), want (%d, %v)", test.value, got, ok, test.want, test.ok)
}
}
}
+174 -7
View File
@@ -91,6 +91,7 @@ var messageResultContractV1 = MessageResultContract{
"failedCount",
"failures",
"partial",
"scope",
"resourceDownloads",
},
}
@@ -283,9 +284,10 @@ func SenderType(m map[string]any) any {
// legacy aliases such as time or msgType, but the underlying identity,
// context, reaction, quote, forward, and resource semantics come from here.
func ProjectMessageV1(m map[string]any, includeReactions bool) map[string]any {
ownedResources := Resources(m)
row := map[string]any{
"sender": Sender(m),
"text": Text(m),
"text": projectedResourceText(m, ownedResources),
"createTime": CreateTime(m),
}
if value := MessageID(m); value != nil {
@@ -357,7 +359,8 @@ func QuotedMessage(m map[string]any) map[string]any {
if value := Sender(quoted); value != nil {
out["sender"] = value
}
if value := Text(quoted); value != nil {
resources := Resources(quoted)
if value := projectedResourceText(quoted, resources); value != nil {
out["text"] = value
}
if value := CreateTime(quoted); value != nil {
@@ -366,7 +369,7 @@ func QuotedMessage(m map[string]any) map[string]any {
if value := MessageType(quoted); value != nil {
out["messageType"] = value
}
if resources := Resources(quoted); len(resources) > 0 {
if len(resources) > 0 {
out["resourceRefs"] = resources
}
return out
@@ -400,10 +403,14 @@ func Resources(m map[string]any) []map[string]any {
collectResourceIDs(m, "mediaid", mediaIDTextRE, &mediaIDs)
mediaIDs = uniqueResourceIDs(mediaIDs)
sort.Strings(mediaIDs)
mediaNames := make(map[string]resourceNameCandidate)
collectResourceNames(m, "mediaid", mediaNames)
fileIDs := make([]string, 0)
collectResourceIDs(m, "fileid", fileIDTextRE, &fileIDs)
fileIDs = uniqueResourceIDs(fileIDs)
sort.Strings(fileIDs)
fileNames := make(map[string]resourceNameCandidate)
collectResourceNames(m, "fileid", fileNames)
if len(mediaIDs) == 0 && len(fileIDs) == 0 {
return nil
}
@@ -434,7 +441,7 @@ func Resources(m map[string]any) []map[string]any {
} else {
missing = append(missing, "open-conversation-id")
}
out = append(out, map[string]any{
resource := map[string]any{
"type": "mediaId",
"resourceId": id,
"download": map[string]any{
@@ -443,10 +450,14 @@ func Resources(m map[string]any) []map[string]any {
"ready": len(missing) == 0,
"missing": missing,
},
})
}
if candidate, ok := mediaNames[id]; ok {
resource["name"] = candidate.name
}
out = append(out, resource)
}
for _, id := range fileIDs {
out = append(out, map[string]any{
resource := map[string]any{
"type": "fileId",
"resourceId": id,
"download": map[string]any{
@@ -458,7 +469,11 @@ func Resources(m map[string]any) []map[string]any {
"ready": true,
"missing": []string{},
},
})
}
if candidate, ok := fileNames[id]; ok {
resource["name"] = candidate.name
}
out = append(out, resource)
}
return out
}
@@ -503,6 +518,158 @@ func resourcesDeep(m map[string]any, inheritedConversationID string, depth int)
var mediaIDTextRE = regexp.MustCompile(`(?i)\bmedia[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileIDTextRE = regexp.MustCompile(`(?i)\bfile[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileNameAndIDTextRE = regexp.MustCompile(`(?i)\[文件\]\s*([^\r\n]*?)\s+file[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var legacyResourceDownloadHintRE = regexp.MustCompile(`\s*注意:如需下载使用dws\s+(?:chat message download-media|drive download)命令下载\s*`)
// projectedResourceText removes only the exact, machine-generated download
// hint emitted by older IM APIs. The readable resource marker and ID remain in
// text, while resourceRefs publishes the current executable download command.
// Text without an owned mediaId/fileId is left byte-for-byte unchanged so an
// ordinary user sentence mentioning a command can never be rewritten.
func projectedResourceText(m map[string]any, resources []map[string]any) any {
value := Text(m)
text, ok := value.(string)
if !ok || len(resources) == 0 ||
(!mediaIDTextRE.MatchString(text) && !fileIDTextRE.MatchString(text)) {
return value
}
return strings.TrimSpace(legacyResourceDownloadHintRE.ReplaceAllString(text, ""))
}
type resourceNameCandidate struct {
name string
priority int
}
const (
resourceNamePriorityText = 1
resourceNamePriorityStructured = 2
)
// collectResourceNames keeps a resource ID paired with a name only when both
// are present in the same structured object or in the legacy, machine-shaped
// "[文件] name fileId: id" text. This deliberately does not borrow a generic
// message title or sender name: an unknown resource name is safer than a
// plausible but incorrect one.
func collectResourceNames(value any, targetKey string, out map[string]resourceNameCandidate) {
switch typed := value.(type) {
case map[string]any:
directIDs := directResourceIDs(typed, targetKey)
if len(directIDs) == 1 {
if name := directResourceName(typed, targetKey); name != "" {
recordResourceName(out, directIDs[0], name, resourceNamePriorityStructured)
}
}
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if isNestedMessageBoundaryKey(normalizeMessageKey(key)) {
continue
}
collectResourceNames(typed[key], targetKey, out)
}
case []any:
for _, child := range typed {
collectResourceNames(child, targetKey, out)
}
case []map[string]any:
for _, child := range typed {
collectResourceNames(child, targetKey, out)
}
case string:
if targetKey == "fileid" {
for _, match := range fileNameAndIDTextRE.FindAllStringSubmatch(typed, -1) {
name := strings.TrimSpace(match[1])
id := resourceIDScalar(match[2])
if name != "" && id != "" {
recordResourceName(out, id, name, resourceNamePriorityText)
}
}
}
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
collectResourceNames(decoded, targetKey, out)
}
}
}
}
func directResourceIDs(value map[string]any, targetKey string) []string {
resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint(
firstMessageValue(value, "resourceType", "resource_type"))))
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
ids := make([]string, 0, 1)
for _, key := range keys {
normalizedKey := normalizeMessageKey(key)
if normalizedKey != targetKey &&
!(normalizedKey == "resourceid" && resourceType == targetKey) {
continue
}
if id := resourceIDScalar(value[key]); id != "" {
ids = append(ids, id)
}
}
return uniqueResourceIDs(ids)
}
func directResourceName(value map[string]any, targetKey string) string {
for _, wanted := range []string{"filename", "resourcename", "originalfilename"} {
if name := directResourceString(value, wanted); name != "" {
return name
}
}
// A bare "name" is accepted only inside an explicit resource envelope.
// Message rows also commonly contain a sender/group name, which must never
// become the attachment filename merely because the row has a resource ID.
resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint(
firstMessageValue(value, "resourceType", "resource_type"))))
if resourceType == targetKey && directResourceString(value, "resourceid") != "" {
return directResourceString(value, "name")
}
return ""
}
func directResourceString(value map[string]any, wanted string) string {
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if normalizeMessageKey(key) != wanted {
continue
}
if text, ok := value[key].(string); ok {
return strings.TrimSpace(text)
}
}
return ""
}
func recordResourceName(
out map[string]resourceNameCandidate,
id, name string,
priority int,
) {
id = resourceIDScalar(id)
name = strings.TrimSpace(name)
if id == "" || name == "" {
return
}
if current, ok := out[id]; ok && current.priority >= priority {
return
}
out[id] = resourceNameCandidate{name: name, priority: priority}
}
func collectResourceIDs(value any, targetKey string, textPattern *regexp.Regexp, out *[]string) {
switch typed := value.(type) {
+22
View File
@@ -540,6 +540,28 @@ func TestCrossPlatformCoverageResourcesBuildsActionableDownloadReferences(t *tes
}
}
func TestCrossPlatformCoverageResourcesKeepsNestedResourceNamesWithTheirOwner(t *testing.T) {
message := map[string]any{
"openMessageId": "parent-message",
"openConversationId": "cid-1",
"content": `{"fileId":"parent-file","fileName":"parent.pdf"}`,
"quotedMessage": map[string]any{
"openMessageId": "quoted-message",
"content": `{"fileId":"quoted-file","file_name":"quoted.pdf"}`,
},
}
resources := ResourcesDeep(message)
if len(resources) != 2 {
t.Fatalf("resources = %#v", resources)
}
if resources[0]["resourceId"] != "parent-file" || resources[0]["name"] != "parent.pdf" {
t.Fatalf("parent resource = %#v", resources[0])
}
if resources[1]["resourceId"] != "quoted-file" || resources[1]["name"] != "quoted.pdf" {
t.Fatalf("quoted resource = %#v", resources[1])
}
}
func TestCrossPlatformCoverageResourcesReportsMissingDownloadContext(t *testing.T) {
resources := Resources(map[string]any{"content": `{"mediaId":"@image-a"}`})
if len(resources) != 1 {
@@ -28,16 +28,18 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
}
resources := Resources(map[string]any{
"attachments": []map[string]any{
{"resourceType": "mediaId", "resourceId": "@file-a"},
{"resourceType": "fileId", "resourceId": "drive-file"},
{"resourceType": "mediaId", "resourceId": "@file-a", "name": "photo.png"},
{"resourceType": "fileId", "resourceId": "drive-file", "fileName": "canonical-report.txt"},
{"mediaId": 42, "fileId": 42},
},
"content": `[文件] report.txt fileId: drive-file`,
})
if len(resources) != 2 ||
resources[0]["resourceId"] != "@file-a" ||
resources[0]["name"] != "photo.png" ||
resources[1]["resourceId"] != "drive-file" ||
resources[1]["type"] != "fileId" {
resources[1]["type"] != "fileId" ||
resources[1]["name"] != "canonical-report.txt" {
t.Fatalf("resources = %#v", resources)
}
fileDownload := resources[1]["download"].(map[string]any)
@@ -53,6 +55,65 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
}
}
func TestCrossPlatformCoverageResourcesExtractsLegacyFileNameWithoutGuessing(t *testing.T) {
resources := Resources(map[string]any{
"name": "sender-name-must-not-leak",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
"content": `[文件] 项目最终报告 2026.pdf fileId: drive-file 注意:如需下载使用旧命令`,
"unrelatedAttachment": map[string]any{"mediaId": "@image-without-name"},
})
if len(resources) != 2 {
t.Fatalf("resources = %#v", resources)
}
if resources[0]["resourceId"] != "@image-without-name" {
t.Fatalf("first resource = %#v", resources[0])
}
if _, leaked := resources[0]["name"]; leaked {
t.Fatalf("message sender name leaked into media resource: %#v", resources[0])
}
if resources[1]["resourceId"] != "drive-file" ||
resources[1]["name"] != "项目最终报告 2026.pdf" {
t.Fatalf("file resource = %#v", resources[1])
}
}
func TestCrossPlatformCoverageResourceNameRejectsIncompletePairs(t *testing.T) {
names := map[string]resourceNameCandidate{}
recordResourceName(names, "", "report.pdf", resourceNamePriorityStructured)
recordResourceName(names, "file-1", "", resourceNamePriorityStructured)
if len(names) != 0 {
t.Fatalf("incomplete resource-name pairs were retained: %#v", names)
}
}
func TestCrossPlatformCoverageProjectionRemovesOnlyLegacyResourceDownloadHint(t *testing.T) {
legacy := `[文件] 项目最终报告 2026.pdf fileId: drive-file 注意:如需下载使用dws drive download命令下载`
row := ProjectMessageV1(map[string]any{"content": legacy}, false)
if row["text"] != `[文件] 项目最终报告 2026.pdf fileId: drive-file` {
t.Fatalf("projected text = %#v", row["text"])
}
resources := row["resourceRefs"].([]map[string]any)
if len(resources) != 1 ||
resources[0]["name"] != "项目最终报告 2026.pdf" ||
resources[0]["download"].(map[string]any)["shortcut"] != "+messages-resource-download" {
t.Fatalf("projected resources = %#v", resources)
}
mediaRow := ProjectMessageV1(map[string]any{
"openMessageId": "msg-media",
"openConversationId": "cid-media",
"content": `[图片消息](mediaId=@media) 注意:如需下载使用dws chat message download-media命令下载`,
}, false)
if mediaRow["text"] != `[图片消息](mediaId=@media)` {
t.Fatalf("projected media text = %#v", mediaRow["text"])
}
ordinary := `团队规范:注意:如需下载使用dws drive download命令下载`
if got := ProjectMessageV1(map[string]any{"content": ordinary}, false)["text"]; got != ordinary {
t.Fatalf("ordinary text was rewritten: got %#v, want %q", got, ordinary)
}
}
func TestCrossPlatformCoverageReactionShapeVariants(t *testing.T) {
got := Reactions(map[string]any{
"reactions": []map[string]any{
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2026 Alibaba Group
// 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.
package chatmsg
import (
"fmt"
"strings"
)
// SearchItems locates and flattens the message list returned by the two
// DingTalk message-search interfaces. Grouped search responses carry the
// conversation identity on the group rather than each message, so the
// flattener copies that identity onto every returned message before callers
// perform scope checks.
func SearchItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, childMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return flattenSearchGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return searchMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, innerKey := range []string{"list", "messages", "messageList", "items", "data", "records"} {
if arr, ok := inner[innerKey].([]any); ok {
return searchMaps(arr)
}
}
}
}
return nil
}
func childMap(data map[string]any, key string) map[string]any {
if value, ok := data[key].(map[string]any); ok {
return value
}
return nil
}
func flattenSearchGroups(groups []any) []map[string]any {
out := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := cleanSearchScalar(group["openConversationId"])
conversationTitle := cleanSearchScalar(group["title"])
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists && conversationID != "" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists && conversationTitle != "" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMaps(items []any) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if message, ok := item.(map[string]any); ok {
out = append(out, message)
}
}
return out
}
// FilterConversationScope keeps only messages belonging to the explicitly
// requested conversations. A message without a conversation identity is
// reported as unverifiable rather than treated as in-scope.
func FilterConversationScope(messages []map[string]any, conversationIDs []string) (matched []map[string]any, unverifiableMessageIDs []string) {
requested := make(map[string]struct{}, len(conversationIDs))
for _, conversationID := range conversationIDs {
if value := strings.TrimSpace(conversationID); value != "" {
requested[value] = struct{}{}
}
}
matched = make([]map[string]any, 0, len(messages))
for _, message := range messages {
conversationID := cleanSearchScalar(ConversationID(message))
if conversationID == "" {
messageID := cleanSearchScalar(MessageID(message))
if messageID == "" {
messageID = "<unknown>"
}
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
continue
}
if _, ok := requested[conversationID]; ok {
matched = append(matched, message)
}
}
return matched, uniqueStrings(unverifiableMessageIDs)
}
// GroupSearchMessages restores the established typed search envelope after a
// client-side scoped scan. Group order follows first occurrence in the search
// result, and each message retains its original fields.
func GroupSearchMessages(messages []map[string]any) []map[string]any {
groups := make([]map[string]any, 0)
index := make(map[string]int)
for _, message := range messages {
conversationID := cleanSearchScalar(ConversationID(message))
if conversationID == "" {
continue
}
groupIndex, ok := index[conversationID]
if !ok {
group := map[string]any{
"openConversationId": conversationID,
"messages": []map[string]any{},
}
if title := cleanSearchScalar(message["conversationTitle"]); title != "" {
group["title"] = title
}
if singleChat, exists := message["singleChat"]; exists {
group["singleChat"] = singleChat
}
groups = append(groups, group)
groupIndex = len(groups) - 1
index[conversationID] = groupIndex
}
groupMessages := groups[groupIndex]["messages"].([]map[string]any)
groups[groupIndex]["messages"] = append(groupMessages, message)
}
return groups
}
func cleanSearchScalar(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || strings.EqualFold(text, "null") {
return ""
}
return text
}
func uniqueStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright 2026 Alibaba Group
// 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.
package chatmsg
import (
"reflect"
"testing"
)
func TestSearchItemsCarriesGroupedConversationIdentity(t *testing.T) {
data := map[string]any{
"result": map[string]any{
"conversationMessagesList": []any{
map[string]any{
"openConversationId": "cid-group",
"title": "项目群",
"singleChat": false,
"messages": []any{
map[string]any{"openMessageId": "m1", "content": "hello"},
},
},
},
},
}
items := SearchItems(data)
if len(items) != 1 || items[0]["openConversationId"] != "cid-group" ||
items[0]["conversationTitle"] != "项目群" || items[0]["singleChat"] != false {
t.Fatalf("items = %#v", items)
}
}
func TestFilterConversationScopeDropsOtherConversationsAndRejectsMissingIdentity(t *testing.T) {
messages := []map[string]any{
{"openMessageId": "m1", "openConversationId": "cid-target"},
{"openMessageId": "m2", "openConversationId": "cid-other"},
{"openMessageId": "m3"},
}
matched, missing := FilterConversationScope(messages, []string{"cid-target"})
if len(matched) != 1 || matched[0]["openMessageId"] != "m1" {
t.Fatalf("matched = %#v", matched)
}
if !reflect.DeepEqual(missing, []string{"m3"}) {
t.Fatalf("missing = %#v", missing)
}
}
func TestGroupSearchMessagesPreservesFirstSeenOrder(t *testing.T) {
messages := []map[string]any{
{"openMessageId": "m1", "openConversationId": "cid-b", "conversationTitle": "B"},
{"openMessageId": "m2", "openConversationId": "cid-a", "conversationTitle": "A"},
{"openMessageId": "m3", "openConversationId": "cid-b", "conversationTitle": "B"},
}
groups := GroupSearchMessages(messages)
if len(groups) != 2 || groups[0]["openConversationId"] != "cid-b" || groups[1]["openConversationId"] != "cid-a" {
t.Fatalf("groups = %#v", groups)
}
groupMessages, _ := groups[0]["messages"].([]map[string]any)
if len(groupMessages) != 2 || groupMessages[1]["openMessageId"] != "m3" {
t.Fatalf("group messages = %#v", groupMessages)
}
}
func TestCrossPlatformCoverageSearchProjectionEdgeBranches(t *testing.T) {
if SearchItems(nil) != nil {
t.Fatal("nil search response returned messages")
}
matched, missing := FilterConversationScope(
[]map[string]any{{}},
[]string{"", "cid-target"},
)
if len(matched) != 0 || !reflect.DeepEqual(missing, []string{"<unknown>"}) {
t.Fatalf("scope result = matched:%#v missing:%#v", matched, missing)
}
groups := GroupSearchMessages([]map[string]any{
{"openMessageId": "missing-scope"},
{"openMessageId": "m1", "openConversationId": "cid-1", "singleChat": true},
})
if len(groups) != 1 || groups[0]["singleChat"] != true {
t.Fatalf("groups = %#v", groups)
}
if cleanSearchScalar(nil) != "" || cleanSearchScalar(" null ") != "" || cleanSearchScalar(" value ") != "value" {
t.Fatal("cleanSearchScalar did not normalize sentinel values")
}
if got := uniqueStrings([]string{"m1", "m1", "m2"}); !reflect.DeepEqual(got, []string{"m1", "m2"}) {
t.Fatalf("uniqueStrings = %#v", got)
}
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chatmsg
import (
"strings"
)
// MessageSendStatusContractVersion identifies the additive workflow fields
// projected by the high-level send-status shortcut. The lower response fields
// remain at their original locations for compatibility.
const MessageSendStatusContractVersion = "im.message-send-status.v1"
// MessageSendReceiptContractVersion identifies the additive receipt attached
// to high-level current-user send results.
const MessageSendReceiptContractVersion = "im.message-send-receipt.v1"
// ProjectMessageSendReceipt connects a send result to its asynchronous status
// query without treating openTaskId as a message identifier.
func ProjectMessageSendReceipt(raw map[string]any) map[string]any {
taskID := firstSendStatusString(raw, "openTaskId", "taskId")
messageID := firstSendStatusString(raw, "openMessageId", "messageId", "msgId")
conversationID := firstSendStatusString(raw, "openConversationId", "conversationId", "openCid")
ready := messageID != "" && conversationID != ""
receipt := map[string]any{
"contractVersion": MessageSendReceiptContractVersion,
"openTaskId": taskID,
"readyForMessageActions": ready,
"nextActions": []map[string]any{},
}
if messageID != "" || conversationID != "" {
messageRef := map[string]any{}
if messageID != "" {
messageRef["openMessageId"] = messageID
}
if conversationID != "" {
messageRef["openConversationId"] = conversationID
}
receipt["messageRef"] = messageRef
}
switch {
case ready:
receipt["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, true)
case taskID != "":
receipt["nextActions"] = []map[string]any{{
"cliPath": "chat +messages-query-send-status",
"arguments": map[string]any{
"open-task-id": taskID,
},
"ready": true,
"when": "需要确认投递结果或取得真实消息 ID 时",
}}
default:
receipt["capabilityGap"] = "下层发送响应未返回 openTaskId 或完整 messageRef,CLI 无法生成后续状态查询"
}
return receipt
}
// ProjectMessageSendStatus preserves the lower response and adds a stable
// receipt that connects openTaskId to the message identifiers required by
// edit, recall, and read-status. It never manufactures a message reference:
// downstream actions are marked ready only when both IDs are actually present.
func ProjectMessageSendStatus(raw map[string]any, requestedTaskID string) map[string]any {
payload := cloneSendStatusMap(raw)
taskID := firstSendStatusString(payload, "openTaskId", "taskId")
if taskID == "" {
taskID = strings.TrimSpace(requestedTaskID)
}
messageID := firstSendStatusString(payload, "openMessageId", "messageId", "msgId")
conversationID := firstSendStatusString(payload, "openConversationId", "conversationId", "openCid")
payload["contractVersion"] = MessageSendStatusContractVersion
payload["openTaskId"] = taskID
messageRef := map[string]any{}
if messageID != "" {
messageRef["openMessageId"] = messageID
}
if conversationID != "" {
messageRef["openConversationId"] = conversationID
}
if len(messageRef) > 0 {
payload["messageRef"] = messageRef
}
ready := messageID != "" && conversationID != ""
payload["readyForMessageActions"] = ready
payload["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, ready)
return payload
}
func sendStatusNextActions(taskID, messageID, conversationID string, ready bool) []map[string]any {
if !ready {
return []map[string]any{{
"cliPath": "chat message query-send-status",
"arguments": map[string]any{
"open-task-id": taskID,
},
"ready": false,
"when": "投递任务尚未返回 openMessageId 和 openConversationId 时稍后重查",
}}
}
messageArgs := map[string]any{
"conversation-id": conversationID,
"msg-id": messageID,
}
return []map[string]any{
{
"cliPath": "chat message recall",
"arguments": cloneSendStatusMap(messageArgs),
"ready": true,
},
{
"cliPath": "chat message edit",
"arguments": map[string]any{
"conversation-id": conversationID,
"msg-id": messageID,
},
"requiredArguments": []string{"text 或 content"},
"ready": false,
},
{
"cliPath": "chat message read-status",
"arguments": map[string]any{
"conversation-id": conversationID,
"message-id": messageID,
},
"ready": true,
},
}
}
func firstSendStatusString(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
return strings.TrimSpace(candidate)
}
}
for _, key := range []string{"result", "data", "response", "content", "message"} {
if candidate := firstSendStatusString(typed[key], keys...); candidate != "" {
return candidate
}
}
case []any:
for _, item := range typed {
if candidate := firstSendStatusString(item, keys...); candidate != "" {
return candidate
}
}
}
return ""
}
func cloneSendStatusMap(source map[string]any) map[string]any {
clone := make(map[string]any, len(source)+5)
for key, value := range source {
clone[key] = value
}
return clone
}
@@ -0,0 +1,94 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package chatmsg
import "testing"
func TestCrossPlatformCoverageProjectMessageSendReceiptLinksStatusQuery(t *testing.T) {
receipt := ProjectMessageSendReceipt(map[string]any{
"result": map[string]any{"openTaskId": "task-1"},
})
if receipt["contractVersion"] != MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-1" || receipt["readyForMessageActions"] != false {
t.Fatalf("receipt = %#v", receipt)
}
actions, _ := receipt["nextActions"].([]map[string]any)
if len(actions) != 1 || actions[0]["cliPath"] != "chat +messages-query-send-status" || actions[0]["ready"] != true {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageProjectMessageSendReceiptReadyWorkflow(t *testing.T) {
receipt := ProjectMessageSendReceipt(map[string]any{
"openTaskId": "task-ready",
"openMessageId": "msg-ready",
"openConversationId": "cid-ready",
})
if receipt["readyForMessageActions"] != true {
t.Fatalf("receipt = %#v", receipt)
}
ref, _ := receipt["messageRef"].(map[string]any)
if ref["openMessageId"] != "msg-ready" || ref["openConversationId"] != "cid-ready" {
t.Fatalf("messageRef = %#v", ref)
}
actions, _ := receipt["nextActions"].([]map[string]any)
if len(actions) != 3 || actions[0]["ready"] != true {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageProjectMessageSendStatusReadyWorkflow(t *testing.T) {
raw := map[string]any{
"result": map[string]any{
"openTaskId": "task-1",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
"status": "SUCCESS",
},
}
payload := ProjectMessageSendStatus(raw, "ignored")
if payload["contractVersion"] != MessageSendStatusContractVersion ||
payload["openTaskId"] != "task-1" || payload["readyForMessageActions"] != true {
t.Fatalf("payload = %#v", payload)
}
ref, _ := payload["messageRef"].(map[string]any)
if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
t.Fatalf("messageRef = %#v", ref)
}
actions, _ := payload["nextActions"].([]map[string]any)
if len(actions) != 3 || actions[0]["cliPath"] != "chat message recall" || actions[2]["cliPath"] != "chat message read-status" {
t.Fatalf("nextActions = %#v", actions)
}
if _, ok := payload["result"]; !ok {
t.Fatal("raw response field was not preserved")
}
}
func TestCrossPlatformCoverageProjectMessageSendStatusPendingDoesNotInventMessageRef(t *testing.T) {
payload := ProjectMessageSendStatus(map[string]any{
"result": map[string]any{"status": "PENDING"},
}, "task-pending")
if payload["openTaskId"] != "task-pending" || payload["readyForMessageActions"] != false {
t.Fatalf("payload = %#v", payload)
}
if _, exists := payload["messageRef"]; exists {
t.Fatalf("pending payload invented messageRef: %#v", payload)
}
actions, _ := payload["nextActions"].([]map[string]any)
if len(actions) != 1 || actions[0]["ready"] != false {
t.Fatalf("nextActions = %#v", actions)
}
}
func TestCrossPlatformCoverageFirstSendStatusStringTraversesArrays(t *testing.T) {
value := []any{
nil,
map[string]any{"result": []any{
map[string]any{"openTaskId": " task-from-array "},
}},
}
if got := firstSendStatusString(value, "openTaskId"); got != "task-from-array" {
t.Fatalf("firstSendStatusString() = %q", got)
}
}
@@ -72,8 +72,9 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
text = f.chatMessagesResult
}
case "im/search_messages":
if f.searchMessagesResult != "" {
text = f.searchMessagesResult
text = f.searchMessagesResult
if text == "" {
text = `{"result":{"messages":[],"hasMore":false}}`
}
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
@@ -405,7 +406,8 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
argv: []string{"chat", "+search-msg", "--id", "cid-1", "--keyword", "树莓派", "--no-enrich", "--yes"},
wantProduct: "im",
wantTool: "search_messages",
wantArgs: map[string]any{"openConversationIds": []string{"cid-1"}, "keyword": "树莓派"},
wantArgs: map[string]any{"keyword": "树莓派"},
wantAbsent: []string{"openConversationIds"},
},
}
@@ -323,15 +323,18 @@ func TestCrossPlatformCoverageSearchMsgResolvesNaturalChatAndSenderBeforeSearch(
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 3 {
t.Fatalf("calls = %#v, want chat resolve + user resolve + search", fake.calls)
if len(fake.calls) != 4 {
t.Fatalf("calls = %#v, want chat resolve + user resolve + scope validation + search", fake.calls)
}
search := fake.calls[2]
if preflight := fake.calls[2]; preflight.product != "chat" || preflight.tool != "get_conversation_info" || preflight.args["openConversationId"] != "cid-1" {
t.Fatalf("scope preflight = %#v", preflight)
}
search := fake.calls[3]
if search.product != "im" || search.tool != "search_messages" {
t.Fatalf("search = %#v", search)
}
if got, want := search.args["openConversationIds"], []string{"cid-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("openConversationIds = %#v, want %#v", got, want)
if _, exists := search.args["openConversationIds"]; exists {
t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", search.args)
}
if got, want := search.args["senderOpenDingTakIds"], []string{"D1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("senderOpenDingTakIds = %#v, want %#v", got, want)
@@ -392,14 +395,17 @@ func TestCrossPlatformCoverageSearchMsgAcceptsStableIDInChatQuery(t *testing.T)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "search_messages" {
if len(fake.calls) != 2 || fake.calls[0].tool != "get_conversation_info" || fake.calls[1].tool != "search_messages" {
t.Fatalf("calls = %#v", fake.calls)
}
if got, want := fake.calls[0].args["openConversationIds"], []string{"cid-fixture-chat-0002"}; !reflect.DeepEqual(got, want) {
t.Fatalf("openConversationIds = %#v, want %#v", got, want)
if fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0002" {
t.Fatalf("scope preflight = %#v", fake.calls[0])
}
if fake.calls[0].args["keyword"] != "评测" {
t.Fatalf("keyword = %#v", fake.calls[0].args["keyword"])
if _, exists := fake.calls[1].args["openConversationIds"]; exists {
t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", fake.calls[1].args)
}
if fake.calls[1].args["keyword"] != "评测" {
t.Fatalf("keyword = %#v", fake.calls[1].args["keyword"])
}
}
+108 -88
View File
@@ -23,6 +23,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
@@ -34,15 +35,18 @@ import (
// search hits through list_messages_by_ids in chunks of 50. A later-page or
// enrichment failure never turns a partial result into a false success: the
// output carries an explicit failure ledger and complete=false.
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话,会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
"显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" +
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"
var SearchMsg = shortcut.Shortcut{
Service: "chat",
Command: "+search-msg",
Product: "im",
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
Intent: "当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。" +
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。",
Risk: shortcut.RiskRead,
Description: "按稳定 ID、内容、时间等条件搜索消息,可校验会话范围、全量翻页并批量富化",
Intent: searchMsgIntent,
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
@@ -55,15 +59,15 @@ var SearchMsg = shortcut.Shortcut{
CLIPath: "chat +search-msg",
PrimaryCLIPath: "chat +search-msg",
},
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
Description: "按稳定 ID、内容、时间等条件搜索消息,可校验会话范围、全量翻页并批量富化",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
UseWhen: []string{"当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"},
AgentSummary: "按稳定 ID、内容、时间等条件搜索消息,可校验会话范围、全量翻页并批量富化",
UseWhen: []string{searchMsgIntent},
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
Examples: []string{
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
@@ -104,7 +108,7 @@ var SearchMsg = shortcut.Shortcut{
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "--cursor 的 lark-cli 对齐别名"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动连续拉取所有游标页"},
{Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 的最大页数(1-40)", Default: "20"},
{Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 或显式会话范围本地扫描的最大页数(1-40)", Default: "20"},
{Name: "no-enrich", Type: shortcut.FlagBool, Desc: "不再按消息 ID 批量查询完整详情"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出命中消息的 reaction(默认输出)"},
}, chatshortcut.MessageResourceDownloadFlags()...),
@@ -145,9 +149,21 @@ var SearchMsg = shortcut.Shortcut{
if err != nil {
return err
}
requestedConversationIDs, _ := params["openConversationIds"].([]string)
scopedSearch := len(requestedConversationIDs) > 0
if scopedSearch {
if err := validateSearchConversationScope(rt, requestedConversationIDs); err != nil {
return err
}
// The downstream search currently drops invalid CID filters and does
// not return group-scoped hits reliably. Scan the same filtered global
// stream and apply the already-validated CID set locally instead.
delete(params, "openConversationIds")
}
pageLimit := 1
if rt.Bool("page-all") {
scanAllPages := rt.Bool("page-all") || scopedSearch
if scanAllPages {
pageLimit = rt.Int("page-limit")
}
cursor := rt.StrFirst("page-token", "cursor")
@@ -176,7 +192,15 @@ var SearchMsg = shortcut.Shortcut{
break
}
pagesFetched++
for _, message := range searchMsgItems(data) {
pageMessages := searchMsgItems(data)
if scopedSearch {
var unverifiableMessageIDs []string
pageMessages, unverifiableMessageIDs = chatmsg.FilterConversationScope(pageMessages, requestedConversationIDs)
if len(unverifiableMessageIDs) > 0 {
return searchScopeUnverifiedError(requestedConversationIDs, unverifiableMessageIDs)
}
}
for _, message := range pageMessages {
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
if messageID != "" && messageID != "<nil>" {
if seen[messageID] {
@@ -204,7 +228,7 @@ var SearchMsg = shortcut.Shortcut{
}
}
hasMore = hasMoreValue
if !rt.Bool("page-all") || !hasMore {
if !scanAllPages || !hasMore {
complete = !hasMore
break
}
@@ -218,7 +242,7 @@ var SearchMsg = shortcut.Shortcut{
}
cursor = nextCursor
}
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
if scanAllPages && hasMore && pagesFetched == pageLimit {
failures = append(failures, map[string]any{
"stage": "search-page-limit",
"error": fmt.Sprintf("达到 --page-limit=%d,仍有更多结果", pageLimit),
@@ -235,6 +259,16 @@ var SearchMsg = shortcut.Shortcut{
complete = false
}
}
if scopedSearch {
validatedMessages, unverifiableMessageIDs := chatmsg.FilterConversationScope(messages, requestedConversationIDs)
if len(unverifiableMessageIDs) > 0 {
return searchScopeUnverifiedError(requestedConversationIDs, unverifiableMessageIDs)
}
if len(validatedMessages) != len(messages) {
return searchScopeViolationError(requestedConversationIDs, messages)
}
messages = validatedMessages
}
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
if order == "" {
@@ -262,6 +296,9 @@ var SearchMsg = shortcut.Shortcut{
if len(resolvedFilters.Senders) > 0 {
payload["resolvedFilters"] = resolvedFilters
}
if scopedSearch {
payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore)
}
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
payload["nextCursor"] = nextCursor
}
@@ -432,6 +469,63 @@ func uniqueSearchStrings(values []string) []string {
return out
}
func validateSearchConversationScope(rt *shortcut.RuntimeContext, conversationIDs []string) error {
for _, conversationID := range conversationIDs {
_, err := rt.CallMCPData("chat", "get_conversation_info", map[string]any{
"openConversationId": conversationID,
})
if err == nil {
continue
}
return helpers.NormalizeSearchConversationScopeError(conversationID, err)
}
return nil
}
func searchScopeUnverifiedError(conversationIDs, messageIDs []string) error {
return apperrors.NewAPI(
"搜索结果缺少 conversationId,无法证明会话过滤范围;已停止输出",
apperrors.WithReason("search_conversation_scope_unverified"),
apperrors.WithDetails(map[string]any{
"requestedConversationIds": conversationIDs,
"unverifiableMessageIds": messageIDs,
}),
apperrors.WithRetryable(false),
apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回 openConversationId"),
)
}
func searchScopeViolationError(conversationIDs []string, messages []map[string]any) error {
observed := make([]string, 0, len(messages))
for _, message := range messages {
conversationID := strings.TrimSpace(fmt.Sprint(chatmsg.ConversationID(message)))
if conversationID == "" || conversationID == "<nil>" {
continue
}
observed = append(observed, conversationID)
}
return apperrors.NewAPI(
"消息富化结果超出请求的会话范围;已停止输出",
apperrors.WithReason("search_conversation_scope_violation"),
apperrors.WithDetails(map[string]any{
"requestedConversationIds": conversationIDs,
"observedConversationIds": uniqueSearchStrings(observed),
}),
apperrors.WithRetryable(false),
)
}
func searchScopePayload(conversationIDs []string, sourceComplete bool) map[string]any {
return map[string]any{
"requestedConversationIds": append([]string(nil), conversationIDs...),
"targetsValidated": true,
"filterApplied": true,
"filterMode": "client",
"resultsWithinScope": true,
"sourceComplete": sourceComplete,
}
}
func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) {
detailsByID := map[string]map[string]any{}
failures := make([]map[string]any, 0)
@@ -505,31 +599,7 @@ func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any
// response, probing common container keys at the top level and nested under
// "result". Returns nil when no list is found.
func searchMsgItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, searchMsgChildMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return searchMsgFlattenGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return searchMsgToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "messages", "messageList", "items", "data", "records"} {
if arr, ok := inner[k2].([]any); ok {
return searchMsgToMaps(arr)
}
}
}
}
return nil
return chatmsg.SearchItems(data)
}
func searchMsgChildMap(data map[string]any, key string) map[string]any {
@@ -539,56 +609,6 @@ func searchMsgChildMap(data map[string]any, key string) map[string]any {
return nil
}
func searchMsgFlattenGroups(groups []any) []map[string]any {
out := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"]))
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists &&
conversationID != "" && conversationID != "<nil>" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists &&
conversationTitle != "" && conversationTitle != "<nil>" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMsgToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// searchMsgProject reshapes one matched message into {sender, time, text,
// messageId}, running text through the shared chatmsg cleaning (card/auto-reply
// JSON → readable, ciphertext → marker) and recursively expanding any forwarded

Some files were not shown because too many files have changed in this diff Show More