Compare commits

...
Author SHA1 Message Date
赤川 71556a19d9 Merge branch 'main' into codex/fix-command-typo-guidance 2026-08-25 09:50:28 +08:00
github-actions[bot] 69f1337316 Merge pull request #1109 from DingTalk-Real-AI/codex/dev-devapp-agoal-shortcuts
feat(shortcut): harden DevApp and Agoal task surfaces
2026-08-24 22:55:59 +00:00
Dennis 01476727e9 feat(shortcut): harden DevApp and Agoal task surfaces 2026-08-25 02:36:18 +08:00
chichuan 61ffc988c5 test: cover command resolution edge cases 2026-08-25 01:33:52 +08:00
chichuan 156be4cee7 fix: preserve guarded flag values during command resolution 2026-08-25 00:50:05 +08:00
chichuan 2a42e282c7 fix: align typo guidance with command framework 2026-08-25 00:50:03 +08:00
chichuan f5378d2f28 test: cover typo guidance CI edges 2026-08-25 00:50:01 +08:00
chichuan ea4a463956 fix: add bounded command typo guidance 2026-08-25 00:49:59 +08:00
github-actions[bot] 8d34acbb38 Merge pull request #1130 from DingTalk-Real-AI/codex/fix-todo-upstream-internal-error
fix(todo): classify upstream failures and validate inputs
2026-08-25 00:44:39 +08:00
克谨 9401f9921a merge: sync latest main 2026-08-25 00:09:27 +08:00
github-actions[bot] ed951ff0e1 chore: update beta formula for v1.0.60-beta.2 [skip ci] 2026-08-24 15:57:43 +00:00
赤川 b42596214d Merge pull request #1133 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.2-amendment
docs: amend v1.0.60-beta.2 release notes
2026-08-24 23:42:06 +08:00
chichuan f684c412ea docs: amend v1.0.60-beta.2 release notes 2026-08-24 23:37:21 +08:00
克谨 a4813f78bc fix(todo): preserve priority schema compatibility 2026-08-24 23:33:43 +08:00
赤川 415d962371 Merge pull request #1131 from DingTalk-Real-AI/codex/fix-pretag-beta-seal-amendment
fix(policy): allow audited pre-tag beta amendments
2026-08-24 23:13:59 +08:00
chichuan 26253a4eeb fix(policy): allow audited pre-tag beta amendments 2026-08-24 23:11:30 +08:00
克谨 46fdaed90b Merge remote-tracking branch 'origin/main' into codex/fix-todo-upstream-internal-error 2026-08-24 23:10:17 +08:00
克谨 9ea19315e9 fix(todo): classify upstream failures and validate inputs 2026-08-24 23:04:45 +08:00
github-actions[bot] 8549a90402 Merge pull request #1127 from DingTalk-Real-AI/codex/param-hallucination-6-products
feat(cli): govern six product parameter aliases
2026-08-24 22:59:59 +08:00
克谨 a5dbd2e049 Merge remote-tracking branch 'origin/main' into codex/param-hallucination-6-products 2026-08-24 22:33:48 +08:00
github-actions[bot] 0ebdcb455d Merge pull request #1105 from maoqxxmm/codex/sheet-float-image-local-file
feat(sheet): support local files for float images
2026-08-24 14:30:48 +00:00
克谨 b1a6f9e20c Merge remote-tracking branch 'origin/main' into codex/param-hallucination-6-products 2026-08-24 22:03:15 +08:00
xiatian 29a0dde9d4 Merge remote-tracking branch 'upstream/main' into codex/sheet-float-image-local-file 2026-08-24 22:02:38 +08:00
克谨 0fae9dd8b3 feat(cli): govern six product parameter aliases 2026-08-24 22:01:10 +08:00
赤川 6096227511 Merge pull request #1126 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.2
chore(release): prepare v1.0.60-beta.2
2026-08-24 22:00:23 +08:00
chichuan aa265acd50 chore(release): prepare v1.0.60-beta.2 2026-08-24 21:46:54 +08:00
赤川 7210e5677c Merge pull request #1121 from hlzjsong/refresh_legacy_slot_fix
recover identity refresh from legacy global slot
2026-08-24 21:38:58 +08:00
赤川 1c90623e31 Merge branch 'main' into refresh_legacy_slot_fix 2026-08-24 21:38:34 +08:00
github-actions[bot] e7236e27d4 Merge pull request #1101 from zh-tinghe/feat/migrate-edu-contact
feat: add edu & college vendor extension commands
2026-08-24 13:33:58 +00:00
赤川 e73ebab2db Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 21:03:27 +08:00
xiatian 1b6a592387 Merge remote-tracking branch 'upstream/main' into codex/sheet-float-image-local-file 2026-08-24 20:59:28 +08:00
github-actions[bot] ce7f66ff8e Merge pull request #1117 from maoqxxmm/codex/schema-compat-float-image-one-of
fix(schema): review float image constraint transition
2026-08-24 12:46:49 +00:00
赤川 e58f255476 Merge branch 'main' into refresh_legacy_slot_fix 2026-08-24 20:28:19 +08:00
赤川 79f4f66d34 Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 20:25:16 +08:00
听荷 66061bb0b0 fix(college-contact): 移除可复制破坏性示例中的确认绕过参数并补齐契约示例必填参数
CR P1-1:15 个 user_required 叶子的 Cobra Example 携带 -y/--yes,
Agent 或用户直接复制即绕过本 PR 实现的用户确认门。改为只保留
--dry-run 预览示例(含 -f json 变体),确认参数由调用方在取得
用户明确确认后自行追加。

CR P1-2:create-group-rule 的 Contract.Selection.Examples 缺少必填
--dept-type,示例实际执行会在参数校验阶段失败。同类缺陷在本文件另有
6 处(dept create / employee add / alumni list / alumni add-alumnus /
alumni update-alumnus / graduate batch-update-pending),一并对齐到
各自 Cobra Example;另修正 dept update、dept batch-update-type 契约
示例中 dept-type 取值与 Cobra 示例不一致的问题(standard -> college)。

验证:5 个产品共 153 条 Contract.Selection.Examples 全部 --dry-run
可执行(153/153 ok);changed code coverage 100.0000%。
2026-08-24 20:23:21 +08:00
xiatian 1d14258121 Merge remote-tracking branch 'upstream/main' into codex/schema-compat-float-image-one-of 2026-08-24 20:15:41 +08:00
muling.cs cbbc9a9f90 refresh token legacy_recovery 2026-08-24 20:14:36 +08:00
github-actions[bot] 723d43f660 Merge pull request #1056 from AlwaysLee/feat/drive-permission-get-setting
feat(drive): add permission get-setting command
2026-08-24 12:09:36 +00:00
半圭 12d60d264d fix(drive): address review feedback on permission get-setting
- drop shareScope.required=["linkShare"] from the result schema: the
  production response omits linkShare unless link sharing is configured;
  document the conditional presence in the schema description, tests,
  and both skill references
- spell out policy value tiers in skill docs (READER_AND_ABOVE /
  DOWNLOADER_AND_ABOVE / EDITOR_AND_ABOVE / MANAGER_AND_ABOVE) so the
  shorthand no longer diverges from the schema enums
2026-08-24 19:46:13 +08:00
半圭 7bb2019dfe feat(drive): expose disabledValues with per-value reasons in get-setting schema 2026-08-24 19:46:13 +08:00
半圭 9e692ce0bf docs(drive): align get-setting semantics with final GUI wording
Sync the get-setting schema descriptions and skill references with the
server-side finalized policy texts: policy code semantics now quote the
product permission-settings page labels (e.g. external_share=add
external collaborators, watermark=show watermark, node_spread_scope=
download and distribution scope), the node_spread_scope binary values
read ALL_NODES=all documents and PREVIEWABLE_ONLY=previewable documents
only (online documents, images, videos, etc.), and the contract test
value-semantic fragments follow the same wording. Both mono and multi
drive references also list per-policy name/description examples
(external_share, node_spread with NOBODY, node_move_forbidden) quoted
verbatim from the server i18n single source.
2026-08-24 19:46:13 +08:00
半圭 e2b390217a feat(drive): align get-setting schema with self-explaining policy values
Sync the get_permission_setting result contract with the finalized
server-side enums: switch values ON/OFF become ENABLED/DISABLED and the
node_spread_scope binary ALL_CONTENT becomes ALL_NODES (restriction
applies to all nodes vs previewable-only nodes), while policy codes,
threshold domains, permissionMode and shareScope stay unchanged.
Policies now carry per-entry name and description fields (Chinese label
and value semantics from the server i18n single source) as deterministic
required members, mirrored in the contract test assertions and both mono
and multi drive skill quick references.
2026-08-24 19:46:13 +08:00
半圭 09ecaaa7a1 feat(drive): document get-setting response schema and field semantics
Declare the drive.permission get_setting ResultSpec data schema from the
server-side output metadata so the field-level contract (permissionMode,
shareScope, policy codes and their typed value domains, inherited /
allowedValues semantics) is reviewed code guarded by contract tests, and
add field-quick-reference rows for the get-setting section in both mono
and multi drive skill references.
2026-08-24 19:46:13 +08:00
半圭 95ecc99c41 fix: route drive permission get-setting to drive MCP server 2026-08-24 19:46:13 +08:00
半圭 6df6b3f8dd docs(release): use PR-numbered fragment name for drive permission get-setting 2026-08-24 19:46:13 +08:00
半圭 227861a71d feat(drive): add permission get-setting command 2026-08-24 19:46:13 +08:00
Dennis4477andDennis 20a01aaeff policy: support compatibility-visible availability migrations (#1123)
Co-authored-by: Dennis <xinyang.dxy@alibaba-inc.com>
2026-08-24 19:37:21 +08:00
赤川 7844b59a8c Merge branch 'main' into refresh_legacy_slot_fix 2026-08-24 19:08:53 +08:00
听荷 2a875eee1d Merge remote-tracking branch 'upstream/main' into feat/migrate-edu-contact 2026-08-24 19:05:05 +08:00
Dennis4477 c75eda1a2b Merge pull request #1118 from DingTalk-Real-AI/codex/devapp-availability-migration-plan
policy: plan DevApp availability hardening migration
2026-08-24 18:49:08 +08:00
听荷 662352202c docs(skills): complete edu-app command surface and fix dead cross-product ref
Skill 参考此前只描述了 edu-app 42 条叶子里的 16 条,homework(11)/diploma(9)
与 notice 的 6 条全部缺失,导致 Agent 把"作业/通知"一律路由到 task 与
notice confirm;三条 user_required 破坏性命令(notice/homework/diploma
delete)也落在未记录区间,PR 里的确认门禁说明因此无法被文档兑现。
edu-familygroup 的上下文传递表引用了不存在的 edu-contact family list,
真正返回家长信息的是 family parents。

check-skill-commands.sh 只校验以 `dws ` 开头的反引号片段,因此上述两处
死引用能在门禁全绿的情况下存活;本次给跨产品引用统一补上 dws 前缀,
让该门禁从现在起覆盖它们。

同时修正扫描测试的陈旧计数(153→156、edu-contact 26→29、class 18→19,
以 DeclareLeafMetadata 声明数为准),并给 edu-familygroup 第 6 条叶子
manage add-child 补上工具名与整体入参的精确断言。
2026-08-24 18:33:08 +08:00
xiatian fa2d54fd42 Merge remote-tracking branch 'upstream/main' into codex/schema-compat-float-image-one-of 2026-08-24 18:29:05 +08:00
Dennis c5d58ec49d policy: plan devapp shortcut availability hardening 2026-08-24 18:27:12 +08:00
github-actions[bot] adbc9aac3e Merge pull request #1120 from DingTalk-Real-AI/codex/param-hallucination-8-products
feat(cli): optimize parameter aliases for eight products
2026-08-24 18:25:44 +08:00
xiatian 82a87bf9ce Merge remote-tracking branch 'upstream/main' into codex/schema-compat-float-image-one-of 2026-08-24 18:24:13 +08:00
muling.cs 0bfb1ff1de chore: add release fragment for legacy global slot recovery 2026-08-24 18:11:07 +08:00
muling.cs 426ad50927 refresh use legacy global slot 2026-08-24 18:06:05 +08:00
克谨 b57cecb62d feat(cli): optimize eight shortcut parameter aliases 2026-08-24 17:51:45 +08:00
Dennis4477 8dc52d052c Merge pull request #1113 from DingTalk-Real-AI/codex/devapp-availability-compat-governance
policy: add consumable Schema availability migrations
2026-08-24 17:39:07 +08:00
xiatian f8e3f0b12a fix(schema): review float image constraint transition 2026-08-24 17:09:11 +08:00
听荷 aab67202a0 Merge remote-tracking branch 'origin/feat/migrate-edu-contact' into feat/migrate-edu-contact 2026-08-24 17:02:24 +08:00
听荷 2e60b8e121 test: pair confirm-gate and exact-dispatch assertions for all destructive edu leaves
Auto-CR flagged that the destructive-command tests only checked the error
string, so a regression that let a command slip past the confirmation gate
(or dispatch the wrong payload) would go unnoticed.

Every user_required leaf across college-contact (15), edu-contact (2),
edu-group (1) and edu-app (3) is now verified in pairs with a fresh
non-dry-run capture caller: without explicit confirmation the gate must
return confirmation_required AND the caller must see zero calls; with
--yes the command must produce exactly one call carrying the correct
productID, tool name and complete argument payload.
2026-08-24 17:01:57 +08:00
Dennis 6147021ae8 policy: add consumable schema availability migrations 2026-08-24 16:47:19 +08:00
赤川 f84fc1d684 Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 16:46:40 +08:00
赤川 ae31d371de Merge pull request #1114 from DingTalk-Real-AI/codex/fix-policy-timeout-governance
ci: extend Policy job timeout for large Schema surfaces
2026-08-24 16:46:22 +08:00
chichuan c20a53cd8a ci: extend Policy job timeout 2026-08-24 16:43:10 +08:00
xiatian 2dc52afd60 fix(sheet): align float image schema constraints 2026-08-24 16:36:12 +08:00
赤川 21bca3025f Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 16:07:50 +08:00
听荷 874b5b091a Merge remote-tracking branch 'upstream/main' into feat/migrate-edu-contact 2026-08-24 15:58:52 +08:00
github-actions[bot] 1bb56364c2 Merge pull request #1111 from DingTalk-Real-AI/codex/ci-windows-coverage-governance
ci: bound exhaustive coverage test runtimes
2026-08-24 15:54:27 +08:00
赤川 cd93b7fdc2 Merge branch 'main' into feat/migrate-edu-contact 2026-08-24 15:34:40 +08:00
克谨 758b0f875e ci: bound exhaustive coverage test runtimes 2026-08-24 15:28:00 +08:00
xiatian fdb5fc19f5 test(sheet): cover float image redirect rejection 2026-08-24 14:48:12 +08:00
xiatian 583d31141d feat(sheet): support local files for float images 2026-08-24 14:48:12 +08:00
github-actions[bot] 19be571574 Merge pull request #1107 from DingTalk-Real-AI/codex/devdoc-hrbrain-pat-shortcuts
feat(shortcut): harden Devdoc HRbrain and PAT surfaces
2026-08-24 14:24:48 +08:00
听荷 38360969e7 style: gofmt college_contact files 2026-08-24 13:36:31 +08:00
听荷 f9277ae1d3 fix: use standard confirmation framework for college-contact destructive commands
Remove custom collegeContactConfirmDestructive function and rely on the
standard framework Confirmation: "user_required" in LeafSpec. This ensures
destructive commands return the proper category:validation/code:3 error
instead of category:internal/code:5.

Also adds run_all_edu_commands_test.go covering all 153 leaf commands
across 5 edu/college products.
2026-08-24 13:30:32 +08:00
听荷 232d9dee6e fix: remove edu products from command matrix to avoid Windows timeout
The command matrix test with 5 additional edu products (153 leaf commands)
exceeds the 10-minute go test timeout on Windows CI runners. The dedicated
TestCrossPlatformCoverage* tests in individual edu test files already
provide 100% changed-code coverage without the matrix.
2026-08-24 13:30:32 +08:00
听荷 c2528f1fc8 fix: add CrossPlatformCoverage prefix to edu tests for platform gate
The CI platform coverage gate (macOS/Windows) only executes tests
matching ^(TestAllShortcuts|TestCrossPlatformCoverage). The edu/college
test functions used standard names and were not exercised during the
platform coverage run, causing 73.89% changed-code coverage (target 100%).

Changes:
- Rename all edu/college test functions with TestCrossPlatformCoverage prefix
- Add edu products to command_matrix_test.go selected map
- Add TestCrossPlatformCoverageOpenSupplementServersIncludesEduEndpoints
  in pkg/edition to cover the new supplement server entries

Local verification: coverage-gate-platform reports 100.0000% (3336 stmts).
2026-08-24 13:30:32 +08:00
听荷 1b22b79fa9 fix: replace --help examples with real execution examples in college-contact
The schema catalog validator requires Examples to demonstrate actual
execution, not just --help. Replace all 65 occurrences with realistic
parameter examples derived from each command's required flags.
2026-08-24 13:30:32 +08:00
听荷 f3e98d55e8 feat: add edu & college vendor extension commands
Add five hidden vendor extension commands for education scenarios:
- edu-contact: school/class/family/teacher contact management
- edu-group: student/class group lifecycle
- edu-app: homework, notices, report cards, diplomas, class circles
- edu-familygroup: family group management, child binding, app permissions
- college-contact: university dept/employee/alumni/graduate management

All route to dedicated MCP servers via callMCPToolOnServer with endpoints
registered in openSupplementServers (helper-only, not VisibleProducts).

Includes: helper implementations, dispatch tests, mono/multi skill docs,
coverage.yaml registration, context-budget compliance, and .changes fragment.
2026-08-24 13:30:32 +08:00
Dennis b5a287ae71 feat(shortcut): harden devdoc hrbrain and pat surfaces 2026-08-24 13:10:38 +08:00
github-actions[bot] da6f867dfa Merge pull request #1085 from typefield/feat/drive-permission-pagination
feat(drive,doc,wiki): permission/member list pagination and multi-type members
2026-08-24 12:17:02 +08:00
zengyouling.zyl 82dc2b5e5e Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-24 11:57:48 +08:00
zengyouling.zyl 9265fd4cb8 fix(skill): point wiki member pagination at native wiki member list 2026-08-24 11:57:36 +08:00
github-actions[bot] e324ef9d4a Merge pull request #1069 from WHUTzju/feat/add-aitable-datasource-tools
Feat/add aitable datasource tools
2026-08-24 11:33:54 +08:00
zengyouling.zyl fb1847d62e Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-24 11:15:26 +08:00
zengyouling.zyl 331681e82b ci: retrigger auto-cr to pick up screenshot evidence 2026-08-24 11:05:14 +08:00
陌渊 1633888290 Merge upstream/main: resolve shortcut count conflict + fix field-ids doc
- schemaPublishedShortcutCount: 461→468 after merging aisearch/contact/live
  shortcuts from upstream/main
- Fix P2: datasource-update --field-ids doc says "不传时同步全部字段" but
  actual behavior keeps existing field config; fixed in usage guide and
  reference
2026-08-24 10:54:58 +08:00
github-actions[bot] 206f33ae1c Merge pull request #1083 from DingTalk-Real-AI/codex/shortcut-aisearch-contact-live
feat(shortcut): harden AiSearch Contact and Live task surfaces
2026-08-24 10:44:39 +08:00
陌渊 9259477372 [WP-46-001] fix: update shortcut count constants to match merged sheet/whiteboard shortcuts
publicShortcutCount 422→424, schemaPublishedShortcutCount 460→462,
publiclyDeliveredShortcutCount 422→424
2026-08-24 10:31:32 +08:00
Dennis 137151b38c fix(shortcut): align reviewed live response shapes 2026-08-24 09:57:54 +08:00
陌渊 081220f15e Merge branch 'main' into feat/add-aitable-datasource-tools 2026-08-24 09:50:50 +08:00
Dennis b8216380de fix(aisearch): declare stable result identity 2026-08-23 20:35:11 +08:00
Dennis 83bc213b7f fix(aisearch): reject non-person search sources 2026-08-23 19:40:27 +08:00
Dennis 804b9a3142 fix(contact): normalize exact mobile lookup input 2026-08-23 19:40:26 +08:00
Dennis cfdb0d0556 fix(contact): preserve legacy role placeholders 2026-08-23 19:40:24 +08:00
Dennis d8686122ab fix(schema): reconcile shortcut counts after rebase 2026-08-23 19:40:21 +08:00
Dennis 222a0230ae fix(contact): preserve strict list roles compatibility 2026-08-23 19:40:18 +08:00
Dennis f7befc7943 fix(contact): preserve roster CLI compatibility 2026-08-23 19:40:15 +08:00
Dennis 1dc924af1b fix(contact): align mobile catalog semantics 2026-08-23 19:40:12 +08:00
Dennis 0e8d6e00cf fix(contact): avoid unnecessary mobile detail lookup 2026-08-23 19:40:09 +08:00
Dennis 0028ed1570 fix(contact): restore exact mobile lookup 2026-08-23 19:40:06 +08:00
Dennis 8b5d9a59b0 fix(contact): preserve published schema interface 2026-08-23 19:40:03 +08:00
Dennis 01d663f597 fix(contact): verify exact mobile ownership 2026-08-23 19:40:01 +08:00
Dennis 2bf314c625 fix(contact): preserve list-roles CLI visibility 2026-08-23 19:39:59 +08:00
Dennis 8c98d1abe4 fix(shortcut): harden AiSearch Contact and Live delivery 2026-08-23 19:39:56 +08:00
Dennis 5e4a65513b fix(aisearch): fail closed on unprovable zero results 2026-08-23 19:39:54 +08:00
Dennis 4ae0e0ffc3 fix(contact): close exhaustive shortcut release gate 2026-08-23 19:39:52 +08:00
Dennis 1c9a977d08 fix(shortcut): close residual search and contact gaps 2026-08-23 19:39:50 +08:00
Dennis 78fabec4bb feat(shortcut): fail close Live list task 2026-08-23 19:39:47 +08:00
Dennis 6d6404993a feat(shortcut): harden Contact task surface 2026-08-23 19:39:45 +08:00
Dennis 008d50bb3d feat(shortcut): harden AiSearch task surface 2026-08-23 19:39:40 +08:00
zengyouling.zyl f871689960 ci: retrigger checks after flaky race shard and transient status upload 2026-08-23 19:34:04 +08:00
zengyouling.zyl b15a21de93 Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-23 19:00:08 +08:00
github-actions[bot] 58e8e35948 Merge pull request #1098 from typefield/fix/schema-compat-confirmation-exceptions
fix(ci): review batch remove confirmation hardening
2026-08-23 18:52:41 +08:00
zengyouling.zyl a8b0d8895b fix(ci): review batch remove confirmation hardening
Rebuild the exact-entry reviewedCompatibilityExceptions carve-out in the
base-owned schema-compat checker for the three destructive batch-remove
tools whose confirmation PR #1085 tightens from not_required to
user_required (doc/doc.remove_permission, drive/drive.permission_remove,
wiki/wiki.remove_member). Because the compatibility gate builds its
checker from the PR merge-base, this carve-out has to land on main before
PR #1085 can pass; the entry set is exact (tool + field + old -> new), so
any other confirmation drift, including weakening a reviewed tool back to
not_required, still fails.
2026-08-23 18:33:32 +08:00
zengyouling.zyl 546c2d2eb2 fix(helpers): require user confirmation for batch permission/member remove
Address the P1 review finding on PR #1085: --members lets one call remove
up to 30 USER/DEPT/CONVERSATION/TAG members, where departments, chats,
and role groups can indirectly affect many more users, yet the remove
branches called the MCP tool right after argument parsing with Safety
confirmation=not_required.

- drive permission remove, doc permission remove, and wiki member remove
  now declare confirmation=user_required. DeclareLeafMetadata installs
  the ConfirmSafety gate automatically (deferred to the first
  deps.Caller.CallTool so flag validation still fails first), so an
  unconfirmed invocation exits with the typed confirmation_required
  error and performs zero MCP calls; --yes, an interactive yes, or
  --dry-run previews remain the supported paths.
- Pass framework confirmation errors through WrapErrorWithOperation
  verbatim (new apperrors.IsConfirmationRequired). Text classification
  misrouted them: command paths containing "permission" (drive/doc
  permission remove) were re-reported as AUTH_PERMISSION_DENIED while
  other paths (wiki member remove) lost their reason and degraded to
  UNCLASSIFIED.
- Tests: TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall
  covers all three entry points for both --members and legacy --users —
  unconfirmed rejects with zero MCP calls, --yes dispatches exactly one
  call with the complete precise arguments, --dry-run previews without
  calls. Existing remove tests inject root --yes for the assembly
  assertions; blank --users still fails validation before confirmation.
2026-08-23 17:55:33 +08:00
zengyouling.zyl 5bd0ea7c53 fix(helpers): drop NO_PERMISSION from document permission codes
Address the P2 review finding on PR #1085: NO_PERMISSION is a generic
code name also returned by non-document tools — attendance
get-self-setting (bossAttendStatNotify) and event-subscription attempts
have both been observed returning it — so keying drive permission
apply-* guidance on it would mislead those products, defeating the goal
of the P1 scoping fix. Only the drive-specific forbidden.* domain codes
(forbidden.no.auth / forbidden.accessDenied) and the role-threshold
message wording remain document signals; a bare NO_PERMISSION still
classifies as AUTH_PERMISSION_DENIED but now keeps the product-neutral
suggestion, and NO_PERMISSION combined with document wording still gets
apply guidance.

Add regression tests for the non-document NO_PERMISSION case and update
the changelog fragment; changed-code coverage stays at 100%.
2026-08-23 17:01:49 +08:00
zengyouling.zyl 4833b39071 fix(helpers): scope permission-apply guidance and null->{} rendering to confirmed tools
Address the two P1 review findings on PR #1085:

- Permission suggestions: the drive permission apply-* guidance is now
  limited to document/wiki-specific errors (node access codes
  NO_PERMISSION / forbidden.no.auth / forbidden.accessDenied and the
  role-threshold wording). Permission failures from other products keep
  their product-specific suggestion (e.g. the mail mailbox hint) or fall
  back to a product-neutral hint instead of being told to run document
  permission commands that cannot fix their problem.

- Null rendering: the null->{} adaptation is limited to the four tools
  with a confirmed empty-response-means-success contract
  (update_permission / remove_permission / update_member /
  remove_member). Every other tool keeps its raw null output so the
  shared machine-output contract stays unchanged.

Update tests and the changelog fragment accordingly; changed-code
coverage stays at 100%.
2026-08-23 16:16:34 +08:00
zengyouling.zyl 8a4e49dbf2 test(helpers): cover permission update/remove members and blank --users branches to #1085 2026-08-23 14:34:17 +08:00
zengyouling.zyl 7c924c54ca fix(drive,doc,wiki): register limit mapping exclusion instead of property redirect to #1085
The server rejects the legacy maxResults path; the CLI now validates
--limit (1-50) and sends it as pageSize at runtime. Schema-compat
rejects a non-empty property redirect (maxResults -> pageSize), so
declare --limit as a CLI pagination input via the reviewed mapping
exclusion ledger (property omitted, provenance
reviewed_mapping_exclusion) on doc.list_permission,
drive.list_permission, and wiki.list_member.
2026-08-23 03:04:11 +08:00
zengyouling.zyl 59d0b6dd75 Merge remote-tracking branch 'upstream/main' into feat/drive-permission-pagination 2026-08-23 02:52:58 +08:00
zengyouling.zyl 74f7bbc980 docs(changes): correct release fragment PR reference to #1085 2026-08-23 02:38:43 +08:00
zengyouling.zyl 2b7d5a2c5f fix(drive,doc,wiki): permission notify default, error guidance, pagination contract to #1065
- --notify now defaults to false and is omitted from the server request
  unless passed explicitly (help updated accordingly)
- forbidden.accessDenied / permission-denied bodies classify as
  AUTH_PERMISSION_DENIED with apply-permission guidance
- user/member validation failures intercepted before RESOURCE_NOT_FOUND
  with --members corpId suggestion
- business error display appends backend code/logId for traceability;
  literal null tool responses render as {}
- drive/doc permission list + wiki member list declare cursor pagination
  (next-token) in Contract; cobra.NoArgs hardening on permission leaves
- cross-platform coverage tests and release fragments updated
2026-08-23 02:25:57 +08:00
github-actions[bot] fcfead71cb Merge pull request #1082 from DingTalk-Real-AI/codex/shortcut-sheet-whiteboard-markdown
feat(shortcuts): harden Sheet Whiteboard and Markdown routes
2026-08-23 01:14:11 +08:00
Dennis 0beb1c6b0c fix(whiteboard): compare readback numbers exactly 2026-08-23 00:54:46 +08:00
Dennis 3b38d4c8da docs(whiteboard): fix shortcut file source syntax 2026-08-23 00:30:58 +08:00
Dennis d68e340a5b fix(whiteboard): preserve interactive confirmation in examples 2026-08-23 00:30:56 +08:00
Dennis 98799effba fix(shortcuts): close sheet and whiteboard review gaps 2026-08-23 00:30:54 +08:00
Dennis 9239f9070a test(ci): share shortcut schema boundary fixture 2026-08-23 00:30:52 +08:00
Dennis 202c5ce697 feat(shortcuts): harden Sheet Whiteboard and Markdown routes 2026-08-23 00:30:49 +08:00
github-actions[bot] 8ab2ac5e7c Merge pull request #994 from FloralTide/codex/fix-event-shutdown-lifecycle
fix(event): clean up shutdown lifecycle
2026-08-21 19:24:44 +08:00
炳昱 b85a342e9f fix(npm): preserve interactive terminal ownership 2026-08-21 19:09:59 +08:00
炳昱 ad72cf4b3d fix(npm): signal the vendor process group 2026-08-21 18:15:19 +08:00
炳昱 89154b3952 fix(npm): avoid duplicate terminal signals 2026-08-21 17:39:48 +08:00
炳昱 b01febf52e Merge remote-tracking branch 'official-upstream/main' into codex/fix-event-shutdown-lifecycle 2026-08-21 17:23:25 +08:00
github-actions[bot] 74b7690cbb Merge pull request #1078 from liyuan333/feat/doc-read-public-and-history-version
feat(doc): read password-protected public docs and historical versions
2026-08-21 17:19:39 +08:00
liyuan333 8312c4f30e Merge branch 'main' into feat/doc-read-public-and-history-version 2026-08-21 16:50:25 +08:00
赤川 35c6fd95e1 Merge pull request #1092 from DingTalk-Real-AI/codex/add-secondary-dingtalk-webhook
ci: notify a secondary DingTalk webhook
2026-08-21 16:24:19 +08:00
chichuan 564ff8563f ci: notify a secondary DingTalk webhook 2026-08-21 16:22:46 +08:00
陌渊 c7510cd1a1 fix(datasource): trim whitespace from batch IDs and fix result/processCode docs
- Add trimNonEmpty for --table-ids in +datasource-sync and --task-ids in
  +datasource-sync-status, matching the existing field-ids pattern
- Add 4 test cases: whitespace-only rejection and trim-through for both
- Fix usage guide: typical workflow and notes no longer equate result
  with processCode; correctly describe result as JSON to parse for
  approvals[].processCode/name/iconUrl/url
2026-08-21 16:19:43 +08:00
john 1c3477c087 Merge branch 'main' into feat/drive-permission-pagination 2026-08-21 16:18:15 +08:00
陌渊 93d450a9bf fix(datasource): read --field-ids as string slice, not string
--field-ids is declared as FlagStringSlice, but DatasourceCreate and
DatasourceUpdate previously called rt.Str to check whether the flag
was empty. RuntimeContext.Str delegates to cobra's GetString, which
returns an empty string on slice-typed flags, so the empty-value
guard rejected every explicit --field-ids input and the downstream
MCP tool never received fieldIds.

Switch to rt.StrSlice, sanitize through a new trimNonEmpty helper
(drop whitespace-only / empty entries) and pass the cleaned slice
to MCP. Add success-passthrough tests for both create and update,
plus a whitespace-only rejection case, and enhance the mock caller
to record MCP arguments so fieldIds can be asserted.
2026-08-21 16:10:23 +08:00
陌渊 cf39768095 fix(datasource): align field-ids semantics and test naming for coverage gate
- Update --field-ids description in create/update shortcuts and the
  helper-layer datasource update to clarify that omitting the flag
  keeps existing config (create defaults to all fields), matching the
  actual update overwrite semantics.
- Rename datasource shortcut coverage tests to the
  TestCrossPlatformCoverage* prefix so they are picked up by the
  macOS platform coverage gate.
2026-08-21 16:10:20 +08:00
陌渊 c096258b0f fix(datasource): reject empty field-ids and auto-sync-setting in shortcut layer
Align shortcut layer validation with helper layer to prevent empty slices
from being sent to MCP, which could clear sync field selection due to
datasource update's overwrite semantics.

- Add empty string checks for --field-ids in both create and update shortcuts
- Add empty string checks for --auto-sync-setting in both create and update shortcuts
- Add regression tests verifying MCP is not called when empty values are rejected
- Both public entry points now have consistent validation behavior

Fixes P1 auto-CR issue for empty flag bypass vulnerability.
2026-08-21 16:10:18 +08:00
陌渊 5ba8ac6775 test(aitable): cover datasource shortcut and helper error paths for 100% changed-code coverage 2026-08-21 16:10:15 +08:00
陌渊 66fee5ef5f fix(aitable): update shortcut counts after rebase onto upstream main 2026-08-21 16:10:12 +08:00
陌渊 d9b9c5c7da fix(aitable): reject empty field-ids/auto-sync-setting and non-object JSON 2026-08-21 16:10:09 +08:00
陌渊 684411e54e fix(aitable): require task-ids for datasource sync-status and align docs
Make +datasource-sync-status consistent across shortcut and native
commands: --task-ids is now required, descriptions focus on querying
by taskId, and optional/IDLE semantics are removed. Update tests,
usage guide, reference doc, and SKILL description accordingly.
2026-08-21 16:10:05 +08:00
陌渊 de5ba029d4 fix(aitable): add field-ids/auto-sync-setting to native datasource create/update
Native datasource create/update now expose --field-ids and
--auto-sync-setting, matching the shortcut-layer capabilities:
- flags registered on both commands
- Contract Parameters updated
- values mapped to MCP tool args
- JSON validation for --auto-sync-setting
- no-change update guard now counts the new flags

Also fixes the missing required name in the usage-guide update example.
2026-08-21 16:10:02 +08:00
陌渊 15f139e32d fix(aitable): reject no-change datasource update and fix doc example
+datasource-update now requires at least one mutable option
(--source-config, --auto, --field-ids, or --auto-sync-setting)
before calling update_datasource_config, preventing accidental
sync triggers. The native datasource update command enforces the
same guard for its supported flags. Also adds the required name
field to the +datasource-get-fields doc example.
2026-08-21 16:09:58 +08:00
陌渊 768c1ce494 fix(aitable): only send --auto on datasource-update when explicitly set
Omitting --auto on +datasource-update previously sent auto=false to
MCP, silently disabling auto-sync for existing datasources. Now auto
is only included in tool args when the flag is explicitly provided,
so --auto=true and --auto=false work while omission preserves the
existing setting. Updated flag descriptions and added tests.
2026-08-21 16:09:55 +08:00
陌渊 966fd60e2f fix(aitable): always send auto=false for datasource create/update
MCP requires the auto field in create_datasource / update_datasource_config
requests. Previously CLI only sent it when --auto was explicitly changed,
causing failures when users omitted the flag. Now both shortcut and helper
layers always include auto=false by default.

Also update flag descriptions and docs to clarify that the field is always
sent downstream, and add test assertions for the default-false behavior.
2026-08-21 16:09:51 +08:00
陌渊 7825c3c7e0 docs(aitable): fix datasource doc inconsistencies for auto CR P2
- docs/datasource-usage-guide.md: clarify that list-sources result is a
  JSON string containing approvals[]; add missing --auto-sync-setting
  parameter table rows and a dedicated autoSyncSetting format section
  using the correct scheduled/daily/weekly/monthly enums.
- skills/references/aitable/aitable-datasource.md: fix autoSyncSetting
  enums (schedule/day/week/month -> scheduled/daily/weekly/monthly) and
  update the create example accordingly.
2026-08-21 16:09:49 +08:00
陌渊 10d44615d1 fix(aitable): include required name in datasource source-config examples
The OA approval source-config contract requires processCode, name,
iconUrl, and url to be passed through unchanged from +datasource-list-sources.
Published examples for +datasource-create, +datasource-update, and
+datasource-get-fields were missing `name`, and the usage guide marked it
as optional. Fix all examples in the shortcut layer, helper layer, and
docs; update flag descriptions to mention name; and add a contract test
that validates every delivered example's source-config JSON contains the
required members.
2026-08-21 16:09:39 +08:00
陌渊 1e88612e43 test(aitable): add datasource helper tests for 100% changed-code coverage
21 tests covering all 7 datasource leaf commands' error paths (missing
required flags, count validation) and happy paths (source-config as raw
string, --auto flag, boundary cases for table-ids/task-ids).
2026-08-21 16:09:36 +08:00
陌渊 5934ccac7f fix(aitable): enforce 1-5 count limit on table-ids and task-ids
Both the shortcut (+datasource-sync, +datasource-sync-status) and
helper (datasource sync, datasource sync-status) layers now validate
that table-ids contains 1-5 IDs and task-ids contains at most 5 IDs
before calling MCP, matching the declared contract.
2026-08-21 16:09:30 +08:00
陌渊 d9365f3fff docs: remove unimplemented --conflict-strategy from all datasource docs 2026-08-21 16:09:27 +08:00
陌渊 15d93698bd fix(aitable): use String instead of StringSlice for datasource flags
ValidateRequiredFlags calls GetString which returns empty for
StringSlice flags, causing the examples test to report --table-ids
as missing. Switch to String + parseCSVValues to match the codebase
convention used by record-ids and other comma-separated flags.
2026-08-21 16:09:23 +08:00
陌渊 7e69a3d6fe fix(aitable): add datasource helper leaf commands and fix CI test counts
- Add 7 datasource leaf commands to internal/helpers/aitable.go so
  coverage test can find tool name literals (fixes TestAllShortcutsAssemble)
- Add 7 entries to semantic_catalog_aitable.json and update catalog count
  from 93 to 100 (fixes TestCrossPlatformCoverageAITableSemanticCatalog)
- Update publicShortcutCount/schemaPublishedShortcutCount/publiclyDelivered
  from 422/447/422 to 429/454/429 (fixes TestDeliverySchemaCoversOrExactly)
- Fix Contract.Selection.AgentSummary and UseWhen[0] in datasource.go to
  match Description and Intent exactly as required by schema contract test
2026-08-21 16:09:21 +08:00
陌渊 94b4958038 chore(aitable): regenerate SKILL.md shortcut section via gen_skill_shortcut_sections.py 2026-08-21 16:09:19 +08:00
陌渊 97a99ba04f style: fix gofmt indentation in datasource.go 2026-08-21 16:09:17 +08:00
陌渊 5e1e5cbb84 fix(aitable): remove duplicate datasource-get-fields and datasource-list-sources rows in SKILL.md 2026-08-21 16:09:14 +08:00
陌渊 e04e886c50 chore: add release fragment for aitable datasource shortcuts 2026-08-21 16:09:12 +08:00
陌渊 1acde9b766 feat(aitable): align datasource shortcuts with MCP snapshot [WP-40-006]
- Fix autoSyncSetting enum: scheduled/daily/weekly/monthly; mark
  selectedMonthDays/selectedWeekdays as required for monthly/weekly
- Remove splitParentTableField from --source-config user-settable fields;
  add note that splitParentTableField/enableDataSyncOaDetailList are
  internal downstream fields not to be passed
- Prepend sync-is-fire-and-forget notice to DatasourceSync descriptions
- Remove --conflict-strategy flag (syncConflictStrategy not in MCP schema)
2026-08-21 16:09:09 +08:00
陌渊 852efe56aa feat(aitable): add datasource skill optimization
- Golden Route: add datasource entry (list-sources → create flow)
- 常用 leaf 直达: add datasource-* commands
- 当前最短路径: add list-sources-first rule
- 安全边界: add sync write warning
- 错误最短路径: add errorCode=4014 and sync=false handling
- 按需加载: add datasource reference trigger
- New reference: aitable-datasource.md with full workflow, sourceConfig
  protocol, autoSyncSetting config, command details, error codes
2026-08-21 16:09:05 +08:00
陌渊 6c287bcb4d feat(aitable): align datasource shortcuts with MCP snapshot [WP-40-005]
- Add --auto-sync-setting flag to DatasourceCreate (was only in Execute, not in Flags)
- Expand DatasourceSync description: add 文档链接, errorCode=4014 幂等冲突, 非数据源表参数错误
- Simplify DatasourceGetFields description: remove field property enumeration to match snapshot
2026-08-21 16:09:00 +08:00
陌渊 df24d53886 feat(aitable): align datasource shortcuts with MCP snapshot [WP-40-004]
Sync CLI field descriptions with latest ai-table-mcp-snapshot.json:
- source-config flags: restructure to "两类字段" (4 passthrough + caller-set),
  add splitParentTableField, fix Update flag to optional semantics
- get_datasource_sync_status: update status list (RUNNING/FINISHED/FAILED,
  remove TIMEOUT), change "不传返回最近一次" → "IDLE(下游暂不支持)"
- get_datasource_config: add sync=true guard note, "其他类型暂不支持", sourceConfig hint
- list_datasource_sources: full rewrite explaining result/approvals structure,
  4-field passthrough rule, enableDataSyncOaDetailList internal note
- get_datasource_fields: add "其他数据源类型暂不支持,待后续开放"
2026-08-21 16:08:50 +08:00
陌渊 49cecabb12 [WP-40-003] feat: align 7 datasource shortcuts with latest MCP snapshot
- Add --auto-sync-setting flag (JSON string) to +datasource-create and
  +datasource-update, validated and passed through as raw string.
- Update +datasource-update --source-config desc to reflect full
  replacement semantics ("传入时整体覆盖") and spell out required /
  optional fields with defaults.
- Append "仅支持 OA 审批数据源 (datasourceType=OA)" to
  +datasource-get-config description.
- Simplify +datasource-list-sources / +datasource-get-fields
  descriptions to concise Chinese aligned with snapshot wording.
- Update SKILL.md shortcuts table and add datasource usage guide.
2026-08-21 16:08:45 +08:00
陌渊 09993ad82c [WP-40-002] fix: correct idempotency value from not_idempotent to non_idempotent 2026-08-21 16:08:42 +08:00
陌渊 0efaf6c82f [WP-40-002] feat: update SKILL.md with 5 datasource shortcuts and trigger words 2026-08-21 16:08:39 +08:00
陌渊 26002637f4 [WP-40-001] feat: add 5 datasource shortcuts for aitable
Add 5 data source sync management shortcuts to the aitable service:
- +datasource-create (create_datasource): create sync config + first sync
- +datasource-update (update_datasource_config): update existing sync config
- +datasource-sync (run_datasource_sync): trigger manual sync (max 5 tables)
- +datasource-sync-status (get_datasource_sync_status): query sync task status
- +datasource-get-config (get_datasource_config): get sync config details

Each shortcut declares a full Contract (Identity/Interface/Selection),
Safety, Flags, and Execute that calls rt.CallMCPData on the "aitable"
MCP server. datasource-type is passed through without CLI enum check;
source-config is validated as a JSON object via parseJSONObject.
2026-08-21 16:08:35 +08:00
github-actions[bot] f7229091ae Merge pull request #1053 from anxiangbo/feat/20260817_agoal_search
Feat/20260817 agoal search
2026-08-21 07:50:26 +00:00
liyuan333 77aa813467 Merge branch 'main' into feat/doc-read-public-and-history-version 2026-08-21 15:42:53 +08:00
anxiangbo 1f595571c0 Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 15:27:26 +08:00
github-actions[bot] cd90d1c322 Merge pull request #1075 from Justper/oa_attachment_upload_dws
Oa attachment upload dws
2026-08-21 14:46:26 +08:00
liyuan 49ab53ea0d 评审问题修复 2026-08-21 14:29:05 +08:00
昭逸 78433198fb Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 14:23:27 +08:00
昭逸 4863152a4a Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 14:20:33 +08:00
昭逸 2057fec3b0 fix(oa): normalize spaceId/fileSize to match ResultSpec integer declaration to #666
- validateOAAttachmentCommitResult 改为返回归一化后的 result map
- string 型 spaceId 通过 ParseInt 转 int64,json.Number 同理,非法字符串报错
- fileSize 的 json.Number 同样归一化为 int64
- 新增归一化行为测试 + 输出契约测试,覆盖率 100% to #666
2026-08-21 14:19:53 +08:00
anxiangbo 1308d08862 Merge branch 'DingTalk-Real-AI:main' into feat/20260817_agoal_search 2026-08-21 14:00:30 +08:00
github-actions[bot] 8b56e9bc9e Merge pull request #1073 from maoqxxmm/codex/sheet-revision-changeset
feat(sheet): add revision and changeset inspection
2026-08-21 05:56:10 +00:00
毛球 87e141f2de Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 13:38:56 +08:00
github-actions[bot] 9b521f0392 chore: update beta formula for v1.0.60-beta.1 [skip ci] 2026-08-21 05:17:50 +00:00
YanChangzhi 4dcd528bc1 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 13:11:34 +08:00
赤川 0bbb3a9d32 Merge pull request #1087 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.1
chore: prepare v1.0.60-beta.1 changelog
2026-08-21 12:48:22 +08:00
chichuan 0ebd840ba9 chore: prepare v1.0.60-beta.1 changelog 2026-08-21 12:35:38 +08:00
github-actions[bot] 9d356cd664 Merge pull request #1076 from hlzjsong/refresh_org_slot_fix
refresh org slot not only identity
2026-08-21 04:20:48 +00:00
YanChangzhi b00f43ee06 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 12:14:19 +08:00
赤川 23167ef974 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:46:45 +08:00
毛球 8b003aef16 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 11:43:45 +08:00
github-actions[bot] 11934eed05 Merge pull request #1081 from DingTalk-Real-AI/codex/fix-report-requiredness-governance
feat(policy): govern optional-to-required flag migrations
2026-08-21 11:40:11 +08:00
玉澜 d552c59d11 feat(drive,doc,wiki): permission/member list pagination and multi-type members
Sync the permission CRUD overhaul from the internal CLI (MR 28965577):

- drive/doc permission list and wiki member list now accept --next-token
  to follow the server cursor (totalCount/hasMore/nextToken); --limit maps
  to pageSize capped at 50 instead of the rejected maxResults=200 path
  (fixes #1065)
- permission add/update/remove and wiki member add/update/remove accept a
  --members JSON array (USER/DEPT/CONVERSATION/TAG grantee types, each with
  its own roleId) with optional --notify; legacy --users/--role stays
- cursor/page-token hidden cross-product aliases now resolve to next-token
- regenerate param_aliases_generated.go; wiki member list override no
  longer blocks cursor
- update mono/multi skill references and add change fragment
2026-08-21 11:30:22 +08:00
YanChangzhi 23e1085a37 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 11:16:29 +08:00
赤川 a352615e77 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:15:06 +08:00
xiatian d210da501a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 11:14:03 +08:00
赤川 6288199a93 Merge branch 'main' into codex/fix-report-requiredness-governance 2026-08-21 11:05:33 +08:00
anxiangbo 6d9781fe15 Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 11:04:19 +08:00
赤川 5b34ed1a7e Merge pull request #1084 from DingTalk-Real-AI/codex/docs-dws-cli-open
docs: 公告 DWS CLI 全面开放
2026-08-21 11:02:36 +08:00
chichuan 3d9a469347 docs: announce DWS CLI availability 2026-08-21 11:01:08 +08:00
xiatian 7640ba7614 fix(sheet): validate changeset audit integrity 2026-08-21 10:59:14 +08:00
anxiangbo c790fe3c3b Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 10:44:53 +08:00
昭逸 4886bdb3f5 Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 10:41:06 +08:00
昭逸 4aef07ccd3 fix(oa): validate commit response required fields before reporting success to #666
- 新增 validateOAAttachmentCommitResult 校验 spaceId/fileName/fileSize/fileId 必需字段
- commit 步不再使用通用 callOAAttachmentResultCtx,改为专用校验后才存储成功结果
- 补充 malformed commit 响应回归测试,覆盖率 100%
2026-08-21 10:40:46 +08:00
github-actions[bot] e0c49377d6 Merge pull request #1074 from DingTalk-Real-AI/codex/investigate-calendar-todo-comment-regressions
fix: harden calendar todo and comment shortcuts
2026-08-21 10:30:04 +08:00
昭逸 dc37dd2c34 fix(oa): remove DDAttachment from unsupported table and use testseam.Swap to #666
- 从 oa-form-components.md (mono/multi) 的"API 不支持的控件"表中移除 DDAttachment,避免 Agent 误判为不支持
- computeFileMD5 测试注入改为 testseam.Swap,符合仓库包变量注入约定
2026-08-21 09:56:48 +08:00
hlzjsong 02aad9020a Merge branch 'main' into refresh_org_slot_fix 2026-08-21 09:26:39 +08:00
昭逸 d59d1091dc Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 08:51:13 +08:00
昭逸 60d6bfeec4 Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 08:50:28 +08:00
昭逸 bf89acb3d2 fix ci fail to #666 2026-08-21 08:50:13 +08:00
Dennis 35d6f47bd6 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-21 08:17:52 +08:00
xiatian d24b71614a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:23:17 +08:00
github-actions[bot] 765b961f4d Merge pull request #1071 from DingTalk-Real-AI/codex/fix-stable-active-fragments
fix(release): consume post-beta fragments in stable seals
2026-08-21 01:06:18 +08:00
xiatian 9ab4bd10a5 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:05:47 +08:00
chichuan 14c5bed4fc ci: split app-c race partition for runner headroom 2026-08-21 00:50:46 +08:00
赤川 c1bd6dcf64 Merge branch 'main' into codex/fix-stable-active-fragments 2026-08-21 00:06:27 +08:00
Dennis 1c8b83ec2f Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 23:58:13 +08:00
Dennis bb6f470df5 test: address shortcut regression review 2026-08-20 23:56:08 +08:00
赤川 01af71a5ae Merge branch 'main' into oa_attachment_upload_dws 2026-08-20 23:47:06 +08:00
github-actions[bot] d4ff8a5f4f Merge pull request #1070 from DingTalk-Real-AI/codex/oa-ding-report-shortcuts
feat(shortcut): harden OA DING and Report workflows
2026-08-20 23:44:23 +08:00
赤川 47e3c2b3c5 Merge branch 'main' into refresh_org_slot_fix 2026-08-20 23:19:33 +08:00
xiatian e8ecff586a fix(sheet): classify malformed revision responses 2026-08-20 23:06:56 +08:00
Dennis 11a9ad5d49 fix(shortcut): align OA execution availability 2026-08-20 23:03:35 +08:00
Dennis 23940e4752 revert: keep coverage shard output compact 2026-08-20 22:24:17 +08:00
Dennis 8cb0f64477 fix(shortcut): reject backward OA cursors 2026-08-20 22:15:27 +08:00
Dennis bbaf033618 ci: stream app coverage progress 2026-08-20 22:14:35 +08:00
xiatian 57cca7ef71 fix(sheet): validate revision result contracts 2026-08-20 22:02:26 +08:00
Dennis 2d38beb7be fix(shortcuts): separate compatibility visibility from availability 2026-08-20 21:51:21 +08:00
昭逸 4372a8c5ba Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-20 21:44:16 +08:00
昭逸 422dc0fde3 fix ci fail to #666 2026-08-20 21:44:05 +08:00
muling.cs 3d59411a1a refresh slot repair org 2026-08-20 21:24:06 +08:00
chichuan fe66ac18a4 feat(policy): govern flag requiredness changes 2026-08-20 21:19:55 +08:00
Dennis bda408d966 test(ding): cover compatibility reminder mappings 2026-08-20 21:10:45 +08:00
Dennis 33631502c9 fix(shortcuts): align unavailable writes and query validation 2026-08-20 21:02:52 +08:00
毛球 f2a608d146 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 20:44:17 +08:00
Dennis 378b9f67a2 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 20:41:56 +08:00
xiatian 43ba466783 fix(sheet): require fresh confirmation for version revert 2026-08-20 20:28:35 +08:00
Dennis 5c9f738012 fix(shortcuts): preserve published schema bindings 2026-08-20 20:20:41 +08:00
Dennis 56c5fb35b8 chore(policy): retire completed flag migrations 2026-08-20 20:20:27 +08:00
Dennis b19c52f61b fix(oa): make approval keyword normalization explicit 2026-08-20 20:20:25 +08:00
Dennis c0641dbf64 fix(shortcut): preserve OA and DING CLI compatibility 2026-08-20 20:20:23 +08:00
Dennis 3b5cb3b0cb test(shortcut): close OA DING Report coverage gaps 2026-08-20 20:20:21 +08:00
Dennis fd2ed2174f chore(release): add OA DING Report fragment 2026-08-20 20:20:19 +08:00
Dennis 5bbaa304e3 docs(shortcut): refresh OA DING Report live evidence 2026-08-20 20:20:17 +08:00
Dennis db938778af chore(shortcut): sync OA DING Report with current main 2026-08-20 20:20:15 +08:00
Dennis 1155b9b5c0 test(shortcut): align OA reviewed input fixtures 2026-08-20 20:20:12 +08:00
Dennis 8fa93ef030 fix(shortcut): retire OA discovery aliases after downgrade 2026-08-20 20:20:10 +08:00
Dennis f4ad1a15f5 docs(shortcut): record Report double-layer release proof 2026-08-20 20:20:08 +08:00
Dennis d2cbd928e2 docs(shortcut): record DING double-layer release proof 2026-08-20 20:20:06 +08:00
Dennis 2346dfba4e fix(shortcut): require OA zero-page pagination evidence 2026-08-20 20:20:03 +08:00
Dennis f6ad2fa01a fix(shortcut): publish Report range constraints 2026-08-20 20:19:44 +08:00
Dennis 7bc56f3dea feat(shortcut): unlock Report outbox workflows 2026-08-20 20:19:41 +08:00
Dennis 03001eb4e0 fix(shortcut): harden DING write routing evidence 2026-08-20 20:19:39 +08:00
Dennis 91974ce981 docs(shortcut): close OA residual audit gaps 2026-08-20 20:19:37 +08:00
Dennis c6417e3527 feat(shortcut): harden Report reads and availability 2026-08-20 20:19:35 +08:00
Dennis 161687ae4c fix(shortcut): deliver OA validation evidence 2026-08-20 20:19:32 +08:00
Dennis 4da5a07d1d feat(shortcut): harden DING reads and availability 2026-08-20 20:19:30 +08:00
Dennis 2cb83d388b fix(shortcut): publish OA validation constraints 2026-08-20 20:19:25 +08:00
Dennis ba9c0f624e feat(shortcut): harden OA workflows and availability 2026-08-20 20:19:19 +08:00
Dennis ff65f80c98 refactor(oa): add strict shortcut response helpers 2026-08-20 20:19:09 +08:00
github-actions[bot] 42240f5e9e Merge pull request #1079 from DingTalk-Real-AI/codex/fix-stable-migration-receipts
fix(ci): keep completed migration receipts inert
2026-08-20 20:18:04 +08:00
Dennis e6b06b561d Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 19:50:43 +08:00
chichuan 11cbc30a10 fix(ci): keep completed migration receipts inert 2026-08-20 19:16:07 +08:00
xiatian 60a474f30c fix(sheet): fail closed on invalid revision results 2026-08-20 19:15:48 +08:00
xiatian 6e8fec5684 chore(policy): retire consumed flag migrations 2026-08-20 17:48:59 +08:00
liyuan 470aa42d7b chore: keep release note in .changes fragment, restore CHANGELOG.md 2026-08-20 17:35:03 +08:00
liyuan a74d96bb96 feat(doc): read password-protected public docs and historical versions 2026-08-20 17:32:11 +08:00
liyuan 9798a60728 feat(doc): read password-protected public docs and historical versions 2026-08-20 17:13:46 +08:00
毛球 e028d443d5 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 17:11:29 +08:00
chichuan 74859b966b fix(release): consume active fragments in stable seals 2026-08-20 17:07:53 +08:00
xiatian 76a5559ca2 fix(sheet): align revision dry-run contract 2026-08-20 16:59:18 +08:00
昭逸 c4a1018213 删除无关文件 to #666 2026-08-20 16:55:18 +08:00
github-actions[bot] 62d72ad84c chore: update formula for v1.0.59 [skip ci] 2026-08-20 08:45:55 +00:00
Dennis e6fe475aea chore: retire consumed chat flag migration 2026-08-20 16:44:07 +08:00
muling.cs e95ac52ff4 refresh org slot not only identity 2026-08-20 16:41:35 +08:00
Dennis 61f8140f91 test: close shortcut regression coverage gaps 2026-08-20 16:38:05 +08:00
昭逸 09c0cd7849 merge uptream to #666 2026-08-20 16:26:32 +08:00
xiatian 5a368a9ab8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 16:11:37 +08:00
Dennis fbcd8887ee fix: harden calendar todo and comment shortcuts 2026-08-20 16:09:18 +08:00
赤川 c0838e7e41 Merge pull request #1072 from DingTalk-Real-AI/codex/changelog-v1.0.59-stable
chore: prepare v1.0.59 changelog
2026-08-20 16:06:54 +08:00
chichuan 9c6ab99bf1 chore: prepare v1.0.59 changelog 2026-08-20 16:01:09 +08:00
github-actions[bot] 87ab311764 chore: update beta formula for v1.0.59-beta.5 [skip ci] 2026-08-20 07:55:40 +00:00
昭逸 7f4318a10d fix审批skill中附件描述 to #666 2026-08-20 15:39:14 +08:00
xiatian 5232f632c8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 15:35:19 +08:00
xiatian 615a775fdf feat(sheet): add revision changeset inspection 2026-08-20 15:34:45 +08:00
anxiangbo b10da77e09 Merge branch 'main' into feat/20260817_agoal_search 2026-08-20 15:22:19 +08:00
昭逸 cefc5c005c 附件上传dws合并为一个 to #666 2026-08-20 15:14:39 +08:00
赤川 15c075e6a6 Merge pull request #1068 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.5
chore: prepare v1.0.59-beta.5 changelog
2026-08-20 14:56:06 +08:00
chichuan f6d1e685e0 chore: prepare v1.0.59-beta.5 changelog 2026-08-20 14:52:30 +08:00
github-actions[bot] 81108e150b Merge pull request #1046 from xlb1130/feat/85614588-chat-personal-emotion
feat(chat): add personal emotion commands
2026-08-20 06:27:50 +00:00
xlb1130 dad9aefefa Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 14:08:05 +08:00
github-actions[bot] 71d49cb12b Merge pull request #1033 from pengzhihan47-star/codex/dingtalk-doc-skill-opt-v1
docs(skill): optimize dingtalk-doc workflows
2026-08-20 14:04:45 +08:00
柏智 f7e2efaaa2 ci: retrigger checks 2026-08-20 13:50:18 +08:00
pengzhihan47-star 557208e16b Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:31:31 +08:00
xlb1130 53401dbb0c Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:25:41 +08:00
github-actions[bot] 6c52ac37dd Merge pull request #1066 from DingTalk-Real-AI/codex/minutes-todo-wiki-param-aliases
feat(cli): expand Minutes TODO Wiki parameter aliases
2026-08-20 13:21:38 +08:00
xlb1130 95a17a3ffc Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:21:07 +08:00
pengzhihan47-star 3318741508 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:01:04 +08:00
克谨 3d7ab2690c feat(cli): expand Minutes TODO Wiki parameter aliases 2026-08-20 12:45:19 +08:00
github-actions[bot] 17eefcd24b Merge pull request #1064 from DingTalk-Real-AI/codex/fix-1060-schema-lineage
fix(policy): preserve historical Schema migration lineage
2026-08-20 04:29:42 +00:00
xlb1130 6f62ce7997 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 12:17:24 +08:00
赤川 2228a32d1a Merge branch 'main' into codex/fix-1060-schema-lineage 2026-08-20 12:11:55 +08:00
github-actions[bot] a6f79e951b Merge pull request #1050 from DingTalk-Real-AI/codex/fix-cli-eval-functional
fix: harden shortcut functional workflows
2026-08-20 04:08:39 +00:00
长真 096dfd48f0 docs(chat): keep emotion skill route within budget 2026-08-20 11:57:55 +08:00
chichuan 3922970bfc fix(policy): preserve schema migration lineage 2026-08-20 11:52:00 +08:00
Dennis4477 9b0441ca56 Merge branch 'main' into codex/fix-cli-eval-functional 2026-08-20 11:47:08 +08:00
xlb1130 2ab0edd5c6 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:45:41 +08:00
pengzhihan47-star 4b3272bcd4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:42:41 +08:00
github-actions[bot] b6eaf3c5af chore: update beta formula for v1.0.59-beta.4 [skip ci] 2026-08-20 03:42:00 +00:00
长真 80d5d24637 Revert "docs(chat): trim chat skill context budget"
This reverts commit c5951a10ff.
2026-08-20 11:39:50 +08:00
柏智 e40397e239 docs(skill): restore bounded doc guidance 2026-08-20 11:34:14 +08:00
xlb1130 15bc7fdc3f Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:27:40 +08:00
柏智 da049be58d docs(skill): require terminal evidence for doc writes 2026-08-20 11:21:58 +08:00
柏智 6ec64e8a03 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:24 +08:00
柏智 bca56cbba6 Merge remote-tracking branch 'origin/codex/dingtalk-doc-skill-opt-v1' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:19 +08:00
赤川 aa4ae9a903 Merge pull request #1063 from DingTalk-Real-AI/codex/fix-996-multi-profile-skill-path
fix(ci): align multi-profile skill paths with canonical setup
2026-08-20 10:53:27 +08:00
chichuan 7a019c6fa3 fix(ci): align multi-profile skill paths 2026-08-20 10:46:17 +08:00
昭逸 103b05413e 审批附件相关dws help补充 to #666 2026-08-20 10:40:35 +08:00
john bb48aa0cc8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:44 +08:00
柏智 6ffb4bcb93 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:30 +08:00
赤川 e2e4d6fc22 Merge pull request #1062 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.4
chore: prepare v1.0.59-beta.4 changelog
2026-08-20 10:30:46 +08:00
chichuan 2c0d6e4118 chore: prepare v1.0.59-beta.4 changelog 2026-08-20 10:25:13 +08:00
昭逸 169bbe88c0 上传附件dws to #666 2026-08-20 10:24:35 +08:00
pengzhihan47-star 08595594d7 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:15:26 +08:00
github-actions[bot] 379f625ca9 Merge pull request #1045 from DingTalk-Real-AI/codex/attendance-mail-shortcuts
feat(shortcut): harden Attendance and Mail workflows
2026-08-20 02:07:16 +00:00
anxiangbo 30782020ad Merge branch 'main' into feat/20260817_agoal_search 2026-08-20 10:03:21 +08:00
柏智 540bbac35b Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 09:57:23 +08:00
赤川 9d27313f5e Merge branch 'main' into codex/attendance-mail-shortcuts 2026-08-20 09:47:51 +08:00
github-actions[bot] cc86d1e958 Merge pull request #1043 from guimingyue/oa_approval_list_by_admin
feat(oa): add approval list-by-admin with string time contract
2026-08-20 01:47:23 +00:00
mygui 5619cb150e Merge branch 'main' into oa_approval_list_by_admin 2026-08-20 09:28:43 +08:00
柏智 c4d5595ca9 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 02:09:49 +08:00
github-actions[bot] 5aaf2efb59 Merge pull request #1060 from DingTalk-Real-AI/codex/govern-command-path-migrations
ci: govern Help and Schema command migrations
2026-08-20 02:04:43 +08:00
柏智 86d1eb8030 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:47:50 +08:00
柏智 ab529e5ee5 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:05:02 +08:00
xlb1130 15cb1f4311 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 22:01:44 +08:00
长真 97ca00868f test(chat): cover personal emotion user resolution 2026-08-19 21:51:49 +08:00
xlb1130 df8885c350 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 21:06:35 +08:00
赤川 d87cdef00b Merge branch 'main' into codex/fix-event-shutdown-lifecycle 2026-08-19 20:04:42 +08:00
赤川 f79c066806 Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 19:51:24 +08:00
柏智 4e27a3a84a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 19:45:13 +08:00
xlb1130 8685464c53 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 19:28:52 +08:00
mygui a0495c169b Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 18:50:35 +08:00
长真 26b5939f9f chore(ci): retrigger pr checks 2026-08-19 18:34:38 +08:00
柏智 95bcace6bd Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 18:10:22 +08:00
mingyue.gmy 20c901d7ae fix(oa): require processCode in list-by-admin --request payloads
- Reject --request payloads with a missing, empty, or non-string
  processCode; the backend answers a bad processCode with success:true
  and an empty list, so validate client-side like startTime
- Add regression cases to keep changed-code coverage at 100%
2026-08-19 18:06:27 +08:00
mingyue.gmy e51ecc04f1 ci: trigger workflow rerun 2026-08-19 17:09:14 +08:00
长真 ce57cdf260 chore(ci): retrigger pr checks 2026-08-19 16:26:32 +08:00
Dennis 17741851f5 test: satisfy native shortcut coverage gate 2026-08-19 16:20:04 +08:00
anxb 999e7a7b9d feat: agoal新增dws2 2026-08-19 15:58:28 +08:00
Dennis d10446bea7 ci: reuse preinstalled archive tooling 2026-08-19 15:50:03 +08:00
anxb 94cee4388e Merge remote-tracking branch 'refs/remotes/origin/main' into feat/20260817_agoal_search 2026-08-19 15:38:58 +08:00
xlb1130 3ec138ba99 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 15:14:09 +08:00
anxb dcc7e72ec1 feat: agoal新增dws 2026-08-19 15:05:21 +08:00
Dennis 0ed05a2c5d fix: address shortcut review edge cases 2026-08-19 14:46:27 +08:00
Dennis ae1edefee6 test: cover shortcut hardening branches 2026-08-19 14:46:23 +08:00
Dennis 6dbc7ed82b fix: harden shortcut functional workflows 2026-08-19 14:46:19 +08:00
mygui 586ad0a5df Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 14:41:40 +08:00
Dennis 6d88c9968e docs(attendance): clarify schedule availability 2026-08-19 14:37:38 +08:00
Dennis 36c61fd8ac fix(attendance): withhold unverifiable schedule query 2026-08-19 14:37:35 +08:00
Dennis 272b6b8a70 test(shortcut): lock public catalog count 2026-08-19 14:37:33 +08:00
Dennis c3328411c9 fix(shortcut): close mail review and schema compatibility 2026-08-19 14:37:31 +08:00
Dennis 83cfd10416 docs(shortcut): record final live review evidence 2026-08-19 14:37:29 +08:00
Dennis 9d43a12e08 fix(shortcut): address Attendance and Mail review findings 2026-08-19 14:37:27 +08:00
Dennis 7900e27946 fix(shortcut): preserve CLI compatibility for unavailable leaves 2026-08-19 14:37:25 +08:00
Dennis 42f54832b0 docs(shortcut): refresh rebased evidence references 2026-08-19 14:37:23 +08:00
Dennis 5f6ca90821 docs(shortcut): sync live evidence and generated lists 2026-08-19 14:37:21 +08:00
Dennis cad437aabd fix(attendance): filter validated record overfetch 2026-08-19 14:37:19 +08:00
Dennis 47d71375f6 fix(attendance): align live identity and availability 2026-08-19 14:37:17 +08:00
Dennis 69540c3372 fix(mail): preserve shortcut query schema property 2026-08-19 14:37:15 +08:00
Dennis 3a2b738c06 fix(shortcut): close attendance and mail release gates 2026-08-19 14:37:13 +08:00
Dennis 725e60f07c feat(mail): harden and align shortcut workflows 2026-08-19 14:37:11 +08:00
Dennis 8b4453adf9 feat(attendance): harden shortcut contracts and live evidence 2026-08-19 14:37:09 +08:00
柏智 f419c0f96d Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 14:37:01 +08:00
xlb1130 17101a8901 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 13:41:20 +08:00
柏智 66aa00fb50 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 12:23:42 +08:00
长真 9d6e151a6f Merge remote-tracking branch 'upstream/main' into feat/85614588-chat-personal-emotion 2026-08-19 10:54:30 +08:00
柏智 0df41d3eff Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:40:45 +08:00
mygui ab0d1d2ad6 Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 10:17:51 +08:00
柏智 2a1ed8cc7a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:09:48 +08:00
mingyue.gmy 358e1ab065 fix(oa): require startTime in --request and validate endTime independently
- Reject --request payloads missing startTime (documented required) so
  endTime can no longer bypass validation when startTime is absent
- Align --request time ordering with simple mode: endTime must be
  strictly after startTime
- Cover all five previously uncovered branches (pageSize absent,
  startTime absent, malformed endTime, valid time pair, empty --start
  flag) to reach 100% changed-code coverage
2026-08-19 10:03:29 +08:00
长真 c5951a10ff docs(chat): trim chat skill context budget 2026-08-19 00:55:27 +08:00
长真 3dbd29ab50 feat(chat): add personal emotion commands 2026-08-18 23:59:15 +08:00
柏智 1b50c7a5b4 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 23:25:57 +08:00
mingyue.gmy fab84af434 docs(changelog): add release fragment for oa approval list-by-admin 2026-08-18 21:32:00 +08:00
mingyue.gmy 2dd724f1e1 feat(oa): add approval list-by-admin with string time contract
- Add dws oa approval list-by-admin leaf with simple flags and
  advanced --request modes backed by get_process_instances_by_admin
- Send startTime/endTime as yyyy-MM-dd HH:mm:ss strings per the
  2026-08 MCP contract update; ISO-8601 flag inputs auto-convert
- Enforce pageSize cap (20) and string time format/order client-side;
  PreRunE reports flag-group violations in Chinese before Cobra's
  built-in English validation
- Extend coverage tests and document the command in mono/multi OA
  skill references
2026-08-18 21:10:23 +08:00
柏智 cbd70d1b88 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 21:03:52 +08:00
柏智 0ae8949d40 fix(doc): align skill contracts with runtime 2026-08-18 20:53:02 +08:00
pengzhihan47-star 0975d970d1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 20:36:25 +08:00
柏智 7808673431 fix(doc): align media receipt contract 2026-08-18 20:31:07 +08:00
pengzhihan47-star 50ed921ca1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 19:28:45 +08:00
pengzhihan47-star b34c29ec35 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 18:41:09 +08:00
pengzhihan47-star 97e5ded043 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 13:49:34 +08:00
pengzhihan47-star 7ceeafbae8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 12:27:08 +08:00
pengzhihan47-star 54b4a24a14 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 11:35:30 +08:00
pengzhihan47-star e1bfb343f4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 10:13:27 +08:00
柏智 7da423bf3c docs(skill): clarify import and recent document routes 2026-08-18 09:37:47 +08:00
柏智 fa83ee579c docs(skill): remove lark-specific wording 2026-08-18 08:25:06 +08:00
pengzhihan47-star 25c694aa2a Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 07:46:06 +08:00
柏智 e064d394ba docs(skill): optimize dingtalk doc workflows 2026-08-18 01:02:37 +08:00
炳昱 5a001f33b6 fix(event): clean up shutdown lifecycle 2026-08-13 17:34:20 +08:00
483 changed files with 72006 additions and 7597 deletions
+3 -1
View File
@@ -25,7 +25,9 @@ category: Added
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Command typo guidance** — returns a validation error with up to three nearest command suggestions and the parent `--help` entry instead of printing the full command list.
@@ -0,0 +1,14 @@
---
category: Changed
---
- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with
strict response, identity, pagination, and real-data verification while
retaining historical CLI discovery and argument compatibility for commands
that remain unavailable to agents. Mailbox auto-resolution now accepts both
reviewed string and object response shapes, and Attendance date ranges cover
the complete requested end date without dropping cross-midnight punches whose
actual check time is inside the requested range. The schedule query remains
CLI-compatible but is withheld from the Agent catalog because its downstream
service returns a successful process exit with a null body for both populated
and empty ranges.
@@ -0,0 +1,5 @@
---
category: Added
---
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
@@ -0,0 +1,7 @@
---
category: Fixed
---
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
@@ -0,0 +1,11 @@
---
category: Fixed
---
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
business code, `dws` now retries once with the still-valid token mirrored in the same
organization's slot (same corp, matching or backfilled user identity) before giving up,
and writes the rotated credential back to both the identity and the organization slots so
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
rejections without a reviewed business code do not trigger the fallback.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
@@ -0,0 +1,6 @@
---
category: Added
---
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
@@ -0,0 +1,24 @@
---
category: Changed
---
- **Permission error guidance and error rendering** (#1085) —
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
instead of a generic business-error rendering; document/wiki-specific errors
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
or the role-threshold wording like
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
(`dws drive permission apply-info` / `dws drive permission apply`), while
permission failures carrying only generic code names (`FORBIDDEN`,
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
or other products' wording keep their product-specific or
product-neutral suggestion instead of a misleading document-permission hint;
member-validation failures such as
“用户不存在/不属于当前组织” are classified as tool errors with a
`--members`-with-`corpId` suggestion instead of a misleading
resource-not-found error; business error output now surfaces the backend
message with `code`/`logId` appended for traceability; and the
`update_permission` / `remove_permission` / `update_member` /
`remove_member` tools — whose servers return a literal `null` on successful
no-payload writes — now render `{}` so downstream JSON consumers do not fail
parsing `null`; other tools keep raw `null` output unchanged.
@@ -0,0 +1,22 @@
---
category: Added
---
- **Permission and member list pagination** (#1085) — `drive/doc permission
list` and `wiki member list` now accept `--next-token` to follow the
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
200` path; `permission add/update/remove` and `wiki member add/update/remove`
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
grantee types. The optional `--notify` defaults to `false` and is omitted from
the server request unless passed explicitly, so member grants no longer notify
recipients by default. These commands also declare cursor pagination
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
change. Because a single batch remove can revoke access for up to 30
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
groups can indirectly affect many more users — `drive/doc permission
remove` and `wiki member remove` now declare
`confirmation=user_required` and gate the actual tool call behind user
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
confirmation-gate failure now also passes through verbatim instead of being
reclassified as a permission-denied or unclassified error.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
@@ -0,0 +1,5 @@
---
category: Added
---
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
@@ -0,0 +1,13 @@
---
category: Added
---
- **Doc public-link and historical-version reads** — `dws doc read` forwards
the reviewed `password` (internet-public documents with password protection)
and `historyVersion` (read content as of a listed historical version; `0`
denotes the document's initial version) parameters on the markdown, JSONML,
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
`--password` and `--version` with the same `historyVersion` forwarding, while
`--revision` stays rejected with explicit guidance: revision is the document
edit revision returned by JSONML reads for `+update --expected-revision`
conditional writes, not a historical version number.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
@@ -0,0 +1,5 @@
---
category: Added
---
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
+53 -10
View File
@@ -534,7 +534,8 @@ jobs:
# where the Schema partition alone owned most of the wall clock. The
# app-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently.
# its job silently. The CrossPlatformCoverage-heavy C range is split again
# to retain headroom on runners reclaimed near the five-minute mark.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -542,7 +543,11 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
@@ -617,7 +622,13 @@ jobs:
- name: Install archive tooling
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test shard with Race Detection
if: ${{ steps.select.outputs.affected == 'true' }}
@@ -672,7 +683,8 @@ jobs:
# partitions run concurrently and each releases its framework registries
# when the process exits; cli/smoke need headroom beyond go test -timeout for
# setup + assembly. The app-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
timeout-minutes: 20
strategy:
fail-fast: false
@@ -680,7 +692,11 @@ jobs:
shard:
- app-schema
- app-a-b
- app-c
- app-c-a-l
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- app-d-r
- app-s-z-example-fuzz
- generators
@@ -757,7 +773,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test release scripts
shell: bash
@@ -1135,7 +1157,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Build
run: make build
@@ -1186,7 +1214,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run policy and shortcut coverage
run: |
@@ -1267,7 +1301,13 @@ jobs:
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run baseline unit tests with coverage
if: steps.baseline-cache.outputs.cache-hit != 'true'
@@ -1559,7 +1599,10 @@ jobs:
name: Policy
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
# Full policy regenerates and validates the runtime Schema several times.
# Keep job-level headroom for large reviewed command-surface additions;
# individual policy gates retain their own fail-closed checks.
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
+15 -9
View File
@@ -14,9 +14,12 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const webhook = process.env.DINGTALK_WEBHOOK;
if (!webhook) {
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
const webhooks = [
process.env.DINGTALK_WEBHOOK,
process.env.DINGTALK_WEBHOOK_SECONDARY
].filter(Boolean);
if (webhooks.length === 0) {
console.log('⚠️ No DingTalk webhook configured, skipping notification');
return;
}
@@ -39,12 +42,15 @@ jobs:
}
};
await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
await Promise.all(webhooks.map(webhook =>
fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
})
));
console.log('✅ DingTalk notification sent');
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
env:
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
+4 -2
View File
@@ -2698,9 +2698,11 @@ jobs:
;;
compatibility)
test -n "$PREVIOUS_STABLE"
./scripts/policy/check-command-compatibility.sh \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
--repo-root "$GITHUB_WORKSPACE" \
--base-ref HEAD \
--stable-ref "$PREVIOUS_STABLE"
--stable-ref "$PREVIOUS_STABLE" \
--candidate-ref HEAD
;;
e2e)
bash scripts/dev/test-multi-profile-e2e.sh
+3 -1
View File
@@ -44,7 +44,8 @@ Schema contract) keep separate authorities — do not merge them with
## Command framework declaration
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
- Today (leaf): `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
- Today (non-leaf): owning Cobra command → complete `corecmd.GroupPolicy{Mode, Positionals, Recovery}` → `corecmd.ApplyGroupPolicy`; the final assembled-tree gate rejects undeclared groups and stale group declarations on leaves
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
@@ -60,6 +61,7 @@ Schema contract) keep separate authorities — do not merge them with
- **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**)
- **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed)
- Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind.
- Group policy is separate from the leaf tiers: `corecmd.Spec` remains leaf-only. `ApplyGroupPolicy` must not infer or enable `TraverseChildren`; parent local-flag inheritance remains an explicit owning-command surface.
- Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority.
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
+161
View File
File diff suppressed because one or more lines are too long
+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.59-beta.3"
version "1.0.60-beta.2"
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.59-beta.3/dws-darwin-arm64.tar.gz"
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz"
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58"
version "1.0.59"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
end
def install
+6 -2
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -20,6 +20,7 @@ help:
@printf " make test - Run the Go test suite\n"
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
@printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\n"
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@@ -62,6 +63,9 @@ test-auth-legacy-compat:
@mkdir -p "$(POLICY_GOTMPDIR)"
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
shortcut-public-e2e-proof: build
@GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh
lint:
@./scripts/dev/lint.sh
@@ -84,7 +88,7 @@ fmt:
$(GO_SOURCE_LIST) > "$$go_files"; \
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
policy: test-auth-legacy-compat
policy: test-auth-legacy-compat shortcut-public-e2e-proof
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
+1 -1
View File
@@ -19,7 +19,7 @@
</p>
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
+63 -5
View File
@@ -13,13 +13,71 @@ if (!fs.existsSync(binaryPath)) {
process.exit(1);
}
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
// Interactive commands must remain in the terminal's foreground session so
// prompts can use /dev/tty. Non-interactive launches use a separate process
// group, allowing a signal sent only to this wrapper to reach the full vendor
// process tree exactly once.
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
detached: isolateVendorProcessGroup,
});
if (result.error) {
console.error(result.error.message);
process.exit(1);
let spawnFailed = false;
let forwardedSignal = null;
const forwardedSignals = ["SIGINT", "SIGTERM"];
function forwardSignal(signal) {
forwardedSignal = signal;
if (child.exitCode === null && child.signalCode === null) {
if (process.platform === "win32") {
child.kill(signal);
return;
}
if (!isolateVendorProcessGroup) {
// Ctrl-C is generated for the whole foreground process group, including
// the vendor. SIGTERM is not terminal-generated and still needs an
// explicit handoff when a process manager targets only this wrapper.
if (signal === "SIGTERM") {
child.kill(signal);
}
return;
}
try {
// detached makes the vendor PID the leader of its POSIX process group.
// Signal the whole group so any subprocesses inherit the same shutdown.
process.kill(-child.pid, signal);
} catch (error) {
// The group may have completed between the state check and kill.
if (error.code !== "ESRCH") {
throw error;
}
}
}
}
process.exit(result.status === null ? 1 : result.status);
const signalHandlers = new Map(
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
);
for (const signal of forwardedSignals) {
process.on(signal, signalHandlers.get(signal));
}
child.on("error", (error) => {
spawnFailed = true;
console.error(error.message);
});
child.on("close", (code, signal) => {
for (const forwarded of forwardedSignals) {
process.removeListener(forwarded, signalHandlers.get(forwarded));
}
const exitSignal = forwardedSignal || signal;
if (exitSignal && process.platform !== "win32") {
process.kill(process.pid, exitSignal);
return;
}
process.exitCode = spawnFailed || code === null ? 1 : code;
});
+55 -8
View File
@@ -1,6 +1,11 @@
# CLI Help / Schema 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
本文定义两种受控 flag 迁移:
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
@@ -44,7 +49,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
scripts/policy/interface-migrations/approved-command-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
## 跨命令迁移原语
@@ -68,6 +75,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
`state=consumed` 的 flag rename 回执作为前驱边。例如
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
@@ -86,22 +104,22 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, 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 的 requiredness 与 legacy 迁移前完全一致 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
@@ -146,6 +164,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
```json
{
"version": 1,
"migrations": [
{
"kind": "requiredness_change",
"command": "dws report entry submit",
"flag": {
"name": "to-user-ids",
"before": {"present": true, "type": "string", "scope": "local"},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reject report submissions that have no visible recipient."
}
]
}
```
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
@@ -166,10 +205,11 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -177,6 +217,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
@@ -204,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
任何不相干漂移继续阻塞。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
+6 -3
View File
@@ -3,7 +3,7 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Products**: 13
- **Total commands**: 160
- **Total commands**: 163
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
_Group chats, conversations, messages, and robot/webhook integrations._
**23 commands**
**26 commands**
| Command | Description | When to use |
|---|---|---|
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
+454
View File
@@ -0,0 +1,454 @@
# AI 表格数据源指令使用指南
## 概述
dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。
所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。
## 指令速览
| 指令 | 用途 | 读写 | 风险 |
|------|------|------|------|
| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low |
| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low |
| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium |
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium |
| `+datasource-sync` | 手动触发一次同步 | 写 | medium |
| `+datasource-sync-status` | 查询同步任务状态 | 读 | low |
| `+datasource-get-config` | 查看数据源表配置 | 读 | low |
## 前置条件
1. **登录认证**:执行 `dws auth login` 确保已登录
2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID
---
## 1. 列出数据源可用来源
```
dws aitable +datasource-list-sources [flags]
```
列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
### 示例
```bash
# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode)
dws aitable +datasource-list-sources \
--base-id BASE123 \
--datasource-type OA
```
### 返回值
返回 `sources` 数组,每个条目包含:
| 字段 | 说明 |
|------|------|
| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` |
| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) |
| `sourceUrl` | 数据源访问链接,可选 |
`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。
---
## 2. 获取数据源可同步字段
```
dws aitable +datasource-get-fields [flags]
```
获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` |
### 示例
```bash
# 获取某审批模板的可同步字段
dws aitable +datasource-get-fields \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
```
### 返回值
返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。
---
## 3. 创建数据源表
```
dws aitable +datasource-create [flags]
```
为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) |
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) |
| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 |
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
### source-config 格式(审批类)
审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` |
| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 |
| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 |
| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 |
| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` |
| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` |
| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` |
| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` |
| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false |
> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。
按 `dataType` 选择对应的时间参数组合:
| dataType | 需要的时间字段 | 说明 |
|----------|----------------|------|
| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) |
| `start_time` | `startDate` | 同步从某日期至今的数据 |
| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 |
### auto-sync-setting 格式
`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 |
| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` |
| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` |
| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 |
| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false |
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
### 示例
```bash
# 基本创建——同步近 30 天审批数据
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 指定日期范围创建并开启自动同步
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
--auto
# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取)
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
--field-ids fldAAA,fldBBB,fldCCC
```
### 返回值
创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。
---
## 4. 更新数据源配置
```
dws aitable +datasource-update [flags]
```
更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) |
| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` |
| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 |
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 |
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
### 示例
```bash
# 更换审批模板并调整时间范围
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 开启自动同步
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--auto
# 更新同步字段范围
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--field-ids fldAAA,fldDDD
```
> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。
---
## 5. 触发手动同步
```
dws aitable +datasource-sync [flags]
```
对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) |
### 示例
```bash
# 同步单张表
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL1
# 批量同步多张表(逗号分隔,最多 5 个)
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL1,TBL2,TBL3
```
### 返回值
返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。
---
## 6. 查询同步状态
```
dws aitable +datasource-sync-status [flags]
```
按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 数据源表 ID |
| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) |
### 示例
```bash
# 按任务 ID 查询(批量,最多 5 个)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK1,TASK2
```
---
## 7. 获取数据源配置
```
dws aitable +datasource-get-config [flags]
```
获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。
### 参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `--base-id` | string | 是 | 目标 Base ID |
| `--table-id` | string | 是 | 数据源表 ID |
### 示例
```bash
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
```
---
## 典型工作流
### 场景一:从零接入审批数据
```bash
# 0. 获取 Base ID
dws aitable +base-search --query "我的项目表"
# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode
dws aitable +datasource-list-sources \
--base-id BASE123 \
--datasource-type OA
# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX
# 2. 查看可同步字段(可选,用于指定 field-ids)
dws aitable +datasource-get-fields \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 3. 创建数据源表(创建后自动触发首次同步)
dws aitable +datasource-create \
--base-id BASE123 \
--datasource-type OA \
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# → 返回 tableId=TBL456, taskId=TASK001
# 4. 查询首次同步是否完成
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK001
# 5. 确认配置
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
```
### 场景二:更换审批模板后重新同步
```bash
# 1. 更新源配置(更新后自动触发一次同步)
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
# 2. 查询同步状态(更新后会返回新的 taskId)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK002
```
### 场景三:手动触发日常同步
```bash
# 仅触发同步,不修改配置
dws aitable +datasource-sync \
--base-id BASE123 \
--table-ids TBL456
# 查询结果(sync 会返回 taskId)
dws aitable +datasource-sync-status \
--base-id BASE123 \
--table-id TBL456 \
--task-ids TASK001
```
### 场景四:开启自动同步后确认
```bash
# 1. 更新配置,开启自动同步
dws aitable +datasource-update \
--base-id BASE123 \
--table-id TBL456 \
--auto
# 2. 确认配置已更新
dws aitable +datasource-get-config \
--base-id BASE123 \
--table-id TBL456
# → 返回中应显示 auto=true
```
---
## 通用选项
以下全局选项可在所有指令中使用:
| 选项 | 说明 |
|------|------|
| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv |
| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) |
| `--fields` | 筛选输出字段(逗号分隔) |
| `--dry-run` | 预览操作内容,不实际执行 |
| `--profile` | 指定组织或账号 |
| `--timeout` | HTTP 请求超时时间(秒,默认 30) |
| `--debug` | 显示调试日志 |
| `-v, --verbose` | 显示详细日志 |
### 输出过滤示例
```bash
# 只取 tableId
dws aitable +datasource-create ... --jq '.tableId'
# 只取同步状态
dws aitable +datasource-sync-status ... --jq '.status'
# table 格式查看
dws aitable +datasource-get-config ... -f table
```
---
## 注意事项
1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。
2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。
3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。
4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。
5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。
6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。
7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。
+10 -5
View File
@@ -23,7 +23,7 @@
`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 被再次触发。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
@@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
`.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 同时提交生成的章节与归档移动,供审计复核。
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
模板。因此并发 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 证据。
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
+20 -1
View File
@@ -274,6 +274,7 @@ Definition(仅声明;不可编译)
| 层 | 含义 | 今日落点 |
|---|---|---|
| **声明(declare)** | `corecmd.Spec` / `LeafSpec` / `ContractDecl` **数据字段**(声明证据;交付见下) | `Flags`/`Constraints`/`Risk`/`ConstParams`/`Contract`;类型真身在 `corecmd/contract`(DTO:`SafetySpec`/`ParamDecl`/`ProductDecl`/`ContractFinalPayload`;**无** Cobra store) |
| **非叶声明(group declare)** | owning Cobra 命令上的完整 `corecmd.GroupPolicy`;不是 leaf `Spec` 字段 | `Mode` / `Positionals` / `Recovery` 经 `corecmd.ApplyGroupPolicy` 一次编译为 Cobra 行为与私有框架元数据 |
| **框架转换** | 类型转换并注册(**禁止** JSON 注解桥) | `embedContractDecl` → `corecmd/contractfinal.RegisterRuntimeContractFinal`(annotate + store;全部调用方直调,`corecmd.New` 内部注册) |
| **注解 seam** | Cobra `dws.schema.*` 写入 | `internal/corecmd/runtimeannotate.AnnotateRuntime*`(框架侧;`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/runtimeannotate` 垫片包已删,一律直引 corecmd) |
| **Schema 透传** / 交付 | 组装读取注册表,原样投影为 `ToolSpec`;`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`(`ResolveMeta` 自同一组装投影);go:embed 仅限 reviewed 输入(MCP meta / `param_concepts` 等;reviewed `schema_command_registry/` 已退役,identity 由 collector 收集),映射排除走 Go ledger(`schema_parameter_mapping_ledger.go`),不得 embed Catalog | `internal/cli` 根(交付边界);ContractFinal store 在 `corecmd/contractfinal`(`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/contractfinal` 垫片包已删) |
@@ -310,7 +311,25 @@ Definition(仅声明;不可编译)
3. 写副作用:新 Leaf 声明完整 `SafetySpec`(框架 `ConfirmSafety` + Schema Final);未迁移旧路径显式标注 `runtime_gate`;二者皆无则不合格;
4. Schema `ToolSpec` 全字段组均落在 §5.0.4 表中某一权威格,禁止无主字段。
#### 5.0.2a 三档声明路径(Tier1 / Tier2 / Tier3)
#### 5.0.2a 非叶命令契约(`corecmd.GroupPolicy`)
`corecmd.Spec` / `LeafSpec` 继续只定义叶命令。每个拥有子命令的 owning Cobra 命令必须在构造处通过 `corecmd.ApplyGroupPolicy` 声明一份完整 `GroupPolicy`:
| 轴 | 允许值 | 语义 |
|---|---|---|
| `Mode` | `navigation_only` / `hybrid` | 仅导航并展示帮助,或同时保留本命令业务执行 |
| `Positionals` | `reject` / `allow` | 未匹配 token 进入命令恢复,或由本命令业务位置参数消费 |
| `Recovery` | `sibling` / `deep` / `disabled` | 只建议直接子命令、显式允许后代路径恢复,或完全关闭恢复 |
硬规则:
1. 三个字段必须同时声明;全零值只表示 leaf,不能应用到命令。`navigation_only` 必须 `Positionals=reject`;`Positionals=allow` 必须 `Recovery=disabled`,避免业务 argv 与命令恢复争抢同一 token。
2. `ApplyGroupPolicy` 是唯一编译入口:navigation 安装统一 help/错误 handler;hybrid 保留 owning `RunE`,仅在声明拒绝 positionals 且开启恢复时包裹 unknown-command 分支。恢复统一投影为有界 `CommandResolution`(最多 3 个建议 + 当前 parent `--help`);只有 `Recovery=deep` 才可建议完整后代路径。
3. `GroupPolicy` **不推导** `TraverseChildren`。该 Cobra 字段会改变父级 local flag 是否向子命令传播,必须由原 owning command 显式保留,不能因迁移到 typo guidance 而扩大参数表面。
4. 最终装配树门禁检查「有 children 必须有 GroupPolicy、leaf 不得残留 GroupPolicy、navigation/hybrid handler 与声明结构一致」。门禁不执行任意 `Args` 函数;`ApplyGroupPolicy` 对 `cobra.NoArgs` / `cobra.ArbitraryArgs` 的编译由 corecmd 单测覆盖。
5. 命令树合并时,两侧非空 group 都必须先声明 policy;冲突声明、group 与 runnable/parse-bearing leaf 合并、或带 children 的未声明节点均 fail closed。纯 metadata 空壳可采用 typed source policy,不能借此吞掉 flags、hooks 或执行体。
#### 5.0.2b 三档叶声明路径(Tier1 / Tier2 / Tier3)
当前生产允许的三档路径(同一 `ContractFinal` 语义;不是互相否定):
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,6 +10,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
@@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
{path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
+11
View File
@@ -1295,6 +1295,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
}
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
hasTarget := false
for _, id := range subscribeIDs {
if strings.TrimSpace(id) != "" {
hasTarget = true
break
}
}
if !hasTarget {
return nil
}
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
return nil
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
+13 -1
View File
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
})
}
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
oldStop := personalStopConsumers
oldQuery := personalQueryStatus
oldFind := personalFindProcess
@@ -746,6 +746,18 @@ func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
personalSignalProcess = oldSignal
}()
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
t.Fatal("targeted stop called without a subscribe_id")
return transport.ConsumerStopResp{}, nil
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy status queried without a subscribe_id")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
t.Fatalf("empty target stop = %v", err)
}
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"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/pkg/mcptypes"
"github.com/spf13/cobra"
)
// TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy replaces the
// old helpers-only AST scan with an invariant over the two real assembly
// products: the deterministic distribution tree and a runtime tree after a
// nested plugin overlay has been merged.
func TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy(t *testing.T) {
distribution := NewSchemaSourceRootCommand()
for _, path := range []string{
"sheet range read",
"pat chmod",
"plugin list",
"chat +chat-messages",
} {
requireFinalCommandPath(t, distribution, path)
}
if err := cobracmd.ValidateGroupTree(distribution); err != nil {
t.Fatalf("distribution command tree GroupPolicy invariant: %v", err)
}
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
testseam.Swap(t, &rootLoadPlugins, func(root *cobra.Command, _ *pipeline.Engine, runner executor.Runner) []*cobra.Command {
descriptor := conferencePluginDescriptor()
return buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, root)
})
runtime := NewRootCommand()
requireFinalCommandPath(t, runtime, "conference camera open")
if err := cobracmd.ValidateGroupTree(runtime); err != nil {
t.Fatalf("runtime command tree GroupPolicy invariant: %v", err)
}
}
func requireFinalCommandPath(t *testing.T, root *cobra.Command, path string) *cobra.Command {
t.Helper()
command, remaining, err := root.Find(strings.Fields(path))
if err != nil || command == nil || len(remaining) != 0 || command == root {
t.Fatalf("final command path %q not assembled: command=%v remaining=%v err=%v", path, command, remaining, err)
}
return command
}
@@ -0,0 +1,175 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
root := NewRootCommand()
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
}
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
registered := 0
for _, item := range shortcut.All() {
if item.Service == "markdown" {
registered++
}
}
if registered != 0 {
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
}
type route struct {
canonical string
confirmation string
flags []string
}
routes := map[string]route{
"create": {
canonical: "markdown.create", confirmation: "not_required",
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
},
"fetch": {
canonical: "markdown.fetch", confirmation: "not_required",
flags: []string{"node", "output", "space-id", "workspace"},
},
"overwrite": {
canonical: "markdown.overwrite", confirmation: "user_required",
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
},
"patch": {
canonical: "markdown.patch", confirmation: "user_required",
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
},
"diff": {
canonical: "markdown.diff", confirmation: "not_required",
flags: []string{"context", "file", "node", "version", "version2"},
},
}
group := mustFindCommand(t, root, "markdown")
children := map[string]bool{}
for _, child := range group.Commands() {
children[child.Name()] = true
}
if len(children) != len(routes) {
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
}
for name, want := range routes {
leaf := mustFindCommand(t, root, "markdown", name)
if leaf.Hidden || !leaf.Runnable() {
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
}
if !children[name] {
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
}
for _, flag := range want.flags {
if leaf.Flags().Lookup(flag) == nil {
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
}
}
if shortcut.InPublicCatalog("markdown", "+"+name) {
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
}
meta, ok := cli.ResolveMeta("markdown " + name)
if !ok {
t.Errorf("markdown %s missing from assembled Schema", name)
continue
}
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
}
if meta.Safety.Confirmation != want.confirmation {
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
}
tool := tools[want.canonical]
if tool == nil {
t.Errorf("markdown %s missing from full delivery Schema", name)
continue
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("markdown %s availability=%q, want available", name, got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
}
}
}
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
driveShortcuts := map[string]string{
"+copy": "drive.shortcut_copy",
"+delete": "drive.shortcut_delete",
"+find-file": "drive.shortcut_find_file",
"+list": "drive.shortcut_list",
"+move": "drive.shortcut_move",
"+publish-get": "drive.shortcut_publish_get",
"+recycle-restore": "drive.shortcut_recycle_restore",
"+rename": "drive.shortcut_rename",
"+version-download": "drive.shortcut_version_download",
"+version-get": "drive.shortcut_version_get",
"+version-history": "drive.shortcut_version_history",
"+version-revert": "drive.shortcut_version_revert",
}
for name, canonical := range driveShortcuts {
leaf := mustFindCommand(t, root, "drive", name)
if leaf.Hidden || !leaf.Runnable() {
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
}
if !shortcut.InPublicCatalog("drive", name) {
t.Errorf("drive %s is not in the public Shortcut catalog", name)
}
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
}
ordinaryRoutes := map[string]string{
"drive permission list": "drive.list_permission",
"drive pull": "drive.folder_pull",
"drive push": "drive.folder_push",
"drive status": "drive.folder_status",
"drive sync": "drive.folder_sync",
"wiki node list": "wiki.list_nodes",
}
for cliPath, canonical := range ordinaryRoutes {
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
}
}
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
t.Helper()
meta, ok := cli.ResolveMeta(cliPath)
if !ok {
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
return
}
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
}
tool := tools[canonical]
if tool == nil {
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
return
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
}
}
+6
View File
@@ -21,6 +21,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
@@ -43,6 +44,11 @@ func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
return cmd.Help()
},
}
corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
group.AddCommand(newMCPURLGetCommand(caller))
return group
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
for _, canonical := range []string{
"oa.shortcut_approve_by",
"oa.shortcut_done_approvals",
"oa.shortcut_list_cc",
"oa.shortcut_list_executed",
"oa.shortcut_list_forms",
"oa.shortcut_list_pending",
"oa.shortcut_list_submitted",
"oa.shortcut_my_initiated",
"oa.shortcut_pending",
"oa.shortcut_search_forms",
} {
tool, ok := snapshot.Tools[canonical]
if !ok {
t.Errorf("final Schema lacks OA tool %s", canonical)
continue
}
if got := tool["availability"]; got != contract.InterfaceAvailable {
t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable)
}
}
}
+28
View File
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"success":true,"result":[]}`
case "list_suggested_event_times":
return `{"success":true,"result":{"recommendEventTimes":[]}}`
case "list_by_keyword_and_time_range":
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
case "get_minutes_basic_info":
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
case "get_minutes_transcription":
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
case "create_personal_todo":
return `{"success":true,"result":{"taskId":"task-1"}}`
case "get_todo_detail":
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
case "get_user_todos_in_current_org":
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
case "add_todo_reminder":
return `{"success":true}`
case "copy_document":
return `{"success":true,"nodeId":"copy-1"}`
case "move_document", "add_member", "update_member", "remove_member":
return `{"success":true}`
case "get_document_info":
if len(c.calls) > 1 {
switch c.calls[len(c.calls)-2].tool {
case "copy_document":
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
case "move_document":
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
}
}
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
case "create_calendar_event":
return `{"success":true,"result":{"eventId":"event-1"}}`
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
@@ -148,12 +148,12 @@ var paramAliasCompleteCommands = map[string][]string{
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1"},
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
@@ -221,21 +221,196 @@ var paramAliasCompleteCommands = map[string][]string{
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder"},
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
}
// paramAliasCandidateCompleteCommands contains complete invocations for
// reviewed parameter-concept product drafts. Keeping candidate-only commands
// in a separate map lets a test change land before a draft replaces the formal
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
"contact +org": {"contact", "+org", "--name", "Fixture User"},
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
"contact +team": {"contact", "+team", "--name", "Fixture User"},
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
"report +template-search": {"report", "+template-search", "--query", "fixture"},
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
}
// A command can expose more than one mutually exclusive canonical route. In
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"markdown create": {
"file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"},
},
"markdown diff": {
"file": {"markdown", "diff", "--node", "node-1", "--file", "../../README.md", "--context", "3"},
},
"markdown overwrite": {
"file": {"markdown", "overwrite", "--node", "node-1", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
"dry-run": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--dry-run"},
},
"markdown patch": {
"dry-run": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--dry-run"},
},
"doc +copy": {
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
@@ -531,6 +706,20 @@ var paramAliasNewConfirmationCases = []struct {
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
}
// Candidate confirmation cases become active with the joint draft. One write
// workflow per product plus TODO's reminder workflow proves semantic aliasing
// cannot move execution across the shared --yes barrier.
var paramAliasCandidateConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
{command: "todo +create", emitted: "deadline", canonical: "due"},
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
// old concept aliases, command overrides, native compatibility flags, read and
// write commands, and different products. Every reviewed alias is still
@@ -600,6 +789,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
// Candidate representatives exercise the final transport boundary for each
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
// exists in the loaded reviewed table, so the tests are mergeable before the
// joint draft is promoted to internal/cli/param_concepts.json.
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
paramAliasPayloadCaseKey("todo comment add", "text"): true,
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
paramAliasPayloadCaseKey("todo task update", "status"): true,
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
}
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
// separate from the long-lived app-c race process. Each case still executes
// both canonical and alias argv through the real PreParse/Cobra path and
@@ -709,6 +922,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands := make(map[string]bool)
activeFixtureCases := make(map[string]bool)
activeCases := 0
executedRepresentatives := make(map[string]bool)
for _, fixture := range concepts.Fixture {
@@ -717,6 +931,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
activeCommands[fixture.Command] = true
activeCases++
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
activeFixtureCases[caseKey] = true
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
@@ -729,8 +945,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
continue
}
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasRepresentativePayloadCases[caseKey] {
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
continue
}
executedRepresentatives[caseKey] = true
@@ -742,26 +957,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if activeCases == 0 {
t.Fatal("reviewed fixture contains no active alias cases")
}
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
for command := range paramAliasCompleteCommands {
if !activeCommands[command] {
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
}
templateCommands[command] = true
}
for command := range paramAliasCandidateCompleteCommands {
if activeCommands[command] {
templateCommands[command] = true
}
}
for command := range activeCommands {
if _, ok := paramAliasCompleteCommands[command]; !ok {
if !templateCommands[command] {
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
}
}
if len(activeCommands) != len(paramAliasCompleteCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
if len(activeCommands) != len(templateCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
}
for caseKey := range paramAliasRepresentativePayloadCases {
if !executedRepresentatives[caseKey] {
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
if !activeFixtureCases[caseKey] {
continue
}
activeRepresentatives++
if !executedRepresentatives[caseKey] {
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
}
}
if len(executedRepresentatives) != activeRepresentatives {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
}
}
@@ -852,6 +1084,122 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
}
}
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
// exercises every distinct reviewed mutating complete-command template in the
// reviewed product expansions. The fixture gate already proves every
// alias resolves through PreParse; this gate removes the confirmation flag
// from one active alias invocation per distinct template and requires the
// runtime boundary to stop it before the first transport call. An explicit
// --dry-run is a reviewed preview path and must not carry a bypass flag.
func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
requiredTemplates := make(map[string]bool)
coveredTemplates := make(map[string]bool)
for _, fixture := range concepts.Fixture {
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
continue
}
product, _, _ := strings.Cut(fixture.Command, " ")
switch product {
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
default:
continue
}
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
continue
}
_, yesCount := removeExactArg(complete, "--yes")
_, userSayYesCount := removeExactArg(complete, "--user-say-yes")
confirmationCount := yesCount + userSayYesCount
confirmationArg := "--yes"
if userSayYesCount == 1 {
confirmationArg = "--user-say-yes"
}
_, dryRunCount := removeExactArg(complete, "--dry-run")
if dryRunCount > 1 {
t.Errorf("template must contain --dry-run at most once: command=%q args=%v", fixture.Command, complete)
continue
}
if meta, exists := cli.ResolveMeta(fixture.Command); exists {
switch meta.Safety.Confirmation {
case "user_required":
if dryRunCount == 1 {
if confirmationCount != 0 {
t.Errorf("Schema-confirmed dry-run template must not contain a confirmation bypass flag: command=%q args=%v", fixture.Command, complete)
}
continue
}
if confirmationCount != 1 {
t.Errorf("Schema-confirmed template must contain exactly one reviewed confirmation flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
continue
}
case "not_required":
if confirmationCount != 0 {
t.Errorf("Schema-unconfirmed template must not contain a confirmation bypass flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
continue
}
}
}
if confirmationCount == 0 {
continue
}
if confirmationCount != 1 {
t.Errorf("confirmation template must contain exactly one reviewed confirmation flag: command=%q args=%v", fixture.Command, complete)
continue
}
templateKey := fixture.Command + "\x00" + strings.Join(complete, "\x00")
requiredTemplates[templateKey] = true
if coveredTemplates[templateKey] {
continue
}
aliasArgs, replacements := replaceLongFlag(complete, fixture.Expect, fixture.Emitted)
if replacements != 1 {
t.Errorf("confirmation template for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, complete)
continue
}
coveredTemplates[templateKey] = true
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
assertTemplatedParamAliasCannotBypassConfirmation(t, fixture.Command, confirmationArg, aliasArgs)
})
}
if len(requiredTemplates) == 0 {
t.Fatal("reviewed complete-command templates contain no confirmation cases")
}
if len(coveredTemplates) != len(requiredTemplates) {
t.Fatalf("templated confirmation coverage = %d, want %d", len(coveredTemplates), len(requiredTemplates))
}
}
func assertTemplatedParamAliasCannotBypassConfirmation(t *testing.T, command, confirmationArg string, aliasArgs []string) {
t.Helper()
unconfirmedArgs, removals := removeExactArg(aliasArgs, confirmationArg)
if removals != 1 {
t.Fatalf("confirmation template must contain %s exactly once; removals=%d args=%v", confirmationArg, removals, aliasArgs)
}
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
if ctx == nil {
t.Fatal("unconfirmed alias command skipped PreParse")
}
var appErr *apperrors.Error
if errors.As(err, &appErr) && appErr.Reason == "confirmation_required" {
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed alias crossed the transport boundary before confirmation: args=%v calls=%#v", unconfirmedArgs, caller.calls)
}
return
}
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\ncommand=%q args=%v calls=%#v", err, command, unconfirmedArgs, caller.calls)
}
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
t.Helper()
canonicalCaller := &paramAliasCaptureCaller{}
@@ -1091,7 +1439,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
}
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewConfirmationCases {
tests := append([]struct {
command string
emitted string
canonical string
}{}, paramAliasNewConfirmationCases...)
for _, candidate := range paramAliasCandidateConfirmationCases {
entry, exists := cli.LookupParamAlias(candidate.command)
target, active := entry.ResolveAlias(candidate.emitted)
if exists && active && target == candidate.canonical {
tests = append(tests, candidate)
}
}
for _, test := range tests {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
@@ -1173,6 +1534,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
return variant, true
}
}
if ok {
return complete, true
}
complete, ok = paramAliasCandidateCompleteCommands[command]
return complete, ok
}
+6 -1
View File
@@ -24,6 +24,7 @@ import (
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
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/output"
@@ -747,7 +748,11 @@ func pruneEmptyPluginGroups(parent *cobra.Command) {
}
for _, child := range append([]*cobra.Command(nil), parent.Commands()...) {
pruneEmptyPluginGroups(child)
if cmdutil.IsGroup(child) && len(child.Commands()) == 0 {
_, group, err := corecmd.GroupPolicyFor(child)
if err != nil {
panic(fmt.Sprintf("prune plugin group %q: %v", child.CommandPath(), err))
}
if group && len(child.Commands()) == 0 {
parent.RemoveCommand(child)
}
}
+39 -1
View File
@@ -12,6 +12,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -30,6 +31,42 @@ func (pluginWrongFlagValue) String() string { return "" }
func (pluginWrongFlagValue) Set(string) error { return nil }
func (pluginWrongFlagValue) Type() string { return "wrong" }
func TestCrossPlatformCoveragePruneEmptyPluginGroupsRejectsMalformedPolicy(t *testing.T) {
emptyParent := &cobra.Command{Use: "plugin"}
emptyGroup := &cobra.Command{Use: "empty"}
corecmd.ApplyGroupPolicy(emptyGroup, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
emptyParent.AddCommand(emptyGroup)
pruneEmptyPluginGroups(emptyParent)
if len(emptyParent.Commands()) != 0 {
t.Fatalf("empty plugin group was not pruned: %#v", emptyParent.Commands())
}
parent := &cobra.Command{Use: "plugin"}
child := &cobra.Command{Use: "group"}
corecmd.ApplyGroupPolicy(child, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
for key := range child.Annotations {
child.Annotations[key] = "malformed"
}
parent.AddCommand(child)
defer func() {
got := recover()
message, ok := got.(string)
if !ok || !strings.Contains(message, "prune plugin group") {
t.Fatalf("pruneEmptyPluginGroups panic = %v", got)
}
}()
pruneEmptyPluginGroups(parent)
}
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
invalidRoot := conferencePluginDescriptor()
invalidRoot.CLI.Command = "Invalid Root"
@@ -507,7 +544,8 @@ func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
mergePluginRoot(nil, root)
mergePluginRoot(root, nil)
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
source := cobracmd.NewGroupCommand("plugin", "plugin")
source.Aliases = []string{"one", "two"}
source.AddCommand(&cobra.Command{Use: "leaf"})
mergePluginRoot(destination, source)
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
@@ -17,6 +17,7 @@ import (
"bytes"
stderrors "errors"
"io"
"slices"
"strings"
"testing"
@@ -55,6 +56,44 @@ func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t
}
}
func TestCrossPlatformCoverageFuzzyRootBooleanBetweenGroupAndLeafKeepsLeafPreParse(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{
"aisearch", "--query", "Alice", "--yess", "enterprise",
"--queries", "fixture", "--content-types", "document", "--time_range", "本周", "--help",
}
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
}
if ctx == nil || ctx.Command != "dws aisearch enterprise" ||
!slices.Contains(ctx.Args, "--yes") || !slices.Contains(ctx.Args, "--types") || !slices.Contains(ctx.Args, "--time-range") {
t.Fatalf("group-middle fuzzy flag skipped leaf PreParse: context=%#v", ctx)
}
if err := root.Execute(); err != nil {
t.Fatalf("corrected group-middle persistent flag failed: %v", err)
}
}
func TestCrossPlatformCoverageProtectedFlagChildNameValueStaysOnOwningCommand(t *testing.T) {
for _, args := range [][]string{
{"aisearch", "--types", "enterprise"},
{"aisearch", "--types", "false", "enterprise"},
{"aisearch", "--types=false", "enterprise"},
} {
root := NewSchemaSourceRootCommand()
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
}
if ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") || !slices.Equal(ctx.Args, args) {
t.Fatalf("protected child-name value selected wrong command: args=%v context=%#v", args, ctx)
}
}
}
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
@@ -105,6 +144,7 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
args []string
wantReason string
wantCommand string
wantHint string
}{
{
name: "unknown shortcut",
@@ -118,6 +158,13 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
wantReason: "unknown_subcommand",
wantCommand: "dws dev app",
},
{
name: "unknown aisearch subcommand before protected flag",
args: []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise", "--format", "json"},
wantReason: "unknown_subcommand",
wantCommand: "dws aisearch",
wantHint: "dws aisearch enterprise",
},
}
for _, test := range tests {
@@ -135,6 +182,9 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
t.Fatalf("structured error = %#v", structured)
}
if test.wantHint != "" && !strings.Contains(structured.Hint, test.wantHint) {
t.Fatalf("hint = %q, want %q", structured.Hint, test.wantHint)
}
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
t.Fatalf("command error leaked flag classification: %#v", structured)
}
+6 -2
View File
@@ -23,6 +23,7 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
@@ -653,8 +654,11 @@ func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
if err == nil {
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
t.Fatalf("error = %v, want auth switch unknown command", err)
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" ||
structured.Message != `unknown subcommand "switch" for "dws auth"` ||
structured.Hint != "Run 'dws auth --help' for the full list" {
t.Fatalf("error = %#v, want bounded auth subcommand guidance", err)
}
}
+33 -10
View File
@@ -30,6 +30,7 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
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"
@@ -924,6 +925,11 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
return nil
},
}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
bindPersistentFlags(root, flags)
@@ -935,25 +941,42 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
mcpCmd.AddCommand(newMCPURLGroup(patCaller))
navigationGroup := func(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
hybridGroup := func(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
newProfileCommand(),
navigationGroup(newAuthCommand(patCaller)),
navigationGroup(newProfileCommand()),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
navigationGroup(newSkillCommand()),
hybridGroup(newCacheCommand()),
newCatalogCommand(),
newConfigCommand(),
navigationGroup(newConfigCommand()),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(flags),
newAuditCommand(),
hybridGroup(newRecoveryCommand()),
navigationGroup(newEventCommand(flags)),
navigationGroup(newAuditCommand()),
newCompletionCommand(root),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
usage.NewShortcutCommand(),
navigationGroup(usage.NewShortcutCommand()),
schemaCmd,
mcpCmd,
navigationGroup(mcpCmd),
}
root.AddCommand(utilityCommands...)
+4 -2
View File
@@ -145,8 +145,10 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
t.Fatalf("dws %s error = %#v, want migration hint %q", strings.Join(tc.args, " "), structured, tc.hint)
}
}
+2
View File
@@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
@@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
+3
View File
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat emotion favorite",
"chat emotion list",
"chat emotion send",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
@@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
tool := snapshot.Tools["oa.attachment_upload"]
if tool == nil {
t.Fatal("oa.attachment_upload is missing from final Schema")
}
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Fatalf("interface_mode = %q, want composite", got)
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Fatalf("availability = %q, want available", got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Fatal("composite upload command must document an interface reason")
}
if got := schemaContractString(tool["effect"]); got != "write" {
t.Fatalf("effect = %q, want write", got)
}
if got := schemaContractString(tool["risk"]); got != "low" {
t.Fatalf("risk = %q, want low", got)
}
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
t.Fatalf("confirmation = %q, want not_required", got)
}
parameters := schemaContractMap(tool["parameters"])
for _, flag := range []string{"file", "file-name", "md5"} {
if parameters[flag] == nil {
t.Fatalf("upload --%s is missing from final Schema", flag)
}
}
if required, _ := parameters["file"]["required"].(bool); !required {
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
}
result := schemaContractMap(tool["result"])
dataSchema := schemaContractMap(result["data_schema"])
properties := schemaContractMap(dataSchema["properties"])
if properties["fileId"] == nil {
t.Fatal("upload Result data_schema is missing fileId")
}
}
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
t.Helper()
result, ok := tool["result"].(map[string]any)
@@ -0,0 +1,70 @@
package app
import (
"testing"
)
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
"sheet.create_float_image",
"sheet.update_float_image",
)
create := payload.Tools["sheet.create_float_image"]
if create == nil {
t.Fatal("missing sheet.create_float_image")
}
if create["interface_mode"] != "mcp" {
t.Fatalf("create interface mode = %#v", create["interface_mode"])
}
createRef, _ := create["interface_ref"].(map[string]any)
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
t.Fatalf("create interface ref = %#v", createRef)
}
createDryRun, _ := create["dry_run"].(map[string]any)
if createDryRun["preview_kind"] != "request" {
t.Fatalf("create dry-run = %#v", createDryRun)
}
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
}
createParameters, _ := create["parameters"].(map[string]any)
file, _ := createParameters["file"].(map[string]any)
src, _ := createParameters["src"].(map[string]any)
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
t.Fatalf("create --file metadata = %#v", file)
}
if schemaContractString(file["property"]) != "" {
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
}
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
t.Fatalf("create --src compatibility metadata = %#v", src)
}
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
update := payload.Tools["sheet.update_float_image"]
if update == nil {
t.Fatal("missing sheet.update_float_image")
}
updateDryRun, _ := update["dry_run"].(map[string]any)
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
}
updateParameters, _ := update["parameters"].(map[string]any)
updateFile, _ := updateParameters["file"].(map[string]any)
if schemaContractString(updateFile["property"]) != "" {
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
}
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
root := NewRootCommand()
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
command := exactCommandForTest(root, cliPath)
if command == nil || command.Flags().Lookup("file") == nil {
t.Fatalf("%s has no executable --file flag", cliPath)
}
}
}
+129 -5
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 435
publicShortcutCount = 436
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 438
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 493
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 435
publiclyDeliveredShortcutCount = 436
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 217; got != want {
if got, want := int(product["count"].(float64)), 220; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
assertChatCatalogCompleteLeafContracts(t)
}
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
for _, tc := range []struct {
cliPath string
params map[string]string
}{
{
cliPath: "chat emotion list",
},
{
cliPath: "chat emotion send",
params: map[string]string{
"media-id": "mediaId",
"emotion-id": "emotionId",
"group": "openConversationId",
"open-dingtalk-id": "receiverOpenDingTalkId",
"idempotency-key": "uuid",
},
},
{
cliPath: "chat emotion favorite",
params: map[string]string{
"media-id": "mediaId",
"name": "name",
"source-conversation-id": "sourceConversationId",
"source-message-id": "sourceMessageId",
},
},
} {
t.Run(tc.cliPath, func(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
}
reason := schemaContractString(leaf["interface_reason"])
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
}
parameters := schemaContractMap(leaf["parameters"])
for name, want := range tc.params {
parameter := parameters[name]
if parameter == nil {
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
}
if got := schemaContractString(parameter["property"]); got != want {
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
}
}
})
}
}
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
result, _ := leaf["result"].(map[string]any)
@@ -220,6 +271,60 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
}
}
func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"}
checked := 0
for _, declared := range shortcut.All() {
if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
continue
}
if !strings.HasPrefix(declared.Command, "+datasource-") {
continue
}
if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" {
continue
}
checked++
canonical := shortcutSchemaCanonical(declared)
tool := tools[canonical]
if tool == nil {
t.Fatalf("delivery schema --all is missing %s", canonical)
}
examples := schemaContractStringSlice(tool["examples"])
if len(examples) == 0 {
t.Fatalf("%s has no delivered examples", canonical)
}
for _, example := range examples {
if !strings.Contains(example, "--source-config") {
continue
}
argv, err := cli.ParseAgentExampleArgv(example)
if err != nil {
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
}
sourceConfig := schemaExampleFlagValue(argv, "source-config")
if sourceConfig == "" {
t.Errorf("%s example %q contains --source-config but has no value", canonical, example)
continue
}
var cfg map[string]any
if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil {
t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err)
continue
}
for _, member := range requiredSourceConfigMembers {
if _, ok := cfg[member]; !ok {
t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member)
}
}
}
}
if checked != 3 {
t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked)
}
}
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
for _, argument := range argv {
for _, name := range names {
@@ -231,6 +336,25 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool {
return false
}
func schemaExampleFlagValue(argv []string, name string) string {
prefix := "--" + name + "="
for _, argument := range argv {
if argument == "--"+name {
continue
}
if strings.HasPrefix(argument, prefix) {
return strings.TrimPrefix(argument, prefix)
}
}
// Value may be in the next argv entry: `--flag value` form.
for i := 0; i < len(argv)-1; i++ {
if argv[i] == "--"+name {
return argv[i+1]
}
}
return ""
}
func assertSchemaSummarySafety(
t testing.TB,
summaries map[string]map[string]any,
+47 -8
View File
@@ -20,18 +20,50 @@ import (
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
message string
reason string
origin string
stage string
hint string
actions []string
operation string
retryable *bool
}
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
combined := text + " " + detail
if code == "999" &&
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
classified := serverFailureClass{
message: message,
reason: "upstream_internal_error",
origin: "dingtalk_api",
stage: "upstream_execution",
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
actions: []string{
"检查目标资源的当前状态,确认本次操作是否已经生效",
"状态未确认前不要直接重试写操作",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
},
}
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
retryable := false
classified.operation = "todo/create_personal_todo"
classified.retryable = &retryable
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
classified.actions = []string{
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
"确认没有创建成功后再重新提交",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
}
}
return classified, true
}
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
@@ -74,6 +106,7 @@ func newServerFailureAPIError(
fallbackReason string,
fallbackHint string,
serverKey string,
tool string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
@@ -84,7 +117,7 @@ func newServerFailureAPIError(
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
@@ -93,6 +126,12 @@ func newServerFailureAPIError(
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
if classified.operation != "" {
opts = append(opts, apperrors.WithOperation(classified.operation))
}
if classified.retryable != nil {
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
}
}
return apperrors.NewAPI(message, opts...)
}
@@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
@@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
"business_error",
"check parameters",
"chat",
"send_message",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
@@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
}
}
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
serverSaysRetryable := true
err := newServerFailureAPIError(
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
"business_error",
"The API returned a business-level error. Check required parameters and values.",
"todo",
"create_personal_todo",
apperrors.ServerDiagnostics{
TraceID: "trace-todo-create",
ServerErrorCode: "999",
ServerRetryable: &serverSaysRetryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.Operation != "todo/create_personal_todo" {
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
}
if !typed.RetryableSet || typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
}
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
t.Fatalf("hint = %q", typed.Hint)
}
for _, action := range typed.Actions {
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
t.Fatalf("misleading action = %q", action)
}
}
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "upstream_internal_error",
"origin": "dingtalk_api",
"stage": "upstream_execution",
"retryable": false,
"trace_id": "trace-todo-create",
"server_error_code": "999",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
@@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
@@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
@@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "999",
"trace_id": "trace-todo-replay",
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "todo",
Tool: "create_personal_todo",
CanonicalPath: "todo.create_personal_todo",
Params: map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": "fixture",
"executorIds": []string{"user-1"},
},
},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
}
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
}
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
}
@@ -0,0 +1,42 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/spf13/cobra"
)
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
for canonical, command := range map[string]string{
"whiteboard.shortcut_query": "+query",
"whiteboard.shortcut_update": "+update",
} {
leaf, _, err := root.Find([]string{"whiteboard", command})
if err != nil || leaf == nil || leaf.Name() != command {
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
} else if leaf.Hidden || !leaf.Runnable() {
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
}
tool := tools[canonical]
if tool == nil {
t.Errorf("public %s missing from delivery Schema surface", canonical)
continue
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("%s availability=%q, want available", canonical, got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Errorf("%s missing composite adapter reason", canonical)
}
if tool["interface_ref"] != nil {
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
}
}
}
+2
View File
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
+201 -1
View File
@@ -811,7 +811,207 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("refreshing token (dual-locked)")
}
return oauthRefreshToken(p, ctx, data)
refreshed, rErr := oauthRefreshToken(p, ctx, data)
if rErr == nil || !isRefreshTokenRejected(rErr) {
return refreshed, rErr
}
// A stale identity slot can survive an older organization-only refresh.
// Retry once with the same-corp organization mirror while holding the
// existing dual lock; the fallback marks the publication so the rotated
// credential is written back into the mirror slot it consumed.
logging.AuthDebug(
"auth.refresh.fallback.triggered",
"corp_id", strings.TrimSpace(data.CorpID),
"user_id", strings.TrimSpace(data.UserID),
"error", rErr,
)
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
if fErr != nil {
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
// The organization mirror may be absent for long-lived local logins
// that predate mirror publication. Recover from the legacy global
// slot before giving up.
if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil {
return recovered, nil
}
return nil, rErr
}
if p.logger != nil {
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
}
return fallback, nil
}
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
// the organization slot. The mirror must match the current corp, be valid,
// and differ from the rejected token. When both slots carry user identities,
// they must agree; legacy mirrors with an empty UserID are backfilled from the
// current identity before refresh.
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
if current == nil {
return nil, fmt.Errorf("no current token data")
}
corpID := strings.TrimSpace(current.CorpID)
if corpID == "" {
return nil, fmt.Errorf("current token has no corpId")
}
orgData, err := tokenLoadKeychainForCorpID(corpID)
if err != nil {
return nil, err
}
if orgData == nil {
return nil, ErrTokenDataNotFound
}
if strings.TrimSpace(orgData.CorpID) != corpID {
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
}
if !orgData.IsRefreshTokenValid() {
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
}
if orgData.RefreshToken == current.RefreshToken {
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
}
currentUserID := strings.TrimSpace(current.UserID)
orgUserID := strings.TrimSpace(orgData.UserID)
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
}
if orgUserID == "" {
orgData.UserID = current.UserID
orgData.UserName = current.UserName
}
// The refresh below consumes the mirror's refresh_token. Mark the
// publication so persistence writes the rotated credential back into the
// organization slot even under an explicit runtime selector whose plan
// would otherwise skip it (for example a preserved unresolved sibling).
orgData.RepairOrganizationMirror = true
refreshed, err := oauthRefreshToken(p, ctx, orgData)
if err != nil {
return nil, err
}
logging.AuthDebug(
"auth.refresh.fallback.success",
"corp_id", corpID,
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
)
return refreshed, nil
}
func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) {
var exchangeErr *MCPTokenExchangeError
if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() {
return nil, refreshErr
}
if selected == nil {
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.triggered",
"corp_id", strings.TrimSpace(selected.CorpID),
"user_id", strings.TrimSpace(selected.UserID),
"refresh_error_code", exchangeErr.Code,
)
legacy, loadErr := tokenLoadKeychain()
if loadErr != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr)
return nil, refreshErr
}
if legacy == nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy")
return nil, refreshErr
}
if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) {
logging.AuthDebug("auth.refresh.legacy_recovery.failed",
"step", "candidate_mismatch",
"legacy_corp_id", strings.TrimSpace(legacy.CorpID),
"legacy_user_id", strings.TrimSpace(legacy.UserID),
)
return nil, refreshErr
}
recovered := *legacy
if strings.TrimSpace(recovered.UserID) == "" {
recovered.UserID = strings.TrimSpace(selected.UserID)
}
if strings.TrimSpace(recovered.UserName) == "" {
recovered.UserName = strings.TrimSpace(selected.UserName)
}
if recovered.IsAccessTokenValid() {
if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err)
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token")
return &recovered, nil
}
if !recovered.IsRefreshTokenValid() {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired")
return nil, refreshErr
}
if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token")
return nil, refreshErr
}
if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err)
return nil, refreshErr
}
refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered)
if recoverErr != nil {
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr)
return nil, refreshErr
}
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh")
return refreshed, nil
}
func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool {
if selected == nil || legacy == nil {
return false
}
selectedCorpID := strings.TrimSpace(selected.CorpID)
legacyCorpID := strings.TrimSpace(legacy.CorpID)
if selectedCorpID == "" || legacyCorpID != selectedCorpID {
return false
}
selectedUserID := strings.TrimSpace(selected.UserID)
legacyUserID := strings.TrimSpace(legacy.UserID)
if legacyUserID != "" {
return legacyUserID == selectedUserID
}
return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID)
}
func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool {
if strings.TrimSpace(corpID) == "" {
return false
}
cfg, err := tokenLoadProfiles(configDir)
if err != nil || cfg == nil {
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err)
return false
}
profiles := profilesForCorpID(cfg, corpID)
if len(profiles) != 1 {
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
"reason", "multi_profile",
"corp_id", strings.TrimSpace(corpID),
"profile_count", len(profiles),
)
return false
}
profile := profiles[0]
if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) {
return true
}
profileUserID := ""
if profile != nil {
profileUserID = strings.TrimSpace(profile.UserID)
}
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
"reason", "identity_mismatch",
"selected_user_id", strings.TrimSpace(userID),
"profile_user_id", profileUserID,
)
return false
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
@@ -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 auth
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
{"plain error is unknown", errors.New("boom"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRefreshTokenRejected(tt.err); got != tt.want {
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
// and records refresh attempts plus organization slot lookups.
type orgSlotFallbackFixture struct {
provider *OAuthProvider
stale *TokenData
orgMirror *TokenData
renewed *TokenData
rejected *MCPTokenExchangeError
refreshErr error
orgRefreshErr error
refreshCalls []string
refreshUserIDs []string
orgLoads int
}
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
t.Helper()
isolateOAuthPersistence(t)
f := &orgSlotFallbackFixture{
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
stale: &TokenData{
AccessToken: "old-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
orgMirror: &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
renewed: &TokenData{
AccessToken: "new-access",
RefreshToken: "new-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
}
f.refreshErr = f.rejected
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
switch data.RefreshToken {
case "stale-refresh":
return nil, f.refreshErr
case "org-refresh":
return f.renewed, f.orgRefreshErr
}
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
f.orgLoads++
if corpID != "corp-1" {
return nil, ErrTokenDataNotFound
}
return f.orgMirror, nil
})
return f
}
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
}
if f.orgLoads != 1 {
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
}
}
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "legacy", CorpID: "corp-1", UserID: ""},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
},
}
selector := profileSelector("corp-1", "user-1")
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
}
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
t.Fatalf("repair marker did not force the organization slot write")
}
}
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
isolateOAuthPersistence(t)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
// Fake MCP refresh endpoint: the first call rejects the stale identity
// refresh_token with the reviewed business code; the second call (the
// organization mirror) succeeds and returns a rotated credential.
var refreshCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if refreshCalls.Add(1) == 1 {
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
return
}
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
}))
defer srv.Close()
configDir := setupMCPConfigDir(t, srv.URL)
resetAppConfigCache()
// Seed the pre-fallback state: an identity slot whose refresh_token the
// server rejects, plus a legacy organization mirror (no userId) with a
// still-valid refresh_token and a preserved unresolved sibling profile so
// an explicit --profile refresh would normally skip the org slot.
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
},
}
if err := SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
orgMirror := &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
}
staleIdentity := &TokenData{
AccessToken: "stale-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
UserName: "User One",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
}
SetRuntimeProfile("corp-1:user-1")
t.Cleanup(func() { SetRuntimeProfile("") })
p := &OAuthProvider{
configDir: configDir,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Output: io.Discard,
httpClient: srv.Client(),
}
got, err := p.lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
}
if refreshCalls.Load() != 2 {
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
}
// The fallback consumed the mirror's refresh_token: both persisted slots
// must now carry the rotated credential instead of the consumed one.
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
}
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
}
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
}
if identitySlot.RefreshToken != "new-refresh" {
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
}
}
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
t.Run("transient failure does not fall back", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing org slot preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, ErrTokenDataNotFound
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, nil
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
}
})
t.Run("different user in org slot is rejected", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = "user-2"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
}
})
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = ""
f.orgMirror.UserName = ""
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
}
if f.refreshUserIDs[1] != "user-1" {
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
}
})
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshToken = "stale-refresh"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
}
})
t.Run("expired org refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing corp id skips fallback", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.stale.CorpID = ""
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
}
})
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
}
if f.orgLoads != 0 {
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
}
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
t.Fatal("refreshFromOrgSlot(nil) succeeded")
}
f.orgMirror.CorpID = "corp-2"
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
}
if len(f.refreshCalls) != 0 {
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
}
}
+13
View File
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
}
return RefreshFailureUnknown
}
// isRefreshTokenRejected reports whether the server returned a reviewed
// business code that definitively rejects the presented refresh_token.
// Only the reviewed MCP business code enables the organization-slot
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
// reviewed business code and deliberately do not trigger the fallback.
func isRefreshTokenRejected(err error) bool {
if err == nil {
return false
}
var exchangeErr *MCPTokenExchangeError
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
}
+517
View File
@@ -15,6 +15,7 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -189,6 +190,195 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039",
UserID: "user-v1039",
UserName: "V1039 User",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
})
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
Name: "V1039 User",
CorpID: selected.CorpID,
UserID: selected.UserID,
UserName: selected.UserName,
}}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
var saved *TokenData
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
copy := *data
saved = &copy
return nil
})
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
}
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
}
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-multi",
UserID: "user-v1039-a",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-blank-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-user-mismatch",
UserID: "user-v1039-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: "user-v1039-other",
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a token owned by a different user")
}
}
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
expired := *fixture.blankToken
@@ -253,3 +443,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola
}
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-single-mismatch",
UserID: "user-v1039-selected",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
Name: "Other User",
CorpID: selected.CorpID,
UserID: "user-v1039-other",
}}}, nil
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-legacy-refresh",
UserID: "user-v1039",
UserName: "V1039 User",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "expired-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
refreshed := &TokenData{
AccessToken: "refreshed-legacy-access",
RefreshToken: "rotated-legacy-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
refreshCalls := 0
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
refreshCalls++
if refreshCalls == 1 {
return nil, rejection
}
return refreshed, nil
})
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if refreshCalls != 2 {
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
}
if recovered.AccessToken != refreshed.AccessToken {
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
}
if recovered.RefreshToken != refreshed.RefreshToken {
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "rejected-identity-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-profiles-error",
UserID: "user-v1039",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "valid-legacy-global-access",
RefreshToken: "valid-legacy-global-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
selected := &TokenData{
AccessToken: "expired-identity-access",
RefreshToken: "shared-rejected-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-v1039-same-refresh",
UserID: "user-v1039",
Source: "mcp",
}
legacy := &TokenData{
AccessToken: "expired-legacy-global-access",
RefreshToken: selected.RefreshToken,
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
saved := false
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
saved = true
return nil
})
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
if !errors.Is(err, rejection) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if saved {
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
}
}
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
provider := NewOAuthProvider(t.TempDir(), nil)
// nil selected must be rejected before any dereference.
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
t.Fatalf("nil selected error = %v, want original rejection", err)
}
// A keychain load that returns (nil, nil) must be rejected before any dereference.
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
selected := &TokenData{
CorpID: "corp-v1039-nil-legacy",
UserID: "user-v1039",
Source: "mcp",
}
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("nil legacy error = %v, want original rejection", err)
}
}
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
provider := NewOAuthProvider(t.TempDir(), nil)
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
plainErr := errors.New("plain refresh failure")
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
t.Fatalf("plain error = %v, want original plain failure", err)
}
}
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
selected := &TokenData{
CorpID: "corp-v1039-recovery-steps",
UserID: "user-v1039",
Source: "mcp",
}
t.Run("save_failure", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "valid-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("save failure error = %v, want original rejection", err)
}
})
t.Run("refresh_expired", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "expired-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(-time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("expired refresh error = %v, want original rejection", err)
}
})
t.Run("refresh_error", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
return nil, errors.New("legacy refresh failed")
})
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("legacy refresh error = %v, want original rejection", err)
}
})
t.Run("preflight_error", func(t *testing.T) {
legacy := &TokenData{
AccessToken: "expired-legacy-access",
RefreshToken: "valid-legacy-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: selected.CorpID,
UserID: selected.UserID,
Source: "mcp",
}
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
t.Fatalf("preflight error = %v, want original rejection", err)
}
})
}
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
configDir := t.TempDir()
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
t.Fatal("nil legacy accepted")
}
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
t.Fatal("different corp accepted")
}
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
t.Fatal("blank selected corp accepted")
}
blankSelected := &TokenData{CorpID: selected.CorpID}
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
})
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
t.Fatal("both blank user IDs should match only through the single-profile guard")
}
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
t.Fatal("blank selected with non-blank legacy accepted")
}
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
t.Fatal("blank corp accepted by single-profile check")
}
}
+16 -1
View File
@@ -107,6 +107,13 @@ type TokenData struct {
// transient marker to reject ambiguous UID-less logins without breaking
// legitimate refreshes of unresolved accounts.
FreshAuthorization bool `json:"-"`
// RepairOrganizationMirror marks a fallback refresh that consumed the
// organization mirror's refresh_token. The regular write plan can skip the
// organization slot under an explicit runtime selector (for example when an
// unresolved sibling profile still owns it), which would strand a
// refresh_token the server has already rotated; the marker forces the
// rotated credential back into that slot.
RepairOrganizationMirror bool `json:"-"`
}
// tokenPersistenceWritePlan is the single source of truth for deciding which
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
ExistingIdentity bool
UpgradesLegacyProfile bool
PreserveUnresolvedOrganization bool
RepairOrganizationMirror bool
WriteIdentity bool
WriteOrganization bool
WriteGlobal bool
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
!plan.UpgradesLegacyProfile
// A fallback refresh consumed the organization mirror's refresh_token;
// the rotated credential must go back into that slot even when the
// selector-driven plan would skip it (for example an explicit --profile
// that preserves an unresolved sibling profile's slot).
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
plan.WriteIdentity = plan.UserID != ""
orgCurrentSelector := ""
if cfg != nil {
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
// reauthorization. Its organization slot must move with the newly exact
// identity even when an explicit runtime selector keeps it from becoming
// process-global current.
plan.WriteOrganization = plan.UserID == "" ||
plan.WriteOrganization = plan.RepairOrganizationMirror ||
plan.UserID == "" ||
plan.UpgradesLegacyProfile ||
(!plan.PreserveUnresolvedOrganization &&
(plan.MakeCurrent ||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
"persistence_profile", plan.PersistenceSelector,
"write_identity_slot", plan.WriteIdentity,
"write_org_mirror", plan.WriteOrganization,
"repair_org_mirror", plan.RepairOrganizationMirror,
"write_global_mirror", plan.WriteGlobal,
"publish_incoming_global", plan.MakeCurrent,
)
+3 -3
View File
@@ -191,12 +191,12 @@
"from": "oa +list-processes",
"mode": "ambiguous",
"candidates": [
"oa +list-forms",
"oa +my-initiated",
"oa +search-forms",
"oa approval list-submitted",
"oa approval list-initiated"
],
"reviewed": true,
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
"review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes."
},
{
"from": "chat +conversation-detail",
@@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{
{
From: "oa +list-processes",
Mode: "ambiguous",
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
Reviewed: true,
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.",
},
}
+1 -1
View File
@@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
"chat +send-file": {"chat +messages-send", "chat message send"},
"chat +send-image": {"chat +messages-send", "chat message send"},
"chat +send-media": {"chat +messages-send", "chat message send"},
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
"oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
}
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
if parameters[flagName]["required"] != false {
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
}
@@ -315,6 +315,15 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
if parameters["max-depth"]["type"] != "integer" {
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
}
if got := parameters["version"]["property"]; got != "historyVersion" {
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
}
if parameters["version"]["type"] != "integer" {
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
}
if got := parameters["password"]["property"]; got != "password" {
t.Fatalf("doc read --password property = %#v, want password", got)
}
}
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
@@ -76,6 +76,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"agoal scorecard detail",
"agoal scorecard entity-detail",
"agoal scorecard update",
"agoal scorecard search-entities",
"agoal strategy detail",
"agoal strategy list",
"agoal strategy update",
+8 -1
View File
@@ -96,7 +96,14 @@ func init() {
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"file", "src"}},
RequireOneOf: [][]string{{"file", "src"}},
})
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"file", "src"}},
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
})
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
registerRequireOneOf("sheet.import", "folder-token", "workspace")
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
@@ -408,6 +408,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.insert_document_block --level": "aggregate convenience input used to build element",
"doc.insert_document_block --content": "aggregate convenience input used to build element",
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
"doc.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
@@ -476,6 +477,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
"drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
@@ -552,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
@@ -644,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
@@ -664,6 +668,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
"wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
"wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces",
"wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches",
+7 -1
View File
@@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error {
if params == nil {
params = map[string]any{}
}
if err := validateSchemaValue("$", params, schema); err != nil {
if err := ValidateJSONSchemaValue(params, schema); err != nil {
return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err))
}
return nil
}
// ValidateJSONSchemaValue validates one decoded JSON value against the
// required/type/enum/properties/items subset used by reviewed CLI contracts.
func ValidateJSONSchemaValue(value any, schema map[string]any) error {
return validateSchemaValue("$", value, schema)
}
func validateSchemaValue(path string, value any, schema map[string]any) error {
if len(schema) == 0 {
return nil
+139 -9
View File
@@ -16,9 +16,10 @@
package cobracmd
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -47,17 +48,14 @@ func NewGroupCommand(use, short string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
// Tag as a group container: its RunE only prints help, so cobra's
// Runnable() can't distinguish it from a real leaf — callers that need to
// collapse empty groups rely on this annotation.
cmdutil.MarkGroup(cmd)
corecmd.ApplyGroupPolicy(cmd, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return cmd
}
@@ -90,6 +88,7 @@ func MergeCommandTree(dst, src *cobra.Command) {
if dst == nil || src == nil {
return
}
mergeGroupPolicy(dst, src)
if dst.Short == "" || (IsGenericOverlayShort(dst.Short) && src.Short != "" && !IsGenericOverlayShort(src.Short)) {
dst.Short = src.Short
}
@@ -113,6 +112,137 @@ func MergeCommandTree(dst, src *cobra.Command) {
}
}
func mergeGroupPolicy(dst, src *cobra.Command) {
dstPolicy, dstOK, err := corecmd.GroupPolicyFor(dst)
if err != nil {
panic(fmt.Sprintf("destination command %q has invalid GroupPolicy: %v", dst.CommandPath(), err))
}
srcPolicy, srcOK, err := corecmd.GroupPolicyFor(src)
if err != nil {
panic(fmt.Sprintf("source command %q has invalid GroupPolicy: %v", src.CommandPath(), err))
}
if len(dst.Commands()) > 0 && !dstOK {
panic(fmt.Sprintf("destination command %q has children but no GroupPolicy", dst.CommandPath()))
}
if len(src.Commands()) > 0 && !srcOK {
panic(fmt.Sprintf("source command %q has children but no GroupPolicy", src.CommandPath()))
}
if dstOK && !srcOK {
if !isNeutralMergePlaceholder(src) {
panic(fmt.Sprintf("cannot merge runnable or behavior-bearing leaf command %q into typed group command %q",
src.CommandPath(), dst.CommandPath()))
}
return
}
if !dstOK && srcOK {
if !isNeutralMergePlaceholder(dst) {
panic(fmt.Sprintf("cannot merge typed group command %q into runnable or behavior-bearing leaf command %q",
src.CommandPath(), dst.CommandPath()))
}
corecmd.ApplyGroupPolicy(dst, srcPolicy)
return
}
if dstOK && srcOK && dstPolicy != srcPolicy {
// A NavigationOnly/Reject/Sibling source is the framework's neutral
// service scaffold (shortcuts and plugin overlays use it before being
// folded into an owning product root). The destination owns the merged
// command's default action and recovery scope, so preserve its policy.
// Any stronger source declaration would lose behavior during this
// destination-oriented merge and therefore fails closed.
if srcPolicy != (corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
}) {
panic(fmt.Sprintf("cannot merge command %q with conflicting GroupPolicy declarations: %+v != %+v",
dst.CommandPath(), dstPolicy, srcPolicy))
}
return
}
}
// isNeutralMergePlaceholder reports whether cmd contributes metadata only.
// Such a shell may adopt (or be folded into) one typed group declaration.
// Anything executable, parse-affecting, or child-bearing must declare its own
// compatible GroupPolicy so tree assembly cannot silently discard behavior.
func isNeutralMergePlaceholder(cmd *cobra.Command) bool {
if cmd == nil || len(cmd.Commands()) != 0 || cmd.Runnable() || cmd.Args != nil ||
cmd.PreRun != nil || cmd.PreRunE != nil || cmd.PostRun != nil || cmd.PostRunE != nil ||
cmd.PersistentPreRun != nil || cmd.PersistentPreRunE != nil ||
cmd.PersistentPostRun != nil || cmd.PersistentPostRunE != nil ||
cmd.TraverseChildren || cmd.DisableFlagParsing {
return false
}
hasFlags := false
cmd.LocalNonPersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
cmd.PersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
return !hasFlags
}
// ValidateGroupTree checks the final assembled Cobra tree rather than source
// syntax. Every command with children must carry one valid typed GroupPolicy;
// leaves must carry none. This catches dynamically assembled aliases, plugin
// parents, and constructors outside any one source directory.
func ValidateGroupTree(root *cobra.Command) error {
if root == nil {
return fmt.Errorf("cannot validate a nil command tree")
}
return validateGroupNode(root)
}
func validateGroupNode(cmd *cobra.Command) error {
policy, declared, err := corecmd.GroupPolicyFor(cmd)
if err != nil {
return fmt.Errorf("command %q has invalid GroupPolicy metadata: %w", cmd.CommandPath(), err)
}
children := cmd.Commands()
if len(children) == 0 {
if declared {
return fmt.Errorf("leaf command %q retains GroupPolicy %+v", cmd.CommandPath(), policy)
}
return nil
}
if !declared {
return fmt.Errorf("command %q has children but no GroupPolicy", cmd.CommandPath())
}
if !cmd.Runnable() {
return fmt.Errorf("group command %q with mode %q is not runnable", cmd.CommandPath(), policy.Mode)
}
if policy.Mode == corecmd.GroupNavigationOnly && (cmd.RunE == nil || cmd.Run != nil) {
return fmt.Errorf("navigation-only group %q does not retain framework help execution", cmd.CommandPath())
}
if policy.Mode == corecmd.GroupHybrid && cmd.RunE == nil {
return fmt.Errorf("hybrid group %q lost its business RunE", cmd.CommandPath())
}
if policy.Positionals == corecmd.PositionalsAllow && cmd.Args == nil {
return fmt.Errorf("group command %q allows positionals without an explicit Args contract", cmd.CommandPath())
}
if policy.Positionals == corecmd.PositionalsReject {
if cmd.Args == nil {
return fmt.Errorf("group command %q rejects positionals without compiled Args behavior", cmd.CommandPath())
}
}
if policy.Recovery == corecmd.RecoveryDeep && !hasAvailableDescendant(cmd) {
return fmt.Errorf("group command %q declares deep recovery without an available descendant", cmd.CommandPath())
}
for _, child := range children {
if err := validateGroupNode(child); err != nil {
return err
}
}
return nil
}
func hasAvailableDescendant(cmd *cobra.Command) bool {
for _, child := range cmd.Commands() {
if !child.IsAvailableCommand() {
continue
}
return true
}
return false
}
// ShouldReplaceLeaf decides whether src should replace dst as a leaf command
// based on override priority and local flag count.
func ShouldReplaceLeaf(dst, src *cobra.Command) bool {
+391 -10
View File
@@ -14,8 +14,10 @@
package cobracmd
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/spf13/cobra"
)
@@ -143,20 +145,399 @@ func TestNewGroupCommand(t *testing.T) {
t.Fatalf("Short = %q, want %q", cmd.Short, "my group description")
}
if cmd.Args == nil {
t.Fatal("Args should be set (cobra.NoArgs)")
t.Fatal("Args should be set (cobra.ArbitraryArgs)")
}
// Verify Args rejects arguments.
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
t.Fatal("expected Args to reject extra arguments")
// Args must reach the shared resolver instead of Cobra's generic arg error.
if err := cmd.Args(cmd, []string{"extra"}); err != nil {
t.Fatalf("Args intercepted command resolution: %v", err)
}
// Verify RunE is set and returns help (no error for valid invocation).
if cmd.RunE == nil {
t.Fatal("RunE should not be nil")
}
policy, ok, err := corecmd.GroupPolicyFor(cmd)
if err != nil || !ok || policy != (corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
}) {
t.Fatalf("GroupPolicyFor() = %+v, %v, %v", policy, ok, err)
}
// RunE calls cmd.Help() which should not error.
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE returned unexpected error: %v", err)
}
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
t.Fatalf("RunE typo error = %v", err)
}
}
func TestCrossPlatformCoverageValidateGroupTree(t *testing.T) {
t.Run("valid final tree", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
nested := NewGroupCommand("nested", "nested")
nested.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.AddCommand(nested)
if err := ValidateGroupTree(root); err != nil {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("nil tree", func(t *testing.T) {
if err := ValidateGroupTree(nil); err == nil || !strings.Contains(err.Error(), "nil") {
t.Fatalf("ValidateGroupTree(nil) = %v", err)
}
})
t.Run("children require declaration", func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "no GroupPolicy") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("leaf rejects stale declaration", func(t *testing.T) {
leaf := NewGroupCommand("stale", "stale")
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("deep policy on a leaf is still a stale group declaration", func(t *testing.T) {
leaf := &cobra.Command{Use: "stale-deep"}
corecmd.ApplyGroupPolicy(leaf, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("declared group must stay runnable", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.RunE = nil
root.Run = nil
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "not runnable") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("rejected positionals require compiled Args behavior", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.Args = nil
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "compiled Args") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("allowed positionals require explicit Args contract", func(t *testing.T) {
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "explicit Args") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("validation does not execute positional contracts", func(t *testing.T) {
calls := 0
root := &cobra.Command{
Use: "dws",
Args: func(*cobra.Command, []string) error {
calls++
return nil
},
RunE: func(*cobra.Command, []string) error { return nil },
}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err != nil {
t.Fatalf("ValidateGroupTree() = %v", err)
}
if calls != 0 {
t.Fatalf("ValidateGroupTree executed Args %d times", calls)
}
})
t.Run("deep recovery requires available descendants", func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "available descendant") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
}
func TestCrossPlatformCoverageValidateGroupTreeFailsClosedOnCorruption(t *testing.T) {
t.Run("malformed policy metadata", func(t *testing.T) {
root := &cobra.Command{
Use: "dws",
Annotations: map[string]string{
"dws.internal.corecmd.group_policy.v1": "malformed",
},
}
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "invalid GroupPolicy metadata") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("navigation-only execution hook changed", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.RunE = nil
root.Run = func(*cobra.Command, []string) {}
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "does not retain framework help execution") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("hybrid business execution hook removed", func(t *testing.T) {
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.RunE = nil
root.Run = func(*cobra.Command, []string) {}
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "lost its business RunE") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("nested validation error is propagated", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(NewGroupCommand("stale", "stale"))
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), `leaf command "dws stale" retains GroupPolicy`) {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("deep recovery accepts an available descendant", func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err != nil {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
}
func TestCrossPlatformCoverageMergeCommandTreeGroupPolicy(t *testing.T) {
t.Run("copies source declaration", func(t *testing.T) {
dst := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
MergeCommandTree(dst, src)
policy, ok, err := corecmd.GroupPolicyFor(dst)
if err != nil || !ok || policy.Recovery != corecmd.RecoverySibling {
t.Fatalf("merged policy = %+v, %v, %v", policy, ok, err)
}
})
t.Run("typed destination accepts metadata-only source", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root", Long: "source details"}
MergeCommandTree(dst, src)
if dst.Long != "source details" {
t.Fatalf("Long = %q", dst.Long)
}
})
t.Run("accepts identical declarations", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := NewGroupCommand("root", "source")
MergeCommandTree(dst, src)
})
t.Run("neutral scaffold preserves owning hybrid deep policy", func(t *testing.T) {
businessCalled := false
dst := &cobra.Command{
Use: "root",
RunE: func(*cobra.Command, []string) error {
businessCalled = true
return nil
},
}
want := corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
}
corecmd.ApplyGroupPolicy(dst, want)
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
src := NewGroupCommand("root", "neutral scaffold")
src.AddCommand(&cobra.Command{Use: "overlay", RunE: func(*cobra.Command, []string) error { return nil }})
MergeCommandTree(dst, src)
got, ok, err := corecmd.GroupPolicyFor(dst)
if err != nil || !ok || got != want {
t.Fatalf("merged owning policy = %+v, %v, %v; want %+v", got, ok, err, want)
}
if ChildByName(dst, "overlay") == nil {
t.Fatal("neutral scaffold child was not merged")
}
if err := dst.RunE(dst, nil); err != nil || !businessCalled {
t.Fatalf("owning Hybrid RunE was not preserved: called=%v err=%v", businessCalled, err)
}
})
t.Run("rejects conflicting declarations", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root"}
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("hybrid deep target rejects non-neutral source", func(t *testing.T) {
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
corecmd.ApplyGroupPolicy(dst, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
src := &cobra.Command{Use: "root"}
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("does not overwrite undeclared runnable destination", func(t *testing.T) {
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
src := NewGroupCommand("root", "source")
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("does not swallow runnable leaf source into group destination", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
src := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
src.Flags().String("source-only", "", "must not be silently discarded")
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("does not swallow parse behavior from source into group destination", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root", Args: cobra.NoArgs}
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("rejects undeclared destination group", func(t *testing.T) {
dst := &cobra.Command{Use: "root"}
dst.AddCommand(&cobra.Command{Use: "child"})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "destination command") || !strings.Contains(got.(string), "no GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, &cobra.Command{Use: "root"})
})
t.Run("rejects undeclared source group", func(t *testing.T) {
src := &cobra.Command{Use: "root"}
src.AddCommand(&cobra.Command{Use: "child"})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "source command") || !strings.Contains(got.(string), "no GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(&cobra.Command{Use: "root"}, src)
})
}
func TestCrossPlatformCoverageMergeCommandTreeFailsClosedOnCorruption(t *testing.T) {
mustPanic := func(t *testing.T, want string, fn func()) {
t.Helper()
defer func() {
got := recover()
message, ok := got.(string)
if !ok || !strings.Contains(message, want) {
t.Fatalf("panic = %v, want substring %q", got, want)
}
}()
fn()
}
malformed := func() *cobra.Command {
return &cobra.Command{
Use: "root",
Annotations: map[string]string{
"dws.internal.corecmd.group_policy.v1": "malformed",
},
}
}
t.Run("malformed destination policy", func(t *testing.T) {
mustPanic(t, "destination command", func() {
MergeCommandTree(malformed(), &cobra.Command{Use: "root"})
})
})
t.Run("malformed source policy", func(t *testing.T) {
mustPanic(t, "source command", func() {
MergeCommandTree(&cobra.Command{Use: "root"}, malformed())
})
})
t.Run("local flag prevents placeholder merge", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root"}
src.Flags().String("local", "", "local parse behavior")
mustPanic(t, "behavior-bearing leaf", func() {
MergeCommandTree(dst, src)
})
})
t.Run("persistent flag prevents placeholder merge", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root"}
src.PersistentFlags().String("persistent", "", "inherited parse behavior")
mustPanic(t, "behavior-bearing leaf", func() {
MergeCommandTree(dst, src)
})
})
}
func TestNewHiddenGroupCommand(t *testing.T) {
@@ -341,11 +722,11 @@ func TestMergeCommandTree(t *testing.T) {
t.Run("child merge recursive", func(t *testing.T) {
t.Parallel()
dst := &cobra.Command{Use: "root"}
dst := NewGroupCommand("root", "destination")
dstChild := &cobra.Command{Use: "sub", Short: ""}
dst.AddCommand(dstChild)
src := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
srcChild := &cobra.Command{Use: "sub", Short: "Merged short"}
src.AddCommand(srcChild)
@@ -361,12 +742,12 @@ func TestMergeCommandTree(t *testing.T) {
t.Run("leaf replacement by higher priority", func(t *testing.T) {
t.Parallel()
dst := &cobra.Command{Use: "root"}
dst := NewGroupCommand("root", "destination")
dstLeaf := &cobra.Command{Use: "leaf", Short: "old"}
SetOverridePriority(dstLeaf, 1)
dst.AddCommand(dstLeaf)
src := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
srcLeaf := &cobra.Command{Use: "leaf", Short: "new"}
SetOverridePriority(srcLeaf, 5)
src.AddCommand(srcLeaf)
@@ -383,10 +764,10 @@ func TestMergeCommandTree(t *testing.T) {
t.Run("new child addition", func(t *testing.T) {
t.Parallel()
dst := &cobra.Command{Use: "root"}
dst := NewGroupCommand("root", "destination")
dst.AddCommand(&cobra.Command{Use: "existing"})
src := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
src.AddCommand(&cobra.Command{Use: "brand-new", Short: "added"})
MergeCommandTree(dst, src)
+56
View File
@@ -0,0 +1,56 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package commentreaction validates the reviewed DingTalk names accepted by
// comment emoji replies. The names mirror the bundled default emoji catalog;
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
package commentreaction
import (
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// Validate rejects values outside DingTalk's reviewed default reaction-name
// catalog. Names are case-sensitive because they are sent verbatim to the
// doc-comment service.
func Validate(value string) error {
name := strings.TrimSpace(value)
if value != name || !utf8.ValidString(name) || !supported(name) {
return apperrors.NewValidation(
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
apperrors.WithReason("unsupported_comment_reaction"),
)
}
return nil
}
func supported(name string) bool {
switch name {
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
return true
default:
return false
}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package commentreaction
import "testing"
func TestCrossPlatformCoverageValidate(t *testing.T) {
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
if err := Validate(value); err != nil {
t.Errorf("supported reaction %q rejected: %v", value, err)
}
}
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
if err := Validate(value); err == nil {
t.Errorf("unsupported reaction %q accepted", value)
}
}
}
+8 -6
View File
@@ -11,11 +11,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package corecmd is the shared, dispatch-agnostic base for building leaf
// commands. It concentrates flag registration, the alias/env/default effective
// value fallback chain, required validation, cross-flag constraint declaration
// checks + runtime enforcement, SafetySpec-driven confirmation, toolArgs
// assembly, and Agent Runtime Schema projection.
// Package corecmd is the shared, dispatch-agnostic base for building commands.
// It concentrates typed group policy, flag registration, the alias/env/default
// effective value fallback chain, required validation, cross-flag constraint
// declaration checks + runtime enforcement, SafetySpec-driven confirmation,
// toolArgs assembly, and Agent Runtime Schema projection.
//
// Declaration vs execution (framework rule):
//
@@ -231,7 +231,9 @@ const (
// or assemble business params that belong in Flags/ConstParams.
//
// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New
// validates this at construction time. corecmd stays dispatch-agnostic and
// validates this at construction time. Non-leaf commands are declared
// separately through ApplyGroupPolicy so leaf execution fields can never be
// configured and then silently ignored. corecmd stays dispatch-agnostic and
// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the
// body.
type Spec struct {
+218
View File
@@ -0,0 +1,218 @@
// 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 corecmd
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
// GroupMode declares whether a command with children is navigation-only or
// also owns business execution. The zero value means the command is a leaf.
type GroupMode string
const (
// GroupNavigationOnly is a parent whose own invocation only renders help.
GroupNavigationOnly GroupMode = "navigation_only"
// GroupHybrid is a runnable business command that also owns children.
GroupHybrid GroupMode = "hybrid"
)
// PositionalsPolicy declares whether a group may consume positional values.
type PositionalsPolicy string
const (
// PositionalsReject makes every unmatched positional token eligible for
// command-resolution recovery rather than business execution.
PositionalsReject PositionalsPolicy = "reject"
// PositionalsAllow reserves positional values for the group's business
// execution. Recovery must therefore be disabled to avoid ambiguity.
PositionalsAllow PositionalsPolicy = "allow"
)
// RecoveryPolicy declares the search scope for unknown-command recovery.
type RecoveryPolicy string
const (
// RecoverySibling suggests only direct children of the current group.
RecoverySibling RecoveryPolicy = "sibling"
// RecoveryDeep may search all descendants of the current group.
RecoveryDeep RecoveryPolicy = "deep"
// RecoveryDisabled leaves positional handling entirely to Cobra or the
// command's business execution.
RecoveryDisabled RecoveryPolicy = "disabled"
)
// GroupPolicy is the typed declaration for every non-leaf command.
//
// Its zero value deliberately means "leaf": callers must declare all three
// fields together for a group. ApplyGroupPolicy compiles the declaration to
// Cobra behavior and private framework metadata; command authors must not
// author parallel kind annotations themselves.
type GroupPolicy struct {
Mode GroupMode
Positionals PositionalsPolicy
Recovery RecoveryPolicy
}
const groupPolicyAnnotation = "dws.internal.corecmd.group_policy.v1"
// IsZero reports whether p is the leaf declaration.
func (p GroupPolicy) IsZero() bool {
return p.Mode == "" && p.Positionals == "" && p.Recovery == ""
}
// ValidateGroupPolicy rejects partial declarations, unknown enum values, and
// combinations whose parsing semantics would be ambiguous.
func ValidateGroupPolicy(p GroupPolicy) error {
if p.IsZero() {
return nil
}
switch p.Mode {
case GroupNavigationOnly, GroupHybrid:
default:
return fmt.Errorf("invalid group mode %q", p.Mode)
}
switch p.Positionals {
case PositionalsReject, PositionalsAllow:
default:
return fmt.Errorf("invalid group positionals policy %q", p.Positionals)
}
switch p.Recovery {
case RecoverySibling, RecoveryDeep, RecoveryDisabled:
default:
return fmt.Errorf("invalid group recovery policy %q", p.Recovery)
}
if p.Mode == GroupNavigationOnly && p.Positionals != PositionalsReject {
return fmt.Errorf("navigation-only group requires positionals=%q", PositionalsReject)
}
if p.Positionals == PositionalsAllow && p.Recovery != RecoveryDisabled {
return fmt.Errorf("group with positionals=%q requires recovery=%q", PositionalsAllow, RecoveryDisabled)
}
return nil
}
// ApplyGroupPolicy is the sole declaration API for non-leaf command behavior.
// Invalid declarations panic because they are framework construction bugs,
// matching the fail-closed behavior of Spec flag/constraint declarations.
//
// Navigation-only groups receive the shared help/unknown-command RunE. Hybrid
// groups retain their existing RunE; when they reject positionals, a wrapper
// sends non-empty args to the same unknown-command resolver before invoking
// business execution. When recovery is enabled, rejecting positionals
// deliberately compiles to cobra.ArbitraryArgs: Cobra must not intercept the
// token with a generic error before command resolution can produce bounded
// guidance. RecoveryDisabled instead compiles rejection to cobra.NoArgs.
func ApplyGroupPolicy(cmd *cobra.Command, policy GroupPolicy) {
if cmd == nil {
panic("cannot apply GroupPolicy to a nil command")
}
if policy.IsZero() {
panic(fmt.Sprintf("command %q cannot apply the zero GroupPolicy; zero means leaf", cmd.Name()))
}
if err := ValidateGroupPolicy(policy); err != nil {
panic(fmt.Sprintf("command %q declares invalid GroupPolicy: %v", cmd.Name(), err))
}
if existing, ok, err := GroupPolicyFor(cmd); err != nil {
panic(fmt.Sprintf("command %q carries invalid GroupPolicy metadata: %v", cmd.Name(), err))
} else if ok && existing != policy {
panic(fmt.Sprintf("command %q redeclares GroupPolicy from %+v to %+v", cmd.Name(), existing, policy))
} else if ok {
return
}
if policy.Mode == GroupNavigationOnly {
cmd.Run = nil
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runGroupPolicy(cmd, args, policy)
}
} else if cmd.RunE == nil {
panic(fmt.Sprintf("hybrid group %q must declare RunE before GroupPolicy is applied", cmd.Name()))
} else if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
businessRunE := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return runGroupPolicy(cmd, args, policy)
}
return businessRunE(cmd, args)
}
}
if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
cmd.Args = cobra.ArbitraryArgs
} else if policy.Positionals == PositionalsReject {
cmd.Args = cobra.NoArgs
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[groupPolicyAnnotation] = encodeGroupPolicy(policy)
}
// GroupPolicyFor reads the typed declaration compiled onto cmd. The boolean is
// false only for a leaf. Malformed private metadata is returned as an error so
// tree assembly can fail closed instead of silently treating a group as a leaf.
func GroupPolicyFor(cmd *cobra.Command) (GroupPolicy, bool, error) {
if cmd == nil || cmd.Annotations == nil {
return GroupPolicy{}, false, nil
}
raw, ok := cmd.Annotations[groupPolicyAnnotation]
if !ok {
return GroupPolicy{}, false, nil
}
parts := strings.Split(raw, "|")
if len(parts) != 3 {
return GroupPolicy{}, false, fmt.Errorf("malformed encoded GroupPolicy %q", raw)
}
policy := GroupPolicy{
Mode: GroupMode(parts[0]),
Positionals: PositionalsPolicy(parts[1]),
Recovery: RecoveryPolicy(parts[2]),
}
if err := ValidateGroupPolicy(policy); err != nil {
return GroupPolicy{}, false, err
}
if policy.IsZero() {
return GroupPolicy{}, false, fmt.Errorf("encoded GroupPolicy must not be zero")
}
return policy, true, nil
}
func encodeGroupPolicy(policy GroupPolicy) string {
return string(policy.Mode) + "|" + string(policy.Positionals) + "|" + string(policy.Recovery)
}
func runGroupPolicy(cmd *cobra.Command, args []string, policy GroupPolicy) error {
if len(args) == 0 {
return cmd.Help()
}
input := strings.TrimSpace(args[0])
reason := cmdutil.ClassifyCommandResolution(cmd, input)
suggestions := cmdutil.SuggestSubcommands(cmd, input)
if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == RecoveryDeep {
if deep := cmdutil.SuggestDescendantSubcommands(cmd, input); len(deep) > 0 {
suggestions = deep
}
}
return cmdutil.NewCommandResolution(
cmd,
input,
reason,
suggestions,
"",
).Err()
}
+323
View File
@@ -0,0 +1,323 @@
// 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 corecmd
import (
"errors"
"runtime"
"slices"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageValidateGroupPolicy(t *testing.T) {
valid := []GroupPolicy{
{},
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling},
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep},
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling},
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDeep},
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoveryDisabled},
}
for _, policy := range valid {
if err := ValidateGroupPolicy(policy); err != nil {
t.Fatalf("ValidateGroupPolicy(%+v) = %v", policy, err)
}
}
invalid := []struct {
name string
policy GroupPolicy
needle string
}{
{name: "partial", policy: GroupPolicy{Mode: GroupHybrid}, needle: "positionals"},
{name: "unknown mode", policy: GroupPolicy{Mode: "leafish", Positionals: PositionalsReject, Recovery: RecoveryDisabled}, needle: "mode"},
{name: "unknown positionals", policy: GroupPolicy{Mode: GroupHybrid, Positionals: "maybe", Recovery: RecoveryDisabled}, needle: "positionals"},
{name: "unknown recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: "global"}, needle: "recovery"},
{name: "navigation allows args", policy: GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsAllow, Recovery: RecoveryDisabled}, needle: "navigation-only"},
{name: "ambiguous recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoverySibling}, needle: "requires recovery"},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
if err := ValidateGroupPolicy(tc.policy); err == nil || !strings.Contains(err.Error(), tc.needle) {
t.Fatalf("ValidateGroupPolicy(%+v) = %v, want %q", tc.policy, err, tc.needle)
}
})
}
}
func TestCrossPlatformCoverageApplyAndReadGroupPolicy(t *testing.T) {
policy := GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}
cmd := &cobra.Command{Use: "parent"}
ApplyGroupPolicy(cmd, policy)
got, ok, err := GroupPolicyFor(cmd)
if err != nil || !ok || got != policy {
t.Fatalf("GroupPolicyFor() = %+v, %v, %v; want %+v, true, nil", got, ok, err, policy)
}
if !cmd.Runnable() || cmd.TraverseChildren {
t.Fatalf("compiled navigation command Runnable=%v TraverseChildren=%v; policy must preserve the flag-traversal surface", cmd.Runnable(), cmd.TraverseChildren)
}
if cmd.Args == nil || cmd.Args(cmd, []string{"extra"}) != nil {
t.Fatal("PositionalsReject must let command resolution inspect unmatched args")
}
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
t.Fatalf("navigation recovery error = %v", err)
}
var help strings.Builder
cmd.SetOut(&help)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("navigation help error = %v", err)
}
if output := help.String(); !strings.Contains(output, "Usage:") {
t.Fatalf("navigation help output = %q", output)
}
// Re-applying the same declaration is idempotent.
ApplyGroupPolicy(cmd, policy)
traversing := &cobra.Command{Use: "traversing", TraverseChildren: true}
ApplyGroupPolicy(traversing, policy)
if !traversing.TraverseChildren {
t.Fatal("ApplyGroupPolicy changed an explicitly declared TraverseChildren surface")
}
leaf := &cobra.Command{Use: "leaf"}
if got, ok, err := GroupPolicyFor(leaf); err != nil || ok || !got.IsZero() {
t.Fatalf("leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
}
annotatedLeaf := &cobra.Command{Use: "annotated-leaf", Annotations: map[string]string{"unrelated": "metadata"}}
if got, ok, err := GroupPolicyFor(annotatedLeaf); err != nil || ok || !got.IsZero() {
t.Fatalf("annotated leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyDoesNotLeakParentLocalFlags(t *testing.T) {
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
ApplyGroupPolicy(root, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
parent := &cobra.Command{
Use: "search",
RunE: func(*cobra.Command, []string) error { return nil },
}
parent.Flags().String("dimension", "", "parent-only search dimension")
ApplyGroupPolicy(parent, GroupPolicy{
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
childCalled := false
child := &cobra.Command{
Use: "enterprise",
RunE: func(*cobra.Command, []string) error {
childCalled = true
return nil
},
}
parent.AddCommand(child)
root.AddCommand(parent)
root.SetArgs([]string{"search", "--dimension", "name", "enterprise"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "unknown flag: --dimension") {
t.Fatalf("Execute() error = %v, want parent local flag rejected by child", err)
}
if childCalled {
t.Fatal("parent local flag leaked into child command execution")
}
}
func TestCrossPlatformCoverageApplyGroupPolicyHybridPreservesExecution(t *testing.T) {
called := false
cmd := &cobra.Command{
Use: "hybrid",
RunE: func(*cobra.Command, []string) error {
called = true
return nil
},
}
ApplyGroupPolicy(cmd, GroupPolicy{
Mode: GroupHybrid,
Positionals: PositionalsAllow,
Recovery: RecoveryDisabled,
})
if err := cmd.RunE(cmd, []string{"business-id"}); err != nil || !called {
t.Fatalf("hybrid RunE was not preserved: called=%v err=%v", called, err)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyHybridRejectRoutesUnknownArgs(t *testing.T) {
called := false
wrapperFrames := 0
cmd := &cobra.Command{
Use: "hybrid",
RunE: func(*cobra.Command, []string) error {
called = true
pcs := make([]uintptr, 32)
frames := runtime.CallersFrames(pcs[:runtime.Callers(0, pcs)])
for {
frame, more := frames.Next()
if strings.Contains(frame.Function, "corecmd.ApplyGroupPolicy.func") {
wrapperFrames++
}
if !more {
break
}
}
return nil
},
}
policy := GroupPolicy{
Mode: GroupHybrid,
Positionals: PositionalsReject,
Recovery: RecoverySibling,
}
ApplyGroupPolicy(cmd, policy)
// Applying the identical declaration must be a no-op. In particular, it
// must not wrap the already wrapped Hybrid RunE a second time.
ApplyGroupPolicy(cmd, policy)
if err := cmd.RunE(cmd, []string{"typo"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
t.Fatalf("hybrid typo error = %v", err)
}
if called {
t.Fatal("unknown positional must not reach hybrid business RunE")
}
if err := cmd.RunE(cmd, nil); err != nil || !called {
t.Fatalf("hybrid empty-args execution called=%v err=%v", called, err)
}
if wrapperFrames != 1 {
t.Fatalf("Hybrid RunE wrapper depth = %d, want exactly 1 after idempotent re-apply", wrapperFrames)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyDeepRecoveryUsesDescendantPath(t *testing.T) {
root := &cobra.Command{Use: "dws"}
sheet := &cobra.Command{Use: "sheet"}
rangeGroup := &cobra.Command{Use: "range"}
rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}})
ApplyGroupPolicy(rangeGroup, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
sheet.AddCommand(
rangeGroup,
&cobra.Command{Use: "+list-sheets", Run: func(*cobra.Command, []string) {}},
)
ApplyGroupPolicy(sheet, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep,
})
root.AddCommand(sheet)
err := sheet.RunE(sheet, []string{"read"})
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("deep recovery error = %T %v", err, err)
}
if structured.Reason != string(cmdutil.ResolutionUnknownSubcommand) ||
structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` {
t.Fatalf("deep recovery = %#v", structured)
}
if got, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(got, []string{"range read"}) {
t.Fatalf("deep suggestions = %#v", structured.Details["suggestions"])
}
err = sheet.RunE(sheet, []string{"+list-sheet"})
structured = nil
if !errors.As(err, &structured) || structured.Reason != string(cmdutil.ResolutionUnknownShortcut) {
t.Fatalf("direct shortcut recovery = %#v, err=%v", structured, err)
}
if !slices.Equal(structured.Actions, []string{
"Run 'dws sheet --help' for the full list",
"Run 'dws shortcut list --service sheet --format json'",
}) {
t.Fatalf("direct shortcut actions = %#v", structured.Actions)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyRejectWithoutRecoveryUsesCobraArgs(t *testing.T) {
cmd := &cobra.Command{Use: "parent"}
ApplyGroupPolicy(cmd, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled,
})
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
t.Fatal("RecoveryDisabled must leave rejected positionals to Cobra")
}
}
func TestCrossPlatformCoverageApplyGroupPolicyFailsClosed(t *testing.T) {
mustPanic := func(name, needle string, fn func()) {
t.Helper()
t.Run(name, func(t *testing.T) {
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), needle) {
t.Fatalf("panic = %v, want %q", got, needle)
}
}()
fn()
})
}
mustPanic("nil command", "nil command", func() { ApplyGroupPolicy(nil, GroupPolicy{}) })
mustPanic("zero policy", "zero GroupPolicy", func() { ApplyGroupPolicy(&cobra.Command{Use: "leaf"}, GroupPolicy{}) })
mustPanic("invalid policy", "invalid GroupPolicy", func() {
ApplyGroupPolicy(&cobra.Command{Use: "broken"}, GroupPolicy{
Mode: GroupHybrid, Positionals: "unexpected", Recovery: RecoveryDisabled,
})
})
mustPanic("hybrid must run", "must declare RunE", func() {
ApplyGroupPolicy(&cobra.Command{Use: "hybrid"}, GroupPolicy{
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
})
mustPanic("conflicting redeclaration", "redeclares GroupPolicy", func() {
cmd := &cobra.Command{Use: "parent"}
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled})
})
mustPanic("invalid existing metadata", "invalid GroupPolicy metadata", func() {
cmd := &cobra.Command{
Use: "broken",
Annotations: map[string]string{
groupPolicyAnnotation: "hybrid|reject|unexpected",
},
}
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
})
}
func TestCrossPlatformCoverageGroupPolicyForRejectsMalformedPrivateMetadata(t *testing.T) {
for name, test := range map[string]struct {
encoded string
needle string
}{
"malformed": {encoded: "hybrid|reject", needle: "malformed"},
"invalid policy": {encoded: "hybrid|reject|unexpected", needle: "recovery"},
"zero policy": {encoded: "||", needle: "must not be zero"},
} {
t.Run(name, func(t *testing.T) {
cmd := &cobra.Command{
Use: "broken",
Annotations: map[string]string{
groupPolicyAnnotation: test.encoded,
},
}
if _, ok, err := GroupPolicyFor(cmd); err == nil || ok || !strings.Contains(err.Error(), test.needle) {
t.Fatalf("GroupPolicyFor(%q) = ok %v, err %v; want %q", test.encoded, ok, err, test.needle)
}
})
}
}
+18
View File
@@ -151,6 +151,24 @@ func WithOperation(operation string) Option {
}
}
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
// typed framework confirmation-gate failure carrying reason
// confirmation_required. Downstream classifiers must pass such errors through
// verbatim: the "re-run with --yes" semantics can only be carried by the
// machine-readable reason, while message-text classification actively
// misroutes them (a command path containing "permission" would be reported as
// an auth failure, and any other wording degrades to an unclassified error).
func IsConfirmationRequired(err error) bool {
if err == nil {
return false
}
var typed *Error
if stderrors.As(err, &typed) {
return strings.TrimSpace(typed.Reason) == "confirmation_required"
}
return false
}
// WithServerKey records the server identifier associated with the failure.
func WithServerKey(serverKey string) Option {
return func(err *Error) {
+32
View File
@@ -15,6 +15,7 @@ package errors
import (
stderrors "errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -464,3 +465,34 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
t.Fatalf("normal mode should not show RPC Code, got %q", got)
}
}
func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) {
t.Parallel()
if IsConfirmationRequired(nil) {
t.Fatal("nil error must not report confirmation_required")
}
if IsConfirmationRequired(NewValidation("missing required flag")) {
t.Fatal("plain validation error must not report confirmation_required")
}
plain := stderrors.New("需要用户确认")
if IsConfirmationRequired(plain) {
t.Fatal("message text alone must not report confirmation_required")
}
confirmation := NewValidation(
"blocked",
WithReason("confirmation_required"),
)
if !IsConfirmationRequired(confirmation) {
t.Fatal("typed confirmation error must report confirmation_required")
}
// 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。
wrapped := fmt.Errorf("call tool: %w", confirmation)
if !IsConfirmationRequired(wrapped) {
t.Fatal("wrapped confirmation error must report confirmation_required")
}
otherReason := NewValidation("rate limited", WithReason("rate_limit"))
if IsConfirmationRequired(otherReason) {
t.Fatal("other reasons must not report confirmation_required")
}
}
+40 -9
View File
@@ -15,7 +15,7 @@ var agoalLoadLocation = time.LoadLocation
// ──────────────────────────────────────────────────────────
func newAgoalCommand() *cobra.Command {
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "agoal",
Short: "Agoal 管理",
Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。
@@ -31,6 +31,7 @@ func newAgoalCommand() *cobra.Command {
dws agoal scorecard detail 获取计分卡详情
dws agoal scorecard entity-detail 获取计分卡实体详情
dws agoal scorecard update 更新计分卡
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
dws agoal user rules 获取用户规则
dws agoal user objectives 查询用户目标列表
dws agoal report list-statistics 获取周月报数据跟催列表
@@ -38,11 +39,11 @@ func newAgoalCommand() *cobra.Command {
dws agoal obj-template list 获取目标模板列表
dws agoal obj-template create-or-update 新增或更新目标模板`,
RunE: groupRunE,
}
})
// ── strategy: 战略解码管理 ──────────────────────────────────
strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}
strategyCmd := newGroupCommand(&cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE})
strategyListCmd := &cobra.Command{
Use: "list",
@@ -138,7 +139,7 @@ scopeType 支持:
// ── contract: 经营合约管理 ──────────────────────────────────
contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}
contractCmd := newGroupCommand(&cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE})
contractListCmd := &cobra.Command{
Use: "list",
@@ -257,7 +258,7 @@ scopeType 支持:
// ── scorecard: 计分卡管理 ───────────────────────────────────
scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}
scorecardCmd := newGroupCommand(&cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE})
scorecardDetailCmd := &cobra.Command{
Use: "detail",
@@ -359,11 +360,41 @@ scopeType 支持:
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
scorecardSearchContentCmd := &cobra.Command{
Use: "search-entities",
Short: "搜索计分卡指标与关键事项",
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
Example: ` dws agoal scorecard search-entities --keyword "业绩"
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
return err
}
toolArgs := map[string]any{
"keyword": mustGetFlag(cmd, "keyword"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
toolArgs["page"] = v
}
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
toolArgs["pageSize"] = v
}
return callMCPTool("search_score_card_entities", toolArgs)
},
}
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
// ── user: 用户目标管理 ──────────────────────────────────────
userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE})
userRulesCmd := &cobra.Command{
Use: "rules",
@@ -414,7 +445,7 @@ scopeType 支持:
// ── report: 周月报管理 ──────────────────────────────────────
reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}
reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE})
reportListStatisticsCmd := &cobra.Command{
Use: "list-statistics",
@@ -489,7 +520,7 @@ scopeType 支持:
// ── template: 目标模板管理 ──────────────────────────────────
objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}
objTemplateCmd := newGroupCommand(&cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE})
objTemplateListCmd := &cobra.Command{
Use: "list",
+1
View File
@@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command {
return groupRunE(cmd, args)
},
}
newHybridGroupCommand(root)
// root 和 person 各自定义同一组本地 flag,这样:
// - dws aisearch --query xxx ← root 自己能解析
File diff suppressed because it is too large Load Diff
@@ -361,3 +361,228 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
}
}
func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"dws", "aitable"}
filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]`
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}`
readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}`
t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" {
t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls)
}
config, _ := caller.calls[1].args["config"].(map[string]any)
if _, ok := config["filter"].([]any); !ok {
t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args)
}
})
t.Run("logical groups fail closed before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("unknown field is rejected before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`)
if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 {
t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti","A"]}]`
stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}`
terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}`
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" {
t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select operator rejects text field", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`)
if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 {
t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select array fails closed before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select invalid array value fails before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 {
t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("date and system-time fields require date operators", func(t *testing.T) {
fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"}
for fieldID := range fieldTypes {
valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(valid, fieldTypes); err != nil {
t.Fatalf("date_eq for %s: %v", fieldID, err)
}
invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") {
t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err)
}
}
})
t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, dryRun: true}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run")
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("read-back mismatch is not success", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls)
}
})
}
func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "aitable"}
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}`
filter := `[{"operator":"eq","operands":["fldA","x"]}]`
args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter}
t.Run("update transport error", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 {
t.Fatalf("update error = %v, calls=%#v", err, caller.calls)
}
})
t.Run("readback transport errors exhaust", func(t *testing.T) {
errs := []error{nil, nil}
for range aitableViewFilterReadbackAttempts {
errs = append(errs, context.DeadlineExceeded)
}
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls))
}
})
t.Run("readback wrong identity exhausts", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") {
t.Fatalf("wrong readback identity = %v", err)
}
})
loadCases := []struct {
name string
response string
callErr error
wantError string
}{
{name: "transport", callErr: context.Canceled, wantError: "context canceled"},
{name: "invalid json", response: `{`, wantError: "not valid JSON"},
{name: "missing collection", response: `{}`, wantError: "missing the fields collection"},
{name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"},
}
for _, tc := range loadCases {
t.Run("load fields "+tc.name, func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}}
installAitableDeps(t, caller)
if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("load fields error = %v, want %q", err, tc.wantError)
}
})
}
t.Run("load legacy field keys", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}}
installAitableDeps(t, caller)
got, err := loadAitableFieldTypes(context.Background(), "b", "t")
if err != nil || got["legacy"] != "text" {
t.Fatalf("legacy field keys = %#v, %v", got, err)
}
})
if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok {
t.Fatal("scalar fields collection must fail")
}
if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok {
t.Fatal("scalar field item must fail")
}
if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 {
t.Fatalf("recursive fields = %#v, %v", got, ok)
}
if got, ok := findAitableObjectList(map[string]any{
"fieldList": []any{map[string]any{"fieldId": "legacy"}},
"fields": []any{map[string]any{"fieldId": "canonical"}},
}, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" {
t.Fatalf("declared collection priority = %#v, %v", got, ok)
}
invalidFilters := []struct {
filter []any
want string
}{
{filter: []any{"bad"}, want: "must be an object"},
{filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"},
{filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"},
{filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"},
{filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"},
{filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"},
{filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"},
}
for _, tc := range invalidFilters {
if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want)
}
}
if err := validateAitableMultiSelectOptionNames(nil); err == nil {
t.Fatal("empty any_of array must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil {
t.Fatal("non-string any_of option must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil {
t.Fatalf("valid any_of option names = %v", err)
}
if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") {
t.Fatalf("compactJSON fallback = %q", got)
}
if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) {
t.Fatal("invalid persisted wrapper must not match")
}
}
+5 -1
View File
@@ -24,6 +24,7 @@ type aitableTestCaller struct {
responses []string
errors []error
calls []aitableTestCall
dryRun bool
}
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg
}
return textToolResult(response), nil
}
func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, server, tool, args)
}
func (*aitableTestCaller) Format() string { return "json" }
func (*aitableTestCaller) DryRun() bool { return false }
func (c *aitableTestCaller) DryRun() bool { return c.dryRun }
func (*aitableTestCaller) Fields() string { return "" }
func (*aitableTestCaller) JQ() string { return "" }
+462
View File
@@ -0,0 +1,462 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"context"
"io"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type aitableDatasourceCaller struct {
calls []aitableTestCall
}
func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
return &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`,
}}}, nil
}
func (*aitableDatasourceCaller) Format() string { return "json" }
func (*aitableDatasourceCaller) DryRun() bool { return false }
func (*aitableDatasourceCaller) Fields() string { return "" }
func (*aitableDatasourceCaller) JQ() string { return "" }
func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) {
t.Helper()
testseam.Protect(t, &os.Args)
caller := &aitableDatasourceCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
os.Args = append([]string{"dws", "aitable", "datasource"}, args...)
root := newAitableCommand()
root.SetArgs(append([]string{"datasource"}, args...))
return caller, root.Execute()
}
func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-ids") {
t.Fatalf("error = %v, want table-ids required", err)
}
}
func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1,T2,T3,T4,T5,T6")
if err == nil || !strings.Contains(err.Error(), "1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "")
if err == nil || !strings.Contains(err.Error(), "table-ids") {
t.Fatalf("error = %v, want table-ids error", err)
}
}
func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1,T2,T3,T4,T5")
if err != nil {
t.Fatalf("5 table-ids should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
"--table-ids", "T1")
if err != nil {
t.Fatalf("single table-id should succeed: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(caller.calls))
}
}
func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456",
"--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6")
if err == nil || !strings.Contains(err.Error(), "requires 1-5") {
t.Fatalf("error = %v, want 1-5 limit", err)
}
}
func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456",
"--task-ids", "TK1,TK2,TK3,TK4,TK5")
if err != nil {
t.Fatalf("5 task-ids should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status",
"--base-id", "BASE123", "--table-id", "TBL456")
if err == nil || !strings.Contains(err.Error(), "task-ids") {
t.Fatalf("error = %v, want task-ids required", err)
}
}
func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceGetConfigSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "get-config",
"--base-id", "BASE123", "--table-id", "TBL456")
if err != nil {
t.Fatalf("get-config should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if caller.calls[0].args["tableId"] != "TBL456" {
t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"])
}
}
func TestAitableDatasourceListSourcesSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "list-sources",
"--base-id", "BASE123", "--datasource-type", "OA")
if err != nil {
t.Fatalf("list-sources should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
}
func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "datasource-type") {
t.Fatalf("error = %v, want datasource-type required", err)
}
}
func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config required", err)
}
}
func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceGetFieldsSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "get-fields",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("get-fields should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
}
}
func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA")
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config required", err)
}
}
func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceCreateSuccess(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("create should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
t.Fatalf("auto = %v, want false when not provided", v)
}
}
func TestAitableDatasourceCreateWithAuto(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto")
if err != nil {
t.Fatalf("create with --auto should succeed: %v", err)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
t.Fatalf("auto = %v, want true", v)
}
}
func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto",
"--field-ids", "fldAAA,fldBBB",
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
if err != nil {
t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
}
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
}
}
func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--base-id", "BASE123", "--datasource-type", "OA",
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
"--auto-sync-setting", `not-json`)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123")
if err == nil || !strings.Contains(err.Error(), "table-id") {
t.Fatalf("error = %v, want table-id required", err)
}
}
func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", `not-json`)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("error = %v, want source-config validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", raw)
if err == nil || !strings.Contains(err.Error(), "source-config") {
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
}
}
}
func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456")
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
t.Fatalf("error = %v, want at least one config change required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when no changes provided")
}
}
func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456", "--auto")
if err != nil {
t.Fatalf("update with --auto only should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
t.Fatalf("auto = %v, want true", v)
}
}
func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
if err != nil {
t.Fatalf("update with source-config should succeed: %v", err)
}
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
}
if _, ok := caller.calls[0].args["auto"]; ok {
t.Fatalf("auto should not be sent when --auto is omitted")
}
}
func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456", "--auto=false")
if err != nil {
t.Fatalf("update with --auto=false should succeed: %v", err)
}
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
t.Fatalf("auto = %v, want false", v)
}
}
func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--field-ids", "fldAAA,fldBBB",
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
if err != nil {
t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
t.Fatalf("unexpected calls: %#v", caller.calls)
}
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
}
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
}
}
func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", `not-json`)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
}
}
func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) {
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
for _, raw := range cases {
_, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", raw)
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err)
}
}
}
func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--field-ids", "")
if err == nil || !strings.Contains(err.Error(), "field-ids") {
t.Fatalf("error = %v, want field-ids empty error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when empty field-ids is rejected")
}
}
func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) {
caller, err := runAitableDatasourceCommand(t, "update",
"--base-id", "BASE123", "--table-id", "TBL456",
"--auto-sync-setting", "")
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected")
}
}
func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "get-fields",
"--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "base-id") {
t.Fatalf("error = %v, want base-id required", err)
}
}
func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) {
_, err := runAitableDatasourceCommand(t, "create",
"--datasource-type", "OA",
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
if err == nil || !strings.Contains(err.Error(), "base-id") {
t.Fatalf("error = %v, want base-id required", err)
}
}

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