Compare commits

..
Author SHA1 Message Date
RuiGong01 f875b1bc87 Merge branch 'main' into dws_optimization 2026-08-12 20:54:37 +08:00
github-actions[bot] 3388df1c63 Merge pull request #978 from xlb1130/feat/85387314-chat-image-guide
docs(chat): clarify image markdown guide
2026-08-12 19:54:03 +08:00
xlb1130 d3584077d7 Merge branch 'main' into feat/85387314-chat-image-guide 2026-08-12 18:30:40 +08:00
github-actions[bot] e49ba1ae71 Merge pull request #972 from typefield/feat/zcode-skill-root
feat(skill): support ZCode skill root
2026-08-12 10:20:18 +00:00
长真 2c46213257 docs(chat): to #85387314 clarify image markdown guide 2026-08-12 18:04:17 +08:00
john 77dc7d30a0 Merge branch 'main' into feat/zcode-skill-root 2026-08-12 17:56:45 +08:00
ruigong aa3c279313 chore(policy): align doc skill context budget with event/chat (10000) 2026-08-12 17:55:31 +08:00
ruigong 51dc3df91b fix(skill): clarify document-space routing in doc/drive/wiki descriptions 2026-08-12 17:14:20 +08:00
github-actions[bot] 70e03887d4 Merge pull request #962 from xlb1130/chore/85200556-im-id-flag-migrations-pending
chore(interface): add IM ID flag migration pending approvals
2026-08-12 08:40:45 +00:00
xlb1130 6ac2bbb7cf Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 16:23:15 +08:00
github-actions[bot] 5812276f46 Merge pull request #958 from typefield/codex/upgrade-stream-client-v0.9.2-beta.1
chore(deps): upgrade DingTalk Stream SDK to v0.9.2-beta.1
2026-08-12 08:15:00 +00:00
john 74baac23a1 Merge branch 'main' into codex/upgrade-stream-client-v0.9.2-beta.1 2026-08-12 15:51:30 +08:00
玉澜 b31eaec78d docs: remove ZCode release fragment 2026-08-12 15:47:39 +08:00
xlb1130 34c5118e85 Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 15:36:05 +08:00
玉澜 6e4ea0980f feat(skill): support ZCode skill root 2026-08-12 15:34:38 +08:00
github-actions[bot] 3ce0e001c1 Merge pull request #961 from yutongShe/feat/drive-file-comments
feat(drive): add file comment commands
2026-08-12 15:20:41 +08:00
xlb1130 077a5c3b30 Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 14:57:37 +08:00
之桐 f3567fba71 Merge remote-tracking branch 'upstream/main' into feat/drive-file-comments 2026-08-12 14:54:18 +08:00
github-actions[bot] e7837cdc6b Merge pull request #964 from typefield/fix/upgrade-default-multi
fix(skill): avoid duplicate Agent skill roots
2026-08-12 14:50:57 +08:00
长真 88e2f8e9e2 chore(interface): address migration approval review feedback to #85200556 2026-08-12 14:40:52 +08:00
之桐 b131726497 docs: add drive file comment release fragment 2026-08-12 14:36:26 +08:00
之桐 86ec9733c0 Merge remote-tracking branch 'upstream/main' into feat/drive-file-comments 2026-08-12 14:35:22 +08:00
玉澜 8a60334978 Merge remote-tracking branch 'upstream/main' into fix/upgrade-default-multi 2026-08-12 14:24:52 +08:00
之桐 76a6980244 fix(drive): validate numeric file comment IDs 2026-08-12 14:24:50 +08:00
玉澜 fcbbc0bd9a fix(skill): require explicit nested layout migration 2026-08-12 14:21:47 +08:00
github-actions[bot] 31edcc3c5a Merge pull request #888 from DingTalk-Real-AI/codex/release-fragments
release: use isolated changelog fragments
2026-08-12 14:21:18 +08:00
chichuan 305ccf0984 Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 13:53:37 +08:00
chichuan c2c1131079 fix: match the release archive directory literally, not as a regex
release_version was interpolated into an awk regex, where '.' matches any
character. Version 1.0.1-beta.1 therefore also admitted
.changes/released/1x0x1-betaX1/, letting the archive drift from the
CHANGELOG version while every other seal assertion still passed and
breaking the documented audit trail.

Compare the archive prefix with index() and split the basename off with
substr(), matching the literal-comparison idiom already used throughout
check-changelog-pr.sh. Only the basename, whose character class is fixed,
stays a pattern.
2026-08-12 13:52:25 +08:00
玉澜 24ea2505a5 test(skill): cover upgrade migration branches 2026-08-12 13:44:14 +08:00
玉澜 566e94a31e fix(skill): make generic cleanup deterministic 2026-08-12 13:19:52 +08:00
玉澜 5c68e4d9cc fix(skill): avoid duplicate Agent skill roots 2026-08-12 12:32:53 +08:00
github-actions[bot] 38e387bcd6 Merge pull request #959 from DingTalk-Real-AI/codex/drive-shortcuts
feat(drive): harden and expand shortcut workflows
2026-08-12 12:18:58 +08:00
长真 276ab52aed chore(interface): add im id flag migration pending approvals to #85200556 2026-08-12 12:14:10 +08:00
chichuan 12435e6e54 refactor: stage the .changes diff once for both fragment triggers
Both trigger predicates ran the same git diff, which the script already
avoids elsewhere by staging --name-status into $tmp_root/status. Write the
path list once and let each awk predicate read it, matching that idiom.
2026-08-12 12:07:07 +08:00
chichuan 1d8182bcfb Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 12:01:38 +08:00
chichuan 4243676739 fix: trigger release fragment tree validation on nested .changes paths
Git records no diff entry for a directory itself, so adding
.changes/foo/bar.md only surfaced the nested path, which the single-level
trigger regex skipped. The entry validation and the renderer were both
bypassed, letting a nested directory reach main and break every later
fragment render with 'unexpected directory'.

Trigger the top-level tree validation on any .changes change outside
.changes/released/ (which keeps its own immutability and release-seal
checks), and assert .changes itself is still a tree so replacing it with a
blob or symlink cannot empty the child listing unnoticed.

Re-rendering stays keyed on fragment changes so a README-only edit does
not fail on an empty fragment set.
2026-08-12 12:00:42 +08:00
Dennis 4324fa72f2 fix(drive): preserve copy schema properties 2026-08-12 11:56:10 +08:00
Dennis ef5462a4dc fix(drive): scan paginated file versions 2026-08-12 11:36:33 +08:00
之桐 bdf3048773 feat(drive): add file comment commands 2026-08-12 11:29:21 +08:00
Dennis e1da6ba356 fix(drive): preserve download output shorthand 2026-08-12 11:21:03 +08:00
Dennis ae309b5846 feat(drive): harden and expand shortcut workflows 2026-08-12 11:11:46 +08:00
玉澜 57e23d661d chore(deps): upgrade DingTalk Stream SDK to v0.9.2-beta.1 2026-08-12 10:57:37 +08:00
github-actions[bot] 9ef26055fa chore: update beta formula for v1.0.58-beta.4 [skip ci] 2026-08-12 02:45:42 +00:00
chichuan d1bd518043 Merge pull request #957 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.4
docs: seal v1.0.58-beta.4 changelog
2026-08-12 10:32:34 +08:00
chichuan bac4fded0d docs: seal v1.0.58-beta.4 changelog 2026-08-12 10:25:48 +08:00
github-actions[bot] 82bfddc1c2 Merge pull request #922 from typefield/feat/skill-mode-migration
feat(skill): default installs and upgrades to multi-skill layout
2026-08-12 09:04:30 +08:00
玉澜 8cf23ee7cb Merge remote-tracking branch 'upstream/main' into feat/skill-mode-migration 2026-08-12 08:47:01 +08:00
玉澜 5777ea36e9 fix(skill): roll back partial mono installs 2026-08-12 08:44:48 +08:00
github-actions[bot] 6eceebd701 Merge pull request #953 from Anonymity-0/feat/card-send-native-mentions
feat(chat): support mentions in native card creation
2026-08-12 02:41:26 +08:00
玉澜 4b898e9011 test(auth): remove PAT polling timing race 2026-08-12 02:41:14 +08:00
玉澜 cb14ae96b3 Merge remote-tracking branch 'upstream/main' into feat/skill-mode-migration 2026-08-12 02:07:42 +08:00
前津 aeb4b2dcaa fix(chat): reject conflicting card update responses 2026-08-12 02:01:13 +08:00
玉澜 09f9289deb fix(skill): match managed names literally 2026-08-12 01:56:17 +08:00
前津 bbb14c24dc Merge remote-tracking branch 'upstream/main' into feat/card-send-native-mentions 2026-08-12 01:28:00 +08:00
github-actions[bot] 79f4be31d5 Merge pull request #956 from DingTalk-Real-AI/codex/fix-text-input-bounds
fix(localio): bound all text input paths
2026-08-11 17:16:44 +00:00
玉澜 e2a1be5e93 fix(skill): roll back partial setup transactions 2026-08-12 01:09:19 +08:00
Dennis 69911543c3 Merge remote-tracking branch 'origin/main' into codex/fix-text-input-bounds 2026-08-12 00:58:25 +08:00
john d4eba7fa96 Merge branch 'main' into feat/skill-mode-migration 2026-08-12 00:54:52 +08:00
前津 bbc2eb111c Merge remote-tracking branch 'upstream/main' into feat/card-send-native-mentions 2026-08-12 00:54:37 +08:00
github-actions[bot] b58b8c51bf Merge pull request #955 from DingTalk-Real-AI/codex/fix-eval-dispatch-403
fix(ci): restore eval dispatch PR comments
2026-08-12 00:52:54 +08:00
Dennis a7678472ab test(localio): scope path replacement to unix 2026-08-12 00:40:36 +08:00
Dennis f413db06be fix(localio): reject special files before open 2026-08-12 00:32:56 +08:00
Dennis 3d67d83110 test(localio): isolate input boundary e2e 2026-08-12 00:28:43 +08:00
玉澜 9de722ab34 fix(skill): roll back failed installer transactions 2026-08-12 00:22:09 +08:00
Dennis df088573fb fix(localio): bound all text input paths 2026-08-11 23:58:11 +08:00
chichuan a0c64e5ef4 fix(ci): restore eval dispatch PR comments 2026-08-11 23:57:12 +08:00
前津 eebd6b2a1c fix(chat): accept card update acknowledgement 2026-08-11 23:44:53 +08:00
玉澜 181f030350 test(skill): normalize backup paths on Windows 2026-08-11 23:40:28 +08:00
john 6140e503ec Merge branch 'main' into feat/skill-mode-migration 2026-08-11 23:31:08 +08:00
玉澜 9539ae8e40 fix(skill): centralize managed skill metadata 2026-08-11 23:26:49 +08:00
github-actions[bot] 7a140e59c3 Merge pull request #946 from DingTalk-Real-AI/codex/minutes-shortcuts
feat(minutes): align and expand shortcut workflows
2026-08-11 23:20:21 +08:00
前津 b1bfe6002d Revert "docs(skill): route create-only cards to native command"
This reverts commit 8e8e3a3ce8.
2026-08-11 22:54:02 +08:00
Dennis 38832448d2 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 22:53:51 +08:00
前津 8e8e3a3ce8 docs(skill): route create-only cards to native command 2026-08-11 22:52:24 +08:00
Dennis 3d4e43f4fc fix(minutes): align search scope enums 2026-08-11 22:49:27 +08:00
github-actions[bot] 155ce984c9 Merge pull request #916 from gtezg30062/feat/pull_knowledge_base_dynamic_1
Feat/pull knowledge base dynamic 1
2026-08-11 14:21:30 +00:00
john 4b93a1cb28 Merge branch 'main' into feat/pull_knowledge_base_dynamic_1 2026-08-11 22:05:50 +08:00
github-actions[bot] 1d384b9189 Merge pull request #952 from DingTalk-Real-AI/feat/eval-devix-poll
feat(eval): 用可验证轮询中继替代受限网络直连
2026-08-11 21:51:30 +08:00
玉澜 9f4e748404 fix(skill): preserve installs during layout migration 2026-08-11 21:37:55 +08:00
chichuan 025287873d Merge remote-tracking branch 'origin/main' into feat/eval-devix-poll 2026-08-11 21:33:26 +08:00
chichuan 6ddda6f1bf fix(eval): bind dispatch markers to workflow artifacts
Bind each accepted marker to the exact workflow run attempt, immutable artifact, source comment, and current PR head so a historical successful run cannot authorize a different payload.
2026-08-11 21:33:10 +08:00
chichuan e0dd800378 docs: state the release fragment filename and file-kind contract 2026-08-11 21:24:43 +08:00
chichuan 309c39a8e0 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 21:24:25 +08:00
chichuan 39d6caa24d fix: validate every top-level .changes entry in the fragment gate
The fragment gate only ran validation when the changed path matched the
legal fragment name pattern, so `.changes/Foo.md`, `.changes/notes.txt`
and a symlinked fragment slipped through untouched and then broke the
next PR that added a legal fragment. The trigger now fires on any
top-level `.changes/` change other than README.md and rejects every
entry that is not README.md, released/, or a 100644 blob named
^[a-z0-9][a-z0-9._-]*\.md$.

The renderer had the same hole from the other side: `find -type f`
is false for symlinks, so a symlinked fragment was silently dropped
from the rendered notes, and the `[a-z0-9]*.md` glob only constrained
the first character so `chat reply.md` passed. It now walks every
top-level entry and fails on symlinks, unexpected directories,
non-regular files and illegal names. Both scripts pin LC_ALL=C so the
ASCII ranges cannot match uppercase under a different collation.

Adds regression coverage for illegal names, non-markdown entries,
symlinks and executable modes on both the gate and the renderer.
2026-08-11 21:07:12 +08:00
玉澜 0d4bd28a08 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 20:46:15 +08:00
玉澜 9264323b29 revert(ci): keep existing pull request checkout policy 2026-08-11 20:45:32 +08:00
github-actions[bot] dde5049454 Merge pull request #911 from Anonymity-0/feat/t07-chat-response-envelope
feat(chat): 统一 typed 与 shortcut 消息响应契约
2026-08-11 12:42:30 +00:00
玉澜 1744880648 test(skill): cover managed marker failure 2026-08-11 20:34:00 +08:00
玉澜 246f4ebaf5 docs(skill): consolidate migration design into RFC 2026-08-11 20:24:07 +08:00
Dennis a3f5a83527 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 20:22:35 +08:00
Anonymity-0 5d7a66d4a3 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 20:22:20 +08:00
玉澜 ec5f312fd6 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 20:19:30 +08:00
玉澜 3c81741e2e fix(skill): fail partial setup installs 2026-08-11 20:19:00 +08:00
chichuan aebb75371b Merge branch 'main' into feat/eval-devix-poll 2026-08-11 20:17:55 +08:00
chichuanandClaude Opus 4.6 0a0634cfc2 fix(eval): harden extract_payload against non-dict JSON and invalid field types
Address P1 finding: extract_payload now strictly requires the parsed JSON
to be a dict, and validates each field's type and format:
- pr_number: string of digits
- pr_head_sha: 40-char lowercase hex string
- products: alphanumeric with commas/dots/hyphens/underscores only
- run_id: string of digits
- cases_ref: string (may be empty)

validate_run_id also guards against non-string input.

Added tests for: integer/array/string/null JSON, numeric field types,
invalid SHA format, injection in products, missing required fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-11 20:17:27 +08:00
github-actions[bot] 46aa0fe16d Merge pull request #944 from xlb1130/fix/85313115-chat-catalog-tools
fix(chat): register missing typed catalog tools
2026-08-11 20:11:26 +08:00
Anonymity-0 78165393e0 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 20:10:06 +08:00
Dennis 931af6af59 chore(pr): keep evidence out of merge tree 2026-08-11 19:51:57 +08:00
chichuanandClaude Opus 4.6 f6a4e0d5ad fix(eval): replace sed with bash string concat to satisfy shellcheck SC2001
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-11 19:51:32 +08:00
Dennis 44311d0160 docs(pr): publish minutes agent e2e evidence 2026-08-11 19:51:08 +08:00
chichuan afb25ae0e9 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 19:49:49 +08:00
长真 fb44601f21 fix(chat): restrict audit join typed enum 2026-08-11 19:48:24 +08:00
chichuanandClaude Opus 4.6 83c64d31dd security(eval): add anti-forgery validation for eval-dispatch comments
Address P1 lint finding: structured eval-dispatch comments could be
forged by unauthorized users. Add three-layer consumer-side validation:

1. comment.user.login == 'github-actions[bot]' (platform-enforced identity)
2. comment.performed_via_github_app.slug == 'github-actions' (App signature)
3. payload.run_id verified against actual successful workflow run via API

Also adds:
- eval_poll_validate.py: consumer validation module (in-repo, auditable)
- test_eval_poll_validate.py: unit tests proving forged comments are rejected
- Go security contract test updated to assert run_id and validate reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-11 19:47:52 +08:00
玉澜 293c085634 fix(skill): preserve same-prefix user skills 2026-08-11 19:47:30 +08:00
Dennis f81d09fb95 fix(localio): pin verified upload file across retries 2026-08-11 19:45:06 +08:00
前津 f8258576ef feat(chat): support mentions in native card creation 2026-08-11 19:42:06 +08:00
chichuanandClaude Opus 4.6 e437cf4bbb feat(eval): replace direct internal API call with structured comment for Devix polling
The GitHub Actions runner cannot reach internal Aone CI API (structural
network isolation). Replace the curl-to-internal step with a structured
HTML comment (<!-- eval-dispatch: {...} -->) that an internal Devix
polling service picks up every 3 minutes to trigger the Aone CI pipeline.

This eliminates the EVAL_TRIGGER_URL/EVAL_TRIGGER_TOKEN secrets dependency
from the GitHub side — those can be removed once verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-11 19:40:48 +08:00
Dennis cd1ba34d96 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 19:30:15 +08:00
Dennis 2cc410db6c docs: remove shortcut analysis artifacts 2026-08-11 19:09:30 +08:00
玉澜 f57c002ae7 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 19:06:57 +08:00
长真 495a3b256f Merge remote-tracking branch 'origin/fix/85313115-chat-catalog-tools' into fix/85313115-chat-catalog-tools 2026-08-11 19:03:09 +08:00
长真 4210334f55 fix(chat): preserve yes shorthand on guarded writes 2026-08-11 19:00:45 +08:00
Dennis 06ec207d17 fix(minutes): harden end-to-end failure handling 2026-08-11 18:54:24 +08:00
xlb1130 30caba5dcb Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 18:51:33 +08:00
玉澜 b17634ef7d fix(skill): fail incomplete bundled skill installs 2026-08-11 18:49:05 +08:00
长真 76316ef5f0 Merge remote-tracking branch 'origin/fix/85313115-chat-catalog-tools' into fix/85313115-chat-catalog-tools 2026-08-11 18:48:18 +08:00
长真 f5b1c2659f fix(chat): enforce confirmation for chat write tools 2026-08-11 18:47:30 +08:00
github-actions[bot] b4f0053bbe Merge pull request #924 from typefield/feat/unified-command-framework-core
feat: add unified result framework with dingtalk-dev pilot
2026-08-11 18:34:15 +08:00
玉澜 3593818a46 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 18:15:58 +08:00
玉澜 e0fd344a26 fix(skill): always refresh bundled skills 2026-08-11 18:13:44 +08:00
github-actions[bot] 21bbf42ca7 chore: update beta formula for v1.0.58-beta.3 [skip ci] 2026-08-11 10:06:41 +00:00
玉澜 edf1e58141 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 17:59:38 +08:00
xlb1130 bd94c63de8 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 17:50:56 +08:00
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
玉澜 9d8806927f Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 17:48:35 +08:00
chichuan dbe47d58fb docs: seal v1.0.58-beta.3 changelog 2026-08-11 17:45:44 +08:00
xlb1130 8c2c94e0f1 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 17:40:29 +08:00
玉澜 b7b78f0c16 fix(dev): keep recovery commands behind confirmation 2026-08-11 17:36:59 +08:00
john c38e988b14 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 17:34:26 +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
Dennis 66468c703f fix(minutes): preserve upload recovery and schema compatibility 2026-08-11 17:06:22 +08:00
玉澜 773e76a1c6 Merge remote-tracking branch 'fork/feat/skill-mode-migration' into feat/skill-mode-migration 2026-08-11 17:01:44 +08:00
玉澜 f4e39a219b fix(skill): preserve state on partial setup 2026-08-11 17:01:32 +08:00
john c170a464e1 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 17:00:13 +08:00
Dennis 74ef426064 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 16:55:06 +08:00
玉澜 5ce391b49b Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 16:41:42 +08:00
xlb1130 4a14f4b1e3 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 16:40:45 +08:00
玉澜 451a6fffe7 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core
# Conflicts:
#	internal/errors/errors.go
#	internal/errors/errors_test.go
2026-08-11 16:40: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
Anonymity-0 e4e653d3b3 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 16:26:43 +08:00
xlb1130 6b85867309 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 16:23:13 +08:00
fengbai fdf3e8cc3b docs(skills): sync calendar reference between mono and multi layouts 2026-08-11 16:21:20 +08:00
前津 a5902ca233 Merge upstream/main into chat response branch 2026-08-11 16:20:06 +08:00
玉澜 4665b42bbf fix(skill): preserve installer caches during refresh 2026-08-11 16:16:49 +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
john 16273de554 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 16:13:48 +08:00
xlb1130 28669ffeee Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 16:04:49 +08:00
长真 fa5bc65d66 fix(chat): preserve conversation id alias contracts 2026-08-11 16:04:11 +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
前津 49afa82d27 chore: rerun ci 2026-08-11 15:37:41 +08:00
john aabee99e3f Merge branch 'main' into feat/skill-mode-migration 2026-08-11 15:35:23 +08:00
玉澜 3eda3b5ce6 docs: align unified framework scope with dev pilot 2026-08-11 15:32:06 +08:00
玉澜 49ab7a46f4 fix(devapp): preserve pagination contract during dry-run 2026-08-11 15:30:40 +08:00
长真 d500f2fe5f chore: rerun CI 2026-08-11 15:29:52 +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
Anonymity-0 bcc9e27da0 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 15:15:51 +08:00
玉澜 cf64f2ad02 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 15:14:39 +08:00
玉澜 5ab46921c5 fix: preserve legacy errors and devdoc pagination contract 2026-08-11 15:13:18 +08:00
xlb1130 f8a031564a Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 15:08:50 +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
玉澜 2989c1db37 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 14:56:53 +08:00
Anonymity-0 eaee7f1c6f Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 14:52:23 +08:00
chichuan 211a5fa393 Merge branch 'main' into codex/aitable-workflow-run-history 2026-08-11 14:33:14 +08:00
xlb1130 103b188458 Merge branch 'main' into fix/85313115-chat-catalog-tools 2026-08-11 14:17:11 +08:00
chichuan 8619d90119 Merge remote-tracking branch 'origin/main' into feat/eval-dispatch 2026-08-11 14:16:33 +08:00
长真 156d95e6d1 fix(chat): complete catalog leaf contracts 2026-08-11 14:16:23 +08:00
玉澜 9e3a083c27 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 14:12:43 +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
前津 b1f5c67e9c Merge upstream/main into chat response branch 2026-08-11 14:00:34 +08:00
玉澜 037deefe67 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 14:00:07 +08:00
chichuan 42e764a7a8 fix(ci): harden eval dispatch authorization 2026-08-11 13:57:42 +08:00
玉澜 3a0d814276 docs(skill): remove confirmation bypass examples 2026-08-11 13:52:24 +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
玉澜 06ed3aeeb3 fix: harden unified result rollout contracts 2026-08-11 13:47:07 +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
Anonymity-0 b244df1634 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 13:31:37 +08:00
玉澜 f3ddbb2db0 fix(upgrade): preserve skill cache during refresh 2026-08-11 13:17:07 +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
长真 eb3f7328bb fix(chat): tighten catalog safety contracts 2026-08-11 12:17:31 +08:00
长真 3c445ce73a fix(chat): to #85313115 register missing catalog tools 2026-08-11 12:17:31 +08:00
玉澜 fbdb5e8d4d Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 12:07:58 +08:00
克谨 e8ca78fe49 Merge origin/main into codex/fix-im-reliability 2026-08-11 12:07:27 +08:00
玉澜 cc7e7bf0e0 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration
# Conflicts:
#	internal/app/skill_setup.go
#	internal/app/skill_setup_test.go
2026-08-11 12:06:59 +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
玉澜 ef73257a69 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 11:58:36 +08:00
Dennis 461b9b773a feat(minutes): align and expand shortcut workflows 2026-08-11 11:52:57 +08: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
Anonymity-0 7945f44c9a Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 11:29:59 +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
玉澜 8034f0c2dc fix: preserve nested error operation context 2026-08-11 11:15:36 +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
玉澜 5b0e44290e Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 11:02:00 +08:00
liangxiaoqin.lxq 4bd9f75231 cr修复1 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 8f8f64c391 cr修复,增加测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ae9caa06af cr修复 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ee0c3507a5 补充测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 72a9902254 wiki feed list命令 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 5f337e0ce5 wiki feed list命令:新增时间格式化/字段裁剪 2026-08-11 10:57:44 +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
玉澜 01a7b20026 fix(skill): keep setup confirmation and Windows tests safe 2026-08-11 10:46:40 +08: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
Anonymity-0 6fdd17d3b6 Merge branch 'main' into feat/t07-chat-response-envelope 2026-08-11 10:43:18 +08:00
玉澜 5c2181a31d test: require envelope-safe fields projection 2026-08-11 10:30:23 +08:00
克谨 0148ad1800 test(chat): cover scoped search error fallbacks 2026-08-11 10:29:53 +08:00
前津 956819663d chore: retrigger CI 2026-08-11 10:29:50 +08:00
玉澜 670ab1fd5e Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 10:24:36 +08:00
玉澜 b299400017 fix: preserve result envelope with fields 2026-08-11 10:23:59 +08:00
玉澜 3afcabc41d fix: report output publication failures 2026-08-11 10:20:05 +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
玉澜 62541947e7 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 10:10:01 +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
玉澜 a43e75e8df Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-11 00:18:18 +08:00
chichuan 783e1eeef9 fix(ci): stabilize minutes coverage contracts 2026-08-11 00:13:48 +08:00
玉澜 596da1343e fix(skill): sync installed multi-skill set safely 2026-08-11 00:10:59 +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
玉澜 2aad96fa7b Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-10 23:42:21 +08:00
玉澜 3fe2a7f5c0 fix: preserve emitted result exit codes on signals 2026-08-10 23:42:11 +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
chichuan e9850a2e49 fix(ci): enforce stable Schema compatibility 2026-08-10 23:06:13 +08:00
玉澜 12c7b6eb89 test(skill): cover mono cleanup failure on Windows 2026-08-10 22:50:52 +08:00
玉澜 24fd2d2573 fix: use default legacy status rollout 2026-08-10 22:49:53 +08:00
玉澜 90d99d9bbe fix: preserve connect status output compatibility 2026-08-10 22:47:55 +08:00
玉澜 bc3d92ccaf Merge remote-tracking branch 'origin/main' into pr-922 2026-08-10 22:36:07 +08:00
玉澜 61adc87987 fix(skill): fail safely during layout migration 2026-08-10 22:35:36 +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 8097943e3a Merge branch 'main' into codex/interface-migration-governance 2026-08-10 22:31:16 +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
github-actions[bot] d5a9a72fa6 Merge pull request #931 from DingTalk-Real-AI/fix/schema-compat-policy-int
ci(schema): allow reviewed parameter type migrations
2026-08-10 22:22:35 +08:00
炳昱 6f73e5187a Merge official main into feat/dws-event-oa 2026-08-10 22:21:25 +08:00
chichuan b7918be6f3 fix(ci): require stable flag migration reference 2026-08-10 22:16:00 +08:00
玉澜 a37f614be4 test: cover unified schema validation edges 2026-08-10 22:15:04 +08:00
Dongming Ji b70e109e89 Merge branch 'main' into fix/json-output-doc-export-drive-download 2026-08-10 22:13:31 +08:00
chichuan b84b56d9f8 Merge origin/main into interface migration governance 2026-08-10 22:03:18 +08:00
chichuan e66cd95c51 Merge remote-tracking branch 'origin/main' into fix/schema-compat-policy-int 2026-08-10 21:59:55 +08:00
chichuan 7e8b216e07 ci(schema): compare the full parameter contract for reviewed type migrations
Auto-CR (P1) correctly flagged that the carve-out's "nothing else changed"
guard was keyed on len(otherFailures) == 0, which only observes changes the
gate already judges incompatible. Several parameter contract changes are
individually compatible and so produce no failure at all: relaxing required or
cli_required, clearing required_when, widening enum, clearing interface_type,
and clearing property through a reviewed mapping exclusion. Any of those could
have ridden along with a reviewed type migration, leaving the exemption wider
than both its documentation and what the entry actually reviewed.

Replace the failure-list heuristic with a real equality check over every
published field except Type. Comparing the struct also means a field added to
parameterSchema later is covered automatically, instead of silently widening
every existing entry. The type check moves back ahead of the field loop because
it no longer needs to observe the other findings.

Add a rejection case for each individually-compatible direction. Each case first
asserts that the drift alone really is compatible, so it keeps exercising the
equality guard instead of quietly duplicating one of the incompatible-bundle
cases.

Verified against the previous implementation: with the old guard all six new
cases fail while the nine incompatible-bundle cases still pass, which is exactly
the gap that was reported.
2026-08-10 21:52:58 +08:00
前津 08cf334cc1 Merge remote-tracking branch 'upstream/main' into feat/t07-chat-response-envelope 2026-08-10 21:41:03 +08:00
玉澜 c515f7c1e5 test(ci): cover aggregate changed-code edges 2026-08-10 21:28:58 +08:00
玉澜 12088f2d44 Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-10 21:24:58 +08:00
玉澜 d9c74fbe96 feat: add result schemas and devapp pagination 2026-08-10 21:23:23 +08:00
克谨 3fc144a699 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 21:19:53 +08:00
github-actions[bot] 5501c9f1a5 Merge pull request #933 from pengzhihan47-star/codex/doc-shortcut_and_skill_opt
feat(doc): harden dingtalk-doc shortcuts, contracts, and verification
2026-08-10 21:11:23 +08:00
玉澜 8eae408e28 fix(ci): pin synthetic merge to event SHA 2026-08-10 21:08:55 +08:00
玉澜 9f3df91584 fix(ci): pin merge checkout and cover Windows edges 2026-08-10 21:04:08 +08:00
玉澜 37cccdbc0e Merge remote-tracking branch 'origin/main' into pr-922 2026-08-10 20:51:58 +08:00
前津 1522653844 test(chat): cover existing operation context 2026-08-10 20:41:37 +08:00
长真 4d274c9da3 fix(chat): merge conversation message pagination 2026-08-10 20:34:43 +08:00
玉澜andCursor b6851e641e fix(skill): back up skill dirs before removal and satisfy coverage gate
Address the two P1 review findings and the coverage-gate CI failures:
- Every install/upgrade path that removes a skill dir (opposite-mode
  leftovers, stale dingtalk-* / dws-shared, and same-name refreshes) now
  moves the directory to ~/.dws/skill-backups/<stamp>/ first across
  install.sh, install-skills.sh, install.ps1, install.js, `dws skill
  setup`, and `dws upgrade`. A backup failure preserves the original
  directory and never removes it.
- Remove --yes from every copyable `dws skill setup` example and document
  what the command may remove; add regression tests that declining the
  confirmation performs no removal and that the confirmation previews
  every directory slated for backup+removal.
- Rename the skill-mode tests to the TestCrossPlatformCoverage* prefix so
  the platform coverage gate selects them, and add edge tests for the
  backup/prune/cleanup fallback branches, restoring changed-code coverage
  to 100%.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:23:35 +08:00
前津 357f31376d fix(chat): preserve operation on read failures 2026-08-10 20:15:04 +08:00
如椽 7ffb48c9ae test: cover JSON export and download receipts 2026-08-10 19:57:37 +08:00
前津 0f178f8382 ci: rerun interrupted tests 2026-08-10 19:37:49 +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
前津 910fb4a9b1 fix(chat): preserve legacy message context 2026-08-10 19:06:59 +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
柏智 eefe6f05e1 fix(schema): review doc import constraint transition 2026-08-10 17:49:27 +08:00
前津 89feea7971 chore: rerun CI 2026-08-10 17:39:40 +08:00
前津 24b61b1c17 fix(chat): reject empty message read responses 2026-08-10 17:39:40 +08:00
前津 edbc8275b6 chore: rerun CI 2026-08-10 17:39:40 +08:00
前津 27afa806ca feat(chat): unify typed and shortcut message contracts 2026-08-10 17:39:40 +08:00
柏智 6598292b1b fix(doc): allow import to default root 2026-08-10 17:32:23 +08:00
柏智 dea637228d fix(doc): preserve schema compatibility after review 2026-08-10 17:32:23 +08:00
柏智 a09467f1eb fix(doc): address PR 906 review feedback 2026-08-10 17:32:23 +08:00
柏智 34feb348af feat(doc): harden shortcuts and skill routing 2026-08-10 17:32:23 +08:00
如椽 08ee5dc573 fix: emit JSON receipts for exports and downloads 2026-08-10 17:21:54 +08:00
chichuan 6b1a1a6201 Merge branch 'main' into fix/schema-compat-policy-int 2026-08-10 17:19: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
chichuan 3af7adaad6 Merge branch 'main' into codex/release-fragments 2026-08-10 17:06:47 +08:00
github-actions[bot] 9f60cdeef1 Merge pull request #920 from Anonymity-0/feat/card-reply-mentions
feat(chat): support mentions in streaming card creation
2026-08-10 17:06:24 +08:00
前津 258caa5906 fix(chat): fail safely when card mention tag is missing 2026-08-10 16:39:51 +08:00
前津 a0d59a79aa feat(chat): prepend card mention tag to content 2026-08-10 16:39:51 +08:00
前津 c7148f3ebb docs(chat): clarify streaming card mention content 2026-08-10 16:39:51 +08:00
前津 ef29b48a55 fix(chat): keep skill within context budget 2026-08-10 16:39:51 +08:00
前津 5c33ea526e feat(chat): support mentions in streaming card creation 2026-08-10 16:39:51 +08:00
chichuan 6d3b54b25f Merge branch 'main' into fix/schema-compat-policy-int 2026-08-10 16:37:45 +08:00
玉澜 b6101bdbc3 fix: preserve typed error fallback contract 2026-08-10 16:35:22 +08:00
克谨 b243b38d65 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 16:32:20 +08:00
github-actions[bot] 867bb44586 chore: update beta formula for v1.0.58-beta.2 [skip ci] 2026-08-10 08:22:55 +00:00
镜玄 819355b31f feat: add aitable workflow run and history commands 2026-08-10 16:20:22 +08:00
玉澜 538f2aba6f fix: complete unified output lifecycle coverage 2026-08-10 16:19:07 +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
chichuan fd0c3350f3 ci(schema): allow reviewed parameter type migrations
schema-compatibility is the third check in the same Interface Integrity job,
after the two CLI interface gates. It also rejected every published parameter
type change outright. Because the earlier gates failed first and `set -e`
stopped the step from ever running, this one never surfaced in CI, so the
previous exemption only covered two thirds of the problem.

checkParameterCompatibility now consults a precise allowlist: the tool path,
parameter name and both type values must match exactly, making it
direction-sensitive by construction, and it applies only when nothing else the
gate checks about the parameter moved (default, interface_default, format,
property, interface_type, required, cli_required, required_when, enum). The type
check moved to the end of the function so the carve-out can see those findings;
ordering is unobservable because the result is sorted.

The only entry is "minutes/minutes.apply_minutes_permission" parameter "policy"
migrating from "string" to "integer" (for #912). That type is projected from the
Cobra flag type (provenance cobra_flag_type), so it describes how the CLI accepts
a value. Consumers build a command line from it, and "--policy 4" is the same
argv under either declaration — a quoted "--policy \"4\"" still reaches pflag as
4 — while RunE keeps enforcing the same [2,4] domain. The parameter maps to
property "policyId", which the command has always sent as a number, so "integer"
is closer to the actual request than "string" was.

Table values must be the canonical form schemaType emits: the JSON encoding of
the type keyword, so `"string"` with its quotes rather than a bare string. The
guard test recomputes both through schemaType and checks the decoded name
against the closed JSON Schema type set — reviewedInterfaceRefRedirect was
silently disabled twice by exactly this class of spelling mistake.
2026-08-10 15:57:15 +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
玉澜 2359de69fa fix: preserve unified failures with output files 2026-08-10 15:08:35 +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
玉澜 b62f6c0c02 fix: reset unified results for each execution 2026-08-10 15:03:33 +08:00
玉澜 25b5e0b9fa Merge remote-tracking branch 'upstream/main' into feat/unified-command-framework-core 2026-08-10 14:42:24 +08:00
玉澜 03bda02e04 test: close unified framework contract coverage 2026-08-10 14:41:41 +08:00
克谨 431f64be85 Merge remote-tracking branch 'origin/main' into codex/fix-im-reliability 2026-08-10 14:13:46 +08:00
玉澜 4da1e52b08 fix(dev): make connect dry-run plans auditable 2026-08-10 13:47:56 +08:00
玉澜 409ee0cb84 refactor: keep signal escalation portable 2026-08-10 13:35:26 +08:00
玉澜 7cf7598ef2 fix(dev): preserve published connect safety metadata 2026-08-10 13:24:14 +08:00
玉澜 9b220d0ee6 test: keep signal coverage portable 2026-08-10 13:14:57 +08:00
玉澜 d7ae59753d fix: preserve legacy formatter bytes during rollout 2026-08-10 13:09:28 +08:00
玉澜 72b2af1d1d feat(dev): integrate unified command results 2026-08-10 13:00:14 +08:00
玉澜 bd41da8caf fix: make signal escalation portable 2026-08-10 12:48:11 +08:00
玉澜 cc0e179a8d refactor: remove protocol version naming 2026-08-10 12:28:28 +08:00
玉澜 e0f66384e2 fix: keep framework core lint-clean 2026-08-10 12:25:50 +08:00
玉澜 2dd067562e feat: add unified command result framework core 2026-08-10 12:22: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
chichuan 64e1dcc150 test: make temp failure portable on Windows 2026-08-10 11:36:53 +08:00
chichuan f3ecac1ad1 ci: satisfy workflow shell lint 2026-08-10 11:14:23 +08:00
阮知夏 b150911da9 docs(minutes): scope permission member-uids rule and add apply routing 2026-08-10 11:09:08 +08:00
玉澜andCursor e02e4a666d Merge latest main into feat/skill-mode-migration
Upstream reorganized the multi-skill layout (#887: long-tail skills folded
into dingtalk-misc, dws-shared renamed to dingtalk-shared). Conflict
resolution keeps this branch's multi-by-default semantics (install.sh /
install.ps1 / skill setup default to multi; interactive prompts list multi
first) and adapts the cleanup paths to the rename: cleanup predicates now
recognize both dingtalk-shared (new bundle name, covered by the dingtalk-
prefix) and the legacy dws-shared so full installs and mode switches remove
pre-rename leftovers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 11:03:33 +08:00
chichuan 46ae1c50fe ci: govern exact CLI flag migrations 2026-08-10 10:46:20 +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
chichuan 1f127881c9 Merge branch 'main' into codex/release-fragments 2026-08-07 10:24:51 +08:00
炳昱 6704eda83a fix(event): switch personal event defaults to production 2026-08-07 10:22:32 +08:00
chichuan c52f2b6e05 release: use isolated changelog fragments 2026-08-06 10:49:46 +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
玉澜andCursor d5c8982c00 feat(upgrade): always refresh to multi-skill layout (no sticky)
When a release zip contains multi/, upgrade one-shot refreshes to the
multi-skill layout and migrates existing mono installs. Docs drop the
cancelled runtime switch / sticky design.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:33:37 +08:00
玉澜andCursor 402429ac2a feat(skill): default installs and upgrades to multi-skill layout
Flip the agent-skill default from mono (single dws/ dir) to multi
(per-product dingtalk-* + dws-shared) across all distribution faces,
and fix the upgrade path so it no longer re-installs mono alongside
multi (mono+multi co-existence bug).

- upgrade: LocateSkillsRoot prefers the zip multi/ tree; multi refresh
  removes mono leftovers and stale skills, refreshes the multi cache
- install.sh/ps1/install-skills.sh/npm install.js: multi real-install
  (was print-only), default flipped, mono stays opt-in via DWS_SKILL_MODE
- skill setup: non-interactive default multi; full installs now clean
  stale dingtalk-*/dws-shared with confirm-preview disclosure, filtered
  (-s/-x) installs stay additive
- mutual exclusion is symmetric and includes dws-shared (previously
  leaked through the dingtalk- prefix) on all faces
- install.js: guard empty/corrupt multi trees (fall back to mono),
  validate SKILL.md on the mono branch, guard cache refreshes
- docs: roadmap (8/30 back-schedule), migration plan, distribution
  mechanism, rollout capability, capability completion, architecture
  optimization, wukong comparison (archived; line retired)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:13:45 +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
436 changed files with 76253 additions and 6465 deletions
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
+34
View File
@@ -0,0 +1,34 @@
# Release fragments
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
并行 PR 争用同一文件。
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
`1234-chat-reply-mentions.md`。文件名必须匹配
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
严格如下:
```markdown
---
category: Added
---
- **Chat reply mentions** (#1234) — supports mentioning selected members.
```
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
例外是否成立。
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
+4 -2
View File
@@ -19,8 +19,10 @@ repeat the entire CI suite locally only to fill this checklist: CI expands the
selected tier from documentation checks, through affected-package tests, to
the complete high-risk suite.
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
- [ ] Release-seal validation (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Targeted test/check commands and results:
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
+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
+35 -17
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/') ||
@@ -1287,6 +1288,12 @@ jobs:
./scripts/policy/check-changelog-pr.sh \
"$mode" "$PR_BASE_SHA" HEAD
- name: Validate release fragment lifecycle
if: github.event_name == 'pull_request'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
- name: Validate trusted main CHANGELOG-only push
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
env:
@@ -1370,36 +1377,47 @@ jobs:
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
candidate_ref="$(git rev-parse 'HEAD^{commit}')"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then
echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2
exit 1
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
. ./scripts/release/release-lib.sh
stable_ref=""
for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
stable_ref="$tag"
break
done
if [ -z "$stable_ref" ]; then
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
exit 1
fi
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
printf '%s\n' \
"COMPATIBILITY_BASE_REF=$base_ref" \
"COMPATIBILITY_STABLE_REF=$stable_ref" \
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
- name: Check historical commands and help compatibility
- name: Check historical commands, help, and complete CLI compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_BASE_REF"
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_STABLE_REF"
fi
- name: Check complete CLI command compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
./scripts/policy/check-command-compatibility.sh \
--base-ref "$COMPATIBILITY_BASE_REF" \
--stable-ref "$COMPATIBILITY_STABLE_REF"
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
- name: Check complete Schema compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
run: |
make schema-compatibility \
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
- name: Check skill command references
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
+296
View File
@@ -0,0 +1,296 @@
name: PR Eval Dispatch
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 生成可验证的评测请求,报告由 bot 回贴。
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
# 的当前 head(作者自背书,无第三方偷换窗口);受控评测执行端另以
# FETCH_HEAD 校验兜底派发后的变更。
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
# 触发通道:workflow 先创建占位评论,再上传与本次 run/comment 绑定的
# 不可变 manifest artifact,最后把 artifact 指针写回同一评论。评论仅是
# 不可信通知;受控评测服务必须验证成功 run、artifact 与 manifest,并在
# 触发评测前原子占用 manifest.idempotency_key,重复占用只能 no-op。
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
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
pull-requests: write
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}"
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$body" \
> /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: Create dispatch placeholder
id: placeholder
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
response="$(
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$placeholder_body"
)"
comment_id="$(
printf '%s' "$response" \
| jq -er \
--arg issue_url "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}" \
'select(.issue_url == $issue_url) | .id | tostring | select(test("^[1-9][0-9]*$"))'
)"
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
- name: Build dispatch request manifest
env:
REPOSITORY_ID: '1187709537'
REPOSITORY: ${{ github.repository }}
WORKFLOW_ID: '331725458'
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
SOURCE_COMMENT_ID: ${{ github.event.comment.id }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
ACTOR_ID: ${{ github.event.comment.user.id }}
ACTOR_LOGIN: ${{ github.event.comment.user.login }}
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 }}
SOURCE_BODY: ${{ github.event.comment.body }}
MANIFEST_PATH: ${{ runner.temp }}/eval-dispatch-request.json
run: |
set -euo pipefail
if [ "$REPOSITORY" != "DingTalk-Real-AI/dingtalk-workspace-cli" ]; then
echo "unexpected repository: ${REPOSITORY}" >&2
exit 1
fi
for value in \
"$REPOSITORY_ID" \
"$WORKFLOW_ID" \
"$RUN_ID" \
"$RUN_ATTEMPT" \
"$SOURCE_COMMENT_ID" \
"$DISPATCH_COMMENT_ID" \
"$ACTOR_ID" \
"$PR_NUMBER"; do
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
echo "dispatch manifest contains a non-canonical identifier" >&2
exit 1
fi
done
if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "dispatch manifest contains an invalid PR head SHA" >&2
exit 1
fi
hash_output="$(printf '%s' "$SOURCE_BODY" | sha256sum)"
source_body_sha256="${hash_output%% *}"
if [[ ! "$source_body_sha256" =~ ^[0-9a-f]{64}$ ]]; then
echo "failed to hash source comment" >&2
exit 1
fi
idempotency_key="${REPOSITORY_ID}:${SOURCE_COMMENT_ID}"
umask 077
jq -n \
--arg repository_id "$REPOSITORY_ID" \
--arg repository "$REPOSITORY" \
--arg workflow_id "$WORKFLOW_ID" \
--arg workflow_path "$WORKFLOW_PATH" \
--arg run_id "$RUN_ID" \
--arg run_attempt "$RUN_ATTEMPT" \
--arg source_comment_id "$SOURCE_COMMENT_ID" \
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
--arg actor_id "$ACTOR_ID" \
--arg actor_login "$ACTOR_LOGIN" \
--arg pr_number "$PR_NUMBER" \
--arg pr_head_sha "$PR_HEAD_SHA" \
--arg products "$PRODUCTS" \
--arg cases_ref "$CASES_REF" \
--arg source_body_sha256 "$source_body_sha256" \
--arg idempotency_key "$idempotency_key" \
'{
schema_version: 1,
repository_id: $repository_id,
repository: $repository,
workflow_id: $workflow_id,
workflow_path: $workflow_path,
run_id: $run_id,
run_attempt: $run_attempt,
source_comment_id: $source_comment_id,
dispatch_comment_id: $dispatch_comment_id,
actor_id: $actor_id,
actor_login: $actor_login,
pr_number: $pr_number,
pr_head_sha: $pr_head_sha,
products: $products,
cases_ref: $cases_ref,
source_body_sha256: $source_body_sha256,
idempotency_key: $idempotency_key
}' > "$MANIFEST_PATH"
- name: Upload dispatch request manifest
id: artifact
uses: actions/upload-artifact@v4
with:
name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}
path: ${{ runner.temp }}/eval-dispatch-request.json
if-no-files-found: error
retention-days: 1
overwrite: false
- name: Finalize dispatch marker
env:
GH_TOKEN: ${{ github.token }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
REPOSITORY_ID: '1187709537'
WORKFLOW_ID: '331725458'
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
PRODUCTS: ${{ steps.parse.outputs.products }}
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
run: |
set -euo pipefail
if [[ ! "$DISPATCH_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || \
[[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then
echo "artifact marker contains a non-canonical identifier" >&2
exit 1
fi
artifact_digest="${ARTIFACT_DIGEST,,}"
if [[ "$artifact_digest" != sha256:* ]]; then
artifact_digest="sha256:${artifact_digest}"
fi
if [[ ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "artifact marker contains an invalid digest" >&2
exit 1
fi
marker_json="$(
jq -nc \
--arg repository_id "$REPOSITORY_ID" \
--arg workflow_id "$WORKFLOW_ID" \
--arg workflow_path "$WORKFLOW_PATH" \
--arg run_id "$RUN_ID" \
--arg run_attempt "$RUN_ATTEMPT" \
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
--arg artifact_id "$ARTIFACT_ID" \
--arg artifact_digest "$artifact_digest" \
'{
schema_version: 1,
repository_id: $repository_id,
workflow_id: $workflow_id,
workflow_path: $workflow_path,
run_id: $run_id,
run_attempt: $run_attempt,
dispatch_comment_id: $dispatch_comment_id,
artifact_id: $artifact_id,
artifact_digest: $artifact_digest
}'
)"
cases_note=""
if [ -n "$CASES_REF" ]; then
cases_note=",用例版本 \`${CASES_REF}\`"
fi
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
response="$(
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$body"
)"
printf '%s' "$response" \
| jq -e \
--arg comment_id "$DISPATCH_COMMENT_ID" \
--arg body "$body" \
'((.id | tostring) == $comment_id) and (.body == $body)' \
> /dev/null
- name: Mark dispatch preparation failure
if: ${{ failure() && steps.placeholder.outputs.comment_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
run: |
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$failure_body" \
> /dev/null \
|| true
+1 -1
View File
@@ -2793,7 +2793,7 @@ jobs:
fi
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
echo
echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish."
echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish."
fi
} >> "$GITHUB_STEP_SUMMARY"
+132 -3
View File
@@ -464,6 +464,134 @@ Keep CLI confirmation behavior and Schema metadata consistent, and add a
semantic regression test through the final embedded loader/query delivery
path; a generator unit test or JSON count alone is insufficient.
## Unified result Schema and performance
The unified runtime envelope and the per-command Schema result declaration are
related but distinct contracts:
- Runtime owns the outer machine envelope (`ok`, `outcome`, `data`, `error`,
`meta`) and derives it through `internal/output`. Business commands return a
`CommandResult`; they must not hand-author the outer JSON shape.
- A leaf `Contract.Result` / `contract.ResultSpec` describes the reviewed
business value inside `data`. It may declare `outcomes`, `data_schema`, and
`sensitive_paths`. `Contract.Pagination` is a separate command capability
because pagination is emitted under envelope `meta`, not inside `data`.
- `outcomes` is the set of results a command may produce; it is not the outcome
of the current invocation. `data_schema` is a JSON Schema object for business
data and must not duplicate the framework envelope.
- Result declarations are delivered in the full leaf and in the reviewed
`--compact` Agent projection. Compact retains the normalized `result` object
verbatim but still omits provenance, interface bindings, and other audit-only
fields. Product/group summaries remain navigation views and need not repeat
every leaf Result. When an Agent needs return-shape facts, query the compact
leaf directly; do not load the whole full Catalog.
- A missing `result` means “no reviewed return-value declaration is published
for this leaf.” It does **not** prove that the runtime is legacy, and it must
not be filled by inference from examples, MCP samples, or previous command
output. Runtime rollout remains an internal per-command fact.
- The public contract has no `contract_version`, no `--output-contract`, and no
Agent-selectable protocol alias. Agents continue to request machine output
with `--format json`; migrated commands use the unified result directly and
unmigrated commands retain their current legacy output.
- Existing `dev` / `devapp` pilot coverage is gradual. Active reviewed
`devapp` shortcuts are gated on a non-empty Result declaration, while `dev`
currently has representative Result coverage. Do not describe that as
repository-wide coverage. Any newly activated Agent-visible command should
add and test its Result declaration; the remaining pilot gaps should shrink,
not expand.
The compact/full leaf `result` object has one stable shape:
```json
{
"result": {
"outcomes": ["success", "pending", "partial_failure", "failure"],
"data_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Stable resource ID"},
"name": {"type": "string", "description": "Display name"}
}
}
}
}
},
"sensitive_paths": ["credential.secret"]
},
"pagination": {
"kind": "cursor",
"cursor_parameter": "cursor",
"meta_path": "meta.pagination",
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
"next_token_path": "meta.pagination.next_token"
}
}
```
Field rules:
| Field | Required | Contract |
|---|---|---|
| `outcomes` | yes | Non-empty unique subset of `success`, `pending`, `partial_failure`, `failure`; normalization publishes canonical order. |
| `data_schema` | yes | One recursive JSON Schema **object** describing only the runtime envelope's `data` value. Every named `properties` child must have a non-empty `description`. It must not duplicate `ok`, `outcome`, `error`, or `meta`. |
| `sensitive_paths` | no | Unique safe dot paths relative to `data`; renderers/redaction consumers must not treat them as shell/JQ expressions. |
Optional members are omitted, never emitted as `null`. A leaf without a
reviewed Result omits the entire `result` key. Compact must preserve the same
normalized Result value as the full leaf; it must not summarize, infer, rename,
or independently rebuild any Result field. Product/group summaries do not
aggregate child Result objects.
`pagination` is a sibling of `result`, not a child. It declares the canonical
CLI cursor parameter and the fixed framework paths under `meta.pagination`.
Product response fields used to derive that metadata remain mapper internals;
they are not part of `result.data_schema`. Do not execute a second request to
derive pagination metadata.
Invalid result declarations fail closed during normalization: unknown or
duplicate outcomes, a non-object/multiple `data_schema`, unsafe or duplicate
sensitive paths, unsupported pagination kinds, attempts to override framework
meta paths, and an invalid cursor parameter must be rejected rather than
silently removed.
Full-leaf wire round trips must
preserve the normalized Result exactly. Do not commit generated Schema JSON as
evidence; tests construct contracts in Go and runtime/CI assemble the Catalog
from declarations.
### Performance model and rules
- Catalog construction is declaration-driven and cached through the existing
lazy `sync.Once` delivery path. Do not reassemble or reopen annotations per
command invocation, per leaf lookup, or per renderer.
- Normalizing one Result declaration is linear in the size of that declaration.
Full `schema --all` is linear in tools + parameters + Result schema bytes and
is an audit/compatibility export, not the normal Agent discovery path.
Overview → compact product/group → compact leaf remains the normal route;
only the final leaf carries its Result declaration.
- Constructing a `CommandResult` defensively clones result data and validates
invariants; rendering is buffer-first and then writes once. Both CPU cost and
transient memory are O(payload size), with roughly one additional in-memory
rendered copy. This buys immutability and prevents partial JSON leakage, but
it is not free.
- Large list/search commands must use bounded pages and publish continuation
facts. The current emitter buffers one command result/page before publishing;
pagination is the memory bound. Continuous event streams are a separate,
command-specific protocol and are not described by `ResultSpec`.
- A `dual_validate` command must execute the business request exactly once,
validate a shadow unified result, and preserve legacy bytes. Never obtain
validation by issuing a second network or write request.
- Filters and alternate formats are render-time work over the same in-memory
result. They must not rerun the business operation or rebuild Schema.
- Performance changes must preserve the one-result, buffer-first, fail-closed,
and atomic `--output` guarantees. Do not trade correctness for a microbenchmark
improvement. For a material hot-path change, benchmark representative small
and page-sized payloads and report allocations/bytes as well as latency.
## Current Schema boundaries
- `schema list` remains a progressive overview. `schema --all` is the stable
@@ -479,8 +607,9 @@ path; a generator unit test or JSON count alone is insufficient.
a complete compatibility baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A compact leaf defines Agent selection, CLI parameters,
constraints, and safety/confirmation semantics. Full leaf fields such as
`property`, `interface_ref`, and provenance are audit facts. A conflict is
contract drift, not permission to guess.
constraints, safety/confirmation semantics, and any reviewed `result`
contract. Full leaf fields such as `property`, `interface_ref`, and
provenance are audit facts. A conflict is contract drift, not permission to
guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
+102
View File
@@ -6,6 +6,103 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58-beta.4] - 2026-08-12
### Added
- **Multi-skill installation and upgrade** — fresh installs, `dws skill setup`,
and `dws upgrade` now use the multi-skill layout by default. Existing mono
installations migrate during upgrade; mono remains an explicit legacy option.
- **Native streaming-card mentions** — `dws chat message send-card` now accepts
`--at-open-dingtalk-ids` and `--at-all` for group cards and forwards them to
`create_and_send_card`, matching the existing shortcut behavior without
changing single-chat card creation.
- **Expanded Minutes workflows** — 27 public Minutes shortcuts now cover
upload, download, export, recording, analysis, sharing, and recovery flows;
every write command keeps an explicit confirmation requirement.
- **Chat command discovery** — 30 existing typed Chat commands are now
available in the runtime Schema and Agent catalog, with sensitive writes
carrying their required confirmation metadata.
### Changed
- **Chat read results** — typed commands and shortcuts now expose a consistent
top-level `messages` list with stable `messageId` and `text` fields while
retaining existing response envelopes and fields.
- **Wiki feed results** — Wiki feed list output now formats time fields and
trims excess fields. Its `--limit` default is 10 and maximum is 20.
- **Developer command results** — the `dev` and selected `devapp` commands now
use the unified result envelope for consistent success, pending, partial,
and failure reporting.
- **Evaluation dispatch hardening** — `/eval` now uses a verifiable polling
relay instead of direct access from the hosted runner, binding the workflow,
comment, PR head, parameters, and result provenance.
### Fixed
- **Streaming-card update acknowledgement** — accepts the pre-production
`success: true` response from `update_streaming_card` as affirmative write
evidence while preserving explicit negative, conflicting, and bizId-drift
failures, so Agents do not repeat an update that the service already applied.
- **Text input bounds** — literal input, stdin, and `@file` inputs now all
enforce the same byte limit; file reads validate the opened descriptor and
cannot exceed the limit after a path replacement or file growth.
- **Evaluation PR comments** — restores `/eval` PR conversation comments with
the least required pull-request write permission and actionable GitHub 403
diagnostics.
## [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
### Added
@@ -32,6 +129,11 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
- **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
+15 -4
View File
@@ -68,14 +68,25 @@ coverage is additionally selected for platform-sensitive code.
3. Include both the commands/results and user-visible or contract-level
behavior evidence in the PR description.
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
paths/flags change. CI also runs
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
against both the target branch and latest stable release.
paths/flags change. CI resolves the exact merge-base, latest reachable
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
decision seam through
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
The Make target delegates to the authoritative wrapper; CI does not invoke a
second comparator or the legacy fixture checker. See
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
for the reviewed two-stage `pending` → `consumed` lifecycle.
Agent-visible flag migrations must also run
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
it consumes the same base-owned ledger rather than a second exception list.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
installer surfaces change (run `make package` first).
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
the release-seal workflow renders and archives fragments into the versioned
changelog section.
## Submission Flow
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58-beta.1"
version "1.0.58-beta.4"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-darwin-arm64.tar.gz"
sha256 "34f7dabf1a97ed950c1cd331e5fb873a62c87632db249ca1b42bd2da925927f0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-arm64.tar.gz"
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-darwin-amd64.tar.gz"
sha256 "c812ad070ac76f8ab5aefe2ae1b9cdf9217389b383296acb36070fc2f40b9890"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-linux-arm64.tar.gz"
sha256 "7dd5e38647c88e4b7936e0f41315cd612128faeb7960f50b28dbbd1fa9683d68"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz"
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-linux-amd64.tar.gz"
sha256 "b384671440a62f1e5dca5dcdb49e51ef1aad816b601021ba363c8c69986ebd2f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-skills.zip"
sha256 "2cf96aa2d35a01a4b92db2bd2d1760476d62d7528de5ec50a997c9a7713c3513"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
end
def install
+25 -8
View File
@@ -24,13 +24,13 @@ help:
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@@ -103,10 +103,22 @@ edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
interface-integrity:
@./scripts/policy/check-interface-baseline.sh
@base_ref="$(BASE_REF)"; \
candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$$base_ref" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
authoritative-interface-integrity:
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
coverage-gate:
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
@@ -121,7 +133,12 @@ reset-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --reset
schema-compatibility:
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-schema-compatibility.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
+56 -35
View File
@@ -70,15 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
How to pick:
- **Quick install** (one-liner above): non-interactive, installs `mono`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
- **Quick install** (one-liner above): non-interactive, installs `multi`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
</details>
@@ -208,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
@@ -391,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```bash
# Install skills into current project (defaults to mono)
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
@@ -413,22 +415,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# Interactive: prompts for mode + target agents
dws skill setup
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# Preview the exact directories that mono setup would back up and replace
dws skill setup --mode mono --target all --dry-run
# Install multi skills to a single agent home
dws skill setup --mode multi --target cursor --yes
# Run interactively and confirm the listed directories
dws skill setup --mode mono --target all
# Point at a local source tree (e.g. a fork or work-in-progress)
# Preview, then install multi skills to a single agent home with interactive confirmation
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| Flag | Values | Description |
|------|--------|-------------|
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Skip confirmation prompts |
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
@@ -471,7 +482,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 +492,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 +526,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 +557,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>
@@ -716,7 +737,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
<summary>Coming soon</summary>
- `conference` (video meetings)
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
</details>
+56 -35
View File
@@ -70,15 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
怎么选:
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
</details>
@@ -205,7 +207,7 @@ bash verify-all-channels.sh
升级过程采用两阶段原子流程,确保一致性:
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包平铺到已检测到的具体 Agent 目录(例如 `~/.codex/skills/dingtalk-chat`、`~/.claude/skills/dingtalk-chat`)。只有未检测到具体 Agent 时才使用 `~/.agents/skills`;检测到具体 Agent 后会备份迁走旧的 DWS 通用副本,避免同一 Skill 被重复发现。
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
@@ -385,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目(默认 mono)
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
@@ -407,22 +409,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# 交互式:提示选模式 + 目标 Agent
dws skill setup
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# 先预览 mono setup 将备份和替换的精确目录
dws skill setup --mode mono --target all --dry-run
# 只装到某一个 Agent home
dws skill setup --mode multi --target cursor --yes
# 交互执行并确认列出的目录
dws skill setup --mode mono --target all
# 指定本地源目录(比如 fork 或正在改的版本)
# 先预览,再交互确认装到某一个 Agent home
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# 指定本地源目录(比如 fork 或正在改的版本),先预览
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| 参数 | 取值 | 说明 |
|------|------|------|
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
| `--yes` | — | 跳过确认提示 |
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
@@ -465,7 +476,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 +486,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 +520,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 +551,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>
@@ -705,7 +726,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
<summary>即将推出</summary>
- `conference`(视频会议)
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
</details>
+637 -17
View File
@@ -3,6 +3,7 @@
"use strict";
const fs = require("fs");
const crypto = require("crypto");
const os = require("os");
const path = require("path");
const childProcess = require("child_process");
@@ -16,6 +17,7 @@ const AGENT_DIRS = [
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
@@ -45,6 +47,58 @@ function ensureCleanDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
// backupStamp returns the UTC timestamp used for backup directory names,
// matching the shell installers' `date -u +%Y%m%d-%H%M%S` layout.
function backupStamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
}
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel-or-basename> instead of destroying it (non-interactive
// installs cannot confirm, so removals must stay reversible). Missing paths
// are a no-op success. On any backup failure the directory is left in place
// and false is returned so callers skip that target rather than silently
// deleting data.
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return true;
}
const rel = path.relative(homeDir, dir);
const name =
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
? rel.split(path.sep).join("-")
: path.basename(dir);
const stamp = backupStamp();
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
let targetRoot = path.join(backupRoot, stamp);
let target = path.join(targetRoot, name);
for (let i = 1; fs.existsSync(target); i++) {
if (i > 1000) {
console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`);
return false;
}
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
target = path.join(targetRoot, name);
}
try {
fs.mkdirSync(targetRoot, { recursive: true });
renameFn(dir, target);
} catch (err) {
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
return false;
}
if (backups) {
backups.push({ original: dir, backup: target });
}
console.log(` × 已备份并移除 ${dir} → ${target}`);
return true;
}
function findBinary(root) {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
@@ -117,47 +171,587 @@ function copyChildren(srcDir, destDir) {
}
}
// publishCacheAtomically prepares a complete sibling tree before replacing a
// cache. If copying or publishing fails, the previous cache stays available.
// copyFn is injectable so the failure contract can be tested without relying
// on platform-specific permission behavior.
function publishCacheAtomically(sourceDir, cacheDir, copyFn = copyChildren) {
const cacheParent = path.dirname(cacheDir);
const cacheName = path.basename(cacheDir);
fs.mkdirSync(cacheParent, { recursive: true });
const stagedDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.tmp-`));
let rollbackDir = "";
let published = false;
try {
copyFn(sourceDir, stagedDir);
if (fs.existsSync(cacheDir)) {
rollbackDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.old-`));
fs.rmSync(rollbackDir, { recursive: true, force: true });
fs.renameSync(cacheDir, rollbackDir);
}
try {
fs.renameSync(stagedDir, cacheDir);
published = true;
} catch (publishErr) {
if (rollbackDir) {
try {
fs.renameSync(rollbackDir, cacheDir);
rollbackDir = "";
} catch (restoreErr) {
throw new Error(
`failed to publish cache ${cacheDir}: ${publishErr.message}; ` +
`failed to restore previous cache from ${rollbackDir}: ${restoreErr.message}`,
);
}
}
throw publishErr;
}
if (rollbackDir) {
try {
fs.rmSync(rollbackDir, { recursive: true, force: true });
} catch (cleanupErr) {
console.warn(
`⚠️ New cache is active, but old cache cleanup failed at ${rollbackDir}: ${cleanupErr.message}`,
);
}
rollbackDir = "";
}
} finally {
if (!published) {
fs.rmSync(stagedDir, { recursive: true, force: true });
}
}
}
function installSkillsToHomes(skillRoot) {
const homeDir = os.homedir();
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
try {
publishManagedMonoSkillSetAtomically(homeDir, skillRoot, baseDir, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(mono 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
const destDir = path.join(baseDir, "dws");
fs.rmSync(destDir, { recursive: true, force: true });
copyChildren(skillRoot, destDir);
installed += 1;
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (installed === 0) {
copyChildren(skillRoot, path.join(homeDir, ".agents", "skills", "dws"));
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 mono Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
}
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
}
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
// bundle carries at least one product skill (a subdir with SKILL.md). An
// empty or corrupt multi/ tree must never select the multi branch nor refresh
// the multi cache — installing it would wipe existing skills and lay down
// nothing.
function multiTreeHasSkills(dir) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return false;
}
return fs
.readdirSync(dir, { withFileTypes: true })
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
}
const MANAGED_SKILL_DIGEST_SCOPE = "skill-directory-v1";
// Frozen exact names shipped before centralized ownership metadata. Retired
// names stay here so old installs can be migrated without treating every
// dingtalk-* directory as DWS-owned.
const LEGACY_OFFICIAL_MULTI_SKILLS = new Set([
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
"dingtalk-todo", "dingtalk-wiki", "dws-shared",
]);
function skillStateDir(homeDir) {
return (process.env.DWS_CONFIG_DIR || "").trim() || path.join(homeDir, ".dws");
}
function readManagedSkillNames(homeDir) {
try {
const state = JSON.parse(fs.readFileSync(path.join(skillStateDir(homeDir), "skills-state.json"), "utf8"));
return new Set((state.managed_skills || []).map((record) => record.name).filter(Boolean));
} catch (_) {
return new Set();
}
}
function isManagedMultiSkillDir(dir, managedNames) {
const name = path.basename(dir);
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
}
function retireGenericSkillRoot(homeDir, managedNames) {
const baseDir = path.join(homeDir, ".agents", "skills");
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
const backups = [];
try {
for (const victim of victims) {
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
} catch (err) {
const restoreErrors = [];
for (let i = backups.length - 1; i >= 0; i -= 1) {
try {
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
fs.renameSync(backups[i].backup, backups[i].original);
} catch (restoreErr) {
restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
}
throw err;
}
}
function skillDirectoryDigest(dir) {
const files = [];
const visit = (current, prefix) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
visit(full, rel);
} else {
files.push({ rel, full });
}
}
};
visit(dir, "");
files.sort((a, b) => Buffer.from(a.rel).compare(Buffer.from(b.rel)));
const hash = crypto.createHash("sha256");
for (const file of files) {
hash.update(file.rel, "utf8");
hash.update(Buffer.from([0]));
hash.update(fs.readFileSync(file.full));
hash.update(Buffer.from([0]));
}
return `sha256:${hash.digest("hex")}`;
}
// Publish a complete multi-skill set as one transaction. The entire new set
// is staged before any Agent-visible directory moves. If a later backup or
// publish fails, every partial publication is removed and all old directories
// are restored from their exact backup paths.
function publishManagedMultiSkillSetAtomically(
homeDir,
multiRoot,
baseDir,
skills,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
const staged = [];
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
for (const name of skills) {
const stagedDir = path.join(stageRoot, name);
copyFn(path.join(multiRoot, name), stagedDir);
staged.push({ staged: stagedDir, dest: path.join(baseDir, name) });
}
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
for (const item of staged) {
renameFn(item.staged, item.dest);
published.push(item.dest);
}
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
// Publish mono plus every mutually-exclusive managed multi victim as one
// transaction. The complete dws/ tree is staged before any live directory is
// moved; a later backup or publish failure restores the exact previous set.
function publishManagedMonoSkillSetAtomically(
homeDir,
monoRoot,
baseDir,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
const stagedDir = path.join(stageRoot, "dws");
const destDir = path.join(baseDir, "dws");
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
copyFn(monoRoot, stagedDir);
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
published.push(destDir);
renameFn(stagedDir, destDir);
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
function writeSkillsState(homeDir, multiRoot, skills) {
const version = process.env.npm_package_version || process.env.DWS_PACKAGE_VERSION || "unknown";
const managedSkills = [...skills].sort().map((name) => ({
name,
version,
source: "npm-postinstall",
digest: skillDirectoryDigest(path.join(multiRoot, name)),
digest_scope: MANAGED_SKILL_DIGEST_SCOPE,
}));
const state = {
version,
official_skills: [...skills].sort(),
updated_skills: [...skills].sort(),
managed_skills: managedSkills,
updated_at: new Date().toISOString(),
};
const stateDir = skillStateDir(homeDir);
fs.mkdirSync(stateDir, { recursive: true });
const stage = fs.mkdtempSync(path.join(stateDir, ".skills-state.tmp-"));
const stagedFile = path.join(stage, "skills-state.json");
const statePath = path.join(stateDir, "skills-state.json");
const rollbackPath = path.join(stage, "skills-state.previous.json");
let movedPrevious = false;
let preserveRecovery = false;
try {
fs.writeFileSync(stagedFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
if (fs.existsSync(statePath)) {
fs.renameSync(statePath, rollbackPath);
movedPrevious = true;
}
try {
fs.renameSync(stagedFile, statePath);
} catch (err) {
if (movedPrevious && !fs.existsSync(statePath)) {
try {
fs.renameSync(rollbackPath, statePath);
movedPrevious = false;
} catch (restoreErr) {
preserveRecovery = true;
throw new Error(
`publish skills state failed: ${err.message}; restore also failed: ${restoreErr.message}; previous state retained at ${rollbackPath}`,
);
}
}
throw err;
}
} finally {
if (!preserveRecovery) {
fs.rmSync(stage, { recursive: true, force: true });
}
}
}
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
// every product skill becomes a sibling directory of the agent home. Mutual
// exclusion: the mono leftover (dws/) and stale, proven DWS-managed skills not
// present in the new bundle are removed first.
function installMultiSkillsToHomes(multiRoot) {
const homeDir = os.homedir();
const skills = fs
.readdirSync(multiRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
.map((e) => e.name);
if (skills.length === 0) {
throw new Error(`no product skills found under ${multiRoot}`);
}
const skillSet = new Set(skills);
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
fs.mkdirSync(baseDir, { recursive: true });
const victims = [path.join(baseDir, "dws")];
// Mutual exclusion: include the mono leftover and stale managed skills in
// the same transaction as every replaced bundled skill.
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (
entry.isDirectory() &&
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
!skillSet.has(entry.name)
) {
victims.push(path.join(baseDir, entry.name));
}
}
for (const name of skills) {
victims.push(path.join(baseDir, name));
}
try {
publishManagedMultiSkillSetAtomically(homeDir, multiRoot, baseDir, skills, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(multi 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 multi Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
}
writeSkillsState(homeDir, multiRoot, skills);
}
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
// wins; multi is the default. The --skill-mode flag accepts both the space
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
function resolveSkillMode() {
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
if (raw === "mono" || raw === "multi") {
return raw;
}
if (raw !== "") {
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
}
let fromFlag;
const flagIndex = process.argv.indexOf("--skill-mode");
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
fromFlag = process.argv[flagIndex + 1];
} else {
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
if (equalsArg) {
fromFlag = equalsArg.slice("--skill-mode=".length);
}
}
if (fromFlag !== undefined) {
const mode = fromFlag.trim().toLowerCase();
if (mode === "mono" || mode === "multi") {
return mode;
}
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
}
return "multi";
}
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
// can fall back to a user-local cache when --source is not provided. mono is
// already installed into agent homes by installSkillsToHomes; the cache is
// purely a source-of-truth for the setup command.
// can fall back to a user-local cache when --source is not provided. A cache
// is only refreshed when the new bundle actually carries that tree — an
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
// good cache.
function cacheUserSkills(extractedSkillsRoot) {
const cacheBase = path.join(os.homedir(), ".dws", "skills");
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
? path.join(extractedSkillsRoot, "mono")
: extractedSkillsRoot;
const monoCache = path.join(cacheBase, "mono");
fs.rmSync(monoCache, { recursive: true, force: true });
copyChildren(monoSource, monoCache);
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
const monoCache = path.join(cacheBase, "mono");
publishCacheAtomically(monoSource, monoCache);
}
const multiSource = path.join(extractedSkillsRoot, "multi");
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
if (multiTreeHasSkills(multiSource)) {
const multiCache = path.join(cacheBase, "multi");
fs.rmSync(multiCache, { recursive: true, force: true });
copyChildren(multiSource, multiCache);
publishCacheAtomically(multiSource, multiCache);
}
}
@@ -191,8 +785,34 @@ function main() {
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
? path.join(skillsStaging, "mono")
: skillsStaging;
installSkillsToHomes(monoRoot);
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
// multi-only zip monoRoot would degrade to the staging root and copy the
// whole bundle (multi/ included) into a dws/ directory — skip instead.
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
const multiRoot = path.join(skillsStaging, "multi");
const skillMode = resolveSkillMode();
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
console.log(`Skill mode: multi — installing per-product skills`);
installMultiSkillsToHomes(multiRoot);
} else {
if (skillMode === "multi") {
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
}
if (monoHasSkill) {
installSkillsToHomes(monoRoot);
} else {
console.log("mono skill tree not found in bundle; skipping skill install.");
}
}
cacheUserSkills(skillsStaging);
}
main();
if (require.main === module) {
main();
}
module.exports = {
publishCacheAtomically,
publishManagedMonoSkillSetAtomically,
publishManagedMultiSkillSetAtomically,
};
+91 -2
View File
@@ -17,18 +17,23 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/spf13/cobra"
)
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
@@ -112,7 +117,11 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
root := newRootCommand()
snapshot := interfacesnapshot.Capture(root)
if err := validateHelpRendering(root, snapshot); err != nil {
return err
}
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
@@ -138,6 +147,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
currentPath := flags.String("current", "", "candidate snapshot path")
basePath := flags.String("base", "", "target main/development baseline snapshot path")
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
approvedMigrationsPath := flags.String(
"approved-flag-migrations",
"",
"merge-base-owned approved flag migration manifest",
)
candidateMigrationsPath := flags.String(
"candidate-flag-migrations",
"",
"candidate flag migration manifest",
)
if err := flags.Parse(args); err != nil {
return false, err
}
@@ -150,6 +169,14 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
if *basePath == "" && *stablePath == "" {
return false, fmt.Errorf("compare requires --base, --stable, or both")
}
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
)
}
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
}
current, err := readSnapshot(*currentPath)
if err != nil {
@@ -170,6 +197,25 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
}
report := interfacesnapshot.CompareAll(current, references)
if *approvedMigrationsPath != "" {
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
}
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
current,
references,
approved,
candidate,
)
if err != nil {
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
}
}
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
@@ -179,6 +225,49 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
return report.Compatible, nil
}
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadFlagMigrationManifest(file)
}
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
for _, command := range snapshot.Commands {
path := strings.TrimPrefix(command.Path, "dws")
resolved, remaining, err := root.Find(strings.Fields(path))
if err != nil || len(remaining) != 0 || resolved == nil {
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
}
if err := renderCommandHelp(resolved); err != nil {
return fmt.Errorf("render %q help: %w", command.Path, err)
}
}
return nil
}
func renderCommandHelp(command *cobra.Command) (err error) {
var stdout, stderr bytes.Buffer
command.InitDefaultHelpFlag()
command.SetOut(&stdout)
command.SetErr(&stderr)
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("help renderer panicked: %v", recovered)
}
}()
command.HelpFunc()(command, []string{})
if stderr.Len() > 0 {
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
}
if stdout.Len() == 0 {
return fmt.Errorf("help renderer produced empty output")
}
return nil
}
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
@@ -191,5 +280,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
}
+447
View File
@@ -15,11 +15,16 @@ package main
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
@@ -56,6 +61,24 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T)
}
}
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
return root
})
var stdout, stderr bytes.Buffer
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "injected help failure") {
t.Fatalf("run(generate) stderr=%q", stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
current := commandSnapshot("dws")
mergeBase := commandSnapshot("dws")
@@ -83,6 +106,368 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes
}
}
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
dir := t.TempDir()
before := flagMigrationSnapshot(false)
after := flagMigrationSnapshot(true)
currentPath := writeSnapshot(t, dir, "current.json", after)
basePath := writeSnapshot(t, dir, "base.json", before)
stablePath := writeSnapshot(t, dir, "stable.json", before)
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
exitCode = run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", emptyApproved,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--approved-flag-migrations", approvedPath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
tests := []struct {
name string
args []string
}{
{
name: "missing stable",
args: []string{"--base", basePath},
},
{
name: "missing base",
args: []string{"--stable", stablePath},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := []string{"compare", "--current", currentPath}
args = append(args, test.args...)
args = append(args,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
)
var stdout, stderr bytes.Buffer
exitCode := run(args, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
tests := []struct {
name string
args []string
wantStderr []string
}{
{
name: "missing command",
args: nil,
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
},
{
name: "unknown command",
args: []string{"unknown"},
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
}
for _, want := range test.wantStderr {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
}
}
})
}
}
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
outputDirectory := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
missingTempRoot := filepath.Join(t.TempDir(), "missing")
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
t.Setenv(name, missingTempRoot)
}
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate"}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
dir := t.TempDir()
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
missingPath := filepath.Join(dir, "missing.json")
tests := []struct {
name string
args []string
want string
}{
{
name: "current",
args: []string{"compare", "--current", missingPath, "--base", validPath},
want: "read current snapshot",
},
{
name: "main",
args: []string{"compare", "--current", validPath, "--base", missingPath},
want: "read main/development baseline snapshot",
},
{
name: "stable",
args: []string{"compare", "--current", validPath, "--stable", missingPath},
want: "read stable snapshot",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
tests := []struct {
name string
approved string
candidate string
want string
}{
{
name: "approved manifest",
approved: invalidManifest,
candidate: validManifest,
want: "read approved flag migrations",
},
{
name: "candidate manifest",
approved: validManifest,
candidate: invalidManifest,
want: "read candidate flag migrations",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
"--stable", snapshotPath,
"--approved-flag-migrations", test.approved,
"--candidate-flag-migrations", test.candidate,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
var stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
}, failingWriter{}, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
}
}
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
dir := t.TempDir()
missingPath := filepath.Join(dir, "missing.json")
invalidPath := filepath.Join(dir, "invalid.json")
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
t.Fatalf("write invalid fixture: %v", err)
}
if _, err := readSnapshot(missingPath); err == nil {
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
}
if _, err := readSnapshot(invalidPath); err == nil {
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(missingPath); err == nil {
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
err := validateHelpRendering(root, commandSnapshot("dws missing"))
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
t.Fatalf("validateHelpRendering resolve error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate(`{{index .Commands 99}}`)
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate("{{")
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template panic = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
t.Fatalf("validateHelpRendering custom stderr = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(*cobra.Command, []string) {})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("validateHelpRendering empty output = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
root := &cobra.Command{Use: "dws", Short: "root command"}
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
t.Fatalf("validateHelpRendering normal output: %v", err)
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, errors.New("injected write failure")
}
var _ io.Writer = failingWriter{}
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
commands := make([]interfacesnapshot.Command, 0, len(paths))
for _, path := range paths {
@@ -120,6 +505,68 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn
return path
}
func writeManifest(t *testing.T, dir, name, contents string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
legacy := interfacesnapshot.Flag{
Name: "legacy-id",
Shorthand: "l",
Type: "string",
Default: "",
NoOpt: "auto",
Required: true,
}
flags := []interfacesnapshot.Flag{legacy}
if after {
legacy.Required = false
legacy.Hidden = true
legacy.AliasOf = "message-id"
flags = []interfacesnapshot.Flag{
legacy,
{Name: "message-id", Type: "string", Default: "", Required: true},
}
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{},
ExcludedFlags: []string{},
},
Commands: []interfacesnapshot.Command{
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
},
}
}
func flagMigrationManifestJSON(state string) string {
return strings.Replace(`{
"version": 1,
"migrations": [{
"command": "dws chat send",
"legacy": {
"name": "legacy-id",
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "STATE",
"reason": "reviewed exact migration"
}]
}`, "STATE", state, 1)
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
+53 -16
View File
@@ -48,8 +48,12 @@ It then runs:
--fast-path "$PR_BASE_SHA" HEAD
```
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
its policy dependencies in that merge tree are byte-for-byte the current base
The exact fast path remains limited to historic one-file maintenance. A
release-seal PR uses `--content-only`, which permits the generated
`CHANGELOG.md` change together with archival moves from `.changes/` to
`.changes/released/`; it receives the normal scoped admission instead of this
fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone
release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base
versions. Validation targets the synthetic merge tree, not the feature-branch
tree, so a stale branch cannot supply an older validator or combine with newer
base notes into an invalid final CHANGELOG.
@@ -79,10 +83,12 @@ to the complete main admission suite. A source change can therefore never
inherit the CHANGELOG-only result.
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
content contract in `Policy` with `--content-only`. That mode permits the
second file but still rejects invalid dates or versions, missing bullets,
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
Adding a second file therefore cannot bypass CHANGELOG validation.
content contract in `Policy` with `--content-only`. That mode accepts only
fragment archival moves (`.changes/<name>.md` to
`.changes/released/<version>/<name>.md`) alongside the changelog; source and
documentation changes are rejected. It still rejects invalid dates or
versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section
changes, and unsafe tree modes.
## Risk tiers and downstream boundaries
@@ -157,20 +163,38 @@ expected to repeat every CI job locally:
```sh
make build
make policy
make interface-integrity
make authoritative-interface-integrity BASE_REF=<merge-base>
make schema-compatibility BASE_REF=<merge-base>
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make skill-command-integrity
make cli-smoke
make mock-mcp-smoke
go test -v -count=1 ./pkg/editiontest/...
```
For an exact CHANGELOG-only branch:
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
candidate SHA。
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
flag 迁移。
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
For a release-seal branch that archives rendered fragments:
```sh
base_ref=$(git merge-base HEAD origin/main)
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD
```
`make coverage-gate` is an enforcement step, not a profile generator. For a
@@ -186,11 +210,24 @@ are evaluated by the same block-deduplicating checker; supporting policy and
shortcut profiles contribute to changed-code coverage only. The checked-in
badge is presentation only and is never read as a gate input.
Compatibility checks derive authoritative Interface snapshots from the PR
merge-base and the latest reachable stable release. The candidate cannot bless
a breaking change by editing a fixture. Schema additions are allowed;
historical products, tools, parameters, mappings, positional execution fields,
constraints, and safety semantics remain protected.
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
提交中的规范空清单;完整边界见下方治理文档。
精确的两阶段 flag 迁移生命周期见
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
## Required GitHub repository settings
+176
View File
@@ -0,0 +1,176 @@
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
## 唯一比较入口与信任边界
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
- `cmd/interface-snapshot` 生成和比较快照;
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
`scripts/policy/check-interface-baseline.sh` 只供
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
Smoke fixture,不参与迁移审批。
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
调用方因漏传历史参考而提前清理 consumed receipt。
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
已审批清单固定为:
```text
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
```json
{
"version": 1,
"migrations": [
{
"command": "dws chat message recall",
"legacy": {
"name": "msg-id",
"before": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
},
"after": {
"present": true,
"type": "string",
"hidden": true,
"scope": "local",
"alias_of": "message-id"
}
},
"canonical": {
"name": "message-id",
"before": { "present": false },
"after": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
}
},
"state": "pending",
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
}
]
}
```
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
- legacy 与 canonical 位于同一个可执行命令;
- canonical flag 确实存在;
- legacy 与 canonical 类型一致;
- legacy 不是指向自身,也不存在 alias chain;
- legacy 的 after 状态精确指向该记录中的 canonical flag。
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
- 删除 legacy、canonical、命令或其他 flag;
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
`required` / `cli_required` 或重写 constraint;
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
`required_when` 必须完全一致;
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
fail closed。
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
```sh
make interface-integrity \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
make schema-compatibility \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
```
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
+312
View File
@@ -0,0 +1,312 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="DWS Drive Shortcut 与 lark-cli 的业务能力、真实数据 E2E 证据和平台边界分析。">
<title>Drive Shortcut 能力全景|业务评审版</title>
<style>
:root {
color-scheme: light;
--paper: #f4f6f2; --surface: #fffefa; --ink: #17251f; --muted: #66746d;
--line: #dce2dc; --forest: #154f3d; --green: #17765a; --mint: #dff4e8;
--blue: #265f86; --blue-soft: #e7f1f7; --amber: #8c5a09; --amber-soft: #fff2cf;
--red: #a43b32; --red-soft: #fde9e5; --shadow: 0 14px 40px rgba(28, 48, 38, .08);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; color: var(--ink); background: var(--paper); font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
a { color: inherit; text-decoration: none; }
code { padding: .12rem .38rem; border: 1px solid #d6e0da; border-radius: 6px; color: #174f3e; background: #f1f7f3; font: 600 .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.wrap { width: min(1180px, calc(100% - 40px)); margin: auto; }
.hero { position: relative; overflow: hidden; padding: 64px 0 52px; color: #f7fff9; background: linear-gradient(125deg, #102b22 0%, #154c3c 57%, #1c6b54 100%); }
.hero::after { position: absolute; inset: -180px -100px auto auto; width: 540px; height: 540px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; box-shadow: 0 0 0 76px rgba(255,255,255,.035), 0 0 0 152px rgba(255,255,255,.025); content: ""; }
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 32px; align-items: end; }
.eyebrow, .section-kicker { margin: 0 0 9px; color: #9fd6bd; font-size: 11px; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(40px, 6vw, 68px); line-height: 1.03; letter-spacing: -.045em; }
.subtitle { max-width: 760px; margin: 19px 0 0; color: #d3e9de; font-size: 17px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 21px; }
.meta-pill { padding: 6px 10px; border: 1px solid rgba(255,255,255,.18); border-radius: 999px; color: #d5e9df; background: rgba(255,255,255,.07); font-size: 11px; font-weight: 750; }
.meta-pill.good { color: #bff2d5; border-color: rgba(139,232,179,.38); }
.hero-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.hero-stat { padding: 17px 16px; border: 1px solid rgba(255,255,255,.17); border-radius: 15px; background: rgba(255,255,255,.075); backdrop-filter: blur(8px); }
.hero-stat strong { display: block; font-size: 30px; line-height: 1; }
.hero-stat span { display: block; margin-top: 7px; color: #cce2d7; font-size: 11px; }
.nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--line); background: rgba(255,254,250,.94); backdrop-filter: blur(12px); }
.nav .wrap { display: flex; overflow-x: auto; }
.nav a { flex: 0 0 auto; padding: 14px 15px; color: #5b6c64; font-size: 12px; font-weight: 800; }
.nav a:hover { color: var(--forest); background: #eaf3ee; }
main { padding: 38px 0 74px; }
section { margin-top: 50px; scroll-margin-top: 72px; }
section:first-child { margin-top: 0; }
.section-head { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 19px; }
h2 { margin: 0; font-size: clamp(25px, 3.2vw, 36px); line-height: 1.16; letter-spacing: -.025em; }
h3 { margin: 0 0 7px; font-size: 18px; }
.section-desc { max-width: 660px; margin: 0; color: var(--muted); font-size: 13px; }
.callout { padding: 19px 21px; border: 1px solid #bdd8cb; border-left: 4px solid var(--green); border-radius: 13px; background: #ecf7f1; box-shadow: 0 6px 20px rgba(28,48,38,.04); }
.callout strong { color: #13513d; }
.callout.warn { border-color: #ead29a; border-left-color: #b67508; background: #fff8e7; }
.callout.warn strong { color: #784b00; }
.callout.danger { border-color: #e9b7b1; border-left-color: var(--red); background: var(--red-soft); }
.score-grid, .domain-grid, .evidence-grid, .review-grid { display: grid; gap: 13px; }
.score-grid { grid-template-columns: repeat(4, 1fr); margin-top: 16px; }
.score, .domain-card, .evidence-card, .review-card { border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.score { padding: 19px; }
.score strong { display: block; color: var(--forest); font-size: 29px; line-height: 1; }
.score span { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
.domain-grid { grid-template-columns: repeat(4, 1fr); }
.domain-card { position: relative; padding: 21px; overflow: hidden; }
.domain-card .number { position: absolute; top: 12px; right: 17px; color: #d5e8de; font: 800 42px/1 ui-monospace, monospace; }
.domain-card p { min-height: 64px; margin: 8px 0 12px; color: var(--muted); font-size: 13px; }
.domain-card small { color: var(--green); font-weight: 800; }
.compare { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.compare-top { display: grid; grid-template-columns: repeat(3, 1fr); }
.compare-column { padding: 21px; border-right: 1px solid var(--line); }
.compare-column:last-child { border-right: 0; }
.compare-column p, .compare-column li { color: var(--muted); font-size: 13px; }
.compare-column ul { margin: 9px 0 0; padding-left: 18px; }
.compare-column.covered { border-top: 5px solid var(--green); }
.compare-column.partial { border-top: 5px solid #c78b22; }
.compare-column.gap { border-top: 5px solid var(--red); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: #617069; background: #f7f8f5; font-size: 11px; font-weight: 900; letter-spacing: .03em; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #f8fbf8; }
.verdict, .badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 900; white-space: nowrap; }
.v-covered, .badge.read { color: #116045; background: var(--mint); }
.v-ahead, .badge.smart { color: #20577c; background: var(--blue-soft); }
.v-partial, .badge.write { color: #7b510a; background: var(--amber-soft); }
.v-gap, .badge.high { color: #8f3028; background: var(--red-soft); }
.truth-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 14px; }
.truth-card { padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.truth-step { display: grid; grid-template-columns: 30px 1fr; gap: 11px; margin-top: 13px; }
.truth-step b { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; color: #fff; background: var(--forest); font-size: 12px; }
.truth-step strong, .truth-step span { display: block; }
.truth-step span { color: var(--muted); font-size: 12px; }
.toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) 180px 180px auto; gap: 10px; align-items: center; margin: 18px 0; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
input, select { width: 100%; min-height: 42px; padding: 9px 11px; border: 1px solid #ccd7d0; border-radius: 9px; color: var(--ink); background: #fff; font: inherit; }
input:focus, select:focus { outline: 3px solid rgba(23,118,90,.13); border-color: var(--green); }
.result-count { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
.catalog { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.shortcut-row { display: grid; grid-template-columns: 215px minmax(0, 1fr) 200px; gap: 16px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); }
.shortcut-row:last-child { border-bottom: 0; }
.shortcut-row:hover { background: #f8fbf8; }
.command code { font-size: 12px; }
.row-main p { margin: 0; font-size: 13px; }
.row-main small { color: var(--muted); }
.badges { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
.hidden-row { display: none; }
.evidence-grid { grid-template-columns: repeat(4, 1fr); }
.evidence-card { padding: 19px; }
.evidence-card strong { display: block; color: var(--forest); font-size: 23px; }
.evidence-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }
.timeline { margin-top: 15px; border-left: 2px solid #bdd7ca; }
.event { position: relative; padding: 0 0 17px 22px; }
.event::before { position: absolute; left: -7px; top: 5px; width: 12px; height: 12px; border: 3px solid var(--paper); border-radius: 50%; background: var(--green); content: ""; }
.event b { display: block; }
.event span { color: var(--muted); font-size: 12px; }
.review-grid { grid-template-columns: repeat(3, 1fr); }
.review-card { position: relative; padding: 20px; }
.review-card .review-num { color: #b8d1c4; font: 800 12px/1 ui-monospace, monospace; letter-spacing: .1em; }
.review-card p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
footer { margin-top: 52px; padding: 23px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
@media (max-width: 900px) { .hero-grid, .truth-grid { grid-template-columns: 1fr; } .score-grid, .domain-grid, .evidence-grid { grid-template-columns: repeat(2, 1fr); } .review-grid { grid-template-columns: 1fr 1fr; } .shortcut-row { grid-template-columns: 180px 1fr; } .badges { grid-column: 1 / -1; justify-content: flex-start; } }
@media (max-width: 620px) { .wrap { width: min(100% - 24px, 1180px); } .hero { padding: 44px 0 38px; } .hero-stats, .score-grid, .domain-grid, .evidence-grid, .review-grid, .compare-top { grid-template-columns: 1fr; } .compare-column { border-right: 0; border-bottom: 1px solid var(--line); } .toolbar { grid-template-columns: 1fr; } .result-count { text-align: left; } .shortcut-row { grid-template-columns: 1fr; } }
@media print { body { background: #fff; } .hero { color: var(--ink); background: #fff; border-bottom: 2px solid var(--ink); } .subtitle, .meta-pill, .hero-stat span { color: #425249; } .hero-stat { border-color: #aebbb3; } .nav, .toolbar { display: none; } .score, .domain-card, .compare, .truth-card, .catalog, .evidence-card, .review-card { box-shadow: none; break-inside: avoid; } }
</style>
</head>
<body>
<header class="hero">
<div class="wrap hero-grid">
<div>
<p class="eyebrow">Business Review · Drive</p>
<h1>Drive Shortcut<br>能力全景</h1>
<p class="subtitle">从 lark-cli 对齐出发,但不止于命令名:逐项审查输入、校验、多步编排、失败语义、真实字节和平台边界。</p>
<div class="meta-row">
<span class="meta-pill good">真实账号 E2E 已执行</span>
<span class="meta-pill">28 个公开入口</span>
<span class="meta-pill">统一 Result / Pagination</span>
<span class="meta-pill">报告已移除 PII / 凭证 / 业务正文</span>
</div>
</div>
<div class="hero-stats" aria-label="关键统计">
<div class="hero-stat"><strong>38</strong><span>lark-cli Drive 逐项审查</span></div>
<div class="hero-stat"><strong>26</strong><span>已覆盖或跨产品路由</span></div>
<div class="hero-stat"><strong>7</strong><span>部分对齐,边界已公开</span></div>
<div class="hero-stat"><strong>5</strong><span>客观不可对齐能力</span></div>
</div>
</div>
</header>
<nav class="nav"><div class="wrap"><a href="#overview">全景</a><a href="#compare">Lark 对齐</a><a href="#ahead">超越项</a><a href="#truth">真实语义</a><a href="#catalog">完整目录</a><a href="#e2e">E2E</a><a href="#review">评审</a></div></nav>
<main class="wrap">
<section id="overview">
<div class="section-head"><div><p class="section-kicker">Executive summary</p><h2>28 个公开入口,覆盖文件完整生命周期</h2></div><p class="section-desc">另有 <code>+publish-set</code> 已实现契约和读回逻辑,但真实普通文件与在线文档均被服务端拒绝,因此保持 unavailable,不进入 Agent 公开目录。</p></div>
<div class="callout"><strong>结论:</strong>Drive 已从 9 个偏原子入口扩展为 28 个可发现 Shortcut。它不仅补齐 Lark 的核心文件、版本和状态任务,还通过严格响应合同、真实落盘、写后读回、回收恢复和个人收藏形成更可审计的钉盘工作流。</div>
<div class="score-grid">
<article class="score"><strong>29</strong><span>已审查注册项(含 1 unavailable)</span></article>
<article class="score"><strong>25</strong><span>公开主能力 / 语义适配</span></article>
<article class="score"><strong>3</strong><span>公开兼容入口</span></article>
<article class="score"><strong>3</strong><span>高风险写入口,均需确认</span></article>
</div>
</section>
<section>
<div class="section-head"><div><p class="section-kicker">Capability map</p><h2>四个业务域</h2></div><p class="section-desc">目录按用户任务组织;兼容命令不重复计为新增能力。</p></div>
<div class="domain-grid">
<article class="domain-card"><span class="number">09</span><h3>发现与检查</h3><p>严格目录分页、搜索、最近访问,以及元数据、统计和封面聚合检查。</p><small>+list · +search · +recent · +inspect</small></article>
<article class="domain-card"><span class="number">09</span><h3>文件生命周期</h3><p>创建目录、上传下载、快捷方式、在线对象复制、移动重命名、删除与恢复。</p><small>+upload · +download · +rename · +recycle-restore</small></article>
<article class="domain-card"><span class="number">06</span><h3>个人与公开状态</h3><p>回收站清单、收藏闭环和互联网公开状态的独立安全域。</p><small>+star-list · +star-add · +publish-get</small></article>
<article class="domain-card"><span class="number">04</span><h3>历史版本</h3><p>版本列表、精确定位、真实字节下载与高风险回滚读回。</p><small>+version-history · +version-download · +version-revert</small></article>
</div>
</section>
<section id="compare">
<div class="section-head"><div><p class="section-kicker">Lark alignment</p><h2>对齐业务语义,不追求同名率</h2></div><p class="section-desc">38 项逐项核对。评论、导入导出和成员权限在 DWS 由更成熟的 Doc 或原子权限入口承接,不在 Drive 再复制一套。</p></div>
<div class="compare">
<div class="compare-top">
<article class="compare-column covered"><h3>26 · 已覆盖 / 路由</h3><p>上传下载、目录与快捷方式、版本、移动删除、状态、搜索、评论、导入导出和成员任务均有真实入口。</p></article>
<article class="compare-column partial"><h3>7 · 部分对齐</h3><p>预览、resolve/reaction、push/pull、权限申请与 setting 受对象模型或接口粒度约束,明确保留有限语义。</p></article>
<article class="compare-column gap"><h3>5 · 客观缺口</h3><p>删除评论恢复、普通文件版本删除、安全标签读写、可靠双向目录同步缺少必要下层接口。</p></article>
</div>
<div class="table-wrap"><table><thead><tr><th>Lark 任务组</th><th>DWS 主路径</th><th>结论</th><th>关键差异与交付决定</th></tr></thead><tbody>
<tr><td>upload / folder / shortcut / download</td><td><code>drive +upload</code> 等</td><td><span class="verdict v-ahead">增强</span></td><td>工作目录边界、OSS PUT、严格 commit、no-clobber、原子落盘、非零字节和读回验证。</td></tr>
<tr><td>preview / cover</td><td><code>drive +cover</code></td><td><span class="verdict v-partial">部分</span></td><td>封面/缩略图可读;没有等价的服务端多格式预览转换,不扩大宣称。</td></tr>
<tr><td>comments / replies</td><td><code>doc +comment-*</code> / <code>doc +review</code></td><td><span class="verdict v-covered">路由</span></td><td>评论归在线文档协作域;独立 resolve、reaction identity 与删除后恢复仍受接口限制。</td></tr>
<tr><td>export / import / task result</td><td><code>doc +export</code> / <code>doc +import</code></td><td><span class="verdict v-ahead">增强</span></td><td>提交、轮询、恢复、安全下载形成类型化闭环,不保留泛化下划线命令。</td></tr>
<tr><td>version history / get / revert</td><td><code>drive +version-*</code></td><td><span class="verdict v-ahead">增强</span></td><td>严格分页、精确版本、历史字节落盘、回滚前预检与终态读回;历史版本删除无接口。</td></tr>
<tr><td>status / inspect</td><td><code>drive +inspect</code></td><td><span class="verdict v-ahead">超越</span></td><td>元数据为必达结果,统计、公开状态和封面按需 fan-out;可选失败为 partial_success。</td></tr>
<tr><td>push / pull / sync</td><td><code>+upload</code> / <code>+download</code> 单文件</td><td><span class="verdict v-partial">部分</span></td><td>不在缺少稳定 hash、rename/delete journal 和冲突向量时制造危险目录同步。</td></tr>
<tr><td>member / permission</td><td><code>doc +access-*</code> / <code>drive permission</code></td><td><span class="verdict v-covered">路由</span></td><td>协作者权限与互联网公开是两个安全域;申请权限需真实上下文,未伪装为通用 Shortcut。</td></tr>
<tr><td>secure labels</td><td>无等价</td><td><span class="verdict v-gap">缺口</span></td><td>当前 DWS/钉钉下层没有 Drive 安全标签目录和写入接口,不能用普通权限代替。</td></tr>
<tr><td>search</td><td><code>drive +search</code> / <code>doc +search</code></td><td><span class="verdict v-ahead">增强</span></td><td>文件与在线文档按域路由;文件搜索严格验证数组、过滤和分页。</td></tr>
</tbody></table></div>
</div>
<div class="callout warn" style="margin-top:14px"><strong>普通文件 copy 边界:</strong>钉钉现有复制接口对普通文件产生 <code>.dlink</code>,不是字节独立副本。因此 <code>+copy</code> 只接受在线对象;普通文件快捷入口用 <code>+create-shortcut</code>,独立副本使用 <code>+download</code> 后 <code>+upload</code>。</div>
</section>
<section id="ahead">
<div class="section-head"><div><p class="section-kicker">Beyond parity</p><h2>DWS 可主推的八个差异化点</h2></div><p class="section-desc">价值来自正确性与完整闭环,而不是额外注册同义命令。</p></div>
<div class="domain-grid">
<article class="domain-card"><h3>严格目录语义</h3><p><code>+list</code> / <code>+recent</code> 只有服务端明确返回数组时才接受空集合。</p><small>缺字段 ≠ 空目录</small></article>
<article class="domain-card"><h3>聚合检查</h3><p><code>+inspect</code> 一次汇总身份、统计、公开状态和封面,并保留局部失败。</p><small>partial_success 可审计</small></article>
<article class="domain-card"><h3>真实文件传输</h3><p>上传执行完整事务;下载验证受控路径、覆盖策略、原子发布和字节。</p><small>不是只返回临时 URL</small></article>
<article class="domain-card"><h3>回收恢复闭环</h3><p>从 <code>recycleItemId</code> 恢复后读取真实节点,证明资源确实回到可访问状态。</p><small>恢复后读回</small></article>
<article class="domain-card"><h3>个人收藏闭环</h3><p>收藏、列表、取消收藏覆盖完整用户偏好过程,并保留分页。</p><small>add → list → remove</small></article>
<article class="domain-card"><h3>版本真实字节</h3><p>除元数据外可下载任意已知历史版本,并用本地字节核验回滚结果。</p><small>version-download</small></article>
<article class="domain-card"><h3>重命名终态</h3><p>处理服务端扩展名规则,再读取节点确认最终名称,避免重复扩展名。</p><small>write → read-back</small></article>
<article class="domain-card"><h3>公开域诚实降级</h3><p>查询和关闭可验证;开启在 eligible 节点闭环完成前保持 unavailable。</p><small>不把 notSupported 当成功</small></article>
</div>
</section>
<section id="truth">
<div class="section-head"><div><p class="section-kicker">Truthful execution</p><h2>空数组不再是“看起来成功”</h2></div><p class="section-desc">合法业务空集合可以成功,但必须先证明响应结构、元素类型和分页语义成立。内部错误、缺字段与坏投影必须失败。</p></div>
<div class="truth-grid">
<article class="truth-card"><h3>四层成功证据</h3>
<div class="truth-step"><b>1</b><div><strong>传输成功</strong><span>进程成功,MCP / HTTP 没有显式错误。</span></div></div>
<div class="truth-step"><b>2</b><div><strong>响应合同</strong><span>对象、数组、success 标志和元素类型与命令声明一致。</span></div></div>
<div class="truth-step"><b>3</b><div><strong>业务终态</strong><span>写命令必须获得新 ID、终态证据或后续元数据读回。</span></div></div>
<div class="truth-step"><b>4</b><div><strong>产物校验</strong><span>下载必须落盘、非零字节;关键链路比较大小和 SHA-256。</span></div></div>
</article>
<article class="truth-card"><h3>明确失败的情况</h3>
<ul><li>空响应、缺少预期集合字段或集合类型错误。</li><li>集合存在坏元素,不能投影时静默丢弃。</li><li><code>success=false</code>、写响应没有 ID 或读回不一致。</li><li>inspect 的可选分支失败却返回整体 success。</li><li>下载得到空文件、越界路径或覆盖既有文件。</li><li>普通文件 copy 返回快捷链接却声称独立副本。</li></ul>
</article>
</div>
</section>
<section id="catalog">
<div class="section-head"><div><p class="section-kicker">Full catalog</p><h2>28 个公开 Shortcut 完整目录</h2></div><p class="section-desc">16 个只读、9 个普通写、3 个高风险写;3 个历史入口保留兼容但不作为新 Agent 主路径。</p></div>
<div class="toolbar"><input id="q" type="search" placeholder="搜索命令或用途,例如 版本、回收、+inspect…" aria-label="搜索 Shortcut"><select id="domain"><option value="all">全部业务域</option><option value="discover">发现与检查</option><option value="lifecycle">文件生命周期</option><option value="personal">个人与公开</option><option value="version">历史版本</option></select><select id="risk"><option value="all">全部风险</option><option value="read">只读</option><option value="write">普通写</option><option value="high">高风险写</option></select><span id="result-count" class="result-count">显示 28 / 28</span></div>
<div class="catalog">
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +list</code></div><div class="row-main"><p>严格分页列出目录,保留游标,区分显式空目录和畸形响应。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +inspect</code></div><div class="row-main"><p>聚合元数据与可选统计、公开状态、封面;局部失败如实报告。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +info</code></div><div class="row-main"><p>历史元数据兼容入口;新场景优先使用 +inspect。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search</code></div><div class="row-main"><p>按关键词、类型、扩展名、创建人、时间和分页搜索钉盘文件。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +find-file</code></div><div class="row-main"><p>历史文件搜索兼容入口;新场景优先使用 +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search-docs</code></div><div class="row-main"><p>历史跨域搜索入口;新的在线文档搜索路由 doc +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +recent</code></div><div class="row-main"><p>读取最近访问或编辑列表,支持创建人筛选并保留分页。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +stats</code></div><div class="row-main"><p>读取访问、编辑、评论、点赞、预览和下载统计。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +cover</code></div><div class="row-main"><p>读取封面或缩略图;不宣称服务端多格式预览。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +upload</code></div><div class="row-main"><p>上传凭证、OSS PUT、严格提交和远端元数据读回的一体化事务。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="read"><div class="command"><code>dws drive +download</code></div><div class="row-main"><p>安全落盘、no-clobber、原子发布并验证非零字节。</p><small>文件生命周期</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-folder</code></div><div class="row-main"><p>创建文件夹后要求新 ID,并读回名称验证。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-shortcut</code></div><div class="row-main"><p>创建快捷方式并读回,明确区别于独立副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +copy</code></div><div class="row-main"><p>复制在线对象;普通文件预检拒绝,避免把 .dlink 当副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +move</code></div><div class="row-main"><p>移动到指定文件夹或知识库位置,语义与 copy/shortcut 消歧。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +rename</code></div><div class="row-main"><p>重命名后读取真实节点,验证最终名称和扩展名。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="high"><div class="command"><code>dws drive +delete</code></div><div class="row-main"><p>将确认过的节点移入回收站,要求 success=true 终态证据。</p><small>文件生命周期</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +recycle-restore</code></div><div class="row-main"><p>按回收项 ID 恢复,并读回恢复后的节点。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +recycle-list</code></div><div class="row-main"><p>严格分页列出回收项并稳定投影 recycleItemId。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +star-list</code></div><div class="row-main"><p>严格分页列出当前用户收藏并保留游标。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-add</code></div><div class="row-main"><p>以幂等用户偏好语义收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-remove</code></div><div class="row-main"><p>以幂等用户偏好语义取消收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +publish-get</code></div><div class="row-main"><p>只读查询互联网公开状态,不沿用错误的写风险标签。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="high"><div class="command"><code>dws drive +publish-unset</code></div><div class="row-main"><p>关闭互联网公开并读回验证外链状态。</p><small>个人与公开</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-history</code></div><div class="row-main"><p>严格分页列出普通文件历史版本。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-get</code></div><div class="row-main"><p>按正整数版本号精确匹配,零命中显式失败。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-download</code></div><div class="row-main"><p>预检版本后安全下载历史字节,要求非零产物。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="high"><div class="command"><code>dws drive +version-revert</code></div><div class="row-main"><p>验证版本存在后回滚,并读取当前节点终态。</p><small>历史版本</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
</div>
<div class="callout warn" style="margin-top:14px"><strong>未公开入口:</strong><code>+publish-set</code> 的安全契约和 set→get 读回代码已存在,但真实后端返回 <code>operation.notSupported</code>。在找到 eligible 节点并完成 set→get→unset 闭环前,不进入公开 Agent catalog。</div>
</section>
<section id="e2e">
<div class="section-head"><div><p class="section-kicker">Real-data E2E</p><h2>真实数据验证,不用空结果证明成功</h2></div><p class="section-desc">测试在隔离目录创建临时资源,覆盖读取、写入、下载、版本、收藏、回收和清理。资源 ID、账号、URL、上传凭证、绝对路径和业务正文均未进入报告。</p></div>
<div class="evidence-grid">
<article class="evidence-card"><strong>39,838 B</strong><p>真实文件上传后下载字节数;与源文件 SHA-256 完全一致。</p></article>
<article class="evidence-card"><strong>2 versions</strong><p>覆盖写入生成两个版本;精确查询、历史下载和回滚全部读回。</p></article>
<article class="evidence-card"><strong>4 / 5</strong><p>隔离夹具中搜索命中 4 项、最近列表命中 5 项,证明非空投影链路。</p></article>
<article class="evidence-card"><strong>0 remain</strong><p>测试结束后隔离根目录无残留;临时资源进入回收站并完成本地清理。</p></article>
</div>
<div class="timeline">
<div class="event"><b>创建与发现</b><span>创建两个隔离目录并读回;+list 命中真实节点,由此发现并修复 dentryId 与 32 字符 fileId 混用。</span></div>
<div class="event"><b>上传与下载</b><span>真实 OSS 上传、远端元数据读回、下载、no-clobber 二次路径、大小与 SHA-256 一致性全部通过。</span></div>
<div class="event"><b>检查与个人状态</b><span>+inspect(含 stats / publish / cover)、+stats、+cover、收藏 add→list→remove 通过。</span></div>
<div class="event"><b>版本闭环</b><span>覆盖文件产生两个版本;history/get/download/revert 通过,回滚后最新字节与原始内容一致。</span></div>
<div class="event"><b>复制、移动与命名</b><span>在线文档 copy 通过;普通文件 .dlink 被修正为预检拒绝;move 往返、rename 扩展名规范化通过。</span></div>
<div class="event"><b>删除与恢复</b><span>delete→recycle-list→recycle-restore 通过,真实回收响应字段已按后端形态修正。</span></div>
<div class="event"><b>平台负向证据</b><span>publish-set 对普通文件和在线文档均明确返回不支持,因此保持 unavailable;没有把失败改写成空对象成功。</span></div>
<div class="event"><b>清理</b><span>隔离目录进入回收站,根目录残留计数为零;本地下载产物删除。</span></div>
</div>
</section>
<section id="review">
<div class="section-head"><div><p class="section-kicker">Review prompts</p><h2>建议业务评审重点确认</h2></div><p class="section-desc">这些是需要接受的产品边界,不是被空结果遮蔽的实现问题。</p></div>
<div class="review-grid">
<article class="review-card"><span class="review-num">01</span><h3>是否接受 26 / 7 / 5 结论?</h3><p>按用户任务计覆盖、部分与缺口,不用同名命令数量代替语义保真。</p></article>
<article class="review-card"><span class="review-num">02</span><h3>普通文件 copy 是否足够清晰?</h3><p>服务端无法提供原子独立副本;快捷方式与下载后上传两条替代路径已明确。</p></article>
<article class="review-card"><span class="review-num">03</span><h3>是否拒绝不可靠目录 sync?</h3><p>缺稳定 hash、删除/重命名日志和冲突向量时,不发布可能覆盖数据的双向同步。</p></article>
<article class="review-card"><span class="review-num">04</span><h3>跨产品路由是否合理?</h3><p>评论、导入导出和协作者权限优先复用 Doc 成熟入口,不在 Drive 制造同义表面。</p></article>
<article class="review-card"><span class="review-num">05</span><h3>publish-set 是否继续 unavailable?</h3><p>建议维持,直到真实 eligible 节点完成开启、查询、关闭的可恢复闭环。</p></article>
<article class="review-card"><span class="review-num">06</span><h3>下一批后端解锁优先级?</h3><p>建议依次评估普通文件原子 copy、同步所需版本信号、安全标签和评论恢复接口。</p></article>
</div>
</section>
</main>
<footer><div class="wrap">依据:DWS 最终 Shortcut catalog / Schema、Drive 实现与测试、真实账号 E2E、lark-cli Drive registrations 与实现。范围仅含 Drive Shortcut 及必要跨产品路由;不包含原子命令总表。所有业务标识、凭证、签名 URL、用户信息和正文均已脱敏。</div></footer>
<script>
const q = document.querySelector('#q');
const domain = document.querySelector('#domain');
const risk = document.querySelector('#risk');
const rows = [...document.querySelectorAll('[data-tool]')];
const count = document.querySelector('#result-count');
function filterTools() {
const needle = q.value.trim().toLocaleLowerCase('zh-CN');
let visible = 0;
rows.forEach((row) => {
const show = (!needle || row.textContent.toLocaleLowerCase('zh-CN').includes(needle)) && (domain.value === 'all' || row.dataset.domain === domain.value) && (risk.value === 'all' || row.dataset.risk === risk.value);
row.classList.toggle('hidden-row', !show);
if (show) visible += 1;
});
count.textContent = `显示 ${visible} / ${rows.length}`;
}
[q, domain, risk].forEach((control) => control.addEventListener('input', filterTools));
</script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
# Drive Shortcut 对齐与超越 Lark CLI
## 目标与判定口径
本轮以 Lark CLI `drive` 的 38 个 shortcut 为对照,但不把“同名命令数量”当完成标准。对齐按用户任务判定:
1. `drive +...` 有更稳定的 Agent 主入口时,提供 Shortcut,并发布 Selection、Safety、Result 与 Pagination。
2. 钉钉已经在其他产品提供更成熟入口时,Skill 明确跨产品路由,不在 Drive 重复实现。
3. 只有原子能力且 Shortcut 不增加校验、编排或投影价值时,保留 Runtime Schema leaf,不制造同义别名。
4. 下层接口不存在或无法满足相同语义时,明确记录 gap;不得用空数组、空对象或只返回任务提交结果伪装完成。
成功判定统一为:进程成功 + 统一结果 `ok=true/outcome=success` + 必要业务字段 + 真实数据读回或本地字节校验。服务端显式返回空数组可以是合法业务空结果;空响应、缺少数组、数组类型错误、坏元素、`success=false`、写入缺少终态证据都必须失败。
## Lark 38 项映射
| Lark Drive shortcut | DWS 路由 | 结论与原因 |
|---|---|---|
| `+upload` | `drive +upload` | 对齐并增强:工作目录边界、OSS PUT、严格 commit、元数据读回。 |
| `+create-folder` | `drive +create-folder` | 对齐并增强:要求新 fileId 和名称读回。 |
| `+create-shortcut` | `drive +create-shortcut` | 对齐并增强:明确 shortcut≠copy,创建后读回。 |
| `+download` | `drive +download` | 对齐并增强:真实落盘、no-clobber、原子发布、非零字节。 |
| `+preview` | `drive +cover`(有限) | 不完全对齐:钉钉当前只提供封面/缩略图读取,没有等价的服务端多格式预览转换接口。 |
| `+cover` | `drive +cover` | 对齐:严格读取封面/缩略图对象。 |
| `+add-comment` | `doc +comment-create` | 用户任务对齐;评论归在线文档协作域,Drive 不复制一套。 |
| `+list-comments` | `doc +comment-list` | 用户任务对齐;Doc 已有类型、状态与分页。 |
| `+batch-query-comments` | `doc +review` / `doc +comment-list` | 超越:可聚合未解决评论与确定性正文上下文;跨文档批量仍由调用方按节点编排。 |
| `+resolve-comment` | `doc +comment-update`(有限) | 部分对齐:DWS 可更新评论,但当前下层未声明独立 resolve 状态接口。 |
| `+restore-comment` | 无等价 | gap:钉钉当前下层未暴露恢复已删除评论的等价能力。 |
| `+add-reply` | `doc +comment-reply` | 对齐。 |
| `+list-replies` | `doc +comment-list` | 用户任务对齐:评论列表返回回复上下文;无独立 Drive reply 目录。 |
| `+update-reply` | `doc +comment-update` | 对齐到评论/回复统一更新语义。 |
| `+delete-reply` | `doc +comment-delete` | 对齐到评论/回复统一删除语义,高风险确认。 |
| `+react-reply` | `doc +comment-reply`(有限) | 部分对齐:支持表情回复;不声称拥有 Lark 的独立 reaction identity。 |
| `+export` | `doc +export` | 用户任务对齐并增强:提交、轮询、安全下载一体化。 |
| `+export-download` | `doc +export` / `doc +export-get` | 超越:常规一体化,`+export-get` 仅作中断恢复。 |
| `+import` | `doc +import` | 用户任务对齐并增强:转换白名单、上传 fallback、轮询终态。 |
| `+version-history` | `drive +version-history` | 对齐并增强:严格空结果与分页。 |
| `+version-get` | `drive +version-get` | 对齐并增强:精确版本号,零命中失败。 |
| `+version-revert` | `drive +version-revert` | 对齐并增强:版本预检、高风险确认、节点读回。 |
| `+version-delete` | 无等价 | gap:钉钉当前普通文件版本接口没有删除历史版本能力。 |
| `+move` | `drive +move` | 对齐;与 copy/shortcut 明确消歧并发布确认。 |
| `+delete` | `drive +delete` | 对齐;移入回收站、高风险确认、终态证据。 |
| `+status` | `drive +inspect` | 超越:远端身份、统计、公开状态和封面按需聚合;不伪装成本地同步状态。 |
| `+push` | `drive +upload`(单文件) | 部分对齐:单文件上传可靠;没有可靠的目录 diff、冲突和远端删除传播语义,因此不提供同名批量 push。 |
| `+pull` | `drive +download`(单文件) | 部分对齐:单文件下载可靠;目录级增量拉取需稳定路径、hash 与冲突策略,当前接口不完整。 |
| `+sync` | 无等价 | gap:在缺少稳定远端内容 hash、rename/delete journal 和冲突版本向量时,双向同步会有数据覆盖风险。 |
| `+task_result` | `doc +export-get` / 导入任务恢复入口 | 用户任务对齐;DWS 按任务所属产品提供类型化恢复入口,不保留 Lark 的下划线泛化命令。 |
| `+apply-permission` | `drive permission apply` raw leaf | 下层能力存在但未提升为 Shortcut:需要真实申请上下文和权限夹具,无法在通用 E2E 中安全创建。 |
| `+member-add` | `doc +access-grant` | 用户任务对齐并增强:解析接收人、批量 ledger、首次写入前停止。 |
| `+member-list` | `drive permission list` / `doc +inspect --include-permissions` | 对齐;常规 Agent 场景优先 Doc 聚合检查。 |
| `+permission-get-setting` | `drive permission list` + `drive +publish-get` | 部分对齐:协作者与互联网公开是两个独立安全域,没有一个与 Lark setting 完全同构的钉钉接口。 |
| `+secure-label-list` | 无等价 | gap:当前 DWS/钉钉下层没有可声明的 Drive 安全标签目录接口。 |
| `+secure-label-update` | 无等价 | gap:没有安全标签写接口,不能用普通权限或公开状态替代。 |
| `+search` | `drive +search` | 对齐并增强:过滤、严格数组和分页;在线文档搜索路由 `doc +search`。 |
| `+inspect` | `drive +inspect` | 对齐并增强:必达元数据 + 可选聚合,部分失败不伪装成功。 |
## DWS 超出 Lark Drive 的可挖掘能力
- `+list`:严格目录分页,而不是把缺字段当空目录。
- `+recent`:最近访问/编辑与创建人筛选。
- `+stats`:阅读、编辑、评论、点赞、预览和下载统计。
- `+recycle-list` / `+recycle-restore`:显式回收项身份与恢复后读回。
- `+star-list` / `+star-add` / `+star-remove`:个人收藏完整闭环。
- `+publish-get` / `+publish-unset`:互联网公开独立安全域与关闭后读回;`+publish-set` 保留为 unavailable 诊断入口。
- `+version-download`:历史版本真实字节下载与本地 artifact 校验。
- `+rename`:写后读回验证。
普通钉盘文件的独立 `copy` 是额外确认出的部分 gap:钉钉当前 `doc/copy_document` 对该对象会生成 `.dlink`,不是字节独立副本。`drive +copy` 因此只接受在线对象;普通文件需要快捷入口时用 `+create-shortcut`,需要独立副本时用 `+download` 后 `+upload`。这不是完整的服务端原子 copy,对大文件也不能宣称完全等价。
互联网公开开启也是账号/对象能力 gap:真实普通文件与在线文档夹具都由服务端返回 `operation.notSupported`。`+publish-get` 与关闭语义可验证,但 `+publish-set` 在找到 eligible 节点完成 set→get→unset 闭环前保持 `unavailable` 且不进入公开 Agent catalog。
## 端到端门禁
每个公开 Drive shortcut 必须至少覆盖:
- Cobra 参数、静态确认、Shortcut Execute、MCP 调度和最终输出;
- 明确业务空集合、空响应、缺字段、错误类型、坏元素、`success=false`;
- 写入的 ID/终态证据与读回不一致;
- 下载的本地路径边界、no-clobber、真实字节数;
- 真实账号数据:读命令必须命中已知非空夹具或明确验证合法空集合;写命令必须创建隔离资源、读回、必要时下载比对字节并清理。
发布前运行 `make build`、完整 Go 测试、Schema 生成/漂移/策略检查,并保存不含账号业务内容的结构化 E2E 汇总。
+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,134 @@
# 独立 `meta.pagination` Schema 方案
## 1. 目标结构
业务结果与分页控制信息分层:
```json
{
"ok": true,
"outcome": "success",
"data": {
"items": [{"id": "a"}]
},
"meta": {
"pagination": {
"endpoint_exhausted": false,
"next_token": "cursor-2"
}
}
}
```
对应 compact/full leaf Schema:
```json
{
"result": {
"outcomes": ["success", "failure"],
"data_schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"description": "当前页业务记录",
"items": {"type": "object"}
}
}
}
},
"pagination": {
"kind": "cursor",
"cursor_parameter": "cursor",
"meta_path": "meta.pagination",
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
"next_token_path": "meta.pagination.next_token"
}
}
```
`result` 只描述 `data`;`pagination` 是与 `result` 同级的命令能力声明。
## 2. 分页状态
| 状态 | `endpoint_exhausted` | `next_token` | Agent 行为 |
|---|---:|---|---|
| 可续跑 | `false` | 必须非空 | 将 token 传给 `--<cursor_parameter>` |
| 已耗尽 | `true` | 必须省略 | 停止翻页 |
`endpoint_exhausted:true` 只表示观察到 Endpoint 分页耗尽,不表示搜索索引
健康、数据全量覆盖或业务对象不存在。
## 3. 映射规则
产品 mapper 可以读取服务端原始 `hasMore/nextCursor`、`has_more/page_token`
等字段,但统一 CLI 输出只公布 `meta.pagination`:
- 服务端表示还有下一页且 cursor 非空 → `endpoint_exhausted:false` + token。
- 服务端表示没有下一页 → `endpoint_exhausted:true`,不带 token。
- 表示还有下一页但 cursor 缺失、类型错误或证据冲突 → typed
`pagination_inconsistent`,禁止伪装终页。
- mapper 使用同一份上游响应构造 `data` 与 `meta`,不得重新请求。
原始分页控制字段不进入新的 `result.data_schema`。未迁移命令保持 legacy;
已迁移命令按命令独立切换和回滚,不通过 Agent 参数选择协议。
## 4. Schema 规则
- `kind` 当前只允许 `cursor`。
- `cursor_parameter` 是真实 canonical CLI flag 名,不带 `--`,并必须存在于
同一 leaf 的 `parameters`。
- 三个 meta path 由框架固定生成,产品不能覆盖。
- compact/full leaf 同时包含相同的 `result` 和 `pagination`。
- product/group 导航摘要不复制分页对象;Agent 需要时查询具体 compact leaf。
- 没有 `pagination` 表示该命令尚未发布经评审的分页能力,Agent 不得猜测。
## 5. 渐进接入
1. **legacy_only**:保持原输出,不公布分页声明。
2. **dual_validate**:业务执行一次;影子构造并校验 `meta.pagination`,外部
legacy 字节不变。
3. **unified_active**:输出独立 `meta.pagination`,Schema 公布同级
`pagination` 声明。
4. **unified_stable**:Skill、示例和 Agent 审计均只读取 meta 分页。
不增加 `contract_version`、`--output-contract` 或分页协议别名。
## 6. 验收
每个分页命令至少验证:
1. 有下一页时 `endpoint_exhausted:false` 且 token 非空。
2. 终页和空终页为 `endpoint_exhausted:true` 且无 token。
3. 分页矛盾产生 typed failure,不 panic、不静默停止。
4. `cursor_parameter` 在 Help/Schema 中真实存在。
5. compact/full 的 `result`、`pagination` 分别 JSON 等价。
6. `data_schema` 不包含分页控制字段。
7. 运行时 `data` 不包含迁移后的分页控制字段。
8. dual validate 与 active 都只消费一次上游响应。
9. Agent 逐命令扫描结果进入评测台账;不提交生成 Schema JSON fixture。
### DevApp 首批落地
以下 8 个终结命令已发布独立 `pagination` Schema;运行时统一输出只在
`meta.pagination` 返回分页控制信息:
- `dev app list`
- `dev app permission list`
- `dev app event list`
- `dev app version list`
- `devapp +list`
- `devapp +permission-list`
- `devapp +event-list`
- `devapp +version-list`
两套既有命令前缀继续保留。原子命令的业务记录字段为 `data.items`;Shortcut
保留既有业务投影(例如 `data.apps`、`data.permissions`、`data.events`、
`data.versions` 以及 `data.count`),但两套入口都不再在业务数据中公布
`hasMore/nextCursor`。
## 7. 对齐依据
GWS 用请求参数和 response schema 描述分页事实;Lark 在统一输出层维护分页
元数据。DWS 采用更明确的分层:业务 `data` 保真承载记录,框架 `meta` 承载
续跑状态,Schema 用独立能力把 token 与下一次 CLI 参数连接起来。
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -17,7 +17,8 @@
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的
release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
@@ -94,7 +95,8 @@ main 上的候选代码 + beta CHANGELOG
dws-release v1.2.3-beta.1
```
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的
fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
```bash
dws-release v1.2.3-beta.1
@@ -132,6 +134,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
### Release fragments
普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
+219
View File
@@ -0,0 +1,219 @@
# RFC:DWS 预制 Skill 安装、升级与模式迁移
| 字段 | 内容 |
|---|---|
| 状态 | Accepted / as implemented |
| 生效范围 | DWS CLI、升级器、npm 与平台安装脚本 |
| 事实源 | 本 RFC 与当前代码;两者冲突时以代码和测试为准 |
| 关联合同 | [Skill 内容框架](skill-content-framework.md)、[Mono↔Multi 内容质检](skill-mono-multi-qa.md) |
## 1. 背景
DWS 同时通过 CLI、升级器、npm、Shell 和 PowerShell 分发预制 Skill。multi
成为默认布局后,所有入口必须对安装集合、模式互斥、失败退出、缓存发布和目录
所有权保持一致。此前分散的调研、迁移计划、阶段性 roadmap 和 rollout 文档容易
相互冲突;本 RFC 将最终行为收敛为一个长期合同。
## 2. 目标与非目标
### 2.1 目标
- 新装与升级默认使用 multi 布局,mono 在兼容期内保留显式 opt-in。
- 每次升级使用当前版本的官方清单全量覆盖预制 Skill。
- 删除或替换任何目录前先创建可恢复备份,备份失败不修改该 Agent 目标。
- 只清理能够证明由 DWS 管理的目录,不通过名称前缀推断所有权。
- 所有安装入口对部分失败返回非零状态,不误报整体成功。
- 安装预览、确认和实际执行使用同一份计划。
### 2.2 非目标
- 不建设独立的 `dws skill mode status|set|rollback` 产品面。
- 不持久化用户对预制 Skill 的本地删除或排除意图。
- 不提供跨所有 Agent 目标的事务式回滚。
- 不把市场 Skill 纳入预制 Skill 的升级和清理范围。
## 3. 业内调研
对主流 CLI 与 Agent Skill 分发方式的公开实现进行归纳后,可以得到以下共性:
| 观察 | 对 DWS 的启示 |
|---|---|
| 多个产品能力通常以同级 Skill 目录安装,由 Agent 按目录发现 | multi 使用平铺的产品 Skill,并保留一个共享 Skill 承载公共协议 |
| CLI 本体安装和 Agent Skill 安装是两个生命周期 | DWS 可以在 CLI 安装、setup 和 upgrade 中触发 Skill 同步,但二者的失败与状态必须分别报告 |
| 生态安装器通常天然采用 multi,不提供 mono/multi 状态机 | DWS 的模式切换保持为重新执行 setup,不新增长期驻留的 mode lifecycle |
| 市场 Skill 与 CLI 预制 Skill 可能落在同一 Agent 根目录 | 必须使用统一所有权元数据识别受管目录,名称前缀不能作为删除依据 |
| 多 Skill 更新常以新清单刷新官方集合 | DWS 使用当前 bundle 官方清单全量覆盖,新增 Skill 自动加入,本地删除不视为持久化排除 |
| 制品可能需要同时服务无运行时依赖、离线和多镜像环境 | DWS 保留 embed、zip 和平台安装脚本,不把单一生态包管理器设为唯一入口 |
| 中断的复制和原地覆盖容易破坏最后一个可用版本 | 缓存与 Go upgrade 的 Agent 目标采用 staging publish;发布失败自动恢复该目标的完整旧集合 |
| Agent 通常以 `SKILL.md` 为入口,其他文件按引用或工具规则按需读取 | 安装元数据使用不被内容引用的隐藏文件,并保证其内容不包含 Agent 指令 |
本节只保留可复用的工程结论,不记录具体产品、仓库、版本或逐项能力对照,也不构成
DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约束和本 RFC 的行为合同为准。
## 4. 布局合同
| 模式 | Agent 目录布局 | 选择方式 |
|---|---|---|
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
不可恢复的删除。
## 5. 官方集合与升级策略
当前版本 bundle 中的 multi 目录清单是升级集合的唯一权威来源。普通 upgrade 和
`--force` 都安装并覆盖该版本的全部官方预制 Skill:
- 本地删除的预制 Skill 会在下一次升级恢复;
- setup 时通过 `--exclude` 暂时排除的 Skill 会在下一次升级恢复;
- 新版本新增的官方 Skill 会自动安装;
- 用户对预制 Skill 的本地修改会被官方版本覆盖;
- `dingtalk-shared` 始终随官方集合安装。
`~/.dws/skills-state.json`(设置 `DWS_CONFIG_DIR` 时位于该目录)不参与安装集合
求解,也不保存排除策略。它既记录结果快照,也集中记录 multi Skill 的所有权和
provenance,供安全清理、诊断与后续迁移使用。
## 6. 目录所有权
每次 multi setup 或 upgrade 全部成功后,DWS 在统一的
`~/.dws/skills-state.json` 中写入:
```json
{
"version": "v0.2.14",
"official_skills": ["dingtalk-aitable"],
"updated_skills": ["dingtalk-aitable"],
"managed_skills": [
{
"name": "dingtalk-aitable",
"version": "v0.2.14",
"source": "dws-upgrade",
"digest": "sha256:<64 个十六进制字符>",
"digest_scope": "skill-directory-v1"
}
],
"updated_at": "2026-08-11T12:34:56Z"
}
```
每条 `managed_skills` 记录代表一个由 DWS 管理的官方 Skill。`version` 记录安装该
副本的 DWS/发布包版本,`source` 记录安装入口,`digest` 是对 bundle 中 Skill 目录
全部普通文件按相对路径排序后计算的内容摘要。摘要用于诊断和来源追踪,不作为后续
升级的完整性门禁;用户修改 Skill 内容后,DWS 仍保有明确管理权并能在下一次升级时
覆盖恢复。
清理 stale Skill 或切换到 mono 时,只接受以下所有权证据:
1. Skill 名称存在于统一状态的 `managed_skills` 中;
2. 统一状态上线前曾发布过的官方 Skill 精确名称集合。
历史集合是冻结的迁移清单,包含 `dws-shared` 以及已退役、折叠或仍在发布的旧官方
目录名。仅有 `dingtalk-*` 前缀不构成所有权证据。因此,市场或用户创建的
`dingtalk-custom` 等非官方精确名称目录不会被迁走。
### 6.1 对 Agent 的影响
Skill 目录内不再放置 DWS 所有权文件,也不增加非通用 frontmatter 字段。支持的
Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agent Skill 目录之外,
不会成为提示词上下文或影响 Agent 行为。
## 7. Setup:Plan → Confirm → Execute
`dws skill setup` 分为三个阶段:
1. **Plan**:只读计算目标、安装集合以及所有待备份路径;
2. **Confirm**:`--dry-run` 和交互确认渲染同一份计划;
3. **Execute**:确认后严格执行计划中的备份和安装。
安全要求:
- 非交互环境未传 `--yes` 时拒绝执行;
- 用户拒绝确认时必须零文件写入;
- 备份失败时跳过整个 Agent 目标,不开始铺设相反布局;
- 同一目标先完成所有必要备份,再复制新集合;
- multi Skill 必须在同级 staging 中完成复制,再原子发布到正式目录;
- 任意 `skipped > 0` 都返回非零退出码,并且不写入完整成功快照;
- 一个 Agent 目标失败不阻止其他目标尝试,但最终结果仍为失败。
## 8. Upgrade 与恢复语义
升级器对每个 Agent 目标执行:
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
3. staging 全部成功后,才将旧集合移入备份目录;
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
“不提供跨所有 Agent 目标的事务式回滚”非目标保持一致。
## 9. 备份合同
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
- 主要操作:同一文件系统内使用 rename 移动;
- 失败语义:备份失败时原目录保持不变,目标安装失败;
- 可见性:计划和执行日志显示原路径与备份路径;
- 保留策略:自动修剪,仅保留最近 5 批。
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
`dws skill setup --mode mono`。
## 10. 缓存与制品
发布制品和二进制内嵌内容同时携带 mono 与 multi 源树。`~/.dws/skills/` 只是
setup 在未显式指定 `--source` 时的本地回退缓存。
缓存刷新必须采用同级 staging + publish:
1. 在 staging 中完整复制并验证新树;
2. 发布前保留旧缓存;
3. 通过 rename 发布新缓存;
4. 复制或发布失败时保留或恢复旧缓存;
5. 空、缺失或损坏的 bundle 不能擦除有效缓存。
## 11. 安装入口一致性
以下入口都遵守本 RFC:
| 入口 | 默认模式 | 失败合同 |
|---|---|---|
| `dws skill setup` | multi | 部分失败返回非零;不写完整成功状态 |
| `dws upgrade` | bundle 含 multi 时安装 multi | 目标失败返回失败;下次全量重试 |
| `scripts/install.sh` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
## 12. 验收与回归门禁
合入和后续修改至少覆盖:
- mono → multi、multi → mono 互斥切换;
- 状态上线前的官方 multi 目录切换 mono 时能够被精确迁移;
- 未登记的同前缀市场/用户 Skill 在刷新和切换后仍存在;
- 统一状态中登记的过期官方 Skill 被备份并移除;
- 备份、复制、统一状态写入、缓存 publish 故障注入;
- 非交互确认拒绝与显式 `--yes`;
- 部分失败返回非零且不写错误状态快照;
- 复制失败不留下 Agent 可见的残缺官方目录;
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
- Windows、macOS、Linux 的路径和覆盖率门禁;
- npm、Shell、PowerShell 与包管理器安装冒烟。
## 13. 后续演进
- 收敛各安装入口中的 Agent home 清单,减少跨语言复制;
- 如确有运维需求,可单独设计备份查看和显式恢复命令;
- mono 的物理删除必须作为独立变更,在 multi 内容、安装入口和迁移回归稳定后推进;
- `managed_skills` 字段若演进,必须同步更新所有安装入口和跨平台回归。
-188
View File
@@ -1,188 +0,0 @@
# lark-cli Shortcut 深度对齐矩阵
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
## 2026-07-13 最新源码复核
对比基线:
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
- lark-cli:`main@e96c4fa5`
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
### 当前命令面快照
| 指标 | 数量 | 说明 |
|---|---:|---|
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
| 产品映射 | DWS | lark | 同名 |
|---|---:|---:|---:|
| aitable ↔ base | 82 | 87 | 31 |
| calendar ↔ calendar | 23 | 10 | 3 |
| chat ↔ im | 89 | 21 | 2 |
| contact ↔ contact | 16 | 2 | 1 |
| devapp ↔ apps | 30 | 63 | 3 |
| doc ↔ doc | 19 | 14 | 1 |
| drive ↔ drive | 9 | 26 | 3 |
| mail ↔ mail | 10 | 21 | 0 |
| minutes ↔ minutes | 13 | 9 | 1 |
| sheet ↔ sheets | 2 | 42 | 0 |
| todo ↔ task | 13 | 17 | 2 |
| wiki ↔ wiki | 7 | 12 | 3 |
### 最新优先差距
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
### 不建议机械追平
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
## 汇总(361 条 lark shortcut)
| dws_status | 数量 | 含义 |
|---|:---:|---|
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
### im → chat(7)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
### task → todo(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
### calendar → calendar(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
### doc (docs) → doc(2)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
### drive → drive(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
### mail → mail(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
### wiki → wiki(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
### minutes → minutes(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
### base → aitable(10)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
### sheets → sheet(14)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
### apps → devapp(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
- **task**: +complete +assign +get-my-tasks +get-related-tasks
- **contact**: +search-user
- **calendar**: +agenda +create +update +freebusy +suggestion
- **doc (docs)**: +history-revert
- **drive**: +upload +search +inspect
- **mail**: +triage
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
+501 -42
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-06T21:08:26.697858",
"count": 357,
"generated_at": "2026-08-12T00:10:44.511794",
"count": 399,
"results": [
{
"suite": "semantic",
@@ -2162,7 +2162,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -2650,8 +2650,8 @@
"command": "+history-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。",
"availability": "available"
},
{
@@ -2660,8 +2660,8 @@
"command": "+history-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。",
"availability": "available"
},
{
@@ -2670,8 +2670,8 @@
"command": "+history-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。",
"availability": "available"
},
{
@@ -2841,7 +2841,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
"semantic_delta": "按名称或关键词检索模板并返回可消歧候选和 templateId。",
"availability": "available"
},
{
@@ -2860,8 +2860,8 @@
"command": "+version-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
"disposition": "semantic_adapter",
"semantic_delta": "版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。",
"availability": "available"
},
{
@@ -2870,8 +2870,8 @@
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
"disposition": "primary_smart",
"semantic_delta": "版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。",
"availability": "available"
},
{
@@ -2880,58 +2880,289 @@
"command": "+version-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
"disposition": "semantic_adapter",
"semantic_delta": "版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "drive",
"command": "+copy",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "复制前预检在线对象类型;普通钉盘文件因下层只会生成 .dlink 而显式拒绝,避免把快捷方式伪装成独立副本。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+cover",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取节点封面或缩略图地址;明确不声称服务端多格式预览转换。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+create-folder",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建普通钉盘文件夹后要求 fileId,并读回名称验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+create-shortcut",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "明确 shortcut 与 copy 语义差异,创建后读取新节点验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "将已确认节点移入回收站,要求高风险确认和 success=true 终态证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "不再只返回临时链接;使用受控相对路径、no-clobber、原子发布并验证非零本地字节。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+find-file",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文件定位入口;新的 Agent 文件搜索统一使用 +search。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+info",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史元数据入口;新的 Agent 场景统一使用可扩展的 +inspect。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "drive",
"command": "+inspect",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "以文件元数据为必达结果,按需聚合统计、公开状态和封面;可选读取失败显式报告 partial_success。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空目录与缺失/畸形响应,稳定投影节点并完整保留分页游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+move",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "移动后原位置不保留,统一 folder/workspace 目标语义并发布静态确认。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+publish-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "只读查询互联网公开状态和权限,不沿用原子命令错误的写风险标签。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+publish-unset",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "高风险确认后关闭互联网公开,并读回状态验证外链已失效。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+recent",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取最近访问/编辑列表并保留 nextCursor/hasMore,防止嵌套响应被投影为空。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+recycle-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出回收项并稳定投影 recycleItemId,显式空数组才是空回收站。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+recycle-restore",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "只要求列表可获得的 recycleItemId;恢复响应必须给出节点 ID,随后读回验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+rename",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "重命名后读取真实节点元数据验证最终名称。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一关键词、文件类型、扩展名、创建者、时间和分页过滤,并拒绝缺失结果数组。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+search-docs",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史跨域文档搜索入口;新的在线文档搜索统一使用 doc +search。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-add",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "以幂等用户偏好语义收藏指定节点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出当前用户收藏并保留游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-remove",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "以幂等用户偏好语义取消收藏指定节点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+stats",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取节点访问、编辑、评论、点赞、预览和下载统计的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "组合工作目录边界校验、上传凭证、OSS PUT、严格提交响应和远端元数据读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "预检版本存在后安全下载历史字节,受控相对路径原子发布且要求非零产物。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按正整数版本号精确匹配元数据;零命中显式失败。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-history",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出普通文件版本,区分合法空历史与响应契约错误。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再经高风险确认回滚并读取当前节点状态。",
"availability": "available"
},
{
"suite": "read",
@@ -3004,46 +3235,274 @@
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+action-items",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+apply-permission",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+detail",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+export-pack",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+latest",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+list-all",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of all accessible Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-mine",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of current-user Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-shared",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of shared Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+mindmap",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+prepare-asr",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-pause",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-pause facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-resume",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-resume facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-start",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-create facade with explicit confirmation and stable session parameter.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+record-stop",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-stop facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-wrap-up",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+replace-batch",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+search",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; replaces the deprecated +minutes-search discovery route.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-insights",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-replace",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+summary",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+transcript",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+unshare",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Owns local file validation, create-PUT-complete polling, pre-complete transfer cancellation compensation, unknown-completion recovery and final task read-back verification.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload-and-analyze",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.",
"availability": "available"
},
{
"suite": "read",
+3 -13
View File
@@ -2,7 +2,8 @@
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 两棵内容树
@@ -87,18 +88,7 @@ skills/mono/
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|---|---|---|
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
## 7. 变更流程
## 6. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
+2 -4
View File
@@ -3,6 +3,8 @@
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 质检矩阵
@@ -70,7 +72,3 @@ paired_files:
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
## 4. 与悟空
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
-272
View File
@@ -1,272 +0,0 @@
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
>
> 对照仓:
>
> | 仓 | 路径 | 基线 |
> |---|---|---|
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
---
## 0. TL;DR
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
### 0.1 IN SCOPE
| 类别 | 包含 |
|---|---|
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
### 0.2 OUT OF SCOPE
| 类别 | 去向 |
|---|---|
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
| 把质检做成「改安装默认值」的后门 | 拒绝 |
---
## 1. 内容现状盘点
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
│ └── best_practices/…
└── scripts/
```
### 1.2 DWS `skills/multi`(内容主体)
```text
skills/multi/
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
└── dingtalk-*/ # 19 产品 + 各 references、scripts
```
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
### 1.4 Zip 内容布局合同
| Zip 路径 | 内容含义 |
|---|---|
| `<root>/` | mono 副本(兼容) |
| `<root>/mono/` | 显式 mono 内容源 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
### 1.5 现有 DWS skill 内容质检资产(复用清单)
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|---|---|---|
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
### 1.6 悟空侧类比质检
| 悟空 | 说明 | 本分支 |
|---|---|---|
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
---
## 2. Diff(内容组织 + 质检视角)
### 2.1 已同构
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
### 2.2 分叉与已知内容风险(质检要盯的)
| 风险 ID | 现象(线索) | 质检类型 |
|---|---|---|
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
### 2.3 Reject
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
---
## 3. Goals / Non-goals
### 3.1 Goals
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
### 3.2 Non-goals
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
---
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
### Phase 0 — 方案冻结(本文)
| | |
|---|---|
| **范围** | 本文件;§7(含质检轨)勾选 |
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
| | |
|---|---|
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
| **触达** | `docs/skill-content-framework.md` |
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
| | |
|---|---|
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
| | |
|---|---|
| **范围** | G1–G4 自动门禁 |
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
| | |
|---|---|
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
### 延期登记(非本分支)
| 主题 | 载体 |
|---|---|
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
| skillhome / 安装面 bootstrap | 行为分支 |
---
## 5. Port / Adapt / Reject
| 项 | 决策 | 说明 |
|---|---|---|
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
| 行为 cherry-pick | **Defer** | 另分支 |
---
## 6. 与 `402429ac` / `d5c8982c`
| | |
|---|---|
| 本分支 cherry-pick? | **否** |
| 质检是否替代行为翻转? | **否** |
| 行为分支 | 另开;可与内容/质检并行 |
---
## 7. 批准清单(请重新勾选)
**范围**
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
- [x] 取消产品与悟空客户端链路仍拒绝
**内容框架 Phase**
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
**质检轨 Phase**
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
**可选**
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
**Follow-up 知悉**
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
---
## 8. 下一步
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
安装默认 multi 等行为仍走 **另一分支**。
---
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
+108
View File
@@ -0,0 +1,108 @@
# DWS 统一命令框架设计概要
> 状态:Framework core 已实现,dingtalk-dev/devapp 首批命令渐进接入中。本文定义框架能力、集成边界和首批 pilot 的发布纪律;其余产品命令迁移、Skill 更新和真实服务复验继续由后续 PR 独立完成。
## 1. 产品裁决
1. 不公开 `--output-contract`,也不增加任何等价别名。
2. Agent 继续只使用既有 `--format json`。
3. 每条 terminal command 在一个 release 中只有一个 active wire contract:已迁移命令直接使用统一结果,未迁移命令保持 legacy。
4. contract 不由用户参数、环境变量、会话能力协商或 Agent 选择。
5. 回滚是命令声明与发布行为,不改变消费者 argv。
6. 本 PR 只迁移完成命令级兼容审计的 dingtalk-dev/devapp pilot;其他命令路径、参数和输出保持不变。
## 2. 渐进迁移
内部状态机:
```text
legacy_only -> dual_validate -> unified_active -> unified_stable -> unified_only
```
- `legacy_only`:只构造、输出 legacy。
- `dual_validate`:业务只执行一次;外部仍逐字输出 legacy;同一内存结果 shadow-build 统一结果并严格校验。
- `unified_active`:`--format json` 直接返回统一结果信封,可按发布声明回退。
- `unified_stable`:完成真实 Agent 消费观察和兼容窗口。
- `unified_only`:清理仅服务 legacy 的产品 renderer。
状态是每条 terminal command 的内部发布元数据。Help、Skill、Agent Schema 不展示迁移状态,也不让消费者选择协议。
## 3. 统一结果
统一命令框架表达四类结果:
```text
success 请求完成且命令认为操作已完成
pending 请求被受理,但异步操作尚未终结
partial_failure 批量操作有成功项,也有失败或未知项
failure 请求或操作失败
```
JSON 基本形态:
```json
{
"ok": true,
"outcome": "success",
"data": {}
}
```
硬不变量:
```text
ok == (outcome in {success, pending})
process rc == 0 <=> ok == true
top-level error present <=> outcome == failure
one invocation emits exactly one primary result
```
框架负责 L1 request outcome 和 L2 operation outcome 的统一表达;L3 verification 必须由产品命令基于业务事实实现,框架不得自动推断 `changed/verified`。
## 4. 输出与错误纪律
- 统一 JSON primary result 写 stdout;stderr 只写诊断。普通命令不把
NDJSON 作为通用结果契约;持续事件流若需要逐事件输出,由 event 命令
自己声明专用流协议。
- 分页统一输出到信封 `meta.pagination`,并在命令 Schema 中作为与 `result`
同级的 `pagination` 能力声明;`result.data_schema` 只描述业务 data,不再
混入分页控制字段。
- 日志不得污染 stdout。
- `ok`、`retryable`、`dry_run` 等必须是 JSON boolean。
- 失败由框架根据 typed error 映射退出码;产品代码不能自报任意 rc。
- `partial_failure` 保留 `succeeded[]/failed[]/unknown[]`,使用非零 rc 7。
- `pending` 必须提供 operation id、state 和可执行的 `next_command`。
- `endpoint_exhausted` 只表示观察到当前 endpoint 分页耗尽;false 必须带 `next_token`,不得扩大成索引健康或业务数据完整。
- dry-run 是已经完成的无副作用预览,表达为 `success + dry_run:true`,不是 `pending`。
## 5. 重试与超时边界
- 框架只统一表达 `retryable`、`retry_after_seconds` 和 `execution_started`,不自动决定业务操作能否安全重放。
- 写调用的模糊失败、HTTP timeout 和异步等待预算属于 transport/产品集成范围,不在本 PR 改动。
- 产品迁移必须证明其重试声明与幂等性、安全等级一致。
## 6. 集成范围
- 产品命令通过 `corecmd.ResultInvoke` 构造 `CommandResult`,由 root 单一出口渲染。
- 首批 dingtalk-dev/devapp 命令用于验证原子命令与 shortcut 的接入缝;未进入 pilot 的 shortcut、长连接、批量写和异步任务各自需要独立集成 PR。框架 core 不替产品推断 success、pending、partial 或分页事实。
- 每条 terminal command 独立 rollout;不能整域一次切换,也不能通过 Agent 参数选择协议。
- 已有命令在进入 `unified_active` 前必须保留 legacy byte golden,并完成真实 Agent 语义扫描。
## 7. 对齐原则
- 对齐 Lark CLI:统一 envelope/emitter、typed error、partial、pending、分页窄语义和强类型结果。
- 对齐 GWS:机器结果稳定结构化、日志与数据分流、消费者不协商协议版本。
- DWS 保留差异:声明式 Agent Schema、安全门禁、静态命令与 shortcut 共存,以及四 outcome 模型。
## 8. 发布门禁
命令晋级 `unified_active` 前至少满足:
1. success/failure/dry-run golden;批量或异步命令另有 partial/pending golden。
2. 业务请求 exactly once;dual validation 不得二次调用服务端。
3. legacy 命令 stdout/stderr/rc 字节级回归不变。
4. Help、Schema 和全仓示例不存在协议选择参数。
5. `--format json` 输出单个合法统一结果文档,stdout 无日志污染。
6. typed error、进程 rc 与信封 `error.exit_code` 一致。
7. 安全声明、确认门禁与 dry-run 运行时行为同源。
8. Agent 语义扫描记录命令级迁移证据;发布回滚无需修改 Agent argv。
+1 -1
View File
@@ -14,7 +14,7 @@ require (
github.com/itchyny/gojq v0.12.18
github.com/mattn/go-isatty v0.0.20
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
+2 -2
View File
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 h1:5WwR5TV6A12taXMH7SggT8yCMMJMF9jWE7Wj+4AuHck=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -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)
}
}
}
})
}
}
+17 -9
View File
@@ -33,6 +33,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -2033,6 +2034,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatal(err)
}
state, readable, err := skillstate.Read(home)
if err != nil || !readable || len(state.OfficialSkills) != 3 || len(state.UpdatedSkills) != 2 {
t.Fatalf("setup state = %#v, readable=%v, err=%v", state, readable, err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
t.Fatalf("multi dry run = %q, %v", output, err)
}
@@ -2048,8 +2053,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
t.Fatalf("invalid setup %#v succeeded", args)
}
}
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("default mono setup: %v", err)
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("mono setup: %v", err)
}
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
t.Fatalf("default mode should be multi: %q, %v", output, err)
}
}
@@ -2082,7 +2090,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing multi source succeeded")
}
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
t.Fatalf("default setup mode = %q, %v", mode, err)
}
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
@@ -2117,7 +2125,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
for _, tc := range []struct{ path, mode string }{{"", skillSetupModeMono}, {mono, skillSetupModeMono}, {filepath.Dir(multi), skillSetupModeMulti}, {root, "bad"}} {
_ = isSkillSourceRoot(tc.path, tc.mode)
}
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
for _, tc := range []struct{ target, mode string }{{"agents", skillSetupModeMono}, {"agents", skillSetupModeMulti}, {"all", skillSetupModeMono}, {"missing", skillSetupModeMono}} {
_, _ = resolveSkillSetupTargets(tc.target, tc.mode)
}
@@ -2125,8 +2133,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
_ = agentHomeForMode("base", skillSetupModeMulti)
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
_ = mutualExclusionVictims(root, mode)
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
_, _ = mutualExclusionVictims(root, mode)
}
if isCharDevice(nil) || isInteractiveTerminal() {
t.Fatal("test process unexpectedly interactive")
@@ -2134,17 +2142,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
multiDest := filepath.Join(t.TempDir(), "agent")
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
badParent := filepath.Join(t.TempDir(), "file")
_ = os.WriteFile(badParent, []byte("x"), 0o600)
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
t.Fatal("copy missing directory succeeded")
}
+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)
}
}
}
+525
View File
@@ -0,0 +1,525 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestFrameworkErrorProjectionPreservesRecoveryMetadata(t *testing.T) {
next := time.Date(2026, 8, 10, 1, 2, 3, 0, time.FixedZone("test", 8*60*60))
retry := int64(4)
started := true
leaf := &helpers.CLIError{Code: "UPSTREAM_CODE", Suggestion: "retry with id", Operation: "create"}
call := &transport.CallError{Stage: transport.CallStage("decode"), HTTPStatus: 503, RPCCode: 91, TraceID: "call-trace", Cause: leaf}
typed := &apperrors.Error{
Category: apperrors.CategoryAPI, Message: "failed", Reason: "upstream_failed", Hint: "use status",
Actions: []string{"dws status"}, Retryable: true, RetryableSet: true, RetryAfterSeconds: &retry,
RPCCode: 92, RPCData: json.RawMessage(`{"task":"x"}`), Operation: "publish", ServerKey: "server",
Origin: "gateway", FailureStage: "response", ExecutionStarted: &started, NextRetryAt: &next,
AvailableFlags: []string{"--id"}, Snapshot: "/tmp/snapshot", Details: map[string]any{"id": "x"},
ServerDiag: apperrors.ServerDiagnostics{TraceID: "typed-trace", ServerErrorCode: "SERVER_CODE", TechnicalDetail: "detail", FriendlyHint: "friendly", ActionURL: "https://example.test"},
Cause: call,
}
info := errorInfoFromExecutionError(typed)
if info.Type != "api" || info.Subtype != "upstream_failed" || info.HTTPStatus != 503 || info.RPCCode != 92 || info.RequestID != "call-trace" || info.TraceID != "typed-trace" {
t.Fatalf("projection=%+v", info)
}
if info.UpstreamCode != "SERVER_CODE" || info.Operation != "publish" || info.NextRetryAt == "" || info.Cause == "" || info.RPCData == nil || info.ExecutionStarted == nil || !*info.ExecutionStarted {
t.Fatalf("recovery metadata=%+v", info)
}
innerOperation := &helpers.CLIError{Operation: "create"}
outerWithoutOperation := &apperrors.Error{
Category: apperrors.CategoryAPI,
Message: "failed",
Cause: innerOperation,
}
preserved := errorInfoFromExecutionError(outerWithoutOperation)
if preserved.Operation != "create" {
t.Fatalf("operation=%q, want inner operation preserved", preserved.Operation)
}
requestCall := &transport.CallError{Stage: transport.CallStage("request"), HTTPStatus: 429, RequestID: "request-id"}
requestInfo := errorInfoFromExecutionError(requestCall)
if requestInfo.RequestID != "request-id" || requestInfo.HTTPStatus != 429 {
t.Fatalf("request projection=%+v", requestInfo)
}
partial := errorInfoFromExecutionError(&apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"})
if partial.Type != "internal" {
t.Fatalf("partial error type=%s", partial.Type)
}
for code, want := range map[int]string{1: "api", 2: "auth", 3: "validation", 4: "permission", 6: "discovery", 99: "internal"} {
if got := errorTypeForExitCode(code); got != want {
t.Fatalf("errorTypeForExitCode(%d)=%q", code, got)
}
}
}
func TestFrameworkExecutePreparseUnifiedErrorAndEmissionFallback(t *testing.T) {
for _, failWriter := range []bool{false, true} {
t.Run(map[bool]string{false: "unified", true: "fallback"}[failWriter], func(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "leaf"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("bad preparse") })
var stdout bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.SetContext(ctx)
leaf := &cobra.Command{Use: "leaf"}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
if failWriter {
leaf.SetOut(frameworkFailWriter{})
} else {
leaf.SetOut(&stdout)
}
leaf.SetErr(&bytes.Buffer{})
root.AddCommand(leaf)
return root
})
if code := Execute(); code != 3 {
t.Fatalf("Execute code=%d", code)
}
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%q", stdout.String())
}
})
}
}
func TestFrameworkPublicRootRequiresResultFromActiveCommand(t *testing.T) {
root := NewRootCommand(context.Background())
leaf := &cobra.Command{Use: "active-no-result", RunE: func(*cobra.Command, []string) error { return nil }}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"active-no-result"})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
t.Fatalf("ExecuteC error=%v", err)
}
}
func TestFrameworkAbortOutputSinkRemoveFailure(t *testing.T) {
originalRemove := rootRemoveFile
t.Cleanup(func() { rootRemoveFile = originalRemove })
file, err := os.CreateTemp(t.TempDir(), "abort-*")
if err != nil {
t.Fatal(err)
}
rootRemoveFile = func(string) error { return errors.New("remove failed") }
cmd := &cobra.Command{Use: "abort"}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file, tempPath: file.Name()}))
if err := abortOutputSink(cmd); err == nil || !strings.Contains(err.Error(), "remove temporary") {
t.Fatalf("abort error=%v", err)
}
}
func TestFrameworkOutputSinkHookWrappingAndCleanupEdges(t *testing.T) {
installOutputSinkRunBoundary(nil)
plain := &cobra.Command{Use: "plain"}
plain.SetContext(context.Background())
installOutputSinkRunBoundary(plain)
// newBoundaryChild builds a leaf whose --output lives on the root's
// persistent flag set, matching production wiring (a local --output flag
// belongs to the leaf's own business contract and skips the sink).
newBoundaryChild := func(outputPath string) *cobra.Command {
root := &cobra.Command{Use: "root"}
root.PersistentFlags().String("output", outputPath, "")
cmd := &cobra.Command{Use: "leaf"}
root.AddCommand(cmd)
cmd.SetContext(context.Background())
return cmd
}
var calls int
cmd := newBoundaryChild("")
cmd.RunE = func(*cobra.Command, []string) error { calls++; return nil }
cmd.PostRunE = func(*cobra.Command, []string) error { calls++; return nil }
installOutputSinkRunBoundary(cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if err := cmd.PostRunE(cmd, nil); err != nil {
t.Fatal(err)
}
runOnly := newBoundaryChild("")
runOnly.Run = func(*cobra.Command, []string) { calls++ }
runOnly.PostRun = func(*cobra.Command, []string) { calls++ }
installOutputSinkRunBoundary(runOnly)
if runOnly.Run != nil || runOnly.RunE == nil {
t.Fatal("Run-only leaf must be converted to RunE so sink setup errors surface")
}
if err := runOnly.RunE(runOnly, nil); err != nil {
t.Fatal(err)
}
runOnly.PostRun(runOnly, nil)
if calls != 4 {
t.Fatalf("hook calls=%d", calls)
}
// A sink setup failure at Run entry returns before the business hook runs.
testseam.Swap(t, &rootCreateTemp, func(string, string) (*os.File, error) { return nil, errors.New("create failed") })
failCmd := newBoundaryChild(filepath.Join(t.TempDir(), "out.txt"))
businessRan := false
failCmd.RunE = func(*cobra.Command, []string) error { businessRan = true; return nil }
installOutputSinkRunBoundary(failCmd)
if err := failCmd.RunE(failCmd, nil); err == nil || !strings.Contains(err.Error(), "create failed") {
t.Fatalf("Run entry sink setup error=%v", err)
}
if businessRan {
t.Fatal("business hook ran after sink setup failure")
}
testseam.Swap(t, &rootCreateTemp, os.CreateTemp)
// A Run entry business error aborts the open sink: the temporary file is
// removed and the final target is never created.
abortTarget := filepath.Join(t.TempDir(), "result.txt")
abortCmd := newBoundaryChild(abortTarget)
abortCmd.RunE = func(*cobra.Command, []string) error { return errors.New("boom") }
installOutputSinkRunBoundary(abortCmd)
if err := abortCmd.RunE(abortCmd, nil); err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Run entry business error=%v", err)
}
if _, err := os.Stat(abortTarget); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("target exists after aborted run: %v", err)
}
assertNoOutputTemps(t, abortTarget)
// A second configureOutputSink call on an already-open sink (a reused
// command tree stacks one Run wrapper per ExecuteC) must not replace the
// live sink with a second temporary file.
repeatTarget := filepath.Join(t.TempDir(), "result.txt")
repeatCmd := newBoundaryChild(repeatTarget)
if err := configureOutputSink(repeatCmd); err != nil {
t.Fatal(err)
}
first := outputSinkForCommand(repeatCmd)
if first == nil {
t.Fatal("first configureOutputSink did not open a sink")
}
if err := configureOutputSink(repeatCmd); err != nil {
t.Fatal(err)
}
if second := outputSinkForCommand(repeatCmd); second != first {
t.Fatal("configureOutputSink replaced an open sink")
}
if err := abortOutputSink(repeatCmd); err != nil {
t.Fatal(err)
}
assertNoOutputTemps(t, repeatTarget)
file2, err := os.CreateTemp(t.TempDir(), "sink-error-*")
if err != nil {
t.Fatal(err)
}
errorCmd := &cobra.Command{Use: "error"}
errorCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file2, tempPath: file2.Name(), target: "unused"}))
if err := runWithOutputSinkErrorCleanup(errorCmd, func() error { return errors.New("boom") }); err == nil {
t.Fatal("run error swallowed")
}
file3, err := os.CreateTemp(t.TempDir(), "sink-panic-*")
if err != nil {
t.Fatal(err)
}
panicCmd := &cobra.Command{Use: "panic"}
panicCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{file: file3, tempPath: file3.Name(), target: "unused"}))
func() {
defer func() {
if recover() == nil {
t.Fatal("panic swallowed")
}
}()
_ = runWithOutputSinkErrorCleanup(panicCmd, func() error { panic("boom") })
}()
if closeOutputSink(nil) != nil || abortOutputSink(nil) != nil || outputSinkForCommand(nil) != nil {
t.Fatal("nil sink guards failed")
}
finished := &outputSinkState{finished: true, file: file3}
finishedCmd := &cobra.Command{Use: "finished"}
finishedCmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, finished))
if closeOutputSink(finishedCmd) != nil || abortOutputSink(finishedCmd) != nil {
t.Fatal("finished sink was processed twice")
}
}
type frameworkFailWriter struct{}
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
for _, failWriter := range []bool{false, true} {
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
var stdout bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws"}
cmd.SetContext(ctx)
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
if failWriter {
cmd.SetOut(frameworkFailWriter{})
} else {
cmd.SetOut(&stdout)
}
cmd.SetErr(&bytes.Buffer{})
return cmd
})
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) { panic("before emission") })
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
if !failWriter && !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%q", stdout.String())
}
})
}
}
func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T) {
t.Run("preparse interrupted", func(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootRunPreParse, func(cmd *cobra.Command, _ *pipeline.Engine) error {
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return errors.New("preparse failed")
})
if code := Execute(); code != 130 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("nil executed after emission attempt", func(t *testing.T) {
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(frameworkFailWriter{})
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
_, _, _ = output.EmitStoredResult(cmd)
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return nil, cmd.Context().Err()
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("publication failure after emission", func(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("publication failure envelope writer also fails", func(t *testing.T) {
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
if err != nil {
t.Fatal(err)
}
defer file.Close()
state := &outputSinkState{file: file, original: frameworkFailWriter{}, finished: true}
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
return cmd, newOutputPublicationError("publish", errors.New("rename failed"))
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("failure envelope cannot be written", func(t *testing.T) {
installSignalExecuteSeams(t, true, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(frameworkFailWriter{})
return cmd, errors.New("business failed")
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("late output publication warning", func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
file, err := os.CreateTemp(t.TempDir(), "late-output-*")
if err != nil {
t.Fatal(err)
}
state := &outputSinkState{file: file, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json")}
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, state))
return cmd, nil
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
for _, tc := range []struct {
name string
unified bool
original io.Writer
wantOutput bool
}{
{name: "unified late publication failure", unified: true, original: &bytes.Buffer{}, wantOutput: true},
{name: "legacy late publication failure", original: io.Discard},
{name: "late publication failure writer fails", unified: true, original: frameworkFailWriter{}},
} {
t.Run(tc.name, func(t *testing.T) {
installSignalExecuteSeams(t, tc.unified, io.Discard, io.Discard)
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
var original io.Writer = tc.original
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
file, err := os.CreateTemp(t.TempDir(), "panic-output-*")
if err != nil {
t.Fatal(err)
}
cmd.SetOut(file)
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file, original: original, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
}))
panic("after sink open")
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
if tc.wantOutput && !strings.Contains(tc.original.(*bytes.Buffer).String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%q", tc.original.(*bytes.Buffer).String())
}
})
}
t.Run("abort failure is diagnostic", func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
file, err := os.CreateTemp(t.TempDir(), "abort-output-*")
if err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file, original: io.Discard, tempPath: file.Name(), target: filepath.Join(t.TempDir(), "result.json"),
}))
return cmd, errors.New("business failed")
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d", code)
}
})
t.Run("publication helper requires observable finished transaction", func(t *testing.T) {
cmd := &cobra.Command{Use: "unified"}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
file, err := os.CreateTemp(t.TempDir(), "unfinished-output-*")
if err != nil {
t.Fatal(err)
}
defer file.Close()
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, finished: true,
}))
if _, handled, emitErr := emitOutputPublicationFailure(cmd, newOutputPublicationError("publish", errors.New("rename failed"))); handled || emitErr != nil {
t.Fatalf("handled=%v err=%v", handled, emitErr)
}
})
}
type frameworkPanicWriter struct{}
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
func TestCrossPlatformCoverageFrameworkRootHookErrors(t *testing.T) {
t.Run("flag group validation", func(t *testing.T) {
root := NewRootCommand(context.Background())
leaf := &cobra.Command{Use: "exclusive", RunE: func(*cobra.Command, []string) error { return nil }}
leaf.Flags().Bool("left", false, "")
leaf.Flags().Bool("right", false, "")
leaf.MarkFlagsMutuallyExclusive("left", "right")
root.AddCommand(leaf)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"exclusive", "--left", "--right"})
if err := root.Execute(); err == nil {
t.Fatal("expected mutually-exclusive flag error")
}
})
t.Run("edition pre-run error", func(t *testing.T) {
old := edition.Get()
t.Cleanup(func() { edition.Override(old) })
edition.Override(&edition.Hooks{AfterPersistentPreRun: func(*cobra.Command, []string) error {
return errors.New("edition hook failed")
}})
root := NewRootCommand(context.Background())
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "edition hook failed") {
t.Fatalf("Execute error=%v", err)
}
})
t.Run("post-run emission panic", func(t *testing.T) {
root := NewRootCommand(context.Background())
cmd := &cobra.Command{Use: "panic-output"}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(frameworkPanicWriter{})
if err := output.StoreResult(ctx, output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
defer func() {
if recover() == nil {
t.Fatal("expected post-run panic")
}
}()
_ = root.PersistentPostRunE(cmd, nil)
})
}
+1 -1
View File
@@ -58,7 +58,7 @@ func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
+15 -2
View File
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
copyArgs[key] = value
}
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
text := paramAliasResponseForTool(tool)
text := c.paramAliasResponseForTool(tool)
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
// print the transport result and need an empty object; smart shortcuts that
// inspect a read response receive the smallest shape that lets their full RunE
// complete without falling back to a validation error.
func paramAliasResponseForTool(tool string) string {
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
@@ -62,9 +62,22 @@ func paramAliasResponseForTool(tool string) string {
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "revert_doc_version":
return `{"version":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "get_document_content":
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
for _, key := range []string{"jsonml", "markdown"} {
if content, ok := call.args[key].(string); ok {
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
return string(encoded)
}
}
}
return `{"revision":1}`
default:
return `{}`
@@ -123,7 +123,7 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
@@ -79,13 +79,15 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
t.Fatalf("timed out authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = 5 * time.Millisecond
patAuthorizationTimeout = time.Second
patAuthorizationPollInterval = time.Millisecond
pollCtx, pollCancel := context.WithCancel(context.Background())
patResolveAccessToken = func(context.Context, string, string) (string, error) {
pollCancel()
return "", authpkg.ErrTokenDataNotFound
}
out.Reset()
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
if ok, err := WaitForPatAuthorization(pollCtx, "", &out); ok || !errors.Is(err, context.Canceled) || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
}
}
+503 -26
View File
@@ -15,24 +15,24 @@ package app
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
@@ -40,6 +40,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -58,8 +59,14 @@ var (
rootStopAllStdioClients = StopAllStdioClients
rootLoadPlugins = loadPlugins
rootMkdirAll = os.MkdirAll
rootCreateFile = os.Create
rootCreateTemp = os.CreateTemp
rootSyncFile = (*os.File).Sync
rootCloseFile = (*os.File).Close
// os.Rename replaces an existing non-directory target on every supported
// Go host; the Windows implementation uses MOVEFILE_REPLACE_EXISTING. Keep
// the temporary file beside the target so publication stays on one volume.
rootRenameFile = os.Rename
rootRemoveFile = os.Remove
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
rootPluginLoadUser = (*plugin.Loader).LoadUser
rootPluginLoadDev = (*plugin.Loader).LoadDev
@@ -76,10 +83,53 @@ var (
// Execute runs the root command and returns the process exit code.
func Execute() (exitCode int) {
var (
root *cobra.Command
executed *cobra.Command
resultStore *output.ResultStore
)
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
target := executed
if target == nil && root != nil {
if found, _, err := root.Find(os.Args[1:]); err == nil {
target = found
}
}
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
exitCode = code
if target != nil {
fmt.Fprintf(target.ErrOrStderr(), "Warning: command panicked after result emission attempt: %v\n", r)
}
} else if target != nil && output.UsesUnifiedResult(target) {
info := &output.ErrorInfo{Type: "internal", ExitCode: 5, Message: fmt.Sprintf("internal panic: %v", r)}
if code, err := output.EmitResult(target, output.Failure(info)); err == nil {
exitCode = code
} else {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
}
} else {
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
exitCode = 5
}
if executed == nil {
executed = target
}
}
CloseFileLogger()
if executed != nil {
if err := closeOutputSink(executed); err != nil {
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
exitCode = code
} else {
exitCode = apperrors.ExitCode(err)
fmt.Fprintf(os.Stderr, "Warning: close output sink: %v\n", err)
if emitErr != nil {
fmt.Fprintf(os.Stderr, "Warning: emit output publication failure: %v\n", emitErr)
}
}
}
}
}()
@@ -95,15 +145,17 @@ func Execute() (exitCode int) {
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
}()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Attach timing collector to context for use by child components
ctx = WithTimingCollector(ctx, timing)
ctx := WithTimingCollector(context.Background(), timing)
ctx, resultStore = output.WithResultStore(ctx)
var signalState *processSignalState
var stopSignals func()
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
defer stopSignals()
initStart := time.Now()
engine := newPipelineEngine()
root := rootNewRootCommandWithEngine(ctx, engine)
root = rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
// Run PreParse handlers on raw argv before Cobra parses flags.
@@ -111,16 +163,89 @@ func Execute() (exitCode int) {
// and --limit100 → --limit 100.
if err := rootRunPreParse(root, engine); err != nil {
err = newPreParseValidationError(err)
if interrupted, _ := signalState.outcome(); interrupted != nil {
err = interrupted
}
if target, _, findErr := root.Find(os.Args[1:]); findErr == nil && target != nil && output.UsesUnifiedResult(target) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(target, result)
if emitErr == nil {
return code
}
}
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
}
executed, err := rootExecuteCommand(root)
var err error
executed, err = rootExecuteCommand(root)
// PersistentPostRunE normally commits or aborts the transactional output
// sink. Finalize once more at the process boundary so custom execution
// seams, embedding callers, or future hook changes cannot leave publication
// errors to a defer that runs after the process exit code is fixed.
if executed != nil {
if err == nil {
if closeErr := closeOutputSink(executed); closeErr != nil {
err = closeErr
}
} else if abortErr := abortOutputSink(executed); abortErr != nil {
fmt.Fprintf(executed.ErrOrStderr(), "Warning: abort output sink after command failure: %v\n", abortErr)
}
}
interrupted, primaryCompletedBeforeSignal := signalState.outcome()
if interrupted != nil && !primaryCompletedBeforeSignal {
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
var publicationErr *outputPublicationError
if err != nil && stderrors.As(err, &publicationErr) {
// The successful result was written only to a transaction that did
// not publish. Let the error path replace it with one observable
// failure envelope on the restored original stream.
} else {
if executed == nil {
executed = root
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: process interrupted after result emission attempt: %v\n", interrupted)
// Once publication starts, its stored exit code is authoritative. A
// signal recorded just before or during publication must not turn a
// successfully emitted result into a contradictory 130/143 process
// status; likewise, a failed publication must retain its internal
// error code instead of being relabelled as cancellation.
return code
}
}
var publicationErr *outputPublicationError
if err == nil || !stderrors.As(err, &publicationErr) {
err = interrupted
}
}
if err != nil {
if executed == nil {
executed = root
}
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
var publicationErr *outputPublicationError
if stderrors.As(err, &publicationErr) {
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
if emitErr == nil {
return failureCode
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
}
return apperrors.ExitCode(publicationErr)
}
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
return code
}
err = rewordRequiredFlagError(err)
var raw apperrors.RawStderrError
if output.UsesUnifiedResult(executed) && !stderrors.As(err, &raw) {
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
code, emitErr := output.EmitResult(executed, result)
if emitErr == nil {
return code
}
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
@@ -129,9 +254,121 @@ func Execute() (exitCode int) {
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
}
if code, emitted := output.StoredExitCode(resultStore); emitted {
return code
}
return 0
}
// errorInfoFromExecutionError projects the repository error model into the unified
// failure body. Exit code and category are derived from the same error value,
// preventing the wire and process status from drifting apart.
func errorInfoFromExecutionError(err error) *output.ErrorInfo {
exitCode := apperrors.ExitCode(err)
info := &output.ErrorInfo{
Type: errorTypeForExitCode(exitCode),
ExitCode: exitCode,
Message: err.Error(),
}
var interrupted *processInterruption
if stderrors.As(err, &interrupted) && interrupted != nil {
info.Type = "internal"
info.Subtype = interrupted.Subtype()
return info
}
if stderrors.Is(err, context.DeadlineExceeded) {
info.Subtype = "deadline_exceeded"
}
var cliErr *helpers.CLIError
if stderrors.As(err, &cliErr) && cliErr != nil {
info.UpstreamCode = cliErr.Code
info.Hint = cliErr.Suggestion
info.Operation = cliErr.Operation
}
var callErr *transport.CallError
if stderrors.As(err, &callErr) && callErr != nil {
info.HTTPStatus = callErr.HTTPStatus
info.RPCCode = callErr.RPCCode
info.Stage = string(callErr.Stage)
if callErr.RequestID != "" {
info.RequestID = callErr.RequestID
} else if callErr.TraceID != "" {
info.RequestID = callErr.TraceID
}
}
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed == nil {
return info
}
if typed.Category == apperrors.CategoryPartial {
// An error lacks the item-level data required by partial_failure.
// Callers must use output.Partial; fail closed consistently otherwise.
info.Type = string(apperrors.CategoryInternal)
} else {
info.Type = string(typed.Category)
}
info.Subtype = typed.Reason
if typed.Hint != "" {
info.Hint = typed.Hint
}
info.Actions = append([]string(nil), typed.Actions...)
info.Retryable = typed.RetryableSet && typed.Retryable
info.RetryAfterSeconds = typed.RetryAfterSeconds
if typed.RPCCode != 0 {
info.RPCCode = typed.RPCCode
}
if typed.ServerDiag.TraceID != "" {
info.TraceID = typed.ServerDiag.TraceID
}
if typed.Operation != "" {
info.Operation = typed.Operation
}
info.ServerKey = typed.ServerKey
info.Origin = typed.Origin
if typed.FailureStage != "" {
info.Stage = typed.FailureStage
}
info.ExecutionStarted = typed.ExecutionStarted
if typed.NextRetryAt != nil {
info.NextRetryAt = typed.NextRetryAt.UTC().Format(time.RFC3339)
}
info.AvailableFlags = append([]string(nil), typed.AvailableFlags...)
info.SnapshotPath = typed.Snapshot
info.Details = typed.Details
if len(typed.RPCData) > 0 {
var rpcData any
if json.Unmarshal(typed.RPCData, &rpcData) == nil {
info.RPCData = rpcData
}
}
info.TechnicalDetail = typed.ServerDiag.TechnicalDetail
info.FriendlyHint, info.ActionURL = apperrors.ServerGuidance(typed.ServerDiag)
if typed.Cause != nil {
info.Cause = typed.Cause.Error()
}
if typed.ServerDiag.ServerErrorCode != "" {
info.UpstreamCode = typed.ServerDiag.ServerErrorCode
}
return info
}
func errorTypeForExitCode(code int) string {
switch code {
case 1:
return "api"
case 2:
return "auth"
case 3:
return "validation"
case 4:
return "permission"
case 6:
return "discovery"
default:
return "internal"
}
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
@@ -368,6 +605,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
rootCtx, _ = output.WithResultStore(rootCtx)
return newRootCommandWithEngine(rootCtx, nil, true, false)
}
@@ -390,6 +628,7 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
registerSchemaRuntimeDelivery()
rootCtx, _ = output.WithResultStore(rootCtx)
return newRootCommandWithEngine(rootCtx, engine, true, false)
}
@@ -414,6 +653,25 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// A public root may be reused by embedding callers through multiple
// ExecuteC invocations. Begin each invocation with an empty result
// lifecycle while retaining the store pointer observed by Execute's
// signal and exit-code handling. Declaration-only command trees do not
// install a store at construction time, so add one lazily when those
// trees are executed for compatibility and policy tests.
executionCtx, _ := output.WithResultStore(cmd.Context())
cmd.SetContext(executionCtx)
// WithResultStore above guarantees the reset precondition.
_ = output.ResetResultStore(executionCtx)
// Do not run Cobra's ValidateRequiredFlags/ValidateFlagGroups here:
// Cobra executes them between the leaf's PreRunE and RunE, and leaves
// rely on that order to normalize alias flags into required canonical
// flags (for example chat message download-media copies --msg-id into
// the required --message-id in PreRunE). Running them early fails the
// alias path before the leaf can normalize it. The transactional
// --output sink instead opens at Run entry (after Cobra's own
// validation), so validation failures still cannot strand a
// temporary file.
// Validate caller-provided identity labels before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
@@ -423,6 +681,11 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
if shouldDetectNestedSkillLayout(cmd) {
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
}
}
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
@@ -436,19 +699,37 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
// Configure global slog level based on --debug / --verbose flags.
configureLogLevel(flags)
if err := configureOutputSink(cmd); err != nil {
return err
}
installOutputSinkRunBoundary(cmd)
if fn := edition.Get().AfterPersistentPreRun; fn != nil {
return fn(cmd, args)
if err := fn(cmd, args); err != nil {
return err
}
}
return nil
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
PersistentPostRunE: func(cmd *cobra.Command, args []string) (err error) {
defer func() {
if r := recover(); r != nil {
warnAbortOutputSink(cmd)
panic(r)
}
if err != nil {
warnAbortOutputSink(cmd)
}
}()
_, emitted, emitErr := output.EmitStoredResult(cmd)
StopAllStdioClients()
CloseAuditSink()
CloseFileLogger()
return closeOutputSink(cmd)
if emitErr != nil {
return apperrors.NewInternal("emit command result: "+emitErr.Error(), apperrors.WithCause(emitErr))
}
if output.UsesUnifiedResult(cmd) && !emitted {
return apperrors.NewInternal("framework 2.0 command returned without a CommandResult")
}
if closeErr := closeOutputSink(cmd); closeErr != nil {
return closeErr
}
return nil
},
}
@@ -472,7 +753,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
newConfigCommand(),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(),
newEventCommand(flags),
newAuditCommand(),
newCompletionCommand(root),
newUpgradeCommand(),
@@ -848,6 +1129,54 @@ func deduplicateCommands(root *cobra.Command) {
}
}
type outputSinkState struct {
mu sync.Mutex
file *os.File
original io.Writer
tempPath string
target string
finished bool
}
type outputPublicationError struct {
cause error
}
func (e *outputPublicationError) Error() string { return e.cause.Error() }
func (e *outputPublicationError) Unwrap() error { return e.cause }
func (e *outputPublicationError) ExitCode() int { return 5 }
func newOutputPublicationError(message string, cause error) error {
return &outputPublicationError{cause: fmt.Errorf("%s: %w", message, cause)}
}
// emitOutputPublicationFailure replaces a result that was rendered only into a
// rolled-back transactional file with one observable failure envelope on the
// original output stream. This is not a second public result: closeOutputSink
// has removed the temporary file and restored cmd.OutOrStdout before returning
// the publication error.
func emitOutputPublicationFailure(cmd *cobra.Command, err error) (code int, handled bool, emitErr error) {
var publicationErr *outputPublicationError
if cmd == nil || !stderrors.As(err, &publicationErr) || !output.UsesUnifiedResult(cmd) {
return 0, false, nil
}
state := outputSinkForCommand(cmd)
if state == nil {
return 0, false, nil
}
state.mu.Lock()
original := state.original
finished := state.finished
state.mu.Unlock()
if original == nil || !finished {
return 0, false, nil
}
cmd.SetOut(original)
result := output.FailureWithExitCode(errorInfoFromExecutionError(publicationErr), apperrors.ExitCode(publicationErr))
code, emitErr = output.EmitResult(cmd, result)
return code, true, emitErr
}
func configureOutputSink(cmd *cobra.Command) error {
if local := cmd.LocalFlags().Lookup("output"); local != nil {
return nil
@@ -860,32 +1189,180 @@ func configureOutputSink(cmd *cobra.Command) error {
if outputPath == "" {
return nil
}
// A public root may be reused across ExecuteC calls, accumulating one Run
// wrapper per execution. When the sink for this invocation is already open,
// an inner wrapper must not replace it with a second temporary file.
if state := outputSinkForCommand(cmd); state != nil {
state.mu.Lock()
finished := state.finished
state.mu.Unlock()
if !finished {
return nil
}
}
if err := validateOptionalPath("--output", outputPath); err != nil {
return err
}
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
}
file, err := rootCreateFile(outputPath)
tempPattern := "." + filepath.Base(outputPath) + ".tmp-*"
file, err := rootCreateTemp(filepath.Dir(outputPath), tempPattern)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
return apperrors.NewInternal(fmt.Sprintf("failed to create temporary output file: %v", err))
}
originalOut := cmd.OutOrStdout()
cmd.SetOut(file)
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file))
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{
file: file,
original: originalOut,
tempPath: file.Name(),
target: outputPath,
}))
return nil
}
// installOutputSinkRunBoundary defers opening the transactional --output sink
// to the executed command's Run entry. Cobra runs ValidateRequiredFlags and
// ValidateFlagGroups after the leaf's PreRunE and immediately before RunE, so
// opening the sink there keeps two invariants at once: leaf PreRunE hooks can
// still normalize alias flags into required canonical flags, and a validation
// failure can never strand a temporary output file. Run-only leaves are
// converted to RunE so a sink setup failure remains a returned error. Post-run
// hooks keep the error cleanup wrapping so a post-run failure still aborts the
// transaction; pre-run hooks need no wrapping because the sink cannot exist
// before Run entry.
func installOutputSinkRunBoundary(cmd *cobra.Command) {
if cmd == nil {
return
}
openSinkAndRun := func(run func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
if err := configureOutputSink(cmd); err != nil {
return err
}
return runWithOutputSinkErrorCleanup(cmd, func() error { return run(cmd, args) })
}
}
if cmd.RunE != nil {
cmd.RunE = openSinkAndRun(cmd.RunE)
} else if cmd.Run != nil {
original := cmd.Run
cmd.Run = nil
cmd.RunE = openSinkAndRun(func(cmd *cobra.Command, args []string) error {
original(cmd, args)
return nil
})
}
if cmd.PostRunE != nil {
original := cmd.PostRunE
cmd.PostRunE = func(cmd *cobra.Command, args []string) error {
return runWithOutputSinkErrorCleanup(cmd, func() error { return original(cmd, args) })
}
}
if cmd.PostRun != nil {
original := cmd.PostRun
cmd.PostRun = func(cmd *cobra.Command, args []string) {
_ = runWithOutputSinkErrorCleanup(cmd, func() error {
original(cmd, args)
return nil
})
}
}
}
func runWithOutputSinkErrorCleanup(cmd *cobra.Command, run func() error) (err error) {
defer func() {
if r := recover(); r != nil {
warnAbortOutputSink(cmd)
panic(r)
}
if err != nil {
warnAbortOutputSink(cmd)
}
}()
return run()
}
func warnAbortOutputSink(cmd *cobra.Command) {
if closeErr := abortOutputSink(cmd); closeErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: close output sink: %v\n", closeErr)
}
}
func closeOutputSink(cmd *cobra.Command) error {
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File)
if !ok || file == nil {
state := outputSinkForCommand(cmd)
if state == nil {
return nil
}
if err := rootCloseFile(file); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
state.mu.Lock()
defer state.mu.Unlock()
// A reusable Cobra tree must never retain the transactional file as its
// stdout after this execution. Restore the caller's writer on every terminal
// path, including sync/close/rename failures and repeated cleanup calls.
if state.original != nil {
cmd.SetOut(state.original)
}
if state.finished {
return nil
}
state.finished = true
if err := rootSyncFile(state.file); err != nil {
_ = rootCloseFile(state.file)
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to sync output file", err)
}
if err := rootCloseFile(state.file); err != nil {
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to close output file", err)
}
if err := rootRenameFile(state.tempPath, state.target); err != nil {
_ = rootRemoveFile(state.tempPath)
return newOutputPublicationError("failed to publish output file", err)
}
return nil
}
func abortOutputSink(cmd *cobra.Command) error {
state := outputSinkForCommand(cmd)
if state == nil {
return nil
}
state.mu.Lock()
defer state.mu.Unlock()
if state.finished {
return nil
}
state.finished = true
// A business error still needs the root execution boundary to publish one
// typed failure envelope. Restore the pre-transaction writer before closing
// and unlinking the temporary file so that failure emission cannot target a
// closed descriptor. The final --output target remains untouched.
if state.original != nil {
cmd.SetOut(state.original)
}
closeErr := rootCloseFile(state.file)
removeErr := rootRemoveFile(state.tempPath)
if closeErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", closeErr))
}
if removeErr != nil && !stderrors.Is(removeErr, os.ErrNotExist) {
return apperrors.NewInternal(fmt.Sprintf("failed to remove temporary output file: %v", removeErr))
}
return nil
}
func outputSinkForCommand(cmd *cobra.Command) *outputSinkState {
if cmd == nil || cmd.Context() == nil {
return nil
}
state, _ := cmd.Context().Value(outputFileContextKey{}).(*outputSinkState)
if state == nil || state.file == nil {
return nil
}
return state
}
func validateOptionalPath(flagName, path string) error {
path = strings.TrimSpace(path)
if path == "" {
+10 -6
View File
@@ -157,11 +157,11 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
})
oldMkdir := rootMkdirAll
oldCreate := rootCreateFile
oldCreate := rootCreateTemp
oldClose := rootCloseFile
t.Cleanup(func() {
rootMkdirAll = oldMkdir
rootCreateFile = oldCreate
rootCreateTemp = oldCreate
rootCloseFile = oldClose
})
wantErr := errors.New("filesystem")
@@ -193,17 +193,19 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
t.Fatal("mkdir failure succeeded")
}
rootMkdirAll = func(string, os.FileMode) error { return nil }
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
rootCreateTemp = func(string, string) (*os.File, error) { return nil, wantErr }
if err := configureOutputSink(newOutputCommand(filepath.Join("create-failure", "out"))); err == nil {
t.Fatal("create failure succeeded")
}
rootCreateFile = oldCreate
rootCreateTemp = oldCreate
file, err := os.CreateTemp(t.TempDir(), "close")
if err != nil {
t.Fatal(err)
}
cmd := &cobra.Command{Use: "close"}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-target"),
}))
rootCloseFile = func(*os.File) error { return wantErr }
if err := closeOutputSink(cmd); err == nil {
t.Fatal("close failure succeeded")
@@ -216,7 +218,9 @@ func TestCrossPlatformCoverageRootFlagsPluginsAndOutputRemainingCoverage(t *test
if err != nil {
t.Fatal(err)
}
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, &outputSinkState{
file: file, tempPath: file.Name(), target: filepath.Join(filepath.Dir(file.Name()), "close-success-target"),
}))
if err := closeOutputSink(cmd); err != nil {
t.Fatalf("close success = %v", err)
}
@@ -0,0 +1,240 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
func TestPublicRootDirectExecuteResetsUnifiedResultLifecycle(t *testing.T) {
root := NewRootCommand(context.Background())
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&bytes.Buffer{})
run := 0
leaf := &cobra.Command{
Use: "lifecycle-repeat",
RunE: func(cmd *cobra.Command, _ []string) error {
run++
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
for want := 1; want <= 2; want++ {
stdout.Reset()
root.SetArgs([]string{"lifecycle-repeat", "--format", "json"})
executed, err := root.ExecuteC()
if err != nil {
t.Fatalf("ExecuteC run %d: %v", want, err)
}
if executed != leaf {
t.Fatalf("ExecuteC run %d executed %v, want lifecycle leaf", want, executed)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Run int `json:"run"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("ExecuteC run %d output %q: %v", want, stdout.String(), err)
}
if !envelope.OK || envelope.Data.Run != want {
t.Fatalf("ExecuteC run %d envelope=%+v", want, envelope)
}
}
missing := &cobra.Command{Use: "lifecycle-missing", RunE: func(*cobra.Command, []string) error { return nil }}
output.SetCommandRollout(missing, output.RolloutUnifiedActive)
root.AddCommand(missing)
stdout.Reset()
root.SetArgs([]string{"lifecycle-missing", "--format", "json"})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "without a CommandResult") {
t.Fatalf("missing-result ExecuteC error=%v, want fresh lifecycle failure", err)
}
if stdout.Len() != 0 {
t.Fatalf("missing-result ExecuteC replayed stale output %q", stdout.String())
}
}
func TestPublicRootRestoresStdoutAfterSuccessfulOutputPublication(t *testing.T) {
root := NewRootCommand(context.Background())
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&bytes.Buffer{})
run := 0
leaf := &cobra.Command{
Use: "lifecycle-output-repeat",
RunE: func(cmd *cobra.Command, _ []string) error {
run++
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"run": run}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
target := filepath.Join(t.TempDir(), "result.json")
root.SetArgs([]string{"lifecycle-output-repeat", "--output", target, "--format", "json"})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("first ExecuteC: %v", err)
}
first, err := os.ReadFile(target)
if err != nil || !bytes.Contains(first, []byte(`"run": 1`)) {
t.Fatalf("published output=%q err=%v", first, err)
}
if err := root.PersistentFlags().Set("output", ""); err != nil {
t.Fatal(err)
}
stdout.Reset()
root.SetArgs([]string{"lifecycle-output-repeat", "--format", "json"})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("second ExecuteC: %v", err)
}
if !strings.Contains(stdout.String(), `"run": 2`) {
t.Fatalf("second stdout=%q", stdout.String())
}
}
func TestPublicRootDirectExecuteFailsWhenUnifiedSinkCannotPublish(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
closeCalls := 0
rootCloseFile = func(file *os.File) error {
closeCalls++
if err := file.Close(); err != nil {
return err
}
return errors.New("late close diagnostic")
}
root := NewRootCommand(context.Background())
leaf := &cobra.Command{
Use: "lifecycle-unified",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"lifecycle-unified", "--output", filepath.Join(t.TempDir(), "result.json")})
executed, err := root.ExecuteC()
if err == nil || apperrors.ExitCode(err) != 5 {
t.Fatalf("direct ExecuteC error=%v, want publication failure with exit 5", err)
}
if executed != leaf {
t.Fatalf("executed=%v, want lifecycle leaf", executed)
}
if closeCalls != 1 {
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
}
}
func TestPublicRootDirectExecutePreservesLegacyCloseError(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
rootCloseFile = func(file *os.File) error {
_ = file.Close()
return errors.New("legacy close failed")
}
root := NewRootCommandWithEngine(context.Background(), nil)
root.AddCommand(&cobra.Command{Use: "lifecycle-legacy", RunE: func(*cobra.Command, []string) error { return nil }})
root.SetArgs([]string{"lifecycle-legacy", "--output", filepath.Join(t.TempDir(), "result.txt")})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "legacy close failed") {
t.Fatalf("legacy direct ExecuteC error=%v, want close failure", err)
}
}
func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
oldClose := rootCloseFile
t.Cleanup(func() { rootCloseFile = oldClose })
closeCalls := 0
rootCloseFile = func(file *os.File) error {
closeCalls++
return file.Close()
}
root := NewRootCommand(context.Background())
root.AddCommand(&cobra.Command{Use: "lifecycle-error", RunE: func(*cobra.Command, []string) error {
return errors.New("handler failed")
}})
root.SetArgs([]string{"lifecycle-error", "--output", filepath.Join(t.TempDir(), "result.txt")})
if _, err := root.ExecuteC(); err == nil || !strings.Contains(err.Error(), "handler failed") {
t.Fatalf("direct ExecuteC error=%v, want handler failure", err)
}
if closeCalls != 1 {
t.Fatalf("output sink close calls=%d, want 1", closeCalls)
}
}
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
var stdout, stderr bytes.Buffer
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetContext(ctx)
return cmd
}
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) {
result := output.Failure(&output.ErrorInfo{Type: "validation", Message: "bad input"})
if err := output.StoreResult(cmd.Context(), result); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
panic("after emission")
}
if code := Execute(); code != 3 {
t.Fatalf("Execute code=%d, want emitted validation code 3", code)
}
if got := strings.Count(stdout.String(), `"outcome": "failure"`); got != 1 {
t.Fatalf("stdout contains %d envelopes, want one: %s", got, stdout.String())
}
if !strings.Contains(stderr.String(), "panicked after result emission attempt") {
t.Fatalf("panic diagnostic missing: %q", stderr.String())
}
}
func TestErrorInfoProjectionKeepsTraceIDDistinctFromRequestID(t *testing.T) {
err := apperrors.NewAPI("failed", apperrors.WithTraceID("trace-1"))
info := errorInfoFromExecutionError(err)
if info.TraceID != "trace-1" || info.RequestID != "" {
t.Fatalf("projection trace_id=%q request_id=%q", info.TraceID, info.RequestID)
}
}
+377
View File
@@ -0,0 +1,377 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestOutputSinkAtomicallyReplacesExistingTargetWithMode0600(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
var tempMode os.FileMode
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
info, err := cmd.OutOrStdout().(*os.File).Stat()
if err != nil {
return err
}
tempMode = info.Mode().Perm()
_, err = fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("ExecuteC: %v", err)
}
assertOutputFile(t, target, "replacement", 0o600)
if tempMode != 0o600 {
t.Fatalf("temporary output mode=%#o, want 0600", tempMode)
}
assertNoOutputTemps(t, target)
}
func TestOutputSinkHandlerFailurePreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
return errors.New("handler failed")
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded")
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestExecuteUnifiedRunEFailureWithOutputRestoresStdoutAndPreservesTarget(t *testing.T) {
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "atomic-output-unified-failure", "--output", filepath.Join(t.TempDir(), "result.json"), "--format", "json"}
target := os.Args[3]
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
var stdout, stderr bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
root := NewRootCommandWithEngine(ctx, engine)
root.SetOut(&stdout)
root.SetErr(&stderr)
leaf := &cobra.Command{
Use: "atomic-output-unified-failure",
RunE: func(*cobra.Command, []string) error {
return apperrors.NewValidation("business validation failed")
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
return root
})
if code := Execute(); code != 3 {
t.Fatalf("Execute exit code=%d, want validation code 3; stderr=%q", code, stderr.String())
}
var envelope struct {
OK bool `json:"ok"`
Outcome string `json:"outcome"`
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
}
if envelope.OK || envelope.Outcome != "failure" || envelope.Error.Type != "validation" || envelope.Error.Message != "business validation failed" {
t.Fatalf("failure envelope=%+v", envelope)
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestOutputSinkPanicCleansTempAndPreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, _ = fmt.Fprint(cmd.OutOrStdout(), "partial")
panic("boom")
})
root.SetArgs([]string{"atomic-output", "--output", target})
if recovered := executeAndRecover(root); recovered == nil {
t.Fatal("ExecuteC did not panic")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkRenameFailurePreservesTarget(t *testing.T) {
testseam.Swap(t, &rootRenameFile, func(string, string) error {
return errors.New("rename failed")
})
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil || err.Error() == "" {
t.Fatalf("ExecuteC error=%v, want publication failure", err)
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkSyncAndCloseFailuresPreserveTarget(t *testing.T) {
tests := []struct {
name string
seam func(*testing.T)
}{
{
name: "sync",
seam: func(t *testing.T) {
testseam.Swap(t, &rootSyncFile, func(*os.File) error { return errors.New("sync failed") })
},
},
{
name: "close",
seam: func(t *testing.T) {
testseam.Swap(t, &rootCloseFile, func(file *os.File) error {
_ = file.Close()
return errors.New("close failed")
})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.seam(t)
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := newAtomicOutputTestRoot(func(cmd *cobra.Command) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), "replacement")
return err
})
root.SetArgs([]string{"atomic-output", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
})
}
}
func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing.T) {
testseam.Swap(t, &rootRenameFile, func(string, string) error {
return errors.New("rename failed")
})
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
root := NewRootCommand()
leaf := &cobra.Command{
Use: "atomic-output-unified",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-output-unified", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("unified ExecuteC succeeded without publishing its output")
} else if code := apperrors.ExitCode(err); code != 5 {
t.Fatalf("publication exit code=%d, want 5: %v", code, err)
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final output exists after publication failure: %v", err)
}
assertNoOutputTemps(t, target)
}
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
testseam.Protect(t, &os.Args)
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
t.Fatal(err)
}
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
var stdout, stderr bytes.Buffer
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, engine *pipeline.Engine) *cobra.Command {
root := NewRootCommandWithEngine(ctx, engine)
root.SetOut(&stdout)
root.SetErr(&stderr)
leaf := &cobra.Command{
Use: "atomic-output-unified-publication",
RunE: func(cmd *cobra.Command, _ []string) error {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"}))
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
return root
})
if code := Execute(); code != 5 {
t.Fatalf("Execute exit code=%d, want publication failure code 5; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
var envelope output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("publication failure stdout=%q: %v; stderr=%q", stdout.String(), err, stderr.String())
}
if envelope.OK || envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || envelope.Error.Type != "internal" || envelope.Error.ExitCode != 5 {
t.Fatalf("publication failure envelope=%+v", envelope)
}
if !strings.Contains(envelope.Error.Message, "failed to publish output file") {
t.Fatalf("publication failure message=%q", envelope.Error.Message)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)); got != 1 {
t.Fatalf("stdout contains %d failure envelopes, want one: %s", got, stdout.String())
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
}
assertOutputFile(t, target, "original", 0o640)
assertNoOutputTemps(t, target)
}
func TestOutputSinkEmissionFailurePreservesTarget(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.json")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := NewRootCommand()
leaf := &cobra.Command{
Use: "atomic-emission-failure",
RunE: func(cmd *cobra.Command, _ []string) error {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"id": "ok"})); err != nil {
return err
}
return cmd.OutOrStdout().(*os.File).Close()
},
}
output.SetCommandRollout(leaf, output.RolloutUnifiedActive)
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-emission-failure", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded after emission failure")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func TestOutputSinkValidationFailureDoesNotCreateTemp(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "result.txt")
if err := os.WriteFile(target, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
root := NewRootCommand()
leaf := &cobra.Command{Use: "atomic-validation", RunE: func(*cobra.Command, []string) error { return nil }}
leaf.Flags().String("required", "", "")
_ = leaf.MarkFlagRequired("required")
root.AddCommand(leaf)
root.SetArgs([]string{"atomic-validation", "--output", target})
if _, err := root.ExecuteC(); err == nil {
t.Fatal("ExecuteC succeeded without required flag")
}
assertOutputFile(t, target, "original", 0o600)
assertNoOutputTemps(t, target)
}
func newAtomicOutputTestRoot(run func(*cobra.Command) error) *cobra.Command {
root := NewRootCommand()
root.AddCommand(&cobra.Command{
Use: "atomic-output",
RunE: func(cmd *cobra.Command, _ []string) error {
return run(cmd)
},
})
return root
}
func executeAndRecover(cmd *cobra.Command) (recovered any) {
defer func() { recovered = recover() }()
_, _ = cmd.ExecuteC()
return nil
}
func assertOutputFile(t *testing.T, path, want string, wantMode os.FileMode) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read output: %v", err)
}
if string(data) != want {
t.Fatalf("output=%q, want %q", data, want)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat output: %v", err)
}
if mode := info.Mode().Perm(); mode != wantMode {
t.Fatalf("output mode=%#o, want %#o", mode, wantMode)
}
}
func assertNoOutputTemps(t *testing.T, target string) {
t.Helper()
matches, err := filepath.Glob(filepath.Join(filepath.Dir(target), "."+filepath.Base(target)+".tmp-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary output files remain: %v", matches)
}
}
@@ -0,0 +1,38 @@
package app
import (
"bytes"
"context"
"path/filepath"
"testing"
)
// TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag is the root-level
// regression for the alias normalization order: root's persistent pre-run must
// not run Cobra's required-flag validation ahead of the leaf PreRunE.
// chat message download-media copies --msg-id / --open-message-id into the
// required --message-id flag in its PreRunE; validating early failed that
// documented alias path with "missing required flag(s): --message-id".
func TestChatDownloadMediaAliasPreRunNormalizesRequiredFlag(t *testing.T) {
for _, alias := range []string{"msg-id", "open-message-id"} {
t.Run(alias, func(t *testing.T) {
root := NewRootCommand(context.Background())
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
target := filepath.Join(t.TempDir(), "download.bin")
root.SetArgs([]string{
"chat", "message", "download-media",
"--type", "mediaId",
"--resource-id", "media-1",
"--" + alias, "msg-1",
"--open-conversation-id", "cid-1",
"--output", target,
"--dry-run", "--format", "json",
})
if _, err := root.ExecuteC(); err != nil {
t.Fatalf("ExecuteC with alias --%s: %v\nstdout: %s\nstderr: %s", alias, err, stdout.String(), stderr.String())
}
})
}
}
+229
View File
@@ -0,0 +1,229 @@
// 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 (
"bytes"
"context"
"errors"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
cmd.SetContext(ctx)
return cmd
}
var stdout, stderr bytes.Buffer
executed := &cobra.Command{Use: "leaf"}
output.SetCommandRollout(executed, output.RolloutUnifiedActive)
executed.SetOut(&stdout)
executed.SetErr(&stderr)
rootExecuteCommand = func(root *cobra.Command) (*cobra.Command, error) {
executed.SetContext(root.Context())
if err := output.StoreResult(executed.Context(), output.Success(map[string]any{"id": "a"})); err != nil {
return executed, err
}
if _, _, err := output.EmitStoredResult(executed); err != nil {
return executed, err
}
return executed, nil
}
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want 0", code)
}
if stderr.Len() != 0 {
t.Fatalf("stderr=%q, want diagnostics only/empty", stderr.String())
}
if !strings.Contains(stdout.String(), `"outcome": "success"`) || strings.Contains(stdout.String(), `"contract_version"`) {
t.Fatalf("stdout does not match the unified envelope: %s", stdout.String())
}
}
// TestRootExecutionErrorToStderrOnly 是 B184 的回归断言:失败信封(JSON 错误
// 输出)恒走 stderr,stdout 严格为空(契约 §5.1:失败时 stdout 必须为空)。
// printExecutionError 把 PrintJSON/PrintHuman 都写 stderr writer,stdout
// writer 不得收到任何字节。
func TestRootExecutionErrorToStderrOnly(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "json", "")
_ = root.PersistentFlags().Set("format", "json")
var stdout, stderr bytes.Buffer
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewAuth("token expired")); err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
}
want := "{\n \"error\": {\n \"category\": \"auth\",\n \"code\": 2,\n \"message\": \"token expired\"\n }\n}\n"
if got := stderr.String(); got != want {
t.Fatalf("legacy root error wire changed\n got: %q\nwant: %q", got, want)
}
}
// TestRootHumanErrorToStderrOnly 是 B184 的人类可读分支断言:非 JSON 模式下,
// 失败走 stderr(PrintHuman),stdout 为空。
func TestRootHumanErrorToStderrOnly(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().String("format", "table", "")
_ = root.PersistentFlags().Set("format", "table")
var stdout, stderr bytes.Buffer
if err := printExecutionError(root, &stdout, &stderr, apperrors.NewInternal("boom")); err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("failure must keep stdout empty, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "Error:") {
t.Fatalf("expected human error on stderr, got %q", stderr.String())
}
}
// TestRootExecuteOutcomeToExitCode 是 B185 的 Execute 出口断言:Execute 把
// 命令返回的 error 类别映射为进程退出码(apperrors.ExitCode)。ok→0、
// confirmation/validation→3、panic 与 unrepresentable partial error→5。
func TestRootExecuteOutcomeToExitCode(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
// ok / pending(信封 success/pending 语义)→ 0
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, nil }
if code := Execute(); code != 0 {
t.Fatalf("success Execute code = %d, want 0", code)
}
// An error cannot carry partial succeeded/failed data and fails closed.
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, &apperrors.Error{Category: apperrors.CategoryPartial, Message: "partial"}
}
if code := Execute(); code != 5 {
t.Fatalf("partial error Execute code = %d, want 5", code)
}
// confirmation_required(validation 子类)→ 3
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, apperrors.NewValidation("blocked", apperrors.WithReason("confirmation_required"))
}
if code := Execute(); code != 3 {
t.Fatalf("confirmation Execute code = %d, want 3", code)
}
// plain internal → 5
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, errors.New("plain")
}
if code := Execute(); code != 5 {
t.Fatalf("plain Execute code = %d, want 5", code)
}
}
// TestRootSilenceErrorsAndDeferTeardown 是 B186 的断言:根命令 SilenceErrors/
// SilenceUsage 打开(Cobra 不自行打印),且 Execute 出口 defer 收尾路径
// (StopAllStdioClients)在错误路径也被调用。
func TestRootSilenceErrorsAndDeferTeardown(t *testing.T) {
oldNormalize := rootNormalizeProcessProfileArgs
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
rootNormalizeProcessProfileArgs = oldNormalize
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
stopped := false
rootStopAllStdioClients = func() { stopped = true }
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
// 错误路径:Execute 返回非零,且 defer 收尾(StopAllStdioClients)被调用。
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) {
return nil, apperrors.NewInternal("fail")
}
_ = Execute()
if !stopped {
t.Fatal("defer teardown (StopAllStdioClients) not called on error path")
}
}
// TestRootSilenceErrorsFlag 断言根命令的 SilenceErrors/SilenceUsage 为真,
// 保证 Cobra 不自行在错误时打印 usage/错误(错误渲染统一走 printExecutionError)。
func TestRootSilenceErrorsFlag(t *testing.T) {
t.Parallel()
root := NewRootCommand()
if !root.SilenceErrors || !root.SilenceUsage {
t.Fatalf("root must set SilenceErrors=%v SilenceUsage=%v", root.SilenceErrors, root.SilenceUsage)
}
}
@@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
t.Fatalf("write dry-run fixture %s: %v", path, err)
}
}
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
return agentExampleFiles{
root: root,
markdown: "./" + filepath.Base(markdown),
json: "./" + filepath.Base(jsonFile),
batch: "./" + filepath.Base(batch),
binary: "./" + filepath.Base(binary),
image: "./" + filepath.Base(image),
}
}
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
+56
View File
@@ -25,11 +25,32 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
if !containsSchemaPath(report.Covered, "chat category create-smart") {
t.Fatal("chat category create-smart is not covered by runtime Schema")
}
for _, path := range missingChatCatalogCoveragePaths() {
if !containsSchemaPath(report.Covered, path) {
t.Fatalf("%s is not covered by runtime Schema", path)
}
}
if !containsSchemaPath(report.Excluded, "agoal strategy list") {
t.Fatal("agoal strategy list is not recorded as a reviewed exclusion")
}
}
func TestRuntimeSchemaCompletenessDoesNotExcludeMissingChatCatalogPaths(t *testing.T) {
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
if err != nil {
t.Fatal(err)
}
excluded := map[string]bool{}
for _, exclusion := range exclusions {
excluded[exclusion.CLIPath] = true
}
for _, path := range missingChatCatalogCoveragePaths() {
if excluded[path] {
t.Fatalf("%s must not remain in runtime Schema exclusions", path)
}
}
}
func containsSchemaPath(paths []string, want string) bool {
for _, path := range paths {
if path == want {
@@ -38,3 +59,38 @@ func containsSchemaPath(paths []string, want string) bool {
}
return false
}
func missingChatCatalogCoveragePaths() []string {
return []string{
"chat category add-conv",
"chat category create",
"chat category delete",
"chat category remove-conv",
"chat category rename",
"chat chmod",
"chat clear-all-red-point",
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
"chat group members list-by-ids",
"chat group notice create",
"chat group notice edit",
"chat group notice get",
"chat group notice list",
"chat group share-invite",
"chat group update-alias",
"chat hide",
"chat list-all-conversations",
"chat mark-read",
"chat mark-unread",
"chat message list-emotion-replies",
"chat message set-top-msg",
"chat message unset-top-msg",
"chat mute-at-all",
"chat mute-red-envelope",
"chat text translate",
}
}
@@ -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},
+205 -5
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 357
publicShortcutCount = 399
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 358
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 401
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 357
publiclyDeliveredShortcutCount = 399
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,12 +114,14 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 187; got != want {
if got, want := int(product["count"].(float64)), 217; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
shortcutCount := 0
summaryByCLIPath := make(map[string]map[string]any, len(summaries))
for _, summary := range summaries {
summaryByCLIPath[schemaContractString(summary["cli_path"])] = summary
if strings.HasPrefix(schemaContractString(summary["canonical_path"]), "chat.shortcut_") {
shortcutCount++
}
@@ -127,6 +129,204 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
if shortcutCount != 98 {
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
}
for _, cliPath := range missingChatCatalogCoveragePaths() {
if summaryByCLIPath[cliPath] == nil {
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
}
}
assertSchemaSummarySafety(t, summaryByCLIPath, "chat clear-messages", "destructive", "high", "user_required")
assertSchemaSummarySafety(t, summaryByCLIPath, "chat data-auth cross-org", "write", "high", "user_required")
assertSchemaSummarySafety(t, summaryByCLIPath, "chat group share-invite", "write", "medium", "user_required")
assertChatCatalogCompleteLeafContracts(t)
}
func assertSchemaSummarySafety(
t testing.TB,
summaries map[string]map[string]any,
cliPath string,
effect string,
risk string,
confirmation string,
) {
t.Helper()
summary := summaries[cliPath]
if summary == nil {
t.Fatalf("schema chat missing expected catalog tool %q", cliPath)
}
if got := schemaContractString(summary["effect"]); got != effect {
t.Fatalf("%s effect = %q, want %q", cliPath, got, effect)
}
if got := schemaContractString(summary["risk"]); got != risk {
t.Fatalf("%s risk = %q, want %q", cliPath, got, risk)
}
if got := schemaContractString(summary["confirmation"]); got != confirmation {
t.Fatalf("%s confirmation = %q, want %q", cliPath, got, confirmation)
}
}
func assertChatCatalogCompleteLeafContracts(t testing.TB) {
t.Helper()
for _, cliPath := range []string{
"chat clear-messages",
"chat clear-red-point",
"chat hide",
"chat mark-read",
"chat mark-unread",
"chat mute-at-all",
"chat mute-red-envelope",
} {
leaf := executeShortcutSchemaQuery(t, "--cli-path", cliPath)
assertSchemaLeafParameterRequired(t, leaf, cliPath, "conversation-id", false)
assertSchemaLeafConstraints(t, leaf, cliPath, map[string]any{
"require_one_of": [][]string{{"conversation-id", "id", "chat"}},
"mutually_exclusive": [][]string{{"conversation-id", "id", "chat"}},
})
}
markRead := executeShortcutSchemaQuery(t, "--cli-path", "chat mark-read")
assertSchemaLeafParameterRequired(t, markRead, "chat mark-read", "message-id", true)
chmod := executeShortcutSchemaQuery(t, "--cli-path", "chat chmod")
assertSchemaLeafConstraints(t, chmod, "chat chmod", map[string]any{
"require_one_of": [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
"mutually_exclusive": [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
})
assertChatGrantParameterFacts(t, chmod, "chat chmod")
crossOrg := executeShortcutSchemaQuery(t, "--cli-path", "chat data-auth cross-org")
assertSchemaLeafConstraints(t, crossOrg, "chat data-auth cross-org", map[string]any{
"require_one_of": [][]string{{"target-org-id", "all"}},
"mutually_exclusive": [][]string{{"target-org-id", "all"}},
})
assertChatGrantParameterFacts(t, crossOrg, "chat data-auth cross-org")
shareInvite := executeShortcutSchemaQuery(t, "--cli-path", "chat group share-invite")
assertSchemaLeafConstraints(t, shareInvite, "chat group share-invite", map[string]any{
"require_one_of": [][]string{{"target", "receiver"}},
"mutually_exclusive": [][]string{{"target", "receiver"}},
})
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
}
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
t.Helper()
parameters := schemaContractMap(leaf["parameters"])
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
}
if got, _ := parameter["required"].(bool); got != want {
t.Fatalf("%s --%s required = %#v, want %v", cliPath, name, parameter["required"], want)
}
}
func assertSchemaLeafParameterEnum(t testing.TB, leaf map[string]any, cliPath, name string, want []string) {
t.Helper()
parameters := schemaContractMap(leaf["parameters"])
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", cliPath, name, parameters)
}
if got := schemaContractStringSlice(parameter["enum"]); !schemaContractJSONEqual(got, want) {
t.Fatalf("%s --%s enum = %#v, want %#v", cliPath, name, got, want)
}
}
func assertSchemaLeafConstraints(t testing.TB, leaf map[string]any, cliPath string, want map[string]any) {
t.Helper()
if got := leaf["constraints"]; !schemaContractJSONEqual(got, want) {
t.Fatalf("%s constraints = %#v, want %#v", cliPath, got, want)
}
}
func assertChatGrantParameterFacts(t testing.TB, leaf map[string]any, cliPath string) {
t.Helper()
parameters := schemaContractMap(leaf["parameters"])
grantType := parameters["grant-type"]
if grantType == nil {
t.Fatalf("%s missing --grant-type parameter: %#v", cliPath, parameters)
}
wantEnum := []string{"once", "session", "timed", "permanent"}
if got := schemaContractStringSlice(grantType["enum"]); !schemaContractJSONEqual(got, wantEnum) {
t.Fatalf("%s --grant-type enum = %#v, want %#v", cliPath, got, wantEnum)
}
if got := schemaContractString(parameters["session-id"]["required_when"]); got != "grant-type is session" {
t.Fatalf("%s --session-id required_when = %q, want grant-type is session", cliPath, got)
}
if got := schemaContractString(parameters["ttl"]["required_when"]); got != "grant-type is timed" {
t.Fatalf("%s --ttl required_when = %q, want grant-type is timed", cliPath, got)
}
}
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
t.Fatalf("confirmation = %q, want %q", got, want)
}
parameters := schemaContractMap(leaf["parameters"])
if got, want := len(parameters), 11; got != want {
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
}
if required, _ := parameters["node"]["required"].(bool); !required {
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
}
if required, _ := parameters["command"]["required"].(bool); required {
t.Errorf("--command required = true, want runtime custom validation")
}
wantProperties := map[string]string{
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
"expected-revision": "expectedRevision",
}
for name, want := range wantProperties {
if got := schemaContractString(parameters[name]["property"]); got != want {
t.Errorf("--%s property = %q, want %q", name, got, want)
}
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
parameter := parameters[name]
if required, _ := parameter["required"].(bool); required {
t.Errorf("--%s required = true, want runtime custom validation", name)
}
if got := schemaContractString(parameter["required_when"]); got != "" {
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
}
}
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
}
}
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
commentParameters := schemaContractMap(comment["parameters"])
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
if _, ok := commentParameters[name]; !ok {
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
}
}
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
if got := schemaContractString(commentParameters[name]["property"]); got != want {
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
}
}
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
exportFormat := schemaContractMap(export["parameters"])["export-format"]
if required, _ := exportFormat["required"].(bool); required {
t.Fatalf("export --export-format required = true, want compatibility default")
}
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
}
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
constraints, _ := importLeaf["constraints"].(map[string]any)
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
}
}
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
@@ -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) {
+137
View File
@@ -0,0 +1,137 @@
package app
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
var rootEscalateSignal = func(sig os.Signal) {
signal.Reset(sig)
redeliverProcessSignal(sig)
}
var (
rootFindProcess = os.FindProcess
rootExitProcess = os.Exit
)
// redeliverProcessSignal asks the current process to handle the second signal
// with the platform's default semantics. Platforms that cannot deliver the
// requested signal through os.Process.Signal fall back to the conventional
// CLI exit status instead of leaving the process running after escalation.
func redeliverProcessSignal(sig os.Signal) {
process, err := rootFindProcess(os.Getpid())
if err == nil {
err = process.Signal(sig)
}
if err != nil {
rootExitProcess(interruptionExitCode(sig))
}
}
func interruptionExitCode(sig os.Signal) int {
if sig == syscall.SIGTERM {
return 143
}
return 130
}
type processInterruption struct {
signal os.Signal
}
func (e *processInterruption) Error() string {
return fmt.Sprintf("process interrupted by %s", e.signal)
}
func (e *processInterruption) Unwrap() error { return context.Canceled }
func (e *processInterruption) ExitCode() int {
return interruptionExitCode(e.signal)
}
func (e *processInterruption) Subtype() string {
if e.signal == syscall.SIGTERM {
return "terminated"
}
return "cancelled_by_user"
}
type processSignalState struct {
mu sync.Mutex
interruption *processInterruption
primaryCompletedAtSignal bool
}
func (s *processSignalState) record(sig os.Signal, store *output.ResultStore) (first bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.interruption != nil {
return false
}
_, _, s.primaryCompletedAtSignal, _ = output.StoredEmissionState(store)
s.interruption = &processInterruption{signal: sig}
return true
}
func (s *processSignalState) outcome() (*processInterruption, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.interruption, s.primaryCompletedAtSignal
}
func installProcessSignalContext(parent context.Context, store *output.ResultStore) (context.Context, *processSignalState, func()) {
signals := make(chan os.Signal, 2)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
return manageProcessSignals(parent, store, signals, func() { signal.Stop(signals) }, rootEscalateSignal)
}
func manageProcessSignals(
parent context.Context,
store *output.ResultStore,
signals <-chan os.Signal,
stopNotify func(),
escalate func(os.Signal),
) (context.Context, *processSignalState, func()) {
ctx, cancel := context.WithCancelCause(parent)
state := &processSignalState{}
done := make(chan struct{})
stopped := make(chan struct{})
var stopOnce sync.Once
go func() {
defer close(stopped)
for {
select {
case sig := <-signals:
if sig == nil {
continue
}
if state.record(sig, store) {
cancel(state.interruption)
continue
}
escalate(sig)
return
case <-done:
return
}
}
}()
stop := func() {
stopOnce.Do(func() {
stopNotify()
close(done)
<-stopped
cancel(context.Canceled)
})
}
return ctx, state, stop
}
+336
View File
@@ -0,0 +1,336 @@
package app
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func signalSelf(t *testing.T, sig syscall.Signal) {
t.Helper()
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("find current process: %v", err)
}
if err := process.Signal(sig); err != nil {
t.Skipf("current platform does not support process signal delivery: %v", err)
}
}
func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
originalFind, originalExit := rootFindProcess, rootExitProcess
t.Cleanup(func() { rootFindProcess, rootExitProcess = originalFind, originalExit })
rootFindProcess = func(int) (*os.Process, error) { return nil, errors.New("find failed") }
exitCode := 0
rootExitProcess = func(code int) { exitCode = code }
rootEscalateSignal(syscall.SIGTERM)
if exitCode != 143 {
t.Fatalf("escalation exit=%d", exitCode)
}
exitCode = 0
redeliverProcessSignal(syscall.SIGTERM)
if exitCode != 143 {
t.Fatalf("fallback exit=%d", exitCode)
}
rootFindProcess = func(int) (*os.Process, error) { return os.FindProcess(99999999) }
exitCode = 0
redeliverProcessSignal(syscall.SIGINT)
if exitCode != 130 {
t.Fatalf("signal fallback exit=%d", exitCode)
}
interrupted := &processInterruption{signal: syscall.SIGINT}
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
t.Fatalf("interruption=%v", interrupted)
}
terminated := &processInterruption{signal: syscall.SIGTERM}
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
t.Fatalf("termination=%v", terminated)
}
state := &processSignalState{}
if !state.record(syscall.SIGINT, nil) || state.record(syscall.SIGTERM, nil) {
t.Fatal("signal state did not reject a second interruption")
}
}
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
signals := make(chan os.Signal, 3)
stopped, escalated := false, make(chan os.Signal, 1)
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() { stopped = true }, func(sig os.Signal) { escalated <- sig })
signals <- nil
signals <- syscall.SIGINT
<-ctx.Done()
signals <- syscall.SIGTERM
if got := <-escalated; got != syscall.SIGTERM {
t.Fatalf("escalated=%v", got)
}
stop()
stop()
if !stopped {
t.Fatal("signal notification was not stopped")
}
}
func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Writer) {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = []string{"dws"}
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
testseam.Swap(t, &rootStopAllStdioClients, func() {})
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
if unified {
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
}
cmd.SetContext(ctx)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
return cmd
})
}
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
for _, tc := range []struct {
name string
signal syscall.Signal
code int
subtype string
}{
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
} {
t.Run(tc.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, &stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
signalSelf(t, tc.signal)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != tc.code {
t.Fatalf("Execute code=%d, want %d", code, tc.code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
}
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
t.Fatalf("error=%+v, want internal/%s exit %d", env.Error, tc.subtype, tc.code)
}
if bytes.Count(stdout.Bytes(), []byte(`"outcome": "failure"`)) != 1 {
t.Fatalf("stdout must contain one failure envelope: %s", stdout.String())
}
})
}
}
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
for _, tc := range []struct {
signal syscall.Signal
code int
}{{syscall.SIGINT, 130}, {syscall.SIGTERM, 143}} {
t.Run(tc.signal.String(), func(t *testing.T) {
installSignalExecuteSeams(t, false, io.Discard, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
signalSelf(t, tc.signal)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != tc.code {
t.Fatalf("Execute code=%d, want %d", code, tc.code)
}
})
}
}
func TestExecuteDeadlineIsNotSignalCancellation(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
return cmd, context.DeadlineExceeded
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d, want internal deadline code 5", code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env.Error == nil || env.Error.Subtype != "deadline_exceeded" || env.Error.ExitCode == 130 || env.Error.ExitCode == 143 {
t.Fatalf("deadline error=%+v", env.Error)
}
}
func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
cmd.SetOut(failingWriter{})
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
_, _, _ = output.EmitStoredResult(cmd)
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 5 {
t.Fatalf("Execute code=%d, want publication failure code 5", code)
}
if stdout.Len() != 0 {
t.Fatalf("second envelope emitted: %q", stdout.String())
}
}
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
// Record cancellation before publication begins, then simulate a command
// hook that has already committed its result and completes publication.
// The wire result must remain authoritative over the earlier signal.
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want published success code 0", code)
}
var env output.Envelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v; output=%q", err, stdout.String())
}
if !env.OK || env.Outcome != output.OutcomeSuccess {
t.Fatalf("published envelope=%+v, want successful outcome", env)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
}
}
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
var stdout bytes.Buffer
installSignalExecuteSeams(t, true, &stdout, io.Discard)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
t.Fatal(err)
}
if _, _, err := output.EmitStoredResult(cmd); err != nil {
t.Fatal(err)
}
signalSelf(t, syscall.SIGINT)
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
if code := Execute(); code != 0 {
t.Fatalf("Execute code=%d, want established success code 0", code)
}
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 1 {
t.Fatalf("stdout contains %d success envelopes, want one: %s", got, stdout.String())
}
}
func TestExecuteSignalSubprocessExitStatus(t *testing.T) {
if os.Getenv("DWS_SIGNAL_HELPER") == "1" {
installSignalExecuteSeams(t, true, os.Stdout, os.Stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
_, _ = fmt.Fprintln(os.Stderr, "READY")
<-cmd.Context().Done()
return cmd, cmd.Context().Err()
})
os.Exit(Execute())
}
for _, tc := range []struct {
name string
signal syscall.Signal
code int
subtype string
}{
{name: "SIGINT", signal: syscall.SIGINT, code: 130, subtype: "cancelled_by_user"},
{name: "SIGTERM", signal: syscall.SIGTERM, code: 143, subtype: "terminated"},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestExecuteSignalSubprocessExitStatus$")
cmd.Env = append(os.Environ(), "DWS_SIGNAL_HELPER=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
if scanner := bufio.NewScanner(stderr); !scanner.Scan() || scanner.Text() != "READY" {
t.Fatalf("helper readiness failed: %q, err=%v", scanner.Text(), scanner.Err())
}
if err := cmd.Process.Signal(tc.signal); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Skipf("current platform does not support subprocess signal delivery: %v", err)
}
payload, readErr := io.ReadAll(stdout)
if readErr != nil {
t.Fatal(readErr)
}
waitErr := cmd.Wait()
var exitErr *exec.ExitError
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != tc.code {
t.Fatalf("wait error=%v, want exit %d", waitErr, tc.code)
}
var env output.Envelope
if err := json.Unmarshal(payload, &env); err != nil {
t.Fatalf("decode helper output: %v; output=%q", err, payload)
}
if env.Error == nil || env.Error.Subtype != tc.subtype || env.Error.ExitCode != tc.code {
t.Fatalf("helper error=%+v", env.Error)
}
})
}
}
func TestSecondSignalUsesEscalationSeam(t *testing.T) {
signals := make(chan os.Signal, 2)
escalated := make(chan os.Signal, 1)
ctx, _, stop := manageProcessSignals(context.Background(), nil, signals, func() {}, func(sig os.Signal) {
escalated <- sig
})
signals <- syscall.SIGINT
<-ctx.Done()
if !errors.Is(context.Cause(ctx), context.Canceled) {
t.Fatalf("cause=%v, want cancellation", context.Cause(ctx))
}
signals <- syscall.SIGTERM
if got := <-escalated; got != syscall.SIGTERM {
t.Fatalf("escalated %v, want SIGTERM", got)
}
stop()
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
+1
View File
@@ -126,6 +126,7 @@ var agentSkillPaths = map[string]string{
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"zcode": ".zcode/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
// IDE / agent registries also probed by `dws skill setup --target all`.
"gemini": ".gemini/skills",
+1 -1
View File
@@ -452,7 +452,7 @@ func TestSupportedTargets(t *testing.T) {
// Should contain all predefined targets — including the agents/* sentinel
// and the IDE/agent registries we share with skillSetupAgentHomes.
expectedTargets := []string{
"agents", "claude", "cursor", "codex", "opencode", "qoder",
"agents", "claude", "cursor", "codex", "zcode", "opencode", "qoder",
"gemini", "github", "windsurf", "augment", "cline",
"amp", "kiro", "trae", "openclaw", "hermes",
".",
+1374 -144
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,707 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func useManagedSkillNames(t *testing.T, names ...string) {
t.Helper()
records := make([]skillprovenance.Record, 0, len(names))
for _, name := range names {
records = append(records, skillprovenance.Record{Name: name})
}
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
return &skillstate.State{ManagedSkills: records}, true, nil
})
}
// TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills verifies the
// confirmation prompt lists stale dingtalk-* / dws-shared directories that a
// full (unfiltered) multi install will back up and remove, and that a
// filtered install previews nothing extra.
func TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills(t *testing.T) {
testseam.Swap(t, &skillSetupInteractive, func() bool { return false })
dest := filepath.Join(t.TempDir(), ".claude", "skills")
stale := filepath.Join(dest, "dingtalk-old")
if err := os.MkdirAll(stale, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, "dingtalk-old")
var out bytes.Buffer
ok, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, false)
if err == nil || ok || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
}
if !strings.Contains(out.String(), "将备份并移除过期 skill") {
t.Fatalf("full install preview must list stale skills, got %q", out.String())
}
if !strings.Contains(out.String(), filepath.Join(dest, "dingtalk-old")) {
t.Fatalf("preview must name the stale directory, got %q", out.String())
}
out.Reset()
ok, err = confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, true)
if err == nil || ok {
t.Fatalf("filtered confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
}
if strings.Contains(out.String(), "将备份并移除过期 skill") {
t.Fatalf("filtered install must stay additive in the preview, got %q", out.String())
}
}
func TestCrossPlatformCoverageSkillSetupUnifiedOwnership(t *testing.T) {
dir := filepath.Join(t.TempDir(), "dingtalk-custom")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if isManagedDWSMultiSkillDir(dir) {
t.Fatal("an unregistered dingtalk-* directory must not be treated as DWS-owned")
}
managed := map[string]bool{"dingtalk-custom": true}
if !isManagedDWSMultiSkillDir(dir, managed) {
t.Fatal("unified metadata must prove ownership")
}
legacy := filepath.Join(t.TempDir(), legacySharedSkill)
if !isManagedDWSMultiSkillDir(legacy) {
t.Fatal("the exact legacy dws-shared name must remain managed")
}
}
func TestCrossPlatformCoveragePublishManagedSkillFailurePaths(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
skillSrc := filepath.Join(src, "dingtalk-a")
failure := errors.New("publish denied")
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishTemp, func(string, string) (string, error) { return "", failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "staging") {
t.Fatalf("mkdir error = %v", err)
}
})
t.Run("copy", func(t *testing.T) {
parent := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
if !errors.Is(err, failure) {
t.Fatalf("copy error = %v", err)
}
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
t.Fatalf("copy failure retained staging: %v, err=%v", entries, readErr)
}
})
t.Run("rename", func(t *testing.T) {
parent := t.TempDir()
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "发布 Skill") {
t.Fatalf("rename error = %v", err)
}
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
t.Fatalf("rename failure retained staging: %v, err=%v", entries, readErr)
}
})
t.Run("cleanup", func(t *testing.T) {
renameErr := errors.New("rename denied")
cleanupErr := errors.New("cleanup denied")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return renameErr })
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
if !errors.Is(err, renameErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("cleanup error = %v", err)
}
})
}
// TestCrossPlatformCoverageSkillSetupCleanupHomeFailure verifies that
// cleanupMutualExclusion keeps every victim in place with a warning when
// $HOME cannot be resolved, instead of destroying anything.
func TestCrossPlatformCoverageSkillSetupCleanupHomeFailure(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".agents", "skills")
victim := filepath.Join(dest, "dws")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
homeErr := errors.New("home boom")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", homeErr })
var out, errOut bytes.Buffer
cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
t.Fatalf("expected HOME warning on errOut, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(victim, "SKILL.md")); err != nil {
t.Fatalf("victim must survive the HOME failure: %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupBackupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dws" {
return "", failure
}
return "", nil
})
dest := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
}
if copyCalls != 2 {
t.Fatalf("backup failure staged %d new Skills, want 2", copyCalls)
}
if !strings.Contains(errOut.String(), "跳过整个 Agent 目标") {
t.Fatalf("missing whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupCleanupMutualExclusionBackupFailure(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
victim := filepath.Join(dest, "dws")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
failure := errors.New("backup boom")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if dir != victim {
t.Fatalf("backup victim = %q, want %q", dir, victim)
}
return "", failure
})
var out, errOut bytes.Buffer
err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
if !errors.Is(err, failure) {
t.Fatalf("cleanup error = %v, want %v", err, failure)
}
if out.Len() != 0 || !strings.Contains(errOut.String(), "互斥清理失败") {
t.Fatalf("cleanup output = %q / %q", out.String(), errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupMonoCleanupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("multi backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dingtalk-a" {
return "", failure
}
return "", nil
})
base := filepath.Join(home, ".agents", "skills")
multi := filepath.Join(base, "dingtalk-a")
if err := os.MkdirAll(multi, 0o755); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, filepath.Base(multi))
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if copyCalls != 1 {
t.Fatalf("multi cleanup failure staged mono %d times, want 1", copyCalls)
}
if _, err := os.Stat(multi); err != nil {
t.Fatalf("multi leftover must survive backup failure: %v", err)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("missing mono whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupStaleBackupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("stale backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dingtalk-stale" {
return "", failure
}
return "", nil
})
dest := filepath.Join(home, ".agents", "skills")
stale := filepath.Join(dest, "dingtalk-stale")
if err := os.MkdirAll(stale, 0o755); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, filepath.Base(stale))
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
}
if copyCalls != 2 {
t.Fatalf("stale backup failure staged %d new Skills, want 2", copyCalls)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("missing stale whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *testing.T) {
for _, failureKind := range []string{"later_backup", "later_publish"} {
failureKind := failureKind
t.Run(failureKind, func(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
first := filepath.Join(dest, "dingtalk-first")
second := filepath.Join(dest, "dingtalk-second")
for path, body := range map[string]string{
filepath.Join(first, "SKILL.md"): "old first\n",
filepath.Join(second, "SKILL.md"): "old second\n",
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
src := writeMultiSkillSource(t, []string{"dingtalk-first", "dingtalk-second"})
if err := os.WriteFile(filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
failure := errors.New("injected " + failureKind + " failure")
if failureKind == "later_backup" {
originalBackup := skillSetupBackupAndRemove
testseam.Swap(t, &skillSetupBackupAndRemove, func(homeDir, dir string) (string, error) {
if dir == second {
return "", failure
}
return originalBackup(homeDir, dir)
})
} else {
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
}
return originalRename(oldPath, newPath)
})
}
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
src,
[]string{"dingtalk-first", "dingtalk-second"},
[]string{dest},
&out,
&errOut,
true,
)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("transaction failure = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
}
for path, want := range map[string]string{
filepath.Join(first, "SKILL.md"): "old first\n",
filepath.Join(second, "SKILL.md"): "old second\n",
} {
got, readErr := os.ReadFile(path)
if readErr != nil || string(got) != want {
t.Fatalf("restored %s = %q, err=%v, want %q", path, got, readErr, want)
}
}
entries, readErr := os.ReadDir(dest)
if readErr != nil {
t.Fatal(readErr)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-setup-set-") {
t.Fatalf("transaction left staging directory %s", entry.Name())
}
}
if !strings.Contains(errOut.String(), "已执行回滚") || !strings.Contains(errOut.String(), failure.Error()) {
t.Fatalf("transaction failure output = %q", errOut.String())
}
})
}
}
func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
failure := errors.New("injected transaction failure")
t.Run("managed publish success", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := filepath.Join(t.TempDir(), "dingtalk-a")
if err := publishDWSManagedSkillDir(filepath.Join(src, "dingtalk-a"), dest); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
t.Fatalf("published Skill missing: %v", err)
}
})
t.Run("staging cleanup failure", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
cleanupErr := errors.New("staging cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
_, _, err := stageSkillSetupTarget(
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
skillSetupTargetPlan{Destination: dest},
)
if !errors.Is(err, failure) || !errors.Is(err, cleanupErr) {
t.Fatalf("staging cleanup error = %v", err)
}
})
t.Run("staging directory failure", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := t.TempDir()
originalMkdirAll := skillSetupMkdirAll
testseam.Swap(t, &skillSetupMkdirAll, func(path string, mode os.FileMode) error {
if filepath.Base(path) == "dingtalk-a" && strings.HasPrefix(filepath.Base(filepath.Dir(path)), ".dws-setup-set-") {
return failure
}
return originalMkdirAll(path, mode)
})
_, _, err := stageSkillSetupTarget(
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
skillSetupTargetPlan{Destination: dest},
)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill staging 目录失败") {
t.Fatalf("staging directory error = %v", err)
}
})
t.Run("restore failure aggregation", func(t *testing.T) {
t.Run("remove published", func(t *testing.T) {
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
t.Fatalf("remove published error = %v", err)
}
})
t.Run("original still exists", func(t *testing.T) {
original := t.TempDir()
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: original, backup: "backup"}})
if err == nil || !strings.Contains(err.Error(), "恢复目标仍存在") {
t.Fatalf("existing restore target error = %v", err)
}
})
t.Run("stat", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
t.Fatalf("restore stat error = %v", err)
}
})
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
t.Fatalf("restore mkdir error = %v", err)
}
})
t.Run("rename", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
t.Fatalf("restore rename error = %v", err)
}
})
})
t.Run("backup rollback failure", func(t *testing.T) {
calls := 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
calls++
if calls == 1 {
return "backup", nil
}
return "", failure
})
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
restoreErr := errors.New("restore failure")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
t.Fatalf("backup rollback error = %v", err)
}
})
t.Run("publish rollback failure", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
err := publishSkillSetupTarget(
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
[]skillSetupBackedUpDir{{original: "dest", backup: "backup"}},
)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "回滚不完整") {
t.Fatalf("publish rollback error = %v", err)
}
})
t.Run("execute cleanup errors", func(t *testing.T) {
newPlan := func(t *testing.T) *skillSetupPlan {
t.Helper()
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
return &skillSetupPlan{
Mode: skillSetupModeMulti,
Source: src,
MultiSkillNames: []string{"dingtalk-a"},
Targets: []skillSetupTargetPlan{{Destination: t.TempDir()}},
}
}
t.Run("after backup failure", func(t *testing.T) {
plan := newPlan(t)
plan.Targets[0].Backups = []skillSetupBackup{{Path: filepath.Join(plan.Targets[0].Destination, "old")}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
cleanupErr := errors.New("cleanup after backup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("backup cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
}
})
t.Run("after publish failure", func(t *testing.T) {
plan := newPlan(t)
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
}
return originalRename(oldPath, newPath)
})
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after publish failure")
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
return cleanupErr
}
return originalRemoveAll(path)
})
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("publish cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
}
})
t.Run("after success", func(t *testing.T) {
plan := newPlan(t)
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after success")
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
return cleanupErr
}
return originalRemoveAll(path)
})
var stderr bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || installed != 1 || skipped != 0 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("success cleanup = installed %d, skipped %d, err %v, stderr %q", installed, skipped, err, stderr.String())
}
})
})
}
// TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips verifies both
// install paths skip (never destroy) every target when $HOME cannot be
// resolved for the pre-refresh backup.
func TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if err := os.MkdirAll(monoDest, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(monoDest, "SKILL.md"), []byte("# old"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{monoDest}, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("mono install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过刷新") {
t.Fatalf("expected HOME skip warning, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
t.Fatalf("existing mono dir must be preserved: %v", err)
}
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-a"})
multiDest := filepath.Join(t.TempDir(), ".claude", "skills")
if err := os.MkdirAll(filepath.Join(multiDest, "dingtalk-a"), 0o755); err != nil {
t.Fatal(err)
}
out.Reset()
errOut.Reset()
installed, skipped, err = installMultiSkillToHomes(multiSrc, []string{"dingtalk-a"}, []string{multiDest}, &out, &errOut, true)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("multi install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过整个 Agent 目标") {
t.Fatalf("expected multi HOME skip warning, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(multiDest, "dingtalk-a")); err != nil {
t.Fatalf("existing sub skill must be preserved: %v", err)
}
}
// TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges covers
// removeStaleMultiSkills and its preview companion staleMultiSkillVictims:
// scan failures, the HOME failure, backup failures, and the success path.
func TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".cursor", "skills")
keep := []string{"dingtalk-chat"}
entries := map[string]bool{ // dir entries; README below is a plain file
"dingtalk-chat": true, // kept (in bundle)
"dingtalk-stale": true, // stale product skill
"dws-shared": true, // legacy shared name is stale too
"other-skill": true, // non-DWS, must survive
}
for name := range entries {
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
if err := os.WriteFile(filepath.Join(dest, "README"), []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
// Non-ENOENT scan failure warns; ENOENT is silent.
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("scan boom") })
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "过期 skill 扫描失败") {
t.Fatalf("expected scan warning, got %q", errOut.String())
}
errOut.Reset()
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist })
removeStaleMultiSkills(dest, keep, &out, &errOut)
if errOut.Len() != 0 {
t.Fatalf("ENOENT scan must be silent, got %q", errOut.String())
}
testseam.Swap(t, &skillSetupReadDir, os.ReadDir)
// The preview companion sees the same victims and skips files/kept/non-DWS.
victims := staleMultiSkillVictims(dest, keep)
wantVictims := []string{filepath.Join(dest, "dingtalk-stale"), filepath.Join(dest, "dws-shared")}
if len(victims) != len(wantVictims) {
t.Fatalf("staleMultiSkillVictims = %v, want %v", victims, wantVictims)
}
// HOME failure keeps every stale directory with a warning.
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
errOut.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
t.Fatalf("expected HOME warning, got %q", errOut.String())
}
for name := range entries {
if _, err := os.Stat(filepath.Join(dest, name)); err != nil {
t.Fatalf("entry %s must survive the HOME failure: %v", name, err)
}
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
// Backup failure keeps the stale directory with a warning.
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup boom") })
errOut.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "过期 skill 清理失败(保留原目录") {
t.Fatalf("expected backup failure warning, got %q", errOut.String())
}
for _, stale := range wantVictims {
if _, err := os.Stat(stale); err != nil {
t.Fatalf("stale dir must survive the backup failure: %v", err)
}
}
// Success: both stale dirs are backed up and reported; the rest survives.
testseam.Swap(t, &skillSetupBackupAndRemove, func(_, dir string) (string, error) { return filepath.Join(t.TempDir(), "backup"), nil })
out.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if count := strings.Count(out.String(), "已备份并清理过期 skill"); count != len(wantVictims) {
t.Fatalf("expected %d stale cleanup lines, got %d (out=%q)", len(wantVictims), count, out.String())
}
if _, err := os.Stat(filepath.Join(dest, "other-skill")); err != nil {
t.Fatalf("non-DWS dir must survive: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-chat")); err != nil {
t.Fatalf("bundle skill must survive: %v", err)
}
}
+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) {
+369 -25
View File
@@ -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"
)
@@ -45,18 +47,16 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
oldTargets := skillSetupResolveTargets
oldList := skillSetupListMulti
oldFilter := skillSetupFilterMulti
oldConfirm := skillSetupConfirm
oldMono := skillSetupInstallMono
oldMulti := skillSetupInstallMulti
oldConfirm := skillSetupConfirmPlan
oldExecute := skillSetupExecutePlan
t.Cleanup(func() {
skillSetupResolveMode = oldMode
skillSetupResolveSource = oldSource
skillSetupResolveTargets = oldTargets
skillSetupListMulti = oldList
skillSetupFilterMulti = oldFilter
skillSetupConfirm = oldConfirm
skillSetupInstallMono = oldMono
skillSetupInstallMulti = oldMulti
skillSetupConfirmPlan = oldConfirm
skillSetupExecutePlan = oldExecute
})
fail := errors.New("failure")
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
@@ -81,17 +81,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
_ = cmd.Root().PersistentFlags().Set("dry-run", "true")
cmd.Flags().Bool("dry-run", true, "")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("confirmation failure should propagate")
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
@@ -103,36 +103,146 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
t.Fatal("unknown resolved mode should fail")
}
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("mono install failure should propagate")
}
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("multi install failure should propagate")
}
}
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
setTestHome(t, t.TempDir())
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,
true,
)
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,
true,
)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
}
got, readErr := os.ReadFile(filepath.Join(failureLegacy, "SKILL.md"))
if readErr != nil || string(got) != "legacy\n" {
t.Fatalf("failed replacement changed the live legacy copy: %q, err=%v", got, readErr)
}
if !strings.Contains(failureErr.String(), "Skill staging 失败,保留原集合") {
t.Fatalf("failed replacement did not report preserved live set: %s", failureErr.String())
}
})
}
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
oldBackup := skillSetupBackupAndRemove
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
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
skillSetupBackupAndRemove = oldBackup
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
})
fail := errors.New("failure")
@@ -143,7 +253,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
t.Fatal("interactive mode failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMulti {
t.Fatalf("interactive default choice = %q, %v", got, err)
}
@@ -202,23 +312,24 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
var out, errOut bytes.Buffer
skillSetupRunForm = func(*huh.Form) error { return fail }
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}, false); err == nil {
t.Fatal("confirmation form failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil, false); err != nil || ok {
t.Fatalf("EOF confirmation = %v, %v", ok, err)
}
skillSetupRemoveAll = func(string) error { return fail }
skillSetupUserHomeDir = func() (string, error) { return t.TempDir(), nil }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
skillSetupCopyDir = func(string, string) error { return fail }
skillSetupRemoveAll = func(string) error { return fail }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
_, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut)
if skipped != 1 {
t.Fatal("mono remove failure not skipped")
t.Fatal("mono backup failure not skipped")
}
skillSetupRemoveAll = func(string) error { return nil }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut)
if skipped != 1 {
@@ -231,18 +342,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
}
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 2 {
t.Fatal("multi mkdir failure count mismatch")
}
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
skillSetupRemoveAll = func(string) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 1 {
t.Fatal("multi remove failure count mismatch")
t.Fatal("multi backup failure count mismatch")
}
skillSetupRemoveAll = func(string) error { return nil }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 1 {
t.Fatal("multi copy failure count mismatch")
}
@@ -310,3 +421,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, bool) (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}, true, 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}, true, 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)
}
})
}
+425
View File
@@ -0,0 +1,425 @@
package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/charmbracelet/huh"
)
func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".claude", "skills")
source := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
for _, name := range []string{"dws", "dingtalk-a", "dingtalk-stale"} {
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return source, func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{dest}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupInteractive, func() bool { return true })
testseam.Swap(t, &skillSetupRunForm, func(*huh.Form) error { return nil })
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
wantBackups := []string{
filepath.Join(dest, "dingtalk-a"),
filepath.Join(dest, "dingtalk-stale"),
filepath.Join(dest, "dws"),
}
// Dry-run must disclose every exact path and perform no backup or copy.
backupCalls, copyCalls := []string{}, 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
backupCalls = append(backupCalls, path)
return "backup", nil
})
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(src, dest)
})
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var dryRunOut bytes.Buffer
dryRunCmd.SetOut(&dryRunOut)
if err := dryRunCmd.Root().PersistentFlags().Set("dry-run", "true"); err != nil {
t.Fatal(err)
}
if err := dryRunCmd.RunE(dryRunCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(dryRunOut.String(), path) != 1 {
t.Fatalf("dry-run path %s count != 1:\n%s", path, dryRunOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("dry-run mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// The real confirmation renderer discloses the same paths. Its default
// negative answer must leave backup and copy at zero calls.
declineCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var declineOut bytes.Buffer
declineCmd.SetOut(&declineOut)
if err := declineCmd.RunE(declineCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(declineOut.String(), path) != 1 {
t.Fatalf("confirmation path %s count != 1:\n%s", path, declineOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("declined confirmation mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// Explicit confirmation executes exactly the paths rendered from the plan.
var confirmedPlan *skillSetupPlan
testseam.Swap(t, &skillSetupConfirmPlan, func(out io.Writer, plan *skillSetupPlan) (bool, error) {
confirmedPlan = plan
renderSkillSetupPlan(out, plan)
return true, nil
})
confirmCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
if err := confirmCmd.RunE(confirmCmd, nil); err != nil {
t.Fatal(err)
}
var planned []string
for _, target := range confirmedPlan.Targets {
for _, backup := range target.Backups {
planned = append(planned, backup.Path)
}
}
if !reflect.DeepEqual(planned, wantBackups) || !reflect.DeepEqual(backupCalls, wantBackups) {
t.Fatalf("planned=%v executed=%v want=%v", planned, backupCalls, wantBackups)
}
if copyCalls != 2 {
t.Fatalf("copy calls = %d, want 2", copyCalls)
}
// A filtered multi plan replaces only selected same-name skills and leaves
// unselected siblings out of the backup set.
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
if err != nil {
t.Fatal(err)
}
var filteredPaths []string
for _, backup := range filtered.Targets[0].Backups {
filteredPaths = append(filteredPaths, backup.Path)
}
if !reflect.DeepEqual(filteredPaths, []string{filepath.Join(dest, "dingtalk-a"), filepath.Join(dest, "dws")}) {
t.Fatalf("filtered backups = %v", filteredPaths)
}
}
func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".agents", "skills", "dws")
if err := os.MkdirAll(dest, 0o755); err != nil {
t.Fatal(err)
}
plan, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{dest}, nil, false)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 1 || len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != dest || plan.Targets[0].Backups[0].Reason != skillSetupBackupReplace {
t.Fatalf("mono plan = %#v", plan)
}
}
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".codex", "skills")
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
return "", errors.New("transient HOME failure")
})
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
t.Fatalf("generic cleanup target = %#v", plan.Targets)
}
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
}
var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
t.Fatalf("generic cleanup preview missing: %s", preview.String())
}
t.Run("managed multi and scan failure", func(t *testing.T) {
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
if targetErr != nil || target == nil || len(target.Backups) != 2 {
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
}
failure := errors.New("generic scan failure")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
t.Fatalf("generic scan error = %v", targetErr)
}
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
t.Fatalf("generic cleanup plan error = %v", planErr)
}
})
}
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
failure := errors.New("cleanup failure")
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("cleanup after skip = (%d, %d, %v)", installed, skipped, err)
}
})
t.Run("home failure keeps generic copy", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
t.Run("backup failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
// "dws" is synthetic but makes the mutual-exclusion target and selected
// same-name target overlap, pinning path deduplication in the plan itself.
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dws"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != filepath.Join(dest, "dws") {
t.Fatalf("deduplicated plan = %#v", plan)
}
failure := errors.New("scan denied")
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if err := os.MkdirAll(filepath.Dir(monoDest), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
t.Fatalf("replacement stat error = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
t.Fatalf("stale scan error = %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupInstallWrappersFailOnPlanErrors(t *testing.T) {
t.Run("multi mono-leftover stat failure", func(t *testing.T) {
failure := errors.New("stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
installed, skipped, err := installMultiSkillToHomes(
"source",
[]string{"dingtalk-a"},
[]string{filepath.Join(t.TempDir(), "skills")},
io.Discard,
io.Discard,
true,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installMultiSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
t.Run("mono multi-leftover scan failure", func(t *testing.T) {
failure := errors.New("scan denied")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) {
return nil, failure
})
installed, skipped, err := installSkillToHomes(
"source",
[]string{filepath.Join(t.TempDir(), "skills", "dws")},
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupMergedEventPlanEdges(t *testing.T) {
t.Run("legacy shared stat failure", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
failure := errors.New("legacy stat denied")
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(dest, legacySharedSkill) {
return nil, failure
}
return nil, os.ErrNotExist
})
_, err := buildSkillSetupPlan(
skillSetupModeMulti,
"source",
[]string{dest},
[]string{multiSharedSkill},
true,
)
if !errors.Is(err, failure) {
t.Fatalf("legacy shared stat error = %v, want %v", err, failure)
}
})
t.Run("migration plan retains unrelated backups", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
unrelated := filepath.Join(dest, "dws")
plan := &skillSetupPlan{Targets: []skillSetupTargetPlan{
{
Destination: dest,
Backups: []skillSetupBackup{
{Path: filepath.Join(dest, multiEventSkill)},
{Path: filepath.Join(dest, multiMiscSkill)},
{Path: unrelated},
},
},
}}
configureEventMiscMigrationPlan(plan, []string{dest}, true)
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != unrelated {
t.Fatalf("migration backups = %#v, want only %s", plan.Targets[0].Backups, unrelated)
}
})
t.Run("no migration targets delegates filtered install", func(t *testing.T) {
called := false
testseam.Swap(t, &skillSetupInstallMulti, func(_ string, _ []string, _ []string, _ io.Writer, _ io.Writer, filtered bool) (int, int, error) {
called = true
if !filtered {
t.Fatal("filtered flag was not forwarded")
}
return 1, 2, nil
})
installed, skipped, err := installMultiSkillsWithEventMigration(
"source", []string{"dingtalk-a"}, []string{"dest"}, nil, true, io.Discard, io.Discard,
)
if err != nil || installed != 1 || skipped != 2 || !called {
t.Fatalf("delegated install = (%d, %d, %v), called=%v", installed, skipped, err, called)
}
})
t.Run("migration cleanup scan failure", func(t *testing.T) {
failure := errors.New("cleanup stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
dest := filepath.Join(t.TempDir(), "skills")
installed, skipped, err := installMultiSkillsWithEventMigration(
"source",
[]string{multiEventSkill, multiSharedSkill},
[]string{dest},
[]string{dest},
true,
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 2 || !errors.Is(err, failure) {
t.Fatalf("cleanup scan failure = (%d, %d, %v), want (0, 2, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupEmptyCleanupPlansAreNoOps(t *testing.T) {
dest := t.TempDir()
var out, errOut bytes.Buffer
if err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut); err != nil {
t.Fatal(err)
}
if err := removeStaleMultiSkills(dest, []string{"dingtalk-a"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("empty cleanup output = %q / %q", out.String(), errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupSameNameBackupFailureSkipsTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
plan := &skillSetupPlan{
Mode: skillSetupModeMulti,
Source: "source",
MultiSkillNames: []string{"dingtalk-a"},
Targets: []skillSetupTargetPlan{{
Destination: dest,
Backups: []skillSetupBackup{{
Path: filepath.Join(dest, "dingtalk-a"),
Reason: skillSetupBackupReplace,
}},
}},
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
return "", errors.New("backup denied")
})
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
var out, errOut bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 || copyCalls != 1 {
t.Fatalf("same-name failure = (%d, %d, %v), copy=%d", installed, skipped, err, copyCalls)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("same-name warning = %q", errOut.String())
}
}
+190
View File
@@ -0,0 +1,190 @@
package app
import (
"errors"
"io"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillSetupPersistsOfficialSnapshot(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
return &skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-existing"}}}, true, nil
})
testseam.Swap(t, &skillSetupExecutePlan, func(plan *skillSetupPlan, _ io.Writer, _ io.Writer) (int, int, error) {
if plan.Mode == skillSetupModeMono {
return 1, 0, nil
}
return 2, 0, nil
})
testseam.Swap(t, &skillSetupNow, func() time.Time {
return time.Date(2026, 8, 10, 1, 2, 3, 0, time.UTC)
})
var saved skillstate.State
testseam.Swap(t, &skillSetupWriteState, func(_ string, state skillstate.State) error {
saved = state
return nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(saved.OfficialSkills, []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}) ||
!reflect.DeepEqual(saved.UpdatedSkills, []string{"dingtalk-shared", "dingtalk-a"}) ||
!reflect.DeepEqual(skillprovenance.Names(saved.ManagedSkills), map[string]bool{"dingtalk-a": true, "dingtalk-existing": true, "dingtalk-shared": true}) {
t.Fatalf("saved = %#v", saved)
}
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return errors.New("denied") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "信息快照失败") {
t.Fatalf("write-state error = %v", err)
}
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return errors.New("denied") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "清理 multi") {
t.Fatalf("remove-state error = %v", err)
}
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return nil })
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("no home") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "无法解析 HOME") {
t.Fatalf("home error = %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupFilteredUnreadableStateStopsBeforeInstall(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
stateErr := errors.New("state denied")
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) { return nil, false, stateErr })
executed := 0
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
executed++
return 1, 0, nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
err := cmd.RunE(cmd, nil)
if !errors.Is(err, stateErr) || executed != 0 {
t.Fatalf("filtered setup = err %v, executed %d", err, executed)
}
}
func TestCrossPlatformCoverageSkillSetupProvenancePreflightFailures(t *testing.T) {
for _, tc := range []struct {
name string
filtered bool
buildError error
homeError error
}{
{name: "digest", buildError: errors.New("digest denied")},
{name: "filtered home", filtered: true, homeError: errors.New("home denied")},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
if tc.buildError != nil {
return skillprovenance.Record{}, tc.buildError
}
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
if tc.homeError != nil {
return "", tc.homeError
}
return home, nil
})
executed := 0
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
executed++
return 1, 0, nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if tc.filtered {
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
}
err := cmd.RunE(cmd, nil)
if err == nil || executed != 0 {
t.Fatalf("preflight = err %v, executed %d", err, executed)
}
})
}
}
func TestCrossPlatformCoverageSkillSetupPartialInstallDoesNotWriteState(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) {
return []string{filepath.Join(home, "skills")}, nil
})
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
return 2, 1, nil
})
writes := 0
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
writes++
return nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
err := cmd.RunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "Skill 安装不完整") || !strings.Contains(err.Error(), "skipped=1") {
t.Fatalf("partial setup error = %v", err)
}
if writes != 0 {
t.Fatalf("partial setup wrote %d complete state snapshot(s)", writes)
}
}
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"path/filepath"
"github.com/spf13/cobra"
)
// detectNestedMultiSkillLayout detects the compatibility gap where an older
// running dws process replaces itself with a newer binary, but then installs
// the new release bundle with its legacy mono copier. That produces the
// impossible layout <agent>/dws/multi/<skill>/SKILL.md.
//
// Detection is deliberately read-only. Ordinary commands must not turn a
// compatibility warning into an unconfirmed, cross-Agent Skill refresh.
// A legitimate mono install has no dws/multi product tree and is ignored.
func detectNestedMultiSkillLayout() (bool, error) {
home, err := skillSetupUserHomeDir()
if err != nil {
return false, err
}
for _, rel := range skillSetupAgentHomes {
nested := filepath.Join(home, rel, "dws", "multi")
if isSkillSourceRoot(nested, skillSetupModeMulti) {
return true, nil
}
}
return false, nil
}
func shouldDetectNestedSkillLayout(cmd *cobra.Command) bool {
if cmd == nil {
return true
}
if cmd.Name() == "upgrade" {
return false
}
return !(cmd.Name() == "setup" && cmd.Parent() != nil && cmd.Parent().Name() == "skill")
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageStartupDetectsNestedUpgradeLayoutWithoutMutation(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
nested := filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
found, err := detectNestedMultiSkillLayout()
if err != nil || !found {
t.Fatalf("detect nested layout = (%v, %v), want (true, nil)", found, err)
}
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
if err != nil || string(data) != "old nested" {
t.Fatalf("detection changed nested Skill: data=%q err=%v", data, err)
}
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) {
t.Fatalf("detection unexpectedly installed a canonical Skill: %v", err)
}
}
func TestCrossPlatformCoverageStartupDetectionIgnoresMonoAndReportsReadFailure(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills"})
mono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(mono, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("valid mono"), 0o644); err != nil {
t.Fatal(err)
}
found, err := detectNestedMultiSkillLayout()
if err != nil || found {
t.Fatalf("valid mono detection = (%v, %v), want (false, nil)", found, err)
}
failure := errors.New("HOME failure")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
found, err = detectNestedMultiSkillLayout()
if found || !errors.Is(err, failure) {
t.Fatalf("HOME failure detection = (%v, %v)", found, err)
}
}
func TestCrossPlatformCoverageStartupDetectionSkipsExplicitSkillManagers(t *testing.T) {
upgradeCmd := &cobra.Command{Use: "upgrade"}
if shouldDetectNestedSkillLayout(upgradeCmd) {
t.Fatal("upgrade must manage its own Skill lifecycle")
}
skillCmd := &cobra.Command{Use: "skill"}
setupCmd := &cobra.Command{Use: "setup"}
skillCmd.AddCommand(setupCmd)
if shouldDetectNestedSkillLayout(setupCmd) {
t.Fatal("skill setup must manage its own Skill lifecycle")
}
if !shouldDetectNestedSkillLayout(&cobra.Command{Use: "version"}) || !shouldDetectNestedSkillLayout(nil) {
t.Fatal("ordinary commands must trigger read-only detection")
}
}
func TestCrossPlatformCoverageStartupWarningIsReadOnlyAndDoesNotBypassConfirmation(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".codex/skills"})
nested := filepath.Join(home, ".codex", "skills", "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
// Any accidental return to startup mutation must fail this test before it
// can touch the fixture.
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
t.Fatal("ordinary command attempted to copy Skills")
return nil
})
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
t.Fatal("ordinary command attempted to back up or remove Skills")
return "", nil
})
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
t.Fatal("ordinary command attempted to write Skill state")
return nil
})
root := newRootCommandWithEngine(context.Background(), nil, false, true)
cmd := &cobra.Command{Use: "version"}
cmd.SetContext(context.Background())
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := root.PersistentPreRunE(cmd, nil); err != nil {
t.Fatal(err)
}
warning := stderr.String()
if !strings.Contains(warning, "dws skill setup --mode multi") {
t.Fatalf("safe migration hint missing: %q", warning)
}
if strings.Contains(warning, "--yes") {
t.Fatalf("migration hint bypasses confirmation: %q", warning)
}
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
if err != nil || string(data) != "old nested" {
t.Fatalf("ordinary command changed nested Skill: data=%q err=%v", data, err)
}
}
+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())
+9 -5
View File
@@ -57,9 +57,9 @@ var (
downloadUpgradeProgress = upgrade.DownloadWithProgress
extractUpgradeZip = upgrade.ExtractZip
findExtractedBinary = upgrade.FindBinaryInDir
locateUpgradeSkill = upgrade.LocateSkillMD
locateUpgradeSkill = upgrade.LocateSkillsRoot
replaceUpgradeSelf = upgrade.ReplaceSelf
installUpgradeSkills = upgrade.UpgradeSkillLocations
installUpgradeSkills = upgrade.UpgradeSkillLocationsWithOptions
upgradeMkdirTemp = os.MkdirTemp
upgradeRemoveAll = os.RemoveAll
upgradeReadFile = os.ReadFile
@@ -99,7 +99,8 @@ func newUpgradeCommand() *cobra.Command {
Long: `检查并升级 DWS CLI 到最新版本。
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
升级前会自动备份当前版本,可通过 --rollback 回滚。
每次升级都会按新版本官方清单全量覆盖预制 Skill;--force 仅额外允许重装当前版本。`,
Example: ` dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出最近版本
@@ -107,6 +108,7 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade --beta # 升级到最新 beta 预发布版本
dws upgrade --version v1.0.7 # 升级到指定正式版本
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
dws upgrade --force # 即使已是最新版本也重装当前版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
dws upgrade -y # 跳过确认直接升级`,
@@ -158,7 +160,7 @@ func newUpgradeCommand() *cobra.Command {
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
cmd.Flags().BoolVar(&flagBeta, "beta", false, "使用最新 beta 预发布版本(默认使用正式 release)")
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "即使已是最新版本也强制重新安装当前版本")
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
return cmd
@@ -596,7 +598,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
}
if hasSkills {
result, installErr := installUpgradeSkills(skillSrc)
result, installErr := installUpgradeSkills(skillSrc, upgrade.SkillUpgradeOptions{
Version: release.Version,
})
if installErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包安装失败: %w", installErr)
+1 -1
View File
@@ -285,7 +285,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
}
return nil
}
installUpgradeSkills = func(string) (*upgradepkg.SkillUpgradeResult, error) {
installUpgradeSkills = func(string, upgradepkg.SkillUpgradeOptions) (*upgradepkg.SkillUpgradeResult, error) {
if stage == "install" {
return nil, fail
}
@@ -0,0 +1,156 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
// TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti is the fake-HOME E2E for
// the 2026-08-05 owner decision: upgrade is not disk-sticky. Seeding a mono
// layout then calling UpgradeSkillLocations with a multi bundle must install
// product skills, remove dws/, and leave non-DWS dirs alone.
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644); err != nil {
t.Fatal(err)
}
extract := t.TempDir()
multiRoot := filepath.Join(extract, "multi")
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
dir := filepath.Join(multiRoot, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
t.Fatal(err)
}
}
result, err := upgrade.UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("expected 0 failures, got %v", failed)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("mono leftover dws/ must be gone, stat err=%v", err)
}
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("multi skill missing: %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
}
// TestUpgradeSkillLocationsRealBundleMigratesMono runs the upgrade path against
// the repository's actual multi bundle (skills/multi, post-#887 layout with
// dingtalk-shared). Disk is seeded with a mono install plus the leftovers the
// rename and stale releases leave behind: a pre-rename dws-shared directory
// and a dingtalk-* skill no longer in the bundle. All three must be gone after
// the upgrade, every bundle skill installed, non-DWS dirs untouched, and the
// ~/.dws/skills/multi cache refreshed.
func TestCrossPlatformCoverageUpgradeSkillLocationsRealBundleMigratesMono(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
bundle := filepath.Join("..", "..", "skills", "multi")
entries, err := os.ReadDir(bundle)
if err != nil {
t.Fatalf("real multi bundle missing: %v", err)
}
var bundleNames []string
for _, e := range entries {
if e.IsDir() {
bundleNames = append(bundleNames, e.Name())
}
}
if len(bundleNames) == 0 {
t.Fatal("real multi bundle is empty")
}
agentsBase := filepath.Join(home, ".agents", "skills")
// Mono install plus pre-rename shared and a stale product skill.
for _, name := range []string{"dws", "dws-shared", "dingtalk-stale", "other-skill"} {
dir := filepath.Join(agentsBase, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
t.Fatal(err)
}
}
if err := skillstate.Write(home, skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-stale"}}}); err != nil {
t.Fatal(err)
}
custom := filepath.Join(agentsBase, "dingtalk-custom")
if err := os.MkdirAll(custom, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
t.Fatal(err)
}
result, err := upgrade.UpgradeSkillLocations(bundle)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("expected 0 failures, got %v", failed)
}
for _, stale := range []string{"dws", "dws-shared", "dingtalk-stale"} {
if _, err := os.Stat(filepath.Join(agentsBase, stale)); !os.IsNotExist(err) {
t.Errorf("leftover %q must be removed by the upgrade, stat err=%v", stale, err)
}
}
for _, name := range bundleNames {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("bundle skill %q missing after upgrade: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
if _, err := os.Stat(filepath.Join(custom, "SKILL.md")); err != nil {
t.Errorf("unregistered market/user dingtalk-* dir should be preserved: %v", err)
}
// Shared skill must come from the renamed bundle dir, never the legacy name.
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-shared", "SKILL.md")); err != nil {
t.Errorf("dingtalk-shared missing: %v", err)
}
// Cache refresh so `dws skill setup` fallbacks stay on the upgraded version.
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "SKILL.md")); err != nil {
// multi cache mirrors the bundle root, which has no top-level SKILL.md;
// check a bundle skill inside the cache instead.
if _, err2 := os.Stat(filepath.Join(home, ".dws", "skills", "multi", bundleNames[0], "SKILL.md")); err2 != nil {
t.Errorf("multi cache not refreshed: %v / %v", err, err2)
}
}
}
+21
View File
@@ -18,6 +18,27 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageUpgradeHelpDoesNotMakeFullSkillRefreshForceOnly(t *testing.T) {
cmd := newUpgradeCommand()
force := cmd.Flags().Lookup("force")
if force == nil {
t.Fatal("upgrade --force flag is missing")
}
for _, text := range []string{cmd.Example, force.Usage} {
if strings.Contains(text, "恢复全部官方 Skill") {
t.Fatalf("upgrade help still implies that only --force performs the full Skill refresh: %q", text)
}
}
if !strings.Contains(force.Usage, "已是最新版本") {
t.Fatalf("upgrade --force help must explain its remaining purpose, got %q", force.Usage)
}
for _, want := range []string{"每次升级", "全量覆盖预制 Skill", "--force 仅额外允许重装当前版本"} {
if !strings.Contains(cmd.Long, want) {
t.Fatalf("upgrade help missing full-refresh contract %q: %s", want, cmd.Long)
}
}
}
// --- ensureV ---
func TestEnsureV(t *testing.T) {
+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
+2 -2
View File
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
Long: `查看当前可运行命令的 Schema 元数据。
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束、安全语义和已评审的返回契约。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
},
}
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束、安全语义和返回契约")
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
return cmd
}
@@ -14,6 +14,7 @@
package cli
import (
"encoding/json"
"errors"
"strings"
"testing"
@@ -21,11 +22,13 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
cmd.Flags().String("mode", "", "usage")
runtimeannotate.AnnotateRuntimeFlag(cmd, "mode", "mode", "string", false)
@@ -35,6 +38,10 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
Effect: "write", Confirmation: "user_required", Idempotency: "none",
},
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string","description":"Created object ID"}}}`),
},
Selection: &contract.SelectionSpec{
AgentSummary: "from contract",
UseWhen: []string{"create things"},
@@ -68,6 +75,9 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
if spec.DryRun == nil || spec.DryRun.PreviewKind != contract.DryRunPreviewInvocation {
t.Fatalf("dry_run = %#v", spec.DryRun)
}
if spec.Result == nil || string(spec.Result.DataSchema) != `{"properties":{"id":{"type":"string","description":"Created object ID"}},"type":"object"}` {
t.Fatalf("result = %#v", spec.Result)
}
if spec.Selection.AgentSummary != "from contract" {
t.Fatalf("selection = %#v", spec.Selection)
}
@@ -79,6 +89,39 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
}
}
func TestRuntimeToolSpecHidesUnifiedResultForInactiveRollout(t *testing.T) {
for _, state := range []output.RolloutState{output.RolloutLegacyOnly, output.RolloutDualValidate} {
t.Run(string(state), func(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
output.SetCommandRollout(cmd, state)
cmd.Flags().String("cursor", "", "cursor")
runtimeannotate.AnnotateRuntimeFlag(cmd, "cursor", "cursor", "string", false)
final := contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "dev", Name: "list_things", CanonicalPath: "dev.list_things",
CLIPath: "dev list", PrimaryCLIPath: "dev list",
},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(`{"type":"object"}`),
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
}
entry := runtimeSchemaEntry{
ProductID: "dev", ToolName: "list_things", CLIName: "list",
CLIPath: "dev list", PrimaryCLIPath: "dev list", ProductName: "Dev", Command: cmd,
}
spec, err := runtimeToolSpecFromContractFinal(entry, final, runtimeSchemaMetadataSources{})
if err != nil {
t.Fatal(err)
}
if spec.Result != nil || spec.Pagination != nil {
t.Fatalf("inactive rollout published result=%#v pagination=%#v", spec.Result, spec.Pagination)
}
})
}
}
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalIdentityMismatchFails(t *testing.T) {
entry := runtimeSchemaEntry{
ProductID: "dev",
+8 -19
View File
@@ -2003,7 +2003,6 @@ var generatedParamAliases = []ParamAliasEntry{
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
@@ -2459,25 +2458,15 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "doc +version-list",
Aliases: map[string]string{
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"max-result": "limit",
"max-results": "limit",
"next-cursor": "cursor",
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
"take": "limit",
"top": "limit",
"url": "node",
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"node-id": "node",
"url": "node",
},
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
},
{
CLIPath: "doc +version-revert",
+1
View File
@@ -44,6 +44,7 @@
},
"command_overrides": {
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
"chat group members": {"bind": {"id": "open_conversation_id"}},
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
+3 -2
View File
@@ -1059,6 +1059,7 @@ var schemaCompactPayloadKeys = map[string]bool{
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"result": true, "pagination": true,
"examples": true, "use_when": true, "avoid_when": true,
}
@@ -1074,8 +1075,8 @@ var schemaCompactParamKeys = map[string]bool{
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
// Agent-view allowlist. Structural product/tool children are projected
// recursively; constraint, positional and dry-run values are already typed
// contract data and are retained verbatim.
// recursively; result, constraint, positional and dry-run values are already
// typed contract data and are retained verbatim.
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
if payload == nil {
return nil
+33
View File
@@ -78,7 +78,9 @@ var schemaCatalogToolOptionalKeys = []string{
"interface_reason",
"interface_ref",
"metadata_source",
"pagination",
"positionals",
"result",
}
var schemaCatalogToolEnums = map[string][]string{
@@ -237,6 +239,37 @@ func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[
}
validateCatalogInterface(toolID, entry, violations)
if result, exists := entry["result"]; exists {
if _, ok := result.(map[string]any); !ok {
report("field %q must be an object", "result")
}
}
if rawPagination, exists := entry["pagination"]; exists {
pagination, ok := rawPagination.(map[string]any)
if !ok {
report("field %q must be an object", "pagination")
} else {
want := map[string]string{
"kind": contract.PaginationKindCursor,
"meta_path": contract.PaginationMetaPath,
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
"next_token_path": contract.PaginationNextTokenPath,
}
for field, expected := range want {
if value, _ := pagination[field].(string); value != expected {
report("field %q.%s = %q, want %q", "pagination", field, value, expected)
}
}
cursor, _ := pagination["cursor_parameter"].(string)
if strings.TrimSpace(cursor) == "" {
report("field %q.cursor_parameter must be a non-empty string", "pagination")
} else if paramsOK {
if _, exists := parameters[cursor]; !exists {
report("field %q.cursor_parameter references missing parameter %q", "pagination", cursor)
}
}
}
}
for paramName, raw := range parameters {
param, ok := raw.(map[string]any)
if !ok {
@@ -18,6 +18,8 @@ import (
"fmt"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// TestDeliverySchemaCatalogStructure gates the delivered catalog: every tool
@@ -100,6 +102,90 @@ func TestValidateCatalogStructureAcceptsValidEntry(t *testing.T) {
}
}
func TestValidateCatalogStructureAcceptsOptionalResultObject(t *testing.T) {
entry := validCatalogToolEntry()
entry["result"] = map[string]any{
"outcomes": []any{"success", "failure"},
"data_schema": map[string]any{"type": "object"},
}
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
t.Fatalf("ValidateCatalogStructure() error = %v", err)
}
entry["result"] = "invalid"
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err == nil || !strings.Contains(err.Error(), `field "result" must be an object`) {
t.Fatalf("invalid result error = %v", err)
}
}
func TestValidateCatalogStructureAcceptsStandalonePagination(t *testing.T) {
entry := validCatalogToolEntry()
parameters := entry["parameters"].(map[string]any)
parameters["cursor"] = map[string]any{
"description": "续页游标",
"field_provenance": map[string]any{},
"required": false,
"type": "string",
}
entry["parameter_count"] = float64(len(parameters))
entry["has_parameters"] = true
entry["pagination"] = map[string]any{
"kind": contract.PaginationKindCursor,
"cursor_parameter": "cursor",
"meta_path": contract.PaginationMetaPath,
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
"next_token_path": contract.PaginationNextTokenPath,
}
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
t.Fatalf("ValidateCatalogStructure() error = %v", err)
}
entry["pagination"].(map[string]any)["next_token_path"] = "data.nextCursor"
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err == nil || !strings.Contains(err.Error(), "next_token_path") {
t.Fatalf("invalid pagination error = %v", err)
}
}
func TestValidateCatalogStructureRejectsMalformedStandalonePagination(t *testing.T) {
validPaginationEntry := func() map[string]any {
entry := validCatalogToolEntry()
parameters := entry["parameters"].(map[string]any)
parameters["cursor"] = map[string]any{
"description": "续页游标",
"field_provenance": map[string]any{},
"required": false,
"type": "string",
}
entry["parameter_count"] = float64(len(parameters))
entry["pagination"] = map[string]any{
"kind": contract.PaginationKindCursor,
"cursor_parameter": "cursor",
"meta_path": contract.PaginationMetaPath,
"endpoint_exhausted_path": contract.PaginationExhaustedPath,
"next_token_path": contract.PaginationNextTokenPath,
}
return entry
}
for _, tc := range []struct {
name string
mutate func(map[string]any)
want string
}{
{"not an object", func(entry map[string]any) { entry["pagination"] = "cursor" }, `field "pagination" must be an object`},
{"empty cursor", func(entry map[string]any) { entry["pagination"].(map[string]any)["cursor_parameter"] = " " }, "cursor_parameter must be a non-empty string"},
{"unknown cursor", func(entry map[string]any) { entry["pagination"].(map[string]any)["cursor_parameter"] = "page-token" }, "references missing parameter"},
} {
t.Run(tc.name, func(t *testing.T) {
entry := validPaginationEntry()
tc.mutate(entry)
err := ValidateCatalogStructure(catalogPayload(t, entry))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("ValidateCatalogStructure() error = %v, want %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageValidateCatalogStructureRejectsViolations(t *testing.T) {
cases := []struct {
name string
+2
View File
@@ -1356,6 +1356,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
{"chat message edit", "conversation-id", "openConversationId", true, ""},
{"chat message edit", "msg-id", "openMessageId", true, ""},
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message send-card", "at-all", "atAll", false, ""},
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
-33
View File
@@ -33,8 +33,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"auth status",
"completion",
"config list",
"dev connect list",
"dev connect restart",
"doctor",
"plugin build",
"plugin config get",
@@ -93,40 +91,9 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"calendar acl add",
"calendar acl delete",
"calendar book update",
"chat category add-conv",
"chat category create",
"chat category delete",
"chat category remove-conv",
"chat category rename",
"chat chmod",
"chat clear-all-red-point",
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
"chat group members list-by-ids",
"chat group notice create",
"chat group notice edit",
"chat group notice get",
"chat group notice list",
"chat group share-invite",
"chat group update-alias",
"chat hide",
"chat list-all-conversations",
"chat mark-read",
"chat mark-unread",
"chat message list-emotion-replies",
"chat message set-top-msg",
"chat message unset-top-msg",
"chat mute-at-all",
"chat mute-red-envelope",
"chat text translate",
"contact label get",
"contact label list",
"contact label list-members",
"dev app version check-approval",
"ding message list",
"ding message recall-personal",
"ding message receiver-status",
@@ -38,20 +38,33 @@ func init() {
registerRequireTogether("calendar.update_calendar_event", "recurrence-type", "recurrence-interval", "recurrence-range-type")
registerExclusiveOneOf("chat.search_messages_by_sender", "sender-user-id", "sender-open-dingtalk-id")
registerExclusiveOneOf("chat.create_and_send_card", "group", "receiver")
RegisterRuntimeSchemaConstraints("chat.chat_permission_grant", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"conversation-id", "open-dingtalk-id", "user"}},
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
})
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
registerExclusiveOneOf("chat.share_group_invite_url", "target", "receiver")
registerExclusiveOneOf("chat.transfer_group_owner", "new-owner", "user")
registerRequireOneOf("chat.update_conv_member_roles", "users", "user")
registerExclusiveOneOf("chat.update_at_all_notification_off", "conversation-id", "id", "chat")
registerRequireOneOf("chat.update_notification_off", "conversation-id", "id", "chat")
registerExclusiveOneOf("chat.update_red_env_notification_off", "conversation-id", "id", "chat")
registerRequireTogether("contact.query_dismission_employee_list", "start", "end")
registerRequireOneOf("dev.connect_status", "robot-client-id", "unified-app-id")
registerRequireOneOf("dev.connect_stop", "robot-client-id", "unified-app-id")
+39 -1
View File
@@ -60,6 +60,8 @@ type ToolSpec struct {
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
@@ -133,6 +135,8 @@ type RuntimeToolSpecInput struct {
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
@@ -538,6 +542,20 @@ func (t ToolSpec) Validate() error {
return err
}
}
if t.Result != nil {
if _, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath); err != nil {
return err
}
}
if t.Pagination != nil {
pagination, err := contract.NormalizePaginationSpec(t.Pagination, id.CanonicalPath)
if err != nil {
return err
}
if !seen[pagination.CursorParameter] {
return fmt.Errorf("tool %s pagination cursor_parameter %q is not a declared parameter", id.CanonicalPath, pagination.CursorParameter)
}
}
if t.Interface.Mode != "" || t.Interface.Availability != "" || t.Interface.Reason != "" || t.Interface.Ref != nil {
if err := t.Interface.Validate(id.CanonicalPath); err != nil {
return err
@@ -726,6 +744,18 @@ func (t ToolSpec) normalized() ToolSpec {
dryRun.PreviewKind = strings.TrimSpace(dryRun.PreviewKind)
out.DryRun = &dryRun
}
if t.Result != nil {
result, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath)
if err == nil {
out.Result = result
}
}
if t.Pagination != nil {
pagination, err := contract.NormalizePaginationSpec(t.Pagination, id.CanonicalPath)
if err == nil {
out.Pagination = pagination
}
}
out.Positionals = append([]contract.RuntimeSchemaPositional(nil), t.Positionals...)
sort.Slice(out.Positionals, func(i, j int) bool {
if out.Positionals[i].Index != out.Positionals[j].Index {
@@ -952,6 +982,14 @@ func (t ToolSpec) ToPayload() (map[string]any, error) {
value, _ := typedJSONValue(t.DryRun)
payload["dry_run"] = value
}
if t.Result != nil {
value, _ := typedJSONValue(t.Result)
payload["result"] = value
}
if t.Pagination != nil {
value, _ := typedJSONValue(t.Pagination)
payload["pagination"] = value
}
applySafetyPayload(payload, t.Safety)
applyInterfacePayload(payload, t.Interface)
applySelectionPayload(payload, t.Selection, true)
@@ -975,7 +1013,7 @@ func (t ToolSpec) ToSummaryPayload() (map[string]any, error) {
}
for _, key := range []string{
"parameters", "has_parameters", "parameter_count", "constraints",
"positionals", "examples", "effect_source", "agent_source_refs",
"positionals", "result", "examples", "effect_source", "agent_source_refs",
"field_provenance", "path", "source", "product_id", "display", "is_alias",
} {
delete(payload, key)
@@ -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",
+152
View File
@@ -0,0 +1,152 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestResultContractModelWireRoundTripAndCompactPolicy(t *testing.T) {
result := &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(`{ "type":"object", "properties":{"items":{"type":"array","description":"Business result records","items":{"type":"object"}}} }`),
SensitivePaths: []string{"items.secret", "credential"},
}
spec, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "list", CLIName: "list", CLIPath: "dev list"},
Parameters: []ParameterSpec{{Name: "cursor", Type: "string"}},
Result: result,
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
})
if err != nil {
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
}
if got, want := spec.Result.Outcomes, []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}; !reflect.DeepEqual(got, want) {
t.Fatalf("outcomes = %#v, want %#v", got, want)
}
result.Outcomes[0] = contract.ResultOutcomePending
result.DataSchema[0] = '['
if spec.Result.Outcomes[0] != contract.ResultOutcomeSuccess || spec.Result.DataSchema[0] != '{' {
t.Fatal("ToolSpec result aliases runtime input")
}
payload, err := spec.ToPayload()
if err != nil {
t.Fatalf("ToPayload() error = %v", err)
}
resultPayload, ok := payload["result"].(map[string]any)
if !ok || schemaString(resultPayload["data_schema"].(map[string]any)["type"]) != "object" {
t.Fatalf("result payload = %#v", payload["result"])
}
if _, exists := specResultSummary(t, spec)["result"]; exists {
t.Fatal("result must remain full-leaf-only")
}
compactResult, exists := stripSchemaPayloadCompact(payload)["result"].(map[string]any)
if !exists {
t.Fatal("compact leaf must include the reviewed result contract")
}
if outcomes, ok := compactResult["outcomes"].([]any); !ok || len(outcomes) != 2 {
t.Fatalf("compact result outcomes = %#v", compactResult["outcomes"])
}
if dataSchema, ok := compactResult["data_schema"].(map[string]any); !ok || schemaString(dataSchema["type"]) != "object" {
t.Fatalf("compact result data_schema = %#v", compactResult["data_schema"])
}
if !reflect.DeepEqual(compactResult, resultPayload) {
t.Fatalf("compact result must equal full-leaf result\ncompact: %#v\nfull: %#v", compactResult, resultPayload)
}
compactPagination, exists := stripSchemaPayloadCompact(payload)["pagination"].(map[string]any)
if !exists || schemaString(compactPagination["meta_path"]) != contract.PaginationMetaPath || schemaString(compactPagination["cursor_parameter"]) != "cursor" {
t.Fatalf("compact pagination = %#v", compactPagination)
}
wire, err := schemaToolWireFromPayload(payload)
if err != nil {
t.Fatalf("schemaToolWireFromPayload() error = %v", err)
}
roundTrip, err := schemaToolSpecFromWire(wire)
if err != nil {
t.Fatalf("schemaToolSpecFromWire() error = %v", err)
}
roundTripPayload, err := roundTrip.ToPayload()
if err != nil {
t.Fatalf("round-trip ToPayload() error = %v", err)
}
if !schemaJSONEqual(payload, roundTripPayload) {
t.Fatalf("result changed across wire round-trip\nfirst: %#v\nround: %#v", payload["result"], roundTripPayload["result"])
}
}
func specResultSummary(t *testing.T, spec ToolSpec) map[string]any {
t.Helper()
payload, err := spec.ToSummaryPayload()
if err != nil {
t.Fatalf("ToSummaryPayload() error = %v", err)
}
return payload
}
func TestToolWithoutResultKeepsResultAbsent(t *testing.T) {
spec, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "legacy", CLIName: "legacy", CLIPath: "dev legacy"},
})
if err != nil {
t.Fatal(err)
}
payload, err := spec.ToPayload()
if err != nil {
t.Fatal(err)
}
if _, exists := payload["result"]; exists {
t.Fatal("tool without Result gained a result key")
}
}
func TestToolSpecRejectsInvalidResultInsteadOfDroppingIt(t *testing.T) {
_, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
Identity: contract.ToolIdentitySpec{ProductID: "dev", Name: "invalid", CLIName: "invalid", CLIPath: "dev invalid"},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(`[]`),
},
})
if err == nil {
t.Fatal("invalid result schema was silently dropped")
}
}
func TestToolSpecRejectsInvalidOrUndeclaredPaginationCursor(t *testing.T) {
identity := contract.ToolIdentitySpec{ProductID: "dev", Name: "list", CLIName: "list", CLIPath: "dev list"}
for _, tc := range []struct {
name string
parameters []ParameterSpec
pagination *contract.PaginationSpec
want string
}{
{
name: "invalid pagination declaration",
parameters: []ParameterSpec{{Name: "cursor", Type: "string"}},
pagination: &contract.PaginationSpec{Kind: "offset", CursorParameter: "cursor"},
want: "unsupported kind",
},
{
name: "cursor is not a parameter",
pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
want: "is not a declared parameter",
},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := ToolSpecFromRuntime(RuntimeToolSpecInput{
Identity: identity, Parameters: tc.parameters, Pagination: tc.pagination,
})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("ToolSpecFromRuntime() error = %v, want %q", err, tc.want)
}
})
}
}

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