From 8c2093a41aa79bf58bfba22772ea7fc8313886ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Sun, 7 Jun 2026 16:17:31 +0800
Subject: [PATCH 01/25] fix(skill): align chat single-chat docs/script with
list-direct (#424)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(skill): align chat single-chat docs/script with list-direct
钉钉 MCP 服务已将单聊从 `chat message list` 拆出独立的 `list-direct` /
`send-direct`(list 现仅支持群聊,--user / --open-dingtalk-id 已移除),
但 skill 侧文档与脚本仍在教 `chat message list --user`,照做会因 unknown
flag 报错;推荐为单聊"优先"方法的 chat_history_with_user.py 也随之失效。
- chat.md (mono+multi): `message list` 改为仅群聊;新增 `list-direct` /
`send-direct` 两段命令文档;意图路由 / 关键区分 / 上下文传递表 / 注意事项
全部对齐单聊新命令
- best_practices/01-messaging.md (mono): query-private-chat 由 `list --user`
改 `list-direct`(multi 版此前已改,未动)
- chat_history_with_user.py (mono+multi): 调用 `list-direct`;并修复返回体
解析——解包 `result.messages` + 对齐 `createTime/content/sender` 字段,
此前会崩在 `'str' object has no attribute 'get'`
* docs(changelog): add entry for chat single-chat list-direct alignment (#424)
* fix(skill): drop send-direct from chat docs (not a v1.0.34 command)
复核 v1.0.34 CLI 发现:单聊发送 rpc `send_direct_message_as_user` 在
v1.0.34 已并入 `chat message send`(cli_name=send,用 --user),不再暴露
独立的 `send-direct` 命令(仅 v1.0.29 有)。上一提交按 v1.0.29 误加的
send-direct 文档段/路由/关键区分/上下文表引用对 v1.0.34 是错的,全部移除。
list-direct 部分保留不变——v1.0.34 确认 `list` 仅群聊、`list-direct`
存在,`list --user` 仍报 unknown flag。单聊发送回归 `send --user`。
---
CHANGELOG.md | 4 ++
.../references/best_practices/01-messaging.md | 2 +-
skills/mono/references/products/chat.md | 57 ++++++++++++-------
skills/mono/scripts/chat_history_with_user.py | 19 +++++--
skills/multi/dingtalk-chat/references/chat.md | 57 ++++++++++++-------
.../scripts/chat_history_with_user.py | 19 +++++--
6 files changed, 107 insertions(+), 51 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b75fad4..eae157fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
+### Fixed
+
+- **`chat` skill 文档与脚本对齐单聊 `list-direct`** (#424) — `chat message list` 现仅支持群聊(`--user` / `--open-dingtalk-id` 已移除),单聊读取改用独立命令 `list-direct`,但 skill 侧文档与脚本仍在教 `chat message list --user`,照做会报 `unknown flag: --user`,被列为查单聊"优先"方法的 `chat_history_with_user.py` 也随之失效。本次更新:`skills/{mono,multi/dingtalk-chat}/references/products/chat.md` 将 `message list` 改为仅群聊、新增 `list-direct` 命令文档,并同步意图路由 / 关键区分 / 上下文传递表 / 注意事项;`skills/mono/references/best_practices/01-messaging.md` 的 query-private-chat 由 `list --user` 改为 `list-direct`(multi 版此前已改);`chat_history_with_user.py`(mono + multi)改调 `list-direct` 并修复返回体解析(解包 `result.messages`、对齐 `createTime/content/sender` 字段,此前会崩在 `'str' object has no attribute 'get'`)。单聊发送仍用 `chat message send --user`(v1.0.34 起单聊发送 rpc 已并入 `send` 命令,无独立 `send-direct`)。纯文档/脚本改动,不涉及 CLI 二进制行为。
+
## [1.0.34] - 2026-06-03
### Changed
diff --git a/skills/mono/references/best_practices/01-messaging.md b/skills/mono/references/best_practices/01-messaging.md
index fb95e817..966a79ee 100644
--- a/skills/mono/references/best_practices/01-messaging.md
+++ b/skills/mono/references/best_practices/01-messaging.md
@@ -5,7 +5,7 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)
备选:1. `chat search --query "<群名>"` → 取 `openConversationId`
2. `chat message list --group --time ""` → 取消息列表
3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`
4. `--forward=false` 拉给定时间**之前**的消息
5. 合并全部消息后总结 |
-| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)
备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`
2. `chat message list --user --time ""` → 取消息列表
3. **翻页**:同 query-group-chat
4. 合并全部消息后总结 |
+| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `aisearch person` + `dws chat message list-direct` 组合)(自动搜人+翻页+导出)
备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`
2. `chat message list-direct --user --time ""` → 取消息列表(单聊专用;旧版 `list --user` 已停用)
3. **翻页**:同 query-group-chat
4. 合并全部消息后总结 |
| escalate-ding | 三级升级:
1. `ding message send --robot-code --type app --users --content "<内容>"`(必填项见 [ding.md](../products/ding.md))
2. `chat message send --group --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](../products/chat.md))
3. `todo task create --title "<标题>" --executors --priority 40` 建紧急待办
前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
| send-by-bot | **多群批量优先**:`bot_broadcast.py`(开源版未引入;可手动用 `dws chat message send-by-bot` 多次调用)
单群:1. `chat bot search` → 取 `robotCode`
2. `chat search --query "<群名>"` → 取 `openConversationId`
3. `chat message send-by-bot --robot-code --group --title "<标题>" --text "<内容>"` |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group --time "<起始时间>"` 拉源消息
2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`
3. `chat message send --open-dingtalk-id --text "<内容>"`(推荐)或 `--group --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) |
diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md
index f925b042..b52ed1d5 100644
--- a/skills/mono/references/products/chat.md
+++ b/skills/mono/references/products/chat.md
@@ -338,35 +338,54 @@ Flags:
### message (会话消息管理)
-#### 拉取会话消息内容 — 拉取指定群聊或单聊的会话消息内容
+#### 拉取群聊会话消息内容 — 拉取指定群聊的会话消息内容(仅群聊)
---group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
+--group 指定群聊 openConversationId(**本命令仅支持群聊**;拉取单聊/私聊消息请改用 `chat message list-direct`)。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
```
Usage:
dws chat message list [flags]
Example:
dws chat message list --group --time "2025-03-01 00:00:00"
- dws chat message list --user --time "2025-03-01 00:00:00" --limit 50
- dws chat message list --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50
+ dws chat message list --group --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group --time "2025-03-01 00:00:00" --forward=false
+ # 拉单聊改用 list-direct: dws chat message list-direct --user --time "2025-03-01 00:00:00"
Flags:
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
- --group string 群聊 openconversation_id(群聊时必填)
+ --group string 群聊 openconversation_id(必填,**仅支持群聊**;查单聊用 chat message list-direct --user )
--limit int 返回数量,不传则不限制
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
- --user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
- --open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
注意:
- - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- - --user 和 --open-dingtalk-id 都是发起单聊消息拉取,区别在于用不同格式的用户标识:
- - --user 传 userId(企业内部应用常用)
- - --open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
+ - 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持)
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group --topic-id 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。
```
+#### 拉取单聊消息内容 — 按对方 userId 拉取与某同事的单聊(私聊)历史消息
+
+按对方 userId(或 openDingTalkId)拉取与该同事的单聊会话消息,**专用于私聊**;查群聊请用 `chat message list --group`。同组织内同事用 --user,非同组织好友用 --open-dingtalk-id,二者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
+```
+Usage:
+ dws chat message list-direct [flags]
+Example:
+ dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --limit 50
+ dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --forward=false
+ dws chat message list-direct --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 20
+ # 查询对方 userId: dws contact user search --keyword "姓名" 或 dws aisearch person --keyword "姓名" --dimension name
+Flags:
+ --forward true=从老往新(给定时间之后),false=从新往老(给定时间之前) (default true)
+ --user string 对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)
+ --open-dingtalk-id string 对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)
+ --limit int 每页返回数量(默认 50)
+ --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
+
+注意:
+ - --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user
+ - --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
+ - 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group`
+```
+
#### 以当前用户身份发送消息 — --group 群聊 / --user 或 --open-dingtalk-id 单聊
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
@@ -592,7 +611,7 @@ Flags:
注意:
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
- - 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
+ - 与 chat message list 的区别:list 拉取指定群聊会话的消息(单聊用 list-direct),list-all 拉取当前用户所有会话的消息
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
```
@@ -1147,7 +1166,7 @@ Flags:
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
用户说"某人发给我的消息/指定发送者的消息/某人最近的消息" → `chat message list-by-sender --sender-user-id ` 或 `--sender-open-dingtalk-id `(跨单聊+群聊)
-用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list --user ` 或 `--open-dingtalk-id `
+用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list-direct --user ` 或 `--open-dingtalk-id `
用户说"某个群的聊天记录" → `chat message list --group `
用户说"我最近所有消息/我今天的消息" → `chat message list-all --start --end `
用户说"@我的消息/提及我的" → `chat message list-mentions --start --end `
@@ -1167,7 +1186,7 @@ Flags:
用户说"改群名" → `chat group rename`
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
-用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
+用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(单聊专用;用户明确说"单聊"时使用)
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
用户说"发群消息(以个人身份)" → `chat message send --group`
@@ -1222,8 +1241,8 @@ Flags:
关键区分:
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
-- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
-- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
+- `chat message list` — 拉取指定**群聊**的消息(需指定 --group,**仅群聊**),按时间点 + 方向翻页
+- `chat message list-direct` — 单聊专用,拉取与指定用户的单聊(私聊)记录(--user / --open-dingtalk-id;用户明确说"单聊""私聊"时使用)
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
@@ -1448,8 +1467,8 @@ Flags:
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
| `chat group create` | `openConversationId` | 同上 |
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
-| `aisearch person` | `userId` | message send 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
-| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id、message list 的 --open-dingtalk-id |
+| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
+| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
@@ -1484,7 +1503,7 @@ Flags:
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;富媒体消息通过 `--msg-type` 指定类型(image/file),必须显式指定;发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片→image,其他所有→file,不可跳过此判断
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
-- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
+- `chat message list` **仅支持群聊**,必须指定 `--group`;拉取单聊用 `chat message list-direct`(`--user` / `--open-dingtalk-id` 二选一)
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
diff --git a/skills/mono/scripts/chat_history_with_user.py b/skills/mono/scripts/chat_history_with_user.py
index 8068be38..4c92627c 100644
--- a/skills/mono/scripts/chat_history_with_user.py
+++ b/skills/mono/scripts/chat_history_with_user.py
@@ -9,7 +9,7 @@
工作流:
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
- 2. 调用 chat message list --user 拉取单聊消息
+ 2. 调用 chat message list-direct --user 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
@@ -116,7 +116,7 @@ def main():
while page < max_pages and remaining > 0:
cmd_args = [
- 'chat', 'message', 'list',
+ 'chat', 'message', 'list-direct',
'--user', user_id or '',
'--time', current_time,
'--format', 'json',
@@ -139,8 +139,12 @@ def main():
page_msgs = data
has_more = False
else:
- page_msgs = data.get('messages', data.get('result', []))
- has_more = data.get('hasMore', False)
+ # list-direct 返回 {result: {hasMore, messages: [...]}},先解包 result
+ result_obj = data.get('result', data)
+ if not isinstance(result_obj, dict):
+ result_obj = data
+ page_msgs = result_obj.get('messages', result_obj.get('records', []))
+ has_more = result_obj.get('hasMore', data.get('hasMore', False))
if not page_msgs:
break
@@ -153,7 +157,8 @@ def main():
break
last_msg = page_msgs[-1]
- boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
+ boundary_time = (last_msg.get('createTime') or last_msg.get('createAt')
+ or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
@@ -183,9 +188,11 @@ def main():
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
+ if not isinstance(m, dict):
+ continue
sender = m.get('senderNick') or m.get('sender', '未知')
text = m.get('text') or m.get('content', '')
- time_str = m.get('createAt') or m.get('time', '')
+ time_str = m.get('createTime') or m.get('createAt') or m.get('time', '')
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md
index 5640f510..c30566e7 100644
--- a/skills/multi/dingtalk-chat/references/chat.md
+++ b/skills/multi/dingtalk-chat/references/chat.md
@@ -338,35 +338,54 @@ Flags:
### message (会话消息管理)
-#### 拉取会话消息内容 — 拉取指定群聊或单聊的会话消息内容
+#### 拉取群聊会话消息内容 — 拉取指定群聊的会话消息内容(仅群聊)
---group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
+--group 指定群聊 openConversationId(**本命令仅支持群聊**;拉取单聊/私聊消息请改用 `chat message list-direct`)。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
```
Usage:
dws chat message list [flags]
Example:
dws chat message list --group --time "2025-03-01 00:00:00"
- dws chat message list --user --time "2025-03-01 00:00:00" --limit 50
- dws chat message list --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50
+ dws chat message list --group --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group --time "2025-03-01 00:00:00" --forward=false
+ # 拉单聊改用 list-direct: dws chat message list-direct --user --time "2025-03-01 00:00:00"
Flags:
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
- --group string 群聊 openconversation_id(群聊时必填)
+ --group string 群聊 openconversation_id(必填,**仅支持群聊**;查单聊用 chat message list-direct --user )
--limit int 返回数量,不传则不限制
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
- --user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
- --open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
注意:
- - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- - --user 和 --open-dingtalk-id 都是发起单聊消息拉取,区别在于用不同格式的用户标识:
- - --user 传 userId(企业内部应用常用)
- - --open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
+ - 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持)
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group --topic-id 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。
```
+#### 拉取单聊消息内容 — 按对方 userId 拉取与某同事的单聊(私聊)历史消息
+
+按对方 userId(或 openDingTalkId)拉取与该同事的单聊会话消息,**专用于私聊**;查群聊请用 `chat message list --group`。同组织内同事用 --user,非同组织好友用 --open-dingtalk-id,二者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
+```
+Usage:
+ dws chat message list-direct [flags]
+Example:
+ dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --limit 50
+ dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --forward=false
+ dws chat message list-direct --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 20
+ # 查询对方 userId: dws contact user search --keyword "姓名" 或 dws aisearch person --keyword "姓名" --dimension name
+Flags:
+ --forward true=从老往新(给定时间之后),false=从新往老(给定时间之前) (default true)
+ --user string 对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)
+ --open-dingtalk-id string 对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)
+ --limit int 每页返回数量(默认 50)
+ --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
+
+注意:
+ - --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user
+ - --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
+ - 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group`
+```
+
#### 以当前用户身份发送消息 — --group 群聊 / --user 或 --open-dingtalk-id 单聊
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
@@ -592,7 +611,7 @@ Flags:
注意:
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
- - 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
+ - 与 chat message list 的区别:list 拉取指定群聊会话的消息(单聊用 list-direct),list-all 拉取当前用户所有会话的消息
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
```
@@ -1147,7 +1166,7 @@ Flags:
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
用户说"某人发给我的消息/指定发送者的消息/某人最近的消息" → `chat message list-by-sender --sender-user-id ` 或 `--sender-open-dingtalk-id `(跨单聊+群聊)
-用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list --user ` 或 `--open-dingtalk-id `
+用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list-direct --user ` 或 `--open-dingtalk-id `
用户说"某个群的聊天记录" → `chat message list --group `
用户说"我最近所有消息/我今天的消息" → `chat message list-all --start --end `
用户说"@我的消息/提及我的" → `chat message list-mentions --start --end `
@@ -1167,7 +1186,7 @@ Flags:
用户说"改群名" → `chat group rename`
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
-用户说"拉取和某人的单聊记录/单聊消息" → `chat message list --user`(用户明确说"单聊"时使用)
+用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(单聊专用;用户明确说"单聊"时使用)
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
用户说"发群消息(以个人身份)" → `chat message send --group`
@@ -1222,8 +1241,8 @@ Flags:
关键区分:
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
-- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
-- `chat message list --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
+- `chat message list` — 拉取指定**群聊**的消息(需指定 --group,**仅群聊**),按时间点 + 方向翻页
+- `chat message list-direct` — 单聊专用,拉取与指定用户的单聊(私聊)记录(--user / --open-dingtalk-id;用户明确说"单聊""私聊"时使用)
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
@@ -1448,8 +1467,8 @@ Flags:
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
| `chat group create` | `openConversationId` | 同上 |
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
-| `aisearch person` | `userId` | message send 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
-| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id、message list 的 --open-dingtalk-id |
+| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
+| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
@@ -1484,7 +1503,7 @@ Flags:
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;富媒体消息通过 `--msg-type` 指定类型(image/file),必须显式指定;发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片→image,其他所有→file,不可跳过此判断
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
-- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
+- `chat message list` **仅支持群聊**,必须指定 `--group`;拉取单聊用 `chat message list-direct`(`--user` / `--open-dingtalk-id` 二选一)
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
diff --git a/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py b/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py
index 8068be38..4c92627c 100644
--- a/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py
+++ b/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py
@@ -9,7 +9,7 @@
工作流:
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
- 2. 调用 chat message list --user 拉取单聊消息
+ 2. 调用 chat message list-direct --user 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
@@ -116,7 +116,7 @@ def main():
while page < max_pages and remaining > 0:
cmd_args = [
- 'chat', 'message', 'list',
+ 'chat', 'message', 'list-direct',
'--user', user_id or '',
'--time', current_time,
'--format', 'json',
@@ -139,8 +139,12 @@ def main():
page_msgs = data
has_more = False
else:
- page_msgs = data.get('messages', data.get('result', []))
- has_more = data.get('hasMore', False)
+ # list-direct 返回 {result: {hasMore, messages: [...]}},先解包 result
+ result_obj = data.get('result', data)
+ if not isinstance(result_obj, dict):
+ result_obj = data
+ page_msgs = result_obj.get('messages', result_obj.get('records', []))
+ has_more = result_obj.get('hasMore', data.get('hasMore', False))
if not page_msgs:
break
@@ -153,7 +157,8 @@ def main():
break
last_msg = page_msgs[-1]
- boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
+ boundary_time = (last_msg.get('createTime') or last_msg.get('createAt')
+ or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
@@ -183,9 +188,11 @@ def main():
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
+ if not isinstance(m, dict):
+ continue
sender = m.get('senderNick') or m.get('sender', '未知')
text = m.get('text') or m.get('content', '')
- time_str = m.get('createAt') or m.get('time', '')
+ time_str = m.get('createTime') or m.get('createAt') or m.get('time', '')
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
From e7ef2c4677d707183570980f0adf668239988110 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Mon, 8 Jun 2026 21:46:41 +0800
Subject: [PATCH 02/25] fix(chat): keep @-mention tokens literal so they render
(#433)
send_personal_message packs the message body via json.Marshal, whose
default HTML escaping rewrites <@openDingTalkId> / <@all> into
<@...>. The DingTalk client renders an @-mention by matching the
literal <@...> token, so the escaped form is shown as plain text and the
@ never renders (API still returns success, masking the bug).
Marshal the content with SetEscapeHTML(false) for both the group and the
direct send_personal_message paths. Add a regression test asserting the
content keeps literal <@...> tokens and is never HTML-escaped.
Verified live: @someone and @all now render as blue mentions in the client.
---
internal/helpers/chat.go | 23 +++++++++++++---
internal/helpers/chat_test.go | 49 +++++++++++++++++++++++++++++++++++
2 files changed, 68 insertions(+), 4 deletions(-)
diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go
index 292b103f..c1cdca3d 100644
--- a/internal/helpers/chat.go
+++ b/internal/helpers/chat.go
@@ -14,6 +14,7 @@
package helpers
import (
+ "bytes"
"context"
"encoding/json"
"strings"
@@ -475,11 +476,10 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
- b, _ := json.Marshal(map[string]string{"title": title, "text": text})
params := map[string]any{
"openConversationId": group,
"msgType": "markdown",
- "content": string(b),
+ "content": marshalMessageContent(title, text),
}
if atAll {
params["atAll"] = true
@@ -495,11 +495,10 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
params := map[string]any{"title": title, "text": text, "receiverUserId": user}
return params, "send_direct_message_as_user", nil
default:
- b, _ := json.Marshal(map[string]string{"title": title, "text": text})
params := map[string]any{
"receiverOpenDingTalkId": openID,
"msgType": "markdown",
- "content": string(b),
+ "content": marshalMessageContent(title, text),
}
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
@@ -1011,3 +1010,19 @@ func jsonMarshal(v any) (string, error) {
}
return string(b), nil
}
+
+// marshalMessageContent builds the send_personal_message content payload
+// ({"title","text"}) WITHOUT HTML-escaping < > &. DingTalk's client renders
+// @-mentions by matching literal <@openDingTalkId> / <@all> tokens in the
+// message text; the default json.Marshal escaping turns them into
+// <@...>, which the client shows as plain text instead of a rendered
+// mention. encoding/json offers no escape toggle on Marshal, so use an Encoder.
+func marshalMessageContent(title, text string) string {
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+ // Encoder errors are impossible for a map[string]string; ignore safely.
+ _ = enc.Encode(map[string]string{"title": title, "text": text})
+ // Encoder.Encode appends a trailing newline; strip it.
+ return strings.TrimRight(buf.String(), "\n")
+}
diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go
index cd9394e2..a2d2b95b 100644
--- a/internal/helpers/chat_test.go
+++ b/internal/helpers/chat_test.go
@@ -222,6 +222,55 @@ func TestChatMessageSendForwardsAtMentions(t *testing.T) {
}
}
+// TestChatMessageSendContentNotHTMLEscaped guards the @-mention rendering fix:
+// the send_personal_message content must keep literal <@openDingTalkId> / <@all>
+// tokens. If json.Marshal's default HTML escaping is reintroduced, the tokens
+// become <@...> and the DingTalk client renders them as plain text
+// instead of a real @-mention.
+func TestChatMessageSendContentNotHTMLEscaped(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ want string // literal token that must survive in content
+ }{
+ {
+ name: "group-at-all",
+ args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@all> hi", "--at-all"},
+ want: "<@all>",
+ },
+ {
+ name: "group-at-open-dingtalk-id",
+ args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@op-1> hi", "--at-open-dingtalk-ids", "op-1"},
+ want: "<@op-1>",
+ },
+ {
+ name: "direct-open-dingtalk-id",
+ args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "<@OP123> hi"},
+ want: "<@OP123>",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ runner := &captureRunner{}
+ cmd := newChatMessageSendCommand(runner)
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs(tc.args)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
+ }
+ content, _ := runner.last.Params["content"].(string)
+ if !strings.Contains(content, tc.want) {
+ t.Fatalf("content %q missing literal %q (HTML-escaped?)", content, tc.want)
+ }
+ if strings.Contains(content, "\\u003c") || strings.Contains(content, "\\u003e") {
+ t.Fatalf("content %q is HTML-escaped; @-mention will not render", content)
+ }
+ })
+ }
+}
+
// TestChatMessageSendRejectsAtMentionsOutsideGroup ensures we do not silently
// drop user intent when --at-* is combined with --user / --open-dingtalk-id
// (single-chat tools have no @-mention semantics, so the flag would never
From e7a3010b81f3d14e67a9c1df17dbd2a2643d0185 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Tue, 9 Jun 2026 10:32:23 +0800
Subject: [PATCH 03/25] docs: cut 1.0.35 changelog + fix README product-count
drift (#435)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* docs: cut 1.0.35 changelog + fix README product-count drift
CHANGELOG: promote [Unreleased] to [1.0.35] - 2026-06-08, covering the
chat @-mention render fix (#433), chat list-direct skill alignment (#424),
pat chmod batch agentCode passthrough (#414), and pat JSON auth-URL
readability (#401).
README (en + zh): the multi-skills section claimed 19/20 products while the
Key Services summary says "18 products" (aiapp was taken offline in 1.0.34,
dropping the count to 18). Unify every product-count reference to 18.
* docs(readme): tidy Key Services table — concise descriptions, drop subcommand duplication
The Description column re-listed every subcommand already shown in the
Subcommands column, making rows (esp. chat) very tall and uneven. Rewrite
descriptions as concise, parallel capability summaries and drop the
malformed inline '(top-level: ...)' sprawl in the sheet row. en + zh.
* docs(readme): restore Key Services table; keep only product-count fix
The previous commit silently rewrote/reflowed the entire Key Services
table (shortening every Subcommands + Description cell) — far beyond this
PR's stated "纯文档改动 / product-count drift fix" scope.
Restore both tables (README.md + README_zh.md) byte-for-byte to main and
keep ONLY the six intended count corrections (multi skills 20/19 → 18),
so the diff matches the PR description and the table is not re-wrapped.
* docs(changelog): write 1.0.35 entries in English
The 1.0.35 Fixed entries were in Chinese while all prior releases
(1.0.34, 1.0.33, ...) use English. Translate the four entries (#433,
#424, #414, #401) to English to match the existing CHANGELOG convention;
content unchanged.
---
CHANGELOG.md | 7 ++++++-
README.md | 6 +++---
README_zh.md | 6 +++---
3 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eae157fd..adec5e1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,14 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
+## [1.0.35] - 2026-06-08
+
### Fixed
-- **`chat` skill 文档与脚本对齐单聊 `list-direct`** (#424) — `chat message list` 现仅支持群聊(`--user` / `--open-dingtalk-id` 已移除),单聊读取改用独立命令 `list-direct`,但 skill 侧文档与脚本仍在教 `chat message list --user`,照做会报 `unknown flag: --user`,被列为查单聊"优先"方法的 `chat_history_with_user.py` 也随之失效。本次更新:`skills/{mono,multi/dingtalk-chat}/references/products/chat.md` 将 `message list` 改为仅群聊、新增 `list-direct` 命令文档,并同步意图路由 / 关键区分 / 上下文传递表 / 注意事项;`skills/mono/references/best_practices/01-messaging.md` 的 query-private-chat 由 `list --user` 改为 `list-direct`(multi 版此前已改);`chat_history_with_user.py`(mono + multi)改调 `list-direct` 并修复返回体解析(解包 `result.messages`、对齐 `createTime/content/sender` 字段,此前会崩在 `'str' object has no attribute 'get'`)。单聊发送仍用 `chat message send --user`(v1.0.34 起单聊发送 rpc 已并入 `send` 命令,无独立 `send-direct`)。纯文档/脚本改动,不涉及 CLI 二进制行为。
+- **`chat message send` @-mentions not rendered in group / direct chat** (#433, `internal/helpers/chat.go`) — when sending a group message or an openDingTalkId direct message (`send_personal_message`) as the current user, the `content` body was packed with `json.Marshal`, whose default HTML escaping turns the `<` `>` in `<@openDingTalkId>` / `<@all>` into `<` `>`. The DingTalk client renders @-mentions by matching the **literal** `<@...>` token, so after escaping the match fails and the mention shows as plain text — while the API still returns `success`, masking the bug. Fix: add `marshalMessageContent`, which serializes `{title,text}` with `json.Encoder` + `SetEscapeHTML(false)`; both the group and openDingTalkId-direct `send_personal_message` paths now use it, preserving the literal `<@...>`. Added regression test `TestChatMessageSendContentNotHTMLEscaped` asserting the content keeps the literal token and is never HTML-escaped. Verified on a real device: `@someone` and `@all` both render as clickable blue mentions.
+- **`chat` skill docs & scripts aligned to direct-chat `list-direct`** (#424) — `chat message list` now supports group chats only (`--user` / `--open-dingtalk-id` removed); reading a direct chat moves to the dedicated `list-direct` command, but the skill docs and scripts still taught `chat message list --user`, which now errors with `unknown flag: --user`, also breaking `chat_history_with_user.py` (listed as the "preferred" way to query direct chats). This update: `skills/{mono,multi/dingtalk-chat}/references/products/chat.md` switches `message list` to group-only and documents the new `list-direct` command, syncing the intent routing / key-distinction / context-passing tables / caveats; `skills/mono/references/best_practices/01-messaging.md` changes query-private-chat from `list --user` to `list-direct` (the multi version was already updated); `chat_history_with_user.py` (mono + multi) now calls `list-direct` and fixes response parsing (unwraps `result.messages`, aligns `createTime/content/sender` fields — it previously crashed on `'str' object has no attribute 'get'`). Direct-chat sending still uses `chat message send --user` (since v1.0.34 the direct-send rpc is folded into the `send` command; there is no separate `send-direct`). Docs/scripts only; no change to CLI binary behavior.
+- **`pat chmod` batch authorization did not pass through `agentCode`** (#414, `internal/pat/chmod.go`) — the batch plan / grant paths (`buildBatchPlanArgs` / `batchArgs`) previously carried `agentCode` only in the single-grant `toolArgs`; batch calls omitted it, so a batch authorization with an explicit `agentCode` was processed under the default agent. Fix: the batch plan / grant args now also carry `agentCode`, matching the single-grant path.
+- **`pat` JSON output escaped the authorization URL into an unreadable form** (#401, `internal/pat`) — the authorization URL attached to PAT error messages, after default HTML escaping, turned `&` into `&`, breaking the link when copied / recognized on mobile. Fix: the PAT error-enrichment JSON output now uses `SetEscapeHTML(false)` (scoped to PAT JSON only), preserving the readable `&` separators.
## [1.0.34] - 2026-06-03
diff --git a/README.md b/README.md
index 487495dd..91ce6d48 100644
--- a/README.md
+++ b/README.md
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
-| **multi** 🧪 **EXPERIMENTAL** | 20 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
+| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
-> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 20 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
+> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
@@ -277,7 +277,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
-- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 19 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
+- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 18 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
diff --git a/README_zh.md b/README_zh.md
index 0d980ad2..1d016e08 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
-| **multi** 🧪 **试验版 / Preview** | 19 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
+| **multi** 🧪 **试验版 / Preview** | 18 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
-> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。19 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
+> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。18 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
@@ -274,7 +274,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
-- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 19 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
+- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
From eaa60f95b53219003c529bab82b752fbdfc7c3bf Mon Sep 17 00:00:00 2001
From: xuan
Date: Tue, 9 Jun 2026 18:00:05 +0800
Subject: [PATCH 04/25] feat(devdoc): add rag mcp cli commands (#434)
* feat(devdoc): add rag mcp cli commands
* chore(config): default mcp endpoint to prepub
* chore(config): use prepub mcp discovery host
* fix(devdoc): wrap rag search request
* fix(transport): preserve dingtalk mcp gateway query
* fix(devdoc): align cli with rag mcp schema
* fix(config): keep mcp defaults production-safe
* test(devdoc): cover rag cli parameter mapping
* chore(test): remove dead nested arg assertion
---------
Co-authored-by: shangguanxuan.sgx
---
README.md | 4 +-
README_zh.md | 4 +-
docs/command-index.md | 8 +-
internal/app/recovery_command.go | 4 +-
internal/compat/dynamic_commands_test.go | 2 +-
internal/errors/validate_test.go | 2 +-
internal/helpers/devdoc.go | 79 +++++++++-
internal/helpers/devdoc_test.go | 145 +++++++++++++++++-
internal/market/registry.go | 2 +-
internal/transport/client.go | 30 +++-
internal/transport/client_extra_test.go | 38 +++++
internal/transport/client_test.go | 50 ++++++
pkg/config/constants_test.go | 14 ++
skills/mono/references/intent-guide.md | 6 +-
skills/mono/references/products/devdoc.md | 37 +++++
skills/mono/references/products/simple.md | 21 +++
skills/multi/dingtalk-devdoc/SKILL.md | 4 +-
.../dingtalk-devdoc/references/devdoc.md | 37 +++++
.../multi/dingtalk-skill/references/skill.md | 23 ++-
test/cli_compat/devdoc_test.go | 94 +++++++++++-
test/cli_compat/helpers_test.go | 22 ++-
test/integration/recovery/recovery_test.go | 10 +-
test/skill_tests.md | 16 +-
test/skill_tests_results.md | 22 ++-
24 files changed, 636 insertions(+), 38 deletions(-)
diff --git a/README.md b/README.md
index 91ce6d48..bb681b17 100644
--- a/README.md
+++ b/README.md
@@ -492,12 +492,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
-| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
+| DevDoc | `devdoc` | 2 | `article` `error` | Search Open Platform documentation and troubleshoot API errors |
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
-> **330 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws --help` for subcommands.
+> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws --help` for subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
diff --git a/README_zh.md b/README_zh.md
index 1d016e08..cd08c987 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -488,12 +488,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
-| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
+| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档、排查开放平台调用错误 |
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
-> **18 个产品,330 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws --help` 查看子命令。
+> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws --help` 查看子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
diff --git a/docs/command-index.md b/docs/command-index.md
index dfd964fc..64f6f956 100644
--- a/docs/command-index.md
+++ b/docs/command-index.md
@@ -4,7 +4,7 @@ Every runtime command the `dws` CLI exposes when loaded with the **pre** environ
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
- **Products**: 13
-- **Total commands**: 159
+- **Total commands**: 160
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
> Auto-generated. Edit `pre/config.json`, not this file.
@@ -36,7 +36,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
-- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands
+- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
@@ -182,11 +182,12 @@ _Users, departments, and directory lookups._
_Search the DingTalk Open Platform documentation._
-**1 commands**
+**2 commands**
| Command | Description | When to use |
|---|---|---|
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
+| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
## `dws ding` — DING Messages
@@ -320,4 +321,3 @@ _Personal todo task management._
| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
-
diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go
index 514c6c47..8b32fa6e 100644
--- a/internal/app/recovery_command.go
+++ b/internal/app/recovery_command.go
@@ -293,7 +293,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
- ToolName: "search_open_platform_docs",
+ ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
@@ -302,7 +302,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
- result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs", requestArgs)
+ result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
diff --git a/internal/compat/dynamic_commands_test.go b/internal/compat/dynamic_commands_test.go
index 16e87909..441ec08b 100644
--- a/internal/compat/dynamic_commands_test.go
+++ b/internal/compat/dynamic_commands_test.go
@@ -381,7 +381,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
"article": {Description: "文档文章"},
},
ToolOverrides: map[string]market.CLIToolOverride{
- "search_open_platform_docs": {
+ "search_open_platform_docs_rag": {
CLIName: "search",
Group: "article",
Flags: map[string]market.CLIFlagOverride{
diff --git a/internal/errors/validate_test.go b/internal/errors/validate_test.go
index 3569afdd..07eb85b9 100644
--- a/internal/errors/validate_test.go
+++ b/internal/errors/validate_test.go
@@ -23,7 +23,7 @@ func TestResourceName(t *testing.T) {
input string
wantErr bool
}{
- {name: "valid", input: "search_open_platform_docs"},
+ {name: "valid", input: "search_open_platform_docs_rag"},
{name: "valid-cjk", input: "审批查询"},
{name: "leading-digit", input: "1tool", wantErr: true},
{name: "shell-char", input: "tool;rm", wantErr: true},
diff --git a/internal/helpers/devdoc.go b/internal/helpers/devdoc.go
index f8495532..298753b3 100644
--- a/internal/helpers/devdoc.go
+++ b/internal/helpers/devdoc.go
@@ -56,7 +56,19 @@ func (devdocHandler) Command(runner executor.Runner) *cobra.Command {
},
}
article.AddCommand(newDevdocArticleSearchCommand(runner))
+ errorCmd := &cobra.Command{
+ Use: "error",
+ Short: "错误排查",
+ Args: cobra.NoArgs,
+ TraverseChildren: true,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return cmd.Help()
+ },
+ }
+ errorCmd.AddCommand(newDevdocErrorDiagnoseCommand(runner))
root.AddCommand(article)
+ root.AddCommand(errorCmd)
return root
}
@@ -82,7 +94,7 @@ func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command {
if size < 1 {
size = 10
}
- return runDevdocTool(cmd, runner, "search_open_platform_docs", map[string]any{
+ return runDevdocTool(cmd, runner, "search_open_platform_docs_rag", map[string]any{
"keyword": keyword,
"page": page,
"size": size,
@@ -97,6 +109,55 @@ func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command {
return cmd
}
+func newDevdocErrorDiagnoseCommand(runner executor.Runner) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "diagnose",
+ Aliases: []string{"troubleshoot"},
+ Short: "排查开放平台调用错误",
+ Args: cobra.NoArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ requestID := devdocFlagOrFallback(cmd, "request-id", "trace-id")
+ errorCode := devdocFlagOrFallback(cmd, "error-code")
+ errorMessage := devdocFlagOrFallback(cmd, "error-message")
+ contextValue := devdocFlagOrFallback(cmd, "context")
+ query := devdocFlagOrFallback(cmd, "query")
+ hasPrimaryInput := query != "" || requestID != "" || errorCode != "" || errorMessage != "" || contextValue != ""
+ if !hasPrimaryInput {
+ return apperrors.NewValidation("one of --query, --request-id, --error-code, --error-message, or --context is required")
+ }
+ combinedQuery := devdocJoinQueryParts(query, errorMessage, devdocFlagOrFallback(cmd, "api"), contextValue)
+ page, _ := cmd.Flags().GetInt("page")
+ if page < 1 {
+ page = 1
+ }
+ size, _ := cmd.Flags().GetInt("size")
+ if size < 1 {
+ size = 10
+ }
+ params := map[string]any{
+ "page": page,
+ "size": size,
+ }
+ devdocSetStringParam(params, "query", combinedQuery)
+ devdocSetStringParam(params, "requestId", requestID)
+ devdocSetStringParam(params, "errorCode", errorCode)
+ return runDevdocTool(cmd, runner, "search_open_error_code_rag", params)
+ },
+ }
+ preferLegacyLeaf(cmd)
+ cmd.Flags().String("query", "", "原始排查问题")
+ cmd.Flags().String("request-id", "", "开放平台 requestId")
+ addDevdocHiddenStringFlag(cmd, "trace-id", "--request-id 的兼容别名")
+ cmd.Flags().String("error-code", "", "错误码")
+ cmd.Flags().String("error-message", "", "错误描述,会合并进原始问题")
+ cmd.Flags().String("api", "", "API 名称,会合并进原始问题作为补充检索词")
+ cmd.Flags().String("context", "", "额外排查上下文,会合并进原始问题")
+ cmd.Flags().Int("page", 1, "分页页码 (从 1 开始,默认 1)")
+ cmd.Flags().Int("size", 10, "分页大小 (默认 10)")
+ return cmd
+}
+
func runDevdocTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
@@ -125,6 +186,22 @@ func devdocFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string)
return ""
}
+func devdocSetStringParam(params map[string]any, key, value string) {
+ if strings.TrimSpace(value) != "" {
+ params[key] = strings.TrimSpace(value)
+ }
+}
+
+func devdocJoinQueryParts(parts ...string) string {
+ cleaned := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if trimmed := strings.TrimSpace(part); trimmed != "" {
+ cleaned = append(cleaned, trimmed)
+ }
+ }
+ return strings.Join(cleaned, " ")
+}
+
func addDevdocHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
diff --git a/internal/helpers/devdoc_test.go b/internal/helpers/devdoc_test.go
index 96c042f7..82b1d4a3 100644
--- a/internal/helpers/devdoc_test.go
+++ b/internal/helpers/devdoc_test.go
@@ -16,6 +16,7 @@ package helpers
import (
"bytes"
"context"
+ "strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
@@ -43,8 +44,8 @@ func TestDevdocArticleSearchAcceptsWukongKeywordAlias(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
- if runner.last.Tool != "search_open_platform_docs" {
- t.Fatalf("tool = %q, want search_open_platform_docs", runner.last.Tool)
+ if runner.last.Tool != "search_open_platform_docs_rag" {
+ t.Fatalf("tool = %q, want search_open_platform_docs_rag", runner.last.Tool)
}
if got := runner.last.Params["keyword"]; got != "openConversationId" {
t.Fatalf("keyword = %#v, want openConversationId", got)
@@ -74,3 +75,143 @@ func TestDevdocArticleSearchAcceptsPositionalKeyword(t *testing.T) {
t.Fatalf("keyword = %#v, want MCP", got)
}
}
+
+func TestDevdocErrorDiagnosePassesRequestID(t *testing.T) {
+ t.Parallel()
+
+ runner := &devdocCommandRunner{}
+ cmd := devdocHandler{}.Command(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{"error", "diagnose", "--request-id", "req-123", "--page", "2", "--size", "5"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if runner.last.Tool != "search_open_error_code_rag" {
+ t.Fatalf("tool = %q, want search_open_error_code_rag", runner.last.Tool)
+ }
+ if got := runner.last.Params["requestId"]; got != "req-123" {
+ t.Fatalf("requestId = %#v, want req-123", got)
+ }
+ if got := runner.last.Params["page"]; got != 2 {
+ t.Fatalf("page = %#v, want 2", got)
+ }
+ if got := runner.last.Params["size"]; got != 5 {
+ t.Fatalf("size = %#v, want 5", got)
+ }
+}
+
+func TestDevdocErrorDiagnoseMapsTraceIDAlias(t *testing.T) {
+ t.Parallel()
+
+ runner := &devdocCommandRunner{}
+ cmd := devdocHandler{}.Command(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{"error", "diagnose", "--trace-id", "trace-abc", "--api", "创建日程"})
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if got := runner.last.Params["requestId"]; got != "trace-abc" {
+ t.Fatalf("requestId = %#v, want trace-abc", got)
+ }
+ if _, ok := runner.last.Params["traceId"]; ok {
+ t.Fatalf("traceId should not be sent, params = %#v", runner.last.Params)
+ }
+ if _, ok := runner.last.Params["apiName"]; ok {
+ t.Fatalf("apiName should not be sent, params = %#v", runner.last.Params)
+ }
+ if got := runner.last.Params["query"]; got != "创建日程" {
+ t.Fatalf("query = %#v, want 创建日程", got)
+ }
+}
+
+func TestDevdocErrorDiagnosePassesErrorContext(t *testing.T) {
+ t.Parallel()
+
+ runner := &devdocCommandRunner{}
+ cmd := devdocHandler{}.Command(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{
+ "error", "troubleshoot",
+ "--error-code", "33012",
+ "--error-message", "missing scope",
+ "--context", "create calendar failed",
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if got := runner.last.Params["errorCode"]; got != "33012" {
+ t.Fatalf("errorCode = %#v, want 33012", got)
+ }
+ if _, ok := runner.last.Params["errorMessage"]; ok {
+ t.Fatalf("errorMessage should not be sent, params = %#v", runner.last.Params)
+ }
+ if _, ok := runner.last.Params["context"]; ok {
+ t.Fatalf("context should not be sent, params = %#v", runner.last.Params)
+ }
+ if got := runner.last.Params["query"]; got != "missing scope create calendar failed" {
+ t.Fatalf("query = %#v, want merged error context", got)
+ }
+}
+
+func TestDevdocErrorDiagnoseMergesAllContextIntoQuery(t *testing.T) {
+ t.Parallel()
+
+ runner := &devdocCommandRunner{}
+ cmd := devdocHandler{}.Command(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{
+ "error", "diagnose",
+ "--query", "机器人回调失败",
+ "--error-message", "missing scope",
+ "--api", "创建日程",
+ "--context", "应用无权限",
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
+ }
+ if got := runner.last.Tool; got != "search_open_error_code_rag" {
+ t.Fatalf("tool = %q, want search_open_error_code_rag", got)
+ }
+ if got := runner.last.Params["query"]; got != "机器人回调失败 missing scope 创建日程 应用无权限" {
+ t.Fatalf("query = %#v, want merged context", got)
+ }
+ for _, key := range []string{"apiName", "errorMessage", "context"} {
+ if _, ok := runner.last.Params[key]; ok {
+ t.Fatalf("%s should not be sent, params = %#v", key, runner.last.Params)
+ }
+ }
+}
+
+func TestDevdocErrorDiagnoseRequiresTroubleshootInput(t *testing.T) {
+ t.Parallel()
+
+ runner := &devdocCommandRunner{}
+ cmd := devdocHandler{}.Command(runner)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ cmd.SetArgs([]string{"error", "diagnose", "--api", "创建日程"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("Execute() error = nil, want validation error")
+ }
+ if !strings.Contains(err.Error(), "one of --query") {
+ t.Fatalf("error = %q, want required input hint", err.Error())
+ }
+ if runner.last.Tool != "" {
+ t.Fatalf("tool = %q, want no call", runner.last.Tool)
+ }
+}
diff --git a/internal/market/registry.go b/internal/market/registry.go
index 78bd0950..4ea1a1e4 100644
--- a/internal/market/registry.go
+++ b/internal/market/registry.go
@@ -31,7 +31,7 @@ import (
)
const (
- defaultBaseURL = "https://mcp.dingtalk.com"
+ defaultBaseURL = config.DefaultMCPBaseURL
registryMetadataKey = "com.dingtalk.mcp.registry/metadata"
// discoveryAPIPath is the path appended to BaseURL when fetching the
diff --git a/internal/transport/client.go b/internal/transport/client.go
index e22b90f2..2dc94734 100644
--- a/internal/transport/client.go
+++ b/internal/transport/client.go
@@ -494,8 +494,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
}
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) {
- // Strip any query/fragment from the endpoint to prevent parameter injection.
- endpoint = validate.StripQueryFragment(endpoint)
+ endpoint = sanitizeJSONRPCEndpoint(endpoint)
var lastErr error
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
@@ -533,7 +532,7 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
// Diagnostic: log identity-related headers on first attempt.
if attempt == 0 && c.FileLogger != nil {
c.FileLogger.LogAttrs(context.Background(), slog.LevelDebug, "http_request_headers",
- slog.String("endpoint", endpoint),
+ slog.String("endpoint", RedactURL(endpoint)),
slog.String("x-user-access-token-present", fmt.Sprintf("%t", req.Header.Get("x-user-access-token") != "")),
slog.Int("extra_headers_count", len(c.ExtraHeaders)),
)
@@ -593,6 +592,31 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
)
}
+func sanitizeJSONRPCEndpoint(endpoint string) string {
+ parsed, err := url.Parse(strings.TrimSpace(endpoint))
+ if err != nil || parsed.Host == "" {
+ return validate.StripQueryFragment(endpoint)
+ }
+ parsed.Fragment = ""
+ if shouldPreserveEndpointQuery(parsed) {
+ return parsed.String()
+ }
+ parsed.RawQuery = ""
+ return parsed.String()
+}
+
+func shouldPreserveEndpointQuery(parsed *url.URL) bool {
+ if parsed == nil || !strings.EqualFold(parsed.Scheme, "https") {
+ return false
+ }
+ switch strings.ToLower(parsed.Hostname()) {
+ case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com":
+ return true
+ default:
+ return false
+ }
+}
+
func retryable(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError
}
diff --git a/internal/transport/client_extra_test.go b/internal/transport/client_extra_test.go
index 395d4135..05177cbd 100644
--- a/internal/transport/client_extra_test.go
+++ b/internal/transport/client_extra_test.go
@@ -2,6 +2,9 @@ package transport
import (
"bytes"
+ "context"
+ "io"
+ "log/slog"
"net/http"
"strings"
"testing"
@@ -83,6 +86,41 @@ func TestRedactURL(t *testing.T) {
}
}
+func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) {
+ t.Parallel()
+
+ var logBuf bytes.Buffer
+ logger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ var requestedURL string
+ client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ requestedURL = req.URL.String()
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{}}`)),
+ Request: req,
+ }, nil
+ })})
+ client.FileLogger = logger
+
+ resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`))
+ if err != nil {
+ t.Fatalf("doWithRetry() error = %v", err)
+ }
+ defer resp.Body.Close()
+
+ if !strings.Contains(requestedURL, "key=secret") {
+ t.Fatalf("gateway request URL = %q, want preserved key query", requestedURL)
+ }
+ out := logBuf.String()
+ if strings.Contains(out, "key=secret") || strings.Contains(out, "secret") {
+ t.Fatalf("debug log leaked gateway key: %s", out)
+ }
+ if !strings.Contains(out, "key=REDACTED") {
+ t.Fatalf("debug log did not include redacted endpoint, got: %s", out)
+ }
+}
+
func TestSanitizeBearerToken(t *testing.T) {
t.Parallel()
tests := []struct {
diff --git a/internal/transport/client_test.go b/internal/transport/client_test.go
index 27d78c65..969a8796 100644
--- a/internal/transport/client_test.go
+++ b/internal/transport/client_test.go
@@ -507,6 +507,56 @@ func TestCallToolClassifiesJSONRPCInvalidParamsAsValidationError(t *testing.T) {
}
}
+func TestSanitizeJSONRPCEndpointPreservesDingTalkMCPGatewayQuery(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ endpoint string
+ want string
+ }{
+ {
+ name: "prepub gateway",
+ endpoint: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag",
+ want: "https://pre-mcp-gw.dingtalk.com/server/demo?key=secret",
+ },
+ {
+ name: "prod gateway",
+ endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag",
+ want: "https://mcp-gw.dingtalk.com/server/demo?key=secret",
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ if got := sanitizeJSONRPCEndpoint(tt.endpoint); got != tt.want {
+ t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSanitizeJSONRPCEndpointStripsQueryForOtherHosts(t *testing.T) {
+ t.Parallel()
+
+ got := sanitizeJSONRPCEndpoint("https://example.com/server/demo?admin=true#frag")
+ want := "https://example.com/server/demo"
+ if got != want {
+ t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
+ }
+}
+
+func TestSanitizeJSONRPCEndpointStripsQueryForHTTPGateway(t *testing.T) {
+ t.Parallel()
+
+ got := sanitizeJSONRPCEndpoint("http://pre-mcp-gw.dingtalk.com/server/demo?key=secret#frag")
+ want := "http://pre-mcp-gw.dingtalk.com/server/demo"
+ if got != want {
+ t.Fatalf("sanitizeJSONRPCEndpoint() = %q, want %q", got, want)
+ }
+}
+
type testSnapshotRecorder struct {
root string
}
diff --git a/pkg/config/constants_test.go b/pkg/config/constants_test.go
index 4cb538d5..c9e538b0 100644
--- a/pkg/config/constants_test.go
+++ b/pkg/config/constants_test.go
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
+ "strings"
"testing"
"time"
)
@@ -194,6 +195,19 @@ func TestDefaultFetchServersLimit(t *testing.T) {
}
}
+func TestGetMCPBaseURLDefaultsToProduction(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", dir)
+
+ got := GetMCPBaseURL()
+ if got != "https://mcp.dingtalk.com" {
+ t.Fatalf("GetMCPBaseURL() = %q, want production MCP URL", got)
+ }
+ if strings.Contains(got, "pre-mcp") {
+ t.Fatalf("GetMCPBaseURL() = %q, must not default to prepub MCP URL", got)
+ }
+}
+
func TestGetMCPBaseURLUsesConfigFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
diff --git a/skills/mono/references/intent-guide.md b/skills/mono/references/intent-guide.md
index 80749e46..a24e6172 100644
--- a/skills/mono/references/intent-guide.md
+++ b/skills/mono/references/intent-guide.md
@@ -43,10 +43,12 @@
### 2. devdoc — 开发文档搜索
**用 `devdoc` 的场景**:
-- "API 调用报错 403 怎么解决" — 开发调试问题
+- "API 调用报错 403 怎么解决" — 走 `devdoc error diagnose`
+- "requestId 15r6h45w0muec 为什么失败" — 走 `devdoc error diagnose --request-id ...`
- "搜一下 OAuth2 接入文档" — 开放平台技术文档
- "CLI 命令出错了怎么办" — CLI 使用错误
-- 用户提到"开发"、"API"、"调用错误"
+- 用户提到"开发"、"API"、"接口文档" → `devdoc article search`
+- 用户提到"调用错误"、"错误码"、"requestId"、"traceId" → `devdoc error diagnose`
---
diff --git a/skills/mono/references/products/devdoc.md b/skills/mono/references/products/devdoc.md
index e4116d9b..862e7b1d 100644
--- a/skills/mono/references/products/devdoc.md
+++ b/skills/mono/references/products/devdoc.md
@@ -20,14 +20,41 @@ Flags:
--size int 分页大小 (默认 10)
```
+### 错误排查
+```
+Usage:
+ dws devdoc error diagnose [flags]
+ dws devdoc error troubleshoot [flags]
+Example:
+ dws devdoc error diagnose --request-id 15r6h45w0muec
+ dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程"
+ dws devdoc error diagnose --error-code 33012 --error-message "missing scope"
+ dws devdoc error diagnose --query "机器人回调失败" --context "HTTP 403"
+Flags:
+ --query string 原始排查问题
+ --request-id string 开放平台 requestId
+ --trace-id string requestId 的兼容别名
+ --error-code string 错误码
+ --error-message string 错误描述,会合并进原始问题
+ --api string API 名称,会合并进原始问题作为补充检索词
+ --context string 额外排查上下文,会合并进原始问题
+ --page int 分页页码 (从 1 开始,默认 1)
+ --size int 分页大小 (默认 10)
+```
+
## 意图判断
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
- 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query`
+用户已经提供 requestId / traceId / 错误码 / 错误描述 / 失败上下文:
+- 走 `devdoc error diagnose`,优先传 `--request-id`,没有 requestId 时传 `--error-code`、`--error-message`、`--query` 或 `--context`
+
关键区分:
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
+- `devdoc error diagnose` 只返回诊断事实、参考资料和链接,不生成 AI 分析结论
+- `--api`、`--error-message`、`--context` 是 CLI 侧易用参数,调用 MCP 时会合并到 `query`;MCP 入参只发送 `query`、`requestId`、`errorCode`、`page`、`size`
## 核心工作流
@@ -43,11 +70,21 @@ dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
# 查错误码 / 字段含义
dws devdoc article search --query "errcode 40078" --format json
+
+# 已经有 requestId 时排查
+dws devdoc error diagnose --request-id 15r6h45w0muec --format json
+
+# 只有 traceId 时按 requestId 兼容处理
+dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程" --format json
+
+# 只有错误码和错误描述时排查
+dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
```
## 注意事项
- 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
+- 错误排查至少提供 `--query`、`--request-id`、`--error-code`、`--error-message`、`--context` 之一;单独 `--api` 只作为补充上下文,不足以发起排查
- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
diff --git a/skills/mono/references/products/simple.md b/skills/mono/references/products/simple.md
index cdaa9e27..a4f841ee 100644
--- a/skills/mono/references/products/simple.md
+++ b/skills/mono/references/products/simple.md
@@ -18,6 +18,25 @@ Flags:
--size string 每页数量 (默认 10)
```
+### 错误排查
+```
+Usage:
+ dws devdoc error diagnose [flags]
+Example:
+ dws devdoc error diagnose --request-id 15r6h45w0muec --format json
+ dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
+Flags:
+ --query string 原始排查问题
+ --request-id string 开放平台 requestId
+ --trace-id string requestId 的兼容别名
+ --error-code string 错误码
+ --error-message string 错误描述,会合并进原始问题
+ --api string API 名称,会合并进原始问题作为补充检索词
+ --context string 额外排查上下文,会合并进原始问题
+ --page int 分页页码 (默认 1)
+ --size int 分页大小 (默认 10)
+```
+
---
## oa — 审批
@@ -99,6 +118,7 @@ Example:
## 意图判断
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
+- 用户说"调用报错/requestId/traceId/错误码/错误描述" → `devdoc error diagnose`
- 用户说"审批/请假/报销/出差" → `oa approval`
- 用户说"同意审批/批准" → `oa approval approve`
- 用户说"拒绝审批/驳回" → `oa approval reject`
@@ -111,6 +131,7 @@ Example:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `devdoc article search` | 文档链接 | 直接展示给用户 |
+| `devdoc error diagnose` | diagnosticInfo、references、materials | 排查开放平台调用错误 |
| `oa approval list-forms` | processCode | detail / records 等 |
| `oa approval tasks` | taskId, instanceId | approve / reject |
| `oa approval list-pending` | instanceId | detail / approve / reject |
diff --git a/skills/multi/dingtalk-devdoc/SKILL.md b/skills/multi/dingtalk-devdoc/SKILL.md
index 2fb09b03..9c717654 100644
--- a/skills/multi/dingtalk-devdoc/SKILL.md
+++ b/skills/multi/dingtalk-devdoc/SKILL.md
@@ -28,7 +28,9 @@ metadata:
| 用户说 | 命令 |
|--------|------|
| "查 OAuth2 接入文档" | `dws devdoc article search --query "OAuth2 接入"` |
-| "API 调用报错怎么办" | `dws devdoc article search --query "<报错关键词>"` |
+| "API 调用报错怎么办" | `dws devdoc error diagnose --query "<报错关键词>"` |
+| "requestId 15r6h45w0muec 为什么失败" | `dws devdoc error diagnose --request-id 15r6h45w0muec` |
+| "错误码 33012" | `dws devdoc error diagnose --error-code 33012` |
| "开放接口文档" | `dws devdoc article search --query "<接口名或场景>"` |
## 跨产品协作
diff --git a/skills/multi/dingtalk-devdoc/references/devdoc.md b/skills/multi/dingtalk-devdoc/references/devdoc.md
index e4116d9b..862e7b1d 100644
--- a/skills/multi/dingtalk-devdoc/references/devdoc.md
+++ b/skills/multi/dingtalk-devdoc/references/devdoc.md
@@ -20,14 +20,41 @@ Flags:
--size int 分页大小 (默认 10)
```
+### 错误排查
+```
+Usage:
+ dws devdoc error diagnose [flags]
+ dws devdoc error troubleshoot [flags]
+Example:
+ dws devdoc error diagnose --request-id 15r6h45w0muec
+ dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程"
+ dws devdoc error diagnose --error-code 33012 --error-message "missing scope"
+ dws devdoc error diagnose --query "机器人回调失败" --context "HTTP 403"
+Flags:
+ --query string 原始排查问题
+ --request-id string 开放平台 requestId
+ --trace-id string requestId 的兼容别名
+ --error-code string 错误码
+ --error-message string 错误描述,会合并进原始问题
+ --api string API 名称,会合并进原始问题作为补充检索词
+ --context string 额外排查上下文,会合并进原始问题
+ --page int 分页页码 (从 1 开始,默认 1)
+ --size int 分页大小 (默认 10)
+```
+
## 意图判断
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
- 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query`
+用户已经提供 requestId / traceId / 错误码 / 错误描述 / 失败上下文:
+- 走 `devdoc error diagnose`,优先传 `--request-id`,没有 requestId 时传 `--error-code`、`--error-message`、`--query` 或 `--context`
+
关键区分:
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
+- `devdoc error diagnose` 只返回诊断事实、参考资料和链接,不生成 AI 分析结论
+- `--api`、`--error-message`、`--context` 是 CLI 侧易用参数,调用 MCP 时会合并到 `query`;MCP 入参只发送 `query`、`requestId`、`errorCode`、`page`、`size`
## 核心工作流
@@ -43,11 +70,21 @@ dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
# 查错误码 / 字段含义
dws devdoc article search --query "errcode 40078" --format json
+
+# 已经有 requestId 时排查
+dws devdoc error diagnose --request-id 15r6h45w0muec --format json
+
+# 只有 traceId 时按 requestId 兼容处理
+dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程" --format json
+
+# 只有错误码和错误描述时排查
+dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
```
## 注意事项
- 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
+- 错误排查至少提供 `--query`、`--request-id`、`--error-code`、`--error-message`、`--context` 之一;单独 `--api` 只作为补充上下文,不足以发起排查
- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
diff --git a/skills/multi/dingtalk-skill/references/skill.md b/skills/multi/dingtalk-skill/references/skill.md
index d9a416c3..850af6da 100644
--- a/skills/multi/dingtalk-skill/references/skill.md
+++ b/skills/multi/dingtalk-skill/references/skill.md
@@ -18,6 +18,25 @@ Flags:
--size string 每页数量 (默认 10)
```
+### 错误排查
+```
+Usage:
+ dws devdoc error diagnose [flags]
+Example:
+ dws devdoc error diagnose --request-id 15r6h45w0muec --format json
+ dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
+Flags:
+ --query string 原始排查问题
+ --request-id string 开放平台 requestId
+ --trace-id string requestId 的兼容别名
+ --error-code string 错误码
+ --error-message string 错误描述,会合并进原始问题
+ --api string API 名称,会合并进原始问题作为补充检索词
+ --context string 额外排查上下文,会合并进原始问题
+ --page int 分页页码 (默认 1)
+ --size int 分页大小 (默认 10)
+```
+
## live — 直播
### 查看我的直播列表
@@ -91,7 +110,8 @@ Args:
## 意图判断
-- 用户说"开发文档/API 文档/接口文档/调用报错" → `devdoc article search`
+- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
+- 用户说"调用报错/requestId/traceId/错误码/错误描述" → `devdoc error diagnose`
- 用户说"直播/我的直播" → `live stream list`
- 用户说"搜索技能/找技能/安装技能/技能市场" → `skill search` / `skill install`(按步骤衔接)
@@ -100,6 +120,7 @@ Args:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `devdoc article search` | 文档链接 | 直接展示给用户 |
+| `devdoc error diagnose` | diagnosticInfo、references、materials | 排查开放平台调用错误 |
| `skill search` | `skillId`、名称、描述 | 用户选型后传给 `skill install ` |
| `skill install` | 安装成功/失败信息 | 确认目标 Agent 目录已注册 |
| `skill publish` | 发布结果(成功或错误信息) | 确认企业技能库已更新 |
diff --git a/test/cli_compat/devdoc_test.go b/test/cli_compat/devdoc_test.go
index 755db3bf..6fb82af3 100644
--- a/test/cli_compat/devdoc_test.go
+++ b/test/cli_compat/devdoc_test.go
@@ -5,7 +5,7 @@ import "testing"
// ── devdoc article search ──────────────────────────────────
func TestDevdocArticleSearch_should_call_correct_tool(t *testing.T) {
- cap := setupTestDeps(t, "devdoc")
+ cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
err := execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{
"keyword": "MCP", "page": "1", "size": "10",
@@ -13,16 +13,18 @@ func TestDevdocArticleSearch_should_call_correct_tool(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- assertToolName(t, cap, "search_open_platform_docs")
+ assertToolName(t, cap, "search_open_platform_docs_rag")
}
func TestDevdocArticleSearch_should_pass_keyword(t *testing.T) {
- cap := setupTestDeps(t, "devdoc")
+ cap := setupTestDepsWithPreview(t, "devdoc")
root := buildRoot()
_ = execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{
"keyword": "openConversationId", "page": "1", "size": "10",
})
assertToolArg(t, cap, "keyword", "openConversationId")
+ assertToolArg(t, cap, "page", float64(1))
+ assertToolArg(t, cap, "size", float64(10))
}
func TestDevdocArticleSearch_should_not_call_when_dry_run(t *testing.T) {
@@ -33,3 +35,89 @@ func TestDevdocArticleSearch_should_not_call_when_dry_run(t *testing.T) {
})
assertCallCount(t, cap, 0)
}
+
+// ── devdoc error diagnose ──────────────────────────────────
+
+func TestDevdocErrorDiagnose_should_call_correct_tool(t *testing.T) {
+ cap := setupTestDepsWithPreview(t, "devdoc")
+ root := buildRoot()
+ err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
+ "request-id": "req-123",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ assertToolName(t, cap, "search_open_error_code_rag")
+}
+
+func TestDevdocErrorDiagnose_should_pass_request_id(t *testing.T) {
+ cap := setupTestDepsWithPreview(t, "devdoc")
+ root := buildRoot()
+ _ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
+ "request-id": "req-123", "page": "2", "size": "5",
+ })
+ assertToolArg(t, cap, "requestId", "req-123")
+ assertToolArg(t, cap, "page", float64(2))
+ assertToolArg(t, cap, "size", float64(5))
+}
+
+func TestDevdocErrorDiagnose_should_map_trace_id_alias(t *testing.T) {
+ cap := setupTestDepsWithPreview(t, "devdoc")
+ root := buildRoot()
+ _ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
+ "trace-id": "trace-abc", "api": "创建日程",
+ })
+ assertToolArg(t, cap, "requestId", "trace-abc")
+ assertToolArg(t, cap, "query", "创建日程")
+ assertArgNotPresent(t, cap, "traceId")
+ assertArgNotPresent(t, cap, "apiName")
+}
+
+func TestDevdocErrorTroubleshootAlias_should_pass_error_context(t *testing.T) {
+ cap := setupTestDepsWithPreview(t, "devdoc")
+ root := buildRoot()
+ _ = execCmd(t, root, []string{"devdoc", "error", "troubleshoot"}, map[string]string{
+ "error-code": "33012", "error-message": "missing scope", "context": "create calendar failed",
+ })
+ assertToolArg(t, cap, "errorCode", "33012")
+ assertToolArg(t, cap, "query", "missing scope create calendar failed")
+}
+
+func TestDevdocErrorDiagnose_should_merge_cli_only_context_into_query(t *testing.T) {
+ cap := setupTestDepsWithPreview(t, "devdoc")
+ root := buildRoot()
+ err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
+ "query": "机器人回调失败",
+ "error-message": "missing scope",
+ "api": "创建日程",
+ "context": "应用无权限",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ assertToolArg(t, cap, "query", "机器人回调失败 missing scope 创建日程 应用无权限")
+ assertArgNotPresent(t, cap, "apiName")
+ assertArgNotPresent(t, cap, "errorMessage")
+ assertArgNotPresent(t, cap, "context")
+}
+
+func TestDevdocErrorDiagnose_should_reject_api_without_primary_input(t *testing.T) {
+ cap := setupTestDepsWithPreview(t, "devdoc")
+ root := buildRoot()
+ err := execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
+ "api": "创建日程",
+ })
+ if err == nil {
+ t.Fatal("expected validation error")
+ }
+ assertCallCount(t, cap, 0)
+}
+
+func TestDevdocErrorDiagnose_should_not_call_when_dry_run(t *testing.T) {
+ cap := setupTestDepsWithDryRun(t, "devdoc")
+ root := buildRoot()
+ _ = execCmd(t, root, []string{"devdoc", "error", "diagnose"}, map[string]string{
+ "request-id": "req-123",
+ })
+ assertCallCount(t, cap, 0)
+}
diff --git a/test/cli_compat/helpers_test.go b/test/cli_compat/helpers_test.go
index 00df6928..1433dd08 100644
--- a/test/cli_compat/helpers_test.go
+++ b/test/cli_compat/helpers_test.go
@@ -37,6 +37,7 @@ type mcpCallCapture struct {
mu sync.Mutex
calls []capturedCall
dryRun bool
+ preview bool
confirm bool
}
@@ -103,6 +104,13 @@ func setupTestDepsWithDryRun(t *testing.T, product string) *mcpCallCapture {
return cap
}
+func setupTestDepsWithPreview(t *testing.T, product string) *mcpCallCapture {
+ t.Helper()
+ cap := setupTestDeps(t, product)
+ cap.preview = true
+ return cap
+}
+
func setupTestDepsAutoConfirm(t *testing.T, product string) *mcpCallCapture {
t.Helper()
cap := setupTestDeps(t, product)
@@ -130,7 +138,7 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map
cliArgs = append(cliArgs, path...)
// Add dry-run if capture says so
- if cap != nil && cap.dryRun {
+ if cap != nil && (cap.dryRun || cap.preview) {
cliArgs = append(cliArgs, "--dry-run")
}
// Add --yes if auto-confirm
@@ -164,12 +172,22 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map
DryRun bool `json:"dry_run"`
} `json:"invocation"`
}
+ var flatInv struct {
+ Tool string `json:"tool"`
+ Params map[string]any `json:"params"`
+ DryRun bool `json:"dry_run"`
+ }
if cap != nil {
if jsonErr := json.Unmarshal(out.Bytes(), &inv); jsonErr == nil && inv.Invocation.Tool != "" {
- if !inv.Invocation.DryRun {
+ if !inv.Invocation.DryRun || cap.preview {
cap.record(inv.Invocation.Tool, inv.Invocation.Params, "")
}
// For dry-run: don't record (matches old behavior: assertCallCount == 0)
+ } else if jsonErr := json.Unmarshal(out.Bytes(), &flatInv); jsonErr == nil && flatInv.Tool != "" {
+ dryRunPreview := flatInv.DryRun || cap.dryRun || cap.preview
+ if !dryRunPreview || cap.preview {
+ cap.record(flatInv.Tool, flatInv.Params, "")
+ }
}
}
return nil
diff --git a/test/integration/recovery/recovery_test.go b/test/integration/recovery/recovery_test.go
index a2791d13..c79b07fa 100644
--- a/test/integration/recovery/recovery_test.go
+++ b/test/integration/recovery/recovery_test.go
@@ -156,7 +156,7 @@ func TestRecoveryClosedLoopDoesNotRecursivelyCaptureExecuteFailures(t *testing.T
t.Fatalf("list_bases calls = %d, want 1", got)
}
if got := calls.docSearch.Load(); got < 1 {
- t.Fatalf("search_open_platform_docs calls = %d, want at least 1", got)
+ t.Fatalf("search_open_platform_docs_rag calls = %d, want at least 1", got)
}
}
@@ -251,7 +251,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC
writeJSONRPCResult(t, w, req["id"], map[string]any{
"tools": []map[string]any{
{
- "name": "search_open_platform_docs",
+ "name": "search_open_platform_docs_rag",
"title": "Search Docs",
"description": "Search docs",
"inputSchema": map[string]any{"type": "object"},
@@ -261,7 +261,7 @@ func newRecoveryRuntimeServer(t *testing.T) (*httptest.Server, *recoveryRuntimeC
case "tools/call":
params, _ := req["params"].(map[string]any)
name, _ := params["name"].(string)
- if name != "search_open_platform_docs" {
+ if name != "search_open_platform_docs_rag" {
t.Fatalf("unexpected devdoc tool %q", name)
}
calls.docSearch.Add(1)
@@ -313,8 +313,8 @@ func writeRecoveryCatalogFixture(t *testing.T, baseURL string) string {
"endpoint": baseURL + "/server/devdoc",
"tools": []any{
map[string]any{
- "rpc_name": "search_open_platform_docs",
- "canonical_path": "devdoc.search_open_platform_docs",
+ "rpc_name": "search_open_platform_docs_rag",
+ "canonical_path": "devdoc.search_open_platform_docs_rag",
},
},
},
diff --git a/test/skill_tests.md b/test/skill_tests.md
index 14158fe1..ad967dfb 100644
--- a/test/skill_tests.md
+++ b/test/skill_tests.md
@@ -44,7 +44,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
| `calendar` | `references/products/calendar.md` | 13 | 30 |
| `chat` | `references/products/chat.md` | 13 | 31 |
| `contact` | `references/products/contact.md` | 7 | 14 |
-| `devdoc` | `references/products/simple.md` | 1 | 5 |
+| `devdoc` | `references/products/simple.md` | 2 | 7 |
| `ding` | `references/products/ding.md` | 2 | 5 |
| `report` | `references/products/report.md` | 6 | 26 |
| `todo` | `references/products/todo.md` | 6 | 30 |
@@ -1007,7 +1007,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
---
-### devdoc(5 条)
+### devdoc(7 条)
#### `dws devdoc article search`
@@ -1036,6 +1036,18 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
- Expected: `dws devdoc article search --keyword 免登鉴权 --format json`
- Flags: `--keyword` = `免登鉴权`
+#### `dws devdoc error diagnose`
+
+**devdoc_devdoc_error_diagnose_001**
+- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败
+- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json`
+- Flags: `--request-id` = `15r6h45w0muec`
+
+**devdoc_devdoc_error_diagnose_002**
+- Prompt: 排查开放平台错误码 33012,错误描述 missing scope
+- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json`
+- Flags: `--error-code` = `33012`, `--error-message` = `missing scope`
+
---
### todo(30 条)
diff --git a/test/skill_tests_results.md b/test/skill_tests_results.md
index 6b0d7831..4c15b155 100644
--- a/test/skill_tests_results.md
+++ b/test/skill_tests_results.md
@@ -2,8 +2,8 @@
## Summary
-- **Total Test Cases**: 202
-- **Passed**: 202
+- **Total Test Cases**: 204
+- **Passed**: 204
- **Failed**: 0
- **Pass Rate**: 100.0%
@@ -16,7 +16,7 @@
| calendar | 30 | 30 | 0 | 100.0% |
| chat | 31 | 31 | 0 | 100.0% |
| contact | 14 | 14 | 0 | 100.0% |
-| devdoc | 5 | 5 | 0 | 100.0% |
+| devdoc | 7 | 7 | 0 | 100.0% |
| ding | 5 | 5 | 0 | 100.0% |
| report | 26 | 26 | 0 | 100.0% |
| todo | 30 | 30 | 0 | 100.0% |
@@ -1133,6 +1133,22 @@
- Command path: PASS (devdoc article search)
- Flags: PASS (1 flags validated)
+**devdoc_devdoc_error_diagnose_001** ✅ PASS
+
+- Prompt: 排查开放平台 requestId 15r6h45w0muec 的调用失败
+- Expected: `dws devdoc error diagnose --request-id 15r6h45w0muec --format json`
+- Skill Reference: references/products/devdoc.md
+- Command path: PASS (devdoc error diagnose)
+- Flags: PASS (1 flags validated)
+
+**devdoc_devdoc_error_diagnose_002** ✅ PASS
+
+- Prompt: 排查开放平台错误码 33012,错误描述 missing scope
+- Expected: `dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json`
+- Skill Reference: references/products/devdoc.md
+- Command path: PASS (devdoc error diagnose)
+- Flags: PASS (2 flags validated)
+
### ding
**ding_ding_message_recall_001** ✅ PASS
From 330922cdee8750a32af8892bba15ca2ac6732fc3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Wed, 10 Jun 2026 15:28:58 +0800
Subject: [PATCH 05/25] fix(cli): degrade to built-in commands when dynamic
build panics (#447)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The dynamic command tree is built from cached discovery data before
Cobra dispatches any command. A panic during that build (e.g. a
duplicate pflag registration fed by a poisoned cache, as seen before
1.0.32: "chat_permission_grant flag redefined: params") aborted every
invocation — including 'dws cache refresh', the very command that
repairs the cache. The only way out was manually deleting
~/.dws/cache//tools/*.
Wrap the envelope-driven build in a local recover: on panic the CLI now
logs the failure, prints a stderr hint pointing at 'dws cache refresh',
and falls back to the hardcoded helper commands so utility commands
stay alive and users can self-heal.
---
internal/app/legacy.go | 34 ++++++++-
internal/app/legacy_panic_fallback_test.go | 86 ++++++++++++++++++++++
2 files changed, 119 insertions(+), 1 deletion(-)
create mode 100644 internal/app/legacy_panic_fallback_test.go
diff --git a/internal/app/legacy.go b/internal/app/legacy.go
index acd6e753..15a603d7 100644
--- a/internal/app/legacy.go
+++ b/internal/app/legacy.go
@@ -16,6 +16,7 @@ package app
import (
"context"
"encoding/json"
+ "fmt"
"log/slog"
"net"
"net/http"
@@ -49,7 +50,38 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
return mergeTopLevelCommands(commands)
}
- dynamicCmds := loadDynamicCommands(ctx, runner)
+ return buildEnvelopeCommandsSafe(ctx, runner)
+}
+
+// loadDynamicCommandsFn is a test seam for buildEnvelopeCommandsSafe so a
+// panic in the cache-driven build can be simulated without crafting a
+// poisoned on-disk cache.
+var loadDynamicCommandsFn = loadDynamicCommands
+
+// buildEnvelopeCommandsSafe builds the public command set from the discovery
+// envelope, degrading to the hardcoded helper commands when the dynamic
+// build panics.
+//
+// Why this guard exists: the dynamic command tree is constructed from cached
+// discovery data BEFORE Cobra dispatches any command, so a panic here (e.g.
+// a duplicate pflag registration fed by a poisoned cache, as seen before
+// 1.0.32: "chat_permission_grant flag redefined: params") used to abort
+// every invocation — including `dws cache refresh`, the very command that
+// repairs the cache. Recovering locally keeps utility and helper commands
+// alive so users can self-heal instead of manually deleting cache files.
+func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) (cmds []*cobra.Command) {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("buildEnvelopeCommandsSafe: dynamic command build panicked, degrading to built-in commands", "panic", r)
+ fmt.Fprintf(os.Stderr,
+ "Warning: building product commands from the local discovery cache failed: %v\n"+
+ "Product commands are temporarily unavailable; utility commands still work.\n"+
+ "Run 'dws cache refresh' to rebuild the cache.\n", r)
+ cmds = mergeTopLevelCommands(helpers.NewPublicCommands(runner))
+ }
+ }()
+
+ dynamicCmds := loadDynamicCommandsFn(ctx, runner)
helperCmds := helpers.NewPublicCommands(runner)
merged := mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
// Post-merge product hooks: tasks the envelope cannot express on its
diff --git a/internal/app/legacy_panic_fallback_test.go b/internal/app/legacy_panic_fallback_test.go
new file mode 100644
index 00000000..7524482f
--- /dev/null
+++ b/internal/app/legacy_panic_fallback_test.go
@@ -0,0 +1,86 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+ "context"
+ "io"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/spf13/cobra"
+)
+
+// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
+// hatch for a poisoned discovery cache: when the dynamic command build
+// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
+// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
+// the panic. Instead it degrades to the hardcoded helper commands and
+// prints a stderr hint pointing at `dws cache refresh`, so the CLI stays
+// usable and the cache can be repaired without manual file deletion.
+func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
+ orig := loadDynamicCommandsFn
+ loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
+ panic("chat_permission_grant flag redefined: params")
+ }
+ t.Cleanup(func() { loadDynamicCommandsFn = orig })
+
+ // Capture stderr to assert the self-heal hint.
+ pipeR, pipeW, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("os.Pipe() error = %v", err)
+ }
+ origStderr := os.Stderr
+ os.Stderr = pipeW
+ t.Cleanup(func() { os.Stderr = origStderr })
+
+ cmds := newLegacyPublicCommands(context.Background(), nil)
+
+ _ = pipeW.Close()
+ os.Stderr = origStderr
+ captured, _ := io.ReadAll(pipeR)
+
+ if len(cmds) == 0 {
+ t.Fatalf("newLegacyPublicCommands() = 0 commands after build panic, want helper fallback set")
+ }
+ if !strings.Contains(string(captured), "dws cache refresh") {
+ t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
+ }
+}
+
+// TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath ensures the guard is
+// transparent on the happy path: commands returned by the dynamic build
+// still reach the caller unchanged.
+func TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath(t *testing.T) {
+ orig := loadDynamicCommandsFn
+ loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
+ return []*cobra.Command{{Use: "dynamic-probe"}}
+ }
+ t.Cleanup(func() { loadDynamicCommandsFn = orig })
+
+ cmds := newLegacyPublicCommands(context.Background(), nil)
+
+ found := false
+ for _, c := range cmds {
+ if c.Name() == "dynamic-probe" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("newLegacyPublicCommands() lost the dynamic command; got %d commands without 'dynamic-probe'", len(cmds))
+ }
+}
From 838e5453d88d41eee759730d2feeb9ec53c9e599 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Wed, 10 Jun 2026 15:31:37 +0800
Subject: [PATCH 06/25] fix(compat): guard envelope-driven flag registration
against pflag panics (#449)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The discovery envelope is remote data, but four of its shapes were
forwarded to pflag registration calls that panic:
- a flag named 'params' or 'json' collides with the reserved payload
flags registered at the end of ApplyBindings (the original pre-1.0.32
lockout: "chat_permission_grant flag redefined: params")
- two bindings resolving to the same long flag name (cross-binding
duplicate primary/alias; the existing dedup map was per-binding only)
- two flags claiming the same shorthand
- a multi-character shorthand
Because the command tree is built from the cached envelope before Cobra
dispatches anything, any of these aborted every CLI invocation.
Add canRegisterFlag (skip duplicate/reserved long names; CollectBindings
already tolerates missing flags via Lookup→nil→continue, and the value
stays reachable through --params) and safeShorthand (drop invalid or
taken shorthands, keep the long flag) and apply them at every
envelope-driven registration site in ApplyBindings and
registerPositionalAliasFlags. The trailing --json/--params registration
is also made idempotent.
Complements #447: that PR adds the escape hatch when the build panics;
this removes the known panic vectors so the escape hatch should never
be needed for them.
---
internal/compat/registry.go | 61 +++++++--
internal/compat/registry_flag_guard_test.go | 135 ++++++++++++++++++++
2 files changed, 185 insertions(+), 11 deletions(-)
create mode 100644 internal/compat/registry_flag_guard_test.go
diff --git a/internal/compat/registry.go b/internal/compat/registry.go
index 380ca903..eb01f959 100644
--- a/internal/compat/registry.go
+++ b/internal/compat/registry.go
@@ -415,6 +415,33 @@ func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, de
return
}
+// canRegisterFlag reports whether a long flag named name can be registered
+// on cmd without panicking pflag ("flag redefined"). The reserved payload
+// names are excluded too: ApplyBindings unconditionally registers hidden
+// --json/--params after the bindings loop. The envelope is remote data —
+// a duplicate or reserved name there must degrade to "flag unavailable",
+// never abort the process.
+func canRegisterFlag(cmd *cobra.Command, name string) bool {
+ if name == "" || name == "json" || name == "params" {
+ return false
+ }
+ return cmd.Flags().Lookup(name) == nil
+}
+
+// safeShorthand returns short when it is a single-character shorthand not
+// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
+// pflag panics on both multi-character and duplicate shorthands.
+func safeShorthand(cmd *cobra.Command, short string) string {
+ short = strings.TrimSpace(short)
+ if len(short) != 1 {
+ return ""
+ }
+ if cmd.Flags().ShorthandLookup(short) != nil {
+ return ""
+ }
+ return short
+}
+
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
for _, binding := range bindings {
// Positional bindings are collected from cobra args rather than flags.
@@ -464,7 +491,7 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
registerHidden := func(name string, suffix string) {
- if name == "" {
+ if !canRegisterFlag(cmd, name) {
return
}
switch binding.Kind {
@@ -484,19 +511,27 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
_ = cmd.Flags().MarkHidden(name)
}
+ if !canRegisterFlag(cmd, primary) {
+ // Duplicate or reserved primary name in the envelope. Skip the
+ // whole binding: CollectBindings tolerates the missing flag
+ // (Lookup → nil → continue) and the value can still be supplied
+ // via the --params payload.
+ continue
+ }
+ short := safeShorthand(cmd, binding.Short)
switch binding.Kind {
case ValueString:
- cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage)
+ cmd.Flags().StringP(primary, short, defStr, binding.Usage)
case ValueInt:
- cmd.Flags().IntP(primary, binding.Short, defInt, binding.Usage)
+ cmd.Flags().IntP(primary, short, defInt, binding.Usage)
case ValueFloat:
- cmd.Flags().Float64P(primary, binding.Short, defFloat, binding.Usage)
+ cmd.Flags().Float64P(primary, short, defFloat, binding.Usage)
case ValueBool:
- cmd.Flags().BoolP(primary, binding.Short, defBool, binding.Usage)
+ cmd.Flags().BoolP(primary, short, defBool, binding.Usage)
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
- cmd.Flags().StringSliceP(primary, binding.Short, defSlice, binding.Usage)
+ cmd.Flags().StringSliceP(primary, short, defSlice, binding.Usage)
case ValueJSON:
- cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage+" (JSON)")
+ cmd.Flags().StringP(primary, short, defStr, binding.Usage+" (JSON)")
}
registerHidden(alias, " (alias)")
for _, extra := range extras {
@@ -513,8 +548,12 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
}
}
}
- cmd.Flags().String("json", "", "Base JSON object payload for this command")
- cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
+ if cmd.Flags().Lookup("json") == nil {
+ cmd.Flags().String("json", "", "Base JSON object payload for this command")
+ }
+ if cmd.Flags().Lookup("params") == nil {
+ cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
+ }
_ = cmd.Flags().MarkHidden("json")
_ = cmd.Flags().MarkHidden("params")
}
@@ -553,12 +592,12 @@ func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
register := func(name string, withShort bool, hidden bool, usageSuffix string) {
- if name == "" {
+ if !canRegisterFlag(cmd, name) {
return
}
short := ""
if withShort {
- short = binding.Short
+ short = safeShorthand(cmd, binding.Short)
}
usage := binding.Usage + usageSuffix
switch binding.Kind {
diff --git a/internal/compat/registry_flag_guard_test.go b/internal/compat/registry_flag_guard_test.go
new file mode 100644
index 00000000..8756fa9c
--- /dev/null
+++ b/internal/compat/registry_flag_guard_test.go
@@ -0,0 +1,135 @@
+// 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 compat
+
+import (
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
+)
+
+// The envelope is remote data; none of these malformed shapes may panic the
+// command build — pflag panics on duplicate long names, duplicate shorthands,
+// and multi-character shorthands, and a poisoned discovery cache used to take
+// down every CLI invocation this way (pre-1.0.32 lockout class).
+func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
+ tests := []struct {
+ name string
+ flags map[string]market.CLIFlagOverride
+ }{
+ {
+ name: "duplicate shorthand across two flags",
+ flags: map[string]market.CLIFlagOverride{
+ "alpha": {Shorthand: "x"},
+ "beta": {Shorthand: "x"},
+ },
+ },
+ {
+ name: "multi-character shorthand",
+ flags: map[string]market.CLIFlagOverride{
+ "alpha": {Shorthand: "xy"},
+ },
+ },
+ {
+ name: "primary collides with reserved payload flag",
+ flags: map[string]market.CLIFlagOverride{
+ "params": {},
+ "json": {},
+ },
+ },
+ {
+ name: "cross-binding duplicate primary via alias",
+ flags: map[string]market.CLIFlagOverride{
+ "user_id": {Alias: "target"},
+ "member_id": {Alias: "target"},
+ },
+ },
+ {
+ name: "cross-binding alias collides with another primary",
+ flags: map[string]market.CLIFlagOverride{
+ "alpha": {},
+ "beta": {Aliases: []string{"alpha"}},
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-guard",
+ CLI: market.CLIOverlay{
+ ID: "guard",
+ Command: "guard",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "guard_tool": {
+ CLIName: "boom",
+ Flags: tc.flags,
+ },
+ },
+ },
+ },
+ }
+
+ // Must not panic; the command must build and stay executable.
+ cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
+ if len(cmds) != 1 {
+ t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
+ }
+ cmds[0].SetArgs([]string{"boom", "--help"})
+ cmds[0].SilenceErrors = true
+ cmds[0].SilenceUsage = true
+ if err := cmds[0].Execute(); err != nil {
+ t.Fatalf("execute --help: %v", err)
+ }
+ })
+ }
+}
+
+// TestBuildDynamicCommandsKeepsFirstShorthand pins the winner: when two
+// flags claim the same shorthand, the first (sorted param order) keeps it
+// and the second still registers its long flag.
+func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-guard",
+ CLI: market.CLIOverlay{
+ ID: "guard",
+ Command: "guard",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "guard_tool": {
+ CLIName: "boom",
+ Flags: map[string]market.CLIFlagOverride{
+ "alpha": {Shorthand: "x"},
+ "beta": {Shorthand: "x"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
+ boom, _, err := cmds[0].Find([]string{"boom"})
+ if err != nil {
+ t.Fatalf("find boom: %v", err)
+ }
+ short := boom.Flags().ShorthandLookup("x")
+ if short == nil || short.Name != "alpha" {
+ t.Fatalf("shorthand -x bound to %v, want alpha", short)
+ }
+ if boom.Flags().Lookup("beta") == nil {
+ t.Fatalf("long flag --beta missing; dropping the shorthand must not drop the flag")
+ }
+}
From 387ae5ff59e268c60207afb0f95933a5a9773e64 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Wed, 10 Jun 2026 16:04:20 +0800
Subject: [PATCH 07/25] fix(doc): strip leading H1 duplicating --name on doc
create (#448)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(doc): strip leading H1 duplicating --name on doc create
The doc platform renders the document name as the page title. When the
markdown body also opens with the same H1 — a habit LLM agents fall
into despite the skill docs saying otherwise — the created document
shows the title twice.
Strip a leading ATX H1 from the markdown body when its text equals the
document name (trimmed, case-insensitive), and print a stderr note so
agents learn the convention. Any other leading H1 is kept as
intentional content; names legitimately ending with '#' are not
over-trimmed. When the body is nothing but the duplicate H1, the
markdown param is omitted entirely.
* docs(skill): note the CLI auto-strip of a duplicate leading H1 in doc create
The convention stays the same (--name is the H1, body starts from ##),
but agents should recognize the new stderr note and not rely on the
fallback.
---
internal/helpers/doc.go | 42 ++++-
internal/helpers/doc_title_dedup_test.go | 161 ++++++++++++++++++
.../references/products/doc/doc-create.md | 2 +-
.../products/doc/style/doc-create-workflow.md | 4 +-
.../dingtalk-doc/references/doc/doc-create.md | 2 +-
.../doc/style/doc-create-workflow.md | 4 +-
6 files changed, 208 insertions(+), 7 deletions(-)
create mode 100644 internal/helpers/doc_title_dedup_test.go
diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go
index 3590bbaa..29fba2e5 100644
--- a/internal/helpers/doc.go
+++ b/internal/helpers/doc.go
@@ -357,7 +357,13 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
if sniffJsonMLLike(content) {
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
}
- params["markdown"] = content
+ if stripped, ok := stripLeadingDuplicateTitleHeading(content, name); ok {
+ fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首行与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
+ content = stripped
+ }
+ if content != "" {
+ params["markdown"] = content
+ }
}
} else if format, err := docContentFormat(cmd, "markdown", "jsonml"); err != nil {
return err
@@ -385,6 +391,40 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
return cmd
}
+// stripLeadingDuplicateTitleHeading removes a leading markdown ATX H1 whose
+// text equals the document name. The platform already renders the document
+// name as the page title, so a body that opens with the same H1 displays the
+// title twice (the "two headings" effect). Only an exact match (trimmed,
+// case-insensitive) is removed; any other leading H1 is kept as intentional
+// content. Reports whether a heading was stripped.
+func stripLeadingDuplicateTitleHeading(content, name string) (string, bool) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return content, false
+ }
+ body := strings.TrimLeft(content, "\ufeff \t\r\n")
+ line := body
+ rest := ""
+ if idx := strings.IndexByte(body, '\n'); idx >= 0 {
+ line = body[:idx]
+ rest = body[idx+1:]
+ }
+ line = strings.TrimRight(line, " \t\r")
+ if !strings.HasPrefix(line, "# ") {
+ return content, false
+ }
+ heading := strings.TrimSpace(line[2:])
+ // ATX closing sequence ("# Title #") — only trim trailing hashes when
+ // separated by a space, so names that legitimately end with '#' survive.
+ if t := strings.TrimRight(heading, "#"); t != heading && strings.HasSuffix(t, " ") {
+ heading = strings.TrimSpace(t)
+ }
+ if !strings.EqualFold(heading, name) {
+ return content, false
+ }
+ return strings.TrimLeft(rest, "\r\n"), true
+}
+
func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
diff --git a/internal/helpers/doc_title_dedup_test.go b/internal/helpers/doc_title_dedup_test.go
new file mode 100644
index 00000000..6d5bf16b
--- /dev/null
+++ b/internal/helpers/doc_title_dedup_test.go
@@ -0,0 +1,161 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestStripLeadingDuplicateTitleHeading(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ docName string
+ want string
+ stripped bool
+ }{
+ {
+ name: "exact match stripped",
+ content: "# 2026-06-10 Ari晚会简报\n\n聚焦今日变化。\n",
+ docName: "2026-06-10 Ari晚会简报",
+ want: "聚焦今日变化。\n",
+ stripped: true,
+ },
+ {
+ name: "leading blank lines tolerated",
+ content: "\n\n# Title\nbody",
+ docName: "Title",
+ want: "body",
+ stripped: true,
+ },
+ {
+ name: "case insensitive match",
+ content: "# weekly REPORT\nbody",
+ docName: "Weekly Report",
+ want: "body",
+ stripped: true,
+ },
+ {
+ name: "atx closing hashes",
+ content: "# Title #\nbody",
+ docName: "Title",
+ want: "body",
+ stripped: true,
+ },
+ {
+ name: "title-only content becomes empty",
+ content: "# Title",
+ docName: "Title",
+ want: "",
+ stripped: true,
+ },
+ {
+ name: "different heading kept",
+ content: "# 背景\nbody",
+ docName: "2026-06-10 Ari晚会简报",
+ want: "# 背景\nbody",
+ stripped: false,
+ },
+ {
+ name: "h2 not touched",
+ content: "## Title\nbody",
+ docName: "Title",
+ want: "## Title\nbody",
+ stripped: false,
+ },
+ {
+ name: "no heading kept",
+ content: "plain text\n# Title later",
+ docName: "Title",
+ want: "plain text\n# Title later",
+ stripped: false,
+ },
+ {
+ name: "name ending with hash not over-trimmed",
+ content: "# C#\nbody",
+ docName: "C",
+ want: "# C#\nbody",
+ stripped: false,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, stripped := stripLeadingDuplicateTitleHeading(tc.content, tc.docName)
+ if stripped != tc.stripped {
+ t.Fatalf("stripped = %v, want %v", stripped, tc.stripped)
+ }
+ if got != tc.want {
+ t.Fatalf("content = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestDocCreateStripsDuplicateTitleHeading verifies the end-to-end behavior:
+// `doc create --name X --content "# X\n..."` must not forward the duplicate
+// H1 to the MCP tool — the platform renders the document name as the page
+// title, so keeping it would display two headings.
+func TestDocCreateStripsDuplicateTitleHeading(t *testing.T) {
+ runner := &docCommandRunner{}
+ root := newDocTestRoot(runner)
+
+ _, errOut, err := executeDocCommand(t, root,
+ "create", "--name", "2026-06-10 Ari晚会简报",
+ "--content", "# 2026-06-10 Ari晚会简报\n\n聚焦今日变化。")
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ got, _ := runner.last.Params["markdown"].(string)
+ if got != "聚焦今日变化。" {
+ t.Errorf("markdown param = %q, want duplicate H1 stripped", got)
+ }
+ if !strings.Contains(errOut, "已自动移除") {
+ t.Errorf("stderr = %q, want a note about the removed heading", errOut)
+ }
+}
+
+// TestDocCreateKeepsDistinctHeading ensures the guard never eats an H1 that
+// differs from the document name.
+func TestDocCreateKeepsDistinctHeading(t *testing.T) {
+ runner := &docCommandRunner{}
+ root := newDocTestRoot(runner)
+
+ _, _, err := executeDocCommand(t, root,
+ "create", "--name", "晚会简报", "--content", "# 背景\n正文")
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ got, _ := runner.last.Params["markdown"].(string)
+ if got != "# 背景\n正文" {
+ t.Errorf("markdown param = %q, want content untouched", got)
+ }
+}
+
+// TestDocCreateTitleOnlyContentOmitsMarkdown: when the body is nothing but
+// the duplicate H1, the markdown param should be omitted entirely instead of
+// sending an empty string.
+func TestDocCreateTitleOnlyContentOmitsMarkdown(t *testing.T) {
+ runner := &docCommandRunner{}
+ root := newDocTestRoot(runner)
+
+ _, _, err := executeDocCommand(t, root,
+ "create", "--name", "晚会简报", "--content", "# 晚会简报")
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if _, ok := runner.last.Params["markdown"]; ok {
+ t.Errorf("markdown param = %v, want omitted", runner.last.Params["markdown"])
+ }
+}
diff --git a/skills/mono/references/products/doc/doc-create.md b/skills/mono/references/products/doc/doc-create.md
index 6ade971b..3f92a4ac 100644
--- a/skills/mono/references/products/doc/doc-create.md
+++ b/skills/mono/references/products/doc/doc-create.md
@@ -41,7 +41,7 @@ Flags:
## 关键说明
-- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
+- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
diff --git a/skills/mono/references/products/doc/style/doc-create-workflow.md b/skills/mono/references/products/doc/style/doc-create-workflow.md
index 6c1f1282..c0503ed8 100644
--- a/skills/mono/references/products/doc/style/doc-create-workflow.md
+++ b/skills/mono/references/products/doc/style/doc-create-workflow.md
@@ -45,7 +45,7 @@
| 项目 | 要求 |
|------|------|
-| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
+| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
@@ -147,7 +147,7 @@ dws doc read --node --content-format jsonml --output /tmp/-readba
- 只使用用户已提供或对话中已确认的正文素材。
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
-- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
+- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
diff --git a/skills/multi/dingtalk-doc/references/doc/doc-create.md b/skills/multi/dingtalk-doc/references/doc/doc-create.md
index f5093586..11a2daa5 100644
--- a/skills/multi/dingtalk-doc/references/doc/doc-create.md
+++ b/skills/multi/dingtalk-doc/references/doc/doc-create.md
@@ -41,7 +41,7 @@ Flags:
## 关键说明
-- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
+- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
diff --git a/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md b/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md
index 6c1f1282..c0503ed8 100644
--- a/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md
+++ b/skills/multi/dingtalk-doc/references/doc/style/doc-create-workflow.md
@@ -45,7 +45,7 @@
| 项目 | 要求 |
|------|------|
-| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
+| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
@@ -147,7 +147,7 @@ dws doc read --node --content-format jsonml --output /tmp/-readba
- 只使用用户已提供或对话中已确认的正文素材。
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
-- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
+- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
From 31e65dda512c461dfcd225b0eaa5dcd02dbaba1c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Wed, 10 Jun 2026 22:45:35 +0800
Subject: [PATCH 08/25] fix(cli): self-heal a poisoned discovery cache by
quarantining it and rebuilding (#452)
A panic during the envelope-driven command build no longer just degrades
to helper commands (#447): the partition's discovery cache is first moved
aside to .quarantined (kept for inspection, previous quarantine
replaced) and the build retried once against a fresh fetch. Any path that
delivers a fixed binary -- dws upgrade or a reinstall -- now escapes the
lock-out with zero manual cache surgery; only a second panic (remote
envelope still poisoned, or offline) falls back to the degraded helper
set with the 'dws cache refresh' hint.
dws upgrade additionally clears the discovery-derived caches (market /
tools / detail across all partitions, leaving the downloads dir alone)
after the binary swap, so the upgraded binary rebuilds its command tree
from fresh data instead of inheriting snapshots written by the old
version.
---
internal/app/legacy.go | 61 ++++++--
internal/app/legacy_panic_fallback_test.go | 153 ++++++++++++++++++---
internal/app/upgrade.go | 10 ++
internal/cache/store.go | 68 +++++++++
internal/cache/store_quarantine_test.go | 131 ++++++++++++++++++
5 files changed, 393 insertions(+), 30 deletions(-)
create mode 100644 internal/cache/store_quarantine_test.go
diff --git a/internal/app/legacy.go b/internal/app/legacy.go
index 15a603d7..7e711d13 100644
--- a/internal/app/legacy.go
+++ b/internal/app/legacy.go
@@ -59,25 +59,62 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
var loadDynamicCommandsFn = loadDynamicCommands
// buildEnvelopeCommandsSafe builds the public command set from the discovery
-// envelope, degrading to the hardcoded helper commands when the dynamic
-// build panics.
+// envelope, self-healing a poisoned cache when the dynamic build panics and
+// degrading to the hardcoded helper commands only if that also fails.
//
// Why this guard exists: the dynamic command tree is constructed from cached
// discovery data BEFORE Cobra dispatches any command, so a panic here (e.g.
// a duplicate pflag registration fed by a poisoned cache, as seen before
// 1.0.32: "chat_permission_grant flag redefined: params") used to abort
// every invocation — including `dws cache refresh`, the very command that
-// repairs the cache. Recovering locally keeps utility and helper commands
-// alive so users can self-heal instead of manually deleting cache files.
-func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) (cmds []*cobra.Command) {
+// repairs the cache.
+//
+// Recovery is two-staged. First the partition's discovery cache is moved
+// aside (kept on disk for inspection) and the build retried against a fresh
+// fetch — so any path that delivers a fixed binary (`dws upgrade`, reinstall)
+// escapes the lock-out with zero manual cache surgery. Only when the rebuild
+// panics again (e.g. the remote envelope itself is still poisoned, or the
+// machine is offline with no usable cache) does the CLI degrade to utility
+// and helper commands with a `dws cache refresh` hint.
+func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) []*cobra.Command {
+ cmds, panicked := tryBuildEnvelopeCommands(ctx, runner)
+ if panicked == nil {
+ return cmds
+ }
+ slog.Error("buildEnvelopeCommandsSafe: dynamic command build panicked", "panic", panicked)
+
+ quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
+ if qErr != nil {
+ slog.Error("buildEnvelopeCommandsSafe: failed to quarantine discovery cache", "error", qErr)
+ }
+ if quarantined != "" {
+ fmt.Fprintf(os.Stderr,
+ "Warning: building product commands from the local discovery cache failed: %v\n"+
+ "The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
+ panicked, quarantined)
+ cmds, panicked = tryBuildEnvelopeCommands(ctx, runner)
+ if panicked == nil {
+ fmt.Fprintln(os.Stderr, "Product commands rebuilt successfully.")
+ return cmds
+ }
+ slog.Error("buildEnvelopeCommandsSafe: rebuild after cache quarantine panicked again, degrading to built-in commands", "panic", panicked)
+ }
+
+ fmt.Fprintf(os.Stderr,
+ "Warning: building product commands from the local discovery cache failed: %v\n"+
+ "Product commands are temporarily unavailable; utility commands still work.\n"+
+ "Run 'dws cache refresh' to rebuild the cache.\n", panicked)
+ return mergeTopLevelCommands(helpers.NewPublicCommands(runner))
+}
+
+// tryBuildEnvelopeCommands runs one attempt of the envelope-driven build,
+// converting a panic into a return value so the caller can decide between
+// self-heal and degradation.
+func tryBuildEnvelopeCommands(ctx context.Context, runner executor.Runner) (cmds []*cobra.Command, panicked any) {
defer func() {
if r := recover(); r != nil {
- slog.Error("buildEnvelopeCommandsSafe: dynamic command build panicked, degrading to built-in commands", "panic", r)
- fmt.Fprintf(os.Stderr,
- "Warning: building product commands from the local discovery cache failed: %v\n"+
- "Product commands are temporarily unavailable; utility commands still work.\n"+
- "Run 'dws cache refresh' to rebuild the cache.\n", r)
- cmds = mergeTopLevelCommands(helpers.NewPublicCommands(runner))
+ cmds = nil
+ panicked = r
}
}()
@@ -90,7 +127,7 @@ func buildEnvelopeCommandsSafe(ctx context.Context, runner executor.Runner) (cmd
// command surface remains predictable from the envelope alone.
helpers.AttachReportLegacyInboxAlias(merged, runner)
helpers.AttachReportListReadableEnrichment(merged, runner)
- return merged
+ return merged, nil
}
// pickCommands returns the union of dynamic and helpers commands. For
diff --git a/internal/app/legacy_panic_fallback_test.go b/internal/app/legacy_panic_fallback_test.go
index 7524482f..725a4fd8 100644
--- a/internal/app/legacy_panic_fallback_test.go
+++ b/internal/app/legacy_panic_fallback_test.go
@@ -17,46 +17,163 @@ import (
"context"
"io"
"os"
+ "path/filepath"
"strings"
"testing"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
-// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
-// hatch for a poisoned discovery cache: when the dynamic command build
-// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
-// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
-// the panic. Instead it degrades to the hardcoded helper commands and
-// prints a stderr hint pointing at `dws cache refresh`, so the CLI stays
-// usable and the cache can be repaired without manual file deletion.
-func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
- orig := loadDynamicCommandsFn
- loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
- panic("chat_permission_grant flag redefined: params")
- }
- t.Cleanup(func() { loadDynamicCommandsFn = orig })
-
- // Capture stderr to assert the self-heal hint.
+// captureStderr redirects os.Stderr for the duration of fn and returns what
+// was written to it.
+func captureStderr(t *testing.T, fn func()) string {
+ t.Helper()
pipeR, pipeW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
origStderr := os.Stderr
os.Stderr = pipeW
- t.Cleanup(func() { os.Stderr = origStderr })
+ defer func() { os.Stderr = origStderr }()
- cmds := newLegacyPublicCommands(context.Background(), nil)
+ fn()
_ = pipeW.Close()
os.Stderr = origStderr
captured, _ := io.ReadAll(pipeR)
+ return string(captured)
+}
+
+// TestNewLegacyPublicCommandsPanicFallsBackToHelpers verifies the escape
+// hatch for a poisoned discovery cache: when the dynamic command build
+// panics (e.g. duplicate pflag registration, the pre-1.0.32 lock-out
+// "flag redefined: params"), newLegacyPublicCommands must NOT propagate
+// the panic. With no on-disk cache to quarantine there is nothing to
+// self-heal from, so it degrades to the hardcoded helper commands and
+// prints a stderr hint pointing at `dws cache refresh`.
+func TestNewLegacyPublicCommandsPanicFallsBackToHelpers(t *testing.T) {
+ t.Setenv(cli.CacheDirEnv, t.TempDir())
+
+ calls := 0
+ orig := loadDynamicCommandsFn
+ loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
+ calls++
+ panic("chat_permission_grant flag redefined: params")
+ }
+ t.Cleanup(func() { loadDynamicCommandsFn = orig })
+
+ var cmds []*cobra.Command
+ captured := captureStderr(t, func() {
+ cmds = newLegacyPublicCommands(context.Background(), nil)
+ })
if len(cmds) == 0 {
t.Fatalf("newLegacyPublicCommands() = 0 commands after build panic, want helper fallback set")
}
- if !strings.Contains(string(captured), "dws cache refresh") {
+ if !strings.Contains(captured, "dws cache refresh") {
+ t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
+ }
+ if calls != 1 {
+ t.Errorf("dynamic build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
+ }
+}
+
+// TestNewLegacyPublicCommandsSelfHealsPoisonedCache verifies the self-heal
+// path: when the build panics AND a discovery cache exists on disk, the
+// partition is quarantined (moved aside, kept for inspection) and the build
+// retried once. The retry succeeding means the user gets the full dynamic
+// command tree with zero manual cache surgery.
+func TestNewLegacyPublicCommandsSelfHealsPoisonedCache(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv(cli.CacheDirEnv, tmp)
+
+ store := cache.NewStore(tmp)
+ partition := editionPartition()
+ if err := store.SaveTools(partition, "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
+ t.Fatalf("SaveTools() error = %v", err)
+ }
+
+ calls := 0
+ orig := loadDynamicCommandsFn
+ loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
+ calls++
+ if calls == 1 {
+ panic("chat_permission_grant flag redefined: params")
+ }
+ return []*cobra.Command{{Use: "dynamic-probe"}}
+ }
+ t.Cleanup(func() { loadDynamicCommandsFn = orig })
+
+ var cmds []*cobra.Command
+ captured := captureStderr(t, func() {
+ cmds = newLegacyPublicCommands(context.Background(), nil)
+ })
+
+ if calls != 2 {
+ t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
+ }
+ found := false
+ for _, c := range cmds {
+ if c.Name() == "dynamic-probe" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("newLegacyPublicCommands() did not return the rebuilt dynamic command tree; got %d commands without 'dynamic-probe'", len(cmds))
+ }
+
+ quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
+ if len(quarantines) != 1 {
+ t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
+ }
+ if _, err := os.Stat(filepath.Join(quarantines[0], "tools", "poisoned-server.json")); err != nil {
+ t.Errorf("poisoned snapshot not preserved in quarantine: %v", err)
+ }
+ if !strings.Contains(captured, "rebuilding from a fresh fetch") {
+ t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
+ }
+ if strings.Contains(captured, "dws cache refresh") {
+ t.Errorf("stderr = %q, must not tell the user to run 'dws cache refresh' when the rebuild succeeded", captured)
+ }
+}
+
+// TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers verifies the final
+// safety net: if the rebuild after quarantine panics again (remote envelope
+// still poisoned, or offline), the CLI degrades to helper commands and keeps
+// the `dws cache refresh` hint.
+func TestNewLegacyPublicCommandsSecondPanicDegradesToHelpers(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv(cli.CacheDirEnv, tmp)
+
+ store := cache.NewStore(tmp)
+ if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
+ t.Fatalf("SaveTools() error = %v", err)
+ }
+
+ calls := 0
+ orig := loadDynamicCommandsFn
+ loadDynamicCommandsFn = func(context.Context, executor.Runner) []*cobra.Command {
+ calls++
+ panic("chat_permission_grant flag redefined: params")
+ }
+ t.Cleanup(func() { loadDynamicCommandsFn = orig })
+
+ var cmds []*cobra.Command
+ captured := captureStderr(t, func() {
+ cmds = newLegacyPublicCommands(context.Background(), nil)
+ })
+
+ if calls != 2 {
+ t.Fatalf("dynamic build attempts = %d, want 2 (initial + retry after quarantine)", calls)
+ }
+ if len(cmds) == 0 {
+ t.Fatalf("newLegacyPublicCommands() = 0 commands after repeated build panics, want helper fallback set")
+ }
+ if !strings.Contains(captured, "dws cache refresh") {
t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
}
}
diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go
index ad7e969b..4bee05eb 100644
--- a/internal/app/upgrade.go
+++ b/internal/app/upgrade.go
@@ -518,6 +518,16 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugGreen("✓"))
}
+ // Clear discovery-derived caches so the upgraded binary rebuilds its
+ // command tree from a fresh fetch instead of inheriting snapshots written
+ // by the old version — a poisoned snapshot used to lock out every
+ // invocation before the build guards landed (#447 / #449).
+ if purged, purgeErr := cacheStoreFromEnv().PurgeDiscoveryData(); purgeErr != nil {
+ fmt.Printf(" %s %s\n", ugYellow("⚠"), ugDim(fmt.Sprintf("清理发现缓存失败 (可手动运行 dws cache refresh): %v", purgeErr)))
+ } else if len(purged) > 0 {
+ fmt.Printf(" %s %s\n", ugGreen("✓"), ugDim("发现缓存已清空, 新版本首次运行时自动重建"))
+ }
+
// Cleanup old backups
rm.Cleanup(5)
diff --git a/internal/cache/store.go b/internal/cache/store.go
index 56c96554..3fbaa398 100644
--- a/internal/cache/store.go
+++ b/internal/cache/store.go
@@ -238,6 +238,74 @@ func (s *Store) DeleteDetail(partition, serverKey string) error {
return nil
}
+// QuarantinePartition moves the entire on-disk cache for a partition aside,
+// renaming it to ".quarantined", so the next load starts from an
+// empty cache while the poisoned snapshot stays on disk for inspection.
+// Returns the quarantine path, or "" when the partition has no cache on disk.
+// A previous quarantine for the same partition is replaced, so repeated
+// quarantines never accumulate.
+func (s *Store) QuarantinePartition(partition string) (string, error) {
+ dir := filepath.Join(s.Root, sanitize(partition))
+ if _, err := os.Stat(dir); err != nil {
+ if os.IsNotExist(err) {
+ return "", nil
+ }
+ return "", err
+ }
+ quarantine := dir + ".quarantined"
+ if err := os.RemoveAll(quarantine); err != nil {
+ return "", err
+ }
+ if err := os.Rename(dir, quarantine); err != nil {
+ return "", err
+ }
+ return quarantine, nil
+}
+
+// discoverySubdirs are the per-partition directories holding discovery-derived
+// data: the market registry envelope plus tools / detail snapshots.
+var discoverySubdirs = []string{"market", "tools", "detail"}
+
+// PurgeDiscoveryData deletes the discovery-derived cache for every partition
+// under the cache root, leaving unrelated data that shares the root (e.g. the
+// upgrade download cache in "downloads/") untouched. Returns the names of the
+// partition directories that had data removed. Removal errors are collected
+// into the returned error but do not stop the sweep.
+func (s *Store) PurgeDiscoveryData() ([]string, error) {
+ entries, err := os.ReadDir(s.Root)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var purged []string
+ var firstErr error
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ removedAny := false
+ for _, sub := range discoverySubdirs {
+ dir := filepath.Join(s.Root, entry.Name(), sub)
+ if _, statErr := os.Stat(dir); statErr != nil {
+ continue
+ }
+ if rmErr := os.RemoveAll(dir); rmErr != nil {
+ if firstErr == nil {
+ firstErr = rmErr
+ }
+ continue
+ }
+ removedAny = true
+ }
+ if removedAny {
+ purged = append(purged, entry.Name())
+ }
+ }
+ return purged, firstErr
+}
+
func (s *Store) registryPath(partition string) string {
return filepath.Join(s.Root, sanitize(partition), "market", "servers.json")
}
diff --git a/internal/cache/store_quarantine_test.go b/internal/cache/store_quarantine_test.go
new file mode 100644
index 00000000..b06ef12f
--- /dev/null
+++ b/internal/cache/store_quarantine_test.go
@@ -0,0 +1,131 @@
+// 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 cache
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestQuarantinePartitionNoCacheIsNoop(t *testing.T) {
+ s := NewStore(t.TempDir())
+ path, err := s.QuarantinePartition("default_default")
+ if err != nil {
+ t.Fatalf("QuarantinePartition() error = %v", err)
+ }
+ if path != "" {
+ t.Errorf("QuarantinePartition() = %q, want empty path when nothing is cached", path)
+ }
+}
+
+func TestQuarantinePartitionMovesCacheAside(t *testing.T) {
+ tmp := t.TempDir()
+ s := NewStore(tmp)
+ if err := s.SaveTools("default_default", "srv", ToolsSnapshot{ServerKey: "srv"}); err != nil {
+ t.Fatalf("SaveTools() error = %v", err)
+ }
+
+ path, err := s.QuarantinePartition("default_default")
+ if err != nil {
+ t.Fatalf("QuarantinePartition() error = %v", err)
+ }
+ want := filepath.Join(tmp, "default_default.quarantined")
+ if path != want {
+ t.Errorf("QuarantinePartition() = %q, want %q", path, want)
+ }
+ if _, statErr := os.Stat(filepath.Join(tmp, "default_default")); !os.IsNotExist(statErr) {
+ t.Errorf("original partition dir still present after quarantine (stat err = %v)", statErr)
+ }
+ if _, statErr := os.Stat(filepath.Join(path, "tools", "srv.json")); statErr != nil {
+ t.Errorf("quarantined snapshot missing: %v", statErr)
+ }
+}
+
+func TestQuarantinePartitionReplacesPreviousQuarantine(t *testing.T) {
+ tmp := t.TempDir()
+ s := NewStore(tmp)
+ if err := s.SaveTools("default_default", "first", ToolsSnapshot{ServerKey: "first"}); err != nil {
+ t.Fatalf("SaveTools() error = %v", err)
+ }
+ if _, err := s.QuarantinePartition("default_default"); err != nil {
+ t.Fatalf("first QuarantinePartition() error = %v", err)
+ }
+ if err := s.SaveTools("default_default", "second", ToolsSnapshot{ServerKey: "second"}); err != nil {
+ t.Fatalf("SaveTools() error = %v", err)
+ }
+
+ path, err := s.QuarantinePartition("default_default")
+ if err != nil {
+ t.Fatalf("second QuarantinePartition() error = %v", err)
+ }
+ if _, statErr := os.Stat(filepath.Join(path, "tools", "second.json")); statErr != nil {
+ t.Errorf("latest quarantine missing newest snapshot: %v", statErr)
+ }
+ if _, statErr := os.Stat(filepath.Join(path, "tools", "first.json")); !os.IsNotExist(statErr) {
+ t.Errorf("previous quarantine was not replaced (stat err = %v)", statErr)
+ }
+}
+
+func TestPurgeDiscoveryDataRemovesDiscoveryDirsOnly(t *testing.T) {
+ tmp := t.TempDir()
+ s := NewStore(tmp)
+
+ mustWrite := func(parts ...string) {
+ t.Helper()
+ path := filepath.Join(parts...)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(path), err)
+ }
+ if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
+ t.Fatalf("WriteFile(%s) error = %v", path, err)
+ }
+ }
+ mustWrite(tmp, "default_default", "market", "servers.json")
+ mustWrite(tmp, "default_default", "tools", "srv.json")
+ mustWrite(tmp, "default_default", "detail", "srv.json")
+ mustWrite(tmp, "wukong_default", "tools", "srv.json")
+ // Unrelated data sharing the cache root must survive the purge.
+ mustWrite(tmp, "downloads", "dws-1.0.36.tar.gz")
+
+ purged, err := s.PurgeDiscoveryData()
+ if err != nil {
+ t.Fatalf("PurgeDiscoveryData() error = %v", err)
+ }
+ if len(purged) != 2 {
+ t.Fatalf("PurgeDiscoveryData() purged = %v, want 2 partitions", purged)
+ }
+ for _, sub := range []string{"market", "tools", "detail"} {
+ if _, statErr := os.Stat(filepath.Join(tmp, "default_default", sub)); !os.IsNotExist(statErr) {
+ t.Errorf("%s dir survived the purge (stat err = %v)", sub, statErr)
+ }
+ }
+ if _, statErr := os.Stat(filepath.Join(tmp, "wukong_default", "tools")); !os.IsNotExist(statErr) {
+ t.Errorf("second partition tools dir survived the purge (stat err = %v)", statErr)
+ }
+ if _, statErr := os.Stat(filepath.Join(tmp, "downloads", "dws-1.0.36.tar.gz")); statErr != nil {
+ t.Errorf("unrelated downloads data was removed: %v", statErr)
+ }
+}
+
+func TestPurgeDiscoveryDataMissingRootIsNoop(t *testing.T) {
+ s := NewStore(filepath.Join(t.TempDir(), "does-not-exist"))
+ purged, err := s.PurgeDiscoveryData()
+ if err != nil {
+ t.Fatalf("PurgeDiscoveryData() error = %v", err)
+ }
+ if len(purged) != 0 {
+ t.Errorf("PurgeDiscoveryData() purged = %v, want none", purged)
+ }
+}
From 4bc4b60dcad34221e13a53020f0ed58773b2d4c3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Wed, 10 Jun 2026 22:45:50 +0800
Subject: [PATCH 09/25] docs: cut 1.0.36 changelog (#453)
---
CHANGELOG.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index adec5e1c..83a32c6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,23 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
+## [1.0.36] - 2026-06-10
+
+This release closes out the poisoned-discovery-cache lock-out for good, with three layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), and (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
+
+**Escaping a locked-out older binary**: a binary ≤1.0.35 bricked by a poisoned cache cannot run `dws upgrade`. Either bypass the cache for one invocation with `DWS_CACHE_DIR=$(mktemp -d) dws upgrade`, or delete `~/.dws/cache//tools/` by hand, or reinstall via the install script. Once 1.0.36 is on the machine this never needs doing again.
+
+### Added
+
+- **`dws devdoc` — RAG-backed Open Platform doc search and error diagnosis** (#434; `internal/helpers/devdoc.go`, `internal/transport/client.go`) — `dws devdoc article search` now routes to the upstream `search_open_platform_docs_rag` tool, returning structured RAG/reference payloads (the CLI stays a thin invoker; no extra AI analysis layer). New `dws devdoc error diagnose` (alias `troubleshoot`) routes to `search_open_error_code_rag` for diagnosing DingTalk Open Platform API errors, with `--request-id` (hidden `--trace-id` kept for compatibility), `--error-code`, `--error-message`, `--api`, `--context`, `--query`, `--page`, `--size`. Transport-side: query parameters required by DingTalk MCP gateway URLs are preserved on the wire but their values are redacted from debug logs. Default MCP / skill hosts stay on production `https://mcp.dingtalk.com` (prepub remains runtime-configurable). Skill docs (mono + multi `dingtalk-devdoc`) and `docs/command-index.md` updated alongside.
+
+### Fixed
+
+- **CLI no longer bricks when the dynamic command build panics — degrades to built-in commands** (#447; `internal/app/legacy.go`) — `buildEnvelopeCommandsSafe` wraps the envelope-driven build in a local `recover()`. On panic the CLI logs it, prints a stderr hint, and falls back to the hardcoded helper commands, so `auth` / `cache` / `doctor` / `version` / `upgrade` and the helpers stay alive and `dws cache refresh` can rebuild the poisoned cache. Before this, the only recovery from the pre-1.0.32 lock-out class was manually deleting cache files; the duplicate-flag class itself had been fixed at the builder level, but any *future* panic class in the cache-driven build would have bricked the CLI again. Tests: `TestNewLegacyPublicCommandsPanicFallsBackToHelpers`, `TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath`.
+- **Envelope-driven flag registration no longer panics on the four known malformed-envelope shapes** (#449; `internal/compat/registry.go`) — while reproducing the lock-out byte-for-byte, four envelope shapes were found still forwarded to pflag calls that panic, each bricking every invocation: a flag named `params` / `json` colliding with the reserved payload flags (the original `flag redefined: params` — earlier dedup fixes covered the alias list and Detail-schema path but not the primary name); two bindings resolving to the same long flag name across bindings; two flags claiming the same shorthand; and a multi-character shorthand. Two small guards applied at every registration site (`ApplyBindings`, `registerPositionalAliasFlags`): `canRegisterFlag` skips duplicate/reserved long names (the value stays reachable via `--params`), and `safeShorthand` drops an invalid or already-taken shorthand while keeping the long flag. The trailing `--json` / `--params` registration is now idempotent. Defense in depth with #447: the escape hatch should never trigger for these known vectors. Test: `TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope` (5 table-driven vectors).
+- **Poisoned discovery cache now self-heals: quarantine + rebuild on panic, and `dws upgrade` clears discovery caches** (#452; `internal/app/legacy.go`, `internal/app/upgrade.go`, `internal/cache/store.go`) — #447's recovery is upgraded from "degrade and ask the user to run `dws cache refresh`" to a two-stage self-heal: on the first build panic the partition's discovery cache is moved aside to `.quarantined` (kept on disk for inspection; a previous quarantine is replaced so nothing accumulates — new `Store.QuarantinePartition`) and the build retried once against a fresh fetch. If the retry succeeds the user gets the full dynamic command tree with zero manual steps; only a second panic (remote envelope itself still poisoned, or offline) degrades to helper commands with the `cache refresh` hint. Additionally `dws upgrade` purges discovery-derived caches (`market` / `tools` / `detail` across all partitions — new `Store.PurgeDiscoveryData`) after a successful binary swap, leaving the co-located `downloads/` cache untouched, so an upgraded binary always rebuilds its command tree from fresh data instead of inheriting snapshots written by the old version. Tests: `internal/cache/store_quarantine_test.go`, rewritten `internal/app/legacy_panic_fallback_test.go` (self-heal success, double-panic degradation, no-cache no-op, happy path).
+- **`dws doc create` no longer produces a document with two identical headings** (#448; `internal/helpers/doc.go`) — the platform renders the document name as the page title, and LLM agents habitually repeat `# ` as the markdown body's first line despite the skill docs saying not to, so duplicate-heading documents kept appearing. The `doc create` helper (which wins the envelope merge via `preferLegacyLeaf`) now strips a leading ATX H1 whose text exactly equals `--name` (trimmed, case-insensitive) before forwarding to `create_document`, printing a stderr note so agents learn the convention. Deliberately conservative: only an exact match is removed (`# 背景` stays), ATX closing hashes are handled without over-trimming names ending in `#` (e.g. `C#`), H2+/setext headings are never touched, and a body that is nothing but the duplicate H1 omits the `markdown` param instead of sending an empty string. JSONML bodies are out of scope. Tests: `TestStripLeadingDuplicateTitleHeading` (9 cases) plus three end-to-end cobra tests asserting the exact `markdown` param sent.
+
## [1.0.35] - 2026-06-08
### Fixed
From 60ac0b409dc1ca45a019ce39370af3145a25f061 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Thu, 11 Jun 2026 09:16:36 +0800
Subject: [PATCH 10/25] fix(cli): guard canonical mcp tree against
poisoned-cache flag collisions (#454)
* fix(cli): guard canonical mcp tree against poisoned-cache flag collisions
The canonical 'dws mcp' tree is built from cached catalog data before the
legacy command build and before Cobra dispatches anything, so a pflag
panic there (a tool schema property named after the reserved --params
flag, as cached during the 1.0.32 incident) aborted every invocation --
including 'dws cache refresh' and 'dws upgrade' -- and sat outside all
three poisoned-cache guards (#447/#449/#452).
Two layers, mirroring the existing guards:
- applyFlagSpecs now skips reserved (--json/--params), duplicate and
alias-colliding flag names and sanitizes shorthands instead of letting
pflag panic; the skipped property stays reachable through the reserved
JSON payload flags (same degradation semantics as #449).
- newMCPCommand wraps the build in the #452 recover -> quarantine ->
retry-once -> degrade-to-stub sequence, so even an unforeseen panic
class no longer locks the CLI out.
* docs: amend 1.0.36 changelog for the re-cut with the canonical tree guard (#454)
---
CHANGELOG.md | 3 +-
internal/app/canonical_panic_fallback_test.go | 157 ++++++++++++++++++
internal/app/root.go | 70 +++++++-
internal/cli/canonical.go | 43 ++++-
internal/cli/canonical_flag_guard_test.go | 116 +++++++++++++
5 files changed, 380 insertions(+), 9 deletions(-)
create mode 100644 internal/app/canonical_panic_fallback_test.go
create mode 100644 internal/cli/canonical_flag_guard_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83a32c6a..1b3548d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [1.0.36] - 2026-06-10
-This release closes out the poisoned-discovery-cache lock-out for good, with three layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), and (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
+This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
**Escaping a locked-out older binary**: a binary ≤1.0.35 bricked by a poisoned cache cannot run `dws upgrade`. Either bypass the cache for one invocation with `DWS_CACHE_DIR=$(mktemp -d) dws upgrade`, or delete `~/.dws/cache//tools/` by hand, or reinstall via the install script. Once 1.0.36 is on the machine this never needs doing again.
@@ -21,6 +21,7 @@ This release closes out the poisoned-discovery-cache lock-out for good, with thr
- **CLI no longer bricks when the dynamic command build panics — degrades to built-in commands** (#447; `internal/app/legacy.go`) — `buildEnvelopeCommandsSafe` wraps the envelope-driven build in a local `recover()`. On panic the CLI logs it, prints a stderr hint, and falls back to the hardcoded helper commands, so `auth` / `cache` / `doctor` / `version` / `upgrade` and the helpers stay alive and `dws cache refresh` can rebuild the poisoned cache. Before this, the only recovery from the pre-1.0.32 lock-out class was manually deleting cache files; the duplicate-flag class itself had been fixed at the builder level, but any *future* panic class in the cache-driven build would have bricked the CLI again. Tests: `TestNewLegacyPublicCommandsPanicFallsBackToHelpers`, `TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath`.
- **Envelope-driven flag registration no longer panics on the four known malformed-envelope shapes** (#449; `internal/compat/registry.go`) — while reproducing the lock-out byte-for-byte, four envelope shapes were found still forwarded to pflag calls that panic, each bricking every invocation: a flag named `params` / `json` colliding with the reserved payload flags (the original `flag redefined: params` — earlier dedup fixes covered the alias list and Detail-schema path but not the primary name); two bindings resolving to the same long flag name across bindings; two flags claiming the same shorthand; and a multi-character shorthand. Two small guards applied at every registration site (`ApplyBindings`, `registerPositionalAliasFlags`): `canRegisterFlag` skips duplicate/reserved long names (the value stays reachable via `--params`), and `safeShorthand` drops an invalid or already-taken shorthand while keeping the long flag. The trailing `--json` / `--params` registration is now idempotent. Defense in depth with #447: the escape hatch should never trigger for these known vectors. Test: `TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope` (5 table-driven vectors).
- **Poisoned discovery cache now self-heals: quarantine + rebuild on panic, and `dws upgrade` clears discovery caches** (#452; `internal/app/legacy.go`, `internal/app/upgrade.go`, `internal/cache/store.go`) — #447's recovery is upgraded from "degrade and ask the user to run `dws cache refresh`" to a two-stage self-heal: on the first build panic the partition's discovery cache is moved aside to `.quarantined` (kept on disk for inspection; a previous quarantine is replaced so nothing accumulates — new `Store.QuarantinePartition`) and the build retried once against a fresh fetch. If the retry succeeds the user gets the full dynamic command tree with zero manual steps; only a second panic (remote envelope itself still poisoned, or offline) degrades to helper commands with the `cache refresh` hint. Additionally `dws upgrade` purges discovery-derived caches (`market` / `tools` / `detail` across all partitions — new `Store.PurgeDiscoveryData`) after a successful binary swap, leaving the co-located `downloads/` cache untouched, so an upgraded binary always rebuilds its command tree from fresh data instead of inheriting snapshots written by the old version. Tests: `internal/cache/store_quarantine_test.go`, rewritten `internal/app/legacy_panic_fallback_test.go` (self-heal success, double-panic degradation, no-cache no-op, happy path).
+- **Canonical `dws mcp` tree no longer escapes the poisoned-cache guards** (#454; `internal/cli/canonical.go`, `internal/app/root.go`) — the canonical tree is assembled from cached catalog data *before* the legacy command build, so a pflag panic there — a tool schema property named after the reserved `--params` flag, exactly what the 2026-05-25 incident cache contained — bypassed #447/#449/#452 entirely and still bricked every invocation, including on this release as originally cut. Two layers, mirroring the existing guards: `applyFlagSpecs` skips reserved (`--json`/`--params`), duplicate, and alias-colliding flag names and sanitizes shorthands (`canRegisterToolFlag` / `safeToolShorthand`; a skipped property stays reachable through the reserved JSON payload flags), and `newMCPCommand` wraps the build in the #452 recover → quarantine → retry-once → degrade-to-stub sequence. Verified against the preserved real poisoned cache: the original cut locks out on `--version` / `cache refresh` / `doctor`; this build self-heals on first run and `cache refresh` clears the poison. Tests: `internal/cli/canonical_flag_guard_test.go` (4 cases), `internal/app/canonical_panic_fallback_test.go` (4 cases mirroring the legacy fallback suite).
- **`dws doc create` no longer produces a document with two identical headings** (#448; `internal/helpers/doc.go`) — the platform renders the document name as the page title, and LLM agents habitually repeat `# ` as the markdown body's first line despite the skill docs saying not to, so duplicate-heading documents kept appearing. The `doc create` helper (which wins the envelope merge via `preferLegacyLeaf`) now strips a leading ATX H1 whose text exactly equals `--name` (trimmed, case-insensitive) before forwarding to `create_document`, printing a stderr note so agents learn the convention. Deliberately conservative: only an exact match is removed (`# 背景` stays), ATX closing hashes are handled without over-trimming names ending in `#` (e.g. `C#`), H2+/setext headings are never touched, and a body that is nothing but the duplicate H1 omits the `markdown` param instead of sending an empty string. JSONML bodies are out of scope. Tests: `TestStripLeadingDuplicateTitleHeading` (9 cases) plus three end-to-end cobra tests asserting the exact `markdown` param sent.
## [1.0.35] - 2026-06-08
diff --git a/internal/app/canonical_panic_fallback_test.go b/internal/app/canonical_panic_fallback_test.go
new file mode 100644
index 00000000..487ece8b
--- /dev/null
+++ b/internal/app/canonical_panic_fallback_test.go
@@ -0,0 +1,157 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+ "github.com/spf13/cobra"
+)
+
+// TestNewMCPCommandPanicDegradesToStub verifies the canonical-tree guard:
+// the `dws mcp` build runs BEFORE the legacy build and used to sit outside
+// every poisoned-cache guard, so a panic there (e.g. a tool schema property
+// named after the reserved --params flag) aborted every invocation. With no
+// on-disk cache to quarantine it must degrade to an inert stub instead.
+func TestNewMCPCommandPanicDegradesToStub(t *testing.T) {
+ t.Setenv(cli.CacheDirEnv, t.TempDir())
+
+ calls := 0
+ orig := buildMCPCommandFn
+ buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
+ calls++
+ panic("chat_permission_grant flag redefined: params")
+ }
+ t.Cleanup(func() { buildMCPCommandFn = orig })
+
+ var cmd *cobra.Command
+ captured := captureStderr(t, func() {
+ cmd = newMCPCommand(context.Background(), nil, nil, nil)
+ })
+
+ if cmd == nil || cmd.Name() != "mcp" {
+ t.Fatalf("newMCPCommand() = %v after build panic, want an 'mcp' stub", cmd)
+ }
+ if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "dws cache refresh") {
+ t.Errorf("stub RunE error = %v, want a 'dws cache refresh' hint", err)
+ }
+ if !strings.Contains(captured, "dws cache refresh") {
+ t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
+ }
+ if calls != 1 {
+ t.Errorf("canonical build attempts = %d, want 1 (no cache on disk, nothing to quarantine and retry)", calls)
+ }
+}
+
+// TestNewMCPCommandSelfHealsPoisonedCache verifies the self-heal path: when
+// the build panics AND a discovery cache exists on disk, the partition is
+// quarantined and the build retried once, so a fixed binary escapes the
+// lock-out with zero manual cache surgery.
+func TestNewMCPCommandSelfHealsPoisonedCache(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv(cli.CacheDirEnv, tmp)
+
+ store := cache.NewStore(tmp)
+ if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
+ t.Fatalf("SaveTools() error = %v", err)
+ }
+
+ calls := 0
+ orig := buildMCPCommandFn
+ buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
+ calls++
+ if calls == 1 {
+ panic("chat_permission_grant flag redefined: params")
+ }
+ return &cobra.Command{Use: "mcp", Short: "rebuilt-probe"}
+ }
+ t.Cleanup(func() { buildMCPCommandFn = orig })
+
+ var cmd *cobra.Command
+ captured := captureStderr(t, func() {
+ cmd = newMCPCommand(context.Background(), nil, nil, nil)
+ })
+
+ if calls != 2 {
+ t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
+ }
+ if cmd == nil || cmd.Short != "rebuilt-probe" {
+ t.Errorf("newMCPCommand() did not return the rebuilt tree, got %v", cmd)
+ }
+ quarantines, _ := filepath.Glob(filepath.Join(tmp, "*.quarantined"))
+ if len(quarantines) != 1 {
+ t.Fatalf("quarantine dirs = %v, want exactly 1", quarantines)
+ }
+ if !strings.Contains(captured, "rebuilding from a fresh fetch") {
+ t.Errorf("stderr = %q, want a note about rebuilding from a fresh fetch", captured)
+ }
+}
+
+// TestNewMCPCommandSecondPanicDegradesToStub verifies the final safety net:
+// if the rebuild after quarantine panics again, the stub is returned and the
+// `dws cache refresh` hint kept.
+func TestNewMCPCommandSecondPanicDegradesToStub(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv(cli.CacheDirEnv, tmp)
+
+ store := cache.NewStore(tmp)
+ if err := store.SaveTools(editionPartition(), "poisoned-server", cache.ToolsSnapshot{ServerKey: "poisoned-server"}); err != nil {
+ t.Fatalf("SaveTools() error = %v", err)
+ }
+
+ calls := 0
+ orig := buildMCPCommandFn
+ buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
+ calls++
+ panic("chat_permission_grant flag redefined: params")
+ }
+ t.Cleanup(func() { buildMCPCommandFn = orig })
+
+ var cmd *cobra.Command
+ captured := captureStderr(t, func() {
+ cmd = newMCPCommand(context.Background(), nil, nil, nil)
+ })
+
+ if calls != 2 {
+ t.Fatalf("canonical build attempts = %d, want 2 (initial + retry after quarantine)", calls)
+ }
+ if cmd == nil || cmd.Name() != "mcp" {
+ t.Fatalf("newMCPCommand() = %v after repeated panics, want an 'mcp' stub", cmd)
+ }
+ if !strings.Contains(captured, "dws cache refresh") {
+ t.Errorf("stderr = %q, want a hint mentioning 'dws cache refresh'", captured)
+ }
+}
+
+// TestNewMCPCommandNoPanicKeepsCanonicalPath ensures the guard is transparent
+// on the happy path.
+func TestNewMCPCommandNoPanicKeepsCanonicalPath(t *testing.T) {
+ orig := buildMCPCommandFn
+ buildMCPCommandFn = func(context.Context, cli.CatalogLoader, executor.Runner, *pipeline.Engine) *cobra.Command {
+ return &cobra.Command{Use: "mcp", Short: "canonical-probe"}
+ }
+ t.Cleanup(func() { buildMCPCommandFn = orig })
+
+ cmd := newMCPCommand(context.Background(), nil, nil, nil)
+ if cmd == nil || cmd.Short != "canonical-probe" {
+ t.Errorf("newMCPCommand() lost the canonical command, got %v", cmd)
+ }
+}
diff --git a/internal/app/root.go b/internal/app/root.go
index 86726148..310b25df 100644
--- a/internal/app/root.go
+++ b/internal/app/root.go
@@ -682,8 +682,76 @@ func newGenerateSkillsCommand() *cobra.Command {
return cmd
}
+// buildMCPCommandFn is a test seam for newMCPCommand so a panic in the
+// catalog-driven canonical build can be simulated without crafting a
+// poisoned on-disk cache.
+var buildMCPCommandFn = cli.NewMCPCommand
+
+// newMCPCommand builds the canonical `dws mcp` tree, self-healing a poisoned
+// cache when the build panics and degrading to an inert stub if that also
+// fails.
+//
+// Why this guard exists: the canonical tree is assembled from cached catalog
+// data BEFORE the legacy command build and before Cobra dispatches anything,
+// so a panic here (e.g. a tool schema property named after the reserved
+// --params flag, as cached during the 1.0.32 incident) used to abort every
+// invocation — including `dws cache refresh` and `dws upgrade` — and was NOT
+// covered by the legacy-path guards (#447/#452). Same two-staged recovery as
+// buildEnvelopeCommandsSafe: quarantine the partition, retry once against a
+// fresh fetch, then degrade with a `dws cache refresh` hint.
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
- return cli.NewMCPCommand(ctx, loader, runner, engine)
+ cmd, panicked := tryBuildMCPCommand(ctx, loader, runner, engine)
+ if panicked == nil {
+ return cmd
+ }
+ slog.Error("newMCPCommand: canonical command build panicked", "panic", panicked)
+
+ quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
+ if qErr != nil {
+ slog.Error("newMCPCommand: failed to quarantine discovery cache", "error", qErr)
+ }
+ if quarantined != "" {
+ fmt.Fprintf(os.Stderr,
+ "Warning: building canonical commands from the local discovery cache failed: %v\n"+
+ "The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
+ panicked, quarantined)
+ cmd, panicked = tryBuildMCPCommand(ctx, loader, runner, engine)
+ if panicked == nil {
+ fmt.Fprintln(os.Stderr, "Canonical commands rebuilt successfully.")
+ return cmd
+ }
+ slog.Error("newMCPCommand: rebuild after cache quarantine panicked again, degrading to a stub", "panic", panicked)
+ }
+
+ fmt.Fprintf(os.Stderr,
+ "Warning: building canonical commands from the local discovery cache failed: %v\n"+
+ "The 'dws mcp' surface is temporarily unavailable; other commands still work.\n"+
+ "Run 'dws cache refresh' to rebuild the cache.\n", panicked)
+ buildErr := apperrors.NewInternal(fmt.Sprintf("canonical command build failed: %v; run 'dws cache refresh'", panicked))
+ stub := &cobra.Command{
+ Use: "mcp",
+ Short: "Canonical MCP-derived CLI surface (unavailable)",
+ Hidden: true,
+ Args: cobra.ArbitraryArgs,
+ DisableAutoGenTag: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return buildErr
+ },
+ }
+ return stub
+}
+
+// tryBuildMCPCommand runs one attempt of the canonical build, converting a
+// panic into a return value so the caller can decide between self-heal and
+// degradation.
+func tryBuildMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) (cmd *cobra.Command, panicked any) {
+ defer func() {
+ if r := recover(); r != nil {
+ cmd = nil
+ panicked = r
+ }
+ }()
+ return buildMCPCommandFn(ctx, loader, runner, engine), nil
}
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go
index 1ddbf4f0..8ad31862 100644
--- a/internal/cli/canonical.go
+++ b/internal/cli/canonical.go
@@ -534,6 +534,34 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner
return cmd
}
+// canRegisterToolFlag reports whether a long flag named name can be
+// registered on cmd without panicking pflag ("flag redefined"). The reserved
+// payload names are excluded too: newToolCommand unconditionally registers
+// --json/--params before the spec loop. Tool schemas are remote data — a
+// property named after a reserved or already-registered flag must degrade to
+// "flag unavailable" (the value stays reachable through --json/--params),
+// never abort the process. Mirrors internal/compat's canRegisterFlag.
+func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
+ if name == "" || name == "json" || name == "params" {
+ return false
+ }
+ return cmd.Flags().Lookup(name) == nil
+}
+
+// safeToolShorthand returns short when it is a single-character shorthand not
+// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
+// pflag panics on both multi-character and duplicate shorthands.
+func safeToolShorthand(cmd *cobra.Command, short string) string {
+ short = strings.TrimSpace(short)
+ if len(short) != 1 {
+ return ""
+ }
+ if cmd.Flags().ShorthandLookup(short) != nil {
+ return ""
+ }
+ return short
+}
+
func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
for _, spec := range specs {
usage := spec.Description
@@ -541,41 +569,42 @@ func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
usage = fmt.Sprintf("Override %s", spec.PropertyName)
}
primary := strings.TrimSpace(spec.FlagName)
- if primary == "" {
+ if !canRegisterToolFlag(cmd, primary) {
continue
}
+ shorthand := safeToolShorthand(cmd, spec.Shorthand)
alias := strings.TrimSpace(spec.Alias)
- if alias == primary {
+ if alias == primary || !canRegisterToolFlag(cmd, alias) {
alias = ""
}
switch spec.Kind {
case flagString, flagJSON:
- cmd.Flags().StringP(primary, spec.Shorthand, "", usage)
+ cmd.Flags().StringP(primary, shorthand, "", usage)
if alias != "" {
cmd.Flags().String(alias, "", usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagInteger:
- cmd.Flags().IntP(primary, spec.Shorthand, 0, usage)
+ cmd.Flags().IntP(primary, shorthand, 0, usage)
if alias != "" {
cmd.Flags().Int(alias, 0, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagNumber:
- cmd.Flags().Float64P(primary, spec.Shorthand, 0, usage)
+ cmd.Flags().Float64P(primary, shorthand, 0, usage)
if alias != "" {
cmd.Flags().Float64(alias, 0, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagBoolean:
- cmd.Flags().BoolP(primary, spec.Shorthand, false, usage)
+ cmd.Flags().BoolP(primary, shorthand, false, usage)
if alias != "" {
cmd.Flags().Bool(alias, false, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
}
case flagStringArray, flagIntegerList, flagNumberList, flagBooleanList:
- cmd.Flags().StringSliceP(primary, spec.Shorthand, nil, usage)
+ cmd.Flags().StringSliceP(primary, shorthand, nil, usage)
if alias != "" {
cmd.Flags().StringSlice(alias, nil, usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
diff --git a/internal/cli/canonical_flag_guard_test.go b/internal/cli/canonical_flag_guard_test.go
new file mode 100644
index 00000000..9db8d6b0
--- /dev/null
+++ b/internal/cli/canonical_flag_guard_test.go
@@ -0,0 +1,116 @@
+// 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 cli
+
+import (
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+// newToolCommandFixture mirrors the flag environment of newToolCommand: the
+// reserved payload flags are registered before the spec loop runs.
+func newToolCommandFixture() *cobra.Command {
+ cmd := &cobra.Command{Use: "probe"}
+ cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
+ cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
+ return cmd
+}
+
+// TestApplyFlagSpecsSkipsReservedNames locks in the fix for the 1.0.32-class
+// lock-out: a tool schema property named after a reserved payload flag
+// ("params", as cached during the chat_permission_grant incident, or "json")
+// must be skipped instead of panicking pflag ("flag redefined") — that panic
+// fires while the canonical tree is assembled, before Cobra dispatches
+// anything, and used to kill every invocation including `dws cache refresh`
+// and `dws upgrade`.
+func TestApplyFlagSpecsSkipsReservedNames(t *testing.T) {
+ t.Parallel()
+
+ cmd := newToolCommandFixture()
+ applyFlagSpecs(cmd, []FlagSpec{
+ {PropertyName: "params", FlagName: "params", Kind: flagString, Description: "命令授权参数"},
+ {PropertyName: "json", FlagName: "json", Kind: flagString},
+ {PropertyName: "scope", FlagName: "scope", Kind: flagString},
+ })
+
+ if cmd.Flags().Lookup("scope") == nil {
+ t.Errorf("non-colliding flag --scope was not registered")
+ }
+ // The reserved flags must keep their payload usage strings, proving the
+ // schema-derived specs did not touch them.
+ if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
+ t.Errorf("--params usage = %q, want the reserved payload usage", got)
+ }
+}
+
+// TestApplyFlagSpecsSkipsDuplicates covers duplicate property names within a
+// single tool schema (or a spec colliding with an already-applied one).
+func TestApplyFlagSpecsSkipsDuplicates(t *testing.T) {
+ t.Parallel()
+
+ cmd := newToolCommandFixture()
+ applyFlagSpecs(cmd, []FlagSpec{
+ {PropertyName: "scope", FlagName: "scope", Kind: flagString, Description: "first"},
+ {PropertyName: "scope", FlagName: "scope", Kind: flagBoolean, Description: "second"},
+ })
+
+ flag := cmd.Flags().Lookup("scope")
+ if flag == nil {
+ t.Fatalf("--scope was not registered at all")
+ }
+ if flag.Usage != "first" {
+ t.Errorf("--scope usage = %q, want the first spec to win", flag.Usage)
+ }
+}
+
+// TestApplyFlagSpecsSkipsCollidingAlias verifies an alias colliding with a
+// reserved or existing flag is dropped while the primary still registers.
+func TestApplyFlagSpecsSkipsCollidingAlias(t *testing.T) {
+ t.Parallel()
+
+ cmd := newToolCommandFixture()
+ applyFlagSpecs(cmd, []FlagSpec{
+ {PropertyName: "scope", FlagName: "scope", Alias: "params", Kind: flagString},
+ })
+
+ if cmd.Flags().Lookup("scope") == nil {
+ t.Errorf("primary flag --scope was not registered when its alias collided")
+ }
+ if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
+ t.Errorf("--params usage = %q, alias overwrote the reserved payload flag", got)
+ }
+}
+
+// TestApplyFlagSpecsSanitizesShorthand verifies multi-character and duplicate
+// shorthands (both pflag panics) degrade to long-flag-only registration.
+func TestApplyFlagSpecsSanitizesShorthand(t *testing.T) {
+ t.Parallel()
+
+ cmd := newToolCommandFixture()
+ applyFlagSpecs(cmd, []FlagSpec{
+ {PropertyName: "alpha", FlagName: "alpha", Shorthand: "ab", Kind: flagString},
+ {PropertyName: "beta", FlagName: "beta", Shorthand: "s", Kind: flagString},
+ {PropertyName: "gamma", FlagName: "gamma", Shorthand: "s", Kind: flagString},
+ })
+
+ for _, name := range []string{"alpha", "beta", "gamma"} {
+ if cmd.Flags().Lookup(name) == nil {
+ t.Errorf("--%s was not registered", name)
+ }
+ }
+ if flag := cmd.Flags().ShorthandLookup("s"); flag == nil || flag.Name != "beta" {
+ t.Errorf("shorthand -s should stay bound to the first claimant --beta, got %v", flag)
+ }
+}
From 2a82d073110d77a4dd6a2423470c34cc76b7f920 Mon Sep 17 00:00:00 2001
From: xuan
Date: Thu, 11 Jun 2026 19:21:25 +0800
Subject: [PATCH 11/25] fix(pat): support batch agentCode and guarded grants
(#455)
* fix(pat): carry agentCode in batch auth args
* fix(pat): let core default missing agent code
* fix(pat): require yes for batch grants
* test(pat): cover cli authorization matrix
* fix(pat): keep canonical agent code env only
---------
Co-authored-by: shangguanxuan.sgx
---
internal/app/runner.go | 5 +-
internal/app/runner_test.go | 11 +
internal/auth/channel.go | 37 +-
internal/pat/browser_policy.go | 2 +-
internal/pat/browser_policy_test.go | 28 +
internal/pat/chmod.go | 230 +++++--
internal/pat/chmod_test.go | 788 +++++++++++++++++++++++-
internal/pat/pat.go | 11 +-
test/unit/pat_host_owned_signal_test.go | 22 +-
9 files changed, 1039 insertions(+), 95 deletions(-)
diff --git a/internal/app/runner.go b/internal/app/runner.go
index eb026b15..bc4b48dd 100644
--- a/internal/app/runner.go
+++ b/internal/app/runner.go
@@ -111,7 +111,7 @@ func logHostOwnedPATDecisionOnce() {
hostOwnedPATDecisionOnce.Do(func() {
slog.Debug("runtime.host_owned_pat",
"hostOwned", authpkg.HostOwnsPATFlow(),
- "agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
+ "agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(),
)
})
}
@@ -687,9 +687,10 @@ func resolveIdentityHeaders() map[string]string {
if sessionID == "" {
sessionID = os.Getenv(envRewindSessionID)
}
+ agentCode, _ := authpkg.AgentCodeFromEnv()
envHeaders := map[string]string{
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
- "x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
+ "x-dingtalk-dws-agent-code": agentCode,
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
"x-dingtalk-session-id": sessionID,
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go
index fd3fd673..1327e567 100644
--- a/internal/app/runner_test.go
+++ b/internal/app/runner_test.go
@@ -328,6 +328,17 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
}
}
+func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
+ setupRuntimeCommandTest(t)
+ t.Setenv(authpkg.AgentCodeEnv, "")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", " compat ")
+
+ headers := resolveIdentityHeaders()
+ if got := headers["x-dingtalk-dws-agent-code"]; got != "" {
+ t.Fatalf("x-dingtalk-dws-agent-code = %q, want empty because reversed env is ignored", got)
+ }
+}
+
func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(envDingtalkSessionID, "ding-session")
diff --git a/internal/auth/channel.go b/internal/auth/channel.go
index e9c1f49d..4b8315a3 100644
--- a/internal/auth/channel.go
+++ b/internal/auth/channel.go
@@ -19,19 +19,38 @@ import (
)
const (
- // AgentCodeEnv is the sole per-spawn environment variable the host injects
- // to declare "this process is driven by a third-party Agent host, render
- // authorization UI yourselves".
+ // AgentCodeEnv is the primary per-spawn environment variable the host
+ // injects to declare "this process is driven by a third-party Agent host,
+ // render authorization UI yourselves".
AgentCodeEnv = "DINGTALK_DWS_AGENTCODE"
)
+// AgentCodeFromEnv returns the effective host agent code and the env name that
+// supplied it.
+//
+// Keep the public env surface intentionally single-spelled. The reversed
+// DWS_DINGTALK_AGENTCODE draft name is not consumed, so host-owned PAT mode,
+// gateway identity headers, and `pat chmod --agentCode` fallback all agree on
+// the same stable signal: DINGTALK_DWS_AGENTCODE.
+func AgentCodeFromEnv() (string, string) {
+ if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" {
+ return value, AgentCodeEnv
+ }
+ return "", ""
+}
+
+func AgentCodeEnvPresent() bool {
+ value, _ := AgentCodeFromEnv()
+ return value != ""
+}
+
// HostOwnsPATFlow reports whether the current process is running under a
// third-party Agent host that will render the PAT authorization card
-// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being
-// non-empty. The CLI deliberately does not consult any other signal
-// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this
-// decision so that server-side routing tags and the host-owned UI contract
-// remain independent concerns.
+// itself. The trigger is DINGTALK_DWS_AGENTCODE being non-empty. The CLI
+// deliberately does not consult any other signal (DINGTALK_AGENT /
+// DWS_CHANNEL / the wire claw-type header) for this decision so that
+// server-side routing tags and the host-owned UI contract remain independent
+// concerns.
func HostOwnsPATFlow() bool {
- return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != ""
+ return AgentCodeEnvPresent()
}
diff --git a/internal/pat/browser_policy.go b/internal/pat/browser_policy.go
index c6c474b3..5667c9fc 100644
--- a/internal/pat/browser_policy.go
+++ b/internal/pat/browser_policy.go
@@ -102,7 +102,7 @@ func saveBrowserPolicy(configDir string, policy *BrowserPolicy) error {
}
func ResolveBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
- agentCode, err := resolveAgentCode(explicitAgentCode, false)
+ agentCode, err := resolveAgentCode(explicitAgentCode)
if err != nil {
return BrowserPolicySelection{}, err
}
diff --git a/internal/pat/browser_policy_test.go b/internal/pat/browser_policy_test.go
index 7819f2c3..146fb352 100644
--- a/internal/pat/browser_policy_test.go
+++ b/internal/pat/browser_policy_test.go
@@ -16,6 +16,7 @@ package pat
import (
"bytes"
"encoding/json"
+ "strings"
"testing"
)
@@ -235,3 +236,30 @@ func TestBrowserPolicyCommand_NoAgentCodeWritesDefaultEvenWhenEnvSet(t *testing.
t.Fatalf("len(policy.Agents) = %d, want 0", got)
}
}
+
+func TestBrowserPolicyCommand_RequiresEnabledFlag(t *testing.T) {
+ configDir := t.TempDir()
+ t.Setenv("DWS_CONFIG_DIR", configDir)
+
+ cmd := newBrowserPolicyCommand()
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(&stdout)
+ cmd.SetArgs([]string{"--agentCode", "agt-command"})
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("browser-policy Execute() error = nil, want missing --enabled error")
+ }
+ if !strings.Contains(err.Error(), "--enabled is required") {
+ t.Fatalf("browser-policy error = %q, want --enabled requirement", err.Error())
+ }
+
+ policy, loadErr := LoadBrowserPolicy(configDir)
+ if loadErr != nil {
+ t.Fatalf("LoadBrowserPolicy error = %v", loadErr)
+ }
+ if policy.Default != nil || len(policy.Agents) != 0 {
+ t.Fatalf("policy was modified despite missing --enabled: %#v", policy)
+ }
+}
diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go
index c5cafdcb..2e7f0b68 100644
--- a/internal/pat/chmod.go
+++ b/internal/pat/chmod.go
@@ -25,6 +25,7 @@ import (
"github.com/fatih/color"
"github.com/spf13/cobra"
+ authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -58,23 +59,24 @@ func resolveSessionIDFromEnv() string {
return ""
}
-// agentCodeEnv is the canonical (and only) environment variable name
-// used as a per-shell fallback for the --agentCode flag on `dws pat *`
-// commands.
+// agentCodeEnv is the canonical environment variable name used as a
+// per-shell fallback for the --agentCode flag on `dws pat *` commands.
//
// Why: agent hosts may set their business agent code once when spawning
// a long-lived shell / sub-process. Exposing DINGTALK_DWS_AGENTCODE lets
// the host export the code once and let the CLI resolve it on every pat
// subcommand. The flag always wins when both are set so scripted one-offs
-// remain deterministic. When neither flag nor env is set, the request is
-// sent without agentCode and lippi-pat-core applies its default agentCode.
+// remain deterministic. When neither flag nor env is set, `pat chmod` omits
+// agentCode and lets the PAT server apply its open-source default. Batch PAT
+// tools receive the resolved agentCode in arguments when present, while the CLI
+// also keeps exporting it through env for older gateway paths.
//
-// Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE
-// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was
-// hard-removed once the public integration surface landed on
-// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a
-// silent fallback.
-const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
+// Namespace note: keep this as a single-spelled public contract. The reversed
+// draft name DWS_DINGTALK_AGENTCODE and legacy names such as DWS_AGENTCODE /
+// DINGTALK_AGENTCODE / REWIND_AGENTCODE are explicitly NOT consumed.
+const (
+ agentCodeEnv = authpkg.AgentCodeEnv
+)
// agentCodePattern is the validation regex for any --agentCode value
// resolved from either the flag or the agent-code env var. It matches
@@ -84,16 +86,12 @@ const agentCodeEnv = "DINGTALK_DWS_AGENTCODE"
// argument.
var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
-// resolveAgentCodeFromEnv returns the fallback agent code from the
-// canonical DINGTALK_DWS_AGENTCODE env var. The second return value
-// reports the env name that was consumed (for error attribution); it
-// is "" when the env is unset or blank. No legacy aliases are honored.
+// resolveAgentCodeFromEnv returns the fallback agent code from the canonical
+// DINGTALK_DWS_AGENTCODE env var. The second return value reports the env name
+// that was consumed (for error attribution); it is "" when the env var is unset
+// or blank.
func resolveAgentCodeFromEnv() (string, string) {
- primary := strings.TrimSpace(os.Getenv(agentCodeEnv))
- if primary != "" {
- return primary, agentCodeEnv
- }
- return "", ""
+ return authpkg.AgentCodeFromEnv()
}
// validateAgentCode rejects agent codes that would be ambiguous or unsafe
@@ -111,34 +109,34 @@ func validateAgentCode(code string) error {
return nil
}
-// resolveAgentCode implements the canonical two-tier lookup for
-// --agentCode:
+// resolveAgentCode implements the canonical optional lookup for --agentCode:
//
// 1. explicit --agentCode flag value (highest priority; wins over env)
// 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback)
-// 3. empty ("") when required=false; typed error when required=true.
+// 3. empty ("") so PAT-core can apply its open-source default.
//
// Any non-empty resolved value is validated via validateAgentCode, so
// callers never have to re-validate.
-func resolveAgentCode(flagVal string, required bool) (string, error) {
+func resolveAgentCode(flagVal string) (string, error) {
code := strings.TrimSpace(flagVal)
envSource := ""
if code == "" {
code, envSource = resolveAgentCodeFromEnv()
}
if code == "" {
- if required {
- return "", fmt.Errorf(
- "flag --agentCode is required (or set env %s)\n hint: dws pat chmod ... --agentCode \n hint: export %s=",
- agentCodeEnv, agentCodeEnv)
- }
return "", nil
}
if err := validateAgentCode(code); err != nil {
if envSource != "" {
- return "", fmt.Errorf("%s env: %w", envSource, err)
+ return "", apperrors.NewValidation(
+ fmt.Sprintf("%s env: %v", envSource, err),
+ apperrors.WithReason("invalid_agent_code"),
+ )
}
- return "", err
+ return "", apperrors.NewValidation(
+ err.Error(),
+ apperrors.WithReason("invalid_agent_code"),
+ )
}
return code, nil
}
@@ -159,8 +157,25 @@ const (
patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED"
patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported"
+ patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD"
+ patForgedIdentityCodeLower = "pat_forged_identity_field"
)
+var patBatchMetadataContractCodes = map[string]bool{
+ "pat_batch_auth_metadata_required": true,
+ "pat_batch_scope_not_declared": true,
+ "pat_batch_product_not_declared": true,
+}
+
+var patBatchIdentityArgumentKeys = map[string]bool{
+ "agentCode": true,
+ "sessionId": true,
+ "orgId": true,
+ "uid": true,
+ "source": true,
+ "caller": true,
+}
+
var validGrantTypes = map[string]bool{
"once": true,
"session": true,
@@ -189,7 +204,21 @@ scope 格式: .:
grantType 规则:
once 一次性,执行一次后自动失效
session 当前会话有效(默认),需要 --session-id
- permanent 永久有效`,
+ permanent 永久有效
+
+批量授权:
+ dws pat chmod 支持一次传多个 scope 直接批量授予。
+ 也支持 --products / --product 按产品编码批量展开 scope 模板,
+ --domains / --domain 按产品域批量展开 scope 模板,
+ --recommend 使用服务端推荐 scope 集合。
+ 使用产品 / 域 / 推荐集合时,CLI 会先生成 batch plan,确认
+ selected / skipped / pending,再对 selected scopes 执行 batch grant;
+ --dry-run 只返回授权计划,不写入授权。真正执行批量授权必须显式
+ 添加 --yes;未加 --yes 时 CLI 会阻断并提示 agent 先确认。
+
+agentCode 配置:
+ 可通过 --agentCode 或 DINGTALK_DWS_AGENTCODE
+ 指定;未传 agentCode 时,CLI 会省略该字段并由服务端默认兜底。`,
Args: func(cmd *cobra.Command, args []string) error {
productCodes := collectChmodProductCodes(productFlags, productsFlag, domainFlags, domainsFlag)
if len(args) > 0 || recommend || len(productCodes) > 0 {
@@ -199,12 +228,14 @@ grantType 规则:
},
Example: ` dws pat chmod aitable.record:read --grant-type session --session-id session-xxx
dws pat chmod chat.message:list --grant-type once
- dws pat chmod aitable.record:read aitable.record:write --grant-type permanent
- dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx
- dws pat chmod --recommend --grant-type session --session-id session-xxx`,
+ dws pat chmod aitable.record:read aitable.record:write --grant-type permanent --yes
+ dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json
+ dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx --yes
+ dws pat chmod --domain calendar --domain chat --grant-type once --yes
+ dws pat chmod --recommend --grant-type session --session-id session-xxx --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
flagVal, _ := cmd.Flags().GetString("agentCode")
- agentCode, err := resolveAgentCode(flagVal, false)
+ agentCode, err := resolveAgentCode(flagVal)
if err != nil {
return err
}
@@ -239,8 +270,6 @@ grantType 规则:
fmt.Printf("%-16s%s\n", "Tool:", patBatchGrantToolName)
if agentCode != "" {
fmt.Printf("%-16s%s\n", "AgentCode:", agentCode)
- } else {
- fmt.Printf("%-16s%s\n", "AgentCode:", "(server default)")
}
fmt.Printf("%-16s%v\n", "Scope:", scopes)
fmt.Printf("%-16s%s\n", "GrantType:", grantType)
@@ -267,6 +296,11 @@ grantType 规则:
if len(scopes) == 0 {
return handleToolResult(cmd, c, planResult)
}
+ if err := requireBatchGrantConfirmation(cmd, true, scopes); err != nil {
+ return err
+ }
+ } else if err := requireBatchGrantConfirmation(cmd, false, scopes); err != nil {
+ return err
}
batchArgs := map[string]any{
"scopes": scopes,
@@ -315,18 +349,37 @@ grantType 规则:
}
chmodCmd.Flags().String("agentCode", "",
- "Agent 唯一标识(可选;不填则由服务端写入默认 AgentCode;env DINGTALK_DWS_AGENTCODE 可注入,flag 优先)")
+ "Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)")
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
- chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价")
- chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔")
- chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权")
- chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔")
- chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权")
+ chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价;执行批量授权需 --yes")
+ chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔;执行批量授权需 --yes")
+ chmodCmd.Flags().StringArrayVar(&domainFlags, "domain", nil, "产品域/产品编码,可重复;按产品 scope 模板批量授权;执行授权需 --yes")
+ chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔;执行批量授权需 --yes")
+ chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权;执行授权需 --yes")
return chmodCmd
}
+func requireBatchGrantConfirmation(cmd *cobra.Command, usesPlan bool, scopes []string) error {
+ if !usesPlan && len(scopes) <= 1 {
+ return nil
+ }
+ if commandBoolFlag(cmd, "yes") {
+ return nil
+ }
+ return apperrors.NewValidation(
+ "batch PAT authorization blocked: explicit user confirmation is required; rerun with --yes only after the user approves the batch grant",
+ apperrors.WithReason("pat_batch_requires_yes"),
+ apperrors.WithHint("先执行 dws pat chmod ... --dry-run --format json 查看 selected/skipped/pending;用户明确确认后再追加 --yes 执行批量授权。"),
+ apperrors.WithActions(
+ "dws pat chmod ... --grant-type once --yes",
+ "dws pat chmod --products --grant-type once --yes",
+ "dws pat chmod --recommend --grant-type once --yes",
+ ),
+ )
+}
+
func collectChmodProductCodes(groups ...[]string) []string {
seen := map[string]bool{}
result := make([]string, 0)
@@ -388,13 +441,11 @@ func callPATBatchGrantWithLegacyFallback(
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
- result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
- return c.CallTool(ctx, "pat", patBatchGrantToolName, batchArgs)
- })
- if err == nil && !isPATBatchUnsupportedResult(result) {
+ result, err := callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchGrantToolName, batchArgs)
+ if err == nil && !isPATBatchFallbackResult(result) {
return result, nil
}
- if err != nil && !isPATBatchUnsupportedError(err) && !isToolNotRegisteredError(err) {
+ if err != nil && !isPATBatchFallbackError(err) && !isToolNotRegisteredError(err) {
return nil, err
}
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
@@ -414,8 +465,19 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess
if c == nil {
return nil, fmt.Errorf("internal error: tool runtime not initialized")
}
+ return callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchPlanToolName, args)
+}
+
+func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCaller, agentCode, sessionID, toolName string, args map[string]any) (*edition.ToolResult, error) {
+ result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
+ return c.CallTool(ctx, "pat", toolName, args)
+ })
+ if !shouldRetryPATBatchWithoutIdentityArgs(result, err, args) {
+ return result, err
+ }
+ compatArgs := cloneWithoutPATIdentityArgs(args)
return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) {
- return c.CallTool(ctx, "pat", patBatchPlanToolName, args)
+ return c.CallTool(ctx, "pat", toolName, compatArgs)
})
}
@@ -485,6 +547,25 @@ func firstToolResultText(result *edition.ToolResult) string {
}
func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
+ return patBatchResultHasCode(result, func(code string) bool {
+ return strings.EqualFold(code, patBatchUnsupportedCode)
+ })
+}
+
+func isPATBatchFallbackResult(result *edition.ToolResult) bool {
+ return patBatchResultHasCode(result, func(code string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(code))
+ return strings.EqualFold(code, patBatchUnsupportedCode) || patBatchMetadataContractCodes[normalized]
+ })
+}
+
+func isPATForgedIdentityResult(result *edition.ToolResult) bool {
+ return patBatchResultHasCode(result, func(code string) bool {
+ return strings.EqualFold(code, patForgedIdentityCode)
+ })
+}
+
+func patBatchResultHasCode(result *edition.ToolResult, matches func(string) bool) bool {
text := firstToolResultText(result)
if text == "" {
return false
@@ -494,7 +575,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool {
return false
}
for _, key := range []string{"code", "errorCode", "error_code"} {
- if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) {
+ if code, ok := body[key].(string); ok && matches(strings.TrimSpace(code)) {
return true
}
}
@@ -505,6 +586,53 @@ func isPATBatchUnsupportedError(err error) bool {
return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower)
}
+func isPATBatchFallbackError(err error) bool {
+ if isPATBatchUnsupportedError(err) {
+ return true
+ }
+ text := normalizedPATErrorText(err)
+ for code := range patBatchMetadataContractCodes {
+ if strings.Contains(text, code) {
+ return true
+ }
+ }
+ return false
+}
+
+func isPATForgedIdentityError(err error) bool {
+ return err != nil && strings.Contains(normalizedPATErrorText(err), patForgedIdentityCodeLower)
+}
+
+func shouldRetryPATBatchWithoutIdentityArgs(result *edition.ToolResult, err error, args map[string]any) bool {
+ if !hasPATIdentityArgs(args) {
+ return false
+ }
+ if err != nil {
+ return isPATForgedIdentityError(err)
+ }
+ return isPATForgedIdentityResult(result)
+}
+
+func hasPATIdentityArgs(args map[string]any) bool {
+ for key := range args {
+ if patBatchIdentityArgumentKeys[key] {
+ return true
+ }
+ }
+ return false
+}
+
+func cloneWithoutPATIdentityArgs(args map[string]any) map[string]any {
+ out := make(map[string]any, len(args))
+ for key, value := range args {
+ if patBatchIdentityArgumentKeys[key] {
+ continue
+ }
+ out[key] = value
+ }
+ return out
+}
+
// callPATToolWithLegacyFallback invokes the canonical PAT grant tool first,
// then silently retries the legacy Chinese alias when the server has not
// registered the canonical tool yet. The retry intentionally emits no stderr
diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go
index 57443096..14d8294f 100644
--- a/internal/pat/chmod_test.go
+++ b/internal/pat/chmod_test.go
@@ -29,8 +29,8 @@ import (
)
// fakeToolCaller captures the toolArgs passed to CallTool so tests can
-// assert how the two-tier --agentCode / DINGTALK_DWS_AGENTCODE / error
-// resolver feeds into the outgoing MCP argv.
+// assert how the optional --agentCode / DINGTALK_DWS_AGENTCODE resolver feeds
+// into the outgoing batch request.
type fakeToolCaller struct {
mu sync.Mutex
dryRun bool
@@ -68,8 +68,11 @@ func (f *fakeToolCaller) Format() string { return "json" }
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
type recordedToolCall struct {
- tool string
- args map[string]any
+ tool string
+ args map[string]any
+ agentEnv string
+ sessionEnv string
+ dingSessionEnv string
}
type fallbackToolCaller struct {
@@ -198,6 +201,7 @@ func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
type sequenceToolCaller struct {
calls []recordedToolCall
responses []string
+ errs []error
dryRun bool
}
@@ -207,6 +211,12 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri
copied[k] = v
}
s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied})
+ s.calls[len(s.calls)-1].agentEnv = os.Getenv(agentCodeEnv)
+ s.calls[len(s.calls)-1].sessionEnv = os.Getenv(sessionIDEnvDWS)
+ s.calls[len(s.calls)-1].dingSessionEnv = os.Getenv(sessionIDEnvDingtalk)
+ if len(s.errs) >= len(s.calls) && s.errs[len(s.calls)-1] != nil {
+ return nil, s.errs[len(s.calls)-1]
+ }
response := `{"success":true,"data":{}}`
if len(s.responses) >= len(s.calls) {
response = s.responses[len(s.calls)-1]
@@ -257,6 +267,37 @@ func buildChmod(t *testing.T, fake *fakeToolCaller) *cobra.Command {
return newChmodCommand(fake)
}
+func attachRootYesFlag(t *testing.T, cmd *cobra.Command, yes bool) {
+ t.Helper()
+ root := &cobra.Command{Use: "dws"}
+ root.PersistentFlags().Bool("yes", false, "skip confirmation")
+ root.AddCommand(cmd)
+ if yes {
+ if err := root.PersistentFlags().Set("yes", "true"); err != nil {
+ t.Fatalf("set root --yes: %v", err)
+ }
+ }
+}
+
+func attachRootPATFlags(t *testing.T, cmd *cobra.Command, yes bool, formatChanged bool) {
+ t.Helper()
+ root := &cobra.Command{Use: "dws"}
+ root.PersistentFlags().Bool("yes", false, "skip confirmation")
+ root.PersistentFlags().String("format", "json", "")
+ root.PersistentFlags().Bool("verbose", false, "")
+ root.AddCommand(cmd)
+ if yes {
+ if err := root.PersistentFlags().Set("yes", "true"); err != nil {
+ t.Fatalf("set root --yes: %v", err)
+ }
+ }
+ if formatChanged {
+ if err := root.PersistentFlags().Set("format", "json"); err != nil {
+ t.Fatalf("set root --format: %v", err)
+ }
+ }
+}
+
func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
root := &cobra.Command{Use: "dws"}
RegisterCommands(root, &fakeToolCaller{})
@@ -279,6 +320,62 @@ func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) {
}
}
+func TestPATHelpDocumentsBatchAuthorization(t *testing.T) {
+ root := &cobra.Command{Use: "dws"}
+ RegisterCommands(root, &fakeToolCaller{})
+
+ patCmd, _, err := root.Find([]string{"pat"})
+ if err != nil {
+ t.Fatalf("pat command not found: %v", err)
+ }
+ var out strings.Builder
+ patCmd.SetOut(&out)
+ patCmd.SetErr(&out)
+ if err := patCmd.Help(); err != nil {
+ t.Fatalf("pat help error = %v", err)
+ }
+ patHelp := out.String()
+ for _, want := range []string{
+ "支持批量授权",
+ "--products / --product",
+ "--domains / --domain",
+ "--recommend",
+ "DINGTALK_DWS_AGENTCODE",
+ "未传 agentCode 时由服务端默认兜底",
+ } {
+ if !strings.Contains(patHelp, want) {
+ t.Fatalf("pat help missing %q\nhelp:\n%s", want, patHelp)
+ }
+ }
+
+ chmodCmd, _, err := root.Find([]string{"pat", "chmod"})
+ if err != nil {
+ t.Fatalf("pat chmod command not found: %v", err)
+ }
+ out.Reset()
+ chmodCmd.SetOut(&out)
+ chmodCmd.SetErr(&out)
+ if err := chmodCmd.Help(); err != nil {
+ t.Fatalf("pat chmod help error = %v", err)
+ }
+ chmodHelp := out.String()
+ for _, want := range []string{
+ "批量授权:",
+ "一次传多个 scope",
+ "batch plan",
+ "--dry-run 只返回授权计划",
+ "执行批量授权必须显式",
+ "由服务端默认兜底",
+ "aitable.record:read aitable.record:write --grant-type permanent --yes",
+ "dws pat chmod --product calendar --product aitable",
+ "dws pat chmod --domain calendar --domain chat",
+ } {
+ if !strings.Contains(chmodHelp, want) {
+ t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp)
+ }
+ }
+}
+
func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
@@ -288,6 +385,7 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("products", "calendar,aitable")
+ attachRootYesFlag(t, cmd, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -305,8 +403,11 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
if got := fake.calls[0].args["recommend"]; got != false {
t.Fatalf("recommend = %#v, want false", got)
}
+ if got := fake.calls[0].agentEnv; got != "qoderwork" {
+ t.Fatalf("plan agent env = %q, want qoderwork", got)
+ }
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ t.Fatalf("batch plan agentCode = %#v, want qoderwork", got)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
@@ -314,12 +415,56 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) {
if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:read"}) {
t.Fatalf("grant scopes = %#v, want selected scopes", got)
}
+ if got := fake.calls[1].agentEnv; got != "qoderwork" {
+ t.Fatalf("grant agent env = %q, want qoderwork", got)
+ }
if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("grant agentCode = %#v, want qoderwork", got)
+ t.Fatalf("batch grant agentCode = %#v, want qoderwork", got)
}
}
-func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
+func TestChmod_productsFlagBlocksGrantWithoutYes(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:read"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("products", "calendar,aitable")
+
+ err := cmd.RunE(cmd, nil)
+ if err == nil {
+ t.Fatal("chmod RunE error = nil, want batch --yes blocker")
+ }
+ if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
+ t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want plan only", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchPlanToolName {
+ t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
+ }
+}
+
+func TestChmod_multipleExplicitScopesBlockWithoutYes(t *testing.T) {
+ fake := &fakeToolCaller{resultOK: true}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+
+ err := cmd.RunE(cmd, []string{"aitable.record:read", "aitable.record:write"})
+ if err == nil {
+ t.Fatal("chmod RunE error = nil, want batch --yes blocker")
+ }
+ if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
+ t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
+ }
+ if fake.callN != 0 {
+ t.Fatalf("CallTool was invoked %d times; batch without --yes must not grant", fake.callN)
+ }
+}
+
+func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
fake := &sequenceToolCaller{responses: []string{
`{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`,
@@ -328,6 +473,7 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("products", "calendar")
_ = cmd.Flags().Set("session-id", "session-123")
+ attachRootYesFlag(t, cmd, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -339,20 +485,423 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) {
if got := fake.calls[0].args["grantType"]; got != "session" {
t.Fatalf("plan grantType = %#v, want session", got)
}
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ }
if got := fake.calls[0].args["sessionId"]; got != "session-123" {
t.Fatalf("plan sessionId = %#v, want session-123", got)
}
- if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ if got := fake.calls[0].agentEnv; got != "qoderwork" {
+ t.Fatalf("plan agent env = %q, want qoderwork", got)
+ }
+ if fake.calls[0].dingSessionEnv != "session-123" {
+ t.Fatalf("plan %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
}
if got := fake.calls[1].args["grantType"]; got != "session" {
t.Fatalf("grant grantType = %#v, want session", got)
}
+ if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("grant agentCode = %#v, want qoderwork", got)
+ }
if got := fake.calls[1].args["sessionId"]; got != "session-123" {
t.Fatalf("grant sessionId = %#v, want session-123", got)
}
- if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("grant agentCode = %#v, want qoderwork", got)
+ if got := fake.calls[1].agentEnv; got != "qoderwork" {
+ t.Fatalf("grant agent env = %q, want qoderwork", got)
+ }
+ if fake.calls[1].dingSessionEnv != "session-123" {
+ t.Fatalf("grant %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[1].dingSessionEnv)
+ }
+}
+
+func TestChmod_singleScopeReturnsServerAgentCodeInSummary(t *testing.T) {
+ t.Setenv(agentCodeEnv, "")
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantType":"once","grantedScopes":["contact.user:get-self"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ attachRootPATFlags(t, cmd, false, false)
+
+ output, err := captureStdout(t, func() error {
+ return cmd.RunE(cmd, []string{"contact.user:get-self"})
+ })
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchGrantToolName {
+ t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
+ }
+ if _, ok := fake.calls[0].args["agentCode"]; ok {
+ t.Fatalf("agentCode arg must be omitted so PAT-core can default it: %#v", fake.calls[0].args)
+ }
+ if !strings.Contains(output, "agentCode: dingmbw5n9ktkkbbjv3g") {
+ t.Fatalf("summary output missing server default agentCode:\n%s", output)
+ }
+}
+
+func TestChmod_flagAgentCodeWinsAndReturnedAgentCodeMatches(t *testing.T) {
+ t.Setenv(agentCodeEnv, "envshouldlose")
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["chat.bot:search"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("agentCode", "qoderwork")
+ attachRootPATFlags(t, cmd, false, false)
+
+ output, err := captureStdout(t, func() error {
+ return cmd.RunE(cmd, []string{"chat.bot:search"})
+ })
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
+ }
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("agentCode arg = %#v, want qoderwork", got)
+ }
+ if got := fake.calls[0].agentEnv; got != "qoderwork" {
+ t.Fatalf("%s during CallTool = %q, want qoderwork", agentCodeEnv, got)
+ }
+ if !strings.Contains(output, "agentCode: qoderwork") {
+ t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
+ }
+}
+
+func TestChmod_batchEntryPointMatrixRequiresYesAndReturnsAgentCode(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ setFlags func(*cobra.Command)
+ wantPlanProducts []string
+ wantRecommend bool
+ wantCallCount int
+ }{
+ {
+ name: "direct multi scope",
+ args: []string{"calendar.event:list", "calendar.event:create"},
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ },
+ wantCallCount: 1,
+ },
+ {
+ name: "product repeated",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("product", "calendar")
+ _ = cmd.Flags().Set("product", "aitable")
+ },
+ wantPlanProducts: []string{"calendar", "aitable"},
+ wantCallCount: 2,
+ },
+ {
+ name: "products comma list",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("products", "calendar,aitable")
+ },
+ wantPlanProducts: []string{"calendar", "aitable"},
+ wantCallCount: 2,
+ },
+ {
+ name: "domain repeated",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("domain", "calendar")
+ _ = cmd.Flags().Set("domain", "chat")
+ },
+ wantPlanProducts: []string{"calendar", "chat"},
+ wantCallCount: 2,
+ },
+ {
+ name: "domains comma list",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("domains", "calendar,chat")
+ },
+ wantPlanProducts: []string{"calendar", "chat"},
+ wantCallCount: 2,
+ },
+ {
+ name: "recommend",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("recommend", "true")
+ },
+ wantRecommend: true,
+ wantCallCount: 2,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ responses := []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
+ }
+ if tc.wantCallCount == 2 {
+ responses = []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","selectedScopes":["calendar.event:list","calendar.event:create"],"skippedScopes":[],"pendingScopes":[]}}`,
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantType":"once","grantedScopes":["calendar.event:list","calendar.event:create"]}}`,
+ }
+ }
+ fake := &sequenceToolCaller{responses: responses}
+ cmd := newChmodCommand(fake)
+ tc.setFlags(cmd)
+ attachRootPATFlags(t, cmd, true, false)
+
+ output, err := captureStdout(t, func() error {
+ return cmd.RunE(cmd, tc.args)
+ })
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != tc.wantCallCount {
+ t.Fatalf("CallTool count = %d, want %d", len(fake.calls), tc.wantCallCount)
+ }
+ if tc.wantCallCount == 1 {
+ if fake.calls[0].tool != patBatchGrantToolName {
+ t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
+ }
+ if got := fake.calls[0].args["scopes"]; !stringSliceArgEqual(got, tc.args) {
+ t.Fatalf("grant scopes = %#v, want %#v", got, tc.args)
+ }
+ } else {
+ if fake.calls[0].tool != patBatchPlanToolName {
+ t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
+ }
+ if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
+ t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
+ }
+ if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
+ t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
+ }
+ if fake.calls[1].tool != patBatchGrantToolName {
+ t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
+ }
+ if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:list", "calendar.event:create"}) {
+ t.Fatalf("grant scopes = %#v, want selected scopes", got)
+ }
+ }
+ last := fake.calls[len(fake.calls)-1]
+ if got := last.args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("grant agentCode = %#v, want qoderwork", got)
+ }
+ if !strings.Contains(output, "agentCode: qoderwork") {
+ t.Fatalf("summary output missing qoderwork agentCode:\n%s", output)
+ }
+ })
+ }
+}
+
+func TestChmod_batchPlanEntryPointsDryRunOnlyReturnPlanAgentCode(t *testing.T) {
+ cases := []struct {
+ name string
+ setFlags func(*cobra.Command)
+ wantPlanProducts []string
+ wantRecommend bool
+ }{
+ {
+ name: "product",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("products", "calendar,aitable")
+ },
+ wantPlanProducts: []string{"calendar", "aitable"},
+ },
+ {
+ name: "domain",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("domains", "calendar,chat")
+ },
+ wantPlanProducts: []string{"calendar", "chat"},
+ },
+ {
+ name: "recommend",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("recommend", "true")
+ },
+ wantRecommend: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{
+ dryRun: true,
+ responses: []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","allGranted":false,"selectedScopes":["calendar.event:list"],"skippedScopes":[],"pendingScopes":[]}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ tc.setFlags(cmd)
+ attachRootPATFlags(t, cmd, false, false)
+
+ output, err := captureStdout(t, func() error {
+ return cmd.RunE(cmd, nil)
+ })
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want dry-run plan only", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchPlanToolName {
+ t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
+ }
+ if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, tc.wantPlanProducts) {
+ t.Fatalf("plan productCodes = %#v, want %#v", got, tc.wantPlanProducts)
+ }
+ if got := fake.calls[0].args["recommend"]; got != tc.wantRecommend {
+ t.Fatalf("plan recommend = %#v, want %v", got, tc.wantRecommend)
+ }
+ if !strings.Contains(output, "agentCode: qoderwork") || !strings.Contains(output, "selected: 1") {
+ t.Fatalf("dry-run summary missing plan agentCode/selection:\n%s", output)
+ }
+ })
+ }
+}
+
+func TestChmod_batchEntryPointsWithoutYesAreBlocked(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ setFlags func(*cobra.Command)
+ wantPlan bool
+ }{
+ {
+ name: "direct multi scope",
+ args: []string{"calendar.event:list", "calendar.event:create"},
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ },
+ },
+ {
+ name: "product",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("products", "calendar")
+ },
+ wantPlan: true,
+ },
+ {
+ name: "domain",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("domains", "calendar")
+ },
+ wantPlan: true,
+ },
+ {
+ name: "recommend",
+ setFlags: func(cmd *cobra.Command) {
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("recommend", "true")
+ },
+ wantPlan: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"data":{"selectedScopes":["calendar.event:list","calendar.event:create"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ tc.setFlags(cmd)
+
+ err := cmd.RunE(cmd, tc.args)
+ if err == nil {
+ t.Fatal("chmod RunE error = nil, want batch --yes blocker")
+ }
+ if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") {
+ t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error())
+ }
+ if tc.wantPlan {
+ if len(fake.calls) != 1 || fake.calls[0].tool != patBatchPlanToolName {
+ t.Fatalf("calls = %#v, want one plan call before blocker", fake.calls)
+ }
+ return
+ }
+ if len(fake.calls) != 0 {
+ t.Fatalf("CallTool count = %d, want no MCP calls for direct multi-scope blocker", len(fake.calls))
+ }
+ })
+ }
+}
+
+func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) {
+ cases := []struct {
+ name string
+ grantType string
+ sessionFlag string
+ sessionEnv string
+ wantSessionID string
+ wantErr string
+ }{
+ {name: "once no session", grantType: "once"},
+ {name: "permanent no session", grantType: "permanent"},
+ {name: "session from flag", grantType: "session", sessionFlag: "flag-session", wantSessionID: "flag-session"},
+ {name: "session from env", grantType: "session", sessionEnv: "env-session", wantSessionID: "env-session"},
+ {name: "session missing rejected", grantType: "session", wantErr: "--session-id is required"},
+ {name: "invalid grant type rejected", grantType: "invalid", wantErr: "invalid --grant-type"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ if tc.sessionEnv != "" {
+ t.Setenv(sessionIDEnvDWS, tc.sessionEnv)
+ }
+ fake := &sequenceToolCaller{responses: []string{
+ `{"success":true,"code":"OK","data":{"agentCode":"qoderwork","grantedScopes":["aitable.record:read"]}}`,
+ }}
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", tc.grantType)
+ if tc.sessionFlag != "" {
+ _ = cmd.Flags().Set("session-id", tc.sessionFlag)
+ }
+
+ err := cmd.RunE(cmd, []string{"aitable.record:read"})
+ if tc.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("chmod RunE error = %v, want containing %q", err, tc.wantErr)
+ }
+ if len(fake.calls) != 0 {
+ t.Fatalf("CallTool count = %d, want validator to block before MCP", len(fake.calls))
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 1 {
+ t.Fatalf("CallTool count = %d, want 1", len(fake.calls))
+ }
+ if got := fake.calls[0].args["grantType"]; got != tc.grantType {
+ t.Fatalf("grantType arg = %#v, want %s", got, tc.grantType)
+ }
+ if tc.wantSessionID == "" {
+ if _, ok := fake.calls[0].args["sessionId"]; ok {
+ t.Fatalf("unexpected sessionId arg: %#v", fake.calls[0].args)
+ }
+ return
+ }
+ if got := fake.calls[0].args["sessionId"]; got != tc.wantSessionID {
+ t.Fatalf("sessionId arg = %#v, want %s", got, tc.wantSessionID)
+ }
+ if got := fake.calls[0].dingSessionEnv; got != tc.wantSessionID {
+ t.Fatalf("%s during CallTool = %q, want %s", sessionIDEnvDingtalk, got, tc.wantSessionID)
+ }
+ })
}
}
@@ -378,11 +927,98 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
if fake.calls[0].tool != patBatchPlanToolName {
t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName)
}
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ }
if got := fake.calls[0].args["sessionId"]; got != "env-session-123" {
t.Fatalf("plan sessionId = %#v, want env-session-123", got)
}
+ if got := fake.calls[0].agentEnv; got != "qoderwork" {
+ t.Fatalf("plan agent env = %q, want qoderwork", got)
+ }
+ if fake.calls[0].dingSessionEnv != "env-session-123" {
+ t.Fatalf("plan %s env = %q, want env-session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv)
+ }
+}
+
+func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{
+ errs: []error{
+ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
+ apperrors.WithReason("business_error"),
+ apperrors.WithServerDiag(apperrors.ServerDiagnostics{
+ ServerErrorCode: patForgedIdentityCode,
+ }),
+ ),
+ nil,
+ },
+ responses: []string{
+ "",
+ `{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+ _ = cmd.Flags().Set("products", "calendar")
+
+ if err := cmd.RunE(cmd, nil); err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 2 {
+ t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchPlanToolName || fake.calls[1].tool != patBatchPlanToolName {
+ t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchPlanToolName)
+ }
if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
- t.Fatalf("plan agentCode = %#v, want qoderwork", got)
+ t.Fatalf("first plan agentCode = %#v, want qoderwork", got)
+ }
+ if _, ok := fake.calls[1].args["agentCode"]; ok {
+ t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
+ }
+ if got := fake.calls[1].agentEnv; got != "qoderwork" {
+ t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
+ }
+}
+
+func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
+ fake := &sequenceToolCaller{
+ errs: []error{
+ apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.",
+ apperrors.WithReason("business_error"),
+ apperrors.WithServerDiag(apperrors.ServerDiagnostics{
+ ServerErrorCode: patForgedIdentityCode,
+ }),
+ ),
+ nil,
+ },
+ responses: []string{
+ "",
+ `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+
+ if err := cmd.RunE(cmd, []string{"calendar.event:read"}); err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 2 {
+ t.Fatalf("CallTool count = %d, want 2", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchGrantToolName || fake.calls[1].tool != patBatchGrantToolName {
+ t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchGrantToolName)
+ }
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("first grant agentCode = %#v, want qoderwork", got)
+ }
+ if _, ok := fake.calls[1].args["agentCode"]; ok {
+ t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args)
+ }
+ if got := fake.calls[1].agentEnv; got != "qoderwork" {
+ t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got)
}
}
@@ -407,6 +1043,7 @@ func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) {
}
func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDingtalk, "ding-session-123")
fake := &fakeToolCaller{resultOK: true}
@@ -416,6 +1053,9 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
t.Fatalf("chmod RunE error = %v", err)
}
+ if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
+ t.Fatalf("agentCode arg = %#v, want qoderwork", got)
+ }
if got := fake.gotArgs["sessionId"]; got != "ding-session-123" {
t.Fatalf("sessionId arg = %#v, want ding-session-123", got)
}
@@ -428,6 +1068,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
}
func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
+ t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDingtalk, "stale-session")
fake := &fakeToolCaller{resultOK: true}
@@ -438,6 +1079,9 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
t.Fatalf("chmod RunE error = %v", err)
}
+ if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
+ t.Fatalf("agentCode arg = %#v, want qoderwork", got)
+ }
if got := fake.gotArgs["sessionId"]; got != "flag-session" {
t.Fatalf("sessionId arg = %#v, want flag-session", got)
}
@@ -458,6 +1102,7 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T)
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("recommend", "true")
+ attachRootYesFlag(t, cmd, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -523,7 +1168,7 @@ func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) {
// TestChmod_agentCode_env_fallback verifies that when --agentCode is
// omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks
-// the env value up and forwards it verbatim in the MCP argv.
+// the env value up for both batch arguments and gateway-compatible env.
func TestChmod_agentCode_env_fallback(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
@@ -543,7 +1188,7 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
t.Fatalf("agent env = %q, want %q", got, "qoderwork")
}
if got := fake.gotArgs["agentCode"]; got != "qoderwork" {
- t.Fatalf("batch argv agentCode = %#v, want qoderwork", got)
+ t.Fatalf("batch agentCode = %#v, want qoderwork", got)
}
if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
@@ -553,8 +1198,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) {
}
}
-func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
+func TestChmod_agentCode_reversedEnvIgnored(t *testing.T) {
t.Setenv(agentCodeEnv, "")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
@@ -566,14 +1212,35 @@ func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) {
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
+ if _, ok := fake.gotArgs["agentCode"]; ok {
+ t.Fatalf("agentCode arg must be omitted; reversed env name must not be consumed: %#v", fake.gotArgs)
+ }
if got := fake.gotAgentEnv; got != "" {
- t.Fatalf("agent env = %q, want empty so server default agentCode is used", got)
+ t.Fatalf("%s during CallTool = %q, want empty because reversed env is ignored", agentCodeEnv, got)
+ }
+}
+
+func TestChmod_withoutAgentCodeLetsServerDefault(t *testing.T) {
+ t.Setenv(agentCodeEnv, "")
+
+ fake := &fakeToolCaller{resultOK: true}
+ cmd := buildChmod(t, fake)
+ _ = cmd.Flags().Set("grant-type", "once")
+
+ if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
+ t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
+ }
+ if fake.callN != 1 {
+ t.Fatalf("CallTool was invoked %d times; missing agentCode must still reach the batch caller", fake.callN)
+ }
+ if fake.gotTool != patBatchGrantToolName {
+ t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if _, ok := fake.gotArgs["agentCode"]; ok {
- t.Fatalf("batch argv must omit agentCode when caller leaves it unset: %#v", fake.gotArgs)
+ t.Fatalf("agentCode arg must be omitted for server default path: %#v", fake.gotArgs)
}
- if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) {
- t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"})
+ if got := fake.gotAgentEnv; got != "" {
+ t.Fatalf("%s during CallTool = %q, want empty for server default path", agentCodeEnv, got)
}
}
@@ -786,6 +1453,40 @@ func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t
}
}
+func TestChmod_batchMetadataScopeErrorFallsBackToPATGrant(t *testing.T) {
+ fake := &sequenceToolCaller{
+ responses: []string{
+ `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{"scopes":["mail:send"]}}`,
+ `{"success":true,"data":{"authRequestId":"req-ok"}}`,
+ },
+ }
+ cmd := newChmodCommand(fake)
+ _ = cmd.Flags().Set("agentCode", "qoderwork")
+ _ = cmd.Flags().Set("grant-type", "once")
+
+ if err := cmd.RunE(cmd, []string{"mail:send"}); err != nil {
+ t.Fatalf("chmod RunE error = %v", err)
+ }
+ if len(fake.calls) != 2 {
+ t.Fatalf("CallTool call count = %d, want 2", len(fake.calls))
+ }
+ if fake.calls[0].tool != patBatchGrantToolName {
+ t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName)
+ }
+ if fake.calls[1].tool != patGrantToolName {
+ t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolName)
+ }
+ if got := fake.calls[0].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("batch agentCode = %#v, want qoderwork", got)
+ }
+ if got := fake.calls[1].args["agentCode"]; got != "qoderwork" {
+ t.Fatalf("fallback agentCode = %#v, want qoderwork", got)
+ }
+ if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"mail:send"}) {
+ t.Fatalf("fallback scopes = %#v, want mail:send", got)
+ }
+}
+
func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) {
err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具")
if !isToolNotRegisteredError(err) {
@@ -813,6 +1514,13 @@ func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) {
}
}
+func TestIsPATBatchFallbackResultIncludesMetadataContractErrors(t *testing.T) {
+ result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED"}`}}}
+ if !isPATBatchFallbackResult(result) {
+ t.Fatal("isPATBatchFallbackResult() = false, want true")
+ }
+}
+
func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
err := apperrors.NewAPI("business error: success=false",
apperrors.WithReason("business_error"),
@@ -825,6 +1533,18 @@ func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) {
}
}
+func TestIsPATBatchFallbackErrorIncludesMetadataContractDiagnostics(t *testing.T) {
+ err := apperrors.NewAPI("business error: success=false",
+ apperrors.WithReason("business_error"),
+ apperrors.WithServerDiag(apperrors.ServerDiagnostics{
+ ServerErrorCode: "PAT_BATCH_PRODUCT_NOT_DECLARED",
+ }),
+ )
+ if !isPATBatchFallbackError(err) {
+ t.Fatal("isPATBatchFallbackError() = false, want true")
+ }
+}
+
func TestHandleToolResult_emptyResultReturnsError(t *testing.T) {
err := handleToolResult(nil, nil, &edition.ToolResult{})
if err == nil {
@@ -978,34 +1698,35 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval")
}
if got := fake.gotArgs["agentCode"]; got != "flagval" {
- t.Fatalf("batch argv agentCode = %#v, want flagval", got)
+ t.Fatalf("batch agentCode = %#v, want flagval", got)
}
}
-// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after
-// the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the
-// legacy env MUST NOT be consumed. The command is still allowed to run,
-// omits agentCode, and lets lippi-pat-core write its default agentCode.
+// TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: only
+// DINGTALK_DWS_AGENTCODE is consumed as the env fallback. Legacy / draft names
+// MUST NOT be consumed as agentCode. The request is still sent so PAT-core can
+// apply its open-source default.
func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) {
t.Setenv(agentCodeEnv, "")
t.Setenv("DWS_AGENTCODE", "legacyval")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", "draftval")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
- t.Fatalf("chmod RunE error = %v", err)
+ t.Fatalf("chmod RunE error = %v, want server-side default agentCode path", err)
}
if fake.callN != 1 {
- t.Fatalf("CallTool was invoked %d times, want 1", fake.callN)
+ t.Fatalf("CallTool was invoked %d times; legacy env should be ignored but request should continue", fake.callN)
+ }
+ if _, ok := fake.gotArgs["agentCode"]; ok {
+ t.Fatalf("agentCode arg must be omitted; legacy DWS_AGENTCODE must not be consumed: %#v", fake.gotArgs)
}
if got := fake.gotAgentEnv; got != "" {
t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got)
}
- if _, ok := fake.gotArgs["agentCode"]; ok {
- t.Fatalf("batch argv must omit agentCode when only legacy env is set: %#v", fake.gotArgs)
- }
}
// ---------------------------------------------------------------------------
@@ -1046,8 +1767,17 @@ func TestResolveAgentCodeFromEnv(t *testing.T) {
code, src, "qoderwork", agentCodeEnv)
}
+ // Reverse-guard: the draft reversed spelling is intentionally ignored.
+ t.Setenv(agentCodeEnv, "")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", "compatwork")
+ if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
+ t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — DWS_DINGTALK_AGENTCODE must be ignored",
+ code, src)
+ }
+
// Empty primary → ("", "").
t.Setenv(agentCodeEnv, "")
+ t.Setenv("DWS_DINGTALK_AGENTCODE", "")
if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" {
t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src)
}
diff --git a/internal/pat/pat.go b/internal/pat/pat.go
index 3d17b11c..5e535f00 100644
--- a/internal/pat/pat.go
+++ b/internal/pat/pat.go
@@ -36,8 +36,17 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) {
能力说明:
pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时,
才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。
+ pat chmod 支持批量授权:可一次传多个 scope,也可通过
+ --products / --product、--domains / --domain 或 --recommend
+ 让服务端按产品模板 / 推荐集合计算授权计划,再批量授予选中的 scope。
+ 批量计划会返回 selected / skipped / pending 明细;--dry-run 只预览计划,
+ 不写入授权。真正执行批量授权前必须由用户显式添加 --yes;未加 --yes
+ 时 CLI 会阻断并提示 agent 先确认。
浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。
- 生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
+ pat chmod 可传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE;
+ CLI 会把显式 agentCode 放入 batch 请求参数,
+ 并同步注入 gateway 兼容身份头。未传 agentCode 时由服务端默认兜底。
+ 浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。
写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。
Host-owned PAT 开关:
diff --git a/test/unit/pat_host_owned_signal_test.go b/test/unit/pat_host_owned_signal_test.go
index 77de05d3..095a2f21 100644
--- a/test/unit/pat_host_owned_signal_test.go
+++ b/test/unit/pat_host_owned_signal_test.go
@@ -22,8 +22,8 @@ import (
// TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the
// "custom authorization card" contract: the CLI switches to host-owned
// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT /
-// claw-type is purely a server-side routing tag and must NOT influence
-// the decision, in either direction.
+// claw-type is purely a server-side routing tag and must NOT influence the
+// decision, in either direction.
//
// Regression guard: several earlier drafts conflated the two signals,
// causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE
@@ -33,6 +33,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
cases := []struct {
name string
agentCode string
+ reversed string
agentEnv string
want bool
}{
@@ -48,6 +49,13 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
agentEnv: "",
want: true,
},
+ {
+ name: "reversed draft agent code only → CLI-owned",
+ agentCode: "",
+ reversed: "agt-compat",
+ agentEnv: "",
+ want: false,
+ },
{
name: "agent code + DINGTALK_AGENT=default → host-owned",
agentCode: "agt-cursor",
@@ -84,6 +92,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, tc.agentCode)
+ t.Setenv("DWS_DINGTALK_AGENTCODE", tc.reversed)
// DINGTALK_AGENT is set purely to demonstrate that it does NOT
// influence the host-owned decision. The literal env name is
// used here because the auth package no longer exports a
@@ -97,6 +106,15 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) {
got, tc.want, tc.agentCode, tc.agentEnv,
)
}
+ if tc.want {
+ gotCode, gotSource := authpkg.AgentCodeFromEnv()
+ wantCode := tc.agentCode
+ wantSource := authpkg.AgentCodeEnv
+ if gotCode != wantCode || gotSource != wantSource {
+ t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)",
+ gotCode, gotSource, wantCode, wantSource)
+ }
+ }
})
}
}
From cdd8414891d825672b12cfbcc65ad7c87a0ded49 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Thu, 11 Jun 2026 19:53:26 +0800
Subject: [PATCH 12/25] docs: cut 1.0.37 changelog (#457)
---
CHANGELOG.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b3548d3..ef973546 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
+## [1.0.37] - 2026-06-11
+
+This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
+
+### Fixed
+
+- **PAT batch grants carry the agent identity and require explicit confirmation** (#455; `internal/pat/chmod.go`, `internal/auth/channel.go`, `internal/app/runner.go`) — an explicit `--agentCode` flag or the `DINGTALK_DWS_AGENTCODE` env var is now carried into PAT batch plan/grant arguments instead of being dropped, and a missing agentCode is forwarded as absent so the PAT core can apply the server-side default rather than failing. Batch grants now refuse to execute without an explicit `--yes` (dry-run and single-scope grants keep their existing behavior), closing the gap where a multi-scope grant could fire without a deliberate confirmation. Only the canonical env name `DINGTALK_DWS_AGENTCODE` is recognized; draft/reversed spellings from earlier iterations are ignored. Verified against prepub: dry-run, single grant, flag-priority grant, and batch grant all resolve the target agentCode, with the granted rows confirmed server-side. Tests: `internal/pat/chmod_test.go`, `internal/pat/browser_policy_test.go`, `test/unit/pat_host_owned_signal_test.go`.
+
## [1.0.36] - 2026-06-10
This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
From ff33114b2cde4e694287470ef2e5c0ab7c9ed0ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Tue, 16 Jun 2026 10:56:53 +0800
Subject: [PATCH 13/25] feat(doc): strip server-rejected unsafe chars on
markdown write path (#465)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The doc write boundary only stripped a fixed dangerous-Unicode set, and only
on the JSONML path. C0 control characters (except tab/newline), DEL (0x7F),
and a few zero-width / line-separator codepoints still reached the server,
where RejectControlChars rejects them — so doc create/update failed on content
that pasted in such characters (common with LLM-generated or copy-pasted text).
- Rename stripDocDangerousUnicode -> stripDocInputUnsafe and extend it to drop
C0 controls (except \t and \n) and DEL, matching apiclient.rejectDangerousChars.
- Add U+200D, U+2028, U+2029 to the dangerous-Unicode set so it covers the
full server-rejected range.
- Apply the strip on the markdown write path (doc create/update) and the JSONML
node path, not just the JSONML body.
- Add unit tests for stripDocInputUnsafe.
Ported from dws-wukong (feat: 增加输入安全字符过滤功能).
---
internal/helpers/doc.go | 4 +-
internal/helpers/doc_jsonml.go | 21 ++++-
.../helpers/doc_strip_input_unsafe_test.go | 82 +++++++++++++++++++
3 files changed, 102 insertions(+), 5 deletions(-)
create mode 100644 internal/helpers/doc_strip_input_unsafe_test.go
diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go
index 29fba2e5..4c68b914 100644
--- a/internal/helpers/doc.go
+++ b/internal/helpers/doc.go
@@ -362,7 +362,7 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
content = stripped
}
if content != "" {
- params["markdown"] = content
+ params["markdown"] = stripDocInputUnsafe(content)
}
}
} else if format, err := docContentFormat(cmd, "markdown", "jsonml"); err != nil {
@@ -473,7 +473,7 @@ func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
if sniffJsonMLLike(content) {
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
}
- params["markdown"] = content
+ params["markdown"] = stripDocInputUnsafe(content)
addDocIntParam(cmd, params, "index", "index")
}
return runDocTool(cmd, runner, "doc", "update_document", params)
diff --git a/internal/helpers/doc_jsonml.go b/internal/helpers/doc_jsonml.go
index b3b8c92b..42fc0856 100644
--- a/internal/helpers/doc_jsonml.go
+++ b/internal/helpers/doc_jsonml.go
@@ -26,6 +26,7 @@ import (
var docDangerousUnicode = [...]rune{
0x200B,
0x200C,
+ 0x200D,
0x200E,
0x200F,
0x202A,
@@ -33,6 +34,8 @@ var docDangerousUnicode = [...]rune{
0x202C,
0x202D,
0x202E,
+ 0x2028,
+ 0x2029,
0x2066,
0x2067,
0x2068,
@@ -49,8 +52,20 @@ var docDangerousSet = func() map[rune]bool {
return m
}()
-func stripDocDangerousUnicode(s string) string {
+// stripDocInputUnsafe removes characters that the server-side RejectControlChars
+// validator (mirrored by apiclient.rejectDangerousChars) would reject:
+//
+// 1. C0 control characters (except tab and newline) and DEL (0x7F)
+// 2. Dangerous Unicode (zero-width, Bidi controls, line/paragraph separators, BOM)
+//
+// It is applied at the write boundary so document content passes server
+// validation instead of being rejected. Tab and newline are preserved because
+// they are legitimate in document text.
+func stripDocInputUnsafe(s string) string {
return strings.Map(func(r rune) rune {
+ if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7F) {
+ return -1
+ }
if docDangerousSet[r] {
return -1
}
@@ -194,7 +209,7 @@ func prepareDocJSONMLBody(cmd *cobra.Command, raw string) (string, error) {
if err != nil {
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
}
- return stripDocDangerousUnicode(string(out)), nil
+ return stripDocInputUnsafe(string(out)), nil
}
func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error) {
@@ -247,7 +262,7 @@ func prepareDocJSONMLNode(cmd *cobra.Command, rawElement string) (string, error)
if err != nil {
return "", fmt.Errorf("marshal normalized jsonml: %w", err)
}
- return string(out), nil
+ return stripDocInputUnsafe(string(out)), nil
}
func docEmitJSONMLFixNotes(cmd *cobra.Command, notes []string) {
diff --git a/internal/helpers/doc_strip_input_unsafe_test.go b/internal/helpers/doc_strip_input_unsafe_test.go
new file mode 100644
index 00000000..20882727
--- /dev/null
+++ b/internal/helpers/doc_strip_input_unsafe_test.go
@@ -0,0 +1,82 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import "testing"
+
+// TestStripDocInputUnsafe verifies that stripDocInputUnsafe removes exactly the
+// characters the server-side RejectControlChars validator rejects (C0 controls
+// except tab/newline, DEL, and the dangerous-Unicode set), while leaving all
+// legitimate text untouched. Offending codepoints use explicit \u / \x escapes
+// so they are unambiguous in source.
+func TestStripDocInputUnsafe(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {
+ name: "preserves plain text",
+ in: "正常的文档内容 with ASCII",
+ want: "正常的文档内容 with ASCII",
+ },
+ {
+ name: "keeps tab and newline",
+ in: "标题\n\t正文",
+ want: "标题\n\t正文",
+ },
+ {
+ name: "drops C0 controls (null, SOH, CR) and DEL",
+ in: "正文\x00内容\x01段落\x0d结尾\x7f",
+ want: "正文内容段落结尾",
+ },
+ {
+ name: "drops zero-width space/non-joiner/joiner",
+ in: "正文\u200b内容\u200c段落\u200d结尾",
+ want: "正文内容段落结尾",
+ },
+ {
+ name: "drops bidi overrides and isolates",
+ in: "Bidi\u202a测试\u202e结束\u2066左\u2069右",
+ want: "Bidi测试结束左右",
+ },
+ {
+ name: "drops line and paragraph separators",
+ in: "\u2028\u2029行段",
+ want: "行段",
+ },
+ {
+ name: "drops BOM / ZWNBSP",
+ in: "BOM\ufeff尾",
+ want: "BOM尾",
+ },
+ {
+ name: "drops mixed control and dangerous unicode",
+ in: "混合\x00测试\u200b结尾\x7f",
+ want: "混合测试结尾",
+ },
+ {
+ name: "empty string stays empty",
+ in: "",
+ want: "",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := stripDocInputUnsafe(tc.in); got != tc.want {
+ t.Fatalf("stripDocInputUnsafe(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ })
+ }
+}
From ece91bfa3ca3f35eac2a3d29d6092050bafedc08 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Tue, 16 Jun 2026 17:55:29 +0800
Subject: [PATCH 14/25] feat(agent): accurate agent_code detection +
per-channel agentId for stats (#467)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tag each MCP request with which agent host is driving dws (agent_code) and a
per-(machine × agent_code) instance id, so usage can be sliced by channel and
instance in the data warehouse. Root cause it fixes: agent_code was only sent
when the host injected DINGTALK_DWS_AGENTCODE (~99.98% empty), so the gateway
logged none.
Detection ladder (every signature observed on a real host / official docs, not
guessed; unknown -> custom):
T0 explicit DINGTALK_DWS_AGENTCODE
T1 verified env signatures: claudecode (CLAUDECODE), codex (CODEX_SANDBOX),
openclaw (OPENCLAW_BUNDLE_ROOT), hermes (HERMES_HOME)
T2 VSCODE_BRAND value (covers the whole VS Code fork family)
T3 macOS __CFBundleIdentifier map (qoder/cursor/vscode/workbuddy)
T4 custom fallback
identity.json v2 (machineId + per-agent_code agents map), deterministic
dwsa_ derivation, transparent v1 migration. Backward-compatible wiring:
x-dws-agent-id stays machine-level; new x-dws-agent-instance-id carries the
per-channel id; X-Cli-Version emitted so old/new clients are distinguishable.
Trust boundary (docs/agent-code.md): agent_code and the ids are self-reported
and spoofable — fit for statistics ONLY, never for auth/limit/billing.
Includes unit tests for every tier and the integration doc.
---
docs/agent-code.md | 93 ++++++++++++
internal/app/runner.go | 23 ++-
internal/app/runner_test.go | 52 ++++++-
internal/auth/agent_code_detect.go | 161 ++++++++++++++++++++
internal/auth/agent_code_detect_test.go | 187 ++++++++++++++++++++++++
internal/auth/identity.go | 165 +++++++++++++++++++--
internal/auth/identity_agentid_test.go | 111 ++++++++++++++
7 files changed, 775 insertions(+), 17 deletions(-)
create mode 100644 docs/agent-code.md
create mode 100644 internal/auth/agent_code_detect.go
create mode 100644 internal/auth/agent_code_detect_test.go
create mode 100644 internal/auth/identity_agentid_test.go
diff --git a/docs/agent-code.md b/docs/agent-code.md
new file mode 100644
index 00000000..f993c9fe
--- /dev/null
+++ b/docs/agent-code.md
@@ -0,0 +1,93 @@
+# Agent identification (agent_code & agentId)
+
+dws tags every MCP request with **which agent host is driving it** and a
+**per-instance id**, so usage can be sliced by channel/instance in the data
+warehouse. This page is the integration contract.
+
+## What dws sends on the wire
+
+| Header | Meaning | Granularity |
+|--------|---------|-------------|
+| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel |
+| `x-dws-agent-instance-id` | `dwsa_` derived from `machineId + agent_code` | machine × channel |
+| `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine |
+| `X-Cli-Version` | dws CLI version (segments old vs new clients) | — |
+
+`x-dws-agent-id` keeps its original machine-level meaning for backward
+compatibility; `x-dws-agent-instance-id` is the new per-channel value. Old
+clients send no `agent_code` / instance id — treat their absence as
+"legacy/unknown", not an error.
+
+## How `agent_code` is resolved (confidence ladder)
+
+1. **T0 — explicit declaration:** `DINGTALK_DWS_AGENTCODE=`. **Use this.**
+2. **T1 — verified env signature:** an agent that auto-sets a distinctive var
+ (`CLAUDECODE`, `CODEX_SANDBOX`, `OPENCLAW_BUNDLE_ROOT`, `HERMES_HOME`).
+3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule
+ covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks.
+4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles.
+5. **T4 — `custom`:** unknown host. Never guessed.
+
+## Declaring your agent (recommended — the only fully-general path)
+
+Auto-detection cannot cover every agent: most terminal agents (gemini/
+antigravity, aider, opencode, qwen-code, crush, goose, kimi, amazon-q,
+continue, …) expose **no reliable self-identifying env var** — only user-set
+API keys, which must not be used as identity. The robust answer is: **the host
+sets `DINGTALK_DWS_AGENTCODE` in the env block where it launches dws as an MCP
+server.** This is accurate for any agent, on any OS, and is future-proof.
+
+MCP server config example (JSON-style hosts):
+```jsonc
+{
+ "mcpServers": {
+ "dingtalk-workspace": {
+ "command": "dws",
+ "args": ["mcp", "..."],
+ "env": { "DINGTALK_DWS_AGENTCODE": "your-agent-code" }
+ }
+ }
+}
+```
+
+### Canonical codes
+
+`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
+`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
+`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
+Use a stable lowercase slug; unknown values are kept as-is (lowercased,
+spaces stripped), so a new agent name flows through cleanly.
+
+## Trust & limitations — READ THIS
+
+**`agent_code` AND the ids (`x-dws-agent-id`, `x-dws-agent-instance-id`) are
+self-reported, best-effort signals, NOT an authenticated identity.**
+
+- `agent_code`: every declaration/auto-detect signal is an env var the
+ host/user controls — spoofable (`export CLAUDECODE=1` → dws reports
+ `claudecode`).
+- The ids are **even easier to forge**: they are generated, stored, and sent
+ entirely client-side. `machineId` is a random UUID in the plaintext
+ `~/.dws/identity.json` (which the user owns), and the instance id is just
+ `sha256(machineId + agent_code)`. Editing that one file — or rewriting the
+ header — lets anyone mint, split, rotate, or impersonate ids at will. The
+ `dwsa_` prefix does NOT make it a secure identifier.
+
+- ✅ **Fit for statistics / observability** (the intended use): there is no
+ incentive to misreport one's own agent, and real hosts emit real signals, so
+ aggregate per-channel metrics are reliable in practice.
+- ❌ **NOT fit for authentication, authorization, rate-limiting, billing, or
+ revocation.** Anything where a party benefits from lying must not trust this
+ field. For control-plane use you need a gateway-issued **authoritative**
+ agentId bound to a verified credential (clientId / PAT / OAuth) — a separate,
+ heavier mechanism, deliberately out of scope here.
+
+Treat `agent_code` / `x-dws-agent-instance-id` as analytics dimensions only.
+
+## Gateway side (required for the data to land)
+
+dws sending the headers is necessary but not sufficient. The gateway must:
+1. add `x-dingtalk-dws-agent-code`, `x-dws-agent-instance-id`, `X-Cli-Version`
+ to the upstream-header pass-through allowlist (otherwise they are stripped);
+2. log them as fields, and deliver them to the warehouse (alongside the
+ existing flow-control / execution logs).
diff --git a/internal/app/runner.go b/internal/app/runner.go
index bc4b48dd..624c06ed 100644
--- a/internal/app/runner.go
+++ b/internal/app/runner.go
@@ -687,7 +687,28 @@ func resolveIdentityHeaders() map[string]string {
if sessionID == "" {
sessionID = os.Getenv(envRewindSessionID)
}
- agentCode, _ := authpkg.AgentCodeFromEnv()
+ // Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the
+ // per-(machine × agent_code) instance id. This is what makes agent_code
+ // actually report a value: previously it was sent only when the host
+ // injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the
+ // gateway logged no agent_code at all. DetectAgentCode always yields a code.
+ //
+ // Backward-compat by design (additive, not breaking):
+ // - x-dws-agent-id keeps its v1 meaning = machine-level install UUID
+ // (set by id.Headers() above), so old/new clients stay comparable.
+ // - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id.
+ // Old clients don't send it, which is itself a clean old/new signal.
+ // Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched.
+ agentCode, agentCodeSig := authpkg.DetectAgentCode()
+ headers["x-dws-agent-instance-id"] = id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig)
+
+ // Emit the CLI version on the wire so the gateway can segment old vs new
+ // clients (and scope agent_code coverage / adoption). The header constant
+ // existed but was never set; wire it here.
+ if version != "" {
+ headers[transport.HeaderVersion] = version
+ }
+
envHeaders := map[string]string{
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
"x-dingtalk-dws-agent-code": agentCode,
diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go
index 1327e567..e794bc1b 100644
--- a/internal/app/runner_test.go
+++ b/internal/app/runner_test.go
@@ -328,14 +328,62 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
}
}
+func TestResolveIdentityHeadersAgentIdentityFields(t *testing.T) {
+ setupRuntimeCommandTest(t)
+ t.Setenv(authpkg.AgentCodeEnv, "qoder")
+
+ headers := resolveIdentityHeaders()
+
+ // x-dws-agent-id stays machine-level (v1 install UUID): non-empty and NOT
+ // the dwsa_ instance form — this is the cross-version continuity anchor.
+ machineID := headers["x-dws-agent-id"]
+ if machineID == "" {
+ t.Fatal("x-dws-agent-id must stay populated (machine-level)")
+ }
+ if strings.HasPrefix(machineID, "dwsa_") {
+ t.Fatalf("x-dws-agent-id must remain machine-level, got instance form %q", machineID)
+ }
+
+ // x-dws-agent-instance-id is the NEW per-(machine × agent_code) id.
+ instID := headers["x-dws-agent-instance-id"]
+ if !strings.HasPrefix(instID, "dwsa_") {
+ t.Fatalf("x-dws-agent-instance-id must be a derived instance id, got %q", instID)
+ }
+ if instID == machineID {
+ t.Fatal("instance id must differ from machine id")
+ }
+
+ // CLI version must now be on the wire so the gateway can segment old/new.
+ if headers[transport.HeaderVersion] == "" {
+ t.Fatalf("%s must be emitted", transport.HeaderVersion)
+ }
+}
+
func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(authpkg.AgentCodeEnv, "")
t.Setenv("DWS_DINGTALK_AGENTCODE", " compat ")
+ // Isolate from ambient agent-host detection signals so this test asserts
+ // only the reversed-env-name behavior (the suite itself may run under
+ // Claude Code / Qoder / VS Code, whose signals would otherwise be detected).
+ for _, k := range []string{
+ "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
+ "OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE", "HERMES_HOME",
+ "CODEX_SANDBOX", "VSCODE_BRAND", "__CFBundleIdentifier",
+ } {
+ t.Setenv(k, "")
+ }
headers := resolveIdentityHeaders()
- if got := headers["x-dingtalk-dws-agent-code"]; got != "" {
- t.Fatalf("x-dingtalk-dws-agent-code = %q, want empty because reversed env is ignored", got)
+ // The reversed env name must never be consumed. With no canonical
+ // declaration and no host signature, agent_code resolves to the honest
+ // "custom" fallback — and crucially is NOT the reversed value.
+ got := headers["x-dingtalk-dws-agent-code"]
+ if got == "compat" {
+ t.Fatalf("x-dingtalk-dws-agent-code = %q, reversed env must be ignored", got)
+ }
+ if got != authpkg.AgentCodeCustom {
+ t.Fatalf("x-dingtalk-dws-agent-code = %q, want %q (fallback)", got, authpkg.AgentCodeCustom)
}
}
diff --git a/internal/auth/agent_code_detect.go b/internal/auth/agent_code_detect.go
new file mode 100644
index 00000000..5ffd9a65
--- /dev/null
+++ b/internal/auth/agent_code_detect.go
@@ -0,0 +1,161 @@
+// 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.
+
+// agent_code_detect.go resolves the agent_code — which agent HOST is driving
+// dws (claudecode / qoder / cursor / vscode / openclaw / hermes / ...). It
+// fills the x-dingtalk-dws-agent-code header for per-channel statistics.
+//
+// SEPARATE axis from DWS_CHANNEL / x-dws-channel (a distribution channel code);
+// the two are never conflated here.
+//
+// Design contract — ACCURACY OVER COVERAGE, but maximize accurate coverage:
+// - Prefer generalizable, host-declared signals so one rule covers a whole
+// family (VSCODE_BRAND covers every VS Code fork, present and future).
+// - Every per-host signature below is OBSERVED on a real host (live process
+// env via `ps eww`, or the app bundle Info.plist), not guessed.
+// - Anything unidentified falls back to AgentCodeCustom — never guess.
+// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
+// not the agent host) and fuzzy parent-process name matching.
+package auth
+
+import (
+ "os"
+ "strings"
+)
+
+// AgentCodeCustom is the honest fallback for any host we cannot identify.
+const AgentCodeCustom = "custom"
+
+// hostSignature is a verified env fingerprint for a known agent host. EnvKeys
+// match when any listed key is present and non-empty.
+type hostSignature struct {
+ Code string
+ EnvKeys []string
+}
+
+// knownSignatures: CLI / daemon agents that inject a distinctive env var, which
+// the dws subprocess they spawn inherits. All verified on a real machine
+// (2026-06-16) via live process env / launch env — not guessed.
+var knownSignatures = []hostSignature{
+ // Claude Code — verified: CLAUDECODE=1, CLAUDE_CODE_ENTRYPOINT=cli.
+ {Code: "claudecode", EnvKeys: []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}},
+ // OpenClaw — verified on the running daemon: OPENCLAW_BUNDLE_ROOT.
+ {Code: "openclaw", EnvKeys: []string{"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE"}},
+ // Hermes — verified on the running gateway: HERMES_HOME.
+ {Code: "hermes", EnvKeys: []string{"HERMES_HOME"}},
+ // OpenAI Codex — CODEX_SANDBOX is auto-set by Codex for the subprocesses it
+ // spawns (e.g. CODEX_SANDBOX=seatbelt on macOS), and Codex filters this
+ // CODEX_-prefixed name out of user .env to prevent spoofing — so its
+ // presence reliably means "running under Codex".
+ // Source: developers.openai.com/codex/concepts/sandboxing
+ {Code: "codex", EnvKeys: []string{"CODEX_SANDBOX"}},
+}
+
+// NOTE on coverage limits (honest, not a TODO to silently ignore):
+// Most terminal agents (gemini-cli/antigravity, aider, opencode, qwen-code,
+// crush, goose, kimi, amazon-q, continue, ...) expose NO reliable
+// self-identifying env marker — only user-set API-key/config vars, which we
+// must not key off (a user setting GEMINI_API_KEY is not "running under
+// gemini"). They therefore resolve to custom unless they declare themselves.
+//
+// The authoritative, fully-general path to 100% coverage is the T0 declaration
+// contract: a host sets DINGTALK_DWS_AGENTCODE= when it launches dws.
+// That is accurate for ANY agent (present or future) on ANY OS, and is what an
+// integrating host should wire up. Auto-detection (signatures / VSCODE_BRAND /
+// bundle id) is a best-effort supplement for hosts that have not declared.
+
+// bundleIDToCode maps macOS app bundle identifiers to agent codes. The bundle
+// id is exposed via __CFBundleIdentifier and inherited by child processes the
+// IDE spawns (including dws), so it identifies the host even from an integrated
+// terminal. Verified from each app's Info.plist (2026-06-16). Only known agent
+// bundles map; everything else (iTerm, Terminal, ...) falls through to custom.
+//
+// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
+// this map is simply a no-op there (os.Getenv returns "").
+var bundleIDToCode = map[string]string{
+ "com.qoder.ide": "qoder",
+ "com.todesktop.230313mzl4w4u92": "cursor", // Cursor's ToDesktop bundle id
+ "com.microsoft.VSCode": "vscode",
+ "com.workbuddy.workbuddy": "workbuddy",
+}
+
+// DetectAgentCode resolves the agent_code via a confidence ladder and returns
+// the normalized code plus the signal that decided it:
+//
+// T0 explicit host declaration (DINGTALK_DWS_AGENTCODE — dedicated field)
+// T1 verified per-agent env signature (CLI/daemon agents)
+// T2 VSCODE_BRAND value (every VS Code fork declares its brand)
+// T3 macOS app bundle id (known agent bundles only)
+// T4 fallback -> custom (never guess)
+func DetectAgentCode() (code string, signal string) {
+ // T0: host explicitly declares its agent_code — highest confidence.
+ if v, name := AgentCodeFromEnv(); v != "" {
+ return normalizeAgentCode(v), "env:" + name
+ }
+
+ // T1: verified per-agent env signature (most specific — wins over the IDE
+ // it may be running inside).
+ for _, sig := range knownSignatures {
+ for _, k := range sig.EnvKeys {
+ if strings.TrimSpace(os.Getenv(k)) != "" {
+ return sig.Code, "sig:" + k
+ }
+ }
+ }
+
+ // T2: VS Code fork family. The brand value IS the host's self-declaration,
+ // so this single rule covers Qoder/Cursor/VS Code/Windsurf/Trae/Kiro/... —
+ // including forks that don't exist yet.
+ if b := strings.TrimSpace(os.Getenv("VSCODE_BRAND")); b != "" {
+ return normalizeAgentCode(b), "env:VSCODE_BRAND"
+ }
+
+ // T3: macOS app bundle id (known agent bundles only).
+ if id := strings.TrimSpace(os.Getenv("__CFBundleIdentifier")); id != "" {
+ if c, ok := bundleIDToCode[id]; ok {
+ return c, "bundle:" + id
+ }
+ }
+
+ // T4: unknown host — honest fallback, no guessing.
+ return AgentCodeCustom, "fallback"
+}
+
+// normalizeAgentCode maps host-declared names/brands to canonical agent_code
+// values. Unrecognized but non-empty input is lowercased, space-stripped and
+// kept as-is — still a host declaration, so still accurate (this is what gives
+// automatic coverage of new VS Code forks via VSCODE_BRAND).
+func normalizeAgentCode(raw string) string {
+ s := strings.ToLower(strings.TrimSpace(raw))
+ s = strings.ReplaceAll(s, " ", "")
+ switch s {
+ case "":
+ return AgentCodeCustom
+ case "claude", "claude-code", "claude_code", "claudecode":
+ return "claudecode"
+ case "qoder", "qoderwork":
+ return "qoder"
+ case "workbuddy", "work-buddy":
+ return "workbuddy"
+ case "visualstudiocode", "code", "code-oss", "vscode":
+ return "vscode"
+ case "cursor":
+ return "cursor"
+ case "windsurf":
+ return "windsurf"
+ case "trae", "traecn":
+ return "trae"
+ default:
+ return s
+ }
+}
diff --git a/internal/auth/agent_code_detect_test.go b/internal/auth/agent_code_detect_test.go
new file mode 100644
index 00000000..0c1274ca
--- /dev/null
+++ b/internal/auth/agent_code_detect_test.go
@@ -0,0 +1,187 @@
+// 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 (
+ "strings"
+ "testing"
+)
+
+// agentCodeSignalEnvs is every env DetectAgentCode consults. Tests clear them
+// all so each case starts clean (the suite itself runs under a real host).
+var agentCodeSignalEnvs = []string{
+ AgentCodeEnv,
+ "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
+ "OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE",
+ "HERMES_HOME", "CODEX_SANDBOX",
+ "VSCODE_BRAND", "__CFBundleIdentifier",
+ "TERM_PROGRAM", "DWS_CHANNEL",
+}
+
+func clearAgentCodeEnv(t *testing.T) {
+ t.Helper()
+ for _, k := range agentCodeSignalEnvs {
+ t.Setenv(k, "")
+ }
+}
+
+func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
+ clearAgentCodeEnv(t)
+ t.Setenv(AgentCodeEnv, "Qoder")
+ code, sig := DetectAgentCode()
+ if code != "qoder" {
+ t.Fatalf("want qoder, got %q", code)
+ }
+ if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
+ t.Fatalf("want env signal, got %q", sig)
+ }
+}
+
+func TestDetectAgentCode_VerifiedSignatures_T1(t *testing.T) {
+ cases := []struct {
+ env, val, want string
+ }{
+ {"CLAUDECODE", "1", "claudecode"},
+ {"CLAUDE_CODE_ENTRYPOINT", "cli", "claudecode"},
+ {"OPENCLAW_BUNDLE_ROOT", "/Users/x/.openclaw-bundle", "openclaw"},
+ {"HERMES_HOME", "/Users/x/.hermes", "hermes"},
+ {"CODEX_SANDBOX", "seatbelt", "codex"},
+ }
+ for _, c := range cases {
+ t.Run(c.env, func(t *testing.T) {
+ clearAgentCodeEnv(t)
+ t.Setenv(c.env, c.val)
+ code, sig := DetectAgentCode()
+ if code != c.want {
+ t.Fatalf("%s=%s: want %q, got %q", c.env, c.val, c.want, code)
+ }
+ if !strings.HasPrefix(sig, "sig:") {
+ t.Fatalf("want sig:* signal, got %q", sig)
+ }
+ })
+ }
+}
+
+func TestDetectAgentCode_VSCodeBrand_T2(t *testing.T) {
+ cases := map[string]string{
+ "Qoder": "qoder",
+ "Cursor": "cursor",
+ "Visual Studio Code": "vscode",
+ "Windsurf": "windsurf",
+ "Trae": "trae",
+ "SomeNewFork": "somenewfork", // generic coverage of future forks
+ }
+ for brand, want := range cases {
+ t.Run(brand, func(t *testing.T) {
+ clearAgentCodeEnv(t)
+ t.Setenv("VSCODE_BRAND", brand)
+ code, sig := DetectAgentCode()
+ if code != want {
+ t.Fatalf("VSCODE_BRAND=%q: want %q, got %q", brand, want, code)
+ }
+ if sig != "env:VSCODE_BRAND" {
+ t.Fatalf("want env:VSCODE_BRAND signal, got %q", sig)
+ }
+ })
+ }
+}
+
+func TestDetectAgentCode_BundleID_T3(t *testing.T) {
+ cases := map[string]string{
+ "com.qoder.ide": "qoder",
+ "com.todesktop.230313mzl4w4u92": "cursor",
+ "com.microsoft.VSCode": "vscode",
+ "com.workbuddy.workbuddy": "workbuddy",
+ }
+ for id, want := range cases {
+ t.Run(id, func(t *testing.T) {
+ clearAgentCodeEnv(t)
+ t.Setenv("__CFBundleIdentifier", id)
+ code, sig := DetectAgentCode()
+ if code != want {
+ t.Fatalf("bundle %q: want %q, got %q", id, want, code)
+ }
+ if !strings.HasPrefix(sig, "bundle:") {
+ t.Fatalf("want bundle:* signal, got %q", sig)
+ }
+ })
+ }
+}
+
+// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to
+// custom.
+func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) {
+ clearAgentCodeEnv(t)
+ t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
+ code, _ := DetectAgentCode()
+ if code != AgentCodeCustom {
+ t.Fatalf("unknown bundle must be custom, got %q", code)
+ }
+}
+
+func TestDetectAgentCode_Fallback_Custom(t *testing.T) {
+ clearAgentCodeEnv(t)
+ code, sig := DetectAgentCode()
+ if code != AgentCodeCustom {
+ t.Fatalf("want custom, got %q", code)
+ }
+ if sig != "fallback" {
+ t.Fatalf("want fallback, got %q", sig)
+ }
+}
+
+// TERM_PROGRAM and DWS_CHANNEL must never decide agent_code.
+func TestDetectAgentCode_IgnoresNoise(t *testing.T) {
+ clearAgentCodeEnv(t)
+ t.Setenv("TERM_PROGRAM", "iTerm.app")
+ t.Setenv("DWS_CHANNEL", "Qoderwork")
+ code, _ := DetectAgentCode()
+ if code != AgentCodeCustom {
+ t.Fatalf("noise must not decide agent_code; want custom, got %q", code)
+ }
+}
+
+// Precedence: explicit declaration (T0) > env signature (T1) > VSCODE_BRAND
+// (T2). A CLI agent running inside an IDE reports the CLI agent.
+func TestDetectAgentCode_Precedence(t *testing.T) {
+ clearAgentCodeEnv(t)
+ t.Setenv("CLAUDECODE", "1") // T1
+ t.Setenv("VSCODE_BRAND", "Qoder") // T2
+ if code, _ := DetectAgentCode(); code != "claudecode" {
+ t.Fatalf("T1 must beat T2, got %q", code)
+ }
+ t.Setenv(AgentCodeEnv, "workbuddy") // T0
+ if code, _ := DetectAgentCode(); code != "workbuddy" {
+ t.Fatalf("T0 must beat all, got %q", code)
+ }
+}
+
+func TestNormalizeAgentCode(t *testing.T) {
+ cases := map[string]string{
+ "claude": "claudecode",
+ "Claude-Code": "claudecode",
+ "CLAUDECODE": "claudecode",
+ "Qoderwork": "qoder",
+ "WorkBuddy": "workbuddy",
+ "Visual Studio Code": "vscode",
+ "Cursor": "cursor",
+ "": AgentCodeCustom,
+ "some-new-ide": "some-new-ide",
+ }
+ for in, want := range cases {
+ if got := normalizeAgentCode(in); got != want {
+ t.Errorf("normalizeAgentCode(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
diff --git a/internal/auth/identity.go b/internal/auth/identity.go
index c800610d..d93abede 100644
--- a/internal/auth/identity.go
+++ b/internal/auth/identity.go
@@ -13,17 +13,27 @@
// identity.go manages agent instance identification for tracking.
//
-// Each agent installation gets a unique agentId (UUID v4) that persists across
-// version upgrades but regenerates on reinstall. This identity is transparently
-// injected into MCP HTTP headers for gateway-side data collection.
+// Identity has two granularities, both injected into MCP HTTP headers for
+// gateway-side statistics:
+//
+// - machineId: a stable per-install UUID v4 (persists across upgrades,
+// regenerates on reinstall). Non-PII.
+// - agentId: a per-(machine × agentCode) id derived deterministically from
+// machineId + agent_code, so one machine running multiple agent hosts
+// (e.g. claudecode + cursor) yields a distinct, idempotent agentId per
+// agent_code. Computed client-side — no gateway round-trip required.
+//
+// The agent_code itself is resolved by DetectAgentCode (agent_code_detect.go).
package auth
import (
"crypto/rand"
+ "crypto/sha256"
"encoding/json"
- "fmt"
+ "math/big"
"os"
"path/filepath"
+ "time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -31,14 +41,34 @@ import (
const identityFile = "identity.json"
+// identityVersion is the current on-disk schema version. v1 files (no
+// machineId/agents) are migrated transparently on load.
+const identityVersion = 2
+
+// AgentEntry records the derived agentId for a single agent_code on this
+// machine.
+type AgentEntry struct {
+ AgentID string `json:"agentId"`
+ FirstSeen string `json:"firstSeen,omitempty"`
+ Detect string `json:"detect,omitempty"` // signal that decided the agent_code
+}
+
// Identity holds the agent instance identification fields.
+//
+// AgentID is retained for backward compatibility with v1 readers: on a fresh
+// install it is written equal to MachineID, and a v1 file's agentId is migrated
+// into MachineID on load.
type Identity struct {
- AgentID string `json:"agentId"` // UUID v4, generated at install time
- Source string `json:"source"` // data source, default "dws"
+ Version int `json:"version,omitempty"`
+ AgentID string `json:"agentId"` // v1 install UUID; == MachineID on v2 installs
+ MachineID string `json:"machineId,omitempty"` // stable per-install machine seed
+ Source string `json:"source"` // data source, default "dws"
+ Agents map[string]*AgentEntry `json:"agents,omitempty"` // agent_code -> derived agentId
}
// Load reads the identity from /identity.json.
// Returns nil if the file does not exist or cannot be parsed.
+// v1 files are migrated in-memory (machineId backfilled from agentId).
func Load(configDir string) *Identity {
path := filepath.Join(configDir, identityFile)
data, err := os.ReadFile(path)
@@ -49,21 +79,43 @@ func Load(configDir string) *Identity {
if err := json.Unmarshal(data, &id); err != nil {
return nil
}
- if id.AgentID == "" {
+ if id.AgentID == "" && id.MachineID == "" {
return nil
}
+ id.migrate()
return &id
}
+// migrate backfills v2 fields from a v1 file in-memory (does not persist).
+func (id *Identity) migrate() {
+ if id.MachineID == "" {
+ id.MachineID = id.AgentID // v1 install UUID becomes the machine seed
+ }
+ if id.AgentID == "" {
+ id.AgentID = id.MachineID
+ }
+ if id.Source == "" {
+ id.Source = "dws"
+ }
+ if id.Agents == nil {
+ id.Agents = make(map[string]*AgentEntry)
+ }
+ id.Version = identityVersion
+}
+
// EnsureExists loads existing identity or creates a new one if not present.
func EnsureExists(configDir string) *Identity {
if id := Load(configDir); id != nil {
return id
}
+ u := generateUUID()
id := &Identity{
- AgentID: generateUUID(),
- Source: "dws",
+ Version: identityVersion,
+ AgentID: u, // kept == MachineID for backward-compat
+ MachineID: u,
+ Source: "dws",
+ Agents: make(map[string]*AgentEntry),
}
// Best-effort persist — don't fail the CLI if write fails.
@@ -71,14 +123,51 @@ func EnsureExists(configDir string) *Identity {
return id
}
-// Headers returns the identity as HTTP header key-value pairs.
+// machineSeed returns the stable seed used to derive per-channel agentIds.
+func (id *Identity) machineSeed() string {
+ if id.MachineID != "" {
+ return id.MachineID
+ }
+ return id.AgentID
+}
+
+// ResolveAgentID returns the per-(machine × agentCode) agentId, deriving and
+// persisting it on first sight of an agentCode. Idempotent: the same machine
+// and agentCode always yields the same id, which is what makes cumulative
+// per-agent_code statistics possible. An empty agentCode is treated as the
+// custom bucket.
+func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
+ if agentCode == "" {
+ agentCode = AgentCodeCustom
+ }
+ if id.Agents == nil {
+ id.Agents = make(map[string]*AgentEntry)
+ }
+ if e, ok := id.Agents[agentCode]; ok && e.AgentID != "" {
+ return e.AgentID
+ }
+ aid := deriveAgentID(id.machineSeed(), agentCode)
+ id.Agents[agentCode] = &AgentEntry{
+ AgentID: aid,
+ FirstSeen: time.Now().UTC().Format(time.RFC3339),
+ Detect: signal,
+ }
+ _ = save(configDir, id) // best-effort cache; recomputable if it fails
+ return aid
+}
+
+// Headers returns the identity as static HTTP header key-value pairs.
+// x-dws-agent-id carries the stable machine-level id (== v1 install UUID), kept
+// continuous across versions. The per-(machine × agent_code) instance id is a
+// SEPARATE header (x-dws-agent-instance-id) injected by the caller via
+// ResolveAgentID — it does not override x-dws-agent-id.
func (id *Identity) Headers() map[string]string {
if id == nil {
return nil
}
h := make(map[string]string, 5)
- if id.AgentID != "" {
- h["x-dws-agent-id"] = id.AgentID
+ if seed := id.machineSeed(); seed != "" {
+ h["x-dws-agent-id"] = seed
}
if id.Source != "" {
h["x-dws-source"] = id.Source
@@ -104,6 +193,38 @@ func save(configDir string, id *Identity) error {
return os.WriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
}
+// deriveAgentID computes a stable, client-side agentId for a (machine,
+// agentCode) pair: dwsa_<12 base62 chars of sha256(seed|agentCode)>.
+// Deterministic and idempotent; no gateway allocation needed for statistics.
+func deriveAgentID(seed, agentCode string) string {
+ sum := sha256.Sum256([]byte(seed + "|" + agentCode))
+ enc := base62Encode(sum[:])
+ for len(enc) < 12 {
+ enc = "0" + enc
+ }
+ return "dwsa_" + enc[:12]
+}
+
+const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+
+func base62Encode(b []byte) string {
+ n := new(big.Int).SetBytes(b)
+ if n.Sign() == 0 {
+ return "0"
+ }
+ base := big.NewInt(62)
+ mod := new(big.Int)
+ var out []byte
+ for n.Sign() > 0 {
+ n.DivMod(n, base, mod)
+ out = append(out, base62Alphabet[mod.Int64()])
+ }
+ for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
+ out[i], out[j] = out[j], out[i]
+ }
+ return string(out)
+}
+
// generateUUID produces a UUID v4 string.
func generateUUID() string {
var u [16]byte
@@ -113,6 +234,22 @@ func generateUUID() string {
}
u[6] = (u[6] & 0x0f) | 0x40 // version 4
u[8] = (u[8] & 0x3f) | 0x80 // variant 10
- return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
- u[0:4], u[4:6], u[6:8], u[8:10], u[10:16])
+ return fmtUUID(u)
+}
+
+func fmtUUID(u [16]byte) string {
+ const hexdig = "0123456789abcdef"
+ // 8-4-4-4-12 with dashes => 36 bytes
+ buf := make([]byte, 36)
+ pos := 0
+ for i := 0; i < 16; i++ {
+ if i == 4 || i == 6 || i == 8 || i == 10 {
+ buf[pos] = '-'
+ pos++
+ }
+ buf[pos] = hexdig[u[i]>>4]
+ buf[pos+1] = hexdig[u[i]&0x0f]
+ pos += 2
+ }
+ return string(buf)
}
diff --git a/internal/auth/identity_agentid_test.go b/internal/auth/identity_agentid_test.go
new file mode 100644
index 00000000..b2f2cb54
--- /dev/null
+++ b/internal/auth/identity_agentid_test.go
@@ -0,0 +1,111 @@
+// 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 (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestDeriveAgentID_Format(t *testing.T) {
+ id := deriveAgentID("machine-abc", "claudecode")
+ if !strings.HasPrefix(id, "dwsa_") {
+ t.Fatalf("want dwsa_ prefix, got %q", id)
+ }
+ if len(id) != len("dwsa_")+12 {
+ t.Fatalf("want 12 base62 chars after prefix, got %q (len %d)", id, len(id))
+ }
+}
+
+func TestDeriveAgentID_Deterministic(t *testing.T) {
+ a := deriveAgentID("seed", "claudecode")
+ b := deriveAgentID("seed", "claudecode")
+ if a != b {
+ t.Fatalf("derivation must be deterministic: %q != %q", a, b)
+ }
+}
+
+func TestDeriveAgentID_DistinctByChannelAndMachine(t *testing.T) {
+ m1c1 := deriveAgentID("machine1", "claudecode")
+ m1c2 := deriveAgentID("machine1", "cursor")
+ m2c1 := deriveAgentID("machine2", "claudecode")
+ if m1c1 == m1c2 {
+ t.Errorf("same machine, different channel must differ: %q", m1c1)
+ }
+ if m1c1 == m2c1 {
+ t.Errorf("different machine, same channel must differ: %q", m1c1)
+ }
+}
+
+func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
+ dir := t.TempDir()
+ id := EnsureExists(dir)
+
+ first := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
+ second := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
+ if first != second {
+ t.Fatalf("ResolveAgentID must be idempotent: %q != %q", first, second)
+ }
+
+ // Reload from disk — the channel entry must have persisted.
+ reloaded := Load(dir)
+ if reloaded == nil {
+ t.Fatal("expected identity to persist")
+ }
+ e, ok := reloaded.Agents["claudecode"]
+ if !ok || e.AgentID != first {
+ t.Fatalf("persisted agentId mismatch: %+v", reloaded.Agents)
+ }
+ if e.Detect != "sig:CLAUDECODE" {
+ t.Errorf("want detect signal recorded, got %q", e.Detect)
+ }
+}
+
+func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) {
+ dir := t.TempDir()
+ id := EnsureExists(dir)
+ got := id.ResolveAgentID(dir, "", "fallback")
+ want := id.ResolveAgentID(dir, AgentCodeCustom, "fallback")
+ if got != want {
+ t.Fatalf("empty agent_code must map to custom bucket: %q != %q", got, want)
+ }
+}
+
+// A v1 file ({agentId, source}) must migrate: machineId backfilled from the
+// legacy agentId, and per-channel derivation keyed off that stable seed.
+func TestLoad_MigratesV1(t *testing.T) {
+ dir := t.TempDir()
+ v1 := `{"agentId":"504ddd36-3acf-45f6-9c1f-82f99260a419","source":"dws"}`
+ if err := os.WriteFile(filepath.Join(dir, identityFile), []byte(v1), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ id := Load(dir)
+ if id == nil {
+ t.Fatal("v1 file should load")
+ }
+ if id.MachineID != "504ddd36-3acf-45f6-9c1f-82f99260a419" {
+ t.Fatalf("machineId must backfill from legacy agentId, got %q", id.MachineID)
+ }
+ if id.machineSeed() != id.MachineID {
+ t.Fatalf("seed should be machineId, got %q", id.machineSeed())
+ }
+ // Derivation is stable against the migrated seed.
+ want := deriveAgentID(id.MachineID, "claudecode")
+ if got := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE"); got != want {
+ t.Fatalf("post-migration derivation mismatch: %q != %q", got, want)
+ }
+}
From 1f413fa322f01e995fcc65ae6b0231a1c4768ef8 Mon Sep 17 00:00:00 2001
From: thisred <49340589+thisred@users.noreply.github.com>
Date: Tue, 16 Jun 2026 20:08:17 +0800
Subject: [PATCH 15/25] fix: pipeline PollUntil case-insensitive comparison to
fix sheet export hang (#462)
---
internal/compat/pipeline.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/compat/pipeline.go b/internal/compat/pipeline.go
index 5f791f99..2dc76bc8 100644
--- a/internal/compat/pipeline.go
+++ b/internal/compat/pipeline.go
@@ -185,7 +185,7 @@ func executePipelineCall(
return nil, err
}
actual := getDotPath(resp, step.PollUntilField)
- if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
+ if actual != nil && strings.EqualFold(fmt.Sprint(actual), step.PollUntilValue) {
return resp, nil
}
if time.Now().After(deadline) {
From a8d009aec848dc5c3f48849b81073691f17d4f26 Mon Sep 17 00:00:00 2001
From: Jackjin <1037461232@qq.com>
Date: Tue, 16 Jun 2026 20:08:25 +0800
Subject: [PATCH 16/25] fix(upgrade): honor --dry-run as preview-only instead
of silently upgrading (#416)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
dws upgrade registered no --dry-run flag and never read the global one,
so --dry-run fell through to runUpgrade and performed a real, irreversible
upgrade (download + replace binary). This contradicts the flag's documented
contract (预览操作内容,不实际执行).
Resolve the target release and platform asset (so a missing build / 'already
latest' is still reported), then render the planned 1-5 steps and return
before any side effect: no backup, no download, no replace. Advertise
--dry-run in the command examples.
Fixes #364
---
internal/app/upgrade.go | 42 ++++++++++++++++++++++++++++
internal/app/upgrade_test.go | 54 ++++++++++++++++++++++++++++++++++++
2 files changed, 96 insertions(+)
diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go
index 4bee05eb..81851023 100644
--- a/internal/app/upgrade.go
+++ b/internal/app/upgrade.go
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"os"
"os/exec"
"path/filepath"
@@ -56,6 +57,7 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade --list --all # 列出所有版本
dws upgrade --version v1.0.5 # 升级到指定版本
dws upgrade --rollback # 回滚到上一版本
+ dws upgrade --dry-run # 仅预览升级步骤,不实际执行
dws upgrade -y # 跳过确认直接升级`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -68,6 +70,7 @@ func newUpgradeCommand() *cobra.Command {
}
yes, _ := cmd.Flags().GetBool("yes")
+ dryRun, _ := cmd.Flags().GetBool("dry-run")
format := resolveUpgradeFormat(cmd)
if flagList {
@@ -88,6 +91,7 @@ func newUpgradeCommand() *cobra.Command {
force: flagForce,
skipSkills: flagSkipSkills,
yes: yes,
+ dryRun: dryRun,
})
},
}
@@ -108,6 +112,7 @@ type upgradeOptions struct {
force bool
skipSkills bool
yes bool
+ dryRun bool
}
// --- dws upgrade --check ---
@@ -298,6 +303,29 @@ func runUpgradeRollback(yes bool) error {
// Phase 2 (Apply): replace binary + install skills — only runs if Phase 1 fully succeeds.
// If anything fails in Phase 1, no files on disk are modified.
+// writeDryRunPlan renders the steps that `dws upgrade` would perform, without
+// touching the filesystem. Kept side-effect-free and writer-injectable so the
+// --dry-run contract can be asserted in tests.
+func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills bool) {
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, " %s 预览模式,不会下载或修改任何文件\n", ugBold("[dry-run]"))
+ fmt.Fprintf(w, " 将执行以下操作:\n")
+ fmt.Fprintf(w, " [1/5] 备份当前版本 %s\n", ugDim(ensureV(currentVer)))
+ fmt.Fprintf(w, " [2/5] 下载 %s\n", ugCyan(binaryAssetName))
+ if hasSkills {
+ fmt.Fprintf(w, " 下载 %s\n", ugCyan("dws-skills.zip"))
+ }
+ fmt.Fprintf(w, " [3/5] 校验 SHA256\n")
+ fmt.Fprintf(w, " [4/5] 解压并验证\n")
+ replaceStep := "替换二进制"
+ if hasSkills {
+ replaceStep += " 并安装技能包"
+ }
+ fmt.Fprintf(w, " [5/5] %s\n", replaceStep)
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, " %s\n", ugDim("移除 --dry-run 以实际执行升级"))
+}
+
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s\n", ugDim("检查更新..."))
@@ -339,6 +367,20 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
}
+ // --dry-run: preview only. Resolve the platform asset so a missing build is
+ // still reported, then describe the steps that *would* run and return before
+ // any side effect (no backup, no download, no replace). Matches the global
+ // flag's contract: "预览操作内容,不实际执行".
+ if opts.dryRun {
+ binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
+ if err != nil {
+ return err
+ }
+ hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
+ writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
+ return nil
+ }
+
if !opts.yes {
fmt.Println()
fmt.Printf("是否升级? [y/N] ")
diff --git a/internal/app/upgrade_test.go b/internal/app/upgrade_test.go
index 8298d402..b0b31995 100644
--- a/internal/app/upgrade_test.go
+++ b/internal/app/upgrade_test.go
@@ -430,6 +430,60 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
if !strings.Contains(help, "--rollback") {
t.Error("help should contain --rollback")
}
+ // Regression for #364: --dry-run must be discoverable from upgrade help so
+ // users know it is supported (and is now actually honored).
+ if !strings.Contains(help, "--dry-run") {
+ t.Error("help should advertise --dry-run for upgrade")
+ }
+}
+
+// --- writeDryRunPlan (#364) ---
+//
+// Regression for #364: `dws upgrade --dry-run` previously performed a real
+// upgrade because the flag was silently ignored. The dry-run path must now be
+// preview-only — it describes the steps without downloading or replacing
+// anything. writeDryRunPlan is the side-effect-free renderer for that preview.
+
+func TestWriteDryRunPlan_PreviewOnly(t *testing.T) {
+ var buf bytes.Buffer
+ writeDryRunPlan(&buf, "v1.0.30", "dws-darwin-arm64.tar.gz", false)
+ out := buf.String()
+
+ if !strings.Contains(out, "dry-run") {
+ t.Errorf("output should be marked as dry-run, got:\n%s", out)
+ }
+ if !strings.Contains(out, "不会下载或修改任何文件") {
+ t.Errorf("output should state nothing is downloaded or modified, got:\n%s", out)
+ }
+ if !strings.Contains(out, "dws-darwin-arm64.tar.gz") {
+ t.Errorf("output should name the resolved platform asset, got:\n%s", out)
+ }
+ // All five steps should be previewed, including the (skipped) replace step.
+ for _, step := range []string{"[1/5]", "[2/5]", "[3/5]", "[4/5]", "[5/5]"} {
+ if !strings.Contains(out, step) {
+ t.Errorf("output missing step %s, got:\n%s", step, out)
+ }
+ }
+}
+
+func TestWriteDryRunPlan_WithSkills(t *testing.T) {
+ var buf bytes.Buffer
+ writeDryRunPlan(&buf, "v1.0.30", "dws-linux-amd64.tar.gz", true)
+ out := buf.String()
+
+ if !strings.Contains(out, "dws-skills.zip") {
+ t.Errorf("with skills, output should mention dws-skills.zip, got:\n%s", out)
+ }
+ if !strings.Contains(out, "安装技能包") {
+ t.Errorf("with skills, replace step should mention installing skills, got:\n%s", out)
+ }
+
+ // Without skills, neither should appear.
+ var buf2 bytes.Buffer
+ writeDryRunPlan(&buf2, "v1.0.30", "dws-linux-amd64.tar.gz", false)
+ if strings.Contains(buf2.String(), "dws-skills.zip") {
+ t.Errorf("without skills, output should not mention dws-skills.zip, got:\n%s", buf2.String())
+ }
}
// --- isLikelyAMFIKill ---
From 497e4f87d84b153aae2f18d10366c765d2bff92c Mon Sep 17 00:00:00 2001
From: patrickmen
Date: Tue, 16 Jun 2026 20:08:29 +0800
Subject: [PATCH 17/25] feat: support to disable auto open browser (#365)
---
internal/app/auth_command.go | 3 ++-
internal/auth/device_flow.go | 3 ++-
internal/auth/oauth_provider.go | 7 +++++--
3 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go
index 99dd26b5..dcf47e87 100644
--- a/internal/app/auth_command.go
+++ b/internal/app/auth_command.go
@@ -116,6 +116,7 @@ func newAuthLoginCommand() *cobra.Command {
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
+ provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
tokenData, err = provider.Login(loginCtx)
if err != nil {
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
@@ -126,6 +127,7 @@ func newAuthLoginCommand() *cobra.Command {
provider := authpkg.NewOAuthProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
+ provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
configureOAuthProviderCompatibility(provider, configDir)
tokenData, err = provider.Login(loginCtx, cfg.Force)
if err != nil {
@@ -186,7 +188,6 @@ func newAuthLoginCommand() *cobra.Command {
_ = cmd.Flags().MarkHidden("token-url")
_ = cmd.Flags().MarkHidden("refresh-url")
_ = cmd.Flags().MarkHidden("login-timeout")
- _ = cmd.Flags().MarkHidden("no-browser")
return cmd
}
diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go
index 031d8b7a..685455f2 100644
--- a/internal/auth/device_flow.go
+++ b/internal/auth/device_flow.go
@@ -52,6 +52,7 @@ type DeviceFlowProvider struct {
logger *slog.Logger
Output io.Writer
httpClient *http.Client
+ NoBrowser bool
}
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
@@ -205,7 +206,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
}
dfPrintDeviceCodeBox(p.output(), authResp)
- if authResp.VerificationURIComplete != "" {
+ if authResp.VerificationURIComplete != "" && !p.NoBrowser {
if bErr := openBrowser(authResp.VerificationURIComplete); bErr != nil && p.logger != nil {
p.logger.Debug("could not open browser", "error", bErr)
}
diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go
index 26707552..bf669d6e 100644
--- a/internal/auth/oauth_provider.go
+++ b/internal/auth/oauth_provider.go
@@ -42,6 +42,7 @@ type OAuthProvider struct {
logger *slog.Logger
Output io.Writer
httpClient *http.Client
+ NoBrowser bool
}
// NewOAuthProvider creates a new OAuth provider.
@@ -393,8 +394,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
if p.logger != nil {
p.logger.Debug("authorization URL", "url", authURL)
}
- if err := openBrowser(authURL); err != nil && p.logger != nil {
- p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
+ if !p.NoBrowser {
+ if err := openBrowser(authURL); err != nil && p.logger != nil {
+ p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
+ }
}
_, _ = fmt.Fprintln(p.output(), "")
From f1a68f2424cc0ddd8eb07691cf4eb7dd90deac14 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Tue, 16 Jun 2026 20:20:20 +0800
Subject: [PATCH 18/25] docs: cut 1.0.38 changelog (#469)
---
CHANGELOG.md | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef973546..59237e80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,21 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
+## [1.0.38] - 2026-06-16
+
+This release adds client-side agent attribution for usage stats, fixes two commands that silently misbehaved (`dws sheet export` hanging, `dws upgrade --dry-run` actually upgrading), hardens the document write path against server-rejected characters, and makes the long-broken `--no-browser` login flag actually work.
+
+### Added
+
+- **Client-side `agent_code` detection + per-channel agent instance id for usage stats** (#467; `internal/auth/agent_code_detect.go`, `internal/auth/identity.go`, `docs/agent-code.md`) — every MCP request now carries `x-dingtalk-dws-agent-code` (which agent host is driving dws — e.g. `claudecode` / `codex` / `qoder` / `cursor` / `hermes` / `openclaw`, falling back to `custom`), `x-dws-agent-instance-id` (a per-machine×channel id, `dwsa_`), the existing machine-level `x-dws-agent-id`, and `X-Cli-Version`. Detection is a confidence ladder, each signature verified on real hosts / official docs (never guessed; anything unrecognized resolves to `custom`): T0 explicit `DINGTALK_DWS_AGENTCODE`, T1 per-agent env signatures, T2 `VSCODE_BRAND` covering the whole VS Code fork family, T3 the macOS `__CFBundleIdentifier` map, T4 `custom`. `identity.json` migrates v1 → v2 transparently and keeps `x-dws-agent-id` machine-level for continuity. **Trust boundary:** `agent_code` and both ids are client self-reported and forgeable — they are for stats / observability only and must not be used for auth, authorization, rate-limiting, billing, or revocation. Server-side gateway work (header passthrough allowlist + logging the fields into the warehouse) is required before the data lands and is tracked separately.
+
+### Fixed
+
+- **`dws sheet export` no longer hangs for the full ~5-minute poll timeout** (#462; `internal/compat/pipeline.go`) — the pipeline poll loop compared the API status against `pollUntilValue` with case-sensitive `==`, but the API returns `"success"` while the pipeline config declares `"SUCCESS"`, so the match never fired and the loop spun until timeout. Switched to `strings.EqualFold`, aligning with the case-insensitive `normalizeAsyncStatus` helper already used for `doc export` / `aitable export`.
+- **`dws upgrade --dry-run` now previews instead of performing a real upgrade** (#416, fixes #364; `internal/app/upgrade.go`) — `newUpgradeCommand` registered no `--dry-run` flag and never read the global persistent one, so `--dry-run` fell through and ran a real, irreversible upgrade (download + binary replace), directly contradicting the flag's documented `预览操作内容,不实际执行` contract. It now resolves the target release and platform asset (so "already latest" / "no build for this platform" is still surfaced), prints the 1–5 steps it *would* perform via the side-effect-free `writeDryRunPlan`, and returns before any backup / download / replace. Covered by `TestWriteDryRunPlan_*` and an updated help test.
+- **`dws doc create` / `dws doc update` strip server-rejected characters instead of failing** (#465; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`) — the Markdown write path sent raw content straight through, and the dangerous-Unicode strip only ran on the JSONML branch, so content carrying C0 control characters (anything `< 0x20` except `\t` / `\n`), DEL (`0x7F`), or zero-width / line-separator codepoints (`U+200D`, `U+2028`, `U+2029`) — common in LLM-generated or copy-pasted text — was rejected by the server-side `RejectControlChars` validator and the command failed. `stripDocDangerousUnicode` is renamed to `stripDocInputUnsafe`, extended to match the authoritative `apiclient.rejectDangerousChars` set, and applied on both the Markdown and JSONML node write paths. Tab and newline are preserved. Ported from dws-wukong.
+- **`dws auth login --no-browser` is now honored** (#365; `internal/app/auth_command.go`, `internal/auth/device_flow.go`, `internal/auth/oauth_provider.go`) — the flag was already defined (and hidden) but never wired to the login providers, so the browser always opened regardless. The value is now passed into `DeviceFlowProvider.NoBrowser` / `OAuthProvider.NoBrowser` and gates the `openBrowser` call; the flag is also unhidden so headless / remote sessions can discover it.
+
## [1.0.37] - 2026-06-11
This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
From 6310dcc39ee4279bd3254acecf07cac648addef1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Thu, 18 Jun 2026 10:53:52 +0800
Subject: [PATCH 19/25] feat(chat): tag user-identity message sends with
edition clawType (#475)
Attach the clawType tool argument to every user-identity send path
(send_personal_message text/rich-media/reply and
send_direct_message_as_user) so the IM server can render the
"Send from AI" indicator on delivered messages. The value comes from a
new edition hook (Hooks.ClawTypeValue, exposed via edition.ClawType())
that falls back to DefaultOSSClawType ("openClaw"); overlays such as
wukong set their own identity to get their branded indicator.
Also fixes the reply command, which hardcoded clawType="wukong" and
made open-source replies carry the Wukong AI identity.
Bot sends intentionally stay untouched: they already render as bot
messages and must not carry the user-identity claw tag.
---
internal/helpers/chat.go | 11 +++--
internal/helpers/chat_test.go | 84 +++++++++++++++++++++++++++++++++++
pkg/edition/edition.go | 17 +++++++
pkg/edition/edition_test.go | 36 +++++++++++++++
4 files changed, 145 insertions(+), 3 deletions(-)
create mode 100644 pkg/edition/edition_test.go
diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go
index c1cdca3d..72354f47 100644
--- a/internal/helpers/chat.go
+++ b/internal/helpers/chat.go
@@ -23,6 +23,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
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/pkg/edition"
"github.com/spf13/cobra"
)
@@ -441,7 +442,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
default:
return nil, "", apperrors.NewValidation("unsupported --msg-type: " + msgType + " (supported: image, file)")
}
- params := map[string]any{"msgType": msgType, "content": contentJSON}
+ params := map[string]any{"msgType": msgType, "content": contentJSON, "clawType": edition.ClawType()}
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
@@ -480,6 +481,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
"openConversationId": group,
"msgType": "markdown",
"content": marshalMessageContent(title, text),
+ "clawType": edition.ClawType(),
}
if atAll {
params["atAll"] = true
@@ -492,13 +494,14 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
}
return params, "send_personal_message", nil
case hasUser:
- params := map[string]any{"title": title, "text": text, "receiverUserId": user}
+ params := map[string]any{"title": title, "text": text, "receiverUserId": user, "clawType": edition.ClawType()}
return params, "send_direct_message_as_user", nil
default:
params := map[string]any{
"receiverOpenDingTalkId": openID,
"msgType": "markdown",
"content": marshalMessageContent(title, text),
+ "clawType": edition.ClawType(),
}
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
@@ -971,11 +974,13 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return apperrors.NewInternal("marshal reply content: " + err.Error())
}
+ // clawType must reflect the actual edition: a hardcoded "wukong"
+ // here made open-source replies render the Wukong AI indicator.
params := map[string]any{
"openConversationId": convID,
"msgType": "reply",
"content": contentJSON,
- "clawType": "wukong",
+ "clawType": edition.ClawType(),
}
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go
index a2d2b95b..c1953a63 100644
--- a/internal/helpers/chat_test.go
+++ b/internal/helpers/chat_test.go
@@ -7,6 +7,8 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
)
type captureRunner struct {
@@ -308,6 +310,88 @@ func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
}
}
+// TestChatMessageSendCarriesClawType guards the "Send from AI" indicator:
+// every user-identity send path must attach the edition claw identity as the
+// clawType tool argument so the IM server renders the AI-sent label on the
+// delivered message. The open-source build pins it to edition.DefaultOSSClawType
+// ("openClaw"); dropping the parameter (or hardcoding another edition's value,
+// as the reply command once did with "wukong") mislabels or unlabels messages.
+func TestChatMessageSendCarriesClawType(t *testing.T) {
+ cases := []struct {
+ name string
+ make func(runner executor.Runner) *cobra.Command
+ args []string
+ }{
+ {
+ name: "group-markdown",
+ make: newChatMessageSendCommand,
+ args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
+ },
+ {
+ name: "user-direct",
+ make: newChatMessageSendCommand,
+ args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
+ },
+ {
+ name: "open-dingtalk-id-direct",
+ make: newChatMessageSendCommand,
+ args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
+ },
+ {
+ name: "group-rich-media-image",
+ make: newChatMessageSendCommand,
+ args: []string{"--group", "cid-xyz", "--msg-type", "image", "--media-id", "media-1"},
+ },
+ {
+ name: "reply",
+ make: newChatMessageReplyCommand,
+ args: []string{
+ "--conversation-id", "cid-xyz",
+ "--ref-msg-id", "msg-1",
+ "--ref-sender", "op-1",
+ "--text", "got it",
+ },
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ runner := &captureRunner{}
+ cmd := tc.make(runner)
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs(tc.args)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
+ }
+ got, ok := runner.last.Params["clawType"]
+ if !ok {
+ t.Fatalf("Params missing clawType; got %#v", runner.last.Params)
+ }
+ if got != edition.DefaultOSSClawType {
+ t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
+ }
+ })
+ }
+}
+
+// Robot sends are rendered as bot messages already; they must NOT carry the
+// user-identity clawType argument.
+func TestChatMessageSendByBotOmitsClawType(t *testing.T) {
+ runner := &captureRunner{}
+ cmd := newChatMessageSendByBotCommand(runner)
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs([]string{"--group", "cid-xyz", "--robot-code", "robot-001", "--title", "t", "--text", "x"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
+ }
+ if _, ok := runner.last.Params["clawType"]; ok {
+ t.Fatalf("bot send must not carry clawType; got %#v", runner.last.Params)
+ }
+}
+
func equalAny(a, b any) bool {
switch av := a.(type) {
case []any:
diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go
index 0db286d3..56702317 100644
--- a/pkg/edition/edition.go
+++ b/pkg/edition/edition.go
@@ -68,6 +68,12 @@ type Hooks struct {
Name string // "open" (default) / overlay identifier
ScenarioCode string // injected into x-dingtalk-scenario-code header
+ // ClawTypeValue is the claw identity carried in message-send tool
+ // arguments (parameter clawType) so the IM server can render the
+ // "Send from AI" indicator on delivered messages. Empty → falls back
+ // to DefaultOSSClawType; overlays set their own value (e.g. "wukong").
+ ClawTypeValue string
+
// --- runtime mode ---
IsEmbedded bool // true when running inside a host application
HideAuthLogin bool // true suppresses the "dws auth login" command
@@ -165,3 +171,14 @@ func Override(h *Hooks) {
defer mu.Unlock()
current = h
}
+
+// ClawType returns the claw identity for the active edition, falling back
+// to DefaultOSSClawType when the overlay does not set one. Message-send
+// helpers attach this value as the clawType tool argument so the IM server
+// can label delivered messages as sent via AI.
+func ClawType() string {
+ if v := Get().ClawTypeValue; v != "" {
+ return v
+ }
+ return DefaultOSSClawType
+}
diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go
new file mode 100644
index 00000000..0be785e8
--- /dev/null
+++ b/pkg/edition/edition_test.go
@@ -0,0 +1,36 @@
+// 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 edition
+
+import "testing"
+
+func TestClawTypeDefaultsToOSSValue(t *testing.T) {
+ prev := Get()
+ defer Override(prev)
+
+ Override(defaultHooks())
+ if got := ClawType(); got != DefaultOSSClawType {
+ t.Fatalf("ClawType() = %q, want %q", got, DefaultOSSClawType)
+ }
+}
+
+func TestClawTypeUsesOverlayValue(t *testing.T) {
+ prev := Get()
+ defer Override(prev)
+
+ Override(&Hooks{Name: "overlay", ClawTypeValue: "wukong"})
+ if got := ClawType(); got != "wukong" {
+ t.Fatalf("ClawType() = %q, want overlay value %q", got, "wukong")
+ }
+}
From 1a7ba01e367a18c6d54dddae11b92a653725ebcd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Thu, 18 Jun 2026 12:22:19 +0800
Subject: [PATCH 20/25] feat(chat): make AI-sent badge opt-in via --ai-tag
(default off) (#477)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previously every user-identity send/reply attached clawType=edition.ClawType()
unconditionally, so the IM server rendered an AI-sent badge ("通过AI发送" on the
open edition) under every message — surprising users by branding all their sends.
Make it opt-in: by default no clawType is attached (no badge). Passing --ai-tag
on 'chat message send' / 'chat message reply' attaches edition.ClawType() so the
badge shows (open=openClaw -> 通过AI发送, wukong overlay -> 悟空AI发送). Bot and
webhook sends remain untouched.
Follow-up to #475 (which fixed the hardcoded wukong leak, #474).
---
internal/helpers/chat.go | 27 ++++++++++++++++++++-------
internal/helpers/chat_test.go | 34 +++++++++++++++++++++++++---------
2 files changed, 45 insertions(+), 16 deletions(-)
diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go
index 72354f47..4f3bd65e 100644
--- a/internal/helpers/chat.go
+++ b/internal/helpers/chat.go
@@ -334,9 +334,19 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
+ cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才在消息下方显示 AI 发送角标)")
return cmd
}
+// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
+// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
+// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
+func attachAITag(cmd *cobra.Command, params map[string]any) {
+ if on, _ := cmd.Flags().GetBool("ai-tag"); on {
+ params["clawType"] = edition.ClawType()
+ }
+}
+
// deriveTitleFromText 在未显式指定 --title 时,从正文截取一个标题
// (首行、最多 20 个字符),与 wukong 行为对齐 (send_personal_message 的
// content 内携带 title)。
@@ -442,7 +452,8 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
default:
return nil, "", apperrors.NewValidation("unsupported --msg-type: " + msgType + " (supported: image, file)")
}
- params := map[string]any{"msgType": msgType, "content": contentJSON, "clawType": edition.ClawType()}
+ params := map[string]any{"msgType": msgType, "content": contentJSON}
+ attachAITag(cmd, params)
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
@@ -481,8 +492,8 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
"openConversationId": group,
"msgType": "markdown",
"content": marshalMessageContent(title, text),
- "clawType": edition.ClawType(),
}
+ attachAITag(cmd, params)
if atAll {
params["atAll"] = true
}
@@ -494,15 +505,16 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
}
return params, "send_personal_message", nil
case hasUser:
- params := map[string]any{"title": title, "text": text, "receiverUserId": user, "clawType": edition.ClawType()}
+ params := map[string]any{"title": title, "text": text, "receiverUserId": user}
+ attachAITag(cmd, params)
return params, "send_direct_message_as_user", nil
default:
params := map[string]any{
"receiverOpenDingTalkId": openID,
"msgType": "markdown",
"content": marshalMessageContent(title, text),
- "clawType": edition.ClawType(),
}
+ attachAITag(cmd, params)
if strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
@@ -974,14 +986,14 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return apperrors.NewInternal("marshal reply content: " + err.Error())
}
- // clawType must reflect the actual edition: a hardcoded "wukong"
- // here made open-source replies render the Wukong AI indicator.
params := map[string]any{
"openConversationId": convID,
"msgType": "reply",
"content": contentJSON,
- "clawType": edition.ClawType(),
}
+ // clawType 仅在 --ai-tag 时携带;默认不带,回复不强加 AI 角标。
+ // edition 决定取值 (开源 openClaw / 悟空 wukong)。
+ attachAITag(cmd, params)
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
@@ -1005,6 +1017,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
cmd.Flags().String("text", "", "回复正文 (必填)")
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
+ cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才显示 AI 发送角标)")
return cmd
}
diff --git a/internal/helpers/chat_test.go b/internal/helpers/chat_test.go
index c1953a63..3ed0d190 100644
--- a/internal/helpers/chat_test.go
+++ b/internal/helpers/chat_test.go
@@ -310,13 +310,13 @@ func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
}
}
-// TestChatMessageSendCarriesClawType guards the "Send from AI" indicator:
-// every user-identity send path must attach the edition claw identity as the
-// clawType tool argument so the IM server renders the AI-sent label on the
-// delivered message. The open-source build pins it to edition.DefaultOSSClawType
-// ("openClaw"); dropping the parameter (or hardcoding another edition's value,
-// as the reply command once did with "wukong") mislabels or unlabels messages.
-func TestChatMessageSendCarriesClawType(t *testing.T) {
+// TestChatMessageAITagControlsClawType guards the opt-in "Send from AI" indicator:
+// by default NO user-identity send carries the clawType tool argument (so the IM
+// server renders no AI badge). Only when --ai-tag is passed does each path attach
+// the edition claw identity (open-source build pins it to edition.DefaultOSSClawType,
+// "openClaw"); the wukong overlay would attach its own value. The label is opt-in so
+// dws does not surprise users by branding every message they send.
+func TestChatMessageAITagControlsClawType(t *testing.T) {
cases := []struct {
name string
make func(runner executor.Runner) *cobra.Command
@@ -354,7 +354,8 @@ func TestChatMessageSendCarriesClawType(t *testing.T) {
},
}
for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
+ // Default: no --ai-tag → must omit clawType entirely (no badge).
+ t.Run(tc.name+"/default-no-tag", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
var out bytes.Buffer
@@ -364,9 +365,24 @@ func TestChatMessageSendCarriesClawType(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
+ if v, ok := runner.last.Params["clawType"]; ok {
+ t.Fatalf("default send must omit clawType, got %#v", v)
+ }
+ })
+ // Opt-in: --ai-tag → attach the edition claw identity.
+ t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
+ runner := &captureRunner{}
+ cmd := tc.make(runner)
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag"))
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
+ }
got, ok := runner.last.Params["clawType"]
if !ok {
- t.Fatalf("Params missing clawType; got %#v", runner.last.Params)
+ t.Fatalf("--ai-tag send missing clawType; got %#v", runner.last.Params)
}
if got != edition.DefaultOSSClawType {
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
From 91f44a1efddcfb3356f29c19810a245c08f3c104 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Thu, 18 Jun 2026 14:10:07 +0800
Subject: [PATCH 21/25] docs: cut 1.0.39 changelog (#475 wukong-leak fix + #477
ai-tag opt-in) (#478)
---
CHANGELOG.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59237e80..1f766f6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,18 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
+## [1.0.39] - 2026-06-18
+
+This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
+
+### Added
+
+- **`--ai-tag` opt-in flag for `chat message send` / `chat message reply`** (#477; `internal/helpers/chat.go`) — by default no `clawType` tool argument is attached, so delivered messages carry no "Send from AI" badge. Passing `--ai-tag` attaches `edition.ClawType()` so the IM server renders the badge (open edition `openClaw` → 「通过AI发送」; the wukong overlay sets its own value → 「悟空AI发送」). Covers the text/Markdown, rich-media, and `--user`/`--open-dingtalk-id` direct send paths plus `reply`. Bot (`send-by-bot`) and webhook sends are intentionally untouched — they already render as bot messages. The badge is opt-in so dws does not brand every message a user sends.
+
+### Fixed
+
+- **`dws chat message reply` no longer leaks the Wukong AI label on the open edition** (#475, fixes #474; `internal/helpers/chat.go`, `pkg/edition/edition.go`) — the reply path hardcoded `clawType: "wukong"`, so open-source quoted replies were tagged 「悟空AI发送」 by the IM server, leaking Wukong branding to external users (reported by an external customer integrating via openclaw). The value now derives from the edition via the new `edition.ClawType()` accessor (open → `DefaultOSSClawType` = `openClaw`), and — together with #477 — is only attached when `--ai-tag` is passed. The earlier fix existed on a branch (PR #450) but was never merged to main; #475 cherry-picked it.
+
## [1.0.38] - 2026-06-16
This release adds client-side agent attribution for usage stats, fixes two commands that silently misbehaved (`dws sheet export` hanging, `dws upgrade --dry-run` actually upgrading), hardens the document write path against server-rejected characters, and makes the long-broken `--no-browser` login flag actually work.
From ed1cbd6f0698ad4f7f066b85dca52c552704e669 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Wed, 24 Jun 2026 17:14:40 +0800
Subject: [PATCH 22/25] feat(install): China mirror via Gitee + npmmirror
(#486)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
国内安装加速:DWS_GITEE_REPO 开关从 Gitee 解析下载脚本/二进制/skill(默认仍 GitHub,向后兼容);release.yml 发版后 CI 自动镜像 release 附件到 Gitee;README 加国内安装说明。Approved-by: wxianfeng.
---
.github/workflows/mirror-to-gitee.yml | 37 ++++++++++++
.github/workflows/release.yml | 10 ++++
README.md | 22 +++++++
README_zh.md | 22 +++++++
scripts/install-skills.sh | 50 +++++++++++++++-
scripts/install.ps1 | 51 +++++++++++++++-
scripts/install.sh | 64 ++++++++++++++++++--
scripts/release/sync-to-gitee.sh | 84 +++++++++++++++++++++++++++
8 files changed, 329 insertions(+), 11 deletions(-)
create mode 100644 .github/workflows/mirror-to-gitee.yml
create mode 100755 scripts/release/sync-to-gitee.sh
diff --git a/.github/workflows/mirror-to-gitee.yml b/.github/workflows/mirror-to-gitee.yml
new file mode 100644
index 00000000..4e4aa299
--- /dev/null
+++ b/.github/workflows/mirror-to-gitee.yml
@@ -0,0 +1,37 @@
+# 此 workflow 把本仓库代码自动镜像到 Gitee,供国内用户访问。
+# 需要在仓库 Settings → Secrets and variables → Actions 配置两个 secret:
+# GITEE_TOKEN —— Gitee 私人令牌(在 Gitee 设置→私人令牌生成),需勾选 projects + admin:repo_hook 权限。
+# GITEE_PRIVATE_KEY —— SSH 私钥(本地 ssh-keygen 生成的私钥),对应公钥需添加到 Gitee 账号的 SSH 公钥中。
+# 未配置 secret 时(例如 fork 仓库),mirror step 会被自动跳过,不会报错(不会出现红叉)。
+name: Mirror code to Gitee
+
+on:
+ push:
+ branches:
+ - main
+ tags:
+ - 'v*'
+ schedule:
+ - cron: '0 18 * * *'
+ workflow_dispatch:
+
+jobs:
+ mirror:
+ runs-on: ubuntu-latest
+ # GitHub Actions 不允许在 job-level if 直接引用 secrets,
+ # 因此先用 env 暴露 GITEE_TOKEN,再在 step 里用 env.GITEE_TOKEN 做守卫判断。
+ env:
+ GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
+ steps:
+ - name: Mirror to Gitee
+ if: env.GITEE_TOKEN != ''
+ uses: Yikun/hub-mirror-action@master
+ with:
+ src: "github/DingTalk-Real-AI"
+ dst: "gitee/DingTalk-Real-AI"
+ dst_key: ${{ secrets.GITEE_PRIVATE_KEY }}
+ dst_token: ${{ secrets.GITEE_TOKEN }}
+ account_type: "org"
+ static_list: "dingtalk-workspace-cli"
+ clone_style: "https"
+ force_update: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3fdee978..158f6730 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,6 +58,16 @@ jobs:
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
+ - name: Mirror release to Gitee (China)
+ # 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
+ # 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
+ # 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
+ run: ./scripts/release/sync-to-gitee.sh
+ env:
+ VERSION: ${{ github.ref_name }}
+ GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
+ GITEE_REPO: ${{ secrets.GITEE_REPO }}
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
diff --git a/README.md b/README.md
index bb681b17..e1ba0ae2 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # install to PATH
+## China mirror
+
+For users in mainland China, the following channels avoid GitHub network issues. By default (without setting these environment variables) the installer pulls from GitHub.
+
+**1. Install script + pre-built binary (Gitee mirror):**
+
+Repository mirror: `https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
+
+```bash
+DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
+```
+
+> With `DWS_GITEE_REPO` set, the installer resolves the latest version and every release asset (binary, checksums, skills) from the Gitee API instead of GitHub. If it is unset, installation defaults to GitHub.
+
+**2. npm package (npmmirror mirror):**
+
+```bash
+npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
+```
+
+> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
+
## Upgrade
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
diff --git a/README_zh.md b/README_zh.md
index cd08c987..9f0a91b8 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # 安装到 PATH
+## 国内加速安装
+
+国内用户可使用以下通道,避免 GitHub 网络问题。默认(不设置这些环境变量)走 GitHub。
+
+**1. 安装脚本 + 预编译二进制(Gitee 镜像):**
+
+仓库镜像地址:`https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
+
+```bash
+DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
+```
+
+> 设置 `DWS_GITEE_REPO` 后,安装脚本会改从 Gitee API 解析最新版本和各个 release 产物(二进制、校验和、skills 包),而不是走 GitHub。不设置时默认从 GitHub 安装。
+
+**2. npm 包(npmmirror 镜像):**
+
+```bash
+npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
+```
+
+> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
+
## 升级
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh
index 745a498f..cc4b32b8 100755
--- a/scripts/install-skills.sh
+++ b/scripts/install-skills.sh
@@ -13,8 +13,12 @@ set -eu
# Environment variables (optional):
# DWS_VERSION — release tag (default: latest)
# DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD)
+# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
+# Gitee API instead of GitHub (China mirror)
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
+# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via Gitee API.
+GITEE_REPO="${DWS_GITEE_REPO:-}"
VERSION="${DWS_VERSION:-latest}"
SKILL_NAME="dws"
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
@@ -29,10 +33,35 @@ need_cmd() {
fi
}
+# Fetch a Gitee API endpoint, retrying transient 502/503 from Gitee's gateway.
+gitee_api() {
+ _url="$1"
+ _try=1
+ while [ "$_try" -le 4 ]; do
+ if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
+ printf '%s' "$_resp"
+ return 0
+ fi
+ _try=$((_try + 1))
+ sleep 2
+ done
+ return 1
+}
+
resolve_version() {
if [ "$VERSION" = "latest" ]; then
- VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
- | grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
+ if [ -n "$GITEE_REPO" ]; then
+ # Gitee's /releases/latest and /releases endpoints are unreliable, so
+ # resolve the newest vN.N.N tag from the git tags endpoint instead.
+ VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
+ | grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
+ | sed 's/.*"name":[ ]*"//;s/"$//' \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
+ | sort -V | tail -1)"
+ else
+ VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
+ | grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
+ fi
if [ -z "$VERSION" ]; then
printf '❌ Could not determine the latest version. Set DWS_VERSION explicitly.\n' >&2
exit 1
@@ -40,6 +69,20 @@ resolve_version() {
fi
}
+# Resolve a release asset's download URL by name (GitHub template vs Gitee API).
+asset_url() {
+ _name="$1"
+ if [ -z "$GITEE_REPO" ]; then
+ printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
+ return 0
+ fi
+ gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
+ | tr '}' '\n' \
+ | grep "\"name\":[ ]*\"${_name}\"" \
+ | grep -o '"browser_download_url":[ ]*"[^"]*"' \
+ | head -1 | sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
+}
+
extract_zip() {
archive="$1"
dest="$2"
@@ -166,7 +209,8 @@ main() {
TMPDIR_WORK="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_WORK"' EXIT INT TERM
- ASSET_URL="https://github.com/${REPO}/releases/download/${VERSION}/dws-skills.zip"
+ ASSET_URL="$(asset_url dws-skills.zip)"
+ [ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; }
printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION"
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted"
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 5d1cbb1c..5ae18efd 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -18,6 +18,8 @@
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
+# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
+# Gitee API instead of GitHub (China mirror)
#
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
@@ -25,6 +27,8 @@ $ErrorActionPreference = "Stop"
$Repo = "DingTalk-Real-AI/dingtalk-workspace-cli"
$BinName = "dws"
+# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via the Gitee API.
+$GiteeRepo = if ($env:DWS_GITEE_REPO) { $env:DWS_GITEE_REPO } else { "" }
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$Version = if ($env:DWS_VERSION) { $env:DWS_VERSION } else { "latest" }
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
@@ -118,8 +122,47 @@ function Get-Arch {
Write-Err "Unsupported architecture: Could not detect system architecture. Please set DWS_ARCH environment variable to 'amd64' or 'arm64'."
}
+function Invoke-GiteeApi {
+ param([string]$Uri)
+ # Gitee's gateway returns sporadic 502/503, so retry a few times before failing.
+ for ($i = 1; $i -le 4; $i++) {
+ try {
+ return Invoke-RestMethod -Uri $Uri -UseBasicParsing
+ } catch {
+ if ($i -eq 4) { throw }
+ Start-Sleep -Seconds 2
+ }
+ }
+}
+
+function Get-GiteeAssetUrl {
+ param([string]$Name)
+ # Resolve a release asset's download URL by name via the Gitee API
+ # (Gitee attachment URLs carry an unstable numeric id, so never template them).
+ $rel = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/releases/tags/$Version"
+ foreach ($a in $rel.assets) {
+ if ($a.name -eq $Name) { return $a.browser_download_url }
+ }
+ return ""
+}
+
function Resolve-LatestVersion {
if ($Version -eq "latest") {
+ if ($GiteeRepo -ne "") {
+ try {
+ # Gitee's /releases/latest and /releases endpoints are unreliable
+ # (404 / empty even when releases exist), so resolve the newest
+ # vN.N.N tag from the git tags endpoint instead.
+ $tags = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/tags"
+ $latest = $tags.name |
+ Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } |
+ ForEach-Object { [version]($_.TrimStart('v')) } |
+ Sort-Object | Select-Object -Last 1
+ if ($latest) { $script:Version = "v$latest"; return }
+ } catch {}
+ Write-Err "Could not determine the latest Gitee version. Set `$env:DWS_VERSION explicitly."
+ return
+ }
try {
$response = Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" `
-MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing 2>$null
@@ -289,7 +332,8 @@ function Install-Binary {
Resolve-LatestVersion
$archiveName = "${BinName}-windows-${arch}.zip"
- $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName"
+ if ($GiteeRepo -ne "") { $downloadUrl = Get-GiteeAssetUrl $archiveName } else { $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName" }
+ if (-not $downloadUrl) { Write-Err "Could not resolve download URL for $archiveName (version $Version)." }
Write-Say "⬇ Downloading $BinName $Version (windows/$arch)..."
@@ -301,7 +345,7 @@ function Install-Binary {
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
# Download and verify SHA256 checksum
- $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt"
+ if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
try {
$checksumPath = Join-Path $tmpDir "checksums.txt"
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing
@@ -483,7 +527,8 @@ function Install-Skills {
Write-Say "📦 Installing agent skills from GitHub Releases..."
Resolve-LatestVersion
- $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip"
+ if ($GiteeRepo -ne "") { $zipUrl = Get-GiteeAssetUrl "dws-skills.zip" } else { $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" }
+ if (-not $zipUrl) { Write-Err "Could not resolve download URL for dws-skills.zip (version $Version)." }
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "dws-skills-$PID"
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
diff --git a/scripts/install.sh b/scripts/install.sh
index 87512e95..7406c100 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -15,6 +15,8 @@
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
+# DWS_GITEE_REPO — "owner/repo" on Gitee; when set, version + assets resolve
+# via the Gitee API instead of GitHub (China mirror)
#
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
@@ -22,6 +24,10 @@ set -eu
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
BIN_NAME="dws"
+# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via
+# the Gitee API (https://gitee.com/api/v5) instead of GitHub. Gitee attachment URLs
+# carry an unstable numeric id, so every asset is resolved by name at install time.
+GITEE_REPO="${DWS_GITEE_REPO:-}"
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}"
VERSION="${DWS_VERSION:-latest}"
@@ -77,6 +83,42 @@ download() {
fi
}
+# Fetch a Gitee API endpoint, retrying transient failures. Gitee's gateway
+# returns sporadic 502/503, so a single curl is unreliable; retry a few times
+# before giving up. Prints the response body on success.
+gitee_api() {
+ _url="$1"
+ _try=1
+ while [ "$_try" -le 4 ]; do
+ if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
+ printf '%s' "$_resp"
+ return 0
+ fi
+ _try=$((_try + 1))
+ sleep 2
+ done
+ return 1
+}
+
+# Resolve the download URL of a release asset by file name.
+# GitHub: deterministic template /releases/download//.
+# Gitee : query the release API and pick the asset whose name matches
+# (Gitee attachment URLs carry an unstable numeric id, so we never
+# template them — we read browser_download_url straight from the API).
+asset_url() {
+ _name="$1"
+ if [ -z "$GITEE_REPO" ]; then
+ printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
+ return 0
+ fi
+ gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
+ | tr '}' '\n' \
+ | grep "\"name\":[ ]*\"${_name}\"" \
+ | grep -o '"browser_download_url":[ ]*"[^"]*"' \
+ | head -1 \
+ | sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
+}
+
extract_zip() {
archive="$1"
dest="$2"
@@ -114,8 +156,18 @@ detect_arch() {
# Resolve the latest version tag from GitHub
resolve_version() {
if [ "$VERSION" = "latest" ]; then
- # Follow the redirect from /releases/latest to get the tag
- if need_cmd curl; then
+ if [ -n "$GITEE_REPO" ]; then
+ # Gitee's /releases/latest and /releases endpoints are unreliable — they
+ # return 404 / an empty list even when releases exist — so resolve the
+ # newest version from the git tags endpoint instead: keep only vN.N.N
+ # tags and pick the highest by version order.
+ VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
+ | grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
+ | sed 's/.*"name":[ ]*"//;s/"$//' \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
+ | sort -V | tail -1)"
+ elif need_cmd curl; then
+ # Follow the redirect from /releases/latest to get the tag
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
elif need_cmd wget; then
@@ -401,7 +453,8 @@ install_binary() {
resolve_version
archive_name="${BIN_NAME}-${os}-${arch}.tar.gz"
- download_url="https://github.com/${REPO}/releases/download/${VERSION}/${archive_name}"
+ download_url="$(asset_url "$archive_name")"
+ [ -n "$download_url" ] || err "Could not resolve download URL for ${archive_name} (version ${VERSION})."
say "⬇ Downloading ${BIN_NAME} ${VERSION} (${os}/${arch})..."
@@ -411,7 +464,7 @@ install_binary() {
download "$download_url" "$tmpdir/$archive_name"
# Download and verify SHA256 checksum
- checksum_url="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
+ checksum_url="$(asset_url checksums.txt)"
if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then
expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")"
if [ -n "$expected" ]; then
@@ -482,7 +535,8 @@ install_skills() {
resolve_version
skills_archive="dws-skills.zip"
- download_url="https://github.com/${REPO}/releases/download/${VERSION}/${skills_archive}"
+ download_url="$(asset_url "$skills_archive")"
+ [ -n "$download_url" ] || err "Could not resolve download URL for ${skills_archive} (version ${VERSION})."
tmpdir_skills="$(mktemp -d)"
trap 'rm -rf "$tmpdir_skills"' EXIT INT TERM
diff --git a/scripts/release/sync-to-gitee.sh b/scripts/release/sync-to-gitee.sh
new file mode 100755
index 00000000..3ee75504
--- /dev/null
+++ b/scripts/release/sync-to-gitee.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Copyright 2026 Alibaba Group
+# Licensed under the Apache License, Version 2.0
+#
+# Mirror release artifacts to a Gitee release so China users can install without
+# hitting GitHub. The repo code itself is kept in sync by Gitee's repo-mirror
+# feature; this script handles what the mirror does NOT carry — the GitHub
+# Release *attachments* (binaries, checksums, skills zip) — by uploading them to
+# the matching Gitee release via the Gitee OpenAPI v5.
+#
+# Consumed by install.sh when DWS_GITEE_REPO is set (it resolves each asset's
+# real download_url from the Gitee API, since Gitee attachment URLs carry an
+# unstable numeric id).
+#
+# Required environment (CI secrets):
+# GITEE_TOKEN Gitee private access token (scopes: projects)
+# GITEE_REPO "owner/repo" on Gitee, e.g. DingTalk-Real-AI/dingtalk-workspace-cli
+# Optional:
+# VERSION release tag (default: git describe)
+# DIST_DIR artifacts dir (default: ./dist)
+# GITEE_API API base (default: https://gitee.com/api/v5)
+#
+# Gating: if GITEE_TOKEN / GITEE_REPO are unset, exit 0 with a notice so the
+# step can live in release.yml without breaking forks that lack the secret.
+
+set -eu
+
+DIST_DIR="${DIST_DIR:-dist}"
+GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
+
+missing=""
+[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
+[ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO"
+if [ -n "$missing" ]; then
+ echo "ℹ️ Gitee mirror sync skipped — missing:${missing}"
+ echo " Set these as repo secrets to auto-mirror releases to Gitee for China users."
+ exit 0
+fi
+
+VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
+OWNER="${GITEE_REPO%%/*}"
+NAME="${GITEE_REPO##*/}"
+base="${GITEE_API}/repos/${OWNER}/${NAME}"
+
+echo "📦 Mirroring release ${VERSION} → Gitee ${GITEE_REPO}"
+
+# ── Resolve or create the Gitee release for this tag ──────────────────────────
+# Gitee mirror sync brings the git tag over, so the tag should already exist.
+rel_json="$(curl -fsSL "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
+release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
+
+if [ -z "$release_id" ]; then
+ echo " No Gitee release for ${VERSION} yet — creating it."
+ rel_json="$(curl -fsSL -X POST "${base}/releases" \
+ -F "access_token=${GITEE_TOKEN}" \
+ -F "tag_name=${VERSION}" \
+ -F "name=${VERSION}" \
+ -F "body=Mirror of GitHub release ${VERSION} for China users." \
+ -F "target_commitish=main" 2>/dev/null || true)"
+ release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
+fi
+[ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; }
+echo " Gitee release id = ${release_id}"
+
+# ── Upload each artifact as a release attachment ──────────────────────────────
+uploaded=0
+for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
+ [ -f "$f" ] || continue
+ fn="$(basename "$f")"
+ echo " ⬆ ${fn}"
+ resp="$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
+ -F "access_token=${GITEE_TOKEN}" \
+ -F "file=@${f}" 2>/dev/null || true)"
+ if printf '%s' "$resp" | grep -q '"browser_download_url"'; then
+ uploaded=$((uploaded + 1))
+ else
+ echo " ⚠ upload may have failed for ${fn}: ${resp}" >&2
+ fi
+done
+
+[ "$uploaded" -gt 0 ] || { echo "❌ No artifacts uploaded. Did the build (goreleaser) run?" >&2; exit 1; }
+echo "✅ Uploaded ${uploaded} asset(s) to Gitee release ${VERSION}."
+echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
+echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
From b175acb48fba4c04dc49c49a786a30afbd369f5d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Wed, 24 Jun 2026 17:55:23 +0800
Subject: [PATCH 23/25] feat(skill): embed skills/ into the binary, default
skill setup to it (#488)
go:embed the skills/ tree (mono + multi) into the binary and make
`dws skill setup` install from the embedded bundle by default. Upgrading
the binary now refreshes the installed skill, instead of silently reusing
a stale copy probed from the current working directory. An explicit
--source / DWS_SKILL_SOURCE still overrides for development.
Replants the change from #441 onto main: #441 was stacked on the
abandoned feat/chat-bot-provisioning-openclaw base (PRs #407/#397 closed),
which is why its CI was red. The embedded-bundle test asserted a
connect.md doc that only existed on that dead base; retargeted it to a
reference doc that exists on main.
---
internal/app/skill_setup.go | 3 +-
internal/app/skill_setup_embed.go | 86 ++++++++++++++++++++++++++
internal/app/skill_setup_embed_test.go | 68 ++++++++++++++++++++
skills_embed.go | 27 ++++++++
4 files changed, 183 insertions(+), 1 deletion(-)
create mode 100644 internal/app/skill_setup_embed.go
create mode 100644 internal/app/skill_setup_embed_test.go
create mode 100644 skills_embed.go
diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go
index 4a43f195..020a48f2 100644
--- a/internal/app/skill_setup.go
+++ b/internal/app/skill_setup.go
@@ -95,10 +95,11 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
}
- skillSrc, err := resolveSkillSetupSource(source, mode)
+ skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
if err != nil {
return err
}
+ defer srcCleanup()
dests, err := resolveSkillSetupTargets(target, mode)
if err != nil {
diff --git a/internal/app/skill_setup_embed.go b/internal/app/skill_setup_embed.go
new file mode 100644
index 00000000..635c4b6b
--- /dev/null
+++ b/internal/app/skill_setup_embed.go
@@ -0,0 +1,86 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
+)
+
+// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
+// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
+// override (validated as an on-disk dir). Otherwise it falls back to the skill
+// bundle embedded in THIS binary, so a plain `dws skill setup` always installs
+// the version shipped with the running binary — upgrading the binary therefore
+// refreshes the installed skill, instead of silently reusing a stale copy from
+// the current working directory.
+//
+// The returned cleanup func removes any temp dir created for the embedded
+// bundle; it is a no-op when an on-disk source is used. Always call it.
+func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), error) {
+ noop := func() {}
+ explicit = strings.TrimSpace(explicit)
+ env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE"))
+ if explicit != "" || env != "" {
+ dir, err := resolveSkillSetupSource(explicit, mode)
+ return dir, noop, err
+ }
+ return materializeEmbeddedSkillSource(mode)
+}
+
+// materializeEmbeddedSkillSource extracts the embedded skills/ subtree
+// into a fresh temp dir and returns its path plus a cleanup func. Reusing a
+// real directory lets the existing dir-based install/copy logic stay unchanged.
+func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
+ noop := func() {}
+ sub := "skills/" + mode // embed.FS always uses forward slashes
+ if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil {
+ return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
+ }
+
+ tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-")
+ if err != nil {
+ return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
+ }
+ cleanup := func() { _ = os.RemoveAll(tmp) }
+
+ walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
+ dst := filepath.Join(tmp, filepath.FromSlash(rel))
+ if d.IsDir() {
+ return os.MkdirAll(dst, 0o755)
+ }
+ data, readErr := dwsroot.EmbeddedSkills.ReadFile(p)
+ if readErr != nil {
+ return readErr
+ }
+ if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
+ return mkErr
+ }
+ return os.WriteFile(dst, data, 0o644)
+ })
+ if walkErr != nil {
+ cleanup()
+ return "", noop, fmt.Errorf("展开内嵌 skill 到临时目录失败: %w", walkErr)
+ }
+ return tmp, cleanup, nil
+}
diff --git a/internal/app/skill_setup_embed_test.go b/internal/app/skill_setup_embed_test.go
new file mode 100644
index 00000000..6e1cbf2a
--- /dev/null
+++ b/internal/app/skill_setup_embed_test.go
@@ -0,0 +1,68 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestMaterializeEmbeddedSkillSourceMono verifies that the mono skill bundle
+// baked into the binary can be extracted to a temp dir and is a valid skill
+// source root (so `dws skill setup` works with zero local checkout). The
+// nested-reference and _common checks guard against the embed dropping nested
+// docs or the `all:` prefix being lost (which would silently skip
+// dot/underscore dirs).
+func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
+ dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMono)
+ if err != nil {
+ t.Fatalf("materializeEmbeddedSkillSource: %v", err)
+ }
+ defer cleanup()
+
+ if !isSkillSourceRoot(dir, skillSetupModeMono) {
+ t.Fatalf("extracted dir %s is not a valid mono skill source root", dir)
+ }
+ for _, rel := range []string{
+ "SKILL.md",
+ filepath.Join("references", "global-reference.md"),
+ filepath.Join("references", "best_practices", "_common"),
+ } {
+ if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
+ t.Errorf("expected embedded skill to contain %s: %v", rel, err)
+ }
+ }
+
+ // cleanup must actually remove the temp dir.
+ cleanup()
+ if _, err := os.Stat(dir); !os.IsNotExist(err) {
+ t.Errorf("cleanup did not remove temp dir %s (err=%v)", dir, err)
+ }
+}
+
+// TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded verifies that with
+// no --source and no DWS_SKILL_SOURCE, resolution uses the embedded bundle
+// rather than probing the current working directory (the stale-skill footgun).
+func TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded(t *testing.T) {
+ t.Setenv("DWS_SKILL_SOURCE", "")
+ dir, cleanup, err := resolveSkillSetupSourceOrEmbedded("", skillSetupModeMono)
+ if err != nil {
+ t.Fatalf("resolveSkillSetupSourceOrEmbedded: %v", err)
+ }
+ defer cleanup()
+ if !isSkillSourceRoot(dir, skillSetupModeMono) {
+ t.Fatalf("embedded fallback returned non-source-root dir %s", dir)
+ }
+}
diff --git a/skills_embed.go b/skills_embed.go
new file mode 100644
index 00000000..9b8bd791
--- /dev/null
+++ b/skills_embed.go
@@ -0,0 +1,27 @@
+// 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 dws is the module-root package. Its sole purpose is to bake the
+// skills/ documentation tree into the binary at build time so that
+// `dws skill setup` installs the skill version shipped with THIS binary,
+// independent of any local checkout. See internal/app/skill_setup.go.
+package dws
+
+import "embed"
+
+// EmbeddedSkills holds the bundled skills/ tree (mono + multi) compiled into
+// the binary. The `all:` prefix is required so dot/underscore entries — e.g.
+// references/best_practices/_common — are included rather than skipped.
+//
+//go:embed all:skills
+var EmbeddedSkills embed.FS
From 9fa76f85983b99654d001d82dbfc317fbcb6d628 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Wed, 24 Jun 2026 17:55:35 +0800
Subject: [PATCH 24/25] docs: cut 1.0.40 changelog + fix gitee mirror gate
(#489)
- CHANGELOG: 1.0.40 (China mirror via Gitee + npmmirror, #486)
- mirror-to-gitee.yml: gate on GITEE_PRIVATE_KEY so it skips cleanly when the
SSH key is unset (code mirror handled by Gitee-side pull-mirror) instead of
failing the run.
---
.github/workflows/mirror-to-gitee.yml | 8 +++++---
CHANGELOG.md | 8 ++++++++
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/mirror-to-gitee.yml b/.github/workflows/mirror-to-gitee.yml
index 4e4aa299..a366abc5 100644
--- a/.github/workflows/mirror-to-gitee.yml
+++ b/.github/workflows/mirror-to-gitee.yml
@@ -18,13 +18,15 @@ on:
jobs:
mirror:
runs-on: ubuntu-latest
- # GitHub Actions 不允许在 job-level if 直接引用 secrets,
- # 因此先用 env 暴露 GITEE_TOKEN,再在 step 里用 env.GITEE_TOKEN 做守卫判断。
+ # GitHub Actions 不允许在 job-level if 直接引用 secrets,因此先用 env 暴露 secret,
+ # 再在 step 里做守卫判断。hub-mirror 必须有 SSH 私钥(GITEE_PRIVATE_KEY)才能推送,
+ # 故以它为守卫:未配置时整步跳过(代码镜像可改由 Gitee 侧 pull-mirror 承担),不报红叉。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
+ GITEE_PRIVATE_KEY: ${{ secrets.GITEE_PRIVATE_KEY }}
steps:
- name: Mirror to Gitee
- if: env.GITEE_TOKEN != ''
+ if: env.GITEE_PRIVATE_KEY != ''
uses: Yikun/hub-mirror-action@master
with:
src: "github/DingTalk-Real-AI"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f766f6f..2de85f5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
+## [1.0.40] - 2026-06-24
+
+This release adds China-accessible install mirrors so the CLI installs reliably from mainland China, where GitHub raw + Releases are slow or fail.
+
+### Added
+
+- **China mirror via Gitee + npmmirror** (#486; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`, `scripts/release/sync-to-gitee.sh`, `.github/workflows/release.yml`, `.github/workflows/mirror-to-gitee.yml`) — an opt-in `DWS_GITEE_REPO` env var makes all three installers resolve the latest version and every release asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee OpenAPI v5 instead of GitHub; with it unset, installation defaults to GitHub (fully backward compatible). The release pipeline mirrors release attachments to the matching Gitee release after each tag (gated on `GITEE_TOKEN`/`GITEE_REPO`), and a hub-mirror workflow keeps the repo code in sync (gated on `GITEE_PRIVATE_KEY`). README documents three China install channels: Gitee raw script, Gitee release binaries, and the npm package via `registry.npmmirror.com`.
+
## [1.0.39] - 2026-06-18
This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
From bd370ed0e904e341a4b4d2d8de1d5312df6aa0a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Wed, 24 Jun 2026 18:34:16 +0800
Subject: [PATCH 25/25] fix(doc): strip duplicate title H1 on the JSONML
doc-create path (#464)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #448 stripped a leading H1 matching --name on the markdown path of
`dws doc create`, but the JSONML path (--content-format jsonml) was never
covered. Rich documents — tables, callouts, styled blocks — go through
create_document + update_document(jsonml=...), which writes the body
verbatim, so a leading h1 whose text equals the document name renders the
title twice (the "two headings" effect the doc platform shows because it
already renders --name as the page title).
Add stripLeadingDuplicateTitleJSONML: parse the marshaled JSONML body,
skip an optional ["root", {}, ...] wrapper, and drop the first node when
it is an h1 whose concatenated leaf text equals --name (trimmed,
case-insensitive). Any parse failure or non-match leaves the body
untouched, so a valid write is never blocked. A stderr note mirrors the
markdown path so agents learn the convention.
---
internal/helpers/doc.go | 81 +++++++++++++++++
internal/helpers/doc_title_dedup_test.go | 107 +++++++++++++++++++++++
2 files changed, 188 insertions(+)
diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go
index 4c68b914..2f4794ce 100644
--- a/internal/helpers/doc.go
+++ b/internal/helpers/doc.go
@@ -335,6 +335,10 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return err
}
+ if stripped, ok := stripLeadingDuplicateTitleJSONML(jsonml, name); ok {
+ fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首个与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
+ jsonml = stripped
+ }
createResult, err := docInvocationResult(cmd, runner, "doc", "create_document", params)
if err != nil {
return err
@@ -425,6 +429,83 @@ func stripLeadingDuplicateTitleHeading(content, name string) (string, bool) {
return strings.TrimLeft(rest, "\r\n"), true
}
+// stripLeadingDuplicateTitleJSONML is the JSONML-path counterpart of
+// stripLeadingDuplicateTitleHeading. The rich-content path (--content-format
+// jsonml) writes the body verbatim via update_document, so a leading h1 node
+// whose text equals the document name renders the title twice — exactly the
+// problem the markdown path already guards against.
+//
+// jsonmlBody is the marshaled body produced by prepareDocJSONMLBody: a JSON
+// array, either a bare node list or wrapped as ["root", {}, ...nodes]. The
+// first content node is removed only when it is an h1 whose concatenated text
+// (trimmed, case-insensitive) exactly equals name. Any parse failure or
+// non-match returns the input untouched, so this never blocks a valid write.
+func stripLeadingDuplicateTitleJSONML(jsonmlBody, name string) (string, bool) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return jsonmlBody, false
+ }
+ var body []any
+ if err := json.Unmarshal([]byte(jsonmlBody), &body); err != nil {
+ return jsonmlBody, false
+ }
+ // Locate the index of the first content node, skipping a ["root", {}, ...]
+ // wrapper if present.
+ first := 0
+ if len(body) >= 2 {
+ if tag, ok := body[0].(string); ok && tag == "root" {
+ first = 2
+ }
+ }
+ if first >= len(body) {
+ return jsonmlBody, false
+ }
+ node, ok := body[first].([]any)
+ if !ok || len(node) == 0 {
+ return jsonmlBody, false
+ }
+ tag, ok := node[0].(string)
+ if !ok || tag != "h1" {
+ return jsonmlBody, false
+ }
+ if !strings.EqualFold(strings.TrimSpace(jsonmlNodeText(node)), name) {
+ return jsonmlBody, false
+ }
+ body = append(body[:first], body[first+1:]...)
+ out, err := json.Marshal(body)
+ if err != nil {
+ return jsonmlBody, false
+ }
+ return string(out), true
+}
+
+// jsonmlNodeText concatenates all string leaves of a JSONML node, skipping the
+// leading tag name and any attributes object, so ["h1", {..}, ["span", {..},
+// ["span", {..}, "Title"]]] yields "Title".
+func jsonmlNodeText(node any) string {
+ switch v := node.(type) {
+ case string:
+ return v
+ case []any:
+ var b strings.Builder
+ for i, child := range v {
+ // element[0] is the tag name; a map at element[1] is attributes.
+ if i == 0 {
+ if _, isStr := child.(string); isStr {
+ continue
+ }
+ }
+ if _, isMap := child.(map[string]any); isMap {
+ continue
+ }
+ b.WriteString(jsonmlNodeText(child))
+ }
+ return b.String()
+ default:
+ return ""
+ }
+}
+
func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
diff --git a/internal/helpers/doc_title_dedup_test.go b/internal/helpers/doc_title_dedup_test.go
index 6d5bf16b..0219ecb2 100644
--- a/internal/helpers/doc_title_dedup_test.go
+++ b/internal/helpers/doc_title_dedup_test.go
@@ -159,3 +159,110 @@ func TestDocCreateTitleOnlyContentOmitsMarkdown(t *testing.T) {
t.Errorf("markdown param = %v, want omitted", runner.last.Params["markdown"])
}
}
+
+// TestDocCreateStripsDuplicateTitleJSONML verifies the end-to-end JSONML path:
+// `doc create --content-format jsonml` must drop a leading h1 whose text equals
+// --name before forwarding the body to update_document, otherwise the rich
+// document shows the page title twice.
+func TestDocCreateStripsDuplicateTitleJSONML(t *testing.T) {
+ runner := &docCommandRunner{responses: []map[string]any{{"nodeId": "NODE_X"}}}
+ root := newDocTestRoot(runner)
+
+ body := `{"jsonml":[["h1",{},"命令树参考"],["p",{},"正文"]]}`
+ _, errOut, err := executeDocCommand(t, root,
+ "create", "--name", "命令树参考",
+ "--content-format", "jsonml", "--content", body)
+ if err != nil {
+ t.Fatalf("execute: %v\nstderr:\n%s", err, errOut)
+ }
+ if len(runner.all) != 2 {
+ t.Fatalf("calls = %d, want 2 (create + update)", len(runner.all))
+ }
+ if runner.all[1].Tool != "update_document" {
+ t.Fatalf("second tool = %q, want update_document", runner.all[1].Tool)
+ }
+ got, _ := runner.all[1].Params["jsonml"].(string)
+ if strings.Contains(got, "命令树参考") {
+ t.Errorf("jsonml = %q, want duplicate h1 stripped", got)
+ }
+ if !strings.Contains(got, "正文") {
+ t.Errorf("jsonml = %q, want body content kept", got)
+ }
+ if !strings.Contains(errOut, "已自动移除") {
+ t.Errorf("stderr = %q, want a note about the removed heading", errOut)
+ }
+}
+
+// TestStripLeadingDuplicateTitleJSONML covers the JSONML-path counterpart: a
+// leading h1 node whose text equals the document name is removed; everything
+// else is left untouched.
+func TestStripLeadingDuplicateTitleJSONML(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ docName string
+ want string
+ stripped bool
+ }{
+ {
+ name: "root-wrapped duplicate h1 stripped",
+ body: `["root",{},["h1",{},"晚会简报"],["p",{},"正文"]]`,
+ docName: "晚会简报",
+ want: `["root",{},["p",{},"正文"]]`,
+ stripped: true,
+ },
+ {
+ name: "nested leaf text matches and stripped",
+ body: `["root",{},["h1",{"uuid":"x"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"晚会简报"]]],["p",{},"正文"]]`,
+ docName: "晚会简报",
+ want: `["root",{},["p",{},"正文"]]`,
+ stripped: true,
+ },
+ {
+ name: "bare body without root wrapper",
+ body: `[["h1",{},"标题"],["p",{},"正文"]]`,
+ docName: "标题",
+ want: `[["p",{},"正文"]]`,
+ stripped: true,
+ },
+ {
+ name: "case insensitive match",
+ body: `["root",{},["h1",{},"Weekly REPORT"],["p",{},"x"]]`,
+ docName: "weekly report",
+ want: `["root",{},["p",{},"x"]]`,
+ stripped: true,
+ },
+ {
+ name: "distinct heading kept",
+ body: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`,
+ docName: "晚会简报",
+ want: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`,
+ stripped: false,
+ },
+ {
+ name: "non-h1 leading node kept",
+ body: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`,
+ docName: "晚会简报",
+ want: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`,
+ stripped: false,
+ },
+ {
+ name: "invalid json untouched",
+ body: `not json`,
+ docName: "x",
+ want: `not json`,
+ stripped: false,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, ok := stripLeadingDuplicateTitleJSONML(tc.body, tc.docName)
+ if ok != tc.stripped {
+ t.Fatalf("stripped = %v, want %v", ok, tc.stripped)
+ }
+ if got != tc.want {
+ t.Fatalf("body = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}