Compare commits

...
Author SHA1 Message Date
赤川 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
赤川 23167ef974 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:46: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
赤川 a352615e77 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:15:06 +08:00
赤川 6288199a93 Merge branch 'main' into codex/fix-report-requiredness-governance 2026-08-21 11:05:33 +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
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
hlzjsong 02aad9020a Merge branch 'main' into refresh_org_slot_fix 2026-08-21 09:26:39 +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
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
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
赤川 47e3c2b3c5 Merge branch 'main' into refresh_org_slot_fix 2026-08-20 23:19:33 +08:00
Dennis 23940e4752 revert: keep coverage shard output compact 2026-08-20 22:24:17 +08:00
Dennis bbaf033618 ci: stream app coverage progress 2026-08-20 22:14:35 +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 378b9f67a2 Merge remote-tracking branch 'origin/main' into codex/investigate-calendar-todo-comment-regressions 2026-08-20 20:41:56 +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
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
Dennis fbcd8887ee fix: harden calendar todo and comment shortcuts 2026-08-20 16:09:18 +08:00
37 changed files with 1593 additions and 43 deletions
@@ -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,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.
+23
View File
@@ -6,6 +6,29 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.60-beta.1] - 2026-08-21
### 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.
- **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.
### 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.
- **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.
## [1.0.59] - 2026-08-20
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
+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">
+41 -5
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 消费。
## 跨命令迁移原语
@@ -103,12 +110,12 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
## 两阶段迁移与回执清理
每条迁移以 `(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`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
@@ -157,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 会验证:
@@ -177,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`。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
@@ -188,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 投影边界
@@ -215,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 相同的权威门禁:
+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
}
+78 -1
View File
@@ -811,7 +811,84 @@ 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)
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
}
// 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()
}
+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,
)
+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)
}
}
}
+4
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
return err
}
if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil {
return err
}
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
"nodeId": nodeID,
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
+23
View File
@@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
}
}
func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) {
for _, surface := range []string{"doc", "sheet"} {
for _, test := range []struct {
name string
args []string
}{
{name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}},
{name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}},
{name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}},
} {
t.Run(surface+"/"+test.name, func(t *testing.T) {
caller := &docCommentMutationCaller{}
if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil {
t.Fatal("unsupported reaction accepted")
}
if len(caller.calls) != 0 {
t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls)
}
})
}
}
}
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
for _, surface := range []struct {
name string
+4
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"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"
@@ -3220,6 +3221,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
return err
}
groupMentions, err := commentGroupMentionIDs(cmd)
if err != nil {
return err
+3 -3
View File
@@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", "oc-1",
)
if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") {
@@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji", "--mentioned-open-conversation-id", " ",
)
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
@@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
err := executeDocGroupMentionCommand(
t,
caller,
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
"--emoji",
)
if err != nil {
+4
View File
@@ -1,6 +1,7 @@
package helpers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -154,6 +155,9 @@ func newSheetCommentCmd() *cobra.Command {
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
return err
}
toolArgs["emoji"] = true
}
if v, _ := cmd.Flags().GetString("mention"); v != "" {
@@ -93,7 +93,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
"comment", "reply",
"--node", "node-1",
"--comment-key", "ck-1",
"--content", "heart",
"--content", "比心",
"--emoji",
"--mention", "uid1",
},
@@ -102,7 +102,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
toolName: "reply_comment",
args: map[string]any{
"nodeId": "node-1",
"content": "heart",
"content": "比心",
"replyCommentKey": "ck-1",
"emoji": true,
"mentionedUserIds": []string{"uid1"},
+1
View File
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
"已取消操作": "Operation cancelled",
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "The current identity's refresh_token is invalid; login state was recovered from the organization mirror token.",
"待办": "todo",
"待办任务 ID (必填)": "Todo task ID (required)",
"待办任务管理": "Todo task management",
+1
View File
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
"已取消操作": "已取消操作",
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态",
"待办": "待办",
"待办任务 ID (必填)": "待办任务 ID (必填)",
"待办任务管理": "待办任务管理",
+125 -18
View File
@@ -17,6 +17,9 @@ const FlagMigrationManifestVersion = 1
const (
FlagMigrationPending = "pending"
FlagMigrationConsumed = "consumed"
FlagMigrationRename = "flag_rename"
FlagMigrationRequirednessChange = "requiredness_change"
)
type FlagMigrationManifest struct {
@@ -25,11 +28,13 @@ type FlagMigrationManifest struct {
}
type FlagMigration struct {
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
State string `json:"state"`
Reason string `json:"reason"`
Kind string `json:"kind,omitempty"`
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
Flag *FlagMigrationSide `json:"flag,omitempty"`
State string `json:"state"`
Reason string `json:"reason"`
}
type FlagMigrationSide struct {
@@ -287,6 +292,23 @@ func (m FlagMigrationManifest) Validate() error {
}
legacyTargets[legacyKey] = migration.Canonical.Name
}
for _, requiredness := range m.Migrations {
if requiredness.EffectiveKind() != FlagMigrationRequirednessChange {
continue
}
for _, rename := range m.Migrations {
if rename.EffectiveKind() != FlagMigrationRename || rename.Command != requiredness.Command {
continue
}
if rename.Legacy.Name == requiredness.Flag.Name || rename.Canonical.Name == requiredness.Flag.Name {
return fmt.Errorf(
"flag requiredness migration %s overlaps rename migration %s",
requiredness.displayKey(),
rename.displayKey(),
)
}
}
}
return nil
}
@@ -294,15 +316,6 @@ func (m FlagMigration) validate() error {
if !isExactCommandPath(m.Command) {
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if strings.TrimSpace(m.Reason) == "" {
return fmt.Errorf("migration must include a non-empty reason")
}
@@ -312,6 +325,30 @@ func (m FlagMigration) validate() error {
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
switch m.EffectiveKind() {
case FlagMigrationRename:
return m.validateRename()
case FlagMigrationRequirednessChange:
return m.validateRequirednessChange()
default:
return fmt.Errorf("invalid kind %q", m.Kind)
}
}
func (m FlagMigration) validateRename() error {
if m.Flag != nil {
return fmt.Errorf("flag rename must not declare flag requiredness fields")
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if err := m.Legacy.Before.validate("legacy before"); err != nil {
return err
}
@@ -392,6 +429,45 @@ func (m FlagMigration) validate() error {
return nil
}
func (m FlagMigration) validateRequirednessChange() error {
if m.Legacy != (FlagMigrationSide{}) || m.Canonical != (FlagMigrationSide{}) {
return fmt.Errorf("flag requiredness migration must not declare legacy or canonical rename fields")
}
if m.Flag == nil || !isExactFlagName(m.Flag.Name) {
return fmt.Errorf("flag name must be an exact flag: %q", flagMigrationSideName(m.Flag))
}
if err := m.Flag.Before.validate("flag before"); err != nil {
return err
}
if err := m.Flag.After.validate("flag after"); err != nil {
return err
}
if !m.Flag.Before.Present || !m.Flag.After.Present || m.Flag.Before.Required || !m.Flag.After.Required {
return fmt.Errorf("flag requiredness migration must change exactly from optional to required")
}
if m.Flag.Before.Hidden || m.Flag.After.Hidden {
return fmt.Errorf("flag requiredness migration flag must stay visible")
}
if m.Flag.Before.AliasOf != "" || m.Flag.After.AliasOf != "" {
return fmt.Errorf("flag requiredness migration must not declare alias_of")
}
before := m.Flag.Before
after := m.Flag.After
before.Required = false
after.Required = false
if before != after {
return fmt.Errorf("flag requiredness migration must preserve every flag attribute except requiredness")
}
return nil
}
func flagMigrationSideName(side *FlagMigrationSide) string {
if side == nil {
return ""
}
return side.Name
}
func (s FlagMigrationState) validate(label string) error {
if !s.Present {
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
@@ -409,7 +485,19 @@ func (s FlagMigrationState) validate(label string) error {
}
func (m FlagMigration) key() string {
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
if m.EffectiveKind() == FlagMigrationRequirednessChange {
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + flagMigrationSideName(m.Flag)
}
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
}
// EffectiveKind keeps manifests written before kinds were introduced valid.
// An omitted kind is the original flag rename primitive.
func (m FlagMigration) EffectiveKind() string {
if m.Kind == "" {
return FlagMigrationRename
}
return m.Kind
}
func isExactCommandPath(path string) bool {
@@ -428,9 +516,9 @@ func isExactFlagName(name string) bool {
}
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
// records participate in the lifecycle check, but never authorize their own
// interface change.
// then consumes only exact, merge-base-owned flag rename or requiredness
// migrations. Candidate-owned records participate in the lifecycle check, but
// never authorize their own interface change.
func CompareAllWithFlagMigrations(
current Snapshot,
references map[string]Snapshot,
@@ -640,6 +728,16 @@ func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMig
if !exists {
return flagMigrationPartial
}
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
state := flagMigrationStateForCommand(command, flagMigrationSideName(migration.Flag))
if state == migration.Flag.Before {
return flagMigrationBefore
}
if state == migration.Flag.After {
return flagMigrationAfter
}
return flagMigrationPartial
}
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
@@ -694,6 +792,12 @@ func flagMigrationAuthorizesChange(
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
continue
}
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
if change.Flag == migration.Flag.Name && change.Kind == "flag_became_required" {
return true
}
continue
}
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
return true
}
@@ -717,5 +821,8 @@ func flagMigrationAuthorizesChange(
}
func (m FlagMigration) displayKey() string {
if m.EffectiveKind() == FlagMigrationRequirednessChange {
return fmt.Sprintf("%q --%s optional -> required", m.Command, flagMigrationSideName(m.Flag))
}
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
}
@@ -38,6 +38,23 @@ const validFlagMigrationManifestJSON = `{
]
}`
const validRequirednessMigrationManifestJSON = `{
"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."
}
]
}`
func optionalFlagMigrationManifestJSON() string {
manifest := strings.Replace(
validFlagMigrationManifestJSON,
@@ -153,6 +170,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
}
if _, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)); err != nil {
t.Fatalf("ReadFlagMigrationManifest(requiredness change) error = %v", err)
}
tests := []struct {
name string
@@ -246,6 +266,229 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
}
}
func TestCrossPlatformCoverageRequirednessMigrationRejectsInexactContracts(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "unknown kind",
input: strings.Replace(validRequirednessMigrationManifestJSON, `"requiredness_change"`, `"anything"`, 1),
wantErr: "invalid kind",
},
{
name: "missing flag name",
input: strings.Replace(validRequirednessMigrationManifestJSON, `"to-user-ids"`, `""`, 1),
wantErr: "exact flag",
},
{
name: "invalid before state",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"before": {"present": true, "type": "string", "scope": "local"}`,
`"before": {"present": true, "type": "string", "scope": "anything"}`,
1,
),
wantErr: "flag before present state has invalid scope",
},
{
name: "invalid after state",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "scope": "anything"}`,
1,
),
wantErr: "flag after present state has invalid scope",
},
{
name: "before already required",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"before": {"present": true, "type": "string", "scope": "local"}`,
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
1,
),
wantErr: "optional to required",
},
{
name: "after remains optional",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "scope": "local"}`,
1,
),
wantErr: "optional to required",
},
{
name: "type drift",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "stringSlice", "required": true, "scope": "local"}`,
1,
),
wantErr: "must preserve every flag attribute except requiredness",
},
{
name: "visibility drift",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`,
1,
),
wantErr: "must stay visible",
},
{
name: "alias relation",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "required": true, "scope": "local", "alias_of": "other"}`,
1,
),
wantErr: "must not declare alias_of",
},
{
name: "rename fields cannot be combined",
input: strings.Replace(
validRequirednessMigrationManifestJSON,
`"flag": {`,
`"legacy": {"name":"old","before":{"present":false},"after":{"present":false}}, "flag": {`,
1,
),
wantErr: "must not declare legacy or canonical",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
missingFlag := FlagMigration{
Kind: FlagMigrationRequirednessChange,
Command: "dws report entry submit",
State: FlagMigrationPending,
Reason: "Missing exact flag state.",
}
if err := missingFlag.validate(); err == nil || !strings.Contains(err.Error(), "exact flag") {
t.Fatalf("missing requiredness flag error = %v", err)
}
rename, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
rename.Migrations[0].Kind = FlagMigrationRename
rename.Migrations[0].Flag = &FlagMigrationSide{
Name: "unrelated",
Before: FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
}
if err := rename.Validate(); err == nil || !strings.Contains(err.Error(), "must not declare flag requiredness fields") {
t.Fatalf("rename with requiredness fields error = %v", err)
}
}
func TestCrossPlatformCoverageRequirednessMigrationLifecycleAndAuthorization(t *testing.T) {
pending, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
migration := pending.Migrations[0]
before := requirednessMigrationSnapshot(migration, false)
after := requirednessMigrationSnapshot(migration, true)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_required", migration.Command, migration.Flag.Name) {
t.Fatalf("fixture did not produce flag_became_required: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("governed requiredness change = (%#v, %v), want compatible", report, err)
}
partial := after
partial.Commands = append([]Command(nil), after.Commands...)
partial.Commands[len(partial.Commands)-1].LocalFlags = append(
[]Flag(nil),
after.Commands[len(after.Commands)-1].LocalFlags...,
)
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Type = "stringSlice"
if phase := matchFlagMigrationPhase(partial, migration); phase != flagMigrationPartial {
t.Fatalf("drifted requiredness migration phase = %s, want partial", phase)
}
beforeWithUnrelated := before
beforeWithUnrelated.Commands = append([]Command(nil), before.Commands...)
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags = append(
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags,
Flag{Name: "unrelated", Type: "string"},
)
afterWithUnrelated := after
afterWithUnrelated.Commands = append([]Command(nil), after.Commands...)
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags = append(
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags,
Flag{Name: "unrelated", Type: "string", Required: true},
)
report, err = CompareAllWithFlagMigrations(
afterWithUnrelated,
map[string]Snapshot{"merge-base": beforeWithUnrelated, "stable": beforeWithUnrelated},
pending,
consumed,
)
if err != nil || report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_became_required", migration.Command, "unrelated") {
t.Fatalf("unrelated requiredness change was hidden: report=%#v err=%v", report, err)
}
empty := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
if _, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
t.Fatalf("candidate self-authorization error = %v", err)
}
}
func requirednessMigrationSnapshot(migration FlagMigration, after bool) Snapshot {
state := migration.Flag.Before
if after {
state = migration.Flag.After
}
flag := Flag{
Name: migration.Flag.Name,
Type: state.Type,
Required: state.Required,
Hidden: state.Hidden,
Shorthand: state.Shorthand,
NoOpt: state.NoOpt,
}
command := testCommandWithFlagScopes(migration.Command, nil, nil)
if state.Scope == "inherited" {
command.InheritedFlags = []Flag{flag}
} else {
command.LocalFlags = []Flag{flag}
}
return testSnapshot(testCommand("dws"), command)
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
@@ -282,6 +525,22 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
if err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing JSON error = %v", err)
}
requiredness, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
overlap := manifest
overlap.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
rename := overlap.Migrations[0]
rename.Command = requiredness.Migrations[0].Command
rename.Canonical.Name = requiredness.Migrations[0].Flag.Name
rename.Legacy.After.AliasOf = rename.Canonical.Name
overlap.Migrations[0] = rename
overlap.Migrations = append(overlap.Migrations, requiredness.Migrations[0])
if err := overlap.Validate(); err == nil || !strings.Contains(err.Error(), "overlaps rename migration") {
t.Fatalf("requiredness and rename overlap error = %v", err)
}
}
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
@@ -72,14 +72,24 @@ func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPaginatio
if err != nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err)
}
var serviceEmptySentinel map[string]any
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"attendees":null,"categories":null,"meetingRooms":null,"reminders":null}],"hasMore":false}}`), &serviceEmptySentinel); err != nil {
t.Fatal(err)
}
events, page, err = eventListProject(serviceEmptySentinel)
if err != nil || events == nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("service empty sentinel: events=%#v page=%+v err=%v", events, page, err)
}
for name, payload := range map[string]string{
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
"bad collection": `{"success":true,"result":{"events":{}}}`,
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
"missing pagination": `{"success":true,"result":{"events":[]}}`,
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
"bad collection": `{"success":true,"result":{"events":{}}}`,
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
"unknown null item": `{"success":true,"result":{"events":[{"summary":null}],"hasMore":false}}`,
"sentinel with cursor": `{"success":true,"result":{"events":[{"attendees":null}],"hasMore":false,"nextCursor":"unexpected"}}`,
"missing pagination": `{"success":true,"result":{"events":[]}}`,
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
} {
t.Run(name, func(t *testing.T) {
var data map[string]any
+4
View File
@@ -16,6 +16,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
@@ -205,6 +206,9 @@ func requireCalendarCollection(data map[string]any, operation string, keys ...st
if !ok {
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
if key == "events" {
items, _ = calendarcompat.NormalizeTerminalEmptyEvents(items, container)
}
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
return nil, nil, err
}
@@ -0,0 +1,48 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package calendarcompat contains narrow compatibility adapters for observed
// Calendar wire shapes shared by the calendar and smart Shortcut packages.
package calendarcompat
// NormalizeTerminalEmptyEvents recognizes the Calendar service's legacy empty
// page sentinel. Some deployments encode an exhausted empty page as one object
// whose collection fields are all null instead of events:[]. Only that exact
// terminal shape is normalized; arbitrary id-less or mixed event rows remain
// available to the strict caller validation and must fail closed.
func NormalizeTerminalEmptyEvents(items []any, container map[string]any) ([]any, bool) {
if len(items) != 1 || container == nil {
return items, false
}
hasMore, ok := container["hasMore"].(bool)
if !ok || hasMore || hasNonEmptyCursor(container) {
return items, false
}
placeholder, ok := items[0].(map[string]any)
knownNullFields := map[string]struct{}{
"attendees": {},
"categories": {},
"meetingRooms": {},
"reminders": {},
}
if !ok || len(placeholder) != len(knownNullFields) {
return items, false
}
for key, value := range placeholder {
if _, known := knownNullFields[key]; !known || value != nil {
return items, false
}
}
return []any{}, true
}
func hasNonEmptyCursor(container map[string]any) bool {
for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
value, present := container[key]
if !present || value == nil || value == "" {
continue
}
return true
}
return false
}
@@ -0,0 +1,44 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package calendarcompat
import "testing"
func TestCrossPlatformCoverageNormalizeTerminalEmptyEvents(t *testing.T) {
sentinel := []any{map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
}}
if got, ok := NormalizeTerminalEmptyEvents(sentinel, map[string]any{"hasMore": false}); !ok || got == nil || len(got) != 0 {
t.Fatalf("terminal sentinel = %#v, %v; want explicit empty slice", got, ok)
}
for name, items := range map[string][]any{
"empty object": {map[string]any{}},
"missing fields": {map[string]any{"attendees": nil}},
"unknown field": {map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "summary": nil,
}},
"non-null field": {map[string]any{
"attendees": []any{}, "categories": nil, "meetingRooms": nil, "reminders": nil,
}},
"mixed rows": {sentinel[0], map[string]any{"id": "event-1"}},
} {
t.Run(name, func(t *testing.T) {
if _, ok := NormalizeTerminalEmptyEvents(items, map[string]any{"hasMore": false}); ok {
t.Fatalf("invalid sentinel accepted: %#v", items)
}
})
}
for name, container := range map[string]map[string]any{
"missing terminal evidence": {},
"more pages": {"hasMore": true},
"cursor": {"hasMore": false, "nextCursor": "next"},
} {
t.Run(name, func(t *testing.T) {
if _, ok := NormalizeTerminalEmptyEvents(sentinel, container); ok {
t.Fatalf("non-terminal sentinel accepted with %#v", container)
}
})
}
}
+4
View File
@@ -19,6 +19,7 @@ package doc
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
@@ -564,6 +565,9 @@ var CommentReply = shortcut.Shortcut{
"replyCommentKey": rt.Str("comment-key"),
}
if rt.Bool("emoji") {
if err := commentreaction.Validate(rt.Str("content")); err != nil {
return err
}
params["emoji"] = true
}
if rt.Changed("mention") {
@@ -132,6 +132,31 @@ func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *doc
return root.Execute()
}
func TestCrossPlatformCoverageCommentReplyRejectsUnsupportedEmojiBeforeRPC(t *testing.T) {
for _, content := range []string{"😄", "乱码"} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
err := runDocCoverage(t, CommentReply, caller,
"--node", "node-1", "--comment-key", "comment-1",
"--content", content, "--emoji", "--yes")
if err == nil {
t.Fatalf("unsupported reaction %q accepted", content)
}
if caller.calls != 0 {
t.Fatalf("unsupported reaction %q reached RPC %d time(s)", content, caller.calls)
}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, CommentReply, caller,
"--node", "node-1", "--comment-key", "comment-1",
"--content", "鼓掌", "--emoji", "--yes"); err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.history[0].params["emoji"] != true {
t.Fatalf("valid reaction call = %#v", caller.history)
}
}
func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) {
revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}}
if got, ok := nestedRevision(revisionPayload); !ok || got != 9 {
@@ -23,6 +23,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
const calendarSmartMaxPages = 20
@@ -215,6 +216,7 @@ func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string
if !hasMore && next != "" {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空")
}
list, _ = calendarcompat.NormalizeTerminalEmptyEvents(list, result)
events := make([]map[string]any, 0, len(list))
for index, item := range list {
event, ok := item.(map[string]any)
@@ -148,6 +148,18 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
t.Fatalf("explicit empty page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
items, hasMore, next, err = calendarSmartEventPage(map[string]any{
"success": true,
"result": map[string]any{
"events": []any{map[string]any{
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
}},
"hasMore": false,
},
})
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
t.Fatalf("service empty sentinel items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
invalidPages := []map[string]any{
{},
@@ -157,6 +169,7 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": true}},
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": false, "nextCursor": "unexpected"}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "event-placeholder"}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"summary": nil}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}},
}
for index, page := range invalidPages {
+44 -1
View File
@@ -6,6 +6,7 @@ package todo
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -159,13 +160,55 @@ func updateTodo(rt *shortcut.RuntimeContext) error {
if key == "taskId" {
continue
}
if fmt.Sprint(detail[key]) != fmt.Sprint(expected) {
if !todoUpdateFieldMatches(key, detail[key], expected) {
return todoResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
}
}
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
}
func todoUpdateFieldMatches(key string, actual, expected any) bool {
switch key {
case "dueTime", "priority":
actualNumber, actualOK := todoExactInteger(actual)
expectedNumber, expectedOK := todoExactInteger(expected)
return actualOK && expectedOK && actualNumber == expectedNumber
case "subject":
actualText, actualOK := actual.(string)
expectedText, expectedOK := expected.(string)
return actualOK && expectedOK && actualText == expectedText
default:
return false
}
}
func todoExactInteger(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int64:
return typed, true
case float64:
return todoExactFloat(typed)
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed, err == nil
default:
return 0, false
}
}
func todoExactFloat(value float64) (int64, bool) {
if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value ||
value < float64(math.MinInt64) || value >= float64(math.MaxInt64) {
return 0, false
}
return int64(value), true
}
var Update = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "todo", Command: "+update", Product: "todo",
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"io"
"math"
"strconv"
"strings"
"testing"
@@ -313,11 +314,44 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) {
}
updateSuccess := &todoCoverageCaller{responses: map[string][]string{
"update_todo_task": {`{"success":true}`},
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40}}}`},
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40,"dueTime":1786932000000}}}`},
}}
if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--yes"); err != nil {
if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--due", todoCoverageTime, "--yes"); err != nil {
t.Fatal(err)
}
if todoUpdateFieldMatches("dueTime", float64(1786932000000), int64(1786932000001)) {
t.Fatal("different dueTime milliseconds matched")
}
for _, tc := range []struct {
value any
want int64
ok bool
}{
{value: int(40), want: 40, ok: true},
{value: int64(1786932000000), want: 1786932000000, ok: true},
{value: float64(40), want: 40, ok: true},
{value: json.Number("40"), want: 40, ok: true},
{value: " 40 ", want: 40, ok: true},
{value: float64(math.MinInt64), want: math.MinInt64, ok: true},
{value: 1786932000000.5},
{value: math.NaN()},
{value: math.Inf(1)},
{value: math.Inf(-1)},
{value: float64(math.MaxInt64)},
{value: 1e300},
{value: -1e300},
{value: json.Number("not-a-number")},
{value: "not-a-number"},
{value: true},
} {
got, ok := todoExactInteger(tc.value)
if got != tc.want || ok != tc.ok {
t.Fatalf("todoExactInteger(%#v) = %d/%v, want %d/%v", tc.value, got, ok, tc.want, tc.ok)
}
}
if todoUpdateFieldMatches("unknown", 1, 1) {
t.Fatal("unknown update field matched")
}
}
func TestCrossPlatformCoverageTodoDoneBranches(t *testing.T) {
@@ -1080,6 +1080,29 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChec
t.Fatalf("lineage hid positional drift: %s", failures)
}
requiredness := interfacesnapshot.FlagMigration{
Kind: interfacesnapshot.FlagMigrationRequirednessChange,
Command: "dws report cli-only",
Flag: &interfacesnapshot.FlagMigrationSide{
Name: "recipient",
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
},
State: interfacesnapshot.FlagMigrationConsumed,
Reason: "Exercise requiredness receipts beside command lineage without treating them as renames.",
}
flagsWithRequiredness := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...)
flagsWithRequiredness = append(flagsWithRequiredness, requiredness)
if _, err := normalizeSchemaCommandMigrationLineage(
stable,
base,
current,
flagsWithRequiredness,
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
); err != nil {
t.Fatalf("requiredness receipt interfered with command lineage: %v", err)
}
// Multiple historical names may converge only when every predecessor carries
// the exact same contract and every receipt is already consumed.
stable, base, current = schemaCommandLineageContracts()
+76 -3
View File
@@ -1261,9 +1261,9 @@ type schemaToolRef struct {
}
// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag
// rename onto a cloned historical Schema contract. The ordinary compatibility
// checker still makes the final decision; this adapter never drops findings by
// matching their rendered text.
// rename or requiredness change onto a cloned historical Schema contract. The
// ordinary compatibility checker still makes the final decision; this adapter
// never drops findings by matching their rendered text.
func normalizeSchemaFlagMigrations(
baseline schemaContract,
current schemaContract,
@@ -1273,6 +1273,14 @@ func normalizeSchemaFlagMigrations(
renamesByTool := map[schemaToolRef]map[string]string{}
for _, migration := range migrations {
if migration.EffectiveKind() == interfacesnapshot.FlagMigrationRequirednessChange {
var err error
normalized, err = normalizeSchemaFlagRequirednessMigration(normalized, baseline, current, migration)
if err != nil {
return schemaContract{}, err
}
continue
}
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
@@ -1394,6 +1402,65 @@ func normalizeSchemaFlagMigrations(
return normalized, nil
}
func normalizeSchemaFlagRequirednessMigration(
normalized schemaContract,
baseline schemaContract,
current schemaContract,
migration interfacesnapshot.FlagMigration,
) (schemaContract, error) {
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
// A reviewed CLI-only command has no Schema compatibility surface.
return normalized, nil
}
if len(matches) != 1 {
return schemaContract{}, fmt.Errorf(
"approved flag requiredness migration %q matches %d historical Schema tools",
migration.Command,
len(matches),
)
}
ref := matches[0]
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
oldParameter, existed := oldTool.Parameters[migration.Flag.Name]
if !existed {
// CLI requiredness is not authority to create or mutate an unrelated
// historical Schema parameter.
return normalized, nil
}
newProduct, productExists := current.Products[ref.productID]
newTool, toolExists := newProduct.Tools[ref.toolID]
if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath {
// Preserve the baseline so the ordinary checker reports the missing tool
// or primary path change.
return normalized, nil
}
newParameter, exists := newTool.Parameters[migration.Flag.Name]
if !exists {
// Preserve the baseline so the ordinary checker reports parameter loss.
return normalized, nil
}
if !newParameter.Required || !newParameter.CLIRequired {
return schemaContract{}, fmt.Errorf(
"approved flag requiredness migration %q Schema parameter %q must be required and cli_required",
migration.Command,
migration.Flag.Name,
)
}
normalizedProduct := normalized.Products[ref.productID]
normalizedTool := normalizedProduct.Tools[ref.toolID]
normalizedParameter := oldParameter
normalizedParameter.Required = newParameter.Required
normalizedParameter.CLIRequired = newParameter.CLIRequired
normalizedTool.Parameters[migration.Flag.Name] = normalizedParameter
normalizedProduct.Tools[ref.toolID] = normalizedTool
normalized.Products[ref.productID] = normalizedProduct
return normalized, nil
}
// normalizeSchemaCommandMigrationLineage composes two independently reviewed
// migration receipts without inventing a second alias authority. A consumed
// flag migration may supply the historical predecessor of a command_move
@@ -1482,6 +1549,9 @@ func stageSchemaCommandMigrationPredecessors(
for _, parameter := range migration.Schema.Parameters {
if _, direct := oldTool.Parameters[parameter.From]; direct {
for _, flagMigration := range flagMigrations {
if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
continue
}
if flagMigration.Command != migration.Legacy.Command ||
flagMigration.Canonical.Name != parameter.From {
continue
@@ -1501,6 +1571,9 @@ func stageSchemaCommandMigrationPredecessors(
predecessors := make([]interfacesnapshot.FlagMigration, 0, 1)
for _, flagMigration := range flagMigrations {
if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
continue
}
if flagMigration.Command != migration.Legacy.Command ||
flagMigration.Canonical.Name != parameter.From {
continue
+138
View File
@@ -1229,6 +1229,144 @@ func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testin
}
}
func TestCrossPlatformCoverageSchemaFlagRequirednessMigration(t *testing.T) {
baseline := schemaContract{
Version: schemaContractVersion,
Products: map[string]productSchema{
"report": {
Tools: map[string]toolSchema{
"report.entry_submit": {
PrimaryCLIPath: "report entry submit",
Parameters: map[string]parameterSchema{
"to-user-ids": {Type: `"string"`, Property: "toUserIds", InterfaceType: "string"},
},
},
},
},
},
}
current := cloneContract(baseline)
product := current.Products["report"]
tool := product.Tools["report.entry_submit"]
parameter := tool.Parameters["to-user-ids"]
parameter.Required = true
parameter.CLIRequired = true
tool.Parameters["to-user-ids"] = parameter
product.Tools["report.entry_submit"] = tool
current.Products["report"] = product
migration := interfacesnapshot.FlagMigration{
Kind: interfacesnapshot.FlagMigrationRequirednessChange,
Command: "dws report entry submit",
Flag: &interfacesnapshot.FlagMigrationSide{
Name: "to-user-ids",
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
},
State: interfacesnapshot.FlagMigrationConsumed,
Reason: "Reject report submissions that have no visible recipient.",
}
normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
t.Fatalf("exact requiredness migration should pass after normalization: %v", failures)
}
t.Run("non-requiredness drift remains blocked", func(t *testing.T) {
drifted := cloneContract(current)
product := drifted.Products["report"]
tool := product.Tools["report.entry_submit"]
parameter := tool.Parameters["to-user-ids"]
parameter.Type = `"array"`
tool.Parameters["to-user-ids"] = parameter
product.Tools["report.entry_submit"] = tool
drifted.Products["report"] = product
normalized, err := normalizeSchemaFlagMigrations(baseline, drifted, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := strings.Join(checkCompatibility(normalized, drifted), "\n"); !strings.Contains(failures, "changed type") {
t.Fatalf("requiredness migration hid type drift: %s", failures)
}
})
t.Run("partial Schema projection fails closed", func(t *testing.T) {
partial := cloneContract(current)
product := partial.Products["report"]
tool := product.Tools["report.entry_submit"]
parameter := tool.Parameters["to-user-ids"]
parameter.CLIRequired = false
tool.Parameters["to-user-ids"] = parameter
product.Tools["report.entry_submit"] = tool
partial.Products["report"] = product
if _, err := normalizeSchemaFlagMigrations(baseline, partial, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "required and cli_required") {
t.Fatalf("partial requiredness projection error = %v", err)
}
})
t.Run("CLI-only migration does not manufacture Schema", func(t *testing.T) {
cliOnly := migration
cliOnly.Command = "dws report cli-only"
normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{cliOnly})
if err != nil || !reflect.DeepEqual(normalized, baseline) {
t.Fatalf("CLI-only requiredness migration = %#v, %v", normalized, err)
}
})
t.Run("duplicate historical path fails closed", func(t *testing.T) {
duplicate := cloneContract(baseline)
product := duplicate.Products["report"]
product.Tools["report.duplicate"] = product.Tools["report.entry_submit"]
duplicate.Products["report"] = product
if _, err := normalizeSchemaFlagMigrations(duplicate, current, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "matches 2") {
t.Fatalf("duplicate requiredness Schema path error = %v", err)
}
})
t.Run("missing historical parameter is not authority", func(t *testing.T) {
missing := cloneContract(baseline)
product := missing.Products["report"]
tool := product.Tools["report.entry_submit"]
delete(tool.Parameters, "to-user-ids")
product.Tools["report.entry_submit"] = tool
missing.Products["report"] = product
normalized, err := normalizeSchemaFlagMigrations(missing, current, []interfacesnapshot.FlagMigration{migration})
if err != nil || !reflect.DeepEqual(normalized, missing) {
t.Fatalf("missing historical requiredness parameter = %#v, %v", normalized, err)
}
})
t.Run("missing current tool stays visible to ordinary checker", func(t *testing.T) {
missing := cloneContract(current)
delete(missing.Products, "report")
normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := checkCompatibility(normalized, missing); len(failures) == 0 {
t.Fatal("requiredness migration hid missing current Schema tool")
}
})
t.Run("missing current parameter stays visible to ordinary checker", func(t *testing.T) {
missing := cloneContract(current)
product := missing.Products["report"]
tool := product.Tools["report.entry_submit"]
delete(tool.Parameters, "to-user-ids")
product.Tools["report.entry_submit"] = tool
missing.Products["report"] = product
normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
if err != nil {
t.Fatal(err)
}
if failures := strings.Join(checkCompatibility(normalized, missing), "\n"); !strings.Contains(failures, "lost parameter") {
t.Fatalf("requiredness migration hid missing current parameter: %s", failures)
}
})
}
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing.T) {
tests := []struct {
name string