Compare commits
401
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5dbefb27e | ||
|
|
1c1abf3069 | ||
|
|
043881e0e3 | ||
|
|
b63e1b4469 | ||
|
|
bba94c0092 | ||
|
|
fe4a79283c | ||
|
|
3ee5f13c62 | ||
|
|
e32fa1535c | ||
|
|
79b8eda3b6 | ||
|
|
be80790172 | ||
|
|
2dbbca1ec9 | ||
|
|
b214c0a06c | ||
|
|
d3087d170b | ||
|
|
49637d982e | ||
|
|
4c5f1faeb1 | ||
|
|
5833e71751 | ||
|
|
0e690fbe4e | ||
|
|
f7e8106a72 | ||
|
|
87a9b5b9be | ||
|
|
97678e6441 | ||
|
|
67090ae09f | ||
|
|
6f042f9167 | ||
|
|
78dd4aaa4b | ||
|
|
088a4d67ae | ||
|
|
81f5245c8a | ||
|
|
c4946c3eaf | ||
|
|
a0b956a780 | ||
|
|
5e4d974039 | ||
|
|
bb641c2098 | ||
|
|
7e1e93478b | ||
|
|
101e7be98d | ||
|
|
9545c1dde5 | ||
|
|
81367b2861 | ||
|
|
97bab33c71 | ||
|
|
ef6d65087f | ||
|
|
30a024e615 | ||
|
|
00e5ce7367 | ||
|
|
760c40dd71 | ||
|
|
aaa2d7d3be | ||
|
|
fbfcd69c2b | ||
|
|
fab9e5be52 | ||
|
|
ad056a8d83 | ||
|
|
0de6e46ef4 | ||
|
|
9a8157a4cd | ||
|
|
b8e915edfb | ||
|
|
5e254b4745 | ||
|
|
29a8a14760 | ||
|
|
00bef0a809 | ||
|
|
3f5b2fa8d3 | ||
|
|
b0e7b58e95 | ||
|
|
fe46cd4dc2 | ||
|
|
c993086d9d | ||
|
|
f522a9c2c2 | ||
|
|
bd370ed0e9 | ||
|
|
9fa76f8598 | ||
|
|
b175acb48f | ||
|
|
d89649f9bb | ||
|
|
f218a05ead | ||
|
|
ed1cbd6f06 | ||
|
|
62aef1cc96 | ||
|
|
fc5e4d0d8d | ||
|
|
34248fabf3 | ||
|
|
430d20f2ee | ||
|
|
ada4acc395 | ||
|
|
8bebd77dfa | ||
|
|
b096fa06db | ||
|
|
107f3eaf6e | ||
|
|
df0c0f7545 | ||
|
|
91e67e2e45 | ||
|
|
75468dca1e | ||
|
|
ef5c05a2e6 | ||
|
|
1ac8636418 | ||
|
|
86086437d8 | ||
|
|
d432029d84 | ||
|
|
029bfdd2ed | ||
|
|
8c0551f359 | ||
|
|
b839ee664e | ||
|
|
c16c4aa59a | ||
|
|
aecc0bc588 | ||
|
|
cfad3bbf4a | ||
|
|
40736e2ae1 | ||
|
|
9aeb60da37 | ||
|
|
d3fcd814f3 | ||
|
|
5ed69744cc | ||
|
|
e5c9c91342 | ||
|
|
0e59fedbbb | ||
|
|
c39378864d | ||
|
|
7cb33e970c | ||
|
|
567cf163f1 | ||
|
|
d9ae15f9a6 | ||
|
|
922745f318 | ||
|
|
58dced0c8a | ||
|
|
3427b65da6 | ||
|
|
9d38a3be54 | ||
|
|
1cda263e0e | ||
|
|
5d5884a0d7 | ||
|
|
4a26f1ebd2 | ||
|
|
b65be61599 | ||
|
|
e193ba97f4 | ||
|
|
787a40ffcb | ||
|
|
f69d9ed281 | ||
|
|
f2b2de89f4 | ||
|
|
7915a5a4e1 | ||
|
|
91f44a1efd | ||
|
|
6067906c55 | ||
|
|
493ca36e08 | ||
|
|
1a7ba01e36 | ||
|
|
6310dcc39e | ||
|
|
518b1cb631 | ||
|
|
f29655e7e4 | ||
|
|
cc8a726b0a | ||
|
|
f0552dd20e | ||
|
|
a040b57be7 | ||
|
|
a2e8700beb | ||
|
|
98804801c9 | ||
|
|
6946dd1e35 | ||
|
|
d09fca4b9b | ||
|
|
70d66daa5f | ||
|
|
1926bd17b9 | ||
|
|
6a392e611c | ||
|
|
710844382e | ||
|
|
b1a88106e5 | ||
|
|
dcf132a7e3 | ||
|
|
d2fd7e00b5 | ||
|
|
7720e3ec9b | ||
|
|
537719c677 | ||
|
|
51db546686 | ||
|
|
e9d50a659f | ||
|
|
2264743e78 | ||
|
|
6244222adf | ||
|
|
a31a29e0ab | ||
|
|
3ec35e30e8 | ||
|
|
da08e84e80 | ||
|
|
5e1983b3df | ||
|
|
8984f7b8be | ||
|
|
dd08db54d8 | ||
|
|
591609fdee | ||
|
|
6447bdd45f | ||
|
|
2610c8ce87 | ||
|
|
0e6cb46f58 | ||
|
|
a613728fbd | ||
|
|
70b9f39715 | ||
|
|
d29aebeecd | ||
|
|
844059a77b | ||
|
|
9ee8ea5524 | ||
|
|
42c71c9c54 | ||
|
|
690d0788e3 | ||
|
|
5a7c0748e3 | ||
|
|
410949cdf3 | ||
|
|
f1a68f2424 | ||
|
|
497e4f87d8 | ||
|
|
a8d009aec8 | ||
|
|
1f413fa322 | ||
|
|
0197dbc81a | ||
|
|
b85353e042 | ||
|
|
ece91bfa3c | ||
|
|
f740955423 | ||
|
|
166f665708 | ||
|
|
55f5e0c3d0 | ||
|
|
f58b426381 | ||
|
|
2640338803 | ||
|
|
dbcbb1de85 | ||
|
|
63b6112fa2 | ||
|
|
eaeb9ac05d | ||
|
|
e82ccd3496 | ||
|
|
b4c9db8807 | ||
|
|
aa76f01015 | ||
|
|
931d7e59ef | ||
|
|
db2043c82b | ||
|
|
c2f3653ec4 | ||
|
|
11dfd4ccf2 | ||
|
|
ef60d99b26 | ||
|
|
1b6e197426 | ||
|
|
41b743de77 | ||
|
|
caf672699f | ||
|
|
ff33114b2c | ||
|
|
67ac777657 | ||
|
|
c9ba0373c2 | ||
|
|
4a19530fd0 | ||
|
|
7a28d97739 | ||
|
|
d63f7e5836 | ||
|
|
8aaf0fb6e8 | ||
|
|
0f3c4ccbdd | ||
|
|
1f21fdf7be | ||
|
|
b2fd204e67 | ||
|
|
6f8c9173d3 | ||
|
|
1921e5e37e | ||
|
|
6480c035fa | ||
|
|
1c88dd6a0d | ||
|
|
6ce4a635b2 | ||
|
|
ee2fa735b2 | ||
|
|
a9df88654d | ||
|
|
e58e805b17 | ||
|
|
d1d6d9e74c | ||
|
|
b75f07547f | ||
|
|
ccb4927c48 | ||
|
|
1a2454a5ab | ||
|
|
0a8de62041 | ||
|
|
d6f44143ad | ||
|
|
4d831e5054 | ||
|
|
2c51774150 | ||
|
|
a10169344f | ||
|
|
82d625603f | ||
|
|
11780ae3b0 | ||
|
|
79bab762bf | ||
|
|
e840cac3cc | ||
|
|
45b4f088af | ||
|
|
b28ca6364e | ||
|
|
8b39dac5db | ||
|
|
7ecfe85696 | ||
|
|
9af7f9a034 | ||
|
|
c50494775c | ||
|
|
1fe4842525 | ||
|
|
5708e1c2d2 | ||
|
|
d43ec228aa | ||
|
|
fb335fb403 | ||
|
|
fd61526707 | ||
|
|
c858241b68 | ||
|
|
b826440995 | ||
|
|
25f69b867e | ||
|
|
172060b868 | ||
|
|
dd0990691e | ||
|
|
02bc7ce880 | ||
|
|
4d476ed317 | ||
|
|
ff89ffed69 | ||
|
|
65ab1a3076 | ||
|
|
cf277f3c8f | ||
|
|
59100407dc | ||
|
|
10de987e81 | ||
|
|
c36fcaaf70 | ||
|
|
0cf97cea55 | ||
|
|
47354b918a | ||
|
|
df1e33442b | ||
|
|
a7ca1e1a5b | ||
|
|
211a06c05a | ||
|
|
bb52a505dc | ||
|
|
f899f4f6b6 | ||
|
|
3adeffb09d | ||
|
|
435bf3151c | ||
|
|
1c90edb92a | ||
|
|
36b89a04b1 | ||
|
|
cdd8414891 | ||
|
|
9d3980f90a | ||
|
|
ae5ef70bb8 | ||
|
|
2a82d07311 | ||
|
|
89c3aba2ed | ||
|
|
c151756168 | ||
|
|
473d2f9aaf | ||
|
|
3dcb40c634 | ||
|
|
5e4b5e4eda | ||
|
|
60ac0b409d | ||
|
|
d873b9c017 | ||
|
|
4b8acc8e73 | ||
|
|
4bc4b60dca | ||
|
|
31e65dda51 | ||
|
|
0f65cb0064 | ||
|
|
f013536aac | ||
|
|
c56eb7f8c2 | ||
|
|
f9abc79ecc | ||
|
|
99893e473a | ||
|
|
51b3316187 | ||
|
|
9f744caafd | ||
|
|
532fcb4874 | ||
|
|
28d556c5e9 | ||
|
|
c106de8361 | ||
|
|
2e11a23381 | ||
|
|
2c1be7a8fb | ||
|
|
e637d793b0 | ||
|
|
864f94e80e | ||
|
|
7f1d36c9ac | ||
|
|
4eea61897d | ||
|
|
387ae5ff59 | ||
|
|
838e5453d8 | ||
|
|
330922cdee | ||
|
|
c99ac87839 | ||
|
|
1b3319603d | ||
|
|
995d490a76 | ||
|
|
5a401ddc1b | ||
|
|
f5bdb7118a | ||
|
|
4c86a9f8e1 | ||
|
|
eaa60f95b5 | ||
|
|
3117ad5d86 | ||
|
|
0e484bb189 | ||
|
|
de4a292174 | ||
|
|
ad43e1ba28 | ||
|
|
ac825ad293 | ||
|
|
8411211a8c | ||
|
|
449d731239 | ||
|
|
13d77ee7b7 | ||
|
|
694288cfbc | ||
|
|
e7a3010b81 | ||
|
|
e7ef2c4677 | ||
|
|
6f5a0afdf7 | ||
|
|
248c6602ac | ||
|
|
9589c23796 | ||
|
|
9460437c9a | ||
|
|
34d691906e | ||
|
|
a78765bb42 | ||
|
|
836f5bdb25 | ||
|
|
3282957958 | ||
|
|
252c61aff6 | ||
|
|
ffefc53861 | ||
|
|
8cbc56edd2 | ||
|
|
fc0873b0c6 | ||
|
|
8c2093a41a | ||
|
|
eea85bd989 | ||
|
|
43560afa78 | ||
|
|
7ef46c1288 | ||
|
|
08413a6903 | ||
|
|
c488421423 | ||
|
|
5fbf12fe50 | ||
|
|
dd419ca498 | ||
|
|
f826375556 | ||
|
|
b3e8783c4f | ||
|
|
cb95207d5a | ||
|
|
1c2c96617a | ||
|
|
5fe366eddd | ||
|
|
1e95d03606 | ||
|
|
6e3f3cbd24 | ||
|
|
ce5e919c52 | ||
|
|
a7d109384b | ||
|
|
c75ed45c70 | ||
|
|
a8b1670ad9 | ||
|
|
8c4bd71964 | ||
|
|
9e7e7dbc7f | ||
|
|
539d10f80f | ||
|
|
56a5edecef | ||
|
|
7652bda320 | ||
|
|
dd1c4e34fd | ||
|
|
e653616aa3 | ||
|
|
3ce64db2fc | ||
|
|
4bbd52fc58 | ||
|
|
9eb3099881 | ||
|
|
73e2de7fe8 | ||
|
|
8b4f05e44e | ||
|
|
a7879edca1 | ||
|
|
3f09eb5c0f | ||
|
|
4ebc8d0d38 | ||
|
|
202cb509a0 | ||
|
|
dd42fd833b | ||
|
|
4e58e45d30 | ||
|
|
b5f241c719 | ||
|
|
4cdc3e7320 | ||
|
|
b558bda6df | ||
|
|
5ce7cb61b0 | ||
|
|
86ff7e2f50 | ||
|
|
1063a6425d | ||
|
|
0a8517432f | ||
|
|
83bfca8d35 | ||
|
|
9e58a062f8 | ||
|
|
45cb237f74 | ||
|
|
bd711108f9 | ||
|
|
6b4d808d39 | ||
|
|
c7d8ddf98d | ||
|
|
754b0df056 | ||
|
|
6be124777f | ||
|
|
355a1460d9 | ||
|
|
c6edc84e40 | ||
|
|
f497047fff | ||
|
|
a9de7d3ca4 | ||
|
|
1c200d883f | ||
|
|
d268524084 | ||
|
|
ed4673e7d2 | ||
|
|
de723914a5 | ||
|
|
649801e479 | ||
|
|
49c5bea4f3 | ||
|
|
6707e56f9c | ||
|
|
2ba1dcdda4 | ||
|
|
1637ae16c7 | ||
|
|
eee19d7347 | ||
|
|
19f7b59ffb | ||
|
|
aa06d9d5c9 | ||
|
|
c4952d0207 | ||
|
|
ecf2684f58 | ||
|
|
9e9b898dd2 | ||
|
|
17f692e7f1 | ||
|
|
574d9aa2f7 | ||
|
|
1aaaef0274 | ||
|
|
00c037b5be | ||
|
|
9e15115ad4 | ||
|
|
25bf3d12f2 | ||
|
|
bb3d1270b9 | ||
|
|
f78cc5c846 | ||
|
|
72fe795f3f | ||
|
|
0e892c7d75 | ||
|
|
8995bf65d6 | ||
|
|
7d40210a00 | ||
|
|
ab445813b7 | ||
|
|
631a3829e4 | ||
|
|
88b4f4eeaa | ||
|
|
ee8af0c7d0 | ||
|
|
4f3501e904 | ||
|
|
b0108b9c21 | ||
|
|
4dc79fc931 | ||
|
|
130387eb36 | ||
|
|
0c2b2b4703 | ||
|
|
fed83761e5 | ||
|
|
55eb481899 | ||
|
|
eb3eecab1d | ||
|
|
3afbc046d8 | ||
|
|
738b64eaf4 |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#dd4343"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">54.2%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,75 @@
|
||||
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
|
||||
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
|
||||
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
|
||||
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
|
||||
# GITEE_REPO —— "owner/repo",如 DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
# 未配置 GITEE_TOKEN 时(如 fork)自动跳过,不报红叉。
|
||||
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 暴露再在 step 守卫。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
steps:
|
||||
- name: Checkout (full history + tags)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push main + tags to Gitee (with README localization)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
run: |
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
|
||||
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
|
||||
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
|
||||
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
|
||||
git checkout -B gitee-main origin/main
|
||||
git config user.email "actions@github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
|
||||
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
|
||||
done
|
||||
|
||||
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
|
||||
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
|
||||
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
|
||||
SVG=".github/badges/coverage.svg"
|
||||
if [ -f "$SVG" ]; then
|
||||
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
|
||||
NUM="${PCT%\%}"; INT="${NUM%.*}"
|
||||
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
|
||||
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
git add README.md README_zh.md 2>/dev/null || true
|
||||
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
|
||||
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Multi Profile E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: multi-profile-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
multi-profile-e2e:
|
||||
name: Multi Profile E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run isolated multi-profile chain
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p .tmp-bin
|
||||
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
|
||||
{
|
||||
echo "### Multi Profile E2E"
|
||||
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
|
||||
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
|
||||
echo "- Result: passed"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload debug artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: multi-profile-e2e-debug
|
||||
path: |
|
||||
.tmp-bin/multi-profile-e2e.*/out
|
||||
.tmp-bin/multi-profile-e2e.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -11,7 +12,10 @@ permissions:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
|
||||
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
|
||||
# idempotent (re-runs only upload missing assets).
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -27,6 +31,20 @@ jobs:
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Multi Profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
mkdir -p /tmp/rcodesign
|
||||
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
|
||||
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
|
||||
rcodesign --version
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -46,6 +64,28 @@ jobs:
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
|
||||
- name: Sync release to China OSS mirror
|
||||
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
|
||||
# 脚本自带门控:未配置 OSS_* secret 时优雅跳过,不影响海外发布。
|
||||
run: ./scripts/release/sync-to-oss.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
|
||||
|
||||
- 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:
|
||||
@@ -53,6 +93,8 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish to npm
|
||||
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Sync release to Gitee
|
||||
|
||||
# Manually mirror a published GitHub release's assets to the matching Gitee
|
||||
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
|
||||
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
|
||||
# step — it does not run GoReleaser and does not touch the GitHub release, so
|
||||
# there is no release outage. The sync script skips assets already on Gitee, so
|
||||
# this only uploads what is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
+13
@@ -26,8 +26,21 @@ test/cli_compat/testdata/
|
||||
*.pem
|
||||
*.key
|
||||
credentials*
|
||||
!skills/**/credentials.md
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
*.code-workspace
|
||||
/dingtalk-workspace.zip
|
||||
|
||||
# envelope/discovery.pre.json synced via Portal, not git-tracked
|
||||
/envelope/discovery.pre.json
|
||||
|
||||
# local/pre-release MCP service configs may contain personal gateway keys
|
||||
/docs/mcp/serviceconfig-pre*
|
||||
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
.vercel
|
||||
.env*
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@ changelog:
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: DingTalk-Real-AI
|
||||
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
|
||||
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
|
||||
name: dingtalk-workspace-cli
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
||||
+441
@@ -4,6 +4,447 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.47] - 2026-07-05
|
||||
|
||||
This release adds **connector supervision & health monitoring** (`dev connect list/status/restart/stop`) and fixes **bot-to-bot @-mention** delivery end-to-end.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dev connect list`** — PM2-style colored table enumerating all local connectors with state (healthy / degraded / down / not_running), PID, channel, and uptime.
|
||||
- **`dev connect status`** — panel view with heartbeat, last recv timestamp, session webhook age, and `--json` for external monitoring.
|
||||
- **`dev connect restart`** — restarts a daemon via persisted `daemon-state.json` (unified-app-id credential fetch, no local secret storage).
|
||||
- **`dev connect stop`** — graceful SIGTERM shutdown releasing the single-instance lock and Stream connection.
|
||||
- **Health watchdog** — background goroutine writes `heartbeat.json`; `status`/`list` derive state from heartbeat freshness + process liveness + pid-reuse detection.
|
||||
- **`--alwayson` flag** — opt-in auto-restart: supervisor relaunches the worker on crash (requires `--daemon`).
|
||||
- **`--notify-staff-id`** — state-change notifications (start / stop / crash) sent as DingTalk messages to the specified staffId.
|
||||
- **`--unified-app-id` credential flow for `dev connect`** — fetches clientId/clientSecret at startup via `dev app credentials get`, keeping secrets off the command line and out of `daemon-state.json`.
|
||||
- **API-sent file download** (`feat(connect): download API-sent files via storage v2 API`) — file messages sent via `dws chat message send --msg-type file --dentry-id --space-id` are now downloaded by the connector through the storage v2 `getDownloadInfo` API (dentryId + spaceId → presigned URL → local temp file), so file-based Q&A works regardless of how the file was sent.
|
||||
- **`--at-open-dingtalk-ids` for `chat message send-by-bot`** — @-mention bots or cross-org users by openDingTalkId in group messages.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bot-to-bot @-mention send side** — `atOpendingtalkIds` (the server's lowercase spelling) is now used instead of the camelCase `atOpenDingTalkIds` which was silently ignored. The unnecessary `openDingTalkId → userId` reverse lookup (always failed for bots) is removed; the id is forwarded verbatim.
|
||||
- **Bot-to-bot @-mention receive side** — `interactiveCard` messages (how DingTalk delivers a bot @-mentioning another bot) are now parsed: `extractInteractiveCardText` flattens `cardContent[].children[].value` leaves and strips the leading @-mention by leaf boundary. The `emotion/reply` reaction (which 500s on bot-sent cards) is skipped for `interactiveCard` turns.
|
||||
- **Markdown/richText body extraction** — `extractCallbackText` gains a `cardContent` fallback so structured-text messages are no longer silently dropped.
|
||||
- **Send-by-bot @ chip rendering** — `<@id>` placeholders in the markdown body are rewritten to `@id` for both userIds and openDingTalkIds so the mention chip renders in all cases.
|
||||
- **Connector retry on transient network errors** — `sendBySession` retries on transient failures instead of dropping the reply.
|
||||
- **Orphan worker cleanup & watchdog deadlock** — stale workers from a crashed supervisor are detected and cleaned; a channel-capacity fix prevents the watchdog from blocking.
|
||||
- **Idle connector false-down** — heartbeat ticker now advances `updatedUnix` so a connector with no inbound traffic is not marked degraded.
|
||||
- **FD limit check** — `checkFDLimit` split into platform files for Windows cross-compilation.
|
||||
- **Default agent timeout removed** — no timeout by default (was incorrectly defaulting to a low value).
|
||||
- **keepAlive shortened to 30 µs** — aligns with Stream SDK expectations; adds `ulimit` check for multi-agent stability.
|
||||
|
||||
## [1.0.46] - 2026-07-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PAT agentCode grants no longer split from follow-up command checks** (`internal/auth/agent_code_detect.go`, `internal/app/runner.go`, `internal/pat/chmod_test.go`) — explicit `DINGTALK_DWS_AGENTCODE` declarations are now forwarded verbatim as the common cross-host contract, and unknown hosts no longer synthesize `custom` into `x-dingtalk-dws-agent-code` / `x-dws-agent-instance-id`. `pat chmod --agentCode` remains the highest-priority grant target and still wins over the env fallback.
|
||||
|
||||
## [1.0.45] - 2026-06-29
|
||||
|
||||
This release adds **multi-organization (profile) support** (#500): `dws` can stay logged in to several DingTalk organizations at once and switch between them, while staying fully backward/forward compatible with the previous single-org token. A profile is one logged-in organization (corp); the current profile decides which org a command runs against. The release also hardens the new credential store for concurrency and corruption recovery, documents the capability in both the mono and multi skill sets, and flips `--ai-tag` on by default so messages sent through `dws` carry the DingTalk 「通过AI发送」 badge (#524).
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-organization login & `profile` management** (`internal/auth/profiles.go`, `internal/app/profile_command.go`) — `dws auth login` against a new organization adds a profile (the first login becomes the primary); `dws profile list` shows logged-in orgs with primary / current markers, status and validity; `dws profile switch <name|corpId|->` persistently switches the default org (`-` toggles back to the previous one, no-arg opens a TUI selector on a terminal); `dws profile use` is an alias of `switch`. `dws auth status [--profile <name>]` reports a specific profile. Credentials are stored per organization in keychain slots keyed by corpId (`auth-token:<corpId>`), with a plaintext `profiles.json` registry holding only metadata and the primary/current/previous pointers (no tokens).
|
||||
- **Global `--profile <name|corpId>` flag** — run a single command against a specific organization without changing the default (one-shot; does not move currentProfile). Cross-org reads are orchestrated by the agent (list profiles → query each with `--profile` → merge); there is intentionally no built-in `--all-orgs`.
|
||||
- **Backward / forward compatibility with the legacy single token slot** — a pre-existing single-slot token is migrated into `auth-token:<corpId>` and marked primary on first multi-profile use; the current (or primary) profile's token is mirrored back into the legacy slot so older binaries and the embedded host keep working. `profiles.json` is additive and ignored by older versions.
|
||||
- **`dingtalk-profile` and `dws-shared` skills + multi-org documentation** (`skills/`) — a standalone `dingtalk-profile` skill plus a new `dws-shared` skill that carries auth, global flags and the multi-org rule, so every multi-mode product skill's PREREQUISITE resolves and all read/search skills inherit cross-org behavior. The mono skill gains a "multi-org / profile" section, trigger conditions, a decision-tree entry and a corrected logout danger note. Multi-mode install now always ships `dws-shared` even when `--skill` / `--exclude` narrows the set.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`--ai-tag` now defaults on — DingTalk 「通过AI发送」 badge for dws-sent messages** (`internal/helpers/chat.go`, #524) — `chat message send` / `reply` flip the `--ai-tag` default from false to true, attaching the AI `clawType` by default so messages sent through `dws` (and by AI agents) transparently carry the 「通过AI发送」 badge; pass `--ai-tag=false` to send as the user with no badge.
|
||||
- **Concurrency-safe, self-healing `profiles.json`** (`internal/auth/profiles.go`, `internal/auth/token.go`) — every read-modify-write on `profiles.json` and the legacy mirror is serialized under the existing dual-layer (process + cross-process) lock, split into public (locking) entry points and lock-free `*Locked` variants so the non-reentrant lock is never re-acquired (the refresh path and the load-path migration use the lock-free savers). `profiles.json` and the token marker are written via per-write random temp names + atomic rename so concurrent writers can no longer corrupt a fixed `.tmp`. An unparseable `profiles.json` is quarantined (`*.corrupt-*`) and rebuilt empty so the CLI self-heals; `auth reset` / `logout` proceed even when it cannot be read and sweep the quarantined files.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No silent fallback to a different org's token** (`internal/auth/token.go`) — when the resolved current/primary profile's keychain slot fails to read and no `--profile` was given, the loader now only falls back to the legacy single slot if it belongs to the same organization; otherwise it surfaces the error instead of acting as a different org.
|
||||
- **Legacy mirror no longer wiped on a transient keychain read error** (`internal/auth/profiles.go`) — `SyncLegacyTokenMirror` distinguishes "token genuinely absent" from "keychain momentarily unreadable" and keeps the existing mirror in the latter case, so a host app's login state is not dropped by a transient failure.
|
||||
|
||||
## [1.0.44] - 2026-06-28
|
||||
|
||||
This release hardens the dynamic-command surface and finishes the dws-wukong parity pass for structured input. Phantom override commands whose backing MCP tool isn't deployed are hidden from `--help`; `report entry submit` reads `--contents-file` / stdin natively; structured JSON flags accept `@file` / `@-`; and `sheet range update` / `range read` now accept the same plain shapes wukong does (scalar cells, flat `values`, null-clears-cell, a `--hyperlinks` flag). On the wukong01 sandbox this lifts the full open-edition cli_to_mcp pass rate from 77.6% to 95.5% (sheet 28.5% → 99.8%, report → 100%); the remaining failures are account / org / out-of-scope, not CLI defects.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app.<group>.<method>` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory.
|
||||
- **`report entry submit --contents-file <path>` / `--contents -` (stdin) read natively** (#514, `internal/compat/report_hooks.go`) — the envelope publishes `entry submit` (MCP `create_report`) with a `--contents` (json_parse, required) flag plus a sibling `--contents-file` that had no transform / mapsTo, so a `--contents-file`-only submit silently sent `contents: [null]` and the report failed (only inline `--contents` worked, which is why `report create` succeeded while `report entry submit --contents-file` did not). A build-time compat hook now resolves the file / stdin natively (10MB cap, UTF-8 check, wukong priority `--contents-file` > `--contents -` > inline) and relaxes the individual `required` on `--contents` into a `contents` / `contents-file` one-of group. No discovery-config change needed.
|
||||
- **`@file` / `@-` input for structured JSON flags** (`internal/compat/transform.go`) — `json_parse` / `json_parse_strict` now expand a leading `@` before parsing (`@-` reads stdin, `@<path>` reads a file), so long / complex payloads (many records, big 2D cell ranges, filter criteria) skip shell-quoting hell. A JSON / YAML value never starts with `@`, so the sentinel is unambiguous; the error hint that already advertised `@path/to/file.json` is now truthful. `sheet`'s shared `sheetParseJSONFlag` routes through `cli.ResolveInputSource` so the same support reaches `--values` / `--criteria` / `--sort-keys`.
|
||||
- **`sheet range update --hyperlinks`** (`internal/helpers/sheet.go`) — a wukong-shaped 2D hyperlink grid (`[[{"type":"path","link":"...","text":"..."}]]`) overlaid onto the cells grid as each cell's `hyperlink` field; `--values` or `--hyperlinks` is now required (at least one).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Phantom override commands hidden from `--help`** (#515, `internal/compat/dynamic_commands.go`) — override leaves whose backing MCP tool isn't actually deployed used to render in `dws <svc> --help` and then fail at invocation with *tool not found*. A tool-existence guard now hides them, and command groups left empty by the hidden leaves are collapsed, so `--help` reflects only invokable commands. Skill references are re-aligned to the real CLI surface (phantom commands dropped; role/duty "who is responsible" queries routed to `aisearch`, not `contact`).
|
||||
- **`sheet range update` accepts scalar cells; `sheet range read` projects a flat `values`; `--values '[[null]]'` clears a cell** (`internal/helpers/sheet.go`, `internal/helpers/sheet_cell_validation.go`) — dws-wukong parity. `range update` (set_cell_range) auto-wraps a scalar cell (string / number / bool) into `{type:text,text:"..."}` instead of rejecting it, so the plain `[["姓名","部门"]]` shape that `sheet append` and wukong's update_range accept now works; a null cell clears content (matching wukong); `{}` still means keep-original. `range read` (get_cell_infos) now also exposes a flat `values` 2D array next to the rich `cells` payload, matching wukong's get_range shape without dropping cell styles.
|
||||
- **report skill aligned to `entry submit` / `inbox list` / `outbox list`** (`skills/multi/dingtalk-report/`, `skills/mono/references/intent-guide.md`) — the multi skill tree was two versions behind and still taught the deprecated flat aliases (`report create` / `sent` / `list` / `detail` / `stats`) and falsely claimed `report inbox` was unimplemented. Re-aligned to the canonical resource.verb commands consistently (old aliases still execute with a stderr deprecation notice).
|
||||
|
||||
## [1.0.43] - 2026-06-26
|
||||
|
||||
This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config.
|
||||
|
||||
### Added
|
||||
|
||||
- **`calendar book get|search` and `calendar acl list`** (cedar discovery overrides) — query a specific calendar (primary via `--id primary`), fuzzy-search calendars by name, and list a calendar's access-control entries. Maps to the calendar MCP `get_calendar` / `search_calendar` / `list_acls` tools.
|
||||
- **`calendar attendee list|add|delete`** (`internal/helpers/calendar_commands.go`) — manage event participants under the wukong-aligned `attendee` naming (equivalent to the legacy `participant` group; calls `get/add/remove_calendar_participant`).
|
||||
- **`minutes tag list` and `minutes tag query --tag-id`** — list a user's AI-minutes tags and query minutes by tag (`query_user_tag_list` / `query_minutes_by_tag_id`).
|
||||
- **`minutes list mine|shared|all`** (`internal/helpers/minutes_commands.go`) — list own / shared / all minutes with renamed output fields.
|
||||
- **`mail folder create|update|delete`, `mail template create|list|get|update|delete`, `mail contact create|list|update|batch-delete`, and `mail message list`** — full mail folder / message-template / contact CRUD plus folder-scoped message listing.
|
||||
- **`chat file upload`** (`internal/helpers/chat_file.go`) — upload a local file (init/PUT/commit) or a remote URL to a conversation's file space.
|
||||
- **`todo task add-attachment`** (`internal/helpers/todo_commands.go`) — attach a local file to a todo (multi-step upload).
|
||||
- **aitable extensions** (`internal/helpers/aitable_extra.go`) — advanced permission / roles, view sub-commands (lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar), section node management, workflow enable/disable, record `upsert` / `share-url` / `history-list` / primary-doc, and field search-options. Helper tools route to the hardcoded `aitable-helper` supplement endpoint.
|
||||
- **sheet, drive, wiki, doc helper coverage** synced from dws-wukong (`internal/helpers/sheet.go`, `drive.go`, `wiki.go`, `doc.go`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Discovery version code `bamboo` → `cedar`** (`internal/market/registry.go`; `discoveryAPIPath = "/cli/discovery/apis/cedar"`) — version codes step by first letter (bamboo → cedar → …); `cedar` carries the dws-wukong alignment. Older binaries keep reading `bamboo`, so the change is isolated to this release line. All test/mock/generator fixtures updated to the cedar path.
|
||||
- **CLI output envelope aligned with wukong for cross-edition parity** (`internal/app/runner.go`, `internal/compat/registry.go`) — dry-run prints a `DRY-RUN Arguments:` line, successful results carry `success: true`, missing-required-flag wording is unified to `missing required flag(s): --x`, and OutputTransform applies to the response content layer.
|
||||
- **New flag transforms** (`internal/compat/transform.go`) — `parse_bool` (explicit boolean strings so `--flag false` is honoured) and `attendance_class_check_time` (`HH:mm` → UTC+8 milliseconds for shift check-times).
|
||||
- **`--calendar-id` accepted on calendar event / participant / room / attachment commands** so calendars other than the primary can be targeted.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Client-side validation** for calendar recurrence completeness and attendance schedule / class / group inputs, surfacing input errors before they reach the server.
|
||||
|
||||
## [1.0.42] - 2026-06-25
|
||||
|
||||
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws devapp robot connect` — generic `custom` channel for self-built / unsupported AI tools** (issue #37; `internal/helpers/devapp_connect.go`, `internal/helpers/connect_stream.go`) — a new `--agent-cmd "<command>"` flag (and `custom` channel) lets the bot forward to any headless AI CLI that takes a question as its trailing argument and prints the answer to stdout, so tools that aren't built-in (e.g. 网易有道龙虾 LobsterAI) or self-built agents can be onboarded without code changes. `--agent-cmd` forces the `custom` channel unless `--channel` is set explicitly; detection also falls back to `custom` when `DWS_AGENT_CMD` is present.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`robot connect` now hints how to match terminal answer quality** (issue #39; `internal/helpers/devapp_connect.go`) — when neither a work dir nor a knowledge source is configured, the connector prints a one-time note that the bot runs in a clean temp dir without local project context, pointing at `--agent-workdir` / `--knowledge-dir` / `--knowledge-source` / `--agent-model`. The robot quickstart gains matching FAQ entries, plus a clarification that step 3 (`robot connect`) produces no approval ticket (issue #19).
|
||||
|
||||
- **`robot connect` session commands `/new` vs `/clear` now use each channel's real session op** (PR #20; `internal/helpers/connect_opencode.go`, `internal/helpers/connect_stream.go`) — `/new` (and `/start`, `/reset`) opens a fresh session and leaves the previous one intact (resumable where the agent supports it); `/clear` actively disposes the current session through the agent's real delete primitive — opencode issues `DELETE /session/:id`. Channels whose agent exposes no delete in the mode DWS drives it (Codex app-server, Qoder stream, Claude-family exec) fall back to a reset, so `/clear` behaves like `/new` there. Previously both commands only dropped the local `conversationId → sessionId` mapping, so the two were indistinguishable and opencode sessions were never disposed (they leaked).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`robot connect` no longer aborts long opencode turns at 30 seconds** (PR #19; `internal/helpers/connect_opencode.go`) — the shared opencode HTTP client hard-coded a 30s `Timeout` that covered every request, including `POST /session/{id}/message`, so a long agent turn (e.g. a multi-minute research report) was killed mid-flight with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` even though the per-turn budget (`DWS_AGENT_TIMEOUT_MS`, default 300s) was far larger. The client-level deadline is removed so the per-request ctx governs the round-trip; only the `/global/health` probe keeps a short 10s timeout so startup detection stays snappy.
|
||||
|
||||
## [1.0.41] - 2026-06-24
|
||||
|
||||
This release makes the installers work from mainland China out of the box (no env var) and keeps the Gitee mirror in sync automatically.
|
||||
|
||||
### Added
|
||||
|
||||
- **Auto-fallback to the Gitee mirror when GitHub is unreachable** (#492; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`) — the installers probe GitHub Releases on startup and, when it is unreachable (typical in mainland China), automatically resolve the version and download every asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee mirror instead. A plain `curl … | sh` now works in China with no `DWS_GITEE_REPO` needed. Explicit `DWS_GITEE_REPO` still wins, `DWS_NO_FALLBACK=1` forces GitHub, and local source-checkout installs skip the probe.
|
||||
|
||||
### Changed
|
||||
|
||||
- **CI mirrors repo code to Gitee automatically** (#493; `.github/workflows/mirror-to-gitee.yml`) — the mirror workflow now pushes `main` + tags to the Gitee mirror over HTTPS using `GITEE_TOKEN` (no SSH key), on every push to `main` and every tag, keeping the Gitee `raw/main` install scripts and tags in sync without any manual `git push`. Gated on `GITEE_TOKEN`; skips cleanly when unset.
|
||||
|
||||
## [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`.
|
||||
- **Skills embedded in the binary** (#488; `skills_embed.go`, `internal/app/skill_setup.go`, `internal/app/skill_setup_embed.go`) — the `skills/` tree (mono + multi) is embedded into the `dws` binary via `go:embed` and `dws skill setup` defaults to the embedded copy, refreshing the installed skill instead of silently reusing a stale copy probed from the current working directory — so skills install offline with no separate download.
|
||||
|
||||
## [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.
|
||||
|
||||
### 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_<base62(sha256(machineId|agent_code))>`), 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.
|
||||
|
||||
### 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).
|
||||
|
||||
**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/<partition>/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 `<partition>.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 `# <title>` 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
|
||||
|
||||
- **`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
|
||||
|
||||
### Changed
|
||||
|
||||
- **Service discovery path now carries a version-coded segment** (`internal/market/registry.go`) — the server-list endpoint moves from `/cli/discovery/apis` to `/cli/discovery/apis/bamboo`. The path is now a single `discoveryAPIPath` constant so future version bumps touch one place. Only the path changes; the MCP base host stays on production `https://mcp.dingtalk.com` and the auth / skill / doctor endpoints are untouched. Discovery via the edition `DiscoveryURL` hook (full-URL `FetchServersFromURL`) is unaffected. Server side must serve the new path.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`dws aiapp` — AI application product taken offline** — removed the `aiapp` product surface (`create` / `query` / `modify`) from the CLI: deleted `internal/helpers/aiapp.go`, dropped it from the generator coverage targets and `knownRegistryProducts`, removed the `aiapp` skill references (mono `references/products/aiapp.md` + `dingtalk-aiapp` multi skill), and unpublished the `aiapp` server from the service-discovery envelope. Product count drops from 19 to 18.
|
||||
|
||||
## [1.0.33] - 2026-06-02
|
||||
|
||||
This release merges the multi-contributor `pre-mcp-discovery` feature branch into `main` as a single squash (#391), bringing a large batch of new product surface — full DingTalk **docs** (`doc`), **knowledge base** (`wiki`), **AI app** (`aiapp`), AI-table **forms** + **import/export**, and reworked **mail** / **todo** / **report** command trees — while keeping service discovery pinned to production `https://mcp.dingtalk.com` (the branch's `pre-mcp.dingtalk.com` endpoint change was deliberately excluded; the four host constants in `skill_command.go` / `auth/endpoints.go` / `cli/loader.go` / `market/registry.go` stay on prod). It also folds in the portable auth bundle (`dws auth export` / `import`, #357) and PAT batch authorization (#389).
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws doc` — full DingTalk document command family** (#387, #362, #388, #390; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`, `internal/helpers/docjsonml/`) — search / list / info / read / create / update / upload / download / copy / move / rename, plus `file`, `folder`, `block`-level editing and `comment` (list / create / reply / create-inline). Authoring supports both DocxXML and a JSONML format with a v2 schema validator (`docjsonml/jsonml-schema-v2.json` + `doc_jsonml_validate_v2.go`). Document export and OA alignment land here.
|
||||
- **`dws wiki` — knowledge base management** (`internal/helpers/wiki.go`, `internal/helpers/wiki_proxy.go`) — knowledge space `create` / `get` / `list` / `search` and member `add` / `list` / `update`, routed through a wiki proxy server.
|
||||
- **`dws aiapp` — AI application lifecycle** (`internal/helpers/aiapp.go`) — `create` (with prompt / attachments / skills), `query` by task ID, `modify` by thread ID.
|
||||
- **`dws aitable` forms + import/export** (`internal/helpers/aitable_form.go`, `internal/helpers/aitable_export_import.go`) — datasheet form management and full record import/export, the latter driven through the async-task helper for large datasets.
|
||||
- **Reworked `chat` / `report` / `todo` / `contact` / `mail` command trees aligned to the Wukong baseline** (#355; `internal/compat/mail_hooks.go`, `internal/compat/todo_hooks.go`, `internal/helpers/report_readable.go`) — mail and todo gain dedicated compat hooks; `report` gains a human-readable rendering path alongside the raw JSON, plus deprecation shims for the old report shape.
|
||||
- **`dws auth export` / `dws auth import`** (#357) — portable auth bundle for migrating Linux sandbox credentials. Exports the encrypted keychain (`~/.local/share/dws-cli`, including `auth-token.enc` and `dek`) plus required `~/.dws` config so refresh tokens survive import; copying only `app.json` leaves access tokens expiring after ~2 hours. Supports `-o` / `-i` tar.gz paths and `--base64` for copy/paste between sandboxes. `dws auth status` now shows refresh-token validity in table output.
|
||||
- **Async-task and paging infrastructure** (`pkg/asynctask/`, `pkg/paging/`) — shared helpers underpinning long-running operations (e.g. aitable import/export, doc export) and cursor/page traversal.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`envelope` now registers `cli.Aliases` as cobra aliases** (#391) — discovery-generated commands expose their declared aliases natively in the command tree, with accompanying command-structure and JSON-parsing cleanups.
|
||||
- **Breaking: `dws pat chmod` prints a compact authorization summary by default, and gains batch authorization flows** (#389; `internal/pat/chmod.go`) — scripts that parse the raw MCP JSON from stdout must now pass `--format json` or `--verbose` to keep the machine-readable payload; the default summary keeps grant status, agentCode, grantType, scope counts, and a next-action hint. New batch grant/plan flows (`pat.batch_grant` / `pat.batch_plan`) authorize multiple products in one session, fall back to the legacy single-grant path when the server reports `PAT_BATCH_AUTH_UNSUPPORTED`, use the server's default `agentCode` when none is given, and surface per-tool authorization metadata for grant planning.
|
||||
- **Skill packs synced to the Wukong-aligned content** across attendance / calendar / minutes / oa / sheet and others (#391).
|
||||
|
||||
## [1.0.32] - 2026-05-25
|
||||
|
||||
Two user-visible regressions resolved plus two AI-agent discoverability fixes. `dws drive upload` was returning `HTTP 403 SignatureDoesNotMatch` for any file whose MIME detects to a non-empty value — basically every real file — because the helper added a client-side `Content-Type` fallback whenever `drive.get_upload_info` returned an empty headers map. DingTalk drive's OSS presigned PUT URLs are signed against an empty `Content-Type` at signing time, so any client-supplied header makes the signature OSS recomputes diverge from the server-signed one, and the PUT is rejected (#347). On Apple Silicon, `dws upgrade` was aborting at the "解压并验证" step with `signal: killed` because GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ `amfid` SIGKILLs unsigned arm64 binaries on first exec (#339) — the release pipeline now ad-hoc signs every darwin tarball, and the upgrade client self-heals if it ever encounters an unsigned binary again. On the AI-agent discoverability side, `dws aitable attachment upload-file` (the one-shot prepare + PUT + commit composite) is no longer hidden from `--help` — agents that only browse the command tree were getting stuck at the prepare-only `attachment upload` step, which returns an upload URL + fileToken but doesn't actually upload. And `dws --help` itself now surfaces the missing-command upgrade hint that the custom `renderRootHelp` had been silently dropping from cobra's `root.Long`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws aitable attachment upload-file` is now visible in `dws aitable attachment --help`** (#347, `internal/helpers/aitable.go`) — the hardcoded one-shot composite (prepare + HTTP PUT + commit, returns `fileToken` directly) was previously marked `Hidden:true` and only reachable by agents that read `skills/references/products/aitable.md`. Agents that only discover commands via `--help` were getting stuck at the sibling envelope-generated `attachment upload` (prepare-only): they'd receive `uploadUrl` + `fileToken`, have no idea how to consume the URL, and either write the URL into the attachment field as if it were a token (wrong shape — the field expects `[{"fileToken":"ft_xxx"}]`) or fall back to "please use the UI" messages, which made `dws` look broken even though the capability was fully implemented. Unhiding mirrors the discoverability pattern `lark-cli base +record-upload-attachment` already follows. `Short` is tightened to explicitly mention the 3 steps it bundles; `Long` calls out the prepare-only sibling and recommends `upload-file` as the default for AI agents. The sibling `attachment upload` (prepare-only) keeps its envelope-generated registration but gets a new `Long` that states it is only step 1 of a 3-step flow, lists what an agent must do after (HTTP PUT to `uploadUrl`, then write `[{"fileToken":"ft_xxx"}]` into the attachment field), and points to `upload-file` as the recommended one-shot alternative. `TestAITableUploadFileCommandIsDiscoverable` in `internal/helpers/aitable_upload_file_test.go` guards against re-introducing `Hidden:true`.
|
||||
- **`dws --help` root output now surfaces the `dws upgrade` hint when no listed command fits** (#347, `internal/app/root.go` + `internal/app/root_help.go`) — `root.Long` is set to `"提示: 如果遇到能力缺失、命令报错、新功能未注册、或无法完成任务, 请先用 'dws upgrade' 升级到最新版本后再试. 钉钉 OpenAPI 和 dws CLI 持续迭代, 新能力和 bugfix 会先在新版本上线."`. The custom `renderRootHelp` (which replaces cobra's default template to render the services / utilities sections) had been silently dropping `root.Long`; restoring it costs one `Fprintln` after the command list, separated by a blank line. The natural failure mode for both agents and users staring at `dws --help` is to give up or hack around when none of the listed commands fit — but in many cases the right action is simply `dws upgrade`, because new capabilities and bugfixes ship continuously and a missing command is usually a stale-binary issue. `TestRenderRootHelpIncludesLong` in `internal/app/visibility_test.go` uses a sentinel `Long` string and asserts the rendered output contains it verbatim, so any future rewrite of the help renderer that drops `Long` fails this test immediately.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws drive upload` no longer fails with `HTTP 403 SignatureDoesNotMatch` on any non-empty MIME type** (#347, `internal/helpers/drive.go`) — `httpPutDriveFile` was setting `req.Header["Content-Type"] = fallbackMIME` whenever the prepare_upload response returned an empty headers map. DingTalk drive's OSS presigned URLs sign `StringToSign` against an empty `Content-Type` at signing time, so any client-side header makes the signature OSS recomputes at PUT time differ from the server's presignature, and the upload is rejected with `403 SignatureDoesNotMatch`. This broke every `dws drive upload` for any file whose MIME detects to a non-empty value (`image/png`, `application/pdf`, every common binary) — i.e. essentially every real upload. Fix: drop the `hasContentType` / `fallbackMIME` path entirely, trust the server's headers map as authoritative; empty map means "no client-side headers needed", do not infer. `httpPutDriveFile`'s signature loses the `fallbackMIME` parameter. Manual verification: `curl -X PUT -H "Content-Type:" --data-binary @file <same-presigned-url>` returns `HTTP 200`, proving the only difference was the client-side `Content-Type`. `TestHttpPutDriveFile_NoContentTypeWhenServerHeadersEmpty` guards the empty-map path; `TestHttpPutDriveFile_PassthroughServerHeaders` guards that server-provided `Content-Type` / `x-oss-*` headers are forwarded verbatim. Important: `internal/helpers/aitable.go`'s `upload-file` helper deliberately keeps its `Set("Content-Type", mimeType)` call — its OSS endpoint uses a different signing mode (server includes the client-declared MIME in the signature, verified across 12 file types — all succeed). The two helpers must not be unified without re-validating both endpoints.
|
||||
- **`dws upgrade` no longer dies with `signal: killed` on Apple Silicon after fetching the new binary** (#339) — GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ on Apple Silicon requires at least an ad-hoc signature on every arm64 binary; `amfid` SIGKILLs unsigned arm64 binaries on first exec, which the upgrade client surfaces as `signal: killed` and aborts at the "解压并验证" step. Two layers of fix:
|
||||
- **Release-side ad-hoc signing** (`scripts/release/post-goreleaser.sh` + `.github/workflows/release.yml`) — after GoReleaser produces the per-platform tarballs, `post-goreleaser.sh` unpacks each `dws-darwin-*.tar.gz`, applies an ad-hoc signature (`codesign --force --sign -` locally, `rcodesign` in CI), deterministically repacks the tarball, and rewrites the matching line in `checksums.txt` so the checksum stays consistent with the resigned tarball. `release.yml` installs `rcodesign 0.27.0` before GoReleaser runs. Every 1.0.32+ tarball ships signed; the install regression is fixed at the source.
|
||||
- **Client-side self-heal in `validateNewBinary`** (`internal/app/upgrade.go`) — when running the freshly-extracted binary returns `signal: killed` on darwin, the validator retries once after running `codesign --force --sign -` on the binary and clearing the `com.apple.quarantine` xattr. This keeps `dws upgrade` working even if a future release ever skips the signing step again, and covers users upgrading from older unsigned binaries. `internal/app/upgrade_test.go` (+80 lines) covers the retry path end-to-end: a stripped binary exits 137 on first exec, `validateNewBinary` recovers via ad-hoc sign + xattr clear, the final binary shows `Signature=adhoc` and runs.
|
||||
|
||||
## [1.0.31] - 2026-05-21
|
||||
|
||||
Closes the last drive-surface gap with the Wukong edition: `dws drive upload` lands as a single-shot composite (`drive.get_upload_info` → HTTP PUT to OSS → `drive.commit_upload`) so a local file reaches DingTalk drive in one CLI invocation, no manual three-step orchestration. Two more drive commands — `dws drive list-spaces` (list visible drive spaces) and `dws drive delete` (delete a drive file, routed via `serverOverride` to the doc MCP server) — ship via the portal envelope; `dws cache refresh` once to pick them up. Companion skill docs teach the agent to recognise dingpan URLs of the form `alidocs.dingtalk.com/document/edit?dentryKey=…` / `…/document/preview?dentryKey=…` and pass the whole URL through to `--node` instead of trying to extract `dentryKey` by hand (the server interprets `dentryKey` and a bare `nodeId` differently — manual extraction was failing).
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws drive upload --file <path> [--folder <dentryUuid>] [--space-id <id>] [--file-name <name>] [--mime-type <type>]`** (#335, see `internal/helpers/drive.go`) — composite leaf that runs the full three-step upload internally:
|
||||
1. `drive.get_upload_info` — fetch the OSS-signed `resourceUrl` + `uploadId` + per-URL headers.
|
||||
2. HTTP `PUT` the file binary to OSS (10-minute timeout, attaches every header returned by step 1).
|
||||
3. `drive.commit_upload` — register the new file under the target space / folder.
|
||||
|
||||
`--dry-run` prints the three step invocations as a single JSON payload without making any network calls. `--file -` is rejected on purpose: this is a local-path upload, not stdin streaming. `--folder` only accepts a `dentryUuid`; pure-numeric values are rejected up front (`validateDriveParentID`) so callers don't accidentally pass a chat-link `dentryId` (a different ID namespace) where the drive API expects a `dentryUuid`. Response normalisation handles all the wrapper shapes the upstream returns — `content` / `result` envelopes, `resourceUrls[]` arrays, and the flat `resourceUrl` / `uploadUrl` fallbacks — so the composite produces a stable JSON shape regardless of which path the upstream takes. The helper only registers `upload`; the existing six envelope-generated leaves (`list` / `info` / `download` / `mkdir` / `upload-info` / `commit`) keep flowing through dynamic discovery unchanged. `pickCommands.MergeHardcodedLeaves` guarantees dynamic leaves win on collision, so this helper only fills the upload gap.
|
||||
- **`dws drive list-spaces` and `dws drive delete` (envelope rollout)** (#335, ships via portal envelope) — `list_spaces` registers as a plain `cliName` alias on the existing drive MCP server; `delete_document` registers with `serverOverride: doc` so the call routes to the doc MCP server (which owns the delete API), surfacing under the drive command tree for ergonomics. **Existing users must run `dws cache refresh` once** to pick up these two new leaves; no binary upgrade is required for them, but they pair naturally with the v1.0.31 client that ships `upload`.
|
||||
- **`skills/references/url-patterns.md`** (#335) — single authority for dispatching `alidocs.dingtalk.com` URLs across doc / sheet / wiki. Five-way split: `/i/p/<token>` short links → expand via `doc info`; `/i/nodes/<id>` node URLs → probe with `doc info` and route by `contentType` / `extension` / `nodeType`; `/spreadsheetv2/...` → `sheet`; `/document/edit|preview?dentryKey=<key>` (dingpan format) → pass the whole URL to `--node`, do not strip `dentryKey` by hand; `/i/share/...` (read-only share) → use the `read_url` fallback. The "URL precheck" Step 0 in `skills/SKILL.md` now redirects every URL-bearing prompt through this dispatcher before the agent picks a product.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`skills/references/products/doc.md` — `--node` accepts dingpan URLs end-to-end** (#335) — `dws doc info` / `dws doc read` examples gain two extra rows showing `--node "https://alidocs.dingtalk.com/document/edit?dentryKey=<KEY>"` and `…/preview?dentryKey=<KEY>` as first-class `--node` inputs. The "URL recognition & DOC_ID extraction" table adds the `document/edit|preview?dentryKey=<key>` row, and the extraction rules are split into three explicit clauses so the agent stops manually pulling `dentryKey` out of the URL and feeding it as a bare `nodeId` (which the server rejects). The "nodeId dual-format note" upgrades to "nodeId multi-format note" with four equivalent `--node` input shapes side by side.
|
||||
|
||||
## [1.0.30] - 2026-05-19
|
||||
|
||||
Aligns the open-source CLI with the IM envelope and schema-pipeline plumbing the Wukong edition has been running in pre-prod, plus three user-visible quality-of-life fixes. The most visible one: chat-bot webhook payloads carrying literal Chinese mentions (`@所有人 周报来了` / `@张三 看一下`) no longer fail with `file not found` — `@` is only treated as the `@<filename>` file-injection prefix when followed by an ASCII path-shaped character. The `chat` command tree is refactored to lean on the service-discovery envelope: thin wrappers (`chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search`) move out of the hardcoded helper and become envelope-generated dynamic commands; the helper keeps only the chat commands with real business logic (intelligent routing, current-user resolution, response normalization, stdin/@file input). A new `dws chat message reply` joins the existing `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` family. Underneath: `transform: invert_bool` lets envelopes flip boolean semantics between CLI surface and MCP body (e.g. `--off` ↔ `mute=true`); the pipeline executor fail-fast on upstream `content.errorCode` instead of polling forever; service-discovery dedup keeps two envelope entries that share an MCP endpoint but declare different `cli.id` as separate descriptors (so the `bot-root` / `bot-message` / `bot-group` trio fronting one MCP server stays as three distinct CLI command roots); and `dws chat` no longer nests as `dws chat chat` when two envelope servers both declare the same top-level command name.
|
||||
|
||||
### Added
|
||||
|
||||
- **`transform: invert_bool` for envelope flag overrides** (#317, see `internal/compat/transform.go`) — flips a boolean at send time. Strings `true`/`1`/`yes`/`on` → `false`; `false`/`0`/`no`/`off`/`""` → `true`. Used when the CLI surface and the MCP body have opposite semantics — e.g. envelope declares `--off` on the CLI but the MCP parameter is `mute=true` for "muted". The framework flips at send time so the envelope keeps the natural CLI verb without forcing every caller to remember the inverted mapping. Coverage in `internal/compat/transform_test.go`.
|
||||
- **`dws chat message reply`** (#317, see `internal/helpers/chat.go`) — reply to a chat message. Sits alongside `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` under `dws chat message`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chat` command tree refactored to lean on the service-discovery envelope** (#317, commit `6be1247`) — `internal/helpers/chat.go` now only carries the chat commands that need real business logic on top of the raw MCP call: `chat message send` (current-user resolution + symmetric direct/group title validation), `chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` (bot routing + stdin/@file input), and `chat group create` (response normalization). The thin wrappers — `chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search` — are now produced by the envelope as dynamic commands. Net diff in the helper: `+358 / -71` overall (re-aligning to envelope-owned chat structure), and `chat_test.go` drops 71 lines of test-stubs the dynamic path covers natively. Every previously documented chat command keeps the same flag set and the same MCP tool routing — the surface is just sourced differently.
|
||||
- **Pipeline executor fail-fast on `content.errorCode`** (#317, see `internal/compat/pipeline.go`) — when an upstream tool returns a non-empty `content.errorCode`, `executePipelineCall` raises a validation error immediately with the upstream `errorMessage` instead of proceeding into the poll/download phase. Pre-execution cobra validation (`MarkFlagRequired`) only checks that a flag was set, not that its value was non-empty — so a `--required-flag ""` reaches the upstream tool and the upstream rejects with `errorCode`. Without the short-circuit the pipeline kept polling for a task ID that would never exist, either spinning to `PollTimeout` or burning through retries with no actionable error. Exit code 2 (validation), same as any other CLI-layer pre-flight rejection.
|
||||
- **Service-discovery dedup keys now include `cli.id`** (#317, see `internal/market/registry.go`) — `NormalizeServers` used to dedup envelope entries by endpoint alone (and by `displayName` in the second pass), which collapsed envelope entries that intentionally split one MCP endpoint into multiple CLI command trees. The `bot-root` / `bot-message` / `bot-group` trio all front the same `.../server/4717...` MCP endpoint and share the displayName `机器人消息`, but each declares a distinct `cli.id` and a distinct CLI command root; the old dedup kept only the last-write and dropped two of them. The dedup key now appends `#<cli.id>` when present, falling back to endpoint / name when absent so historical envelopes without `cli.id` keep their existing behaviour. Coverage in `internal/market/registry_test.go`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`@<text>` injection no longer eats Chinese mentions like `@所有人` / `@张三`** (#317, see `internal/cli/stdin.go`) — `ReadFileArg` and `ResolveInputSource` used to treat *any* value starting with `@` as the `@<filename>` injection syntax. Chat-bot webhook payloads commonly contain literal mentions, so `dws chat message send-by-bot --text "@所有人 周报"` was failing with `file not found: 所有人 周报` before the message reached the API. The new `looksLikeFilePath` heuristic accepts `@` followed by an ASCII path-prefix character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin, and passes the value through unchanged otherwise. `@A 但接下来都是中文@测试` *does* still attempt a file lookup because the rune right after `@` is ASCII — this matches the documented `@<path>` prefix shape. The historical "bare `@` is an error" behaviour is preserved. Coverage in `internal/cli/stdin_test.go::TestReadFileArgChineseAtMention`.
|
||||
- **`dws chat` no longer nests as `dws chat chat` when two envelope servers contribute the same top-level command** (#317, see `internal/compat/dynamic_commands.go`) — `BuildDynamicCommands` used to overwrite `topLevel[name]` on the second contribution and rely on `attachOrMerge` later, which then attached the *whole* incoming command (named `chat`) under the existing root, producing `dws chat chat <leaf>`. The new `mergeSubcommandsInto` moves the second contribution's *children* under the first root and drops the duplicate wrapper, so e.g. `group-chat` + `im` envelopes that both declare `cli.command: chat` produce a single flat `dws chat` subtree.
|
||||
- **Multi-server tool-name authority correction in the runtime runner** (#317, see `internal/app/runner.go` + `internal/app/direct_runtime.go`) — when two envelope servers share the same `cli.command`, the per-product endpoint map `endpoints[cmd]` in `registerDynamicServer` is second-writer-wins, and `catalog.FindProduct` may return the wrong server's endpoint for a tool whose real owner is the *other* server. `runtimeRunner.Run` now cross-checks the canonical tool→endpoint map exposed by the new `directRuntimeToolEndpoint`: when the per-tool endpoint exists and differs from the per-product endpoint the catalog returned, the tool-owner endpoint wins. Pairs with the registry dedup change above so the routing matches the dedup result.
|
||||
|
||||
## [1.0.29] - 2026-05-17
|
||||
|
||||
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
|
||||
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
|
||||
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
|
||||
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
|
||||
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
|
||||
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
|
||||
|
||||
### Security
|
||||
|
||||
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
|
||||
|
||||
## [1.0.28] - 2026-05-14
|
||||
|
||||
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
|
||||
|
||||
## [1.0.27] - 2026-05-14
|
||||
|
||||
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
|
||||
|
||||
### Added
|
||||
|
||||
- **`file_read` transform + `CLIFlagOverride.MapsTo` field** (#291, closes #277 #278 #282 #288) — discovery envelopes can now declare a path-typed CLI flag that performs the "file path → file contents string" conversion client-side before the value reaches the upstream MCP parameter.
|
||||
- `transform: "file_read"` (`internal/compat/transform.go`) — reads the file at the flag's value with UTF-8 validation; `-` means stdin. Any IO / encoding failure is surfaced as a validation error (exit 2), distinct from the generic transient-failure path (exit 1).
|
||||
- `CLIFlagOverride.MapsTo` (`internal/market/registry.go`) — redirects the flag's final value (post-transform or literal) into a named MCP parameter slot instead of the default `params[propertyName]`. This lets a single MCP parameter (e.g. `markdown`) be fed by two sibling CLI flags — a literal `--content` and a file-reading `--content-file` — paired with the existing tool-level `MutuallyExclusive` / `RequireOneOf` to express "exclusive, at least one".
|
||||
- Wired into the `internal/compat/dynamic_commands.go` normalizer via a separate `mapsToRoutes` collection + routing pass; empty `MapsTo` preserves the legacy `params[propertyName] = value` semantics, so every pre-existing dynamic_commands test passes unchanged. Pre-prod end-to-end verified across 6 cases (see PR #291's Validation table).
|
||||
- **`dws doc update --content-file <path>` (envelope rollout)** — fixes "long Markdown can't reach the doc". The old command only accepted `--content "..."`, so long / multi-line / table-heavy Markdown got mangled by shell escaping and AI agents writing >2KB of content were stuck. The envelope now maps both `--content` (literal) and `--content-file` (`file_read` transform) to the `markdown` parameter, makes them mutually exclusive via cobra's `MarkFlagsMutuallyExclusive`, and requires at least one via `RequireOneOf`. `--content-file -` reads from stdin, so `cat long.md | dws doc update --content-file -` works directly. **Existing users must run `dws cache refresh` once** to pick up the new envelope.
|
||||
- **`dws sheet find --query` hidden alias (envelope rollout)** — fixes "unknown flag when copy-pasting commands across editions". Users copying `dws sheet find --query "..."` from internal wukong docs onto open-source `dws` got `unknown flag: --query`, because the open-source primary flag is named `--find`. The envelope now registers `--query` as a hidden alias of `--find` via `CLIFlagOverride.Aliases` (the field shipped in 1.0.26) — it doesn't show up in `--help`, but accepts values and writes to the same MCP parameter. `--find` behaviour is unchanged. Also requires `dws cache refresh` once.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Noisy `failed to stop stdio client: exit status 1` WARN on normal stdio-plugin shutdown** (#285) — when `Stop()` explicitly `Kill`s the subprocess, the non-zero exit code returned by `cmd.Wait()` is expected behaviour, but it was being propagated as an error and logged to stderr on every CLI exit, polluting agent log parsing. `Stop()` now returns `nil` after Kill + Wait; the error path is reserved for "process exited on its own with non-zero" (e.g. stdin close without an explicit Kill). `internal/transport/stdio.go` + `stdio_integration_test.go` assert "Stop() returns nil after kill".
|
||||
|
||||
## [1.0.26] - 2026-05-12
|
||||
|
||||
Platform-stability round: Windows PAT-auth browser opener no longer truncates URLs at `&userCode=`, macOS sandbox hosts get an opt-in keychain fallback, and `dws doc download` rejects `axls` nodes before requesting `drive:download` consent. Two new global output formats `-f ndjson` and `-f csv` (matching `larksuite/cli`) land as first-class citizens with real-traffic-verified list detection. The `dws doc comment *` regression tracked in #240 is also resolved — fix is in the market metadata, users just need `dws cache refresh` once.
|
||||
|
||||
### Added
|
||||
|
||||
- **`-f ndjson` and `-f csv` global output formats** (#259, closes #252) — `ndjson` emits one compact JSON record per line (works straight with `jq -c` / `while read` / log pipelines); `csv` goes through `encoding/csv` (RFC-4180 — quoting, embedded newlines, CJK all handled by stdlib) and reuses the existing `-f table` column resolver (`normalizePayload` / `unwrapPrimaryObject` / `extractRowsFromMap` / `rowsFromSlice` / `formatValue`) so table and csv stay visually aligned. After a 7-product real-traffic sweep (contact / chat / doc / mail / todo / minutes / schema), the `preferredListKeys` whitelist was extended to cover the actual DingTalk envelope shapes — `contact user search` (`result`), `chat search` (`result.value`), `doc search` (`documents`), `mail mailbox list` (`emailAccounts`), `todo task list` (`result.todoCards`) — so these commands now degrade into a proper row stream instead of collapsing to a single-line `key,value` blob. Lives in `internal/output/ndjson.go` + `internal/output/csv.go`; `--format` help in `internal/app/flags.go` now lists `ndjson|csv` alongside `json|table|raw|pretty`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sticky flag splitting is now schema-aware** (#272) — PreParse `StickyHandler` 此前会把任何前缀命中已知 flag 的 `--flagsuffix` 一律切成 `--flag suffix`,于是 `--starttime20260507` 这类拼错被静默改写成 `--start time20260507`,把假值传到下游。新行为按 flag 的 pflag 类型 / JSON Schema `format` / `enum` 校验 suffix 是否像合法 value(共享逻辑见 `pkg/cmdutil/sticky_suffix.go`),不像就保留原 token 让 cobra 报 `unknown flag`。slice/array/object 类型的 flag 永不切分。首 rune 读取使用 `utf8.DecodeRuneInString`,对中文等多字节 value 安全。
|
||||
|
||||
### Added
|
||||
|
||||
- **`available_flags` field on unknown-flag errors** (#272) — `dws -f json` 的 unknown-flag 错误体里新增 `available_flags`(已排序、过滤掉 hidden 与内部 `json` / `params`),方便 agent 不解析 `--help` 就能恢复。Human-readable 输出会附 `Flags: ...` 行,截断在 200 字节内。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` 单聊缺 `--title` 时前置校验** (#250) — 单聊(`--user` / `--open-dingtalk-id`)的底层工具 `send_direct_message_as_user` 在 API 层强制要求 title,缺失时返回误导性的 `发群服务窗会话消息失败`。CLI 现在在 `buildChatMessageSendInvocation` 里前置校验,直接返回 `--title is required for direct messages (--user / --open-dingtalk-id)`;同时把 `Long` help、`--title` flag 描述、Example 和 `skills/references/products/chat.md` 全部对齐为「单聊必填,群聊可选」。群聊行为不变。
|
||||
- **PAT auth URLs were truncated on Windows browser open** (#242, fixes #230) — `cmd /c start <url>` on Windows interprets `&` as a command separator, so PAT URLs containing `&userCode=...` were silently chopped before the userCode segment, and the browser landed on a 0-permission DingTalk page. The retry opener now uses `rundll32 url.dll,FileProtocolHandler`, which passes the URL through verbatim. The PAT response also exposes a copy-safe `data.authorizationUrl` (in addition to the service-provided `data.uri`, which is preserved as-is), and human-readable PAT output prints `PAT_AUTHORIZATION_URL=<full-url>` on its own line so OpenClaw-style host wrappers that swallow or reformat stderr can still capture the full link. Legacy DingTalk hash-route shapes (`https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId=...%26userCode=...`) are normalised back into the working `/fe/old?hash=...#/personalAuthorization?...&userCode=...` form. Regression tests cover the issue-shaped URLs (encoded hash, fragment, `&userCode`) plus the OpenClaw malformed-hash variant.
|
||||
- **`dws doc download` triggered `drive:download` PAT consent for unsupported axls nodes** (#268, fixes #190) — added a `get_document_info` preflight before `download_file`, so online-sheet (`axls`) nodes are rejected locally with guidance to use sheet range tools instead. The preflight reads `extension` from deterministic response paths (no recursive payload scan) and routes its own PAT errors back through `handlePatAuthCheck`, preserving device-flow / host-owned PAT behaviour. Costs one extra MCP roundtrip per `doc download` — deliberate, so the unsupported path fails before consent. Lives in `internal/app/doc_download_preflight.go`; coverage in `internal/app/runner_test.go`.
|
||||
- **macOS sandbox hosts (Codex App etc.) couldn't read/write tokens via Keychain** (#267, fixes #214) — sandboxed macOS environments intercept `security` / Keychain APIs, so every token operation failed. New opt-in `DWS_DISABLE_KEYCHAIN=1` switches macOS to the same file-DEK path Linux uses (DEK at `~/Library/Application Support/dws-cli/dek`, mode `0600`), bypassing the system Keychain. Default behaviour is unchanged — fallback is strictly opt-in because file-DEK is a weaker trust model than Keychain-managed storage (DEK file sits next to ciphertext in the same directory). The Darwin / Linux file-DEK implementation is now shared in `internal/keychain/file_dek.go` (Linux path deduplicated by ~40 lines). Documented in `docs/reference.md` (中英) with the security tradeoff spelt out so users make the choice explicitly.
|
||||
- **`dws doc comment {list,create,create-inline,reply}` returned `PARAM_ERROR - 未找到指定工具`** (fixes #240, also #234) — the four comment tools used to live on an independent `doc-comment` MCP server. After the Portal merged comment functionality into the `doc` server descriptor, the runtime `tools/list` on the merged `doc` server didn't include them, so every `dws doc comment *` call returned the "tool not found" PARAM_ERROR. The market metadata for the `doc` server now declares `serverOverride: "doc-comment"` on all four comment `toolOverrides`, so the existing CLI routing path sends `dws doc comment *` to the still-running `doc-comment` MCP server (which has the tools). No CLI code change was required, but **existing users must run `dws cache refresh` once** to pick up the updated descriptor — without that, the stale local market cache keeps pointing the call at the merged `doc` server and the error persists. Verified post-refresh: dry-run resolves to `https://mcp-gw.dingtalk.com/server/doc-comment` with tool `list_comments`, real calls return normal business responses (e.g. legitimate cross-org authz errors) instead of `未找到指定工具`.
|
||||
|
||||
## [1.0.25] - 2026-05-11
|
||||
|
||||
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands. Plus missing skill references for the already-registered `sheet` and `wiki` products are now shipped.
|
||||
|
||||
### Added
|
||||
|
||||
- **`sheet` (在线电子表格) skill reference + product-overview entry** — the `sheet` product registers **34 envelope tools** covering worksheet CRUD (`create` / `new` / `list` / `info` / `copy_sheet` / `update_sheet`), range read/write (`range read` / `range update` / `append`), dimension ops (`add-dimension` / `insert-dimension` / `delete-dimension` / `move-dimension` / `update-dimension`), merge (`merge-cells` / `unmerge-cells`), find/replace (`find` / `replace`), filter views (`filter-view {create, list, update, delete, update-criteria, delete-criteria}`), sheet-level filters (`create_filter` / `get_filter` / `update_filter` / `delete_filter` / `set_filter_criteria` / `clear_filter_criteria` / `sort_filter`), image write (`write-image`), and async export (`submit_export_job` + `query_export_job`). These were live in the envelope but `skills/references/products/sheet.md` had not shipped and `skills/SKILL.md` 产品总览 didn't list `sheet`, so agents had no reference to consult and were skipping it during intent routing. This release adds the doc, registers `sheet` in 产品总览 + 意图判断决策树, extends `description` to include 在线电子表格, adds a Sheet row to `README.md` / `README_zh.md` "Key Services", and notes the v1.0.25 reality on naming (about a third of `sheet` tools still expose snake_case cli_names pending `CLIAliases` (#246) rollout) and on export (no consolidated `dws sheet export` exists in v1.0.25 — `submit_export_job` + `query_export_job` are the atomic primitives; Pipeline (#247) provides the future plumbing).
|
||||
- **`wiki` (知识库) skill reference + product-overview entry** — the wiki product's 7 envelope tools (`wiki.create_wikiSpace`, `wiki.get_wikiSpace`, `wiki.list_wikiSpaces`, `wiki.search_wikiSpaces`, `wiki.add_member`, `wiki.list_member`, `wiki.update_member`, surfaced as `dws wiki space create / get / list / search` and `dws wiki member add / list / update`) have been registered for a while, but no `skills/references/products/wiki.md` shipped with them, so agents had no per-command reference to consult. This release adds the reference doc, registers `wiki` in `skills/SKILL.md`'s 产品总览 table and 意图判断决策树, mentions 知识库 in the skill `description` frontmatter, adds a Wiki row to `README.md` / `README_zh.md` "Key Services", and removes `wiki` from the "Coming soon" callout (which was now stale).
|
||||
- **`CLIToolOverride.CLIAliases` envelope field** (#246) — lets a single MCP tool register additional cobra command aliases via envelope JSON (e.g. `range read` also accepts `range get`, `member list` accepts `member ls`). Plumbed through the existing `Route.Aliases → cobra.Command.Aliases` path; sibling conflicts are silently dropped by cobra. Lives in `internal/market/registry.go` + `internal/compat/dynamic_commands.go`.
|
||||
- **`json_parse_strict` transform** (#246) — strict-JSON variant of `json_parse` that does **not** fall back to YAML. Use when the upstream tool requires a structured array/object and silently coercing a malformed input to a scalar string would mask a real user error (observed: `filter-view --criteria 'NOT_VALID_JSON'` was being accepted and quietly creating an empty-criteria view). In `internal/compat/transform.go`.
|
||||
- **`CLIToolOverride.Pipeline` + pipeline executor** (#247) — a single CLI command can now orchestrate an ordered sequence of MCP tool calls plus optional HTTP-download sinks, declared entirely in envelope JSON. Motivating use case: the "submit-job → poll-status → download-result" pattern (e.g. sheet export) that previously required per-product hardcoded helpers.
|
||||
- `PipelineStep` supports `type:"call"` (with optional `PollUntilField` / `PollUntilValue` / `PollIntervalSec` / `PollTimeoutSec` for polling loops) and `type:"download"` (resolves `DownloadURLField`, HTTP GETs the body, writes to the path from `OutputFlag`, infers filename for directory paths).
|
||||
- Template language: `$flag.<name>` resolves a user CLI flag by alias; `$step.<idx>.<dotPath>` walks a prior step's response (works through wrapped MCP envelopes); literals pass through.
|
||||
- `CLIFlagOverride.PipelineLocal` marks a flag as CLI-side only so `CollectBindings` skips it (value never reaches MCP params); the pipeline executor still reads it via `extractFlagValuesByAlias`.
|
||||
- Download step emits machine-parseable plain-text lines (`jobId: <id>\n`, `downloadUrl: <url>\n`) alongside the standard JSON envelope, so shell pipelines and regex-based tests can extract key values without JSON parsing.
|
||||
|
||||
## [1.0.24] - 2026-05-09
|
||||
|
||||
Three small but user-visible safety/usability changes: the embedded distribution now refuses to self-upgrade, the `dws auth login` help text finally matches the actual default flow (loopback, not device), and the release workflow gains a manual fallback trigger.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws upgrade` is blocked in embedded distributions** (#248) — when the CLI is shipped as an embedded asset (e.g. inside another product), `dws upgrade` would happily overwrite the host-managed binary. The upgrade entry point now detects the embedded build flag and exits early with a clear message; covered by `internal/app/upgrade_embedded_guard_test.go`.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`dws auth login` help text reflects the real default** (#238, fixes #226) — the long help previously claimed "OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback listener and only switches to device flow when `--device` is passed. SSH-into-headless-Linux users following the old text hit a dead end (remote-side 127.0.0.1 is unreachable from the local browser). Help and two `flagErrorWithSuggestions` messages in `root.go` are realigned: each method is named after its real flag (`OAuth Loopback 流 (默认)` / `OAuth 设备流 (--device)` / `直接提供 Token (--token)`), with an explicit `--device` example for SSH/headless. No behaviour change.
|
||||
|
||||
### CI
|
||||
|
||||
- **`workflow_dispatch` trigger added to release workflow as a fallback** (#261) — GitHub occasionally drops tag-push events; the release job can now be re-run manually against any tag ref without having to delete and re-push the tag.
|
||||
|
||||
## [1.0.23] - 2026-05-08
|
||||
|
||||
A single fix for HTTP proxy support across the CLI's custom HTTP transports. No behaviour changes elsewhere.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`HTTP_PROXY` / `HTTPS_PROXY` environment variables silently ignored by all custom transports** (#237, fixes #236) — the three custom `http.Transport` instances built by the CLI (`internal/transport/client.go` MCP transport, `internal/apiclient/client.go` DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry client) all set `DialContext` / `TLSClientConfig` / timeouts but omitted the `Proxy` field. Per Go's `net/http` contract, a non-nil Transport without an explicit `Proxy` means "no proxy" — env vars are silently ignored, breaking sandboxed or air-gapped deployments that route outbound through `HTTP_PROXY` / `HTTPS_PROXY`. All three transports now set `Proxy: http.ProxyFromEnvironment`.
|
||||
|
||||
### Tests
|
||||
|
||||
- Per-package regression test that pointer-compares the Transport's `Proxy` func against `http.ProxyFromEnvironment`, avoiding flakiness from Go's `envProxyOnce` memoisation when running alongside tests that read proxy env early. (#237)
|
||||
|
||||
## [1.0.22] - 2026-05-07
|
||||
|
||||
Two release-blocking bug fixes: `dws attendance summary` now exposes the server-required `--stats-type` flag (without it, every call returned C0002), and the install scripts finally populate `~/.hermes/skills/dws/` for users who already have Hermes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws attendance summary` returned C0002 (统计类型错误) on every call** (#228, fixes #227) — the DingTalk MCP tool `get_attendance_summary` requires `statsType` at the business layer even though the schema marks it optional. The CLI did not expose any way to set it, so the command was 100% unusable. A new `--stats-type` flag (`week` / `month`) is now plumbed through to `QueryUserAttendVO.statsType`; the flag is documented as required in the long help, flag description, and `skills/references/products/attendance.md`.
|
||||
- **Install scripts skipped `.hermes/skills/` when populating skill directories** (#221, fixes #188) — the `AGENT_DIRS` lists across `build/npm/install.js`, `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh` and the four upgrade-path mirrors (8 sources total once review feedback was addressed) did not include `.hermes/skills`, so users with Hermes installed were not getting `~/.hermes/skills/dws/` populated automatically. The existing parent-directory gate keeps this zero-side-effect for users without Hermes.
|
||||
|
||||
### Tests
|
||||
|
||||
- New `--stats-type` regression coverage in `test/cli_compat/attendance_test.go` — verifies `statsType` is written to `QueryUserAttendVO` when set to `month` or `week`, and is omitted when not provided. (#228)
|
||||
|
||||
## [1.0.21] - 2026-05-05
|
||||
|
||||
A single critical routing fix for `dws drive` commands. No new commands or behaviour changes elsewhere.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws drive mkdir` / `dws drive download` silently routed to the doc MCP server** (#220, fixes #219) — when two MCP servers register tools with the same name (e.g. both `drive` and `doc` expose `create_folder`), the tool-level endpoint map used last-writer-wins, so drive-side calls landed on the doc endpoint and returned mock-shaped responses (`success: true` with a fake `folderId`) without actually creating anything. `directRuntimeEndpoint` now resolves product-level first when the caller already knows the productID, and only falls back to the tool-level lookup when productID is empty. The wrong-server collision and the resulting "succeeded but didn't" behaviour are gone.
|
||||
|
||||
## [1.0.20] - 2026-05-04
|
||||
|
||||
Documentation polish and a login regression fix. No behaviour changes outside the login MCP refresh path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Login no longer reuses stale `clientId` from an old MCP cache** (#213) — `dws login` now unconditionally re-fetches the MCP descriptor, so a previously cached client id can't keep producing auth errors after the server rotates it.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`dws chat message list` pagination** (#218, fixes #195) — clarifies that `nextCursor` is opaque and must be passed back as `--cursor` exactly; warns against parsing or reusing it as an offset.
|
||||
- **`dws contact search` examples** (#209) — switched from the removed `--keyword` flag to the current `--query`.
|
||||
- **`dws todo` help text** (#205) — expanded field semantics so MCP wrappers generate accurate schemas.
|
||||
- **`dws chat message send-by-bot` and `dws report create` help** (#217, #106, #107) — `--robot-code` / `--title` / `--text` now carry the `(必填)` marker; `report create --contents` documents the `key=field_name` requirement and rewrites examples as a `template detail → create` two-step pipeline.
|
||||
- **CHANGELOG backfill for 1.0.19** (#204).
|
||||
|
||||
## [1.0.19] - 2026-04-30
|
||||
|
||||
Discovery hardening for edition overlays: `edition.SupplementServers` / `FallbackServers` hooks now consistently surface through the **runtime catalog loader**, not just the static command tree, so overlay products that live outside the Portal envelope (e.g. Wukong gray-release `conference`) resolve an endpoint on both the cold-cache and tool-not-in-catalog paths. Ships with per-edition cache partitioning to stop cross-edition disk-cache leakage, plus a small todo fix.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>Table of Contents</strong></summary>
|
||||
@@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Skill mode: mono vs multi</strong></summary>
|
||||
|
||||
The installer ships skills in one of two layouts. CLI commands (`dws aitable ...`, `dws calendar ...`) are identical in both modes — only the agent-side skill layout differs.
|
||||
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`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:
|
||||
|
||||
- **Quick install** (one-liner above): non-interactive, installs `mono`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Other install methods</summary>
|
||||
|
||||
@@ -92,6 +113,36 @@ cp dws ~/.local/bin/ # install to PATH
|
||||
|
||||
</details>
|
||||
|
||||
## 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.
|
||||
|
||||
**3. Skills only (Gitee mirror):**
|
||||
|
||||
```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-skills.sh | sh
|
||||
```
|
||||
|
||||
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
@@ -182,6 +233,42 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Multiple organizations (profiles)</strong></summary>
|
||||
|
||||
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
|
||||
|
||||
```bash
|
||||
dws auth login # log in to another org → adds a profile (first login becomes the primary)
|
||||
dws profile list # list logged-in orgs (primary / current marker, status)
|
||||
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
|
||||
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
|
||||
```
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
|
||||
|
||||
Copying only `~/.dws/app.json` does not carry the refresh token; access tokens expire after ~2 hours. Use the official export/import flow:
|
||||
|
||||
```bash
|
||||
# Sandbox A (already logged in)
|
||||
dws auth export -o /tmp/dws-auth.tar.gz
|
||||
# Or for copy/paste: dws auth export --base64 -o /tmp/dws-auth.b64
|
||||
|
||||
# Sandbox B
|
||||
dws auth import -i /tmp/dws-auth.tar.gz
|
||||
# Or: dws auth import -i /tmp/dws-auth.b64 --base64
|
||||
dws auth status # confirm "Refresh Token: valid"
|
||||
```
|
||||
|
||||
The bundle includes the encrypted keychain under `~/.local/share/dws-cli` (with `auth-token.enc` and `dek`) plus required `~/.dws` config files.
|
||||
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -224,32 +311,67 @@ dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
# Step 2: Inspect target tool's parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.parameters'
|
||||
|
||||
# Optional: inspect DingTalk authorization metadata for PAT planning
|
||||
dws schema aitable.query_records --jq '.tool.auth'
|
||||
|
||||
# Step 3: Construct the correct call
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
### Agent Skills
|
||||
|
||||
The repo ships a complete Agent Skill system (`skills/`). After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
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/`, ... 20 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:
|
||||
|
||||
```bash
|
||||
# Install skills into current project
|
||||
# Install skills into current project (defaults to mono)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
**What's included:**
|
||||
**Switching or re-installing with `dws skill setup`:**
|
||||
|
||||
```bash
|
||||
# Interactive: prompts for mode + target agents
|
||||
dws skill setup
|
||||
|
||||
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
|
||||
# Install multi skills to a single agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress)
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| Flag | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
|
||||
| `--source` | path | Local source directory (overrides bundled skills) |
|
||||
| `--yes` | — | Skip confirmation prompts |
|
||||
|
||||
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
|
||||
|
||||
**What's included (mono layout):**
|
||||
|
||||
| Component | Path | Description |
|
||||
|-----------|------|-------------|
|
||||
| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling |
|
||||
| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
|
||||
| Intent guide | `references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
|
||||
| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
| Master Skill | `skills/mono/SKILL.md` | Intent routing, decision tree, safety rules, error handling |
|
||||
| Product references | `skills/mono/references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
|
||||
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
|
||||
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
|
||||
<details>
|
||||
<summary><strong>Ready-made scripts</strong> — 13 Python scripts for common multi-step workflows</summary>
|
||||
@@ -372,6 +494,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
dws schema # list all products and tools
|
||||
dws schema aitable.query_records # view parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.required' # view required fields
|
||||
dws schema aitable.query_records --jq '.tool.auth' # view authorization metadata
|
||||
dws schema --jq '.products[].id' # extract all product IDs
|
||||
```
|
||||
|
||||
@@ -394,36 +517,65 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report" --text @-
|
||||
```
|
||||
|
||||
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
|
||||
|
||||
</details>
|
||||
|
||||
## DingTalk bot — connect a robot to your local AI
|
||||
|
||||
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --unified-app-id <unifiedAppId>
|
||||
```
|
||||
|
||||
> `--unified-app-id` resolves `clientSecret` at runtime via `dev app credentials get`,
|
||||
> so the secret never appears in argv (`ps` / journald / shell history). The
|
||||
> legacy `--robot-client-id <id> --robot-client-secret <secret>` still works but
|
||||
> the CLI will warn you.
|
||||
|
||||
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
|
||||
|
||||
| Command | Effect |
|
||||
|---------|--------|
|
||||
| `/new` (aliases `/start`, `/reset`) | Start a fresh session; the previous one is left intact (resumable where the agent supports it) |
|
||||
| `/clear` | Wipe the current session — disposed through the agent's real session op (opencode issues `DELETE /session/:id`); channels whose agent exposes no delete primitive fall back to a reset |
|
||||
|
||||
See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step walkthrough (install → create robot → connect → add to a group).
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
|
||||
| Chat / IM | `chat` (alias `im`) | 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | Messages (send / list / list-all / by-sender / mentions / focused / unread / topic replies / search), group CRUD + member management (incl. `add-bot`), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common groups lookup |
|
||||
| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
|
||||
| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records |
|
||||
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
|
||||
| Ding | `ding` | 2 | `message` | Send / recall DING messages |
|
||||
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
|
||||
| AI Tables | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments; templates |
|
||||
| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
|
||||
| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 4 | `mailbox` `message` | List mailbox addresses, KQL message search, get full message content, send email |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
| Service | Command | Capabilities |
|
||||
|---------|---------|--------------|
|
||||
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
|
||||
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
|
||||
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
|
||||
| Todo | `todo` | Create / list / update / complete tasks and comments |
|
||||
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
|
||||
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
|
||||
| Ding | `ding` | Send / recall DING messages |
|
||||
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
|
||||
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
|
||||
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
|
||||
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
|
||||
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
|
||||
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
|
||||
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
|
||||
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
|
||||
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
|
||||
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
|
||||
| Live | `live` | List my live streams |
|
||||
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
|
||||
|
||||
> **163 commands across 14 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 <service> --help` for subcommands.
|
||||
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's 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.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
`conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
|
||||
|
||||
</details>
|
||||
|
||||
@@ -475,6 +627,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
|
||||
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
|
||||
- [Changelog](./CHANGELOG.md) — release history and migration notes
|
||||
|
||||
## Contributing
|
||||
|
||||
+174
-30
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>目录</strong></summary>
|
||||
@@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Skill 模式:mono 与 multi</strong></summary>
|
||||
|
||||
安装时可以选择两种 skill 组织方式。两种模式下 CLI 命令完全一样(`dws aitable ...` / `dws calendar ...`),区别只在 Agent 那边读到的 skill 文档结构。
|
||||
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>其他安装方式</summary>
|
||||
|
||||
@@ -92,6 +113,36 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
|
||||
</details>
|
||||
|
||||
## 国内加速安装
|
||||
|
||||
国内用户可使用以下通道,避免 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 的公开包,国内可直接使用。
|
||||
|
||||
**3. 单独安装 Skills(Gitee 镜像):**
|
||||
|
||||
```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-skills.sh | sh
|
||||
```
|
||||
|
||||
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
@@ -182,6 +233,42 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>多组织(profile)</strong></summary>
|
||||
|
||||
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
|
||||
|
||||
```bash
|
||||
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
|
||||
dws profile list # 列出已登录组织(主 / 当前标记、状态)
|
||||
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
|
||||
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
|
||||
```
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
|
||||
|
||||
仅拷贝 `~/.dws/app.json` 无法带走 refresh token;access token 约 2 小时后会失效。请使用官方导出/导入:
|
||||
|
||||
```bash
|
||||
# A 沙箱(已登录)
|
||||
dws auth export -o /tmp/dws-auth.tar.gz
|
||||
# 或便于分片复制:dws auth export --base64 -o /tmp/dws-auth.b64
|
||||
|
||||
# B 沙箱
|
||||
dws auth import -i /tmp/dws-auth.tar.gz
|
||||
# 或:dws auth import -i /tmp/dws-auth.b64 --base64
|
||||
dws auth status # 确认 Refresh Token: 有效
|
||||
```
|
||||
|
||||
包内包含 `~/.local/share/dws-cli` 加密 keychain(含 `auth-token.enc` 与 `dek`)及 `~/.dws` 必要配置。
|
||||
|
||||
</details>
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
@@ -230,26 +317,58 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
### Agent Skills
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉:
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目
|
||||
# 安装 skills 到当前项目(默认 mono)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
**包含内容:**
|
||||
**用 `dws skill setup` 切换或重装:**
|
||||
|
||||
```bash
|
||||
# 交互式:提示选模式 + 目标 Agent
|
||||
dws skill setup
|
||||
|
||||
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
|
||||
# 只装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本)
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
|
||||
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
|
||||
| `--yes` | — | 跳过确认提示 |
|
||||
|
||||
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
|
||||
|
||||
**包含内容(mono 布局):**
|
||||
|
||||
| 组件 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 主 Skill | `SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
|
||||
| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
|
||||
| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
||||
| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
| 主 Skill | `skills/mono/SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
|
||||
| 产品参考 | `skills/mono/references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
|
||||
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
||||
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
|
||||
<details>
|
||||
<summary><strong>现成脚本</strong> — 13 个 Python 脚本,覆盖常见多步工作流</summary>
|
||||
@@ -394,36 +513,60 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报" --text @-
|
||||
```
|
||||
|
||||
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
|
||||
|
||||
</details>
|
||||
|
||||
## 钉钉机器人 —— 把机器人接到你本地的 AI
|
||||
|
||||
`dws dev connect` 把一个钉钉机器人接到本地 AI CLI(Claude Code / Codex / opencode / Qoder / Gemini,或用 `--agent-cmd` 接任意工具):群里 @ 机器人提问,它用你本地的 agent 回答,按会话保留多轮上下文。
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
|
||||
```
|
||||
|
||||
聊天里的**会话指令**(整条消息就是指令时生效,不消耗一次 AI 调用):
|
||||
|
||||
| 指令 | 作用 |
|
||||
|------|------|
|
||||
| `/new`(别名 `/start`、`/reset`) | 开启新会话;旧会话保留(agent 支持的话仍可回溯) |
|
||||
| `/clear` | 清空当前会话 —— 调 agent 真实会话原语真删(opencode 走 `DELETE /session/:id`);驱动接口没有删除原语的渠道退化为重置 |
|
||||
|
||||
完整四步教程见 [`docs/robot-quickstart.md`](./docs/robot-quickstart.md)(装工具 → 建机器人 → 接上 AI → 拉进群)。
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
|
||||
| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 |
|
||||
| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
|
||||
| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
|
||||
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
|
||||
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 |
|
||||
| AI 表格 | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件;模板 |
|
||||
| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
|
||||
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
|
||||
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
|
||||
| 邮箱 | `mail` | 4 | `mailbox` `message` | 邮箱地址列表、KQL 邮件搜索、邮件详情、发送邮件 |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
| 服务 | 命令 | 能力 |
|
||||
|------|------|------|
|
||||
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
|
||||
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
|
||||
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
|
||||
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
|
||||
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
|
||||
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
|
||||
| DING | `ding` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
|
||||
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
|
||||
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
|
||||
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
|
||||
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
|
||||
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
|
||||
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
|
||||
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
|
||||
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
|
||||
| 直播 | `live` | 查看我的直播列表 |
|
||||
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
|
||||
|
||||
> **14 个产品,163 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --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` 产品。
|
||||
|
||||
<details>
|
||||
<summary>即将推出</summary>
|
||||
|
||||
`conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
|
||||
- `conference`(视频会议)
|
||||
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
|
||||
|
||||
</details>
|
||||
|
||||
@@ -477,6 +620,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
|
||||
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
|
||||
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
|
||||
|
||||
## 贡献指南
|
||||
|
||||
@@ -43,6 +43,8 @@ __KEG_ONLY_LINE__
|
||||
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
|
||||
|
||||
+38
-3
@@ -12,6 +12,8 @@ const AGENT_DIRS = [
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
@@ -136,11 +138,36 @@ function installSkillsToHomes(skillRoot) {
|
||||
}
|
||||
}
|
||||
|
||||
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
|
||||
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
|
||||
// can fall back to a user-local cache when --source is not provided. mono is
|
||||
// already installed into agent homes by installSkillsToHomes; the cache is
|
||||
// purely a source-of-truth for the setup command.
|
||||
function cacheUserSkills(extractedSkillsRoot) {
|
||||
const cacheBase = path.join(os.homedir(), ".dws", "skills");
|
||||
|
||||
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
|
||||
? path.join(extractedSkillsRoot, "mono")
|
||||
: extractedSkillsRoot;
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
|
||||
const multiSource = path.join(extractedSkillsRoot, "multi");
|
||||
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
|
||||
const multiCache = path.join(cacheBase, "multi");
|
||||
fs.rmSync(multiCache, { recursive: true, force: true });
|
||||
copyChildren(multiSource, multiCache);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const packageRoot = __dirname;
|
||||
const assetsDir = path.join(packageRoot, "assets");
|
||||
const vendorDir = path.join(packageRoot, "vendor");
|
||||
const skillDir = path.join(packageRoot, "share", "skills", "dws");
|
||||
// Extract dws-skills.zip into a staging directory so we can split mono/
|
||||
// (installed to agent homes) from multi/ (cached for later setup use).
|
||||
const skillsStaging = path.join(packageRoot, "share", "skills");
|
||||
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
|
||||
if (!assetName) {
|
||||
throw new Error(`unsupported platform: ${process.platform}/${process.arch}`);
|
||||
@@ -156,8 +183,16 @@ function main() {
|
||||
}
|
||||
|
||||
extractArchive(archivePath, vendorDir);
|
||||
extractSkills(skillsPath, skillDir);
|
||||
installSkillsToHomes(skillDir);
|
||||
extractSkills(skillsPath, skillsStaging);
|
||||
|
||||
// For backward compatibility, the zip root carries a copy of mono content
|
||||
// (SKILL.md + references/ + scripts/). Prefer the explicit mono/ subdir
|
||||
// when present; fall back to the staging root otherwise.
|
||||
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
|
||||
? path.join(skillsStaging, "mono")
|
||||
: skillsStaging;
|
||||
installSkillsToHomes(monoRoot);
|
||||
cacheUserSkills(skillsStaging);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -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 if explicitly declared …) | channel |
|
||||
| `x-dws-agent-instance-id` | `dwsa_<base62>` 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=<code>`. **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 — unresolved:** unknown host sends no agent_code. 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 slug. Values declared via `DINGTALK_DWS_AGENTCODE` are forwarded
|
||||
verbatim so PAT grants and follow-up command checks use the same key.
|
||||
|
||||
## 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).
|
||||
+31
-16
@@ -4,23 +4,38 @@
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
1. `internal/market` fetches the registry and server metadata.
|
||||
2. `internal/discovery` resolves runtime server capabilities and caches results.
|
||||
3. `internal/ir` normalizes discovery output into one canonical tool catalog.
|
||||
4. `internal/cli` and `internal/app` mount that catalog into the public Cobra command tree.
|
||||
5. `internal/transport` executes MCP JSON-RPC calls and `internal/output` formats responses.
|
||||
1. `cmd` is the CLI entrypoint, invoking `internal/app` to build the root Cobra command tree.
|
||||
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`) and dynamically loads product commands via `internal/plugin`.
|
||||
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
|
||||
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
|
||||
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `cmd`: CLI entrypoint
|
||||
- `internal/app`: root command wiring and static utility commands
|
||||
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and execution
|
||||
- `internal/ir`: canonical intermediate representation for discovered tools
|
||||
- `internal/generator`: docs, schema, and skill generation pipeline
|
||||
- `internal/compat`, `internal/helpers`: legacy-compatible overlays and helper commands
|
||||
- `skills/`: bundled agent skills source and generated skill docs
|
||||
- `test/`: CLI, compatibility, integration, contract, and script tests
|
||||
|
||||
## Public Repository Contract
|
||||
|
||||
This repository ships source, docs, tests, packaging templates, and install scripts. Generated or release-only artifacts are produced by repository scripts and are not required to exist in a clean checkout unless explicitly committed as part of a release workflow.
|
||||
- `internal/app`: root command wiring, static utility commands, and plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: plugin-based dynamic command loader
|
||||
- `internal/cli`: catalog types and endpoint loader (static endpoint mode)
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection, identity
|
||||
- `internal/audit`: user operation audit log (JSONL, hash chain, forwarding)
|
||||
- `internal/errors`: structured error model with categories and hints
|
||||
- `internal/keychain`: OS keychain integration for credential storage
|
||||
- `internal/security`: endpoint allowlist and domain trust
|
||||
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
|
||||
- `internal/cobracmd`: shared Cobra command builders
|
||||
- `internal/pat`: PAT (Personal Access Token) authorization flow
|
||||
- `internal/output`: response formatting (json, table, raw, pretty)
|
||||
- `internal/logging`: structured logging and argument sanitization
|
||||
- `internal/tui`: terminal UI helpers
|
||||
- `internal/recovery`: panic recovery and graceful degradation
|
||||
- `pkg/configmeta`: environment variable registry and documentation
|
||||
- `pkg/config`: configuration constants and paths
|
||||
- `pkg/edition`: edition detection (oss vs enterprise)
|
||||
- `pkg/mcptypes`: MCP protocol type definitions
|
||||
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
|
||||
- `test/`: CLI, integration, contract, unit, and skill E2E tests
|
||||
- `scripts/`: install scripts, policy checks, and CI helpers
|
||||
- `envelope/`: pre-built discovery payloads for offline use
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# 审计日志(Audit Log)
|
||||
|
||||
DWS 自动记录每次 MCP HTTP 调用的审计事件,用于合规追溯和安全审查。默认启用,无需额外配置。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **自动记录**:每次命令执行产生一条 JSONL 审计事件
|
||||
- **按天轮转**:日志文件按日期分割(`audit-YYYYMMDD.jsonl`),默认留存 90 天
|
||||
- **防篡改**:L1 哈希链(sha256),每条事件链接前一条的 hash,可验证完整性
|
||||
- **远端转发**:支持 POST 到外部 SIEM 或审计平台
|
||||
- **三级脱敏**:转发时可按 none/hashed/minimal 脱敏敏感字段
|
||||
- **CLI 命令**:内置 `dws audit tail/export/verify` 查看和验证
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `DWS_AUDIT` | 启用 | 设 `0`/`false`/`off` 关闭审计 |
|
||||
| `DWS_AUDIT_DIR` | `~/.dws/audit` | 审计日志目录 |
|
||||
| `DWS_AUDIT_RETENTION_DAYS` | `90` | 日志留存天数 |
|
||||
| `DWS_AUDIT_FORWARD_URL` | (空) | 远端转发 URL(POST JSON) |
|
||||
| `DWS_AUDIT_FORWARD_TOKEN` | (空) | 远端转发 Bearer Token |
|
||||
| `DWS_AUDIT_FORWARD_REDACT` | `none` | 转发脱敏级别:`none`/`hashed`/`minimal` |
|
||||
|
||||
## 审计事件格式
|
||||
|
||||
每条事件是一行 JSON,字段如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-07-06T10:59:06+08:00",
|
||||
"execution_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"agent_id": "agent-xxx",
|
||||
"actor": {
|
||||
"user_id": "525018",
|
||||
"name": "胡奕舟",
|
||||
"corp_id": "ding8196cd9a2b2405da24f2f5cc6abecb85",
|
||||
"corp_name": "钉钉"
|
||||
},
|
||||
"product": "calendar",
|
||||
"command": "list_events",
|
||||
"endpoint": "https://api.dingtalk.com/v1.0/calendar/users/xxx/calendars/primary/events",
|
||||
"params_summary": "maxResults=20",
|
||||
"result": "success",
|
||||
"error_category": "",
|
||||
"error_reason": "",
|
||||
"duration_ms": 234,
|
||||
"cli_version": "1.0.47",
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"prev_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
- `ts`:事件时间戳(RFC3339)
|
||||
- `execution_id`:本次命令执行的唯一 ID
|
||||
- `agent_id`:Agent 标识(若有)
|
||||
- `actor`:执行者信息(从登录态获取)
|
||||
- `product`:调用的产品(如 `calendar`、`chat`、`contact`)
|
||||
- `command`:调用的命令(如 `list_events`、`send_message`)
|
||||
- `endpoint`:实际请求的 API 端点(已脱敏)
|
||||
- `params_summary`:参数摘要(已脱敏)
|
||||
- `result`:`success` 或 `error`
|
||||
- `error_category` / `error_reason`:错误分类和原因(成功时为空)
|
||||
- `duration_ms`:命令执行耗时(毫秒)
|
||||
- `prev_hash` / `hash`:哈希链字段,用于防篡改验证
|
||||
|
||||
## CLI 命令
|
||||
|
||||
### 查看最近日志
|
||||
|
||||
```bash
|
||||
# 查看最近 20 条审计事件(默认)
|
||||
dws audit tail
|
||||
|
||||
# 查看最近 50 条
|
||||
dws audit tail -n 50
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```
|
||||
2026-07-06 10:59:06 calendar list_events user=525018 result=success 234ms
|
||||
2026-07-06 10:58:42 chat send_message user=525018 result=success 156ms
|
||||
2026-07-06 10:57:15 contact search user=525018 result=success 89ms
|
||||
```
|
||||
|
||||
### 导出日志
|
||||
|
||||
```bash
|
||||
# 导出最近 7 天的 JSONL
|
||||
dws audit export --since 2026-06-29 --until 2026-07-06 --format jsonl > audit-7d.jsonl
|
||||
|
||||
# 导出为 CSV(方便 Excel 打开)
|
||||
dws audit export --since 2026-07-01 --format csv > audit-july.csv
|
||||
```
|
||||
|
||||
### 验证哈希链
|
||||
|
||||
```bash
|
||||
# 验证当前最新日志文件的哈希链完整性
|
||||
dws audit verify
|
||||
|
||||
# 验证指定文件
|
||||
dws audit verify --file ~/.dws/audit/audit-20260706.jsonl
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```
|
||||
✓ 哈希链完整:234 条事件全部校验通过
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```
|
||||
✗ 哈希链断裂:第 156 条事件的 hash 不匹配
|
||||
```
|
||||
|
||||
## 哈希链防篡改
|
||||
|
||||
每条事件的 `hash` 字段由以下公式计算:
|
||||
|
||||
```
|
||||
hash = sha256(prev_hash + event_json_without_hash_fields)
|
||||
```
|
||||
|
||||
- 首条事件的 `prev_hash` 为空字符串
|
||||
- 后续事件的 `prev_hash` = 前一条的 `hash`
|
||||
- 任何对历史事件的篡改都会导致后续所有 hash 失效
|
||||
|
||||
**验证流程**:
|
||||
|
||||
1. 读取日志文件,逐行解析
|
||||
2. 对每条事件,移除 `prev_hash` 和 `hash` 字段,重新序列化
|
||||
3. 用前一条的 hash + 当前事件 JSON 计算新 hash
|
||||
4. 对比计算结果与文件中记录的 hash
|
||||
5. 全部匹配 = 完整;某条不匹配 = 被篡改
|
||||
|
||||
## 远端转发
|
||||
|
||||
设置 `DWS_AUDIT_FORWARD_URL` 后,每条审计事件会异步 POST 到指定端点:
|
||||
|
||||
```bash
|
||||
export DWS_AUDIT_FORWARD_URL=https://siem.example.com/ingest/audit
|
||||
export DWS_AUDIT_FORWARD_TOKEN=your-bearer-token # 可选
|
||||
```
|
||||
|
||||
**请求格式**:
|
||||
|
||||
```http
|
||||
POST /ingest/audit HTTP/1.1
|
||||
Host: siem.example.com
|
||||
Authorization: Bearer your-bearer-token
|
||||
Content-Type: application/json
|
||||
|
||||
{"ts":"2026-07-06T10:59:06+08:00","product":"calendar",...}
|
||||
```
|
||||
|
||||
**超时与重试**:3 秒超时,失败不阻塞命令执行(best-effort),不自动重试。
|
||||
|
||||
## 脱敏分级
|
||||
|
||||
转发时可通过 `DWS_AUDIT_FORWARD_REDACT` 控制脱敏级别:
|
||||
|
||||
| 级别 | 行为 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `none`(默认) | 原样转发,不脱敏 | 内部审计平台 |
|
||||
| `hashed` | actor.name 哈希化,params_summary 脱敏 | 跨部门共享 |
|
||||
| `minimal` | 仅保留 ts/product/command/result/duration_ms,移除 actor/endpoint/params | 对外合规报告 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```bash
|
||||
# 最小化脱敏(仅保留元数据)
|
||||
export DWS_AUDIT_FORWARD_REDACT=minimal
|
||||
export DWS_AUDIT_FORWARD_URL=https://compliance.example.com/audit
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 审计日志占多少磁盘?
|
||||
|
||||
典型场景(每天 100 条命令)约 50KB/天,90 天约 4.5MB。日志文件是 JSONL 纯文本,gzip 压缩后约 1/5。
|
||||
|
||||
### Q: 关闭审计会影响性能吗?
|
||||
|
||||
设置 `DWS_AUDIT=0` 后,审计模块不初始化,零开销。默认启用时,每条事件写入耗时 <1ms(异步磁盘 IO)。
|
||||
|
||||
### Q: 哈希链断了怎么办?
|
||||
|
||||
可能原因:
|
||||
1. 手动编辑过日志文件
|
||||
2. 磁盘损坏
|
||||
3. 并发写入导致顺序错乱(罕见)
|
||||
|
||||
**处理**:
|
||||
- 备份当前日志
|
||||
- 用 `dws audit verify` 定位断裂位置
|
||||
- 从断裂点之后的事件可继续验证(前缀已不可信)
|
||||
|
||||
### Q: 如何清理旧日志?
|
||||
|
||||
自动清理:`DWS_AUDIT_RETENTION_DAYS=90`(默认),超过 90 天的文件在下次启动时 best-effort 删除。
|
||||
|
||||
手动清理:
|
||||
|
||||
```bash
|
||||
# 删除 2026 年 6 月之前的日志
|
||||
rm ~/.dws/audit/audit-202605*.jsonl
|
||||
```
|
||||
|
||||
## 技术实现
|
||||
|
||||
- **核心包**:`internal/audit`
|
||||
- **集成点**:`internal/app/runner.go` 的 `executeInvocation` 方法(defer 调用 `emitAudit`)
|
||||
- **身份获取**:从 `auth.LoadTokenData` 读取当前登录用户
|
||||
- **参数脱敏**:调用 `logging.SanitizeArguments`(与现有日志脱敏逻辑一致)
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [环境变量参考](./reference.md)
|
||||
- [架构概览](./architecture.md)
|
||||
- [自动化与脚本](./automation.md)
|
||||
+26
-25
@@ -13,40 +13,44 @@ repository root while preserving repo-local guidance for automation.
|
||||
## Project Snapshot
|
||||
|
||||
- `dws` is a Go-based DingTalk Workspace CLI and MCP runtime bridge.
|
||||
- One internal Tool IR drives canonical CLI, schema, docs, skills, and snapshots.
|
||||
- Compatibility and helper surfaces are overlays, not the canonical truth.
|
||||
- Product commands are loaded dynamically via `internal/plugin` from bundled descriptors.
|
||||
- Command handlers live in `internal/helpers`; runtime execution flows through `internal/executor` and `internal/transport`.
|
||||
|
||||
## Repository Map
|
||||
|
||||
- `cmd`: public CLI entrypoint
|
||||
- `internal/app`: root command wiring and command tree mount points
|
||||
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and MCP transport
|
||||
- `internal/generator`: CLI/schema/docs/skills generation pipeline
|
||||
- `internal/compat`, `internal/helpers`: legacy-compatible aliases and helper commands
|
||||
- `internal/app`: root command wiring, static utility commands, plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: plugin-based dynamic command loader
|
||||
- `internal/cli`: catalog types and static endpoint loader
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection
|
||||
- `internal/audit`: user operation audit log
|
||||
- `internal/errors`: structured error model with categories and hints
|
||||
- `internal/keychain`: OS keychain integration for credential storage
|
||||
- `internal/security`: endpoint allowlist and domain trust
|
||||
- `internal/pat`: PAT (Personal Access Token) authorization flow
|
||||
- `docs/`: public architecture and reference docs
|
||||
- `hack/`: developer-only helper commands not shipped as public binaries
|
||||
- `scripts/`: build, test, lint, packaging, and policy checks
|
||||
- `test/`: integration, contract, compatibility, and script validation suites
|
||||
- `test/`: CLI, integration, contract, unit, and skill E2E test suites
|
||||
|
||||
## Task Routing
|
||||
|
||||
- Add or fix a command path: start from `internal/app` and the related module under `internal/*`
|
||||
- Discovery or protocol issues: inspect `internal/discovery`, `internal/market`, `internal/transport`
|
||||
- Generated output drift: inspect `internal/generator` and run drift checks
|
||||
- Legacy behavior mismatch: inspect `internal/compat` and `test/cli_compat`
|
||||
- Failure or degraded mode: inspect `internal/discovery`, `internal/errors`
|
||||
- Add or fix a command path: start from `internal/helpers` (handler implementations) or `internal/app` (command tree wiring)
|
||||
- Protocol or transport issues: inspect `internal/transport`
|
||||
- Auth or login issues: inspect `internal/auth`, `internal/pat`, `internal/keychain`
|
||||
- Error message or category issues: inspect `internal/errors`
|
||||
- Audit log issues: inspect `internal/audit`
|
||||
- Plugin loading or command surface: inspect `internal/plugin`
|
||||
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
|
||||
|
||||
## Generated Artifacts
|
||||
## Policy Checks
|
||||
|
||||
Prefer editing source logic instead of generated files directly.
|
||||
When command surface or plugin descriptors change, run:
|
||||
|
||||
- Generated-heavy paths:
|
||||
- `docs/generated/`
|
||||
- `skills/generated/`
|
||||
- `test/golden/generated_outputs/`
|
||||
- When generator or command surface changes, run:
|
||||
- `./scripts/policy/check-generated-drift.sh`
|
||||
- `./scripts/policy/check-command-surface.sh --strict`
|
||||
- `./scripts/policy/check-command-surface.sh --strict`
|
||||
- `./scripts/policy/check-open-source-assets.sh`
|
||||
|
||||
## Common Commands
|
||||
|
||||
@@ -55,9 +59,6 @@ make build
|
||||
make test
|
||||
make lint
|
||||
./scripts/dev/ci-local.sh
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-command-surface.sh --strict
|
||||
./scripts/policy/check-open-source-assets.sh
|
||||
git diff --check
|
||||
```
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
|
||||
- **Products**: 13
|
||||
- **Total commands**: 159
|
||||
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
|
||||
- **Total commands**: 160
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Edit `pre/config.json`, not this file.
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
|
||||
## Global flags
|
||||
|
||||
@@ -36,7 +35,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 +181,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, traceId, 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 +320,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. |
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Running the connector as a 7x24 service
|
||||
|
||||
`dws dev connect` keeps a DingTalk robot wired to a local agent over a
|
||||
Stream long-connection. By default it runs in the foreground and dies when the
|
||||
terminal closes. For an unattended "digital employee" you have two options.
|
||||
|
||||
> **Security**: prefer `--unified-app-id <uappid>` over
|
||||
> `--robot-client-id/--robot-client-secret`. With `--unified-app-id` the CLI
|
||||
> resolves clientId/clientSecret at runtime through `dev app credentials get`,
|
||||
> so the secret never appears in `ps` / journald / shell history. Pasting
|
||||
> `--robot-client-secret` onto argv lets any local user read your AppSecret
|
||||
> with `ps -ef`; the CLI will warn you when you do that.
|
||||
|
||||
## Option A: built-in daemon (recommended for a quick start)
|
||||
|
||||
```bash
|
||||
# Detach into a background supervisor that restarts the connector if it crashes.
|
||||
dws dev connect --daemon \
|
||||
--channel claudecode \
|
||||
--unified-app-id <unifiedAppId>
|
||||
|
||||
# Inspect / stop / restart it (locate the daemon by unifiedAppId).
|
||||
dws dev connect status --unified-app-id <unifiedAppId>
|
||||
dws dev connect stop --unified-app-id <unifiedAppId>
|
||||
dws dev connect restart --unified-app-id <unifiedAppId>
|
||||
```
|
||||
|
||||
- The parent prints the daemon pid and the log path, then exits.
|
||||
- A supervisor process (POSIX `setsid`, detached from the terminal) keeps a
|
||||
worker connector alive, restarting it with exponential backoff (1s..60s, up to
|
||||
10 consecutive fast failures) when it exits abnormally.
|
||||
- The single-instance lock (one connector per robot per machine) is reused, so a
|
||||
duplicate daemon refuses to start.
|
||||
- Logs go to `~/.dws/connect/<key>/daemon.log` with size-based rotation
|
||||
(5 MB x 2 backups), and the pid file lives at
|
||||
`~/.dws/connect/<key>/daemon.pid`.
|
||||
- The daemon does NOT survive a reboot. For that, use Option B.
|
||||
|
||||
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
|
||||
> Windows service wrapper around the foreground command instead.
|
||||
|
||||
## Option B: OS service manager (survives reboot)
|
||||
|
||||
Use the foreground command (NOT `--daemon`) and let the OS supervise and
|
||||
restart it. This is the most robust way to get boot-time auto-start.
|
||||
|
||||
### macOS — launchd
|
||||
|
||||
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
|
||||
and `REPLACE_UNIFIED_APP_ID`, then `launchctl load -w <path>`.
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.dingtalk.dws.connect</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/dws</string>
|
||||
<string>dev</string>
|
||||
<string>connect</string>
|
||||
<string>--channel</string>
|
||||
<string>claudecode</string>
|
||||
<string>--unified-app-id</string>
|
||||
<string>REPLACE_UNIFIED_APP_ID</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/dws-connect.out.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/dws-connect.err.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
`KeepAlive=true` makes launchd restart the connector if it exits; the connector
|
||||
itself relies on the single-instance lock to avoid duplicates.
|
||||
|
||||
### Linux — systemd (user service)
|
||||
|
||||
Save as `~/.config/systemd/user/dws-connect.service`, edit paths and
|
||||
`REPLACE_UNIFIED_APP_ID`, then:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now dws-connect.service
|
||||
# allow it to keep running after logout:
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=DWS DingTalk robot connector
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/dws dev connect \
|
||||
--channel claudecode \
|
||||
--unified-app-id REPLACE_UNIFIED_APP_ID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# Optional hardening:
|
||||
# NoNewPrivileges=true
|
||||
# PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
|
||||
stderr into the journal (`journalctl --user -u dws-connect -f`).
|
||||
|
||||
## Legacy: passing clientId/clientSecret directly (not recommended)
|
||||
|
||||
If you truly must pass credentials on the command line (e.g. one-off local
|
||||
debugging without a unifiedAppId), the CLI still accepts
|
||||
`--robot-client-id <id> --robot-client-secret <secret>` and will print a
|
||||
security warning to stderr. This form:
|
||||
|
||||
- exposes `clientSecret` to every user on the box via `ps -ef`;
|
||||
- gets baked into launchd `ProgramArguments` / systemd `ExecStart`, which
|
||||
makes rotation harder;
|
||||
- means `dws dev connect restart` cannot re-fetch credentials — you
|
||||
must re-run the full command yourself.
|
||||
|
||||
Prefer `--unified-app-id`. Only fall back to the pair when you understand the
|
||||
trade-off.
|
||||
|
||||
## Which to choose
|
||||
|
||||
- Just need it to outlive the terminal and self-heal on crash → `--daemon`.
|
||||
- Need it to come back after a reboot, with the OS owning the lifecycle → use
|
||||
launchd / systemd with the foreground command.
|
||||
@@ -0,0 +1,137 @@
|
||||
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
|
||||
|
||||
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
|
||||
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
|
||||
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
|
||||
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
|
||||
|
||||
## 手工评测流程
|
||||
|
||||
逐条执行,每条三步:
|
||||
|
||||
1. **发起**:在一个干净的 agent 会话里,把该条的「用户说」原样发给 agent(不给额外提示)。
|
||||
2. **观察**:看 agent 选了哪些命令、什么 flag、做了哪些判断/追问。
|
||||
3. **判分**:对照「通过判据」人工打分。复合用例含多个判据,**全部满足才记 PASS**;部分满足记 PASS\*(半通过)并在备注写清缺哪条。记一行 `用例# | PASS / PASS* / FAIL | 备注(错在哪)`。
|
||||
|
||||
> 「易错点」是常见扣分项,重点盯。建议每次技能改动后整套重跑,对比上次。
|
||||
|
||||
## 覆盖矩阵
|
||||
|
||||
| 用例 | 覆盖的子命令 | 横切行为 |
|
||||
|------|-------------|---------|
|
||||
| C1 建应用配齐基础 | app create / get / credentials get / update | dry-run/yes、定位符、密钥脱敏 |
|
||||
| C2 列表与定位 | app list | cursor 分页、按名定位、多命中候选 |
|
||||
| C3 生命周期 | app disable / enable / delete | 写后回读、appStatus、pretty 标签、confirm-name 防误删 |
|
||||
| C4 网页应用到生效 | webapp get / config | 生效模型(改配置≠生效) |
|
||||
| C5 版本发布全流程 | version create / list / get / check-approval / publish / status | 生效模型、审批人由用户拍板 |
|
||||
| C6 权限全流程 | permission list / add / remove | 过滤分页、生效模型、批量聚合出参 |
|
||||
| C7 成员与安全 | member list / add / remove、security config | 整组覆盖语义 |
|
||||
| C8 机器人与建联 | robot submit / result / get / config / enable / disable、dev connect | 异步轮询、robot info not exist、建联依赖预检、长驻进程 |
|
||||
| C9 事件与文档排查 | event list / subscribe / unsubscribe、dev doc search | 错误码透传、文档 RAG |
|
||||
| C10 意图消歧 | (不进 dev,先澄清) | 泛词边界、转其它技能出口 |
|
||||
|
||||
---
|
||||
|
||||
## 用例
|
||||
|
||||
### C1. 新建应用并配齐基础
|
||||
- **用户说**:「建一个内部应用叫 DemoApp,描述『内部测试』;建好后给我看看它的详情,把它的 AppKey/AppSecret 也取出来;对了名字再改成 DemoApp2。」
|
||||
- **覆盖**:`app create` / `get` / `credentials get` / `update`;dry-run/yes、定位符、密钥脱敏。
|
||||
- **期望(分步)**:
|
||||
1. `app create --name DemoApp --desc 内部测试 --dry-run` → 给用户看 `invocation.params` 确认 → `--yes`,记下返回的 `unifiedAppId`。
|
||||
2. `app get --unified-app-id <id> --format json` 看详情。
|
||||
3. `credentials get --unified-app-id <id> --format json` 取凭证。
|
||||
4. `app update --unified-app-id <id> --name DemoApp2 --dry-run` → `--yes`。
|
||||
- **通过判据**:每个写操作先 dry-run 再 yes;全程用 `unifiedAppId` 定位;取凭证走 `credentials get`(不是 app get);`clientSecret/appSecret` 按敏感处理、不明文写进回答。
|
||||
- **易错点**:不 dry-run 直接 yes;把 secret 打印给用户;用 `app get` 当取凭证。
|
||||
|
||||
### C2. 应用列表与按名定位
|
||||
- **用户说**:「列出我们企业的开放平台应用,一页 20 条,有下一页继续翻;再帮我找名字叫『早晚会』的那个应用,看它详情。」
|
||||
- **覆盖**:`app list`;cursor 分页、按名定位、多命中。
|
||||
- **期望(分步)**:
|
||||
1. `app list --page-size 20 --format json`;出参有 `nextCursor` 则续翻 `--cursor <上次 nextCursor>` 直到为空。
|
||||
2. `app list --name 早晚会 --format json` 找 `unifiedAppId` → 唯一命中后 `app get --unified-app-id <id>`。
|
||||
- **通过判据**:首次不传 `--cursor`,续翻原样回传 `nextCursor`,不自己构造/解析、不跨命令复用;用 list 过滤拿 id 再 get;多条命中时展示候选让用户选、不取第一条。
|
||||
- **易错点**:用 `--page/--offset` 翻页;`app get --name xxx`(get 不接受 name 定位)。
|
||||
|
||||
### C3. 应用生命周期(停用 / 启用 / 删除)
|
||||
- **用户说**:「先把 DemoApp2 停用,确认停好了告诉我;然后再启用回来;最后这个应用不要了,删掉。」
|
||||
- **覆盖**:`app disable` / `enable` / `delete`;写后回读、appStatus、pretty、confirm-name。
|
||||
- **期望(分步)**:
|
||||
1. `disable --unified-app-id <id> --dry-run` → `--yes` → 回读 `app get`(可 `--format pretty` 看 `appStatusText`),确认 `appStatus=0` 才算停用完成。
|
||||
2. `enable --dry-run` → `--yes` → 回读确认 `appStatus=1`。
|
||||
3. 删除:先 `app get` 展示摘要 → `delete --dry-run` → 真删需 `--confirm-name <应用真实名>`(与定位到的名一致)+ `--yes`。
|
||||
- **通过判据**:写成功 ≠ 状态已变,每步回读 appStatus(0停/1激活/2待激活/3过期);删除前展示摘要并让用户确认;confirm-name 匹配才删,读不到应用名时中止(fail-closed)。
|
||||
- **易错点**:看到 success 就回报已停/已删不回读;不带 confirm-name 直接删。
|
||||
|
||||
### C4. 网页应用配置到生效
|
||||
- **用户说**:「给这个应用配个钉钉里打开的移动端首页 https://example.com/m,配完要真正能用。」
|
||||
- **覆盖**:`webapp config` / `get`;生效模型。
|
||||
- **期望(分步)**:`webapp config --unified-app-id <id> --homepage-url https://example.com/m --dry-run` → `--yes` → `webapp get` 回读;明确说明「改配置 ≠ 线上生效,需走版本通道」:`version create → check-approval → publish`(详见 C5)。
|
||||
- **通过判据**:先 dry-run 再 yes;配完回读 webapp get;主动点明需发版本才生效,不谎称「已生效」。
|
||||
- **易错点**:配完直接说已生效,不提版本通道。
|
||||
|
||||
### C5. 版本发布全流程(含选审批人)
|
||||
- **用户说**:「我刚改了配置,发个版本上线;先看下历史版本和这次要发的版本详情;需要审批的话我来选审批人。」
|
||||
- **覆盖**:`version create` / `list` / `get` / `check-approval` / `publish` / `status`;生效模型、审批人由用户拍板。
|
||||
- **期望(分步)**:
|
||||
1. `version create --unified-app-id <id> --version <号> --desc <说明> --yes`,记 `versionId`(新应用 `version list` 空时先 create,不要误判无可发布)。
|
||||
2. `version list` 看历史、`version get --version-id <id>` 看详情。
|
||||
3. `version check-approval --version-id <id>`(预检,不发布,返回是否需审批 + 候选审批人)。
|
||||
4. 把候选审批人列表给用户选 → `version publish --version-id <id> --approver <用户选的> --yes`(含高敏权限加 `--confirm-sensitive`)。
|
||||
5. `version status --version-id <id>` 跟踪到 `versionStatus=RELEASE` 才算生效。
|
||||
- **通过判据**:check-approval 不实际发布;审批人由用户拍板、agent 不默认取第一个;发布后回读 status 到 RELEASE。
|
||||
- **易错点**:跳过 check-approval 直接 publish;agent 自己选审批人;version list 空就说没东西可发。
|
||||
|
||||
### C6. 权限全流程(查 / 申请 / 批量取消)
|
||||
- **用户说**:「查下跟『机器人发消息』有关、还没开通的权限;开通其中合适的那个,要真正生效;再把另外两个不需要的权限点 A、B 一起取消掉。」
|
||||
- **覆盖**:`permission list` / `add` / `remove`;过滤分页、生效模型、批量聚合。
|
||||
- **期望(分步)**:
|
||||
1. `permission list --unified-app-id <id> --keyword 机器人发消息 --status UNAUTHED --page-size 50` 找 `scopeValue`(150+ 时用 `nextCursor` 续翻)。
|
||||
2. `permission add --permissions <scopeValue> --dry-run` → `--yes`;若 `requiredApproval=true`,走版本通道生效(接 C5)。
|
||||
3. `permission remove --permissions A,B --dry-run` → `--yes`,读出参 `{results, ok, total, failedCount}` 逐条判断。
|
||||
- **通过判据**:只传 `scopeValue`(不传 API/分组名);用 keyword+status 过滤、分页不漏;需审批的明确走版本;批量取消读 `ok/failedCount` 报告部分失败,不只看命令成功。
|
||||
- **易错点**:把 API 名当权限点;add 后就说开通了;批量 remove 漏报部分失败。
|
||||
|
||||
### C7. 成员与安全配置
|
||||
- **用户说**:「把 userId 张三、李四加成这个应用的开发者,加完看下成员列表,回头把李四移除;另外给应用加一个登录重定向地址 https://b.example.com/cb,别把原来的地址冲掉。」
|
||||
- **覆盖**:`member list` / `add` / `remove`、`security config`;整组覆盖。
|
||||
- **期望(分步)**:
|
||||
1. `member add --unified-app-id <id> --user-ids 张三id,李四id --member-type DEVELOPER --dry-run` → `--yes` → `member list` 回读 → `member remove --user-ids 李四id --member-type DEVELOPER --dry-run` → `--yes`。
|
||||
2. 安全配置:提醒 `--redirect-urls` 是**整组覆盖、不是追加**——要保留原地址需把旧+新一起传:`security config --redirect-urls <旧1,旧2,新> --dry-run` → `--yes`。
|
||||
- **通过判据**:`--user-ids` 逗号分隔、`--member-type` 必填、用 userId 不用姓名;识别整组覆盖语义、避免只传新地址冲掉旧的;未提供的字段(如 ip-whitelist)不动。
|
||||
- **易错点**:漏 `--member-type`;security 只传新 redirect-urls 把旧的清空。
|
||||
|
||||
### C8. 机器人建号、配置与本地建联
|
||||
- **用户说**:「帮我建一个叫『小助手』的答疑机器人;另外这个现有应用还没机器人,给它也配上并启用;最后把机器人接到我本地的 Claude Code 调试。」
|
||||
- **覆盖**:`robot submit` / `result` / `get` / `config` / `enable` / `disable`、`dev connect`;异步轮询、robot info not exist、建联依赖预检、长驻进程、密钥脱敏。
|
||||
- **期望(分步)**:
|
||||
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
|
||||
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
|
||||
3. 建联:`dev connect --channel auto --unified-app-id UAID --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
|
||||
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号);默认用 `--unified-app-id` 建联而不是把 clientSecret 明文拼进命令行(避免被 `ps` 拉到)。
|
||||
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话;把 clientSecret 直接怼到命令行上。
|
||||
|
||||
### C9. 事件订阅与上游错误排查
|
||||
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
|
||||
- **覆盖**:`event list` / `subscribe` / `unsubscribe`、`dev doc search`;错误码透传、文档 RAG。
|
||||
- **期望(分步)**:
|
||||
1. `event list --unified-app-id <id> --page-size 20 --format json` 取 `eventCode` → `event subscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes` → `event list` 回读 → `event unsubscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes`。事件码不确定先 `event list` 翻页查。
|
||||
2. 错误码:业务错误 `ServiceResult.success=false` 原样透传 `errorCode/errorMsg`,再 `dev doc search --keyword "errcode 62012 <message>" --format json` 做官方文档 RAG,结论基于命中条目。
|
||||
- **通过判据**:`--event-codes` 逗号分隔,写操作先 dry-run;`event list` 使用 `hasMore/nextCursor` 翻页;不编造事件码/错误含义;先透传原始错误再走 RAG,结论不臆测、不编不存在的命令。
|
||||
- **易错点**:编事件码;把事件回调地址塞进事件订阅命令;凭空解释错误码。
|
||||
|
||||
### C10. 意图消歧(泛词边界)
|
||||
- **用户说**:「帮我建个机器人。」(无任何开放平台上下文)
|
||||
- **覆盖**:泛词消歧、边界与角色。
|
||||
- **期望**:`应用`/`机器人` 是泛词——先追问确认是不是开发者后台的「企业内部应用机器人」,还是工作台应用、或群里发消息的机器人(→ `dingtalk-chat`);确认是开放平台场景后才走 dev 流程(接 C8)。
|
||||
- **通过判据**:不直接假设走 dev,先澄清;能正确指向其它技能出口。
|
||||
- **易错点**:上来就 `robot submit`,没确认是不是开放平台场景。
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 10 条合起来覆盖全部 34 个子命令 + 8 类横切行为(见覆盖矩阵)。
|
||||
- 评测可分两层:**静态**——无环境,只看 agent 选的命令/flag/判断是否符合「期望/通过判据」;**真机**——有联调环境时核对真实出参。
|
||||
- 真机注意:`dev connect` 正式连接是长驻进程;`version publish`/`app delete` 等写操作请用占位应用或停在 dry-run,避免动真实数据。
|
||||
@@ -0,0 +1,321 @@
|
||||
# dws dev 一键安装与 Agent 接入指南
|
||||
|
||||
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
|
||||
|
||||
这份指南参考 Notion Developer Platform 的引导方式:先给出一条可复制的安装命令,再用最短路径完成验证、登录、Agent 调用和排障。
|
||||
|
||||
## 一键安装
|
||||
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
### Windows(PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
|
||||
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
|
||||
|
||||
支持这些环境变量(全部可选):
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
|
||||
|
||||
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
|
||||
|
||||
### 国内加速
|
||||
|
||||
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
|
||||
```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` 可执行:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
```
|
||||
|
||||
确认 `dws dev app` 命令存在:
|
||||
|
||||
```bash
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
|
||||
|
||||
确认登录状态:
|
||||
|
||||
```bash
|
||||
dws auth status
|
||||
```
|
||||
|
||||
如果尚未登录:
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
登录完成后读取应用列表:
|
||||
|
||||
```bash
|
||||
dws dev app list --format json
|
||||
```
|
||||
|
||||
## dws dev 是什么
|
||||
|
||||
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
|
||||
|
||||
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
|
||||
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
|
||||
- `dws dev doc` — 开放平台开发文档搜索。
|
||||
|
||||
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
|
||||
|
||||
- 查询、创建、更新、启用、停用、删除开放平台应用。
|
||||
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
|
||||
- 配置网页应用首页和管理后台地址。
|
||||
- 查询、申请、移除权限点。
|
||||
- 管理应用成员。
|
||||
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
|
||||
- 异步创建机器人、配置/启停现有机器人。
|
||||
- 创建版本、发起发布、查询审批和发布状态。
|
||||
|
||||
## 给 Agent 使用
|
||||
|
||||
安装完成后,可以直接让 Agent 操作 `dws dev`。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
帮我查一下最近创建的开放平台应用。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我给 unifiedAppId=<unifiedAppId> 的应用配置机器人,先 dry-run 给我确认。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我查询这个应用缺哪些权限点,并申请 Contact.User.mobile。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我发布这个应用版本,先预检是否需要审批。
|
||||
```
|
||||
|
||||
Agent 写操作必须遵循:
|
||||
|
||||
1. 先查询定位应用。
|
||||
2. 先 dry-run 预览。
|
||||
3. 明确展示将要修改的应用、字段和值。
|
||||
4. 用户确认后加 `--yes` 执行。
|
||||
5. 执行后回读验证。
|
||||
|
||||
## 第一个写操作
|
||||
|
||||
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
|
||||
|
||||
提交建号任务(记下返回的 `taskId`):
|
||||
|
||||
```bash
|
||||
dws dev app robot submit \
|
||||
--name "告警助手" \
|
||||
--robot-name "告警机器人" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--dry-run \
|
||||
--format json
|
||||
```
|
||||
|
||||
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
|
||||
|
||||
```bash
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
```
|
||||
|
||||
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
|
||||
|
||||
```bash
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 应用管理
|
||||
|
||||
```bash
|
||||
dws dev app list --format json
|
||||
dws dev app get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app create --name "考勤应用" --dry-run --format json
|
||||
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
|
||||
```
|
||||
|
||||
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
|
||||
|
||||
### 凭证查询
|
||||
|
||||
```bash
|
||||
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
|
||||
|
||||
### 权限点管理
|
||||
|
||||
```bash
|
||||
dws dev app permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
|
||||
```
|
||||
|
||||
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
|
||||
|
||||
### 机器人能力
|
||||
|
||||
```bash
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
|
||||
dws dev app robot result --task-id <taskId> --format json
|
||||
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 成员与安全
|
||||
|
||||
```bash
|
||||
dws dev app member list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
|
||||
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
```
|
||||
|
||||
### 网页应用与事件
|
||||
|
||||
```bash
|
||||
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
|
||||
dws dev app event list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 版本发布
|
||||
|
||||
```bash
|
||||
dws dev app version list --unified-app-id <unifiedAppId> --format json
|
||||
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
```
|
||||
|
||||
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
|
||||
默认安全策略:
|
||||
|
||||
- 写操作先 dry-run。
|
||||
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
|
||||
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
|
||||
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
|
||||
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
|
||||
|
||||
## 排障
|
||||
|
||||
### `dws dev app` 不存在
|
||||
|
||||
先确认装上的是带 `dws dev` 的版本:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
dws dev app --help --format json
|
||||
```
|
||||
|
||||
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
|
||||
|
||||
### `dws dev app list` 失败
|
||||
|
||||
优先检查登录态:
|
||||
|
||||
```bash
|
||||
dws auth status
|
||||
dws auth login
|
||||
```
|
||||
|
||||
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
|
||||
|
||||
### 提示"当前用户没有开发者身份"
|
||||
|
||||
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
|
||||
|
||||
### 页面能操作,但 CLI 或 MCP 提示无权限
|
||||
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
|
||||
|
||||
- 应用 owner。
|
||||
- 应用管理员。
|
||||
- 应用开发者。
|
||||
- 企业管理员或具备开放平台应用管理权限的角色。
|
||||
|
||||
### 机器人配置失败
|
||||
|
||||
先查当前机器人状态:
|
||||
|
||||
```bash
|
||||
dws dev app robot get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
|
||||
|
||||
## 页面文案建议
|
||||
|
||||
用于产品页顶部:
|
||||
|
||||
```text
|
||||
Install dws dev in one command.
|
||||
|
||||
Let your coding agents manage DingTalk Open Platform apps from the terminal:
|
||||
create apps, configure robots, apply permissions, manage security settings,
|
||||
and publish versions with dry-run safety built in.
|
||||
```
|
||||
|
||||
中文版本:
|
||||
|
||||
```text
|
||||
一行命令接入 dws dev。
|
||||
|
||||
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
|
||||
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
|
||||
所有写操作先预览,再确认执行。
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- Notion Developer Platform: https://www.notion.com/product/dev
|
||||
- Notion CLI Help: https://www.notion.com/help/use-notion-from-your-terminal-with-notion-cli
|
||||
- Notion Developer Platform Blog: https://www.notion.com/blog/introducing-developer-platform
|
||||
@@ -10,6 +10,7 @@
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
|
||||
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
|
||||
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
|
||||
|
||||
## Exit Codes / 退出码
|
||||
|
||||
@@ -75,6 +76,7 @@ Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cl
|
||||
| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
|
||||
| `output_schema` | MCP 输出 Schema(上游下发时才有) |
|
||||
| `sensitive` | 敏感写操作,需 `--yes` 确认 |
|
||||
| `auth` | DingTalk 授权元数据,包括 `requiredScopes` / `requiredPermissions` / `recommendedScopes` / `grantProductCodes` / `riskAction` / `confirmationRequired` |
|
||||
| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 |
|
||||
| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` |
|
||||
|
||||
@@ -84,6 +86,7 @@ Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cl
|
||||
|
||||
```bash
|
||||
dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
|
||||
dws schema calendar.create_event --jq '.tool.auth' # 只看授权元数据
|
||||
dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
|
||||
dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
|
||||
```
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# 钉钉 AI 群机器人快速上手
|
||||
|
||||
10 分钟搭一个自己的钉钉群答疑机器人:群里 @它 提问,它用你本地的 AI(Claude Code / Codex / Qoder 等)回答,支持发文字和报错截图。
|
||||
|
||||
只需四步:装工具 → 建机器人 → 接上 AI → 拉进群。
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
打开终端,整段复制执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
|
||||
> ```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
|
||||
> ```
|
||||
|
||||
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
|
||||
|
||||
### Windows
|
||||
|
||||
打开 PowerShell,整段复制执行:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
|
||||
|
||||
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
|
||||
|
||||
### 登录钉钉
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
按提示扫码登录即可。
|
||||
|
||||
## 第二步:创建机器人
|
||||
|
||||
建号是异步的,两步(名字、描述可以改成你自己的):
|
||||
|
||||
```bash
|
||||
# 1) 提交建号任务,记下返回的 taskId
|
||||
dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "群内答疑" --yes --format json
|
||||
|
||||
# 2) 用上一步的 taskId 查结果,直到 status 变成 SUCCESS(还是 WAITING 就过几秒再查一次)
|
||||
dws dev app robot result --task-id 上一步返回的taskId --format json
|
||||
```
|
||||
|
||||
`status` 变成 `SUCCESS` 后,返回结果里的 `unifiedAppId` **记下来**,下一步要用。(`clientId` / `clientSecret` 也会返回,但下一步默认走 `unifiedAppId`,密钥由 dws 后台从 `credentials get` 自动拉取,你不需要手工复制密钥。)
|
||||
|
||||
## 第三步:把机器人接上你本地的 AI
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --unified-app-id 上一步的unifiedAppId
|
||||
```
|
||||
|
||||
- 把 `上一步的unifiedAppId` 换成第二步返回的 `unifiedAppId` 实际值
|
||||
- 只用 `--unified-app-id`:`clientSecret` 由 `dws dev app credentials get` 后台取回,**不会出现在你的命令行**,不会被 `ps` 看到、不会留在 shell 历史里
|
||||
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
|
||||
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
|
||||
|
||||
> 安全提示:老写法 `--robot-client-id <id> --robot-client-secret <secret>` 仍然能用,但 `clientSecret` 会以明文出现在命令行,任何本机用户 `ps -ef` 都能拉到;dws 会在 stderr 打一条 WARNING 提醒。除了没有 unifiedAppId 的老应用兜底之外,都建议改用 `--unified-app-id`。
|
||||
|
||||
## 第四步:拉进群聊
|
||||
|
||||
在钉钉里打开目标群:
|
||||
|
||||
**群设置 → 机器人 → 添加机器人 → 在企业机器人里搜"小助手"(你起的名字)→ 添加**
|
||||
|
||||
完成。现在在群里 @小助手 提问试试,发文字、发报错截图都能答。
|
||||
|
||||
## 进阶配置(可选)
|
||||
|
||||
按需加在第三步的命令后面:
|
||||
|
||||
| 参数 | 作用 |
|
||||
|------|------|
|
||||
| `--agent-workdir ./项目目录` | 让机器人在你的项目目录里跑,能读到和终端一样的本地文件(详见下方「机器人答得不如终端准?」) |
|
||||
| `--knowledge-dir ./docs` | 挂本地知识目录(.md/.txt),回答自动带上你的资料 |
|
||||
| `--agent-cmd "<命令>"` | 接入内置列表之外的 AI 工具(自研的、或还没内置支持的),详见下方「想用没在列表里的 AI 工具?」 |
|
||||
| `--allowed-users 工号1,工号2` | 用户白名单,名单外的人无法触发机器人 |
|
||||
| `--allowed-groups 群ID` | 群白名单 |
|
||||
| `--user-rate-limit 0` | 关闭限流(默认每人每分钟 20 条) |
|
||||
|
||||
### 想用没在列表里的 AI 工具?(自研 / 未内置支持)
|
||||
|
||||
`--channel auto` 只认内置的几款工具(Claude Code / Codex / Qoder / Gemini 等)。如果你用的是自研的、或还没内置支持的 AI(比如网易有道龙虾 LobsterAI),用 `--agent-cmd` 把它接进来——只要它能在命令行「一次性」跑(给一段问题、把答案打到标准输出),就能接:
|
||||
|
||||
```bash
|
||||
dws dev connect \
|
||||
--agent-cmd "你的AI命令 一次性问答参数" \
|
||||
--unified-app-id 你的unifiedAppId
|
||||
```
|
||||
|
||||
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
|
||||
|
||||
举例:假设龙虾的命令行叫 `lobster`、一次性问答用 `-p` 参数,就写 `--agent-cmd "lobster -p"`。命令里有空格就整体用引号括起来。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**执行命令报 `zsh: parse error near '\n'`?**
|
||||
命令里残留了 `<...>` 尖括号占位符(旧版文档的写法),shell 会把尖括号当成重定向符。把占位符整体替换成实际值、不要保留尖括号,再执行。
|
||||
|
||||
**群里 @机器人 没反应?**
|
||||
确认第三步的 `dev connect` 窗口还开着——关掉窗口机器人就下线了。
|
||||
|
||||
**第二步提示"当前用户没有开发者身份"?**
|
||||
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试第二步。
|
||||
|
||||
**提示找不到 dws 命令?**
|
||||
macOS 重开一个终端窗口;Windows 重开一个 PowerShell 窗口(安装时改了 PATH,需要新窗口才生效)。
|
||||
|
||||
**提示本地没有装 AI 工具?**
|
||||
机器人背后需要一个本地 AI CLI。推荐先装 [Claude Code](https://claude.com/claude-code) 或 Codex,装好后重新执行第三步。
|
||||
|
||||
**机器人回复"调用失败"?**
|
||||
通常是本地 AI 工具未登录或额度用尽,单独运行一次该 AI 工具确认其本身可用。
|
||||
|
||||
**机器人答得不如终端准?(同样的问题,终端对、机器人不对)**
|
||||
这通常不是模型问题,而是"机器人看到的上下文比终端少":
|
||||
|
||||
- **工作目录不同**:默认机器人在一个空白临时目录里跑(为了启动快、回复中立),它看不到你终端所在项目里的文件。要让它和终端读到同样的资料,在第三步加 `--agent-workdir ./你的项目目录`(指到你平时在终端里跑 AI 的那个目录)。
|
||||
- **知识没挂上**:如果靠的是本地文档/知识库,加 `--knowledge-dir ./docs`(或 `--knowledge-source wiki:<spaceId>`)把资料显式挂给机器人,别指望它自己去翻。
|
||||
- **模型不同**:机器人默认走一个偏快的小模型;如果你终端用的是更强的模型,给机器人也指定同一个:`--agent-model <模型名>`。
|
||||
- **回答"水位"上下浮动**:先确认没关 `--agent-memory`(默认开)。Codex 走 app-server thread 续聊;Qoder/Claude Code/CodeBuddy/WorkBuddy 走可恢复会话,其中 Qoder 的映射只保存在当前 DWS 进程内,重启后会重新开始;Gemini 仍是一次性调用。
|
||||
|
||||
一句话:让机器人和终端"看到一样的东西、用一样的模型",差距基本就抹平了。
|
||||
|
||||
## 会话指令:`/new` 和 `/clear`
|
||||
|
||||
机器人默认记住同一个会话的上下文(多轮对话)。想重置上下文,直接在聊天里发这两个斜杠指令——整条消息就是指令时才生效(普通问题不受影响),不消耗一次 AI 调用,秒回提示:
|
||||
|
||||
| 指令 | 作用 |
|
||||
|------|------|
|
||||
| `/new`(或 `/start`、`/reset`) | **开启新会话**:之前的上下文不再带入,旧会话保留(agent 支持的话仍可回溯) |
|
||||
| `/clear` | **清空当前会话**:彻底从头开始 |
|
||||
|
||||
两者按各渠道**真实能力**对齐:`/clear` 在 opencode 渠道会真正删除当前会话(调 opencode 的 `DELETE /session/:id`);Codex / Qoder / Claude 系等驱动接口没有删除原语的渠道,`/clear` 退化为与 `/new` 相同的重置。
|
||||
|
||||
**第三步执行完,在蚂蚁钉/开放平台搜不到审批工单?**
|
||||
这是正常的,不是出错。第三步 `dev connect`(把机器人接到本地 AI)只是用现成机器人的凭证起一条连接、本地转发,**不产生任何审批工单**。会产生审批工单的是第二步「建机器人」(`dev app robot submit`),由平台/管理员审批。所以第三步之后搜不到工单是预期内的。
|
||||
@@ -3,9 +3,15 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
@@ -14,11 +20,34 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.7 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -1,27 +1,97 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
|
||||
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
|
||||
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
|
||||
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
@@ -31,11 +101,18 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
|
||||
@@ -210,6 +210,8 @@ func NormalisePath(path, baseURL string) string {
|
||||
// defaultTransport returns a tuned http.Transport matching the project conventions.
|
||||
func defaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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 apiclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDefaultTransportHonoursHTTPProxyEnv is the regression guard for #236
|
||||
// on the apiclient transport. Same rationale as transport/proxy_env_test.go:
|
||||
// a custom Transport without an explicit Proxy field silently bypasses
|
||||
// HTTP_PROXY/HTTPS_PROXY.
|
||||
//
|
||||
// We pointer-compare against http.ProxyFromEnvironment instead of invoking
|
||||
// it, because http.ProxyFromEnvironment memoises the env on first call;
|
||||
// other tests that read proxy env early would make a value-based assertion
|
||||
// flaky.
|
||||
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tr := defaultTransport()
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
+833
-62
File diff suppressed because it is too large
Load Diff
@@ -15,16 +15,123 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestAuthExportImportBase64RoundTrip(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
||||
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
||||
t.Setenv("DWS_CONFIG_DIR", sourceConfig)
|
||||
|
||||
original := &authpkg.TokenData{
|
||||
AccessToken: "access-cli",
|
||||
RefreshToken: "refresh-cli",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
ClientID: "client-cli",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := authpkg.SaveTokenData(sourceConfig, original); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
exportCmd := NewRootCommand()
|
||||
var exported bytes.Buffer
|
||||
exportCmd.SetOut(&exported)
|
||||
exportCmd.SetErr(&bytes.Buffer{})
|
||||
exportCmd.SetArgs([]string{"auth", "export", "--base64"})
|
||||
if err := exportCmd.Execute(); err != nil {
|
||||
t.Fatalf("auth export --base64 error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(exported.String()) == "" {
|
||||
t.Fatal("auth export --base64 produced empty output")
|
||||
}
|
||||
|
||||
targetRoot := t.TempDir()
|
||||
inputPath := filepath.Join(targetRoot, "dws-auth.b64")
|
||||
if err := os.WriteFile(inputPath, exported.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("write input bundle error = %v", err)
|
||||
}
|
||||
|
||||
targetKeychain := filepath.Join(targetRoot, "target-keychain")
|
||||
targetConfig := filepath.Join(targetRoot, ".dws")
|
||||
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
||||
t.Setenv("DWS_CONFIG_DIR", targetConfig)
|
||||
|
||||
importCmd := NewRootCommand()
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&bytes.Buffer{})
|
||||
importCmd.SetArgs([]string{"auth", "import", "--input", inputPath, "--base64"})
|
||||
if err := importCmd.Execute(); err != nil {
|
||||
t.Fatalf("auth import --base64 error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := authpkg.LoadTokenData(targetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after CLI import error = %v", err)
|
||||
}
|
||||
if loaded.RefreshToken != original.RefreshToken {
|
||||
t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken)
|
||||
}
|
||||
if !loaded.IsRefreshTokenValid() {
|
||||
t.Fatal("refresh token should remain valid after CLI import")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
if err := authpkg.SaveTokenData(configDir, &authpkg.TokenData{
|
||||
AccessToken: "existing",
|
||||
RefreshToken: "existing-refresh",
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(root, "bundle.tar.gz")
|
||||
if err := os.WriteFile(bundlePath, []byte("not-a-real-bundle"), 0o600); err != nil {
|
||||
t.Fatalf("write bundle stub error = %v", err)
|
||||
}
|
||||
|
||||
importCmd := NewRootCommand()
|
||||
var stderr bytes.Buffer
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
importCmd.SetErr(&stderr)
|
||||
importCmd.SetArgs([]string{"auth", "import", "--input", bundlePath})
|
||||
err := importCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("auth import without --force should fail when auth exists")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--force") {
|
||||
t.Fatalf("error = %v, want --force hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
// Isolate keychain storage to a per-test directory so the saved
|
||||
// token can't leak into other test packages running in parallel.
|
||||
@@ -77,8 +184,710 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusTableIncludesCorpName(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"))
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "auth", "status"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} {
|
||||
if !bytes.Contains(out.Bytes(), []byte(want)) {
|
||||
t.Fatalf("auth status table missing %q in output:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "auth", "status", "--profile", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"corp_primary org", "corp_primary"} {
|
||||
if !bytes.Contains(out.Bytes(), []byte(want)) {
|
||||
t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) {
|
||||
t.Fatalf("auth status --profile should render selected profile, got:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
|
||||
ClientID: "client-app",
|
||||
ClientSecret: authpkg.PlainSecret("secret-app"),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("remote revoke disabled in unit test")
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("auth logout output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
|
||||
t.Fatalf("profiles after logout = %#v, want empty", cfg)
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
|
||||
t.Fatal("primary profile token should be deleted")
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
|
||||
t.Fatal("secondary profile token should be deleted")
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychain() {
|
||||
t.Fatal("legacy auth-token mirror should be deleted")
|
||||
}
|
||||
appConfig, err := authpkg.LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig() error = %v", err)
|
||||
}
|
||||
if appConfig == nil || appConfig.ClientID != "client-app" {
|
||||
t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
originalTransport := http.DefaultTransport
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = originalTransport
|
||||
})
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("remote revoke disabled in unit test")
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
|
||||
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
|
||||
}
|
||||
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
|
||||
t.Fatal("selected primary profile token should be deleted")
|
||||
}
|
||||
if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
|
||||
t.Fatal("unselected secondary profile token should be retained")
|
||||
}
|
||||
loaded, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" {
|
||||
t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
|
||||
newRoot := func(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
return root
|
||||
}
|
||||
|
||||
t.Run("recommend skips tui but keeps human auth for interactive login", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", true, true) {
|
||||
t.Fatal("--recommend must not show the post-login product TUI")
|
||||
}
|
||||
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
|
||||
t.Fatal("default interactive --recommend should still use human authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without recommend shows two-step authorization tui", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if !authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) {
|
||||
t.Fatal("default interactive login should show post-login authorization TUI")
|
||||
}
|
||||
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
|
||||
t.Fatal("default interactive post-login authorization should use human authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit json keeps machine mode", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if err := root.PersistentFlags().Set("format", "json"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) {
|
||||
t.Fatal("explicit --format json must not show post-login TUI")
|
||||
}
|
||||
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) {
|
||||
t.Fatal("explicit --format json must keep machine-readable authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("table without recommend shows authorization tui", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if err := root.PersistentFlags().Set("format", "table"); err != nil {
|
||||
t.Fatalf("set format: %v", err)
|
||||
}
|
||||
if !authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) {
|
||||
t.Fatal("table format should show post-login TUI without --recommend")
|
||||
}
|
||||
if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "table", true, true) {
|
||||
t.Fatal("table format should use human authorization flow in an interactive terminal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non interactive skips selector", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, false) {
|
||||
t.Fatal("non-interactive login should skip post-login TUI")
|
||||
}
|
||||
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, false) {
|
||||
t.Fatal("non-interactive login should keep machine-readable authorization flow")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without authorization flow keeps normal login output contract", func(t *testing.T) {
|
||||
root := newRoot(t)
|
||||
if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", false, true) {
|
||||
t.Fatal("login without a post-login authorization flow should not switch default json to human mode")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) {
|
||||
label := loginRecommendProductLabel(pat.LoginRecommendProduct{
|
||||
ProductCode: "approval",
|
||||
ProductName: "审批",
|
||||
Summary: "审批实例,审批模板,审批任务管理",
|
||||
ScopeCount: 12,
|
||||
})
|
||||
if label != "approval 审批 - 审批实例,审批模板,审批任务管理" {
|
||||
t.Fatalf("label = %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
t.Fatalf("set yes: %v", err)
|
||||
}
|
||||
if err := login.Flags().Set("recommend", "true"); err != nil {
|
||||
t.Fatalf("set recommend: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if !cfg.Recommend {
|
||||
t.Fatal("Recommend = false, want true")
|
||||
}
|
||||
if !cfg.Yes {
|
||||
t.Fatal("Yes = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
|
||||
if !authLoginForcesAuthorization(authLoginConfig{}) {
|
||||
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
|
||||
}
|
||||
if !authLoginForcesAuthorization(authLoginConfig{Force: false}) {
|
||||
t.Fatal("Force=false should still force authorization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("--recommend must not call the post-login guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("--recommend must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
t.Fatal("--recommend must not call the scope-mode TUI")
|
||||
return "", nil
|
||||
}
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
|
||||
t.Fatal("--recommend must not call the product-domain TUI")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token", "--recommend"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login --recommend error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(fake.tools) != 2 {
|
||||
t.Fatalf("CallTool count = %d, want plan + grant", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" {
|
||||
t.Fatalf("tool sequence = %v, want plan, grant", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["recommend"]; got != true {
|
||||
t.Fatalf("--recommend plan recommend = %#v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("default auth login must not call the operation guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("default auth login must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
t.Fatal("all-granted recommend plan must not call the scope-mode TUI")
|
||||
return "", nil
|
||||
}
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
|
||||
t.Fatal("all-granted recommend plan must not call the product-domain TUI")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`,
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(fake.tools) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want only preflight plan", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" {
|
||||
t.Fatalf("tool sequence = %v, want only plan", fake.tools)
|
||||
}
|
||||
if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") {
|
||||
t.Fatalf("output = %q, want all-granted message", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsTUIAndAuthorizationPage(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("default auth login must not call the operation guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("default auth login must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
t.Fatal("already-granted recommended auth must not call the scope-mode TUI")
|
||||
return "", nil
|
||||
}
|
||||
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) {
|
||||
t.Fatal("already-granted recommended auth must not call product-domain TUI")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`,
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if len(fake.tools) != 1 {
|
||||
t.Fatalf("CallTool count = %d, want only preflight recommend plan", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" {
|
||||
t.Fatalf("tool sequence = %v, want only plan", fake.tools)
|
||||
}
|
||||
if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") {
|
||||
t.Fatalf("output = %q, want already-granted message", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
oldGuideSelector := authLoginGuideActionSelector
|
||||
oldGuideApplier := authLoginGuideActionApplier
|
||||
oldScopeSelector := loginRecommendScopeModeSelector
|
||||
oldProductSelector := loginRecommendProductSelector
|
||||
oldInteractiveTerminal := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authLoginGuideActionSelector = oldGuideSelector
|
||||
authLoginGuideActionApplier = oldGuideApplier
|
||||
loginRecommendScopeModeSelector = oldScopeSelector
|
||||
loginRecommendProductSelector = oldProductSelector
|
||||
authLoginInteractiveTerminal = oldInteractiveTerminal
|
||||
})
|
||||
authLoginInteractiveTerminal = func() bool { return true }
|
||||
|
||||
var sawTokenBeforeScopeTUI bool
|
||||
var sawTokenBeforeProductTUI bool
|
||||
var sawTokenBeforePlan bool
|
||||
authLoginGuideActionSelector = func() (authLoginGuideAction, error) {
|
||||
t.Fatal("default login must not call the operation guide selector")
|
||||
return "", nil
|
||||
}
|
||||
authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error {
|
||||
t.Fatal("default login must not apply a post-login guide action")
|
||||
return nil
|
||||
}
|
||||
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) {
|
||||
token, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData before scope TUI error = %v", err)
|
||||
}
|
||||
if token.AccessToken != "login-token" {
|
||||
t.Fatalf("AccessToken before scope TUI = %q, want login-token", token.AccessToken)
|
||||
}
|
||||
sawTokenBeforeScopeTUI = true
|
||||
return pat.LoginRecommendScopeAll, nil
|
||||
}
|
||||
loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) {
|
||||
token, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData before product TUI error = %v", err)
|
||||
}
|
||||
if token.AccessToken != "login-token" {
|
||||
t.Fatalf("AccessToken before product TUI = %q, want login-token", token.AccessToken)
|
||||
}
|
||||
sawTokenBeforeProductTUI = true
|
||||
if len(products) != 1 || products[0].ProductCode != "calendar" {
|
||||
t.Fatalf("selector products = %+v, want calendar", products)
|
||||
}
|
||||
return []string{"calendar"}, nil
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`,
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
}, beforeCall: func(toolName string) {
|
||||
token, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData before %s error = %v", toolName, err)
|
||||
}
|
||||
if token.AccessToken != "login-token" {
|
||||
t.Fatalf("AccessToken before %s = %q, want login-token", toolName, token.AccessToken)
|
||||
}
|
||||
sawTokenBeforePlan = true
|
||||
}}
|
||||
cmd := newAuthLoginCommand(fake)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--token", "login-token"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !sawTokenBeforeScopeTUI {
|
||||
t.Fatal("scope-mode TUI was not called after token save")
|
||||
}
|
||||
if !sawTokenBeforeProductTUI {
|
||||
t.Fatal("product-domain TUI was not called after token save")
|
||||
}
|
||||
if !sawTokenBeforePlan {
|
||||
t.Fatal("authorization plan was not called after token save")
|
||||
}
|
||||
if len(fake.tools) != 3 {
|
||||
t.Fatalf("CallTool count = %d, want discovery plan + selected plan + grant", len(fake.tools))
|
||||
}
|
||||
if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" {
|
||||
t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["recommend"]; got != true {
|
||||
t.Fatalf("discovery plan recommend = %#v, want true", got)
|
||||
}
|
||||
if got := fake.args[1]["recommend"]; got != false {
|
||||
t.Fatalf("selected all-scope plan recommend = %#v, want false", got)
|
||||
}
|
||||
if got := fake.args[1]["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) {
|
||||
t.Fatalf("selected all-scope plan productCodes = %#v, want calendar", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
token := &authpkg.TokenData{
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "ding32fff839a3e0105d",
|
||||
ClientID: "client-id",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
fake := &authLoginRecommendSequenceCaller{responses: []string{
|
||||
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
|
||||
}}
|
||||
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
|
||||
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
|
||||
}
|
||||
if token.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName)
|
||||
}
|
||||
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
|
||||
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
|
||||
}
|
||||
|
||||
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
|
||||
}
|
||||
if loaded.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName)
|
||||
}
|
||||
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
|
||||
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
|
||||
}
|
||||
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
|
||||
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type authLoginRecommendSequenceCaller struct {
|
||||
responses []string
|
||||
tools []string
|
||||
args []map[string]any
|
||||
beforeCall func(toolName string)
|
||||
}
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if f.beforeCall != nil {
|
||||
f.beforeCall(toolName)
|
||||
}
|
||||
f.tools = append(f.tools, toolName)
|
||||
copiedArgs := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copiedArgs[key] = value
|
||||
}
|
||||
f.args = append(f.args, copiedArgs)
|
||||
response := `{"success":true,"data":{}}`
|
||||
if len(f.responses) > 0 {
|
||||
response = f.responses[0]
|
||||
f.responses = f.responses[1:]
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
|
||||
}
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
|
||||
|
||||
func stringSliceArgEqual(got any, want []string) bool {
|
||||
if got == nil {
|
||||
return len(want) == 0
|
||||
}
|
||||
switch values := got.(type) {
|
||||
case []string:
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range values {
|
||||
if values[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case []any:
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range values {
|
||||
if values[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
t.Cleanup(func() {
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
})
|
||||
|
||||
for _, token := range tokens {
|
||||
if err := authpkg.SaveTokenData(configDir, token); err != nil {
|
||||
t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err)
|
||||
}
|
||||
}
|
||||
return configDir
|
||||
}
|
||||
|
||||
func authLogoutTestToken(corpID string) *authpkg.TokenData {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "access-" + corpID,
|
||||
RefreshToken: "refresh-" + corpID,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
CorpName: corpID + " org",
|
||||
UserID: "user-" + corpID,
|
||||
UserName: "User " + corpID,
|
||||
ClientID: "client-" + corpID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// toolMappingParam 描述一个 MCP 参数到 CLI flag + 中文友好名的映射。
|
||||
type toolMappingParam struct {
|
||||
Flag string `json:"flag"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// toolMappingEntry 是单个 MCP 工具的映射条目。key 用 RPCName,对齐 SLS 日志的 tool 字段。
|
||||
type toolMappingEntry struct {
|
||||
Product string `json:"product"`
|
||||
CLICommand string `json:"cliCommand"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Params map[string]toolMappingParam `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// toolMapping 是给开放平台日志页渲染用的全量映射契约。
|
||||
type toolMapping struct {
|
||||
Version string `json:"version"`
|
||||
Count int `json:"count"`
|
||||
Tools map[string]toolMappingEntry `json:"tools"`
|
||||
}
|
||||
|
||||
// newCatalogCommand 提供 `dws catalog export`:把已发现的工具目录投影成
|
||||
// tool→指令 映射 JSON,供开放平台 MCP/DWS 日志页把 tool/args 渲染成中文友好名。
|
||||
// 复用 root 注入的带 auth 的 loader(缓存优先;建议先 `dws cache refresh`)。
|
||||
func newCatalogCommand(loader cli.CatalogLoader) *cobra.Command {
|
||||
catalogCmd := &cobra.Command{
|
||||
Use: "catalog",
|
||||
Short: "导出已发现的工具目录(内部用)",
|
||||
Hidden: true,
|
||||
}
|
||||
|
||||
var out string
|
||||
var version string
|
||||
exportCmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出 tool→指令 映射 JSON(供开放平台日志页渲染)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
catalog, err := loader.Load(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mapping := projectToolMapping(catalog, version)
|
||||
data, err := json.MarshalIndent(mapping, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if strings.TrimSpace(out) == "" {
|
||||
_, werr := os.Stdout.Write(data)
|
||||
return werr
|
||||
}
|
||||
return os.WriteFile(out, data, 0o644)
|
||||
},
|
||||
}
|
||||
exportCmd.Flags().StringVar(&out, "out", "", "输出文件路径(默认 stdout)")
|
||||
exportCmd.Flags().StringVar(&version, "version", "dev", "版本号标记")
|
||||
|
||||
catalogCmd.AddCommand(exportCmd)
|
||||
return catalogCmd
|
||||
}
|
||||
|
||||
// projectToolMapping 把 ir.Catalog 投影成 toolMapping 契约。
|
||||
func projectToolMapping(catalog ir.Catalog, version string) toolMapping {
|
||||
mapping := toolMapping{Version: version, Tools: make(map[string]toolMappingEntry)}
|
||||
for _, product := range catalog.Products {
|
||||
command := ""
|
||||
if product.CLI != nil {
|
||||
command = strings.TrimSpace(product.CLI.Command)
|
||||
}
|
||||
if command == "" {
|
||||
command = product.ID
|
||||
}
|
||||
for _, tool := range product.Tools {
|
||||
if tool.Hidden {
|
||||
continue
|
||||
}
|
||||
entry := toolMappingEntry{
|
||||
Product: command,
|
||||
CLICommand: tmBuildCLICommand(command, tool),
|
||||
DisplayName: tmFirstNonEmpty(tool.Title, tmFirstNonEmpty(tmFirstLine(tool.Description), tool.RPCName)),
|
||||
Params: make(map[string]toolMappingParam),
|
||||
}
|
||||
for name, raw := range tmSchemaProperties(tool.InputSchema) {
|
||||
prop, _ := raw.(map[string]any)
|
||||
overlay, hasOverlay := tool.FlagOverlay[name]
|
||||
if hasOverlay && overlay.Hidden {
|
||||
continue
|
||||
}
|
||||
flag := tmKebab(name)
|
||||
if hasOverlay && strings.TrimSpace(overlay.Alias) != "" {
|
||||
flag = strings.TrimSpace(overlay.Alias)
|
||||
}
|
||||
label := tmMapStr(prop, "title")
|
||||
if label == "" {
|
||||
label = tmFirstLine(tmMapStr(prop, "description"))
|
||||
}
|
||||
entry.Params[name] = toolMappingParam{
|
||||
Flag: flag,
|
||||
Label: label,
|
||||
Type: tmMapStr(prop, "type"),
|
||||
}
|
||||
}
|
||||
if len(entry.Params) == 0 {
|
||||
entry.Params = nil
|
||||
}
|
||||
mapping.Tools[tool.RPCName] = entry
|
||||
}
|
||||
}
|
||||
mapping.Count = len(mapping.Tools)
|
||||
return mapping
|
||||
}
|
||||
|
||||
// tmBuildCLICommand 拼出 CLI 命令路径,如 chat + message + list -> "chat message list"。
|
||||
func tmBuildCLICommand(command string, tool ir.ToolDescriptor) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if command != "" {
|
||||
parts = append(parts, command)
|
||||
}
|
||||
if g := strings.TrimSpace(tool.Group); g != "" {
|
||||
parts = append(parts, g)
|
||||
}
|
||||
name := strings.TrimSpace(tool.CLIName)
|
||||
if name == "" {
|
||||
name = tool.RPCName
|
||||
}
|
||||
parts = append(parts, name)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func tmSchemaProperties(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
return props
|
||||
}
|
||||
|
||||
func tmMapStr(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := m[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// tmFirstLine 取第一句中文/换行前的片段,作为长描述的短标签兜底。
|
||||
func tmFirstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexAny(s, "\n。"); i >= 0 {
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tmFirstNonEmpty(a, b string) string {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
return strings.TrimSpace(a)
|
||||
}
|
||||
return strings.TrimSpace(b)
|
||||
}
|
||||
|
||||
// tmKebab 把 camelCase 参数名转 kebab-case 作为默认 flag。
|
||||
func tmKebab(s string) string {
|
||||
var b strings.Builder
|
||||
for i, r := range s {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
if i > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r - 'A' + 'a')
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
+107
-24
@@ -24,6 +24,7 @@ import (
|
||||
"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/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -45,8 +46,17 @@ const (
|
||||
defaultPATProductID = "pat"
|
||||
defaultPATDisplayName = "行为授权"
|
||||
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
|
||||
devappProductID = "devapp"
|
||||
devappServerPath = "/server/op-app"
|
||||
)
|
||||
|
||||
// devappMCPEndpoint resolves the open-platform app-management MCP endpoint
|
||||
// from the configured gateway base URL, so it follows the active environment
|
||||
// (production by default, pre when ~/.dws/mcp_url points at the pre gateway).
|
||||
func devappMCPEndpoint() string {
|
||||
return defaultPATGatewayBaseURL() + devappServerPath
|
||||
}
|
||||
|
||||
func defaultPATServerDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: defaultPATProductID,
|
||||
@@ -210,6 +220,22 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
|
||||
// whose toolOverrides registered this tool name. Used to correct catalog
|
||||
// lookups when two envelope servers share the same cli.command and the
|
||||
// per-product endpoint map collides (see runner.go cross-check).
|
||||
func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
endpoint, ok := dynamicToolEndpoints[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
@@ -222,24 +248,27 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
dynamicMu.RLock()
|
||||
de := dynamicEndpoints
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
// Hardcoded built-in: devapp is pinned to the open-platform app-management
|
||||
// MCP server in source (NOT service discovery), per product decision.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == devappProductID {
|
||||
return devappMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 1: product-level endpoint.
|
||||
// When the caller already knows the productID (e.g. "drive"), the product
|
||||
// endpoint is authoritative. This prevents cross-product tool name
|
||||
// collisions (e.g. both "drive" and "doc" register "create_folder") from
|
||||
// routing the request to the wrong MCP server. See issue #219.
|
||||
dynamicMu.RLock()
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if de != nil {
|
||||
if endpoint, ok := de[candidate]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
if endpoint, ok := dynamicEndpoints[candidate]; ok {
|
||||
dynamicMu.RUnlock()
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,11 +276,13 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// This path is used when the caller does not know the productID but has a
|
||||
// tool name, e.g. in helper invocations or plugin routes where only the
|
||||
// tool name is available.
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
if tool := strings.TrimSpace(toolName); tool != "" {
|
||||
if endpoint, ok := dynamicToolEndpoints[tool]; ok {
|
||||
dynamicMu.RUnlock()
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
// Priority 3: built-in PAT fallback for cold-start paths that run before
|
||||
// discovery/plugin registration has populated the dynamic registry.
|
||||
@@ -260,18 +291,69 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
return defaultPATMCPEndpoint(), true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: edition-owned static/supplement endpoints. Helper-only
|
||||
// products such as devapp intentionally do not depend on Market discovery,
|
||||
// so the internal edition may provide only an endpoint and no tool list.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if endpoint, ok := editionServerEndpoint(candidate); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns the set of product IDs that have direct
|
||||
// runtime endpoints configured, sourced from dynamic server discovery.
|
||||
func editionServerEndpoint(productID string) (string, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
if productID == "" {
|
||||
return "", false
|
||||
}
|
||||
hooks := edition.Get()
|
||||
if hooks == nil {
|
||||
return "", false
|
||||
}
|
||||
if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
if endpoint, ok := endpointFromEditionServers(productID, hooks.SupplementServers); ok {
|
||||
return endpoint, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo) (string, bool) {
|
||||
if fn == nil {
|
||||
return "", false
|
||||
}
|
||||
for _, server := range fn() {
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(server.ID) == productID {
|
||||
return endpoint, true
|
||||
}
|
||||
for _, prefix := range server.Prefixes {
|
||||
if strings.TrimSpace(prefix) == productID {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns product IDs that should stay visible for
|
||||
// direct runtime execution. Dynamic products come from MCP discovery/plugin
|
||||
// registration; built-in helper products such as devapp resolve their endpoint
|
||||
// through DINGTALK_<PRODUCT>_MCP_URL instead of requiring discovery.
|
||||
func DirectRuntimeProductIDs() map[string]bool {
|
||||
dynamicMu.RLock()
|
||||
dp := dynamicProducts
|
||||
dynamicMu.RUnlock()
|
||||
ids := make(map[string]bool, len(dp)+1)
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
ids := make(map[string]bool, len(dynamicProducts)+2)
|
||||
ids[defaultPATProductID] = true
|
||||
for key := range dp {
|
||||
ids[devappProductID] = true
|
||||
for key := range dynamicProducts {
|
||||
ids[key] = true
|
||||
}
|
||||
return ids
|
||||
@@ -310,7 +392,9 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
@@ -344,15 +428,14 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
}
|
||||
|
||||
func normalizeDirectRuntimeProductID(productID string) string {
|
||||
dynamicMu.RLock()
|
||||
da := dynamicAliases
|
||||
dynamicMu.RUnlock()
|
||||
trimmed := strings.TrimSpace(productID)
|
||||
if da != nil {
|
||||
if normalizedID, ok := da[trimmed]; ok && normalizedID != "" {
|
||||
return normalizedID
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
if normalizedID, ok := dynamicAliases[trimmed]; ok && normalizedID != "" {
|
||||
dynamicMu.RUnlock()
|
||||
return normalizedID
|
||||
}
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
if normalizedID, ok := legacyDirectRuntimeAliases[trimmed]; ok {
|
||||
return normalizedID
|
||||
}
|
||||
|
||||
@@ -270,6 +270,68 @@ func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
// --- Command field first-writer-wins regression test ---
|
||||
//
|
||||
// When two plugins declare the same CLI.Command but different CLI.ID values,
|
||||
// AppendDynamicServer must NOT let the second registration overwrite the
|
||||
// command → endpoint mapping established by the first. The fix uses a simple
|
||||
// "if not exists" guard on dynamicEndpoints[cmd].
|
||||
|
||||
const (
|
||||
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
|
||||
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
|
||||
)
|
||||
|
||||
func firstPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testFirstEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-alpha",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func secondPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testSecondEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-beta",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
|
||||
// two plugins declare the same Command (but different IDs), only the first
|
||||
// registration takes effect for the command → endpoint mapping. The second
|
||||
// plugin's own id-based endpoint is unaffected.
|
||||
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
AppendDynamicServer(firstPluginDescriptor())
|
||||
AppendDynamicServer(secondPluginDescriptor())
|
||||
|
||||
// The command "shared-cmd" must resolve to the first plugin's endpoint.
|
||||
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
|
||||
|
||||
// Each plugin's own id-based endpoint is always unconditionally written.
|
||||
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
|
||||
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
|
||||
|
||||
// Command must appear in dynamicProducts (discovery) regardless.
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["shared-cmd"] {
|
||||
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-alpha"] {
|
||||
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-beta"] {
|
||||
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
|
||||
// tool-level routing still works as a fallback when productID is empty or has
|
||||
// no registered endpoint (the original design intent for tool-level Priority 1).
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
|
||||
@@ -38,6 +39,107 @@ func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeProductIDsIncludesDevappHelper(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["devapp"] {
|
||||
t.Fatalf("DirectRuntimeProductIDs() missing devapp helper product: %#v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverrideWithoutRegistry(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp")
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverridePreservesQuery(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp?key=secret")
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp?key=secret")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappDynamicServerDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://example.test/server/devapp-supplement",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "devapp",
|
||||
Command: "devapp",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEditionSupplementDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-supplement?key=secret",
|
||||
Prefixes: []string{"devapp", "app"},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEditionStaticDoesNotOverrideHardcoded(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-static",
|
||||
Prefixes: []string{"devapp", "app"},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint())
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DevappEnvOverrideWinsOverEditionSupplement(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp-env")
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "wukong",
|
||||
SupplementServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{
|
||||
{
|
||||
ID: "devapp",
|
||||
Name: "开放平台应用管理",
|
||||
Endpoint: "https://example.test/server/devapp-edition-supplement",
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp-env")
|
||||
}
|
||||
|
||||
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
docProductID = "doc"
|
||||
docDownloadFileTool = "download_file"
|
||||
docGetDocumentInfoTool = "get_document_info"
|
||||
docAXLSExtension = "axls"
|
||||
)
|
||||
|
||||
func (r *runtimeRunner) preflightDocDownload(ctx context.Context, tc *transport.Client, endpoint string, invocation executor.Invocation) error {
|
||||
if !isDocDownloadInvocation(invocation) {
|
||||
return nil
|
||||
}
|
||||
nodeID := docDownloadNodeID(invocation.Params)
|
||||
if nodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
preflightStart := time.Now()
|
||||
info, err := tc.CallTool(ctx, endpoint, docGetDocumentInfoTool, map[string]any{"nodeId": nodeID})
|
||||
RecordTiming(ctx, "doc_download_preflight", time.Since(preflightStart))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||
if err := classify(info.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patCheck := apperrors.ClassifyPatAuthCheck(info.Content); patCheck != nil {
|
||||
return patCheck
|
||||
}
|
||||
if info.IsError {
|
||||
return apperrors.NewAPI(
|
||||
extractMCPErrorMessage(info),
|
||||
apperrors.WithOperation("doc.get_document_info"),
|
||||
apperrors.WithReason("doc_download_preflight_failed"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>"),
|
||||
)
|
||||
}
|
||||
if bizErr := detectBusinessError(info.Content); bizErr != "" {
|
||||
return apperrors.NewAPI(
|
||||
bizErr,
|
||||
apperrors.WithOperation("doc.get_document_info"),
|
||||
apperrors.WithReason("doc_download_preflight_failed"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>"),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.EqualFold(documentInfoExtension(info.Content), docAXLSExtension) {
|
||||
return unsupportedAXLSDownloadError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDocDownloadInvocation(invocation executor.Invocation) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), docProductID) &&
|
||||
strings.TrimSpace(invocation.Tool) == docDownloadFileTool
|
||||
}
|
||||
|
||||
func docDownloadNodeID(params map[string]any) string {
|
||||
for _, key := range []string{"nodeId", "node", "dentryUuid"} {
|
||||
if value, ok := params[key].(string); ok {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unsupportedAXLSDownloadError() error {
|
||||
return apperrors.NewValidation(
|
||||
"nodeId 指向的节点是钉钉表格(extension=axls),在线表格不支持直接下载。请使用 getRange 工具获取表格数据。",
|
||||
apperrors.WithOperation("doc.download_file.preflight"),
|
||||
apperrors.WithReason("unsupported_alidoc_extension"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("在线表格应先用 doc info 确认 extension,再改用表格 MCP 的 get_all_sheets / get_range 读取数据。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>", "使用表格 MCP get_all_sheets / get_range"),
|
||||
)
|
||||
}
|
||||
|
||||
func documentInfoExtension(content map[string]any) string {
|
||||
for _, path := range [][]string{
|
||||
{"result", "extension"},
|
||||
{"data", "extension"},
|
||||
{"extension"},
|
||||
} {
|
||||
if value := stringAtPath(content, path...); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringAtPath(value any, path ...string) string {
|
||||
current := value
|
||||
for _, key := range path {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
current = object[key]
|
||||
}
|
||||
if text, ok := current.(string); ok {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -22,9 +22,9 @@ import (
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"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/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -113,7 +113,8 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
return output.WriteJSON(w, result)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n诊断完成: %d 项通过, %d 项警告, %d 项失败\n", pass, warn, fail)
|
||||
fmt.Fprintf(w, "\n%s\n", tui.Header("Doctor", fmt.Sprintf("%d pass · %d warn · %d fail", pass, warn, fail)))
|
||||
fmt.Fprintf(w, "%s 诊断完成: %d 项通过, %d 项警告, %d 项失败\n", tui.StateMark("ok"), pass, warn, fail)
|
||||
if fail > 0 {
|
||||
return fmt.Errorf("诊断发现 %d 项失败", fail)
|
||||
}
|
||||
@@ -124,7 +125,7 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查登录状态... ")
|
||||
fmt.Fprint(w, tui.Dim("检查登录状态... "))
|
||||
}
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
@@ -187,10 +188,10 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
|
||||
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查网络连通性... ")
|
||||
fmt.Fprint(w, tui.Dim("检查网络连通性... "))
|
||||
}
|
||||
|
||||
baseURL := cli.DefaultMarketBaseURL
|
||||
baseURL := config.GetMCPBaseURL()
|
||||
httpClient := &http.Client{Timeout: timeout}
|
||||
client := market.NewClient(baseURL, httpClient)
|
||||
|
||||
@@ -205,7 +206,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusFail,
|
||||
Message: fmt.Sprintf("mcp.dingtalk.com 不可达: %v", err),
|
||||
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
|
||||
Hint: "请检查网络连接或代理设置",
|
||||
}
|
||||
if !jsonOut {
|
||||
@@ -217,7 +218,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
r := checkResult{
|
||||
Name: "network",
|
||||
Status: statusPass,
|
||||
Message: fmt.Sprintf("mcp.dingtalk.com 可达 (延迟 %dms)", latency.Milliseconds()),
|
||||
Message: fmt.Sprintf("%s 可达 (延迟 %dms)", baseURL, latency.Milliseconds()),
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
@@ -229,7 +230,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
|
||||
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查缓存状态... ")
|
||||
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
|
||||
}
|
||||
|
||||
store := cacheStoreFromEnv()
|
||||
@@ -301,7 +302,7 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
|
||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查版本更新... ")
|
||||
fmt.Fprint(w, tui.Dim("检查版本更新... "))
|
||||
}
|
||||
|
||||
currentVer := version
|
||||
@@ -349,9 +350,18 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
|
||||
|
||||
func printCheckResult(w io.Writer, r checkResult) {
|
||||
icon := statusIcon(r.Status)
|
||||
fmt.Fprintf(w, "%s %s\n", icon, r.Message)
|
||||
message := r.Message
|
||||
switch r.Status {
|
||||
case statusPass:
|
||||
message = tui.Success(message)
|
||||
case statusWarn:
|
||||
message = tui.Warning(message)
|
||||
case statusFail:
|
||||
message = tui.Danger(message)
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s\n", icon, message)
|
||||
if r.Hint != "" {
|
||||
fmt.Fprintf(w, " %s\n", r.Hint)
|
||||
fmt.Fprintf(w, " %s\n", tui.Dim(r.Hint))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +396,7 @@ func countResults(checks []checkResult) (pass, warn, fail int) {
|
||||
|
||||
func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, "检查性能报告... ")
|
||||
fmt.Fprint(w, tui.Dim("检查性能报告... "))
|
||||
}
|
||||
|
||||
report, err := LoadLatestReport()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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 (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFlagErrorWithSuggestions_authStructured(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "login", Run: func(*cobra.Command, []string) {}}
|
||||
orig := fmt.Errorf("unknown flag: --json")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if !strings.Contains(ae.Message, orig.Error()) {
|
||||
t.Fatalf("Message = %q, want to contain %q", ae.Message, orig.Error())
|
||||
}
|
||||
// 尾部 hint:所有 flag 解析错误的 Message 都应以 See '<cmd> --help' for usage. 结尾
|
||||
if !strings.HasSuffix(ae.Message, "See 'login --help' for usage.") {
|
||||
t.Fatalf("Message tail = %q, want suffix See 'login --help' for usage.", ae.Message)
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q, want unknown_flag", ae.Reason)
|
||||
}
|
||||
if ae.Hint == "" || !strings.Contains(ae.Hint, "format json") {
|
||||
t.Fatalf("Hint = %q", ae.Hint)
|
||||
}
|
||||
if ae.Cause != orig {
|
||||
t.Fatalf("Cause = %v, want orig", ae.Cause)
|
||||
}
|
||||
if !stderrors.Is(err, orig) {
|
||||
t.Fatal("errors.Is(err, orig) should hold via unwrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
orig := fmt.Errorf("unknown flag: --starttime1")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q", ae.Reason)
|
||||
}
|
||||
if strings.Contains(ae.Hint, "Space required") {
|
||||
t.Fatalf("false glue must not suggest space: %q", ae.Hint)
|
||||
}
|
||||
if !strings.Contains(ae.Hint, "help") {
|
||||
t.Fatalf("expected help fallback in hint, got %q", ae.Hint)
|
||||
}
|
||||
if len(ae.AvailableFlags) != 1 || ae.AvailableFlags[0] != "start" {
|
||||
t.Fatalf("AvailableFlags = %v, want [start]", ae.AvailableFlags)
|
||||
}
|
||||
// 尾部 hint 验证:非 alias 路径(SuggestFlagFix 命中)同样应带 See '... --help' for usage.
|
||||
if !strings.HasSuffix(ae.Message, "See 'list --help' for usage.") {
|
||||
t.Fatalf("Message tail = %q, want suffix See 'list --help' for usage.", ae.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
|
||||
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
|
||||
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
|
||||
func TestFlagErrorWithSuggestions_fallbackTailHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
|
||||
orig := fmt.Errorf("required flag(s) \"to\" not set")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
// fallback 路径返回 plain error(非 *apperrors.Error),保持原 exit code 行为
|
||||
var ae *apperrors.Error
|
||||
if stderrors.As(err, &ae) {
|
||||
t.Fatalf("fallback path should return plain error, got *apperrors.Error: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, orig.Error()) {
|
||||
t.Fatalf("err = %q, want to contain orig %q", msg, orig.Error())
|
||||
}
|
||||
if !strings.HasSuffix(msg, "See 'send --help' for usage.") {
|
||||
t.Fatalf("err tail = %q, want suffix See 'send --help' for usage.", msg)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type GlobalFlags struct {
|
||||
JQ string
|
||||
Mock bool
|
||||
Output string
|
||||
Profile string
|
||||
Timeout int
|
||||
Token string
|
||||
Verbose bool
|
||||
@@ -41,11 +42,12 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
|
||||
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
|
||||
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty|ndjson|csv")
|
||||
cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
|
||||
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
|
||||
_ = cmd.PersistentFlags().MarkHidden("output")
|
||||
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
|
||||
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
|
||||
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
|
||||
_ = cmd.PersistentFlags().MarkHidden("token")
|
||||
|
||||
@@ -55,10 +55,11 @@ func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
|
||||
t.Cleanup(func() { SetDiscoveryBaseURL("") })
|
||||
|
||||
root := NewRootCommand()
|
||||
// `minutes list all` is intentionally provided as a hardcoded helper
|
||||
// (see internal/helpers/minutes_commands.go) to align with the wukong
|
||||
// baseline, so it is expected to resolve and is no longer asserted here.
|
||||
for _, path := range []string{
|
||||
"doc upload",
|
||||
"chat message list-topic-replies",
|
||||
"minutes list all",
|
||||
} {
|
||||
if cmd := lookupCommand(root, path); cmd != nil {
|
||||
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
|
||||
@@ -162,11 +163,16 @@ func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} {
|
||||
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"Global Flags:", "--profile"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
|
||||
@@ -216,6 +222,60 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileHelpDocumentsMultiProfileUsage(t *testing.T) {
|
||||
got := executeHelpForTest(t, "profile", "switch", "--help")
|
||||
for _, want := range []string{
|
||||
"切换默认组织 profile",
|
||||
"需要只影响单次业务命令时,请使用全局 --profile",
|
||||
"dws profile switch --corpId <corpId>",
|
||||
"dws --profile <corpId> contact user get-self",
|
||||
"--corpId string",
|
||||
"--name string",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("profile switch help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
got = executeHelpForTest(t, "profile", "list", "--help")
|
||||
for _, want := range []string{
|
||||
"列出本机已登录的所有组织 profile",
|
||||
"dws profile list --format json",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("profile list help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHelpDocumentsProfileUsage(t *testing.T) {
|
||||
got := executeHelpForTest(t, "auth", "login", "--help")
|
||||
if !strings.Contains(got, "dws auth login --profile <corpId>") {
|
||||
t.Fatalf("auth login help missing --profile example:\n%s", got)
|
||||
}
|
||||
|
||||
got = executeHelpForTest(t, "auth", "status", "--help")
|
||||
for _, want := range []string{
|
||||
"查看当前或指定组织 profile 的认证状态",
|
||||
"只读取并刷新被选中的 token slot",
|
||||
"dws auth status --profile <corpId>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("auth status help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
got = executeHelpForTest(t, "auth", "logout", "--help")
|
||||
for _, want := range []string{
|
||||
"默认退出所有已登录组织 profile",
|
||||
"dws auth logout --profile <corpId>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("auth logout help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
|
||||
@@ -223,6 +283,22 @@ func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func executeHelpForTest(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
t.Setenv(cli.CacheDirEnv, t.TempDir())
|
||||
|
||||
root := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any {
|
||||
cliMeta := map[string]any{
|
||||
"id": command,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// newHelperToolFetcher returns a cli.HelperToolFetcher that loads a helper MCP
|
||||
// server's tools/list LIVE (by source) and projects each tool into a
|
||||
// cli.HelperToolSchema (name, description, inputSchema properties/required). It
|
||||
// is injected into the schema command so the cli package can render
|
||||
// `dws schema dev.*` from real server schema without importing app/transport.
|
||||
//
|
||||
// Sources: "op-app" backs the dev app commands (pinned endpoint); "devdoc"
|
||||
// backs `dws dev doc search` (endpoint resolved dynamically, see
|
||||
// helperSourceEndpoint). Results are memoized per source per process so
|
||||
// repeated `dws schema dev.*` hit the network at most once per source. A failed
|
||||
// fetch is not cached, allowing a later retry within the same process.
|
||||
func newHelperToolFetcher() cli.HelperToolFetcher {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
cached = map[string]map[string]cli.HelperToolSchema{}
|
||||
)
|
||||
return func(ctx context.Context, source string) (map[string]cli.HelperToolSchema, error) {
|
||||
mu.Lock()
|
||||
if got, ok := cached[source]; ok {
|
||||
mu.Unlock()
|
||||
return got, nil
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
endpoint, err := helperSourceEndpoint(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schemas, err := fetchHelperToolSchemas(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mu.Lock()
|
||||
cached[source] = schemas
|
||||
mu.Unlock()
|
||||
return schemas, nil
|
||||
}
|
||||
}
|
||||
|
||||
// helperSourceEndpoint maps a schema source to its MCP endpoint. op-app (dev
|
||||
// app) is pinned in source (devappMCPEndpoint, derived from the active gateway
|
||||
// base — production by default, pre when ~/.dws/mcp_url points at pre); other
|
||||
// sources (e.g. devdoc) are resolved the same way the runner resolves a product
|
||||
// endpoint — env override → discovery → edition StaticServers/SupplementServers.
|
||||
func helperSourceEndpoint(source string) (string, error) {
|
||||
switch source {
|
||||
case "", "op-app", "devapp":
|
||||
return devappMCPEndpoint(), nil
|
||||
default:
|
||||
if endpoint, ok := directRuntimeEndpoint(source, ""); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
return "", fmt.Errorf("no MCP endpoint resolved for source %q (not injected by edition/discovery)", source)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchHelperToolSchemas performs the live tools/list call against endpoint and
|
||||
// converts the descriptors. Auth and identity headers are resolved the same way
|
||||
// the runner does for direct-runtime invocations.
|
||||
func fetchHelperToolSchemas(ctx context.Context, endpoint string) (map[string]cli.HelperToolSchema, error) {
|
||||
token := resolveRuntimeAuthToken(ctx, "")
|
||||
headers := resolveIdentityHeaders()
|
||||
client := transport.NewClient(nil).WithAuth(token, headers)
|
||||
|
||||
result, err := client.ListTools(ctx, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]cli.HelperToolSchema, len(result.Tools))
|
||||
for _, td := range result.Tools {
|
||||
out[td.Name] = cli.HelperToolSchema{
|
||||
Name: td.Name,
|
||||
Description: td.Description,
|
||||
Properties: inputSchemaProperties(td.InputSchema),
|
||||
Required: inputSchemaRequired(td.InputSchema),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inputSchemaProperties pulls the "properties" object out of a deserialized
|
||||
// MCP inputSchema map. Returns an empty (non-nil) map when absent.
|
||||
func inputSchemaProperties(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
if props == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// inputSchemaRequired pulls the "required" string list out of a deserialized
|
||||
// MCP inputSchema map.
|
||||
func inputSchemaRequired(schema map[string]any) []string {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+136
-11
@@ -16,6 +16,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -49,9 +50,84 @@ 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, 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.
|
||||
//
|
||||
// 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 {
|
||||
cmds = nil
|
||||
panicked = r
|
||||
}
|
||||
}()
|
||||
|
||||
dynamicCmds := loadDynamicCommandsFn(ctx, runner)
|
||||
helperCmds := helpers.NewPublicCommands(runner)
|
||||
return mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
|
||||
merged := mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
|
||||
// Post-merge product hooks: tasks the envelope cannot express on its
|
||||
// own (e.g. dual-role group+leaf semantics for deprecated aliases).
|
||||
// Keep each hook narrowly scoped to one product so the open-source
|
||||
// command surface remains predictable from the envelope alone.
|
||||
helpers.AttachReportLegacyInboxAlias(merged, runner)
|
||||
helpers.AttachReportListReadableEnrichment(merged, runner)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// pickCommands returns the union of dynamic and helpers commands. For
|
||||
@@ -211,11 +287,7 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
|
||||
} else {
|
||||
baseURL := cli.DefaultMarketBaseURL
|
||||
if discoveryBaseURLOverride != "" {
|
||||
baseURL = discoveryBaseURLOverride
|
||||
}
|
||||
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", baseURL)
|
||||
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", DiscoveryBaseURL())
|
||||
}
|
||||
}
|
||||
fetchStart := time.Now()
|
||||
@@ -238,7 +310,7 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
// no-op: fall through to FallbackServers check below
|
||||
}
|
||||
} else {
|
||||
servers = market.NormalizeServers(resp, "market")
|
||||
servers = market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
if discoveryTraceEnabled() {
|
||||
slog.Info("loadDynamicCommands: sync discovery fetch ok",
|
||||
"partition", partition,
|
||||
@@ -279,15 +351,59 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
|
||||
detailStart := time.Now()
|
||||
detailsByID := loadCachedDetailsFast(store, servers)
|
||||
existingTools := loadCachedToolNames(store, servers)
|
||||
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
|
||||
|
||||
buildStart := time.Now()
|
||||
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
|
||||
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID, existingTools)
|
||||
RecordTiming(ctx, "build_commands", time.Since(buildStart))
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
// loadCachedToolNames reads the live tools/list snapshot from disk cache for
|
||||
// each server and returns a map from CLI server ID (slug) → set of tool names
|
||||
// the server actually exposes. This is the existence oracle BuildDynamicCommands
|
||||
// uses to hide phantom override leaves (commands whose backing MCP tool is not
|
||||
// deployed) from `--help`.
|
||||
//
|
||||
// Source note: this reads the `tools/` partition (populated by `dws cache
|
||||
// refresh` / discovery, keyed by server.Key), NOT the `detail/` partition used
|
||||
// by loadCachedDetailsFast — the latter is frequently empty even after a
|
||||
// refresh, so it is unusable as an existence signal.
|
||||
//
|
||||
// Keyed by cli.ID so serverOverride routing (e.g. contact → hrmregister)
|
||||
// resolves against the target server's tool set. A server with no cached tools
|
||||
// is simply absent from the map; the build guard treats "absent / empty" as
|
||||
// "unknown" and keeps the command, so a cold cache never blanks the tree.
|
||||
func loadCachedToolNames(store *cache.Store, servers []market.ServerDescriptor) map[string]map[string]struct{} {
|
||||
result := make(map[string]map[string]struct{})
|
||||
if store == nil {
|
||||
return result
|
||||
}
|
||||
partition := editionPartition()
|
||||
for _, server := range servers {
|
||||
slug := strings.TrimSpace(server.CLI.ID)
|
||||
if slug == "" || strings.TrimSpace(server.Key) == "" {
|
||||
continue
|
||||
}
|
||||
snap, _, err := store.LoadTools(partition, server.Key)
|
||||
if err != nil || len(snap.Tools) == 0 {
|
||||
continue
|
||||
}
|
||||
names := make(map[string]struct{}, len(snap.Tools))
|
||||
for _, t := range snap.Tools {
|
||||
if n := strings.TrimSpace(t.Name); n != "" {
|
||||
names[n] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
result[slug] = names
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
|
||||
// no network calls. Returns whatever is available (fresh or stale).
|
||||
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
|
||||
@@ -453,7 +569,7 @@ func DiscoveryBaseURL() string {
|
||||
if discoveryBaseURLOverride != "" {
|
||||
return discoveryBaseURLOverride
|
||||
}
|
||||
return cli.DefaultMarketBaseURL
|
||||
return config.GetMCPBaseURL()
|
||||
}
|
||||
|
||||
// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with
|
||||
@@ -464,6 +580,8 @@ func ipv4HTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
},
|
||||
@@ -494,6 +612,13 @@ func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.
|
||||
return client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
}
|
||||
|
||||
func registryDiscoveryBaseURL() string {
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
return editionURL
|
||||
}
|
||||
return DiscoveryBaseURL()
|
||||
}
|
||||
|
||||
// asyncRevalidateRegistry refreshes the registry cache in the background.
|
||||
// Uses a short timeout derived from the parent context and silently ignores
|
||||
// errors — the next CLI invocation will pick up the refreshed cache or retry.
|
||||
@@ -506,7 +631,7 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti
|
||||
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
|
||||
return
|
||||
}
|
||||
servers := market.NormalizeServers(resp, "market")
|
||||
servers := market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL())
|
||||
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
|
||||
slog.Debug("asyncRevalidateRegistry: save failed", "error", saveErr)
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis":
|
||||
case r.URL.Path == "/cli/discovery/apis/cedar":
|
||||
payload := map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
@@ -433,7 +433,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryT
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/cli/discovery/apis":
|
||||
case r.URL.Path == "/cli/discovery/apis/cedar":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"metadata": map[string]any{"count": 2, "nextCursor": ""},
|
||||
"servers": []any{
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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
|
||||
defer func() { os.Stderr = origStderr }()
|
||||
|
||||
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(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_a"),
|
||||
authLogoutTestToken("corp_b"),
|
||||
)
|
||||
authpkg.SetRuntimeProfile("corp_a, corp_b")
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
result, err := runner.Run(context.Background(), executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
Params: map[string]any{"limit": 10},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_a, corp_b" {
|
||||
t.Fatalf("runtime profile after Run = %q, want restored raw selector", got)
|
||||
}
|
||||
|
||||
content := result.Response["content"].(map[string]any)
|
||||
if content["multiProfile"] != true {
|
||||
t.Fatalf("multiProfile = %#v, want true", content["multiProfile"])
|
||||
}
|
||||
if content["success"] != true {
|
||||
t.Fatalf("success = %#v, want true", content["success"])
|
||||
}
|
||||
profiles := content["profiles"].([]any)
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2", len(profiles))
|
||||
}
|
||||
for i, wantCorpID := range []string{"corp_a", "corp_b"} {
|
||||
entry := profiles[i].(map[string]any)
|
||||
if entry["corpId"] != wantCorpID {
|
||||
t.Fatalf("profiles[%d].corpId = %#v, want %q", i, entry["corpId"], wantCorpID)
|
||||
}
|
||||
if entry["ok"] != true {
|
||||
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
|
||||
}
|
||||
resultPayload := entry["result"].(map[string]any)
|
||||
if resultPayload["runtimeProfile"] != wantCorpID {
|
||||
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a, corp_a org,corp_b")
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if !multi {
|
||||
t.Fatal("multi = false, want true")
|
||||
}
|
||||
if len(selections) != 2 {
|
||||
t.Fatalf("selections len = %d, want 2", len(selections))
|
||||
}
|
||||
if selections[0].Profile.CorpID != "corp_a" || selections[1].Profile.CorpID != "corp_b" {
|
||||
t.Fatalf("resolved corp IDs = %q, %q; want corp_a, corp_b", selections[0].Profile.CorpID, selections[1].Profile.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a")
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
result, err := runner.Run(context.Background(), executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
|
||||
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != "corp_a" {
|
||||
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == "corp_comma" {
|
||||
cfg.Profiles[i].Name = "alpha,beta"
|
||||
}
|
||||
}
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, "alpha,beta")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if multi {
|
||||
t.Fatalf("multi = true, want false; selections=%#v", selections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommaSeparatedProfileRejectsEmptySelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
|
||||
_, _, err := resolveMultiProfileSelections(configDir, "corp_a,,corp_b")
|
||||
if err == nil {
|
||||
t.Fatal("resolveMultiProfileSelections() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("error = %q, want empty profile selector", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type multiProfileFallbackRunner struct{}
|
||||
|
||||
func (multiProfileFallbackRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"runtimeProfile": authpkg.RuntimeProfile(),
|
||||
"tool": invocation.Tool,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeMultiSkillSrc creates a fake multi skill source tree with the given
|
||||
// subdir names, each containing a minimal SKILL.md.
|
||||
func writeMultiSkillSrc(t *testing.T, names ...string) string {
|
||||
t.Helper()
|
||||
src := t.TempDir()
|
||||
for _, n := range names {
|
||||
dir := filepath.Join(src, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+n+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
for _, s := range ss {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dws-shared must ship even when --skill narrows the set to a single product.
|
||||
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(all, "dws-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
|
||||
}
|
||||
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(filtered, "dws-shared") {
|
||||
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
|
||||
}
|
||||
final := ensureMandatorySharedSkill(filtered, all)
|
||||
if !contains(final, "dws-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
|
||||
}
|
||||
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-aitable not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the source has no dws-shared (older layout), nothing is forced.
|
||||
func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
||||
if contains(final, "dws-shared") {
|
||||
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
|
||||
}
|
||||
}
|
||||
+236
-81
@@ -14,6 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
@@ -32,8 +33,9 @@ import (
|
||||
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/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/fatih/color"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -50,6 +52,10 @@ const (
|
||||
|
||||
var openBrowserFunc = tryOpenBrowser
|
||||
|
||||
type patSuppressBrowserOpenKeyType struct{}
|
||||
|
||||
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
|
||||
|
||||
// PatScopeError holds information about a missing PAT scope.
|
||||
type PatScopeError struct {
|
||||
OriginalError string
|
||||
@@ -152,36 +158,31 @@ func extractPatScopeError(err error) *PatScopeError {
|
||||
|
||||
// PrintPatAuthError prints a human-readable PAT authorization error.
|
||||
func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "{\n")
|
||||
fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false")
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity)
|
||||
fmt.Fprintf(w, " %s: {\n", bold("\"error\""))
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType)
|
||||
fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message)
|
||||
fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint)
|
||||
fmt.Fprintf(w, " %s: %s,\n", tui.Bold("\"ok\""), "false")
|
||||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"identity\""), scopeErr.Identity)
|
||||
fmt.Fprintf(w, " %s: {\n", tui.Bold("\"error\""))
|
||||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"type\""), scopeErr.ErrorType)
|
||||
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"message\""), scopeErr.Message)
|
||||
fmt.Fprintf(w, " %s: %q\n", tui.Bold("\"hint\""), scopeErr.Hint)
|
||||
fmt.Fprintf(w, " }\n")
|
||||
fmt.Fprintf(w, "}\n")
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Print authorization instructions
|
||||
fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权"))
|
||||
fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要额外授权"))
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权"))
|
||||
fmt.Fprintf(w, " %s %s\n", tui.Dim("#"), tui.Dim("运行以下命令完成授权"))
|
||||
|
||||
if scopeErr.MissingScope != "" {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||||
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
|
||||
} else {
|
||||
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login"))
|
||||
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan("dws auth login"))
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ"))
|
||||
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", tui.Dim("ℹ"))
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
@@ -205,7 +206,10 @@ func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
|
||||
return wantsStructuredPATOutput(rr)
|
||||
}
|
||||
|
||||
func currentPATOpenBrowser(configDir string) bool {
|
||||
func currentPATOpenBrowser(ctx context.Context, configDir string) bool {
|
||||
if suppressed, _ := ctx.Value(patSuppressBrowserOpenKey).(bool); suppressed {
|
||||
return false
|
||||
}
|
||||
return pat.EffectiveOpenBrowser(configDir)
|
||||
}
|
||||
|
||||
@@ -224,24 +228,34 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
data = map[string]any{}
|
||||
payload["data"] = data
|
||||
}
|
||||
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
|
||||
authURL := apperrors.PATAuthorizationURL(rawURI)
|
||||
data["uri"] = authURL
|
||||
delete(data, "authUrl")
|
||||
delete(data, "authorizationUrl")
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func patAuthorizationURIFromData(data map[string]any) string {
|
||||
for _, key := range []string{"uri", "authUrl", "authorizationUrl"} {
|
||||
value, _ := data[key].(string)
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||
// It returns true if authorization was completed, false if timed out or cancelled.
|
||||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
timeout := PatAuthRetryTimeout
|
||||
deadline := time.Now().Add(timeout)
|
||||
pollTicker := time.NewTicker(PatAuthPollInterval)
|
||||
@@ -249,21 +263,21 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
start := time.Now()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ"))
|
||||
fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout)
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", tui.Dim("ℹ"))
|
||||
fmt.Fprintf(output, " %s 超时时间: %s\n", tui.Dim("⏱"), timeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCount := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", red("✗"))
|
||||
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
|
||||
return false
|
||||
|
||||
case <-time.After(time.Until(deadline)):
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ"))
|
||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
|
||||
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
|
||||
return false
|
||||
|
||||
case <-pollTicker.C:
|
||||
@@ -276,7 +290,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
if err == nil && tokenData != nil {
|
||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||
green("✓"), bold("授权成功!"), elapsed, remaining)
|
||||
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
|
||||
fmt.Fprintln(output)
|
||||
return true
|
||||
}
|
||||
@@ -284,7 +298,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
||||
|
||||
// Show polling status
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
|
||||
dim("⟳"), pollCount, elapsed, remaining)
|
||||
tui.Dim("⟳"), pollCount, elapsed, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,8 +338,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
||||
|
||||
// Retry the invocation
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"),
|
||||
color.New(color.Bold).SprintFunc()("授权完成,正在重试..."))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
return runner.Run(ctx, invocation)
|
||||
@@ -336,6 +349,9 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
||||
const (
|
||||
// patPollInterval is how often we poll the device flow status endpoint.
|
||||
patPollInterval = 2 * time.Second
|
||||
// patMaxPollInterval caps a server-provided poll interval so a malformed
|
||||
// response cannot make the CLI look permanently stuck.
|
||||
patMaxPollInterval = 30 * time.Second
|
||||
// patPollTimeout is the maximum time to wait for user authorization via device flow.
|
||||
patPollTimeout = 10 * time.Minute
|
||||
)
|
||||
@@ -346,6 +362,12 @@ type patRetryingKeyType struct{}
|
||||
|
||||
var patRetryingKey = patRetryingKeyType{}
|
||||
|
||||
type patRetryRunnerFunc func(context.Context, executor.Invocation) (executor.Result, error)
|
||||
|
||||
func (f patRetryRunnerFunc) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
return f(ctx, invocation)
|
||||
}
|
||||
|
||||
// IsPatRetrying returns true if the current context is already in a PAT retry.
|
||||
func IsPatRetrying(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(patRetryingKey).(bool)
|
||||
@@ -359,10 +381,10 @@ func openPATAuthorizationURI(rawURI string) error {
|
||||
return nil
|
||||
}
|
||||
// The PAT service returns the complete authorization URL. Treat it as an
|
||||
// opaque string and open it verbatim instead of parsing/rebuilding it
|
||||
// locally, because required parameters may live in query, hash, or
|
||||
// fragment sections.
|
||||
return openBrowserFunc(rawURI)
|
||||
// opaque string unless it is the known legacy DingTalk hash-route variant.
|
||||
// That variant is normalized by the PAT error contract helper before being
|
||||
// printed, opened, or returned in structured output.
|
||||
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
|
||||
}
|
||||
|
||||
func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
|
||||
@@ -378,6 +400,70 @@ func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
|
||||
fmt.Fprintf(output, " %s\n", trimmed)
|
||||
}
|
||||
|
||||
func runDirectPATAuthCheck(
|
||||
ctx context.Context,
|
||||
globalFlags *GlobalFlags,
|
||||
patErr *apperrors.PATError,
|
||||
retry func(context.Context) error,
|
||||
output io.Writer,
|
||||
) error {
|
||||
if retry == nil {
|
||||
return patErr
|
||||
}
|
||||
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, retry, output, true)
|
||||
}
|
||||
|
||||
func runDirectPATAuthCheckWaitOnly(
|
||||
ctx context.Context,
|
||||
globalFlags *GlobalFlags,
|
||||
patErr *apperrors.PATError,
|
||||
output io.Writer,
|
||||
) error {
|
||||
ctx = context.WithValue(ctx, patSuppressBrowserOpenKey, true)
|
||||
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, nil, output, false)
|
||||
}
|
||||
|
||||
func runDirectPATAuthCheckWithMode(
|
||||
ctx context.Context,
|
||||
globalFlags *GlobalFlags,
|
||||
patErr *apperrors.PATError,
|
||||
retry func(context.Context) error,
|
||||
output io.Writer,
|
||||
retryAfterApproval bool,
|
||||
) error {
|
||||
if retryAfterApproval && retry == nil {
|
||||
return patErr
|
||||
}
|
||||
runner := &runtimeRunner{
|
||||
globalFlags: globalFlags,
|
||||
fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
if retry != nil {
|
||||
if err := retry(retryCtx); err != nil {
|
||||
return executor.Result{}, err
|
||||
}
|
||||
}
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"ok": true,
|
||||
},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
|
||||
Kind: "direct_pat_authorization",
|
||||
Stage: "auth_login_recommend",
|
||||
CanonicalProduct: defaultPATProductID,
|
||||
Tool: "pat.batch_grant",
|
||||
CanonicalPath: "pat.batch_grant",
|
||||
Params: map[string]any{
|
||||
"retryAfterApproval": retryAfterApproval,
|
||||
},
|
||||
}, patErr, defaultConfigDir(), output)
|
||||
return err
|
||||
}
|
||||
|
||||
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
|
||||
// authorization error is detected. It injects the server-assigned clientId
|
||||
// as x-robot-uid header, prints authorization details, opens the browser,
|
||||
@@ -395,16 +481,25 @@ func handlePatAuthCheck(
|
||||
var patData struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
URI string `json:"uri"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
URI string `json:"uri"`
|
||||
AuthURL string `json:"authUrl"`
|
||||
AuthorizationURL string `json:"authorizationUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
PollIntervalSecs int `json:"pollIntervalSeconds"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
if patData.Data.URI == "" {
|
||||
patData.Data.URI = patData.Data.AuthURL
|
||||
}
|
||||
if patData.Data.URI == "" {
|
||||
patData.Data.URI = patData.Data.AuthorizationURL
|
||||
}
|
||||
|
||||
slog.Debug("PAT auth check",
|
||||
"clientId", patData.Data.ClientID,
|
||||
@@ -412,7 +507,7 @@ func handlePatAuthCheck(
|
||||
"hasSecret", patData.Data.ClientSecret != "",
|
||||
)
|
||||
hostOwnedPAT := authpkg.HostOwnsPATFlow()
|
||||
openBrowser := currentPATOpenBrowser(configDir)
|
||||
openBrowser := currentPATOpenBrowser(ctx, configDir)
|
||||
slog.Debug("pat.host_owned_decision",
|
||||
"site", "handlePatAuthCheck",
|
||||
"hostOwned", hostOwnedPAT,
|
||||
@@ -457,47 +552,45 @@ func handlePatAuthCheck(
|
||||
|
||||
if wantsStructuredPATOutput(r) {
|
||||
if openBrowser && patData.Data.URI != "" {
|
||||
_ = openBrowserFunc(patData.Data.URI)
|
||||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||||
}
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
greenFn := color.New(color.FgGreen).SprintFunc()
|
||||
yellowFn := color.New(color.FgYellow).SprintFunc()
|
||||
redFn := color.New(color.FgRed).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要 PAT 授权"))
|
||||
if patData.Data.Desc != "" {
|
||||
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
|
||||
fmt.Fprintf(output, " %s %s\n", tui.Dim("ℹ"), patData.Data.Desc)
|
||||
}
|
||||
if patData.Data.URI != "" {
|
||||
fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI))
|
||||
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", tui.Dim("🔗"), authURL)
|
||||
fmt.Fprintln(output)
|
||||
if openBrowser {
|
||||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||||
_ = openPATAuthorizationURI(authURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the device flow status until user authorizes, rejects, or timeout.
|
||||
fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout)
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
|
||||
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", tui.Dim("ℹ"), patPollTimeout)
|
||||
fmt.Fprintln(output)
|
||||
|
||||
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output)
|
||||
status, authCode, err := pollPatDeviceFlowWithInterval(
|
||||
pollCtx, patData.Data.FlowID, configDir, output,
|
||||
resolvePATPollInterval(patData.Data.PollIntervalSecs),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err)
|
||||
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", tui.StateMark("error"), err)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
|
||||
switch status {
|
||||
case authpkg.StatusApproved:
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权成功!"))
|
||||
fmt.Fprintln(output)
|
||||
|
||||
if appCfg != nil {
|
||||
@@ -513,11 +606,11 @@ func handlePatAuthCheck(
|
||||
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
|
||||
if exchErr != nil {
|
||||
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
|
||||
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", yellowFn("⚠"), exchErr)
|
||||
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr)
|
||||
} else {
|
||||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||||
slog.Warn("PAT retry: failed to save new token", "error", err)
|
||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err)
|
||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
|
||||
} else {
|
||||
slog.Debug("PAT retry: token refreshed and saved")
|
||||
}
|
||||
@@ -527,13 +620,23 @@ func handlePatAuthCheck(
|
||||
// Clear token cache so the new credentials take effect.
|
||||
ResetRuntimeTokenCache()
|
||||
|
||||
if shouldSkipPATRetryAfterApproval(invocation) {
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"ok": true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Workaround: brief delay to let server-side authorization state propagate
|
||||
// before retrying. Without this the retry may use stale credentials.
|
||||
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Retry the original invocation with pat-retrying flag to prevent recursion.
|
||||
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试..."))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||||
fmt.Fprintln(output)
|
||||
slog.Debug("PAT retry: identity env check",
|
||||
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
|
||||
@@ -542,7 +645,7 @@ func handlePatAuthCheck(
|
||||
return r.Run(retryCtx, invocation)
|
||||
|
||||
case authpkg.StatusRejected:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("用户已拒绝授权"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"用户已拒绝授权",
|
||||
apperrors.WithReason("pat_auth_rejected"),
|
||||
@@ -550,7 +653,7 @@ func handlePatAuthCheck(
|
||||
)
|
||||
|
||||
case authpkg.StatusExpired:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("授权超时"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"授权超时",
|
||||
apperrors.WithReason("pat_auth_expired"),
|
||||
@@ -558,7 +661,7 @@ func handlePatAuthCheck(
|
||||
)
|
||||
|
||||
case authpkg.StatusCancelled:
|
||||
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消"))
|
||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("操作已取消"))
|
||||
return executor.Result{}, apperrors.NewAuth(
|
||||
"操作已取消",
|
||||
apperrors.WithReason("pat_auth_cancelled"),
|
||||
@@ -566,11 +669,23 @@ func handlePatAuthCheck(
|
||||
)
|
||||
|
||||
default:
|
||||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status)
|
||||
fmt.Fprintf(output, "%s 未知授权状态: %s\n", tui.StateMark("error"), status)
|
||||
return executor.Result{}, patErr
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipPATRetryAfterApproval(invocation executor.Invocation) bool {
|
||||
if invocation.Params == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := invocation.Params["retryAfterApproval"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
retry, ok := value.(bool)
|
||||
return ok && !retry
|
||||
}
|
||||
|
||||
func enrichPATErrorForHostControl(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
@@ -590,7 +705,7 @@ func enrichPATErrorForHostControl(raw string) string {
|
||||
apperrors.ApplyHostMutations(payload)
|
||||
|
||||
// stderr JSON MUST be single-line.
|
||||
encoded, err := json.Marshal(payload)
|
||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
@@ -624,17 +739,38 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
|
||||
"data": data,
|
||||
}
|
||||
// stderr JSON MUST be single-line.
|
||||
b, err := json.Marshal(payload)
|
||||
b, err := jsonutil.Marshal(payload)
|
||||
if err != nil {
|
||||
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := buf.Bytes()
|
||||
if len(out) > 0 && out[len(out)-1] == '\n' {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal
|
||||
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
|
||||
// Returns the final status string and the authCode (non-empty only on APPROVED).
|
||||
func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, output io.Writer) (string, string, error) {
|
||||
return pollPatDeviceFlowWithInterval(ctx, flowID, configDir, output, patPollInterval)
|
||||
}
|
||||
|
||||
func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir string, output io.Writer, interval time.Duration) (string, string, error) {
|
||||
if interval <= 0 {
|
||||
interval = patPollInterval
|
||||
}
|
||||
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
||||
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
||||
|
||||
@@ -651,10 +787,9 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
},
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(patPollInterval)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
pollCount := 0
|
||||
|
||||
for {
|
||||
@@ -666,7 +801,7 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
return authpkg.StatusExpired, "", nil
|
||||
case <-ticker.C:
|
||||
pollCount++
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount)
|
||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
||||
if err != nil {
|
||||
@@ -718,18 +853,38 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
}
|
||||
}
|
||||
|
||||
// tryOpenBrowser opens url in the default browser; errors are silently ignored.
|
||||
func tryOpenBrowser(url string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
func resolvePATPollInterval(seconds int) time.Duration {
|
||||
if seconds <= 0 {
|
||||
return patPollInterval
|
||||
}
|
||||
interval := time.Duration(seconds) * time.Second
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
if interval > patMaxPollInterval {
|
||||
return patMaxPollInterval
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
return exec.Command("open", rawURL)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
return exec.Command("xdg-open", rawURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
|
||||
func tryOpenBrowser(rawURL string) error {
|
||||
cmd := browserOpenCommand(runtime.GOOS, rawURL)
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
@@ -590,6 +590,35 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
|
||||
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
|
||||
|
||||
out := enrichPATErrorWithOpenBrowser(raw, true)
|
||||
|
||||
if strings.Contains(out, `\u0026`) {
|
||||
t.Fatalf("enriched PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "&userCode=QZYH-D64W") {
|
||||
t.Fatalf("enriched PAT JSON missing readable authorization URL separator, got: %s", out)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(enriched PAT payload) error = %v\nraw=%s", err, out)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want %q", got, rawURI)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted from enriched PAT payload")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted from enriched PAT payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
@@ -624,7 +653,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
if !retryHasKey {
|
||||
t.Fatal("expected retry context to have patRetryingKey")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(configDir)); err != nil {
|
||||
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
|
||||
}
|
||||
// Verify SetClientIDFromMCP was called with the PAT response clientId.
|
||||
@@ -633,6 +662,192 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
var retried atomic.Bool
|
||||
var retryHadKey atomic.Bool
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
|
||||
retried.Store(true)
|
||||
retryHadKey.Store(IsPatRetrying(ctx))
|
||||
return nil
|
||||
}, &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatalf("runDirectPATAuthCheck error = %v", err)
|
||||
}
|
||||
if !retried.Load() {
|
||||
t.Fatal("expected direct PAT auth retry callback to run")
|
||||
}
|
||||
if !retryHadKey.Load() {
|
||||
t.Fatal("expected direct PAT auth retry context to be marked as PAT retrying")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), "授权完成,正在重试") {
|
||||
t.Fatalf("wait-only auth must not print retry prompt, output:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "授权成功") {
|
||||
t.Fatalf("wait-only auth should still report success, output:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", true); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
var opened bool
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = true
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err)
|
||||
}
|
||||
if opened {
|
||||
t.Fatal("wait-only auth must not open a second browser tab")
|
||||
}
|
||||
if !strings.Contains(out.String(), "授权链接:") {
|
||||
t.Fatalf("wait-only auth should still print the authorization URL, output:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePATPollInterval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seconds int
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "default", seconds: 0, want: patPollInterval},
|
||||
{name: "server value", seconds: 3, want: 3 * time.Second},
|
||||
{name: "cap excessive value", seconds: 90, want: patMaxPollInterval},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := resolvePATPollInterval(tt.seconds); got != tt.want {
|
||||
t.Fatalf("resolvePATPollInterval(%d) = %s, want %s", tt.seconds, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
rawURI := "https://example.com/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH"
|
||||
raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","uri":"` + rawURI + `","authUrl":"` + rawURI + `","clientId":"test-client-id"}}`
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"},
|
||||
&apperrors.PATError{RawJSON: raw},
|
||||
func(ctx context.Context) error {
|
||||
t.Fatal("retry callback should not run in structured PAT output mode")
|
||||
return nil
|
||||
},
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected structured PATError")
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
if got, _ := payload["code"].(string); got != "PAT_BATCH_AUTH_PENDING" {
|
||||
t.Fatalf("code = %q, want PAT_BATCH_AUTH_PENDING", got)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want %q", got, rawURI)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted from structured PAT output")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted from structured PAT output")
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || got {
|
||||
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectPATAuthCheck_JSONModeBackfillsSingleURIFromAuthURL(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
rawURL := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH"
|
||||
wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH#/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH"
|
||||
raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","authUrl":"` + rawURL + `","clientId":"test-client-id"}}`
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"},
|
||||
&apperrors.PATError{RawJSON: raw},
|
||||
func(ctx context.Context) error {
|
||||
t.Fatal("retry callback should not run in structured PAT output mode")
|
||||
return nil
|
||||
},
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected structured PATError")
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `\u0026`) {
|
||||
t.Fatalf("PAT output escaped URL separators: %s", patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != wantURL {
|
||||
t.Fatalf("data.uri = %q, want %q", got, wantURL)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_Rejected(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "REJECTED", "")
|
||||
@@ -700,7 +915,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
@@ -759,7 +974,7 @@ func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *te
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
@@ -855,7 +1070,7 @@ func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *tes
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
@@ -903,7 +1118,8 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "json"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Df72437f040f04a8295988ff71e690b35%26userCode%3D98JV-JSBL#/personalAuthorization?flowId=f72437f040f04a8295988ff71e690b35&userCode=98JV-JSBL"
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"` + rawURI + `","clientId":"test-client-id"}}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
@@ -917,11 +1133,38 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
if opened != "https://example.com/pat" {
|
||||
t.Fatalf("opened url = %q, want https://example.com/pat", opened)
|
||||
if opened != rawURI {
|
||||
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
if strings.Contains(patOut.RawJSON, `\u0026`) {
|
||||
t.Fatalf("PATError RawJSON escaped ampersands in authorization URL: %s", patOut.RawJSON)
|
||||
}
|
||||
if !strings.Contains(patOut.RawJSON, "&userCode=98JV-JSBL") {
|
||||
t.Fatalf("PATError RawJSON missing literal ampersand route separator: %s", patOut.RawJSON)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted from json PAT output")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted from json PAT output")
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || !got {
|
||||
t.Fatalf("data.openBrowser = %#v, want true", data["openBrowser"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,7 +1196,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "table"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","uri":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
@@ -973,6 +1216,12 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
if !strings.Contains(buf.String(), "需要 PAT 授权") {
|
||||
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") {
|
||||
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
|
||||
t.Fatalf("human-readable PAT output should not emit a second machine-readable URL line, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithPatAuthRetry_JSONModeReturnsStructuredPATError(t *testing.T) {
|
||||
@@ -1063,6 +1312,21 @@ func TestEnrichPATErrorForHostControl_SingleLineOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPATErrorForHostControlKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
|
||||
t.Setenv("DINGTALK_AGENT", "sales-copilot")
|
||||
|
||||
raw := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"flowId":"flow-host","desc":"授权","uri":"https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-host%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-host&userCode=QZYH-D64W"}}`
|
||||
out := enrichPATErrorForHostControl(raw)
|
||||
|
||||
if strings.Contains(out, `\u0026`) {
|
||||
t.Fatalf("host PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "&userCode=QZYH-D64W") {
|
||||
t.Fatalf("host PAT JSON missing readable authorization URL separator, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPATScopeHostJSON_SingleLineOutput mirrors the above regression
|
||||
// for the scope-error branch (PAT_SCOPE_AUTH_REQUIRED emission).
|
||||
func TestBuildPATScopeHostJSON_SingleLineOutput(t *testing.T) {
|
||||
@@ -1189,4 +1453,78 @@ func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) {
|
||||
if opened != rawURI {
|
||||
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
|
||||
}
|
||||
if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") {
|
||||
t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
defer server.Close()
|
||||
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
|
||||
wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
|
||||
var opened string
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = rawURL
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
var retryCalled bool
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
retryCalled = true
|
||||
return executor.Result{Response: map[string]any{"ok": true}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-legacy-hash", "test-client-id", rawURI)}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, configDir, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !retryCalled {
|
||||
t.Fatal("expected retry to run after approved PAT flow")
|
||||
}
|
||||
if opened != wantURL {
|
||||
t.Fatalf("opened url = %q, want normalized %q", opened, wantURL)
|
||||
}
|
||||
if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") {
|
||||
t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpenCommand_WindowsPreservesOpaquePATURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Df72437f040f04a8295988ff71e690b35%26userCode%3D98JV-JSBL#/personalAuthorization?flowId=f72437f040f04a8295988ff71e690b35&userCode=98JV-JSBL"
|
||||
cmd := browserOpenCommand("windows", rawURI)
|
||||
if cmd == nil {
|
||||
t.Fatal("browserOpenCommand(windows) returned nil")
|
||||
}
|
||||
if got := cmd.Args[0]; got == "cmd" {
|
||||
t.Fatalf("windows browser opener must not route PAT URLs through cmd.exe: args=%v", cmd.Args)
|
||||
}
|
||||
if got := len(cmd.Args); got != 3 {
|
||||
t.Fatalf("windows browser opener args length = %d, want 3: %v", got, cmd.Args)
|
||||
}
|
||||
if got := cmd.Args[0]; got != "rundll32" {
|
||||
t.Fatalf("windows browser opener command = %q, want rundll32", got)
|
||||
}
|
||||
if got := cmd.Args[1]; got != "url.dll,FileProtocolHandler" {
|
||||
t.Fatalf("windows browser opener handler = %q, want url.dll,FileProtocolHandler", got)
|
||||
}
|
||||
if got := cmd.Args[2]; got != rawURI {
|
||||
t.Fatalf("windows browser opener URL arg = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +148,11 @@ func registerStdioServerFromOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// nil existingTools: this overlay is built from the plugin's own live tool
|
||||
// list (detailsByID is derived from it), so there are no phantom leaves to
|
||||
// guard against here.
|
||||
cmds := compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
|
||||
|
||||
slog.Debug("plugin: stdio server registered from overlay",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeProfileFlagArgsAcceptsUnquotedCommaContinuation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "root profile before command",
|
||||
args: []string{"--mock", "--profile", "corpA,", "corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--mock", "--profile", "corpA,corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "profile after leaf command",
|
||||
args: []string{"contact", "user", "get-self", "--profile", "corpA,", "corpB", "--format", "json"},
|
||||
want: []string{"contact", "user", "get-self", "--profile", "corpA,corpB", "--format", "json"},
|
||||
},
|
||||
{
|
||||
name: "equals form",
|
||||
args: []string{"--profile=corpA,", "corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--profile=corpA,corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "three profiles",
|
||||
args: []string{"--profile", "corpA,", "corpB,", "corpC", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA,corpB,corpC", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "already quoted by shell remains unchanged",
|
||||
args: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "single profile remains unchanged",
|
||||
args: []string{"--profile", "corpA", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA", "contact", "user", "get-self"},
|
||||
},
|
||||
{
|
||||
name: "trailing comma before next flag remains validation input",
|
||||
args: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
|
||||
want: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, _ := normalizeProfileFlagArgs(tc.args)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("normalizeProfileFlagArgs() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
got := preparseProfileFlag([]string{"--profile", "corpA,", "corpB", "contact", "user", "get-self"})
|
||||
if got != "corpA,corpB" {
|
||||
t.Fatalf("preparseProfileFlag() = %q, want corpA,corpB", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
os.Args = []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}
|
||||
restore := normalizeProcessProfileArgs()
|
||||
if want := []string{"dws", "--profile", "corpA,corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
|
||||
t.Fatalf("os.Args after normalize = %#v, want %#v", os.Args, want)
|
||||
}
|
||||
restore()
|
||||
if want := []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
|
||||
t.Fatalf("os.Args after restore = %#v, want %#v", os.Args, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newProfileCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "组织 profile 管理",
|
||||
Long: `管理本机已登录的钉钉组织 profile。
|
||||
|
||||
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
|
||||
profile switch/use 才会持久修改默认组织上下文。`,
|
||||
Example: ` dws profile list
|
||||
dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newProfileListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "列出已登录组织 profile",
|
||||
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
|
||||
Example: ` dws profile list
|
||||
dws profile list --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
|
||||
}
|
||||
writeProfileListTable(cmd.OutOrStdout(), cfg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newProfileUseCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "use [name|corpId|-]",
|
||||
Short: "切换当前组织 profile(兼容 profile switch)",
|
||||
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
|
||||
Example: ` dws profile use <corpId>
|
||||
dws profile use --name "钉钉"
|
||||
dws profile use -`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProfileSwitchCommand(cmd, args)
|
||||
},
|
||||
}
|
||||
addProfileSwitchSelectorFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newProfileSwitchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "switch [name|corpId|-]",
|
||||
Short: "切换当前组织 profile",
|
||||
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
|
||||
|
||||
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
|
||||
需要只影响单次业务命令时,请使用全局 --profile。`,
|
||||
Example: ` dws profile switch
|
||||
dws profile switch <corpId>
|
||||
dws profile switch --corpId <corpId>
|
||||
dws profile switch --name "钉钉"
|
||||
dws profile switch -
|
||||
dws --profile <corpId> contact user get-self`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProfileSwitchCommand(cmd, args)
|
||||
},
|
||||
}
|
||||
addProfileSwitchSelectorFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("corpId", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corp-id", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corpid", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("corp", "", "按 corpId 直接切换组织 profile")
|
||||
cmd.Flags().String("name", "", "按组织名或 profile 名直接切换组织 profile")
|
||||
_ = cmd.Flags().MarkHidden("corp-id")
|
||||
_ = cmd.Flags().MarkHidden("corpid")
|
||||
_ = cmd.Flags().MarkHidden("corp")
|
||||
}
|
||||
|
||||
var (
|
||||
profileSwitchSelector = selectProfileSwitchProfile
|
||||
profileSwitchInteractiveTerminal = isInteractiveTerminal
|
||||
)
|
||||
|
||||
const (
|
||||
profileSwitchVisibleOptions = 5
|
||||
profileSwitchCellPadding = 1
|
||||
profileSwitchOrgWidth = 34
|
||||
profileSwitchStatusWidth = 10
|
||||
)
|
||||
|
||||
var profileSwitchRenderer = newProfileSwitchRenderer()
|
||||
|
||||
func newProfileSwitchRenderer() *lipgloss.Renderer {
|
||||
renderer := lipgloss.NewRenderer(io.Discard)
|
||||
renderer.SetColorProfile(termenv.TrueColor)
|
||||
renderer.SetHasDarkBackground(true)
|
||||
return renderer
|
||||
}
|
||||
|
||||
func runProfileSwitchCommand(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
selector, err := profileSwitchSelectorFromCommand(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usedTUI := false
|
||||
if selector == "" {
|
||||
selector, err = profileSwitchSelector(cmd, configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usedTUI = true
|
||||
}
|
||||
return switchProfileAndWrite(cmd, configDir, selector, usedTUI)
|
||||
}
|
||||
|
||||
func profileSwitchSelectorFromCommand(cmd *cobra.Command, args []string) (string, error) {
|
||||
selectors := make([]string, 0, 2)
|
||||
if len(args) > 0 {
|
||||
selectors = append(selectors, strings.TrimSpace(args[0]))
|
||||
}
|
||||
for _, name := range []string{"corpId", "corp-id", "corpid", "corp", "name"} {
|
||||
value, changed := changedStringFlag(cmd, name)
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
if value == "" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", name))
|
||||
}
|
||||
selectors = append(selectors, value)
|
||||
}
|
||||
if len(selectors) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
selector := selectors[0]
|
||||
for _, candidate := range selectors[1:] {
|
||||
if candidate != selector {
|
||||
return "", apperrors.NewValidation("只能指定一个组织选择器,请使用位置参数或 --corpId/--name 其中一种")
|
||||
}
|
||||
}
|
||||
return selector, nil
|
||||
}
|
||||
|
||||
func changedStringFlag(cmd *cobra.Command, name string) (string, bool) {
|
||||
if cmd == nil || cmd.Flags() == nil {
|
||||
return "", false
|
||||
}
|
||||
flag := cmd.Flags().Lookup(name)
|
||||
if flag == nil || !flag.Changed {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(flag.Value.String()), true
|
||||
}
|
||||
|
||||
func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error {
|
||||
var (
|
||||
profile *authpkg.Profile
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(selector) == "-" {
|
||||
profile, err = authpkg.UsePreviousProfile(configDir)
|
||||
} else {
|
||||
profile, err = authpkg.SetCurrentProfile(configDir, selector)
|
||||
}
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
|
||||
cfg, loadErr := authpkg.LoadProfiles(configDir)
|
||||
if loadErr != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
|
||||
}
|
||||
return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg)
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile))
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
|
||||
if !profileSwitchInteractiveTerminal() {
|
||||
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
|
||||
}
|
||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||
}
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
return "", apperrors.NewValidation("未找到已登录 profile,请先运行 dws auth login")
|
||||
}
|
||||
choice := strings.TrimSpace(cfg.CurrentProfile)
|
||||
if choice == "" {
|
||||
choice = strings.TrimSpace(cfg.PrimaryProfile)
|
||||
}
|
||||
if choice == "" {
|
||||
choice = cfg.Profiles[0].CorpID
|
||||
}
|
||||
return runProfileSwitchTUI(cmd, cfg, choice)
|
||||
}
|
||||
|
||||
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
|
||||
model := newProfileSwitchTUIModel(cfg, selectedCorpID)
|
||||
program := tea.NewProgram(
|
||||
model,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithInput(cmd.InOrStdin()),
|
||||
tea.WithOutput(cmd.ErrOrStderr()),
|
||||
tea.WithContext(cmd.Context()),
|
||||
)
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, tea.ErrInterrupted) {
|
||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||
}
|
||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to run profile selector: %v", err))
|
||||
}
|
||||
final, ok := finalModel.(profileSwitchTUIModel)
|
||||
if !ok || final.aborted || !final.submitted {
|
||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||
}
|
||||
return final.selectedCorpID(), nil
|
||||
}
|
||||
|
||||
type profileSwitchTUIModel struct {
|
||||
cfg *authpkg.ProfilesConfig
|
||||
profiles []authpkg.Profile
|
||||
selected int
|
||||
offset int
|
||||
submitted bool
|
||||
aborted bool
|
||||
}
|
||||
|
||||
func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string) profileSwitchTUIModel {
|
||||
model := profileSwitchTUIModel{cfg: cfg}
|
||||
if cfg != nil {
|
||||
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
|
||||
}
|
||||
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
|
||||
if model.selected < 0 {
|
||||
model.selected = 0
|
||||
}
|
||||
model.ensureSelectedVisible()
|
||||
return model
|
||||
}
|
||||
|
||||
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
|
||||
sorted := append([]authpkg.Profile(nil), profiles...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
left, leftOK := profileSwitchSortTime(sorted[i])
|
||||
right, rightOK := profileSwitchSortTime(sorted[j])
|
||||
if leftOK && rightOK && !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return false
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
|
||||
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
|
||||
if t, ok := parseProfileSwitchTime(raw); ok {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseProfileSwitchTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "q":
|
||||
m.aborted = true
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
m.ensureSelectedVisible()
|
||||
}
|
||||
case "down", "j":
|
||||
if m.selected < len(m.profiles)-1 {
|
||||
m.selected++
|
||||
m.ensureSelectedVisible()
|
||||
}
|
||||
case "enter":
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) View() string {
|
||||
var b strings.Builder
|
||||
title := profileSwitchTitleStyle().Render("选择要切换的组织")
|
||||
hint := profileSwitchMutedStyle().Render("全部已登录 profile,↑↓ 选择,Enter 确认")
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(hint)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.tableView())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(profileSwitchMutedStyle().Render("↑/k up • ↓/j down • enter submit • esc cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) tableView() string {
|
||||
rows := []string{
|
||||
profileSwitchBorder("┌", "┬", "┐"),
|
||||
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
|
||||
profileSwitchBorder("├", "┼", "┤"),
|
||||
}
|
||||
for i := 0; i < profileSwitchVisibleOptions; i++ {
|
||||
idx := m.offset + i
|
||||
if idx >= 0 && idx < len(m.profiles) {
|
||||
rows = append(rows, m.profileRow(idx))
|
||||
continue
|
||||
}
|
||||
rows = append(rows, profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()))
|
||||
}
|
||||
rows = append(rows, profileSwitchBorder("└", "┴", "┘"))
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) profileRow(idx int) string {
|
||||
profile := m.profiles[idx]
|
||||
org, status := profileSwitchProfileCells(profile, m.cfg)
|
||||
style := profileSwitchNormalRowStyle()
|
||||
if idx == m.selected {
|
||||
org = "› " + org
|
||||
style = profileSwitchSelectedRowStyle()
|
||||
} else {
|
||||
org = " " + org
|
||||
}
|
||||
return profileSwitchStyledTableLine(org, status, style)
|
||||
}
|
||||
|
||||
func (m *profileSwitchTUIModel) ensureSelectedVisible() {
|
||||
if len(m.profiles) == 0 {
|
||||
m.selected = 0
|
||||
m.offset = 0
|
||||
return
|
||||
}
|
||||
if m.selected < 0 {
|
||||
m.selected = 0
|
||||
}
|
||||
if m.selected >= len(m.profiles) {
|
||||
m.selected = len(m.profiles) - 1
|
||||
}
|
||||
if m.selected < m.offset {
|
||||
m.offset = m.selected
|
||||
}
|
||||
if m.selected >= m.offset+profileSwitchVisibleOptions {
|
||||
m.offset = m.selected - profileSwitchVisibleOptions + 1
|
||||
}
|
||||
maxOffset := len(m.profiles) - profileSwitchVisibleOptions
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
if m.offset > maxOffset {
|
||||
m.offset = maxOffset
|
||||
}
|
||||
if m.offset < 0 {
|
||||
m.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m profileSwitchTUIModel) selectedCorpID() string {
|
||||
if m.selected < 0 || m.selected >= len(m.profiles) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.profiles[m.selected].CorpID)
|
||||
}
|
||||
|
||||
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
for i, p := range profiles {
|
||||
if strings.TrimSpace(p.CorpID) == corpID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
org, status := profileSwitchProfileCells(p, cfg)
|
||||
if status == "" {
|
||||
return org
|
||||
}
|
||||
return strings.Join([]string{org, status}, " | ")
|
||||
}
|
||||
|
||||
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
|
||||
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
|
||||
}
|
||||
|
||||
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||||
if cfg != nil && p.CorpID == cfg.CurrentProfile {
|
||||
return "当前组织"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func profileSwitchBorder(left, sep, right string) string {
|
||||
segments := []string{
|
||||
strings.Repeat("─", profileSwitchCellWidth(profileSwitchOrgWidth)),
|
||||
strings.Repeat("─", profileSwitchCellWidth(profileSwitchStatusWidth)),
|
||||
}
|
||||
return profileSwitchBorderStyle().Render(left + strings.Join(segments, sep) + right)
|
||||
}
|
||||
|
||||
func profileSwitchTableLine(org, status string) string {
|
||||
cells := []string{
|
||||
profileSwitchTableCell(org, profileSwitchOrgWidth),
|
||||
profileSwitchTableCell(status, profileSwitchStatusWidth),
|
||||
}
|
||||
return "│" + strings.Join(cells, "│") + "│"
|
||||
}
|
||||
|
||||
func profileSwitchStyledTableLine(org, status string, style lipgloss.Style) string {
|
||||
cells := []string{
|
||||
style.Render(profileSwitchTableCell(org, profileSwitchOrgWidth)),
|
||||
style.Render(profileSwitchTableCell(status, profileSwitchStatusWidth)),
|
||||
}
|
||||
return profileSwitchTableSeparator() + strings.Join(cells, profileSwitchTableSeparator()) + profileSwitchTableSeparator()
|
||||
}
|
||||
|
||||
func profileSwitchTableSeparator() string {
|
||||
return profileSwitchBorderStyle().Render("│")
|
||||
}
|
||||
|
||||
func profileSwitchTableCell(value string, width int) string {
|
||||
clipped := clipProfileDisplayCell(strings.TrimSpace(value), width)
|
||||
padding := strings.Repeat(" ", profileSwitchCellPadding)
|
||||
return padding + padProfileDisplayCell(clipped, width) + padding
|
||||
}
|
||||
|
||||
func padProfileDisplayCell(value string, width int) string {
|
||||
padding := width - lipgloss.Width(value)
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
return value + strings.Repeat(" ", padding)
|
||||
}
|
||||
|
||||
func profileSwitchCellWidth(contentWidth int) int {
|
||||
return contentWidth + profileSwitchCellPadding*2
|
||||
}
|
||||
|
||||
func profileSwitchSelectedRowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchNormalRowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#FFFFFF"))
|
||||
}
|
||||
|
||||
func profileSwitchHeaderStyle() lipgloss.Style {
|
||||
return profileSwitchMutedStyle().Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchBorderStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#2F3B52"))
|
||||
}
|
||||
|
||||
func profileSwitchTitleStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
|
||||
}
|
||||
|
||||
func profileSwitchMutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#8A96A8"))
|
||||
}
|
||||
|
||||
type profileListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
PrimaryProfile string `json:"primaryProfile,omitempty"`
|
||||
CurrentProfile string `json:"currentProfile,omitempty"`
|
||||
PreviousProfile string `json:"previousProfile,omitempty"`
|
||||
Profiles []profileView `json:"profiles"`
|
||||
}
|
||||
|
||||
type profileUseResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Profile profileView `json:"profile"`
|
||||
}
|
||||
|
||||
type profileView struct {
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpAt string `json:"refreshExpAt,omitempty"`
|
||||
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
}
|
||||
|
||||
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
|
||||
resp := profileListResponse{
|
||||
Success: true,
|
||||
PrimaryProfile: cfg.PrimaryProfile,
|
||||
CurrentProfile: cfg.CurrentProfile,
|
||||
PreviousProfile: cfg.PreviousProfile,
|
||||
Profiles: profileViews(cfg),
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error {
|
||||
resp := profileUseResponse{Success: true}
|
||||
if profile != nil {
|
||||
primaryProfile := ""
|
||||
currentProfile := ""
|
||||
if cfg != nil {
|
||||
primaryProfile = cfg.PrimaryProfile
|
||||
currentProfile = cfg.CurrentProfile
|
||||
}
|
||||
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
fmt.Fprintln(w, "未找到已登录 profile")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
|
||||
for _, p := range cfg.Profiles {
|
||||
current := ""
|
||||
if p.CorpID == cfg.CurrentProfile {
|
||||
current = "*"
|
||||
}
|
||||
primary := ""
|
||||
if p.CorpID == cfg.PrimaryProfile {
|
||||
primary = "*"
|
||||
}
|
||||
user := p.UserName
|
||||
if user == "" {
|
||||
user = p.UserID
|
||||
}
|
||||
status := p.Status
|
||||
if status == "" {
|
||||
status = authpkg.ProfileStatusActive
|
||||
}
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
"%-3s %-3s %-28s %-34s %-10s %s\n",
|
||||
current,
|
||||
primary,
|
||||
clipProfileCell(profileOrgName(p), 28),
|
||||
clipProfileCell(p.CorpID, 34),
|
||||
status,
|
||||
user,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func profileUseMessage(profile *authpkg.Profile) string {
|
||||
if profile == nil {
|
||||
return "[OK] 当前 profile 已切换"
|
||||
}
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
orgName := strings.TrimSpace(profile.CorpName)
|
||||
if orgName == "" {
|
||||
orgName = profileOrgName(*profile)
|
||||
}
|
||||
return fmt.Sprintf("[OK] 当前组织: %s (%s)", orgName, corpID)
|
||||
}
|
||||
|
||||
func profileOrgName(p authpkg.Profile) string {
|
||||
if v := strings.TrimSpace(p.CorpName); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(p.Name); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(p.CorpID)
|
||||
}
|
||||
|
||||
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
views := make([]profileView, 0, len(cfg.Profiles))
|
||||
for _, p := range cfg.Profiles {
|
||||
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
|
||||
return profileView{
|
||||
CorpID: p.CorpID,
|
||||
CorpName: profileOrgName(p),
|
||||
UserID: p.UserID,
|
||||
UserName: p.UserName,
|
||||
ClientID: p.ClientID,
|
||||
Status: p.Status,
|
||||
AuthorizedDomains: p.AuthorizedDomains,
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
RefreshExpAt: p.RefreshExpAt,
|
||||
LastLoginAt: p.LastLoginAt,
|
||||
LastUsedAt: p.LastUsedAt,
|
||||
IsPrimary: p.CorpID == primaryProfile,
|
||||
IsCurrent: p.CorpID == currentProfile,
|
||||
}
|
||||
}
|
||||
|
||||
func clipProfileCell(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit <= 3 {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return string(runes[:limit-3]) + "..."
|
||||
}
|
||||
|
||||
func clipProfileDisplayCell(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(value) <= limit {
|
||||
return value
|
||||
}
|
||||
if limit <= 3 {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
rw := lipgloss.Width(string(r))
|
||||
if lipgloss.Width(b.String())+rw > limit {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
target := limit - 3
|
||||
var b strings.Builder
|
||||
width := 0
|
||||
for _, r := range value {
|
||||
rw := lipgloss.Width(string(r))
|
||||
if width+rw > target {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
width += rw
|
||||
}
|
||||
return b.String() + "..."
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) {
|
||||
profile := &authpkg.Profile{
|
||||
Name: "B Org",
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B Org",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_a",
|
||||
CurrentProfile: "corp_b",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := writeProfileUseJSON(&buf, profile, cfg); err != nil {
|
||||
t.Fatalf("writeProfileUseJSON() error = %v", err)
|
||||
}
|
||||
var resp profileUseResponse
|
||||
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if bytes.Contains(buf.Bytes(), []byte(`"name"`)) {
|
||||
t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String())
|
||||
}
|
||||
if resp.Profile.CorpName != "B Org" {
|
||||
t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName)
|
||||
}
|
||||
if !resp.Profile.IsCurrent {
|
||||
t.Fatalf("isCurrent = false, want true")
|
||||
}
|
||||
if resp.Profile.IsPrimary {
|
||||
t.Fatalf("isPrimary = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
var resp profileListResponse
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatal("success = false, want true")
|
||||
}
|
||||
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
|
||||
}
|
||||
if len(resp.Profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
|
||||
}
|
||||
if bytes.Contains(out.Bytes(), []byte(`"name"`)) {
|
||||
t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String())
|
||||
}
|
||||
for _, p := range resp.Profiles {
|
||||
if p.CorpName == "" {
|
||||
t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile use output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_primary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||||
}
|
||||
|
||||
cmd = NewRootCommand()
|
||||
out.Reset()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
|
||||
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err = authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err = authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_secondary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile switch output should include organization name:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
|
||||
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
legacyToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if legacyToken.CorpID != "corp_primary" {
|
||||
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "--corpId", "corp_primary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
|
||||
cmd = NewRootCommand()
|
||||
out.Reset()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
cfg, err = authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_secondary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "只能指定一个组织选择器") {
|
||||
t.Fatalf("error = %v, want conflicting selector validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
oldSelector := profileSwitchSelector
|
||||
t.Cleanup(func() {
|
||||
profileSwitchSelector = oldSelector
|
||||
})
|
||||
called := false
|
||||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||||
called = true
|
||||
if gotConfigDir != configDir {
|
||||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||||
}
|
||||
return "corp_primary", nil
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "switch"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("profile switch without args did not invoke TUI selector")
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_primary",
|
||||
CurrentProfile: "corp_secondary",
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
CorpID: "corp_primary",
|
||||
CorpName: "第一组织",
|
||||
UserName: "alice",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
CorpID: "corp_secondary",
|
||||
CorpName: "第二组织",
|
||||
UserName: "bob",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
},
|
||||
}
|
||||
primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg)
|
||||
current := profileSwitchOptionLabel(cfg.Profiles[1], cfg)
|
||||
for _, label := range []string{primary, current} {
|
||||
if strings.Contains(label, "\n") {
|
||||
t.Fatalf("profile switch label contains newline: %q", label)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(primary, "第一组织") {
|
||||
t.Fatalf("primary option missing organization name: %q", primary)
|
||||
}
|
||||
if !strings.Contains(current, "当前组织") {
|
||||
t.Fatalf("current option missing current marker: %q", current)
|
||||
}
|
||||
for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} {
|
||||
if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) {
|
||||
t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
|
||||
cfg := profileSwitchTestConfig(2)
|
||||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||||
view := model.tableView()
|
||||
if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 {
|
||||
t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view)
|
||||
}
|
||||
for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("profile switch table missing %q in:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} {
|
||||
if strings.Contains(view, unwanted) {
|
||||
t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 {
|
||||
t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
if got := strings.Count(view, profile.CorpID); got != 0 {
|
||||
t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "old",
|
||||
CurrentProfile: "old",
|
||||
Profiles: []authpkg.Profile{
|
||||
{CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"},
|
||||
{CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"},
|
||||
{CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"},
|
||||
},
|
||||
}
|
||||
model := newProfileSwitchTUIModel(cfg, "old")
|
||||
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
|
||||
wantOrder := []string{"new", "fallback", "old"}
|
||||
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
|
||||
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
|
||||
}
|
||||
if got := model.selectedCorpID(); got != "old" {
|
||||
t.Fatalf("selectedCorpID = %q, want old", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) {
|
||||
cfg := profileSwitchTestConfig(7)
|
||||
model := newProfileSwitchTUIModel(cfg, "corp_00")
|
||||
for step := 0; step < 6; step++ {
|
||||
view := model.tableView()
|
||||
if got := strings.Count(view, "›"); got != 1 {
|
||||
t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view)
|
||||
}
|
||||
for _, profile := range cfg.Profiles {
|
||||
name := profileOrgName(profile)
|
||||
if got := strings.Count(view, name); got > 1 {
|
||||
t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view)
|
||||
}
|
||||
}
|
||||
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = next.(profileSwitchTUIModel)
|
||||
}
|
||||
if model.selected != 6 || model.offset != 2 {
|
||||
t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) {
|
||||
rows := []string{
|
||||
profileSwitchTableLine("组织名", "本地状态"),
|
||||
profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"),
|
||||
profileSwitchTableLine(" ACME", ""),
|
||||
profileSwitchTableLine("", ""),
|
||||
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
|
||||
profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()),
|
||||
profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()),
|
||||
profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()),
|
||||
}
|
||||
wantWidth := lipgloss.Width(rows[0])
|
||||
for i, row := range rows {
|
||||
if got := lipgloss.Width(row); got != wantWidth {
|
||||
t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row)
|
||||
}
|
||||
if got := strings.Count(row, "│"); got != 3 {
|
||||
t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) {
|
||||
const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85"
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: corpID,
|
||||
CurrentProfile: corpID,
|
||||
}
|
||||
label := profileSwitchOptionLabel(authpkg.Profile{
|
||||
CorpID: corpID,
|
||||
CorpName: "钉钉",
|
||||
}, cfg)
|
||||
for _, want := range []string{"钉钉", "当前组织"} {
|
||||
if !strings.Contains(label, want) {
|
||||
t.Fatalf("profile switch label missing %q in %q", want, label)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"ding8196", "cb85", "主组织"} {
|
||||
if strings.Contains(label, unwanted) {
|
||||
t.Fatalf("profile switch label should not contain %q in %q", unwanted, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_00",
|
||||
CurrentProfile: "corp_00",
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
corpID := fmt.Sprintf("corp_%02d", i)
|
||||
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
|
||||
CorpID: corpID,
|
||||
CorpName: fmt.Sprintf("组织%02d", i),
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
})
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"auth", "switch"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
|
||||
t.Fatalf("error = %v, want auth switch unknown command", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
authLogoutTestToken("corp_secondary"),
|
||||
)
|
||||
oldSelector := profileSwitchSelector
|
||||
t.Cleanup(func() {
|
||||
profileSwitchSelector = oldSelector
|
||||
})
|
||||
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
|
||||
if gotConfigDir != configDir {
|
||||
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
|
||||
}
|
||||
return "corp_primary", nil
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"profile", "use"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
|
||||
t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String())
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_primary" {
|
||||
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) {
|
||||
oldInteractive := profileSwitchInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
profileSwitchInteractiveTerminal = oldInteractive
|
||||
})
|
||||
profileSwitchInteractiveTerminal = func() bool { return false }
|
||||
|
||||
_, err := selectProfileSwitchProfile(nil, t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("selectProfileSwitchProfile() succeeded, want validation error")
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) {
|
||||
t.Fatalf("error = %v, want profile selector hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
PrimaryProfile: "corp_a",
|
||||
CurrentProfile: "corp_b",
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "DingTalk China",
|
||||
CorpID: "corp_a",
|
||||
CorpName: "钉钉(中国)信息技术有限公司",
|
||||
UserName: "alice",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
Name: "B Org",
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B 组织",
|
||||
UserID: "bob-id",
|
||||
},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
writeProfileListTable(&buf, cfg)
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"ORG_NAME",
|
||||
"钉钉(中国)信息技术有限公司",
|
||||
"B 组织",
|
||||
"corp_a",
|
||||
"corp_b",
|
||||
} {
|
||||
if !bytes.Contains(buf.Bytes(), []byte(want)) {
|
||||
t.Fatalf("profile list table missing %q in output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"PROFILE", "DingTalk China"} {
|
||||
if bytes.Contains(buf.Bytes(), []byte(unwanted)) {
|
||||
t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUseMessageIncludesCorpName(t *testing.T) {
|
||||
got := profileUseMessage(&authpkg.Profile{
|
||||
Name: "DingTalk China",
|
||||
CorpID: "ding8196",
|
||||
CorpName: "钉钉(中国)信息技术有限公司",
|
||||
})
|
||||
for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} {
|
||||
if !bytes.Contains([]byte(got), []byte(want)) {
|
||||
t.Fatalf("profileUseMessage() missing %q in %q", want, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("DingTalk China")) {
|
||||
t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestProductCommandsAcceptGlobalProfileFlag(t *testing.T) {
|
||||
const selectedProfile = "corp_profile_matrix"
|
||||
|
||||
products := []struct {
|
||||
name string
|
||||
path []string
|
||||
tool string
|
||||
}{
|
||||
{name: "aitable", path: []string{"aitable", "profile-test", "probe"}, tool: "aitable_profile_probe"},
|
||||
{name: "attendance", path: []string{"attendance", "profile-test", "probe"}, tool: "attendance_profile_probe"},
|
||||
{name: "calendar", path: []string{"calendar", "profile-test", "probe"}, tool: "calendar_profile_probe"},
|
||||
{name: "contact", path: []string{"contact", "profile-test", "probe"}, tool: "contact_profile_probe"},
|
||||
{name: "devdoc", path: []string{"devdoc", "profile-test", "probe"}, tool: "devdoc_profile_probe"},
|
||||
{name: "ding", path: []string{"ding", "profile-test", "probe"}, tool: "ding_profile_probe"},
|
||||
{name: "report", path: []string{"report", "profile-test", "probe"}, tool: "report_profile_probe"},
|
||||
{name: "todo", path: []string{"todo", "profile-test", "probe"}, tool: "todo_profile_probe"},
|
||||
}
|
||||
|
||||
descriptors := make([]market.ServerDescriptor, 0, len(products))
|
||||
for _, product := range products {
|
||||
descriptors = append(descriptors, profileFlagProductDescriptor(product.name, product.tool))
|
||||
}
|
||||
|
||||
capture := &profileFlagRunner{}
|
||||
oldLoadDynamicCommands := loadDynamicCommandsFn
|
||||
loadDynamicCommandsFn = func(_ context.Context, _ executor.Runner) []*cobra.Command {
|
||||
SetDynamicServers(descriptors)
|
||||
return compat.BuildDynamicCommands(descriptors, capture, nil, nil)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
t.Cleanup(func() {
|
||||
loadDynamicCommandsFn = oldLoadDynamicCommands
|
||||
SetDynamicServers(nil)
|
||||
authpkg.SetRuntimeProfile("")
|
||||
ResetRuntimeTokenCache()
|
||||
})
|
||||
|
||||
for _, product := range products {
|
||||
t.Run(product.name, func(t *testing.T) {
|
||||
capture.reset()
|
||||
authpkg.SetRuntimeProfile("")
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
args := append([]string{"-f", "json"}, product.path...)
|
||||
args = append(args, "--profile", selectedProfile)
|
||||
cmd.SetArgs(args)
|
||||
|
||||
// Arrange / Act: execute a product command with root --profile after the leaf.
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
|
||||
}
|
||||
|
||||
// Assert: the product tool runs under the selected profile without leaking it as a business arg.
|
||||
call := capture.last()
|
||||
if call == nil {
|
||||
t.Fatal("expected product command to invoke runner")
|
||||
}
|
||||
if call.product != product.name {
|
||||
t.Fatalf("canonical product = %q, want %q", call.product, product.name)
|
||||
}
|
||||
if call.tool != product.tool {
|
||||
t.Fatalf("tool = %q, want %q", call.tool, product.tool)
|
||||
}
|
||||
if call.profile != selectedProfile {
|
||||
t.Fatalf("runtime profile at execution = %q, want %q", call.profile, selectedProfile)
|
||||
}
|
||||
if _, ok := call.params["profile"]; ok {
|
||||
t.Fatalf("--profile leaked into business params: %#v", call.params)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func profileFlagProductDescriptor(product, tool string) market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Key: product,
|
||||
DisplayName: product,
|
||||
Endpoint: "https://example.invalid/" + product,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: product,
|
||||
Command: product,
|
||||
Groups: map[string]market.CLIGroupDef{
|
||||
"profile-test": {Description: "profile-test"},
|
||||
},
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
tool: {
|
||||
CLIName: "probe",
|
||||
Group: "profile-test",
|
||||
Description: tool,
|
||||
RejectPositional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type profileFlagCall struct {
|
||||
product string
|
||||
tool string
|
||||
profile string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
type profileFlagRunner struct {
|
||||
mu sync.Mutex
|
||||
calls []profileFlagCall
|
||||
}
|
||||
|
||||
func (r *profileFlagRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
params := make(map[string]any, len(invocation.Params))
|
||||
for key, value := range invocation.Params {
|
||||
params[key] = value
|
||||
}
|
||||
r.calls = append(r.calls, profileFlagCall{
|
||||
product: invocation.CanonicalProduct,
|
||||
tool: invocation.Tool,
|
||||
profile: authpkg.RuntimeProfile(),
|
||||
params: params,
|
||||
})
|
||||
return executor.Result{Invocation: invocation}, nil
|
||||
}
|
||||
|
||||
func (r *profileFlagRunner) reset() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = nil
|
||||
}
|
||||
|
||||
func (r *profileFlagRunner) last() *profileFlagCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
call := r.calls[len(r.calls)-1]
|
||||
return &call
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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 (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
|
||||
// IPv4-forcing client used by the legacy registry / discovery path. The
|
||||
// custom Transport overrides DialContext to force IPv4 — without an
|
||||
// explicit Proxy field it would also drop env-var proxy support.
|
||||
//
|
||||
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
|
||||
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
|
||||
// tests that read proxy env early would make this flaky. Asserting that the
|
||||
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
|
||||
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
|
||||
// at process boot.
|
||||
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := ipv4HTTPClient(5 * time.Second)
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+258
-17
@@ -47,6 +47,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -66,6 +67,9 @@ func Execute() (exitCode int) {
|
||||
}
|
||||
}()
|
||||
|
||||
restoreArgs := normalizeProcessProfileArgs()
|
||||
defer restoreArgs()
|
||||
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
StopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
@@ -95,6 +99,7 @@ func Execute() (exitCode int) {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
if isUnknownCommandError(err) {
|
||||
executed.SetOut(os.Stderr)
|
||||
_ = executed.Help()
|
||||
@@ -113,15 +118,55 @@ func isUnknownCommandError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "unknown command")
|
||||
}
|
||||
|
||||
// rewordRequiredFlagError rewrites cobra's default missing-required-flag message
|
||||
// (`required flag(s) "email" not set`) into the wukong-aligned form
|
||||
// (`missing required flag(s): --email`). cobra's ValidateRequiredFlags returns
|
||||
// this error directly (it does not pass through FlagErrorFunc), so it is
|
||||
// normalised here. The substring "required flag" is preserved for compatibility
|
||||
// with existing assertions; flag names gain the "--" prefix and quotes are
|
||||
// dropped so error output matches hardcoded cmdutil.ValidateRequiredFlags.
|
||||
func rewordRequiredFlagError(err error) error {
|
||||
if err == nil {
|
||||
return err
|
||||
}
|
||||
const pfx = "required flag(s) "
|
||||
const sfx = " not set"
|
||||
msg := err.Error()
|
||||
if !strings.HasPrefix(msg, pfx) || !strings.HasSuffix(msg, sfx) {
|
||||
return err
|
||||
}
|
||||
mid := strings.TrimSuffix(strings.TrimPrefix(msg, pfx), sfx)
|
||||
var flags []string
|
||||
for _, part := range strings.Split(mid, ", ") {
|
||||
if name := strings.Trim(strings.TrimSpace(part), "\""); name != "" {
|
||||
flags = append(flags, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(flags) == 0 {
|
||||
return err
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf("missing required flag(s): %s", strings.Join(flags, ", ")))
|
||||
}
|
||||
|
||||
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
|
||||
//
|
||||
// 所有 flag 解析错误都会在 message 末尾追加 "See '<CommandPath> --help' for usage.",
|
||||
// 与 docker / kubectl / gh / wukong CLI 的 UX 一致,方便用户/agent 复制完整命令查 help。
|
||||
// 装在 root 的 FlagErrorFunc 通过 cobra 的 parent fallback 机制覆盖全命令树
|
||||
// (cobra.Command.FlagErrorFunc 沿 c.parent 递归向上查找)。
|
||||
func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
errMsg := err.Error()
|
||||
// 尾部 hint:换行 + See '...' for usage.
|
||||
// JSON 输出时 \n 会被序列化为字面 \n,文本输出时换行;
|
||||
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
|
||||
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
|
||||
msgWithTail := errMsg + tail
|
||||
|
||||
// Common flag aliases and suggestions
|
||||
suggestions := map[string]string{
|
||||
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
|
||||
"--method": "提示: dws auth login 默认使用 OAuth 设备流登录,无需指定 --method",
|
||||
"--device-flow": "提示: dws auth login 默认已使用设备流,无需 --device-flow 参数",
|
||||
"--method": "提示: dws auth login 默认使用 OAuth loopback 流;SSH/无头环境请加 --device 走设备流",
|
||||
"--device-flow": "提示: 设备流的标志名是 --device(不是 --device-flow),SSH/无头环境登录请用 dws auth login --device",
|
||||
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
|
||||
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
|
||||
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
|
||||
@@ -133,11 +178,35 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
|
||||
for flag, suggestion := range suggestions {
|
||||
if strings.Contains(errMsg, "unknown flag: "+flag) {
|
||||
return fmt.Errorf("%w\n%s", err, suggestion)
|
||||
return apperrors.NewValidation(
|
||||
msgWithTail,
|
||||
apperrors.WithHint(suggestion),
|
||||
apperrors.WithReason("unknown_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
if strings.Contains(errMsg, "unknown flag:") {
|
||||
fix := cmdutil.SuggestFlagFix(cmd, err)
|
||||
if fix.Suggestion != "" {
|
||||
return apperrors.NewValidation(
|
||||
msgWithTail,
|
||||
apperrors.WithHint(fix.Suggestion),
|
||||
apperrors.WithReason("unknown_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback:未命中已知别名 / SuggestFlagFix 未给建议的 flag 解析错误
|
||||
// (missing required / ambiguous / unknown shorthand 等),仍包尾部 hint,
|
||||
// 行为对齐 wukong / docker / kubectl。
|
||||
return fmt.Errorf("%s%s", errMsg, tail)
|
||||
}
|
||||
|
||||
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
|
||||
@@ -232,6 +301,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
rootCtx = context.Background()
|
||||
}
|
||||
flags := &GlobalFlags{}
|
||||
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
|
||||
loader := cli.EnvironmentLoader{
|
||||
LookupEnv: os.LookupEnv,
|
||||
CatalogBaseURLOverride: DiscoveryBaseURL(),
|
||||
@@ -245,6 +315,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
Short: "DWS CLI",
|
||||
Long: `提示: 如果遇到能力缺失、命令报错、新功能未注册、或无法完成任务, 请先用 'dws upgrade' 升级到最新版本后再试. 钉钉 OpenAPI 和 dws CLI 持续迭代, 新能力和 bugfix 会先在新版本上线.`,
|
||||
Args: cobra.NoArgs,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
@@ -254,6 +325,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
return cmd.Help()
|
||||
},
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
authpkg.SetRuntimeProfile(flags.Profile)
|
||||
// Apply OAuth credential overrides from CLI flags (highest priority).
|
||||
if flags.ClientID != "" {
|
||||
authpkg.SetClientID(flags.ClientID)
|
||||
@@ -287,12 +359,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
genSkillsCmd.Hidden = true
|
||||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||||
mcpCmd.Hidden = true
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(),
|
||||
newAuthCommand(patCaller),
|
||||
newProfileCommand(),
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
newCatalogCommand(loader),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newCompletionCommand(root),
|
||||
@@ -318,7 +393,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
}
|
||||
|
||||
// PAT authorization commands (open-source core)
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
pat.RegisterCommands(root, patCaller)
|
||||
|
||||
if fn := edition.Get().RegisterExtraCommands; fn != nil {
|
||||
@@ -336,8 +410,87 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
return root
|
||||
}
|
||||
|
||||
func newAuthCommand() *cobra.Command {
|
||||
return buildAuthCommand()
|
||||
func preparseProfileFlag(args []string) string {
|
||||
args, _ = normalizeProfileFlagArgs(args)
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--profile" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeProcessProfileArgs() func() {
|
||||
original := append([]string(nil), os.Args...)
|
||||
if len(os.Args) > 1 {
|
||||
if normalized, changed := normalizeProfileFlagArgs(os.Args[1:]); changed {
|
||||
os.Args = append([]string{os.Args[0]}, normalized...)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
os.Args = original
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProfileFlagArgs(args []string) ([]string, bool) {
|
||||
if len(args) == 0 {
|
||||
return args, false
|
||||
}
|
||||
out := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
trimmed := strings.TrimSpace(arg)
|
||||
switch {
|
||||
case trimmed == "--profile":
|
||||
out = append(out, arg)
|
||||
if i+1 >= len(args) {
|
||||
continue
|
||||
}
|
||||
value, next := collectProfileFlagValue(args[i+1], args, i+2)
|
||||
out = append(out, value)
|
||||
i = next - 1
|
||||
case strings.HasPrefix(trimmed, "--profile="):
|
||||
value, next := collectProfileFlagValue(strings.TrimPrefix(trimmed, "--profile="), args, i+1)
|
||||
out = append(out, "--profile="+value)
|
||||
i = next - 1
|
||||
default:
|
||||
out = append(out, arg)
|
||||
}
|
||||
}
|
||||
return out, argsChanged(args, out)
|
||||
}
|
||||
|
||||
func collectProfileFlagValue(first string, args []string, next int) (string, int) {
|
||||
parts := []string{strings.TrimSpace(first)}
|
||||
for len(parts) > 0 && strings.HasSuffix(strings.TrimSpace(parts[len(parts)-1]), ",") && next < len(args) {
|
||||
candidate := strings.TrimSpace(args[next])
|
||||
if candidate == "" || strings.HasPrefix(candidate, "-") {
|
||||
break
|
||||
}
|
||||
parts = append(parts, candidate)
|
||||
next++
|
||||
}
|
||||
return strings.Join(parts, ""), next
|
||||
}
|
||||
|
||||
func argsChanged(before, after []string) bool {
|
||||
if len(before) != len(after) {
|
||||
return true
|
||||
}
|
||||
for i := range before {
|
||||
if before[i] != after[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
return buildAuthCommand(patCaller)
|
||||
}
|
||||
|
||||
func newSkillCommand() *cobra.Command {
|
||||
@@ -438,7 +591,7 @@ func newCacheCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err))
|
||||
}
|
||||
servers := market.NormalizeServers(resp, "live_market")
|
||||
servers := market.NormalizeServersForBaseURL(resp, "live_market", registryDiscoveryBaseURL())
|
||||
_ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
selected := selectServersForProduct(servers, product)
|
||||
@@ -560,7 +713,7 @@ func newVersionCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
|
||||
return cli.NewSchemaCommand(loader)
|
||||
return cli.NewSchemaCommand(loader, newHelperToolFetcher())
|
||||
}
|
||||
|
||||
func newGenerateSkillsCommand() *cobra.Command {
|
||||
@@ -646,8 +799,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
|
||||
@@ -666,6 +887,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
"completion": true,
|
||||
"skill": true,
|
||||
"plugin": true,
|
||||
"profile": true,
|
||||
"version": true,
|
||||
"help": true,
|
||||
"recovery": true,
|
||||
@@ -692,7 +914,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
// by a malicious or misconfigured plugin.
|
||||
var reservedCommands = map[string]bool{
|
||||
"auth": true, "api": true, "login": true, "logout": true,
|
||||
"plugin": true, "skill": true, "cache": true,
|
||||
"plugin": true, "profile": true, "skill": true, "cache": true,
|
||||
"config": true, "doctor": true, "completion": true,
|
||||
"recovery": true, "upgrade": true, "version": true,
|
||||
"schema": true, "mcp": true, "help": true,
|
||||
@@ -1367,8 +1589,10 @@ func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.T
|
||||
}
|
||||
}
|
||||
|
||||
// nil existingTools: single-server overlay built from a live tool list, so
|
||||
// no phantom-leaf guard is needed (see BuildDynamicCommands doc).
|
||||
return compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{srv}, runner, detailsByID)
|
||||
[]market.ServerDescriptor{srv}, runner, detailsByID, nil)
|
||||
}
|
||||
|
||||
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
|
||||
@@ -1472,22 +1696,38 @@ func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner e
|
||||
}
|
||||
|
||||
// discoverStdioTools performs the blocking Initialize + ListTools handshake
|
||||
// on a stdio MCP subprocess. Returns nil on any error (logged at Warn level).
|
||||
// on a stdio MCP subprocess. Returns nil on any error (logged at Debug level).
|
||||
// The default 2s budget comfortably accommodates Python/Node runtimes whose
|
||||
// interpreter + dependency load dominates the first response. Operators with
|
||||
// heavier startup chains can relax further via DWS_PLUGIN_COLD_TIMEOUT.
|
||||
//
|
||||
// A handshake failure here is an EXPECTED, benign outcome for an optional local
|
||||
// plugin: e.g. the conference plugin reports "本地服务未就绪" whenever the
|
||||
// DingTalk desktop client isn't running, which is the common case for anyone
|
||||
// not actively recording a meeting. Discovery simply yields no tools and the
|
||||
// run proceeds — commands that ship toolOverrides still register up-front via
|
||||
// registerStdioServerFromOverlay (Phase A), so availability is unaffected.
|
||||
//
|
||||
// These run during command-tree construction (NewRootCommandWithEngine), which
|
||||
// happens BEFORE PersistentPreRunE applies --debug/--verbose via
|
||||
// configureLogLevel. So a Warn here printed to stderr on EVERY invocation
|
||||
// regardless of flags, polluting output and misleading callers into treating it
|
||||
// as the cause of an unrelated command error (e.g. an auth or PARAM_ERROR from a
|
||||
// completely different server). Logging at Debug keeps the discovery miss out of
|
||||
// normal output; surfacing it would require configuring the log level before the
|
||||
// tree is built, which we deliberately avoid this close to release.
|
||||
func discoverStdioTools(p *plugin.Plugin, sc plugin.StdioServerClient, timeouts pluginColdTimeouts) []transport.ToolDescriptor {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeouts.stdio)
|
||||
defer cancel()
|
||||
|
||||
if _, err := sc.Client.Initialize(ctx); err != nil {
|
||||
slog.Warn("plugin: stdio initialize failed",
|
||||
slog.Debug("plugin: stdio initialize failed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
toolsResult, err := sc.Client.ListTools(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("plugin: stdio ListTools failed",
|
||||
slog.Debug("plugin: stdio ListTools failed",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
|
||||
return nil
|
||||
}
|
||||
@@ -1539,8 +1779,9 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
|
||||
|
||||
detailsByID := toolsToDetails(tools, overlay.ID)
|
||||
// nil existingTools: overlay built from this plugin's live tool list.
|
||||
cmds := compat.BuildDynamicCommands(
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
|
||||
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
|
||||
|
||||
slog.Debug("plugin: stdio server registered",
|
||||
"plugin", p.Manifest.Name, "server", sc.Key,
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/cli/discovery/apis":
|
||||
case "/cli/discovery/apis/cedar":
|
||||
_ = json.NewEncoder(w).Encode(market.ListResponse{
|
||||
Metadata: market.ListMetadata{Count: 2},
|
||||
Servers: []market.ServerEnvelope{
|
||||
@@ -146,7 +146,7 @@ func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/cli/discovery/apis":
|
||||
case "/cli/discovery/apis/cedar":
|
||||
marketHits.Add(1)
|
||||
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
|
||||
default:
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
if !strings.Contains(out.String(), "Discovered MCP Services:") {
|
||||
t.Fatalf("root help output missing MCP summary:\n%s", out.String())
|
||||
}
|
||||
for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} {
|
||||
for _, want := range []string{"Utility Commands:", "skill", "auth", "profile", "version", "Global Flags:", "--profile"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("root help output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
@@ -351,8 +351,8 @@ func TestNestedShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(devdoc article search -h) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "devdoc/search") {
|
||||
t.Fatalf("nested short help output missing command title:\n%s", out.String())
|
||||
if !strings.Contains(out.String(), "搜索开放平台文档") || !strings.Contains(out.String(), "dws devdoc article search") {
|
||||
t.Fatalf("nested short help output missing command help:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func configureRootHelp(root *cobra.Command) {
|
||||
@@ -50,38 +52,111 @@ func renderRootHelp(root *cobra.Command) {
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
w := root.OutOrStdout()
|
||||
|
||||
_, _ = fmt.Fprintln(w, tui.Header("Workspace CLI", "DingTalk blue-white technical console"))
|
||||
_, _ = fmt.Fprintln(w, tui.Rule(76))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
if len(services) == 0 {
|
||||
_, _ = fmt.Fprintln(w, "No MCP services discovered.")
|
||||
_, _ = fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Warning("No MCP services discovered."))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
} else {
|
||||
_, _ = fmt.Fprintln(w, "Discovered MCP Services:")
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Discovered MCP Services:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, service := range services {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", service.Name(), strings.TrimSpace(service.Short))
|
||||
_, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.StateMark("ok"), tui.Bold(service.Name()), tui.Dim(strings.TrimSpace(service.Short)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(w, "Usage:")
|
||||
_, _ = fmt.Fprintln(w, " dws <service> [command] [flags]")
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Usage:"))
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <service> [command] [flags]"))
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, " dws <command> [flags]")
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <command> [flags]"))
|
||||
}
|
||||
_, _ = fmt.Fprintln(w)
|
||||
if len(utilities) > 0 {
|
||||
_, _ = fmt.Fprintln(w, "Utility Commands:")
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Utility Commands:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, utility := range utilities {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", utility.Name(), strings.TrimSpace(utility.Short))
|
||||
_, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.Bullet(), tui.Bold(utility.Name()), tui.Dim(commandShort(utility)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
renderRootGlobalFlags(root)
|
||||
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
|
||||
|
||||
// Render root.Long after the command list so agents see the upgrade
|
||||
// hint (or any other root-level guidance) after browsing all available
|
||||
// commands and concluding none of them fit. Cobra's default help template
|
||||
// would render Long automatically; the custom SetHelpFunc above replaces
|
||||
// it and dropped this, so we restore it explicitly here.
|
||||
if long := strings.TrimSpace(root.Long); long != "" {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, tui.Dim(long))
|
||||
}
|
||||
}
|
||||
|
||||
func renderRootGlobalFlags(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
flags := visiblePersistentFlags(root)
|
||||
if len(flags) == 0 {
|
||||
return
|
||||
}
|
||||
w := root.OutOrStdout()
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Global Flags:"))
|
||||
_, _ = fmt.Fprintln(w)
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
for _, flag := range flags {
|
||||
_, _ = fmt.Fprintf(tw, " %s\t%s\n", formatRootFlag(flag), tui.Dim(strings.TrimSpace(flag.Usage)))
|
||||
}
|
||||
_ = tw.Flush()
|
||||
_, _ = fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
func visiblePersistentFlags(root *cobra.Command) []*pflag.Flag {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
flags := make([]*pflag.Flag, 0)
|
||||
root.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
|
||||
if flag == nil || flag.Hidden {
|
||||
return
|
||||
}
|
||||
flags = append(flags, flag)
|
||||
})
|
||||
return flags
|
||||
}
|
||||
|
||||
func formatRootFlag(flag *pflag.Flag) string {
|
||||
if flag == nil {
|
||||
return ""
|
||||
}
|
||||
name := "--" + flag.Name
|
||||
if flag.Value != nil && flag.Value.Type() != "bool" {
|
||||
name += " " + flag.Value.Type()
|
||||
}
|
||||
if flag.Shorthand == "" {
|
||||
return " " + name
|
||||
}
|
||||
return "-" + flag.Shorthand + ", " + name
|
||||
}
|
||||
|
||||
func commandShort(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
}
|
||||
short := strings.TrimSpace(cmd.Short)
|
||||
if cmd.Name() == "help" && short == "Help about any command" {
|
||||
return i18n.T("查看任意命令的帮助信息")
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
// resolveVisibleProducts returns the set of top-level product IDs that should
|
||||
|
||||
+309
-26
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -88,6 +89,8 @@ const (
|
||||
envDingtalkTraceID = "DINGTALK_TRACE_ID"
|
||||
envDingtalkSessionID = "DINGTALK_SESSION_ID"
|
||||
envDingtalkMessageID = "DINGTALK_MESSAGE_ID"
|
||||
envDWSSessionID = "DWS_SESSION_ID"
|
||||
envRewindSessionID = "REWIND_SESSION_ID"
|
||||
|
||||
// Environment variables for third-party channel integration
|
||||
envDWSChannel = "DWS_CHANNEL"
|
||||
@@ -109,7 +112,7 @@ func logHostOwnedPATDecisionOnce() {
|
||||
hostOwnedPATDecisionOnce.Do(func() {
|
||||
slog.Debug("runtime.host_owned_pat",
|
||||
"hostOwned", authpkg.HostOwnsPATFlow(),
|
||||
"agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
"agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -159,9 +162,22 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
// invocations within the same process free.
|
||||
logHostOwnedPATDecisionOnce()
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if multi {
|
||||
return r.runMultiProfile(ctx, invocation, selections)
|
||||
}
|
||||
|
||||
return r.runSingle(ctx, invocation, true)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
r.transport.ExtraHeaders = resolveIdentityHeaders()
|
||||
|
||||
// Mock mode: skip catalog validation, use a placeholder endpoint.
|
||||
if r.globalFlags != nil && r.globalFlags.Mock {
|
||||
@@ -175,7 +191,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
// Prefetch the Keychain token in the background. Keychain access costs
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
go getCachedRuntimeToken(ctx)
|
||||
if prefetchToken {
|
||||
go getCachedRuntimeToken(ctx)
|
||||
}
|
||||
|
||||
if shouldUseDirectRuntime(invocation) {
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
@@ -219,9 +237,160 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
|
||||
endpoint = override
|
||||
}
|
||||
// Multi-server tool-name authority correction.
|
||||
//
|
||||
// When two envelope servers share the same cli.command (e.g. group-chat
|
||||
// and im both publish `dws chat ...`), the endpoints[cmd] map in
|
||||
// registerDynamicServer is the second-writer wins, and catalog FindProduct
|
||||
// may pick the wrong product's Endpoint for a tool whose real owner is
|
||||
// a different server. Cross-check the canonical tool→endpoint map: when
|
||||
// the per-tool endpoint exists and differs from the per-product endpoint
|
||||
// catalog returned, trust the tool-owner endpoint (the server that
|
||||
// actually declares this tool in its toolOverrides).
|
||||
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
|
||||
endpoint = toolEndpoint
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
type multiProfileSelection struct {
|
||||
Selector string
|
||||
Profile authpkg.Profile
|
||||
}
|
||||
|
||||
func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) {
|
||||
rawSelector = strings.TrimSpace(rawSelector)
|
||||
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
|
||||
return nil, false, nil
|
||||
}
|
||||
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(rawSelector, ",")
|
||||
selections := make([]multiProfileSelection, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
selector := strings.TrimSpace(part)
|
||||
if selector == "" {
|
||||
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
|
||||
}
|
||||
profile, err := authpkg.ResolveProfile(configDir, selector)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, false, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
if seen[profile.CorpID] {
|
||||
continue
|
||||
}
|
||||
seen[profile.CorpID] = true
|
||||
selections = append(selections, multiProfileSelection{
|
||||
Selector: selector,
|
||||
Profile: *profile,
|
||||
})
|
||||
}
|
||||
if len(selections) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return selections, true, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) {
|
||||
previousProfile := authpkg.RuntimeProfile()
|
||||
defer authpkg.SetRuntimeProfile(previousProfile)
|
||||
|
||||
entries := make([]any, 0, len(selections))
|
||||
succeeded := 0
|
||||
failed := 0
|
||||
|
||||
for _, selection := range selections {
|
||||
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
|
||||
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
||||
|
||||
entry := map[string]any{
|
||||
"selector": selection.Selector,
|
||||
"corpId": selection.Profile.CorpID,
|
||||
"corpName": selection.Profile.CorpName,
|
||||
"ok": err == nil,
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
entry["error"] = multiProfileErrorPayload(err)
|
||||
} else {
|
||||
succeeded++
|
||||
if payload := multiProfileResultPayload(result); payload != nil {
|
||||
entry["result"] = payload
|
||||
}
|
||||
if result.Response != nil {
|
||||
if endpoint, ok := result.Response["endpoint"]; ok {
|
||||
entry["endpoint"] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": map[string]any{
|
||||
"success": failed == 0,
|
||||
"multiProfile": true,
|
||||
"summary": map[string]any{
|
||||
"total": len(selections),
|
||||
"succeeded": succeeded,
|
||||
"failed": failed,
|
||||
},
|
||||
"profiles": entries,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cloneInvocation(invocation executor.Invocation) executor.Invocation {
|
||||
cloned := invocation
|
||||
if invocation.Params != nil {
|
||||
cloned.Params = make(map[string]any, len(invocation.Params))
|
||||
for key, value := range invocation.Params {
|
||||
cloned.Params[key] = value
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func multiProfileResultPayload(result executor.Result) any {
|
||||
if result.Response == nil {
|
||||
return nil
|
||||
}
|
||||
if content, ok := result.Response["content"]; ok {
|
||||
return content
|
||||
}
|
||||
return result.Response
|
||||
}
|
||||
|
||||
func multiProfileErrorPayload(err error) map[string]any {
|
||||
payload := map[string]any{
|
||||
"message": err.Error(),
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
payload["category"] = string(typed.Category)
|
||||
if typed.Reason != "" {
|
||||
payload["reason"] = typed.Reason
|
||||
}
|
||||
if typed.Operation != "" {
|
||||
payload["operation"] = typed.Operation
|
||||
}
|
||||
if code := typed.ExitCode(); code != 0 {
|
||||
payload["exitCode"] = code
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// handleCatalogMiss decides what to do when discovery catalog does not cover the
|
||||
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
|
||||
//
|
||||
@@ -243,13 +412,22 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
|
||||
invocation.DryRun = true
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
hint := "产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"
|
||||
actions := []string{"dws cache refresh"}
|
||||
if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID {
|
||||
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖 discovery;真实调用需要内部版通过 SupplementServers/StaticServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
|
||||
actions = []string{
|
||||
"检查内部版 SupplementServers/StaticServers 是否包含 devapp endpoint",
|
||||
"本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试",
|
||||
}
|
||||
}
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail),
|
||||
apperrors.WithOperation("discovery.resolve"),
|
||||
apperrors.WithReason("endpoint_not_resolved"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。"),
|
||||
apperrors.WithActions("dws cache refresh"),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(actions...),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -308,6 +486,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
|
||||
|
||||
if invocation.DryRun {
|
||||
// Emit a wukong-aligned human-readable preview on stderr so the dry-run
|
||||
// surface advertises the resolved MCP arguments without polluting the
|
||||
// stdout payload (which stays valid JSON in --format json mode). Mirrors
|
||||
// wukong's "Arguments: {...}" dry-run line; stderr keeps it out of the
|
||||
// machine-readable channel.
|
||||
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
|
||||
}
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
@@ -364,6 +550,17 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil {
|
||||
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
callStart := time.Now()
|
||||
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
||||
@@ -456,6 +653,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
// Align with wukong's response envelope: stamp a top-level success=true on
|
||||
// map payloads that don't already carry a success flag. Business errors
|
||||
// (success=false) are intercepted above, so reaching here means the call
|
||||
// succeeded. Additive only — existing keys are never overwritten.
|
||||
if callResult.Content != nil {
|
||||
if _, has := callResult.Content["success"]; !has {
|
||||
callResult.Content["success"] = true
|
||||
}
|
||||
}
|
||||
response := map[string]any{
|
||||
"endpoint": transport.RedactURL(endpoint),
|
||||
"content": callResult.Content,
|
||||
@@ -550,28 +756,40 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
|
||||
|
||||
// Cached token state for process lifetime
|
||||
var (
|
||||
cachedRuntimeToken string
|
||||
cachedRuntimeTokenOnce sync.Once
|
||||
cachedRuntimeTokenMu sync.Mutex
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
)
|
||||
|
||||
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
|
||||
// This avoids repeated Keychain access which takes ~70ms each time.
|
||||
func getCachedRuntimeToken(ctx context.Context) string {
|
||||
cachedRuntimeTokenOnce.Do(func() {
|
||||
loadStart := time.Now()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
|
||||
if cacheKey == "" {
|
||||
cacheKey = "__default__"
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
if token := cachedRuntimeTokens[cacheKey]; token != "" {
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return
|
||||
}
|
||||
if token != "" {
|
||||
cachedRuntimeToken = token
|
||||
}
|
||||
})
|
||||
return cachedRuntimeToken
|
||||
loadStart := time.Now()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||
slog.Error(tokenErr.Error())
|
||||
return ""
|
||||
}
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
cachedRuntimeTokens[cacheKey] = token
|
||||
cachedRuntimeTokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
|
||||
// generateExecutionID returns a random 16-char hex string used to correlate
|
||||
@@ -586,8 +804,9 @@ func generateExecutionID() string {
|
||||
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
|
||||
// This should be called after login/logout operations.
|
||||
func ResetRuntimeTokenCache() {
|
||||
cachedRuntimeTokenOnce = sync.Once{}
|
||||
cachedRuntimeToken = ""
|
||||
cachedRuntimeTokenMu.Lock()
|
||||
defer cachedRuntimeTokenMu.Unlock()
|
||||
cachedRuntimeTokens = map[string]string{}
|
||||
}
|
||||
|
||||
func newRuntimeContentScanner() safety.Scanner {
|
||||
@@ -653,11 +872,40 @@ func resolveIdentityHeaders() map[string]string {
|
||||
// open-source edition pins to edition.DefaultOSSClawType via the
|
||||
// MergeHeaders hook below) and it does NOT influence the host-owned
|
||||
// PAT decision (driven solely by DINGTALK_DWS_AGENTCODE).
|
||||
sessionID := os.Getenv(envDingtalkSessionID)
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv(envDWSSessionID)
|
||||
}
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv(envRewindSessionID)
|
||||
}
|
||||
// Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the
|
||||
// per-(machine × agent_code) instance id when a code is known. Synthetic
|
||||
// fallbacks must not be sent because PAT authorization checks use the same
|
||||
// header as their grant key.
|
||||
//
|
||||
// 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,
|
||||
// sent only when x-dingtalk-dws-agent-code is non-empty.
|
||||
// Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched.
|
||||
agentCode, agentCodeSig := authpkg.DetectAgentCode()
|
||||
if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" {
|
||||
headers["x-dws-agent-instance-id"] = agentInstanceID
|
||||
}
|
||||
|
||||
// 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": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
|
||||
"x-dingtalk-dws-agent-code": agentCode,
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": os.Getenv(envDingtalkSessionID),
|
||||
"x-dingtalk-session-id": sessionID,
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
}
|
||||
for k, v := range envHeaders {
|
||||
@@ -675,6 +923,9 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if fn := edition.Get().MergeHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil {
|
||||
headers = fn(headers)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
@@ -682,13 +933,23 @@ func resolveIdentityHeaders() map[string]string {
|
||||
// errors (success=false + errorCode/errorMsg) that are not flagged at the MCP
|
||||
// protocol level. Returns the error message, or "" if the response is OK.
|
||||
func detectBusinessError(content map[string]any) string {
|
||||
return detectBusinessErrorAtDepth(content, 0)
|
||||
}
|
||||
|
||||
func detectBusinessErrorAtDepth(content map[string]any, depth int) string {
|
||||
if content == nil || depth > 8 {
|
||||
return ""
|
||||
}
|
||||
success, ok := content["success"]
|
||||
if !ok {
|
||||
return ""
|
||||
return detectNestedBusinessError(content, depth)
|
||||
}
|
||||
b, ok := success.(bool)
|
||||
if !ok || b {
|
||||
return ""
|
||||
return detectNestedBusinessError(content, depth)
|
||||
}
|
||||
if nested := detectNestedBusinessError(content, depth); nested != "" {
|
||||
return nested
|
||||
}
|
||||
if msg, ok := content["errorMsg"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return strings.TrimSpace(msg)
|
||||
@@ -699,6 +960,28 @@ func detectBusinessError(content map[string]any) string {
|
||||
return "business error: success=false"
|
||||
}
|
||||
|
||||
func detectNestedBusinessError(content map[string]any, depth int) string {
|
||||
for _, key := range []string{"content", "result", "data"} {
|
||||
switch child := content[key].(type) {
|
||||
case map[string]any:
|
||||
if msg := detectBusinessErrorAtDepth(child, depth+1); msg != "" {
|
||||
return msg
|
||||
}
|
||||
case []any:
|
||||
for _, item := range child {
|
||||
childMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if msg := detectBusinessErrorAtDepth(childMap, depth+1); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractMCPErrorMessage builds an error message from a ToolCallResult with
|
||||
// isError=true. It extracts text from content blocks when available.
|
||||
func extractMCPErrorMessage(result transport.ToolCallResult) string {
|
||||
|
||||
+314
-3
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -27,7 +28,10 @@ import (
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
|
||||
)
|
||||
@@ -316,11 +320,274 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
|
||||
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
|
||||
t.Setenv(authpkg.AgentCodeEnv, " QoderWork ")
|
||||
t.Setenv(authpkg.AgentCodeEnvCompat, "")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "QoderWork" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want QoderWork", got)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
// The reversed env name must never be consumed. With no canonical
|
||||
// declaration and no host signature, agent_code stays empty rather than
|
||||
// falling back to a synthetic key.
|
||||
if got, ok := headers["x-dingtalk-dws-agent-code"]; ok {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want header omitted", got)
|
||||
}
|
||||
if got, ok := headers["x-dws-agent-instance-id"]; ok {
|
||||
t.Fatalf("x-dws-agent-instance-id = %q, want header omitted when agent_code is empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(envDingtalkSessionID, "ding-session")
|
||||
t.Setenv(envDWSSessionID, "dws-session")
|
||||
t.Setenv(envRewindSessionID, "rewind-session")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-session-id"]; got != "ding-session" {
|
||||
t.Fatalf("x-dingtalk-session-id = %q, want DINGTALK_SESSION_ID", got)
|
||||
}
|
||||
|
||||
t.Setenv(envDingtalkSessionID, "")
|
||||
headers = resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-session-id"]; got != "dws-session" {
|
||||
t.Fatalf("x-dingtalk-session-id = %q, want DWS_SESSION_ID", got)
|
||||
}
|
||||
|
||||
t.Setenv(envDWSSessionID, "")
|
||||
headers = resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-session-id"]; got != "rewind-session" {
|
||||
t.Fatalf("x-dingtalk-session-id = %q, want REWIND_SESSION_ID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightRejectsAXLSBeforeDownloadPAT(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"contentType": "ALIDOC",
|
||||
"extension": "axls",
|
||||
"nodeType": "file",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
t.Fatalf("download_file should not be called for axls")
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "axls-node"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("executeInvocation() error = nil, want axls rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "extension=axls") {
|
||||
t.Fatalf("executeInvocation() error = %v, want extension=axls guidance", err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error category = %q, want validation", typed.Category)
|
||||
}
|
||||
if typed.Reason != "unsupported_alidoc_extension" {
|
||||
t.Fatalf("error reason = %q, want unsupported_alidoc_extension", typed.Reason)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
|
||||
t.Fatalf("tool calls = %q, want only %s", got, docGetDocumentInfoTool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightAllowsNonAXLSDownload(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"contentType": "DRIVE",
|
||||
"extension": "xlsx",
|
||||
"nodeType": "file",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"resourceUrl": []any{"https://example.invalid/file.xlsx"},
|
||||
}, false)
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
result, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "xlsx-node"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executeInvocation() error = %v", err)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool+","+docDownloadFileTool {
|
||||
t.Fatalf("tool calls = %q, want preflight then download", got)
|
||||
}
|
||||
content, ok := result.Response["content"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("response.content = %#v, want map", result.Response["content"])
|
||||
}
|
||||
if _, ok := content["resourceUrl"]; !ok {
|
||||
t.Fatalf("response.content.resourceUrl missing: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightPATAuthorizationUsesExistingHandler(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
originalOpenBrowser := openBrowserFunc
|
||||
var openedURI string
|
||||
openBrowserFunc = func(uri string) error {
|
||||
openedURI = uri
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = originalOpenBrowser })
|
||||
|
||||
const authURI = "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-1%26userCode%3DCODE#/personalAuthorization?flowId=flow-1&userCode=CODE"
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"flowId": "flow-1",
|
||||
"uri": authURI,
|
||||
"clientId": "client-1",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
t.Fatalf("download_file should not be called before preflight PAT authorization")
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
runner.globalFlags.Format = "json"
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "pat-node"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("executeInvocation() error = nil, want PAT error")
|
||||
}
|
||||
var patErr *apperrors.PATError
|
||||
if !errors.As(err, &patErr) {
|
||||
t.Fatalf("executeInvocation() error = %T, want *errors.PATError", err)
|
||||
}
|
||||
if openedURI != authURI {
|
||||
t.Fatalf("opened URI = %q, want %q", openedURI, authURI)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
|
||||
t.Fatalf("tool calls = %q, want only %s before PAT authorization", got, docGetDocumentInfoTool)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,6 +925,50 @@ func contentScanServer() *mockmcp.Server {
|
||||
return mockmcp.MustNewServer(fixture)
|
||||
}
|
||||
|
||||
func runtimeRunnerForHTTPTest(server *httptest.Server) *runtimeRunner {
|
||||
client := transport.NewClient(server.Client())
|
||||
client.Stderr = &bytes.Buffer{}
|
||||
return &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "test-token", Timeout: 30},
|
||||
}
|
||||
}
|
||||
|
||||
func jsonRPCToolName(req map[string]any) string {
|
||||
params, _ := req["params"].(map[string]any)
|
||||
if params == nil {
|
||||
return ""
|
||||
}
|
||||
name, _ := params["name"].(string)
|
||||
return name
|
||||
}
|
||||
|
||||
func TestDetectBusinessErrorNestedServiceResult(t *testing.T) {
|
||||
content := map[string]any{
|
||||
"success": false,
|
||||
"result": map[string]any{
|
||||
"success": false,
|
||||
"errorCode": "ROBOT_NOT_FOUND",
|
||||
"errorMsg": "robot info is not exist",
|
||||
},
|
||||
}
|
||||
if got := detectBusinessError(content); got != "robot info is not exist" {
|
||||
t.Fatalf("detectBusinessError() = %q, want nested errorMsg", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONRPCToolResult(t *testing.T, w http.ResponseWriter, req map[string]any, content map[string]any, isError bool) {
|
||||
t.Helper()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"content": content,
|
||||
"isError": isError,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestClassifyToolResultHookPreemptsBusinessError(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -85,24 +86,81 @@ type CliSkillDTO struct {
|
||||
|
||||
// agentSkillPaths maps target names to their relative skill installation paths.
|
||||
// These paths are relative to the user's home directory.
|
||||
//
|
||||
// Source of truth for both `dws skill install <skillId> <target>` and
|
||||
// `dws skill setup --target <name>`. Every entry in skillSetupAgentHomes
|
||||
// (skill_setup.go) MUST have a matching path value here — enforced by
|
||||
// TestAgentSkillPathsCoversSetupHomes.
|
||||
var agentSkillPaths = map[string]string{
|
||||
"qoder": ".qoder/skills",
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
// `agents` is the generic-agent sentinel: install scripts and `setup`
|
||||
// special-case ~/.agents/skills as a no-checks-required fallback so a
|
||||
// fresh machine without any IDE/agent registry still gets skills.
|
||||
"agents": ".agents/skills",
|
||||
"qoder": ".qoder/skills",
|
||||
"qoderwork": ".qoderwork/skills",
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
// IDE / agent registries also probed by `dws skill setup --target all`.
|
||||
"gemini": ".gemini/skills",
|
||||
"github": ".github/skills",
|
||||
"windsurf": ".windsurf/skills",
|
||||
"augment": ".augment/skills",
|
||||
"cline": ".cline/skills",
|
||||
"amp": ".amp/skills",
|
||||
"kiro": ".kiro/skills",
|
||||
"trae": ".trae/skills",
|
||||
"openclaw": ".openclaw/skills",
|
||||
"hermes": ".hermes/skills",
|
||||
}
|
||||
|
||||
// supportedTargets returns a comma-separated list of supported targets.
|
||||
// supportedTargets returns a sorted, comma-separated list of supported
|
||||
// targets. Sorted so help text and error messages stay stable across runs
|
||||
// (Go map iteration order is intentionally randomized).
|
||||
func supportedTargets() string {
|
||||
targets := make([]string, 0, len(agentSkillPaths)+1)
|
||||
for target := range agentSkillPaths {
|
||||
targets = append(targets, target)
|
||||
}
|
||||
sort.Strings(targets)
|
||||
targets = append(targets, ".")
|
||||
return strings.Join(targets, ", ")
|
||||
}
|
||||
|
||||
// longestAgentTargetName returns the character count of the longest target
|
||||
// name in agentSkillPaths. Used by --help formatting to keep the "." entry
|
||||
// vertically aligned with named targets.
|
||||
func longestAgentTargetName() int {
|
||||
n := 0
|
||||
for name := range agentSkillPaths {
|
||||
if len(name) > n {
|
||||
n = len(name)
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// formatAgentSkillPathsForHelp renders agentSkillPaths as an aligned
|
||||
// " <name> -> ~/<path>" block, sorted by name, for use in --help output.
|
||||
// Keeps `dws skill install --help` in sync with the map without hand-edits.
|
||||
func formatAgentSkillPathsForHelp() string {
|
||||
names := make([]string, 0, len(agentSkillPaths))
|
||||
maxWidth := 0
|
||||
for n := range agentSkillPaths {
|
||||
names = append(names, n)
|
||||
if len(n) > maxWidth {
|
||||
maxWidth = len(n)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
for _, n := range names {
|
||||
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildSkillCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "skill",
|
||||
@@ -122,6 +180,7 @@ func buildSkillCommand() *cobra.Command {
|
||||
newSkillSearchCommand(),
|
||||
newSkillFindHintCommand(),
|
||||
newSkillAddHintCommand(),
|
||||
newSkillSetupCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -179,17 +238,15 @@ func newSkillInstallCommand() *cobra.Command {
|
||||
target 安装目标(必填),支持: %s
|
||||
|
||||
安装路径:
|
||||
qoder -> ~/.qoder/skills/
|
||||
claude -> ~/.claude/skills/
|
||||
cursor -> ~/.cursor/skills/
|
||||
codex -> ~/.codex/skills/
|
||||
opencode -> ~/.config/opencode/skills/
|
||||
. -> 当前目录
|
||||
%s .%s -> 当前目录
|
||||
|
||||
示例:
|
||||
dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
|
||||
dws skill install skill-123 claude # 安装到 ~/.claude/skills/
|
||||
dws skill install skill-123 . # 安装到当前目录`, supportedTargets()),
|
||||
dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
|
||||
dws skill install skill-123 . # 安装到当前目录`,
|
||||
supportedTargets(),
|
||||
formatAgentSkillPathsForHelp(),
|
||||
strings.Repeat(" ", longestAgentTargetName()-1)),
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillAdd,
|
||||
|
||||
@@ -452,8 +452,14 @@ func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) {
|
||||
func TestSupportedTargets(t *testing.T) {
|
||||
targets := supportedTargets()
|
||||
|
||||
// Should contain all predefined targets
|
||||
expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."}
|
||||
// Should contain all predefined targets — including the agents/* sentinel
|
||||
// and the IDE/agent registries we share with skillSetupAgentHomes.
|
||||
expectedTargets := []string{
|
||||
"agents", "claude", "cursor", "codex", "opencode", "qoder",
|
||||
"gemini", "github", "windsurf", "augment", "cline",
|
||||
"amp", "kiro", "trae", "openclaw", "hermes",
|
||||
".",
|
||||
}
|
||||
for _, expected := range expectedTargets {
|
||||
if !strings.Contains(targets, expected) {
|
||||
t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets)
|
||||
@@ -461,6 +467,27 @@ func TestSupportedTargets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentSkillPathsCoversSetupHomes guards against drift between
|
||||
// agentSkillPaths (used by `dws skill install` and `dws skill setup
|
||||
// --target <name>`) and skillSetupAgentHomes (used by `dws skill setup
|
||||
// --target all` to detect candidate agent homes).
|
||||
//
|
||||
// Every path in skillSetupAgentHomes MUST be reachable via at least one
|
||||
// entry in agentSkillPaths — otherwise `--target all` would silently
|
||||
// install into agent homes that the user cannot address by name.
|
||||
func TestAgentSkillPathsCoversSetupHomes(t *testing.T) {
|
||||
paths := make(map[string]bool, len(agentSkillPaths))
|
||||
for _, p := range agentSkillPaths {
|
||||
paths[p] = true
|
||||
}
|
||||
for _, home := range skillSetupAgentHomes {
|
||||
if !paths[home] {
|
||||
t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+
|
||||
"add it to agentSkillPaths so users can address it via --target <name>", home)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSkillPathsCrossPlatform(t *testing.T) {
|
||||
// Verify that paths use platform-appropriate separators
|
||||
for target, path := range agentSkillPaths {
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// skillSetupAgentHomes is the ordered list of agent home subdirectories
|
||||
// where dws skills get installed. Mirrors install.sh / install.ps1 /
|
||||
// build/npm/install.js so that `dws skill setup` and the install scripts
|
||||
// agree on the install footprint.
|
||||
var skillSetupAgentHomes = []string{
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
".windsurf/skills",
|
||||
".augment/skills",
|
||||
".cline/skills",
|
||||
".amp/skills",
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
".hermes/skills",
|
||||
}
|
||||
|
||||
const (
|
||||
skillSetupModeMono = "mono"
|
||||
skillSetupModeMulti = "multi"
|
||||
)
|
||||
|
||||
func newSkillSetupCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "安装 dws 自身 skill 到 Agent 目录",
|
||||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||||
|
||||
支持两种模式:
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
||||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
||||
生产前请评估,问题请提 issue 反馈
|
||||
|
||||
multi 模式支持按产品挑选:
|
||||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||||
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
|
||||
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
|
||||
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
|
||||
Example: ` dws skill setup # 交互式
|
||||
dws skill setup --mode mono --yes # 非交互装 mono
|
||||
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
|
||||
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
|
||||
dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个
|
||||
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillSetup,
|
||||
}
|
||||
cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)")
|
||||
cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets())
|
||||
cmd.Flags().String("source", "", "skill 源目录(默认使用二进制内嵌的 skill 源,与当前版本一致)")
|
||||
cmd.Flags().Bool("yes", false, "跳过所有确认提示")
|
||||
cmd.Flags().StringSliceP("skill", "s", nil, "multi 模式:仅安装指定子 skill(可重复,接受短名 aitable 或全名 dingtalk-aitable)")
|
||||
cmd.Flags().StringSliceP("exclude", "x", nil, "multi 模式:从全装中剔除指定子 skill(可重复,与 --skill 互斥)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
mode, _ := cmd.Flags().GetString("mode")
|
||||
target, _ := cmd.Flags().GetString("target")
|
||||
source, _ := cmd.Flags().GetString("source")
|
||||
autoYes, _ := cmd.Flags().GetBool("yes")
|
||||
includeRaw, _ := cmd.Flags().GetStringSlice("skill")
|
||||
excludeRaw, _ := cmd.Flags().GetStringSlice("exclude")
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
errOut := cmd.ErrOrStderr()
|
||||
|
||||
mode, err := resolveSkillSetupMode(mode, autoYes, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mode == skillSetupModeMono && (len(includeRaw) > 0 || len(excludeRaw) > 0) {
|
||||
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
|
||||
}
|
||||
|
||||
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcCleanup()
|
||||
|
||||
dests, err := resolveSkillSetupTargets(target, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
|
||||
var multiSkillNames []string
|
||||
if mode == skillSetupModeMulti {
|
||||
allMultiSkillNames, listErr := listMultiSkillNames(skillSrc)
|
||||
if listErr != nil {
|
||||
return listErr
|
||||
}
|
||||
if len(allMultiSkillNames) == 0 {
|
||||
return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc)
|
||||
}
|
||||
filtered, filterErr := filterMultiSkillNames(allMultiSkillNames, includeRaw, excludeRaw)
|
||||
if filterErr != nil {
|
||||
return filterErr
|
||||
}
|
||||
// dws-shared carries the global rules every product skill declares as a
|
||||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
fmt.Fprintln(out, "已取消。")
|
||||
return nil
|
||||
}
|
||||
} else if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
||||
}
|
||||
|
||||
var installed, skipped int
|
||||
switch mode {
|
||||
case skillSetupModeMono:
|
||||
installed, skipped, err = installSkillToHomes(skillSrc, dests, out, errOut)
|
||||
case skillSetupModeMulti:
|
||||
installed, skipped, err = installMultiSkillToHomes(skillSrc, multiSkillNames, dests, out, errOut)
|
||||
default:
|
||||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
// multiSkillPrefix is the canonical prefix for every per-product skill
|
||||
// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
|
||||
const multiSkillPrefix = "dingtalk-"
|
||||
|
||||
// multiSharedSkill is the shared, non-product skill that every per-product
|
||||
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
||||
// regardless of --skill / --exclude, otherwise the product skills reference a
|
||||
// dws-shared that was never installed.
|
||||
const multiSharedSkill = "dws-shared"
|
||||
|
||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||||
func ensureMandatorySharedSkill(selected, all []string) []string {
|
||||
hasShared := false
|
||||
for _, n := range all {
|
||||
if n == multiSharedSkill {
|
||||
hasShared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasShared {
|
||||
return selected
|
||||
}
|
||||
for _, n := range selected {
|
||||
if n == multiSharedSkill {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
return append([]string{multiSharedSkill}, selected...)
|
||||
}
|
||||
|
||||
// normalizeMultiSkillName accepts either the short form (aitable) or the
|
||||
// full form (dingtalk-aitable) and returns the canonical full form.
|
||||
// Empty input returns "". Comparison is case-insensitive.
|
||||
func normalizeMultiSkillName(name string) string {
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
if n == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(n, multiSkillPrefix) {
|
||||
return n
|
||||
}
|
||||
return multiSkillPrefix + n
|
||||
}
|
||||
|
||||
// filterMultiSkillNames narrows `all` by include / exclude lists.
|
||||
// Semantics mirror lark-cli's `npx skills add -s lark-calendar`:
|
||||
//
|
||||
// - include + exclude are mutually exclusive (both → error)
|
||||
// - names accept short or full form; normalized before matching
|
||||
// - unknown names → error, with the available list inlined for discovery
|
||||
// - both lists empty → return `all` (install everything)
|
||||
// - exclude that drops every name → error (avoid silent no-op install)
|
||||
//
|
||||
// The caller is responsible for additive installation: install only the
|
||||
// returned names, leaving any other already-installed dingtalk-* siblings
|
||||
// untouched (handled by installMultiSkillToHomes which does not enumerate
|
||||
// the destination).
|
||||
func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
|
||||
if len(include) > 0 && len(exclude) > 0 {
|
||||
return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用")
|
||||
}
|
||||
|
||||
available := make(map[string]struct{}, len(all))
|
||||
for _, n := range all {
|
||||
available[n] = struct{}{}
|
||||
}
|
||||
|
||||
validate := func(raw []string, flagName string) ([]string, error) {
|
||||
var normalized []string
|
||||
var unknown []string
|
||||
seen := make(map[string]bool)
|
||||
for _, r := range raw {
|
||||
n := normalizeMultiSkillName(r)
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := available[n]; !ok {
|
||||
unknown = append(unknown, r)
|
||||
continue
|
||||
}
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
normalized = append(normalized, n)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
return nil, fmt.Errorf("%s 中的以下名称在 multi 源中找不到:%s\n可用列表(共 %d 个):%s",
|
||||
flagName, strings.Join(unknown, ", "), len(all), strings.Join(all, ", "))
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
if len(include) > 0 {
|
||||
names, err := validate(include, "--skill")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
if len(exclude) > 0 {
|
||||
excluded, err := validate(exclude, "--exclude")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
excludedSet := make(map[string]bool, len(excluded))
|
||||
for _, n := range excluded {
|
||||
excludedSet[n] = true
|
||||
}
|
||||
var out []string
|
||||
for _, n := range all {
|
||||
if !excludedSet[n] {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("--exclude 把全部 %d 个子 skill 都剔除了,没有可装的", len(all))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// listMultiSkillNames returns sorted names of subdirectories under src that
|
||||
// contain a SKILL.md file (i.e. valid multi-mode skill bundles).
|
||||
func listMultiSkillNames(src string) ([]string, error) {
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// resolveSkillSetupMode resolves the mode either from the flag or via an
|
||||
// interactive prompt. If no TTY is available and no mode was given, returns
|
||||
// an error rather than silently picking a default.
|
||||
func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, error) {
|
||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||
switch mode {
|
||||
case skillSetupModeMono, skillSetupModeMulti:
|
||||
return mode, nil
|
||||
case "":
|
||||
// fall through to interactive prompt
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode)
|
||||
}
|
||||
|
||||
if autoYes || !isInteractiveTerminal() {
|
||||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
|
||||
return skillSetupModeMono, nil
|
||||
}
|
||||
|
||||
var choice string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("选择 dws skill 安装模式").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
||||
Options(
|
||||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("交互式选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
// resolveSkillSetupSource finds the local skill source directory for the
|
||||
// given mode. PR 1 supports only mono; multi is reserved for a later PR
|
||||
// and currently returns an error before reaching this function.
|
||||
func resolveSkillSetupSource(explicit, mode string) (string, error) {
|
||||
subdir := mode // "mono" or "multi"
|
||||
|
||||
// An explicit override (--source flag or DWS_SKILL_SOURCE) wins, and an
|
||||
// override that does not contain a skill root is an error — never a
|
||||
// silent fallback to another source the user did not ask for.
|
||||
var overrides []string
|
||||
if explicit != "" {
|
||||
overrides = append(overrides, explicit, filepath.Join(explicit, "skills", subdir))
|
||||
}
|
||||
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
|
||||
overrides = append(overrides, env, filepath.Join(env, "skills", subdir))
|
||||
}
|
||||
if len(overrides) > 0 {
|
||||
for _, c := range overrides {
|
||||
if isSkillSourceRoot(c, mode) {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
hint := strings.Join(overrides, "\n - ")
|
||||
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录(--source / DWS_SKILL_SOURCE 显式指定时不回退到内嵌源),已尝试:\n - %s", mode, hint)
|
||||
}
|
||||
|
||||
// No explicit override: legacy fallback only — embedded materialization
|
||||
// is handled by resolveSkillSetupSourceOrEmbedded (skill_setup_embed.go),
|
||||
// the wrapper that callers use. This branch is reachable only when the
|
||||
// wrapper passes through with an empty explicit/env (legacy direct call).
|
||||
candidates := skillSourceCandidates("", subdir)
|
||||
for _, c := range candidates {
|
||||
if isSkillSourceRoot(c, mode) {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
hint := strings.Join(candidates, "\n - ")
|
||||
return "", fmt.Errorf("未找到 %s 模式的 skill 源目录,已尝试:\n - %s\n\n请用 --source 显式指定包含 skills/%s 的仓库根目录", mode, hint, mode)
|
||||
}
|
||||
|
||||
// skillSourceCandidates returns the ordered list of paths to probe for a
|
||||
// skill source root, given an optional explicit override and the mode
|
||||
// subdir (mono or multi).
|
||||
func skillSourceCandidates(explicit, subdir string) []string {
|
||||
var roots []string
|
||||
if explicit != "" {
|
||||
// allow either repo root or already-resolved skills/<mode> dir
|
||||
roots = append(roots, explicit, filepath.Join(explicit, "skills", subdir))
|
||||
}
|
||||
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
|
||||
roots = append(roots, env, filepath.Join(env, "skills", subdir))
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
exeDir := filepath.Dir(exe)
|
||||
roots = append(roots,
|
||||
filepath.Join(exeDir, "skills", subdir),
|
||||
filepath.Join(exeDir, "..", "skills", subdir),
|
||||
filepath.Join(exeDir, "..", "share", "skills", "dws"),
|
||||
)
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
roots = append(roots, filepath.Join(wd, "skills", subdir))
|
||||
}
|
||||
// User-level cache populated by install.sh / install.ps1 / npm install.js
|
||||
// from the dws-skills.zip release asset. Lets `dws skill setup` find a
|
||||
// source even when the user has no source checkout on disk.
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
roots = append(roots, filepath.Join(home, ".dws", "skills", subdir))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func isSkillSourceRoot(path, mode string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
switch mode {
|
||||
case skillSetupModeMono:
|
||||
fi, err := os.Stat(filepath.Join(path, "SKILL.md"))
|
||||
return err == nil && !fi.IsDir()
|
||||
case skillSetupModeMulti:
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
if _, err := os.Stat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
|
||||
// If target == "all", returns every agent home whose parent directory exists.
|
||||
// Otherwise returns the single matching home (whether or not it currently exists).
|
||||
//
|
||||
// 末段约定:
|
||||
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
|
||||
// - multi → <agent-home> (安装时把 src 下每个子目录拷成兄弟 skill)
|
||||
func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法解析用户 HOME: %w", err)
|
||||
}
|
||||
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
if target == "" || target == "all" {
|
||||
return detectExistingAgentHomes(home, mode), nil
|
||||
}
|
||||
|
||||
rel, ok := agentSkillPaths[target]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
|
||||
}
|
||||
|
||||
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
|
||||
func agentHomeForMode(base, mode string) string {
|
||||
if mode == skillSetupModeMulti {
|
||||
return base
|
||||
}
|
||||
return filepath.Join(base, "dws")
|
||||
}
|
||||
|
||||
func detectExistingAgentHomes(home, mode string) []string {
|
||||
var out []string
|
||||
for i, rel := range skillSetupAgentHomes {
|
||||
base := filepath.Join(home, rel)
|
||||
parent := filepath.Dir(base)
|
||||
if i > 0 {
|
||||
if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, agentHomeForMode(base, mode))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, agentHomeForMode(filepath.Join(home, ".agents", "skills"), mode))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
|
||||
}
|
||||
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
||||
for _, n := range multiSkillNames {
|
||||
fmt.Fprintf(out, " · %s\n", n)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(out, " destinations:")
|
||||
for _, d := range dests {
|
||||
fmt.Fprintf(out, " - %s\n", d)
|
||||
}
|
||||
// 列出互斥清理:装 mode 前要把对面 mode 的残留删掉
|
||||
fmt.Fprintln(out, " 互斥清理(确认后才执行):")
|
||||
for _, d := range dests {
|
||||
for _, victim := range mutualExclusionVictims(d, mode) {
|
||||
fmt.Fprintf(out, " × 将删除 %s\n", victim)
|
||||
}
|
||||
}
|
||||
|
||||
if !isInteractiveTerminal() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var confirm bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("确认安装?").
|
||||
Affirmative("继续").
|
||||
Negative("取消").
|
||||
Value(&confirm),
|
||||
),
|
||||
)
|
||||
if err := form.Run(); err != nil {
|
||||
return false, fmt.Errorf("确认中止: %w", err)
|
||||
}
|
||||
return confirm, nil
|
||||
}
|
||||
|
||||
// mutualExclusionVictims returns the paths that should be removed before
|
||||
// installing into dest under the given mode, to prevent leftover files from
|
||||
// the opposite mode from co-existing.
|
||||
//
|
||||
// - mono dest is <agent-home>/dws → multi 残留是 <agent-home>/dingtalk-*
|
||||
// - multi dest is <agent-home> → mono 残留是 <agent-home>/dws
|
||||
func mutualExclusionVictims(dest, mode string) []string {
|
||||
switch mode {
|
||||
case skillSetupModeMono:
|
||||
// dest = <agent-home>/dws → agent-home = parent
|
||||
agentHome := filepath.Dir(dest)
|
||||
entries, err := os.ReadDir(agentHome)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") {
|
||||
victims = append(victims, filepath.Join(agentHome, e.Name()))
|
||||
}
|
||||
}
|
||||
sort.Strings(victims)
|
||||
return victims
|
||||
case skillSetupModeMulti:
|
||||
// dest = <agent-home> → mono 残留是 dest/dws
|
||||
monoPath := filepath.Join(dest, "dws")
|
||||
if _, err := os.Stat(monoPath); err == nil {
|
||||
return []string{monoPath}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupMutualExclusion best-effort removes the opposite-mode leftovers.
|
||||
// Failures emit a warning to errOut but never abort the install.
|
||||
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||||
for _, victim := range mutualExclusionVictims(dest, mode) {
|
||||
if err := os.RemoveAll(victim); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, " × 已清理对面模式残留 %s\n", victim)
|
||||
}
|
||||
}
|
||||
|
||||
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
sort.Strings(dests)
|
||||
for _, dest := range dests {
|
||||
// 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉
|
||||
cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut)
|
||||
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := copyDir(src, dest); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, " ✓ %s\n", dest)
|
||||
installed++
|
||||
}
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
|
||||
// dest as a sibling skill directory. installed/skipped is counted per
|
||||
// (agent-home × sub-skill) pair so the user sees granular progress.
|
||||
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
sort.Strings(dests)
|
||||
for _, dest := range dests {
|
||||
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
|
||||
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
|
||||
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err)
|
||||
skipped += len(skillNames)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, name := range skillNames {
|
||||
subSrc := filepath.Join(src, name)
|
||||
subDest := filepath.Join(dest, name)
|
||||
if err := os.RemoveAll(subDest); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := copyDir(subSrc, subDest); err != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, " ✓ %s\n", subDest)
|
||||
installed++
|
||||
}
|
||||
}
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
// resolve symlink target and copy the underlying file
|
||||
resolved, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !filepath.IsAbs(resolved) {
|
||||
resolved = filepath.Join(filepath.Dir(path), resolved)
|
||||
}
|
||||
return copyFileContent(resolved, target, info.Mode())
|
||||
}
|
||||
return copyFileContent(path, target, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
func copyFileContent(src, dst string, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
|
||||
func isInteractiveTerminal() bool {
|
||||
return isCharDevice(os.Stdin) && isCharDevice(os.Stdout) && isCharDevice(os.Stderr)
|
||||
}
|
||||
|
||||
func isCharDevice(file *os.File) bool {
|
||||
if file == nil {
|
||||
return false
|
||||
}
|
||||
fi, err := file.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
@@ -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/<mode> 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillSetupCommandRegistered(t *testing.T) {
|
||||
root := buildSkillCommand()
|
||||
var found bool
|
||||
for _, sub := range root.Commands() {
|
||||
if sub.Name() == "setup" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("dws skill setup not registered as subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
|
||||
got, err := resolveSkillSetupMode("mono", true, &bytes.Buffer{})
|
||||
if err != nil || got != skillSetupModeMono {
|
||||
t.Fatalf("expected mono no-error, got %q err=%v", got, err)
|
||||
}
|
||||
got, err = resolveSkillSetupMode("MULTI", true, &bytes.Buffer{})
|
||||
if err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("expected multi case-insensitive, got %q err=%v", got, err)
|
||||
}
|
||||
if _, err = resolveSkillSetupMode("hybrid", true, &bytes.Buffer{}); err == nil {
|
||||
t.Fatalf("expected error on invalid mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
got, err := resolveSkillSetupMode("", true, &buf)
|
||||
if err != nil || got != skillSetupModeMono {
|
||||
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "mono") {
|
||||
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCharDeviceRejectsNilAndRegularFiles(t *testing.T) {
|
||||
if isCharDevice(nil) {
|
||||
t.Fatal("nil file must not be treated as interactive")
|
||||
}
|
||||
|
||||
file, err := os.CreateTemp(t.TempDir(), "stdout")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if isCharDevice(file) {
|
||||
t.Fatal("regular files must not be treated as interactive terminals")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupSourceFindsMonoRoot(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
monoDir := filepath.Join(tmp, "skills", "mono")
|
||||
if err := os.MkdirAll(monoDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(monoDir, "SKILL.md"), []byte("# test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := resolveSkillSetupSource(tmp, skillSetupModeMono)
|
||||
if err != nil {
|
||||
t.Fatalf("expected to find mono source, got err=%v", err)
|
||||
}
|
||||
if got != monoDir {
|
||||
t.Fatalf("expected %s, got %s", monoDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||
// Isolate HOME so the ~/.dws/skills/<mode>/ fallback (added by the release
|
||||
// pipeline cache work) does not pick up real cached content on the
|
||||
// developer machine.
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
_, err := resolveSkillSetupSource(tmp, skillSetupModeMono)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when source missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "未找到") {
|
||||
t.Fatalf("expected 未找到 message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
|
||||
got, err := resolveSkillSetupTargets("claude", skillSetupModeMono)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
}
|
||||
if !strings.Contains(got[0], ".claude/skills/dws") {
|
||||
t.Fatalf("expected .claude/skills/dws path, got %s", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupTargetsUnknown(t *testing.T) {
|
||||
if _, err := resolveSkillSetupTargets("nonsense", skillSetupModeMono); err == nil {
|
||||
t.Fatalf("expected error for unknown target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
|
||||
got, err := resolveSkillSetupTargets("claude", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
}
|
||||
if strings.HasSuffix(got[0], "/dws") {
|
||||
t.Fatalf("multi target must not end with /dws, got %s", got[0])
|
||||
}
|
||||
if !strings.HasSuffix(got[0], ".claude/skills") {
|
||||
t.Fatalf("expected suffix .claude/skills, got %s", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSkillToHomesEndToEnd(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(src, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "references", "x.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst1 := filepath.Join(t.TempDir(), "a", "dws")
|
||||
dst2 := filepath.Join(t.TempDir(), "b", "dws")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installSkillToHomes(src, []string{dst1, dst2}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v", err)
|
||||
}
|
||||
if installed != 2 || skipped != 0 {
|
||||
t.Fatalf("expected installed=2 skipped=0, got %d/%d", installed, skipped)
|
||||
}
|
||||
for _, d := range []string{dst1, dst2} {
|
||||
if _, err := os.Stat(filepath.Join(d, "SKILL.md")); err != nil {
|
||||
t.Fatalf("missing SKILL.md in %s: %v", d, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(d, "references", "x.md")); err != nil {
|
||||
t.Fatalf("missing references/x.md in %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeMultiSkillSource builds a fake skills/multi/ layout containing N
|
||||
// dingtalk-* subdirs, each with a SKILL.md and one references/<name>.md
|
||||
// file. Returns the absolute skill source root.
|
||||
func writeMultiSkillSource(t *testing.T, names []string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, n := range names {
|
||||
sub := filepath.Join(root, n)
|
||||
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
got, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatalf("listMultiSkillNames err: %v", err)
|
||||
}
|
||||
if len(got) != len(names) {
|
||||
t.Fatalf("expected %d skills, got %d (%v)", len(names), len(got), got)
|
||||
}
|
||||
|
||||
dst1 := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("installMultiSkillToHomes err: %v", err)
|
||||
}
|
||||
if installed != len(names)*2 || skipped != 0 {
|
||||
t.Fatalf("expected installed=%d skipped=0, got %d/%d (stderr=%q)", len(names)*2, installed, skipped, stderr.String())
|
||||
}
|
||||
for _, d := range []string{dst1, dst2} {
|
||||
for _, n := range names {
|
||||
sub := filepath.Join(d, n)
|
||||
if _, err := os.Stat(filepath.Join(sub, "SKILL.md")); err != nil {
|
||||
t.Fatalf("missing %s/SKILL.md: %v", sub, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sub, "references", n+".md")); err != nil {
|
||||
t.Fatalf("missing %s/references/%s.md: %v", sub, n, err)
|
||||
}
|
||||
}
|
||||
// dws/ should NOT exist (multi mode is pure siblings)
|
||||
if _, err := os.Stat(filepath.Join(d, "dws")); err == nil {
|
||||
t.Fatalf("unexpected dws/ subdir in multi-mode install at %s", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable", "dingtalk-calendar"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
// Simulate a pre-existing mono install under <agent-home>/dws/
|
||||
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
monoLeftover := filepath.Join(agentHome, "dws")
|
||||
if err := os.MkdirAll(filepath.Join(monoLeftover, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sanity: leftover exists before
|
||||
if _, err := os.Stat(monoLeftover); err != nil {
|
||||
t.Fatalf("setup: mono leftover should exist before, err=%v", err)
|
||||
}
|
||||
|
||||
// Confirm mutualExclusionVictims sees the leftover
|
||||
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
if len(victims) != 1 || victims[0] != monoLeftover {
|
||||
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
if installed != len(names) || skipped != 0 {
|
||||
t.Fatalf("expected installed=%d skipped=0, got %d/%d", len(names), installed, skipped)
|
||||
}
|
||||
|
||||
// mono leftover should be gone
|
||||
if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected mono leftover removed, stat err=%v", err)
|
||||
}
|
||||
// multi skills should be in place
|
||||
for _, n := range names {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
|
||||
t.Fatalf("missing %s/%s/SKILL.md: %v", agentHome, n, err)
|
||||
}
|
||||
}
|
||||
// the cleanup line should appear in stdout (best-effort observability)
|
||||
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
|
||||
t.Fatalf("expected cleanup log line, got stdout=%q", stdout.String())
|
||||
}
|
||||
|
||||
// Now test the reverse: pre-existing multi → installing mono cleans dingtalk-*
|
||||
monoSrc := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
monoDest := filepath.Join(agentHome, "dws")
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
installed2, skipped2, err := installSkillToHomes(monoSrc, []string{monoDest}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("mono install err: %v", err)
|
||||
}
|
||||
if installed2 != 1 || skipped2 != 0 {
|
||||
t.Fatalf("expected mono installed=1 skipped=0, got %d/%d", installed2, skipped2)
|
||||
}
|
||||
// All dingtalk-* siblings should be gone after mono install
|
||||
for _, n := range names {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, n)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected %s removed by mutual exclusion, stat err=%v", n, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
|
||||
t.Fatalf("mono SKILL.md missing: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
|
||||
t.Fatalf("expected cleanup log line on mono install, got stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSourceCandidatesIncludesUserCache verifies that the user-level
|
||||
// cache populated by install.sh / install.ps1 / npm install.js is part of the
|
||||
// fallback candidate list, so `dws skill setup` can find a source on a fresh
|
||||
// machine without --source.
|
||||
func TestSkillSourceCandidatesIncludesUserCache(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserHomeDir error = %v", err)
|
||||
}
|
||||
|
||||
for _, subdir := range []string{"mono", "multi"} {
|
||||
got := skillSourceCandidates("", subdir)
|
||||
want := filepath.Join(home, ".dws", "skills", subdir)
|
||||
found := false
|
||||
for _, c := range got {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("skillSourceCandidates(%q) missing %q; got %v", subdir, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSkillSetupSourceFallsBackToUserCache verifies that when no
|
||||
// --source / DWS_SKILL_SOURCE / source checkout is available, the resolver
|
||||
func TestNormalizeMultiSkillName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"aitable", "dingtalk-aitable"},
|
||||
{"dingtalk-aitable", "dingtalk-aitable"},
|
||||
{" Calendar ", "dingtalk-calendar"},
|
||||
{"DINGTALK-DOC", "dingtalk-doc"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeMultiSkillName(c.in); got != c.want {
|
||||
t.Errorf("normalizeMultiSkillName(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMultiSkillNames(t *testing.T) {
|
||||
all := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc", "dingtalk-live"}
|
||||
|
||||
t.Run("no filter returns all", func(t *testing.T) {
|
||||
got, err := filterMultiSkillNames(all, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != len(all) {
|
||||
t.Fatalf("expected %d, got %v", len(all), got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include short names", func(t *testing.T) {
|
||||
got, err := filterMultiSkillNames(all, []string{"aitable", "calendar"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got, ",") != "dingtalk-aitable,dingtalk-calendar" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include full names", func(t *testing.T) {
|
||||
got, err := filterMultiSkillNames(all, []string{"dingtalk-doc"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "dingtalk-doc" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include dedups", func(t *testing.T) {
|
||||
got, err := filterMultiSkillNames(all, []string{"aitable", "dingtalk-aitable", "AITABLE"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "dingtalk-aitable" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include unknown errors with available list", func(t *testing.T) {
|
||||
_, err := filterMultiSkillNames(all, []string{"aitable", "bogus"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "bogus") {
|
||||
t.Errorf("error should mention bad name, got: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "dingtalk-calendar") {
|
||||
t.Errorf("error should list available names, got: %s", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exclude short names", func(t *testing.T) {
|
||||
got, err := filterMultiSkillNames(all, nil, []string{"live", "doc"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got, ",") != "dingtalk-aitable,dingtalk-calendar" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exclude unknown errors", func(t *testing.T) {
|
||||
_, err := filterMultiSkillNames(all, nil, []string{"bogus"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exclude all errors", func(t *testing.T) {
|
||||
_, err := filterMultiSkillNames(all, nil, []string{"aitable", "calendar", "doc", "live"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when exclude drops everything")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "全部") {
|
||||
t.Errorf("expected 全部 in error, got: %s", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include + exclude mutually exclusive", func(t *testing.T) {
|
||||
_, err := filterMultiSkillNames(all, []string{"aitable"}, []string{"doc"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both given")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
|
||||
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
|
||||
// touch already-installed dingtalk-* siblings (additive semantics, matches
|
||||
// lark-cli `npx skills add -s lark-calendar`).
|
||||
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
|
||||
})
|
||||
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
|
||||
// Pretend the user already installed two dingtalk-* skills earlier.
|
||||
preExisting := []string{"dingtalk-chat", "dingtalk-todo"}
|
||||
for _, n := range preExisting {
|
||||
dir := filepath.Join(agentHome, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// User now runs `... --mode multi -s aitable -s calendar`.
|
||||
filtered, err := filterMultiSkillNames(
|
||||
[]string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"},
|
||||
[]string{"aitable", "calendar"},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("filter err: %v", err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
if installed != 2 || skipped != 0 {
|
||||
t.Fatalf("expected installed=2 skipped=0, got %d/%d", installed, skipped)
|
||||
}
|
||||
|
||||
// Asked-for skills should be in place.
|
||||
for _, n := range []string{"dingtalk-aitable", "dingtalk-calendar"} {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
|
||||
t.Errorf("missing newly-installed %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
// Unselected source skill must NOT be installed.
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-doc")); !os.IsNotExist(err) {
|
||||
t.Errorf("dingtalk-doc was not requested but appeared (stat err=%v)", err)
|
||||
}
|
||||
// Pre-existing sibling skills must be UNTOUCHED — additive semantics.
|
||||
for _, n := range preExisting {
|
||||
body, err := os.ReadFile(filepath.Join(agentHome, n, "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Errorf("pre-existing %s was wiped (err=%v)", n, err)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(string(body), "OLD ") {
|
||||
t.Errorf("pre-existing %s content changed: got %q", n, string(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillSetupRejectsSkillFlagInMonoMode verifies that the new
|
||||
// -s/--skill and -x/--exclude flags are gated on --mode multi.
|
||||
func TestRunSkillSetupRejectsSkillFlagInMonoMode(t *testing.T) {
|
||||
cmd := newSkillSetupCommand()
|
||||
cmd.SetArgs([]string{"--mode", "mono", "--yes", "--skill", "aitable"})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --skill in mono mode")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "multi") {
|
||||
t.Fatalf("error should mention multi gating, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
multiDir := filepath.Join(tmp, "skills", "multi")
|
||||
for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} {
|
||||
if err := os.MkdirAll(filepath.Join(multiDir, n), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(multiDir, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := resolveSkillSetupSource(tmp, skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("expected to find multi source, got err=%v", err)
|
||||
}
|
||||
if got != multiDir {
|
||||
t.Fatalf("expected %s, got %s", multiDir, got)
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ func convertResult(r executor.Result) *edition.ToolResult {
|
||||
contentRaw, ok := resp["content"]
|
||||
if !ok {
|
||||
// Dry-run or echo mode: serialize the whole response as text.
|
||||
data, _ := json.Marshal(resp)
|
||||
data, _ := jsonutil.Marshal(resp)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
@@ -98,12 +98,12 @@ func convertResult(r executor.Result) *edition.ToolResult {
|
||||
}
|
||||
return &edition.ToolResult{Content: blocks}
|
||||
case map[string]any:
|
||||
data, _ := json.Marshal(v)
|
||||
data, _ := jsonutil.Marshal(v)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
default:
|
||||
data, _ := json.Marshal(contentRaw)
|
||||
data, _ := jsonutil.Marshal(contentRaw)
|
||||
return &edition.ToolResult{
|
||||
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
|
||||
}
|
||||
|
||||
+113
-14
@@ -7,25 +7,28 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/fatih/color"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
ugBold = color.New(color.Bold).SprintFunc()
|
||||
ugGreen = color.New(color.FgGreen).SprintFunc()
|
||||
ugYellow = color.New(color.FgYellow).SprintFunc()
|
||||
ugRed = color.New(color.FgRed).SprintFunc()
|
||||
ugCyan = color.New(color.FgCyan).SprintFunc()
|
||||
ugDim = color.New(color.Faint).SprintFunc()
|
||||
ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc()
|
||||
ugBold = tui.Bold
|
||||
ugGreen = tui.Success
|
||||
ugYellow = tui.Warning
|
||||
ugRed = tui.Danger
|
||||
ugCyan = tui.Cyan
|
||||
ugDim = tui.Dim
|
||||
ugBoldGrn = tui.Success
|
||||
)
|
||||
|
||||
const defaultListLimit = 10
|
||||
@@ -54,10 +57,20 @@ 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 {
|
||||
if h := edition.Get(); h != nil && h.IsEmbedded {
|
||||
name := h.Name
|
||||
if name == "" {
|
||||
name = "embedded"
|
||||
}
|
||||
return fmt.Errorf("当前运行在嵌入模式(%s),dws upgrade 已禁用;请通过宿主完成升级", name)
|
||||
}
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
if flagList {
|
||||
@@ -78,6 +91,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
force: flagForce,
|
||||
skipSkills: flagSkipSkills,
|
||||
yes: yes,
|
||||
dryRun: dryRun,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -98,6 +112,7 @@ type upgradeOptions struct {
|
||||
force bool
|
||||
skipSkills bool
|
||||
yes bool
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
// --- dws upgrade --check ---
|
||||
@@ -288,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("检查更新..."))
|
||||
|
||||
@@ -329,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] ")
|
||||
@@ -508,6 +560,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)
|
||||
|
||||
@@ -571,13 +633,18 @@ func validateNewBinary(binaryPath, expectedVersion string) error {
|
||||
return fmt.Errorf("设置执行权限失败: %w", err)
|
||||
}
|
||||
|
||||
// Try running the binary
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, binaryPath, "version").CombinedOutput()
|
||||
out, err := tryExecVersion(binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("二进制无法执行: %w", err)
|
||||
// Apple Silicon kills unsigned arm64 binaries with SIGKILL via amfid.
|
||||
// Repair the binary in-place (ad-hoc codesign + drop quarantine) and retry once.
|
||||
if runtime.GOOS == "darwin" && isLikelyAMFIKill(err) {
|
||||
if repairErr := repairDarwinBinary(binaryPath); repairErr == nil {
|
||||
out, err = tryExecVersion(binaryPath)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("二进制无法执行: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(string(out), expectedVersion) {
|
||||
@@ -587,6 +654,38 @@ func validateNewBinary(binaryPath, expectedVersion string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryExecVersion(binaryPath string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, binaryPath, "version").CombinedOutput()
|
||||
}
|
||||
|
||||
// isLikelyAMFIKill returns true when err looks like macOS amfid SIGKILL'ing an
|
||||
// unsigned binary. Go reports this as "signal: killed".
|
||||
func isLikelyAMFIKill(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "signal: killed") || strings.Contains(msg, "signal: kill")
|
||||
}
|
||||
|
||||
// repairDarwinBinary applies an ad-hoc codesign and clears the quarantine xattr.
|
||||
// Used as a self-heal step when an unsigned binary is killed by amfid on Apple Silicon.
|
||||
func repairDarwinBinary(binaryPath string) error {
|
||||
// Best-effort: strip quarantine. Failure is fine (attribute often absent).
|
||||
_ = exec.Command("xattr", "-d", "com.apple.quarantine", binaryPath).Run()
|
||||
|
||||
if _, err := exec.LookPath("codesign"); err != nil {
|
||||
return fmt.Errorf("codesign 不可用: %w", err)
|
||||
}
|
||||
out, err := exec.Command("codesign", "--force", "--sign", "-", binaryPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("codesign 失败: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarGz extracts a .tar.gz file using the system tar command.
|
||||
func extractTarGz(archivePath, destDir string) error {
|
||||
os.MkdirAll(destDir, 0755)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestUpgradeCommand_BlockedWhenEmbedded(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"check", []string{"--check"}},
|
||||
{"list", []string{"--list"}},
|
||||
{"rollback", []string{"--rollback"}},
|
||||
{"plain", []string{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs(tc.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("upgrade %v in embedded mode must return error, got nil", tc.args)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "嵌入模式") {
|
||||
t.Errorf("error message should mention 嵌入模式, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "embedded") {
|
||||
t.Errorf("error message should include edition name, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "dws upgrade") {
|
||||
t.Errorf("error message should reference dws upgrade for clarity, got: %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeCommand_NotBlockedInOpenSourceMode(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: false, Name: "open"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cmd := newUpgradeCommand()
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil && strings.Contains(err.Error(), "嵌入模式") {
|
||||
t.Errorf("open-source mode must not be blocked by embedded guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -427,4 +430,135 @@ 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 ---
|
||||
|
||||
func TestIsLikelyAMFIKill(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil error", nil, false},
|
||||
{"signal killed (real Go format)", errors.New("signal: killed"), true},
|
||||
{"signal kill variant", errors.New("signal: kill"), true},
|
||||
{"unrelated error", errors.New("exit status 1"), false},
|
||||
{"file not found", errors.New("no such file or directory"), false},
|
||||
{"wrapped killed in middle", errors.New("exec: signal: killed: cleanup"), true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isLikelyAMFIKill(tt.err); got != tt.want {
|
||||
t.Errorf("isLikelyAMFIKill(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- validateNewBinary self-heal (darwin only) ---
|
||||
//
|
||||
// On macOS, an unsigned arm64 binary is SIGKILL'd by amfid. This test verifies
|
||||
// validateNewBinary recovers via repairDarwinBinary (ad-hoc codesign) and
|
||||
// successfully re-executes the binary. We use go itself as a stand-in for the
|
||||
// new dws binary — it's a real signed Mach-O we can strip and re-sign.
|
||||
|
||||
func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("amfid SIGKILL only happens on macOS")
|
||||
}
|
||||
if _, err := exec.LookPath("codesign"); err != nil {
|
||||
t.Skip("codesign not available")
|
||||
}
|
||||
|
||||
// Build a fresh dws binary into a temp dir.
|
||||
tmpDir := t.TempDir()
|
||||
bin := filepath.Join(tmpDir, "dws-test")
|
||||
|
||||
// Locate repo root from this test file's location.
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
repoRoot := filepath.Join(wd, "..", "..")
|
||||
cmd := exec.Command("go", "build", "-o", bin, "./cmd")
|
||||
cmd.Dir = repoRoot
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("go build failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Strip signature to reproduce the unsigned state from CI cross-compilation.
|
||||
if out, err := exec.Command("codesign", "--remove-signature", bin).CombinedOutput(); err != nil {
|
||||
t.Fatalf("strip signature: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Sanity: confirm direct exec is killed.
|
||||
if _, err := tryExecVersion(bin); err == nil {
|
||||
t.Skip("unsigned binary executed without amfid kill — likely Intel Mac or SIP disabled")
|
||||
}
|
||||
|
||||
// validateNewBinary should self-heal and succeed.
|
||||
if err := validateNewBinary(bin, "dev"); err != nil {
|
||||
t.Fatalf("validateNewBinary did not recover: %v", err)
|
||||
}
|
||||
|
||||
// Verify the binary now has an ad-hoc signature.
|
||||
out, _ := exec.Command("codesign", "-dv", bin).CombinedOutput()
|
||||
if !strings.Contains(string(out), "Signature=adhoc") {
|
||||
t.Errorf("expected adhoc signature, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
@@ -123,3 +125,27 @@ func commandNames(cmds []*cobra.Command) []string {
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// TestRenderRootHelpIncludesLong guards that renderRootHelp surfaces the
|
||||
// root command's Long description in `dws --help` output. The custom
|
||||
// SetHelpFunc in root_help.go replaces cobra's default help template, which
|
||||
// previously caused root.Long to be silently dropped. The production
|
||||
// root.Long carries the "use 'dws upgrade' if a command is missing or
|
||||
// failing" hint that AI agents rely on when they cannot find a suitable
|
||||
// command — if this test fails after a help-rendering change, agents will
|
||||
// silently lose that guidance.
|
||||
func TestRenderRootHelpIncludesLong(t *testing.T) {
|
||||
const sentinel = "SENTINEL-LONG-MUST-APPEAR-IN-HELP"
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
Long: sentinel,
|
||||
}
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
|
||||
renderRootHelp(root)
|
||||
|
||||
if !strings.Contains(out.String(), sentinel) {
|
||||
t.Fatalf("renderRootHelp must render root.Long verbatim in --help output; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// 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 stays empty — never guess or synthesize a PAT key.
|
||||
// - 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 literal code a host may explicitly declare for a
|
||||
// custom integration. It is not used as an implicit fallback.
|
||||
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 empty unless they declare themselves.
|
||||
//
|
||||
// The authoritative, fully-general path to 100% coverage is the T0 declaration
|
||||
// contract: a host sets DINGTALK_DWS_AGENTCODE=<code> 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 empty.
|
||||
//
|
||||
// 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 unresolved -> empty (never guess)
|
||||
func DetectAgentCode() (code string, signal string) {
|
||||
// T0: host explicitly declares its agent_code — highest confidence.
|
||||
if v, name := AgentCodeFromEnv(); v != "" {
|
||||
return 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 — leave agent_code empty, no guessing.
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
case "claude", "claude-code", "claude_code", "claudecode":
|
||||
return "claudecode"
|
||||
case "qoder":
|
||||
return "qoder"
|
||||
case "qoderwork":
|
||||
return "QoderWork"
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// 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, "QoderWork")
|
||||
code, sig := DetectAgentCode()
|
||||
if code != "QoderWork" {
|
||||
t.Fatalf("want verbatim QoderWork, 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.
|
||||
func TestDetectAgentCode_UnknownBundleIsEmpty(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
|
||||
code, _ := DetectAgentCode()
|
||||
if code != "" {
|
||||
t.Fatalf("unknown bundle must be empty, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_FallbackEmpty(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != "" {
|
||||
t.Fatalf("want empty code, got %q", code)
|
||||
}
|
||||
if sig != "" {
|
||||
t.Fatalf("want empty signal, 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 != "" {
|
||||
t.Fatalf("noise must not decide agent_code; want empty, 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": "QoderWork",
|
||||
"WorkBuddy": "workbuddy",
|
||||
"Visual Studio Code": "vscode",
|
||||
"Cursor": "cursor",
|
||||
"": "",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,21 +16,31 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
// appConfigFile is the filename for storing app credentials.
|
||||
appConfigFile = "app.json"
|
||||
// appConfigFile is the filename for the open-source edition's app
|
||||
// credentials store. Sibling editions get a name-suffixed file via
|
||||
// config.EditionFileName so two dws binaries sharing the same config
|
||||
// directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's
|
||||
// credentials. See GetAppConfigPath for the path derivation contract.
|
||||
appConfigBase = "app"
|
||||
appConfigExt = ".json"
|
||||
appConfigFile = appConfigBase + appConfigExt
|
||||
)
|
||||
|
||||
// AppConfig represents the application credentials configuration.
|
||||
// This is stored in ~/.dws/app.json with the client secret securely stored in keychain.
|
||||
// This is stored in the edition-specific app config file, with the client
|
||||
// secret securely stored in keychain when present.
|
||||
type AppConfig struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret SecretInput `json:"clientSecret"`
|
||||
@@ -53,9 +63,14 @@ var (
|
||||
cachedResolvedMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetAppConfigPath returns the path to the app config file.
|
||||
// GetAppConfigPath returns the path to the app config file for the
|
||||
// currently-active edition. The filename is partitioned by edition so that
|
||||
// two dws binaries from different editions sharing the same configDir
|
||||
// (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each
|
||||
// other's credentials. Open-source stays on "app.json" for backwards
|
||||
// compatibility; sibling editions land on "app-<edition>.json".
|
||||
func GetAppConfigPath(configDir string) string {
|
||||
return filepath.Join(configDir, appConfigFile)
|
||||
return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt))
|
||||
}
|
||||
|
||||
// LoadAppConfig loads the app configuration from disk.
|
||||
@@ -105,6 +120,7 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("writing app config: %w", err)
|
||||
}
|
||||
cleanupLegacySiblingAppConfig(configDir, config)
|
||||
|
||||
// Update cache
|
||||
cachedAppConfigMu.Lock()
|
||||
@@ -121,6 +137,34 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
|
||||
if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) {
|
||||
return
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
if legacyPath == GetAppConfigPath(configDir) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var legacy AppConfig
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return
|
||||
}
|
||||
if legacy.ClientID != config.ClientID {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAppConfig removes the app configuration and associated keychain secrets.
|
||||
func DeleteAppConfig(configDir string) error {
|
||||
// Load existing config to clean up keychain
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// 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"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Verifies that two dws binaries from different editions sharing the same
|
||||
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
|
||||
// app.json files. Without partitioning, a sibling edition's post-login
|
||||
// persistence path could leak its pinned ClientID into the open-source
|
||||
// build by reading the shared file.
|
||||
|
||||
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
for _, name := range []string{"", "open"} {
|
||||
edition.Override(&edition.Hooks{Name: name})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", "app.json")
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
editionName string
|
||||
wantFile string
|
||||
}{
|
||||
{"wukong", "app-wukong.json"},
|
||||
{"dev", "app-dev.json"},
|
||||
{"embedded", "app-embedded.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
edition.Override(&edition.Hooks{Name: tc.editionName})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", tc.wantFile)
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
|
||||
// End-to-end invariant: when the same configDir is observed from two
|
||||
// different editions, the resulting app.json paths must NOT collide.
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
const cfg = "/tmp/shared-cfg"
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
openPath := GetAppConfigPath(cfg)
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(cfg)
|
||||
|
||||
if openPath == wukongPath {
|
||||
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
|
||||
}
|
||||
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
|
||||
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(configDir)
|
||||
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
got, err := LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig(open) error = %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("open edition read sibling app config: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
|
||||
t.Fatalf("sibling app config not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("different legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing malformed legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("malformed legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
siblingFiles := map[string][]byte{
|
||||
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
}
|
||||
for name, data := range siblingFiles {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(open) error = %v", err)
|
||||
}
|
||||
|
||||
for name, want := range siblingFiles {
|
||||
got, err := os.ReadFile(filepath.Join(configDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
@@ -330,6 +330,63 @@ func TestBuildTokenData_DefaultExpiry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMCPTokenResponseIncludesCorpName(t *testing.T) {
|
||||
provider := &OAuthProvider{}
|
||||
data, err := provider.parseMCPTokenResponse([]byte(`{
|
||||
"accessToken": "access-123",
|
||||
"refreshToken": "refresh-456",
|
||||
"expiresIn": 7200,
|
||||
"corpId": "ding123",
|
||||
"corpName": "钉钉(中国)信息技术有限公司"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMCPTokenResponse() error = %v", err)
|
||||
}
|
||||
if data.CorpID != "ding123" {
|
||||
t.Fatalf("corp id = %q, want ding123", data.CorpID)
|
||||
}
|
||||
if data.CorpName != "钉钉(中国)信息技术有限公司" {
|
||||
t.Fatalf("corp name = %q, want 钉钉(中国)信息技术有限公司", data.CorpName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMCPTokenResponseCorpNameFallbacks(t *testing.T) {
|
||||
provider := &OAuthProvider{}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "snake",
|
||||
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","corp_name":"Snake Corp"}`,
|
||||
want: "Snake Corp",
|
||||
},
|
||||
{
|
||||
name: "orgName",
|
||||
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","orgName":"Org Corp"}`,
|
||||
want: "Org Corp",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := provider.parseMCPTokenResponse([]byte(tc.body))
|
||||
if err != nil {
|
||||
t.Fatalf("parseMCPTokenResponse() error = %v", err)
|
||||
}
|
||||
if data.CorpName != tc.want {
|
||||
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
|
||||
authURL := buildAuthURL("client-id", "http://127.0.0.1:1234/callback", "ding-target")
|
||||
if !strings.Contains(authURL, "corpId=ding-target") {
|
||||
t.Fatalf("auth URL missing target corpId: %s", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
|
||||
if resp.AccessToken == "" {
|
||||
return nil
|
||||
|
||||
@@ -19,19 +19,42 @@ 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"
|
||||
|
||||
// AgentCodeEnvCompat is a compatibility alias for hosts that shipped the
|
||||
// reversed prefix before AgentCodeEnv became the public spelling.
|
||||
AgentCodeEnvCompat = "DWS_DINGTALK_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()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ func TestClassifyDenialReason(t *testing.T) {
|
||||
},
|
||||
want: "channel_required",
|
||||
},
|
||||
{
|
||||
name: "error ENTERPRISE_NOT_AUTHORIZED",
|
||||
status: &CLIAuthStatus{
|
||||
ErrorCode: "ENTERPRISE_NOT_AUTHORIZED",
|
||||
},
|
||||
want: "enterprise_not_authorized",
|
||||
},
|
||||
{
|
||||
name: "error NO_AUTH",
|
||||
status: &CLIAuthStatus{
|
||||
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -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 {
|
||||
@@ -146,13 +147,14 @@ type serviceResult struct {
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods (OAuth, PAT, etc.) so that device flow always
|
||||
// starts fresh by fetching clientID from MCP.
|
||||
// previous login methods (OAuth, PAT, etc.) before device flow falls back to
|
||||
// MCP-managed credentials.
|
||||
//
|
||||
// This is a defensive measure: no matter what a prior login wrote to
|
||||
// app.json or runtime globals, device flow will re-fetch from MCP and
|
||||
// set the correct clientIDFromMCP flag, ensuring exchangeCode() uses
|
||||
// the MCP proxy path (which doesn't require clientSecret).
|
||||
// app.json, device flow will re-fetch from MCP and set the correct
|
||||
// clientIDFromMCP flag, ensuring exchangeCode() uses the MCP proxy path
|
||||
// (which doesn't require clientSecret). Complete runtime AppKey/AppSecret
|
||||
// overrides intentionally skip this reset.
|
||||
func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
@@ -161,22 +163,29 @@ func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP.
|
||||
// This ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
} else {
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we can re-fetch from MCP. This
|
||||
// ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
}
|
||||
|
||||
const maxAttempts = 3
|
||||
@@ -205,7 +214,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)
|
||||
}
|
||||
@@ -267,6 +276,14 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试"))
|
||||
case "enterprise_not_authorized":
|
||||
msg := i18n.T("本次请求未通过企业安全认证")
|
||||
if authStatus != nil && strings.TrimSpace(authStatus.ErrorMsg) != "" {
|
||||
msg = strings.TrimSpace(authStatus.ErrorMsg)
|
||||
}
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed("⚠️ "+msg))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
return nil, errors.New(msg)
|
||||
case "no_auth":
|
||||
_, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效")))
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。"))
|
||||
@@ -582,20 +599,21 @@ func truncateBody(body []byte, maxLen int) string {
|
||||
}
|
||||
|
||||
var (
|
||||
dfBold = color.New(color.Bold).SprintFunc()
|
||||
dfGreen = color.New(color.FgGreen).SprintFunc()
|
||||
dfYellow = color.New(color.FgYellow).SprintFunc()
|
||||
dfRed = color.New(color.FgRed).SprintFunc()
|
||||
dfCyan = color.New(color.FgCyan).SprintFunc()
|
||||
dfDim = color.New(color.Faint).SprintFunc()
|
||||
dfBold = tui.Bold
|
||||
dfGreen = tui.Success
|
||||
dfYellow = tui.Warning
|
||||
dfRed = tui.Danger
|
||||
dfCyan = tui.Cyan
|
||||
dfDim = tui.Dim
|
||||
)
|
||||
|
||||
func dfPrintStep(w io.Writer, step int, message string, attempt int) {
|
||||
label := fmt.Sprintf("Step %d", step)
|
||||
if attempt > 1 {
|
||||
_, _ = fmt.Fprintf(w, i18n.T("%s (第 %d 次尝试)\\n"), dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)), attempt)
|
||||
_, _ = fmt.Fprintf(w, i18n.T("%s %s: %s (第 %d 次尝试)\\n"), tui.StateMark("ok"), dfBold(label), message, attempt)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\n", dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)))
|
||||
_, _ = fmt.Fprintf(w, "%s %s: %s\n", tui.StateMark("ok"), dfBold(label), message)
|
||||
}
|
||||
|
||||
func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) {
|
||||
@@ -621,7 +639,7 @@ func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) {
|
||||
func dfPrintBox(w io.Writer, lines []string) {
|
||||
maxLen := 0
|
||||
for _, line := range lines {
|
||||
if l := dfPlainLength(line); l > maxLen {
|
||||
if l := tui.PlainRuneWidth(line); l > maxLen {
|
||||
maxLen = l
|
||||
}
|
||||
}
|
||||
@@ -630,38 +648,19 @@ func dfPrintBox(w io.Writer, lines []string) {
|
||||
}
|
||||
|
||||
border := strings.Repeat("─", maxLen+4)
|
||||
_, _ = fmt.Fprintf(w, " ┌%s┐\n", border)
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╭"+border+"╮"))
|
||||
for _, line := range lines {
|
||||
pad := maxLen - dfPlainLength(line)
|
||||
pad := maxLen - tui.PlainRuneWidth(line)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, " │ %s%s │\n", line, strings.Repeat(" ", pad))
|
||||
_, _ = fmt.Fprintf(w, " %s %s%s %s\n", tui.Blue("│"), line, strings.Repeat(" ", pad), tui.Blue("│"))
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, " └%s┘\n", border)
|
||||
}
|
||||
|
||||
func dfPlainLength(s string) int {
|
||||
inEscape := false
|
||||
length := 0
|
||||
for _, r := range s {
|
||||
if r == '\033' {
|
||||
inEscape = true
|
||||
continue
|
||||
}
|
||||
if inEscape {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
inEscape = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
length++
|
||||
}
|
||||
return length
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╰"+border+"╯"))
|
||||
}
|
||||
|
||||
func dfPrintPollStatus(w io.Writer, count, elapsedSec int) {
|
||||
_, _ = fmt.Fprintf(w, " %s ", dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec)))
|
||||
_, _ = fmt.Fprintf(w, " %s %s ", tui.StateMark("pending"), dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec)))
|
||||
}
|
||||
|
||||
func dfPrintPollResult(w io.Writer, status, message string) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// 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 (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// applyEditionEnterpriseCredentialHeaders injects overlay-provided enterprise
|
||||
// credential headers (e.g. x-dws-enterprise-credential) into MCP control-plane
|
||||
// and OAuth proxy requests.
|
||||
func applyEditionEnterpriseCredentialHeaders(req *http.Request) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
fn := edition.Get().EnterpriseCredentialHeaders
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
merged := fn(nil)
|
||||
for k, v := range merged {
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
if k != "" && v != "" {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ const (
|
||||
LogoutContinueURL = "https://login.dingtalk.com"
|
||||
|
||||
// MCP API endpoints for CLI authorization management.
|
||||
DefaultMCPBaseURL = "https://mcp.dingtalk.com"
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
@@ -131,13 +131,7 @@ func GetDeveloperSettingsURL() string {
|
||||
// 1. ~/.dws/mcp_url file content (for pre-release environment)
|
||||
// 2. Default value (https://mcp.dingtalk.com)
|
||||
func GetMCPBaseURL() string {
|
||||
mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url")
|
||||
if data, err := os.ReadFile(mcpURLPath); err == nil {
|
||||
if url := strings.TrimSpace(string(data)); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return DefaultMCPBaseURL
|
||||
return config.GetMCPBaseURL()
|
||||
}
|
||||
|
||||
// Runtime overrides set via CLI flags (--client-id, --client-secret).
|
||||
@@ -296,6 +290,11 @@ func getRuntimeCredentials() (clientID, clientSecret string) {
|
||||
return runtimeClientID, runtimeClientSecret
|
||||
}
|
||||
|
||||
func getCompleteRuntimeCredentials() (clientID, clientSecret string, ok bool) {
|
||||
clientID, clientSecret = getRuntimeCredentials()
|
||||
return clientID, clientSecret, strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != ""
|
||||
}
|
||||
|
||||
// getDefaultConfigDir returns the default configuration directory.
|
||||
// Priority: DWS_CONFIG_DIR env var > ~/.dws
|
||||
func getDefaultConfigDir() string {
|
||||
|
||||
+151
-14
@@ -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 <configDir>/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 has no per-agent
|
||||
// identity and returns empty.
|
||||
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
|
||||
if agentCode == "" {
|
||||
return ""
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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_EmptyAgentCodeReturnsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := EnsureExists(dir)
|
||||
got := id.ResolveAgentID(dir, "", "fallback")
|
||||
if got != "" {
|
||||
t.Fatalf("empty agent_code must not derive an instance id, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
@@ -30,6 +31,24 @@ var (
|
||||
// SaveTokenDataKeychain saves TokenData to the platform keychain.
|
||||
// This is the new secure storage method using random master key.
|
||||
func SaveTokenDataKeychain(data *TokenData) error {
|
||||
return saveTokenDataKeychainAccount(keychain.AccountToken, data)
|
||||
}
|
||||
|
||||
// TokenAccountForCorpID returns the keychain account used for a corp-bound token.
|
||||
func TokenAccountForCorpID(corpID string) string {
|
||||
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
|
||||
}
|
||||
|
||||
// SaveTokenDataKeychainForCorpID saves TokenData to a corp-scoped keychain slot.
|
||||
func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return fmt.Errorf("corpId is required for profile token storage")
|
||||
}
|
||||
return saveTokenDataKeychainAccount(TokenAccountForCorpID(corpID), data)
|
||||
}
|
||||
|
||||
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal token data: %w", err)
|
||||
@@ -41,7 +60,7 @@ func SaveTokenDataKeychain(data *TokenData) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := keychain.Set(keychain.Service, keychain.AccountToken, string(jsonData)); err != nil {
|
||||
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
|
||||
return fmt.Errorf("save to keychain: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -49,12 +68,25 @@ func SaveTokenDataKeychain(data *TokenData) error {
|
||||
|
||||
// LoadTokenDataKeychain loads TokenData from the platform keychain.
|
||||
func LoadTokenDataKeychain() (*TokenData, error) {
|
||||
jsonStr, err := keychain.Get(keychain.Service, keychain.AccountToken)
|
||||
return loadTokenDataKeychainAccount(keychain.AccountToken)
|
||||
}
|
||||
|
||||
// LoadTokenDataKeychainForCorpID loads TokenData from a corp-scoped keychain slot.
|
||||
func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return nil, fmt.Errorf("corpId is required for profile token storage")
|
||||
}
|
||||
return loadTokenDataKeychainAccount(TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
jsonStr, err := keychain.Get(keychain.Service, account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load from keychain: %w", err)
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("no token data in keychain")
|
||||
return nil, fmt.Errorf("no token data in keychain account %q", account)
|
||||
}
|
||||
|
||||
var data TokenData
|
||||
@@ -69,11 +101,29 @@ func DeleteTokenDataKeychain() error {
|
||||
return keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
}
|
||||
|
||||
// DeleteTokenDataKeychainForCorpID removes TokenData from a corp-scoped keychain slot.
|
||||
func DeleteTokenDataKeychainForCorpID(corpID string) error {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return fmt.Errorf("corpId is required for profile token storage")
|
||||
}
|
||||
return keychain.Remove(keychain.Service, TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
// TokenDataExistsKeychain checks if token data exists in keychain.
|
||||
func TokenDataExistsKeychain() bool {
|
||||
return keychain.Exists(keychain.Service, keychain.AccountToken)
|
||||
}
|
||||
|
||||
// TokenDataExistsKeychainForCorpID checks if a corp-scoped token exists.
|
||||
func TokenDataExistsKeychainForCorpID(corpID string) bool {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if corpID == "" {
|
||||
return false
|
||||
}
|
||||
return keychain.Exists(keychain.Service, TokenAccountForCorpID(corpID))
|
||||
}
|
||||
|
||||
// EnsureMigration performs one-time migration from legacy .data to keychain.
|
||||
// This should be called early in the auth flow (e.g., during GetAccessToken).
|
||||
// The migration is idempotent and thread-safe.
|
||||
|
||||
@@ -18,11 +18,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
@@ -143,9 +145,13 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.CorpName = data.CorpName
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
if err := SaveTokenData(p.configDir, updated); err != nil {
|
||||
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
|
||||
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
|
||||
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
|
||||
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
@@ -185,9 +191,13 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.CorpName = data.CorpName
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
if err := SaveTokenData(p.configDir, updated); err != nil {
|
||||
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
|
||||
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
|
||||
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
|
||||
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
@@ -203,6 +213,7 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any)
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
@@ -259,7 +270,7 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
|
||||
}
|
||||
|
||||
// parseMCPTokenResponse parses token response from MCP proxy.
|
||||
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
|
||||
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
|
||||
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
var resp struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
@@ -267,6 +278,9 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
PersistentCode string `json:"persistentCode"`
|
||||
ExpiresIn int64 `json:"expiresIn"`
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName"`
|
||||
CorpNameSnake string `json:"corp_name"`
|
||||
OrgName string `json:"orgName"`
|
||||
// Error fields (when request fails)
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
@@ -293,6 +307,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
|
||||
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
|
||||
CorpID: resp.CorpID,
|
||||
CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName),
|
||||
}
|
||||
if resp.PersistentCode != "" {
|
||||
data.PersistentCode = resp.PersistentCode
|
||||
@@ -300,7 +315,16 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI string) string {
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
params := url.Values{
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
@@ -308,6 +332,9 @@ func buildAuthURL(clientID, redirectURI string) string {
|
||||
"scope": {DefaultScopes},
|
||||
"prompt": {"consent"},
|
||||
}
|
||||
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
|
||||
params.Set("corpId", targetCorpID)
|
||||
}
|
||||
return AuthorizeURL + "?" + params.Encode()
|
||||
}
|
||||
|
||||
@@ -1118,6 +1145,112 @@ const channelDeniedHTML = `<!doctype html>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const enterpriseDeniedHTML = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>钉钉 CLI</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
height: 600px;
|
||||
width: 480px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #f2f2f6;
|
||||
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
||||
padding: 32px 24px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lock-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
h1 {
|
||||
margin: 8px 0 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: #181c1f;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"PingFang SC",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
line-height: 21px;
|
||||
text-align: center;
|
||||
letter-spacing: normal;
|
||||
color: rgba(24, 28, 31, 0.6);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img
|
||||
class="lock-icon"
|
||||
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
||||
alt="lock icon"
|
||||
/>
|
||||
<h1>企业安全认证未通过</h1>
|
||||
<p>__ENTERPRISE_DENIED_MSG__</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// defaultEnterpriseDeniedMsg is shown when the server returns no errorMsg.
|
||||
const defaultEnterpriseDeniedMsg = "本次请求未通过企业安全认证"
|
||||
|
||||
// renderEnterpriseDeniedHTML injects the server-provided denial message (falling
|
||||
// back to the default text) into the enterprise-denied page. The message is
|
||||
// HTML-escaped before insertion.
|
||||
func renderEnterpriseDeniedHTML(serverMsg string) string {
|
||||
msg := strings.TrimSpace(serverMsg)
|
||||
if msg == "" {
|
||||
msg = defaultEnterpriseDeniedMsg
|
||||
}
|
||||
return strings.ReplaceAll(enterpriseDeniedHTML, "__ENTERPRISE_DENIED_MSG__", html.EscapeString(msg)+" 此页面可以关闭。")
|
||||
}
|
||||
|
||||
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
|
||||
type CLIAuthStatus struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -1154,6 +1287,9 @@ func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string {
|
||||
if status.ErrorCode == "CHANNEL_REQUIRED" {
|
||||
return "channel_required"
|
||||
}
|
||||
if status.ErrorCode == "ENTERPRISE_NOT_AUTHORIZED" {
|
||||
return "enterprise_not_authorized"
|
||||
}
|
||||
if status.ErrorCode == "NO_AUTH" {
|
||||
return "no_auth"
|
||||
}
|
||||
@@ -1243,6 +1379,7 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
|
||||
req.Header.Set("x-dws-channel", ch)
|
||||
}
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
@@ -1294,6 +1431,7 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -1341,6 +1479,7 @@ func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -37,11 +38,13 @@ var oauthHTTPClient = &http.Client{
|
||||
|
||||
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
|
||||
type OAuthProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
configDir string
|
||||
clientID string
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
TargetCorpID string
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -56,8 +59,8 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider {
|
||||
}
|
||||
|
||||
// resetCredentialState clears any stale credential state inherited from
|
||||
// previous login methods so that OAuth flow always starts fresh by
|
||||
// fetching clientID from MCP.
|
||||
// previous login methods before the OAuth flow falls back to MCP-managed
|
||||
// credentials. Complete runtime AppKey/AppSecret overrides skip this reset.
|
||||
func (p *OAuthProvider) resetCredentialState() {
|
||||
p.clientID = ""
|
||||
clientMu.Lock()
|
||||
@@ -109,22 +112,29 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we always re-fetch clientID from MCP. This ensures
|
||||
// --force login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
clientIDFromMCP = false
|
||||
clientMu.Unlock()
|
||||
} else {
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we can re-fetch clientID from MCP. This ensures --force
|
||||
// login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
}
|
||||
|
||||
// Find a free port for the callback server.
|
||||
@@ -141,6 +151,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
err error
|
||||
cliAuthDisabled bool
|
||||
denialReason string
|
||||
errorMsg string // server-provided errorMsg from /cli/cliAuthEnabled
|
||||
}
|
||||
resultCh := make(chan callbackResult, 1)
|
||||
errCh := make(chan error, 1)
|
||||
@@ -261,6 +272,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
cliAuthEnabled := denialReason == ""
|
||||
|
||||
// Server-provided errorMsg (nil-safe), surfaced both on the page and to
|
||||
// the terminal so portal can update copy without releasing the CLI.
|
||||
serverMsg := ""
|
||||
if authStatus != nil {
|
||||
serverMsg = authStatus.ErrorMsg
|
||||
}
|
||||
|
||||
// Update CLI auth disabled state
|
||||
callbackTokenMu.Lock()
|
||||
callbackAuthDisabled = !cliAuthEnabled
|
||||
@@ -275,6 +293,8 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = fmt.Fprint(w, accessDeniedHTML)
|
||||
case denialReason == "channel_not_allowed" || denialReason == "channel_required":
|
||||
_, _ = fmt.Fprint(w, channelDeniedHTML)
|
||||
case denialReason == "enterprise_not_authorized":
|
||||
_, _ = fmt.Fprint(w, renderEnterpriseDeniedHTML(serverMsg))
|
||||
default:
|
||||
_, _ = fmt.Fprint(w, notEnabledHTML)
|
||||
}
|
||||
@@ -284,7 +304,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
// Notify main goroutine with full result
|
||||
select {
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}:
|
||||
case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason, errorMsg: serverMsg}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
@@ -389,12 +409,14 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_ = server.Shutdown(shutCtx)
|
||||
}()
|
||||
|
||||
authURL := buildAuthURL(p.clientID, redirectURI)
|
||||
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
|
||||
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(), "")
|
||||
@@ -433,6 +455,11 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单"))
|
||||
case "channel_not_allowed", "channel_required":
|
||||
return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI"))
|
||||
case "enterprise_not_authorized":
|
||||
if msg := strings.TrimSpace(result.errorMsg); msg != "" {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
return nil, errors.New(i18n.T("本次请求未通过企业安全认证"))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
@@ -537,9 +564,12 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
|
||||
if rErr == nil {
|
||||
return refreshed.AccessToken, nil
|
||||
}
|
||||
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
||||
}
|
||||
} else {
|
||||
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
|
||||
}
|
||||
|
||||
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
// 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 (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// PortableImportReport summarizes bundle metadata consumed during import.
|
||||
type PortableImportReport struct {
|
||||
BundleOS string
|
||||
OSMismatch bool
|
||||
}
|
||||
|
||||
// PortableExportSupported reports whether the current platform can produce a
|
||||
// bundle that includes the file-based DEK required for import elsewhere.
|
||||
func PortableExportSupported() bool {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return true
|
||||
}
|
||||
return os.Getenv(keychain.DisableKeychainEnv) != ""
|
||||
}
|
||||
|
||||
// PortableAuthTargetPopulated reports whether local auth files would be
|
||||
// overwritten by a portable import.
|
||||
func PortableAuthTargetPopulated(configDir string) bool {
|
||||
if TokenDataExistsKeychain() {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(ProfilesPath(configDir)); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
encPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")
|
||||
if _, err := os.Stat(encPath); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PortableAuthSourceReady reports whether encrypted auth token exists for export.
|
||||
func PortableAuthSourceReady() bool {
|
||||
return portableAuthSourcePopulated(keychain.StorageDir(keychain.Service))
|
||||
}
|
||||
|
||||
func portableAuthSourcePopulated(keychainDir string) bool {
|
||||
_, err := os.Stat(filepath.Join(keychainDir, keychain.AccountToken+".enc"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
const portableAuthManifest = "manifest.json"
|
||||
|
||||
type portableAuthBundleManifest struct {
|
||||
Version int `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
OS string `json:"os"`
|
||||
KeychainService string `json:"keychain_service"`
|
||||
ConfigFiles []string `json:"config_files,omitempty"`
|
||||
}
|
||||
|
||||
// ExportPortableAuthBundle writes a portable auth bundle as tar.gz.
|
||||
// It copies the encrypted keychain files plus the small config files needed
|
||||
// to refresh tokens in another Linux sandbox.
|
||||
func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("missing output writer")
|
||||
}
|
||||
if !PortableExportSupported() {
|
||||
return fmt.Errorf("portable export unavailable on macOS while DEK is in system Keychain; set %s=1, re-login, then export", keychain.DisableKeychainEnv)
|
||||
}
|
||||
keychainDir := keychain.StorageDir(keychain.Service)
|
||||
if _, err := os.Stat(keychainDir); err != nil {
|
||||
return fmt.Errorf("auth keychain directory is not available: %w", err)
|
||||
}
|
||||
if !portableAuthSourcePopulated(keychainDir) {
|
||||
return fmt.Errorf("auth token is not available for export; run dws auth login first")
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
|
||||
configFiles, err := portableConfigFiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest := portableAuthBundleManifest{
|
||||
Version: 1,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
OS: runtime.GOOS,
|
||||
KeychainService: keychain.Service,
|
||||
ConfigFiles: configFiles,
|
||||
}
|
||||
if err := writePortableManifest(tw, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := addPortableDir(tw, keychainDir, path.Join("keychain", keychain.Service)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range configFiles {
|
||||
src := filepath.Join(configDir, name)
|
||||
if err := addPortableFile(tw, src, path.Join("config", filepath.ToSlash(name))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportPortableAuthBundle extracts a tar.gz auth bundle into the current
|
||||
// config and keychain locations.
|
||||
func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportReport, error) {
|
||||
if r == nil {
|
||||
return PortableImportReport{}, fmt.Errorf("missing input reader")
|
||||
}
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return PortableImportReport{}, fmt.Errorf("open auth bundle: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
keychainDir := keychain.StorageDir(keychain.Service)
|
||||
var manifest portableAuthBundleManifest
|
||||
manifestRead := false
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return PortableImportReport{}, fmt.Errorf("read auth bundle: %w", err)
|
||||
}
|
||||
if hdr == nil {
|
||||
continue
|
||||
}
|
||||
cleanName, err := cleanPortableName(hdr.Name)
|
||||
if err != nil {
|
||||
return PortableImportReport{}, err
|
||||
}
|
||||
if cleanName == portableAuthManifest {
|
||||
if err := json.NewDecoder(tr).Decode(&manifest); err != nil {
|
||||
return PortableImportReport{}, fmt.Errorf("read auth bundle manifest: %w", err)
|
||||
}
|
||||
manifestRead = true
|
||||
continue
|
||||
}
|
||||
|
||||
var target string
|
||||
switch {
|
||||
case strings.HasPrefix(cleanName, "keychain/"+keychain.Service+"/"):
|
||||
rel := strings.TrimPrefix(cleanName, "keychain/"+keychain.Service+"/")
|
||||
target, err = safeJoin(keychainDir, rel)
|
||||
case strings.HasPrefix(cleanName, "config/"):
|
||||
rel := strings.TrimPrefix(cleanName, "config/")
|
||||
target, err = safeJoin(configDir, rel)
|
||||
default:
|
||||
return PortableImportReport{}, fmt.Errorf("unsupported auth bundle path %q", hdr.Name)
|
||||
}
|
||||
if err != nil {
|
||||
return PortableImportReport{}, err
|
||||
}
|
||||
if err := extractPortableEntry(target, hdr, tr); err != nil {
|
||||
return PortableImportReport{}, err
|
||||
}
|
||||
}
|
||||
report := PortableImportReport{}
|
||||
if manifestRead {
|
||||
report.BundleOS = manifest.OS
|
||||
report.OSMismatch = manifest.OS != "" && manifest.OS != runtime.GOOS
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func portableConfigFiles(configDir string) ([]string, error) {
|
||||
var files []string
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
|
||||
for _, pattern := range patterns {
|
||||
matches, err := filepath.Glob(filepath.Join(configDir, pattern))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan config files: %w", err)
|
||||
}
|
||||
for _, match := range matches {
|
||||
info, err := os.Stat(match)
|
||||
if err != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
rel, err := filepath.Rel(configDir, match)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve config file: %w", err)
|
||||
}
|
||||
files = append(files, rel)
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func writePortableManifest(tw *tar.Writer, manifest portableAuthBundleManifest) error {
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal auth bundle manifest: %w", err)
|
||||
}
|
||||
return writePortableBytes(tw, portableAuthManifest, append(data, '\n'), config.FilePerm)
|
||||
}
|
||||
|
||||
func addPortableDir(tw *tar.Writer, root, prefix string) error {
|
||||
return filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if filePath == root {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := path.Join(prefix, filepath.ToSlash(rel))
|
||||
return tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeDir, Mode: int64(config.DirPerm)})
|
||||
}
|
||||
return addPortableFile(tw, filePath, path.Join(prefix, mustPortableRel(root, filePath)))
|
||||
})
|
||||
}
|
||||
|
||||
func mustPortableRel(root, filePath string) string {
|
||||
rel, err := filepath.Rel(root, filePath)
|
||||
if err != nil {
|
||||
return filepath.Base(filePath)
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func addPortableFile(tw *tar.Writer, src, name string) error {
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", src, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", src, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: path.Clean(name),
|
||||
Size: info.Size(),
|
||||
Mode: int64(config.FilePerm),
|
||||
ModTime: info.ModTime(),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write auth bundle header: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
return fmt.Errorf("write auth bundle file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePortableBytes(tw *tar.Writer, name string, data []byte, mode os.FileMode) error {
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: path.Clean(name),
|
||||
Size: int64(len(data)),
|
||||
Mode: int64(mode),
|
||||
ModTime: time.Now(),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write auth bundle header: %w", err)
|
||||
}
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return fmt.Errorf("write auth bundle data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanPortableName(name string) (string, error) {
|
||||
name = path.Clean(strings.TrimSpace(name))
|
||||
if name == "." || name == "/" || strings.HasPrefix(name, "../") || strings.HasPrefix(name, "/") {
|
||||
return "", fmt.Errorf("unsafe auth bundle path %q", name)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func safeJoin(root, rel string) (string, error) {
|
||||
if rel == "" {
|
||||
return "", fmt.Errorf("empty auth bundle path")
|
||||
}
|
||||
rel = filepath.FromSlash(path.Clean(rel))
|
||||
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
||||
return "", fmt.Errorf("unsafe auth bundle path %q", rel)
|
||||
}
|
||||
target := filepath.Join(root, rel)
|
||||
cleanRoot := filepath.Clean(root) + string(filepath.Separator)
|
||||
if target != filepath.Clean(root) && !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanRoot) {
|
||||
return "", fmt.Errorf("unsafe auth bundle path %q", rel)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func extractPortableEntry(target string, hdr *tar.Header, r io.Reader) error {
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("create auth bundle directory: %w", err)
|
||||
}
|
||||
return os.Chmod(target, config.DirPerm)
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), config.DirPerm); err != nil {
|
||||
return fmt.Errorf("create auth bundle directory: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth bundle temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if err := tmp.Chmod(config.FilePerm); err != nil {
|
||||
return fmt.Errorf("set auth bundle file permissions: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
return fmt.Errorf("write auth bundle file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("sync auth bundle file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close auth bundle file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, target); err != nil {
|
||||
return fmt.Errorf("install auth bundle file: %w", err)
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported auth bundle entry type %d for %q", hdr.Typeflag, hdr.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
func TestPortableExportSupported(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
if !PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be true on non-darwin")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
if PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be false on darwin without file DEK")
|
||||
}
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
if !PortableExportSupported() {
|
||||
t.Fatal("PortableExportSupported() should be true when file DEK is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPortableAuthBundleRequiresAuthToken(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
keychainRoot := filepath.Join(t.TempDir(), "empty-keychain")
|
||||
if err := os.MkdirAll(filepath.Join(keychainRoot, keychain.Service), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
t.Setenv(keychain.StorageDirEnv, keychainRoot)
|
||||
configDir := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
var bundle bytes.Buffer
|
||||
err := ExportPortableAuthBundle(configDir, &bundle)
|
||||
if err == nil {
|
||||
t.Fatal("ExportPortableAuthBundle() should fail without auth-token.enc")
|
||||
}
|
||||
if bundle.Len() != 0 {
|
||||
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthTargetPopulated(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
||||
|
||||
if PortableAuthTargetPopulated(configDir) {
|
||||
t.Fatal("PortableAuthTargetPopulated() should be false before save")
|
||||
}
|
||||
if err := SaveTokenData(configDir, &TokenData{
|
||||
AccessToken: "token",
|
||||
RefreshToken: "refresh",
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if !PortableAuthTargetPopulated(configDir) {
|
||||
t.Fatal("PortableAuthTargetPopulated() should be true after save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
||||
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
original := &TokenData{
|
||||
AccessToken: "access-source",
|
||||
RefreshToken: "refresh-source",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
CorpID: "dingcorp",
|
||||
ClientID: "client-from-mcp",
|
||||
Source: "mcp",
|
||||
}
|
||||
if err := SaveTokenData(sourceConfig, original); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := SaveAppConfig(sourceConfig, &AppConfig{ClientID: "client-from-mcp"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
||||
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
if bundle.Len() == 0 {
|
||||
t.Fatal("ExportPortableAuthBundle() wrote an empty bundle")
|
||||
}
|
||||
|
||||
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
||||
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
||||
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(targetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after import error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != original.AccessToken {
|
||||
t.Fatalf("access token = %q, want %q", loaded.AccessToken, original.AccessToken)
|
||||
}
|
||||
if loaded.RefreshToken != original.RefreshToken {
|
||||
t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken)
|
||||
}
|
||||
if !loaded.IsRefreshTokenValid() {
|
||||
t.Fatal("refresh token should remain valid after import")
|
||||
}
|
||||
if cfg, err := LoadAppConfig(targetConfig); err != nil {
|
||||
t.Fatalf("LoadAppConfig() after import error = %v", err)
|
||||
} else if cfg == nil || cfg.ClientID != "client-from-mcp" {
|
||||
t.Fatalf("imported app config = %#v, want client ID preserved", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
SetRuntimeProfile("")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
|
||||
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
||||
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
|
||||
tokenA := &TokenData{
|
||||
AccessToken: "access-a",
|
||||
RefreshToken: "refresh-a",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
CorpID: "corp_a",
|
||||
CorpName: "A Org",
|
||||
ClientID: "client-a",
|
||||
}
|
||||
tokenB := &TokenData{
|
||||
AccessToken: "access-b",
|
||||
RefreshToken: "refresh-b",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
CorpID: "corp_b",
|
||||
CorpName: "B Org",
|
||||
ClientID: "client-b",
|
||||
}
|
||||
if err := SaveTokenData(sourceConfig, tokenA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(sourceConfig, tokenB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
||||
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
||||
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
||||
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
||||
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(targetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() after import error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
||||
t.Fatalf("profiles after import = %#v", cfg)
|
||||
}
|
||||
if len(cfg.Profiles) != 2 {
|
||||
t.Fatalf("profiles len = %d, want 2: %#v", len(cfg.Profiles), cfg.Profiles)
|
||||
}
|
||||
|
||||
loadedA, err := LoadTokenDataForProfile(targetConfig, "corp_a")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(A) after import error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "access-a" {
|
||||
t.Fatalf("profile A token = %q, want access-a", loadedA.AccessToken)
|
||||
}
|
||||
loadedB, err := LoadTokenDataForProfile(targetConfig, "corp_b")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(B) after import error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "access-b" {
|
||||
t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// withProfilesLock runs fn while holding the auth dual-layer lock (process +
|
||||
// cross-process file lock) so that all read-modify-write cycles on
|
||||
// profiles.json and the legacy token mirror are serialized.
|
||||
//
|
||||
// The lock is NOT reentrant. fn must only call the lock-free *Locked variants;
|
||||
// calling a public (locking) function from within fn would deadlock. Paths that
|
||||
// already hold the lock (e.g. OAuthProvider.lockedRefresh and the read path
|
||||
// reached from it) must likewise call the lock-free variants directly.
|
||||
func withProfilesLock(configDir string, fn func() error) error {
|
||||
lock, err := AcquireDualLock(context.Background(), configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lock.Release()
|
||||
return fn()
|
||||
}
|
||||
|
||||
const profilesJSONFile = "profiles.json"
|
||||
|
||||
const (
|
||||
ProfileStatusActive = "active"
|
||||
ProfileStatusExpired = "expired"
|
||||
ProfileStatusRevoked = "revoked"
|
||||
)
|
||||
|
||||
// ProfilesConfig stores non-sensitive profile metadata. Token material stays in keychain.
|
||||
type ProfilesConfig struct {
|
||||
Version int `json:"version"`
|
||||
PrimaryProfile string `json:"primaryProfile,omitempty"`
|
||||
CurrentProfile string `json:"currentProfile,omitempty"`
|
||||
PreviousProfile string `json:"previousProfile,omitempty"`
|
||||
Profiles []Profile `json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// Profile is a logged-in DingTalk organization identity.
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
CorpID string `json:"corpId"`
|
||||
CorpName string `json:"corpName,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
RefreshExpAt string `json:"refreshExpAt,omitempty"`
|
||||
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
||||
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
runtimeProfileMu sync.RWMutex
|
||||
runtimeProfile string
|
||||
)
|
||||
|
||||
// SetRuntimeProfile sets a process-local one-shot profile override.
|
||||
func SetRuntimeProfile(profile string) {
|
||||
runtimeProfileMu.Lock()
|
||||
defer runtimeProfileMu.Unlock()
|
||||
runtimeProfile = strings.TrimSpace(profile)
|
||||
}
|
||||
|
||||
// RuntimeProfile returns the process-local one-shot profile override.
|
||||
func RuntimeProfile() string {
|
||||
runtimeProfileMu.RLock()
|
||||
defer runtimeProfileMu.RUnlock()
|
||||
return runtimeProfile
|
||||
}
|
||||
|
||||
// ProfilesPath returns the profile metadata path for a config dir.
|
||||
func ProfilesPath(configDir string) string {
|
||||
return filepath.Join(configDir, profilesJSONFile)
|
||||
}
|
||||
|
||||
// LoadProfiles reads profiles.json. A missing file returns an empty config.
|
||||
func LoadProfiles(configDir string) (*ProfilesConfig, error) {
|
||||
path := ProfilesPath(configDir)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &ProfilesConfig{Version: 1}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read profiles: %w", err)
|
||||
}
|
||||
var cfg ProfilesConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
// Corrupt file (e.g. an interrupted concurrent write): quarantine it and
|
||||
// rebuild an empty config so the CLI can self-heal (auth reset / re-login)
|
||||
// instead of being permanently locked out by an unreadable profiles.json.
|
||||
quarantine := path + ".corrupt-" + time.Now().Format("20060102-150405.000")
|
||||
_ = os.Rename(path, quarantine)
|
||||
return &ProfilesConfig{Version: 1}, nil
|
||||
}
|
||||
normalizeProfilesConfig(&cfg)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveProfiles writes profiles.json atomically.
|
||||
func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
|
||||
if cfg == nil {
|
||||
cfg = &ProfilesConfig{}
|
||||
}
|
||||
normalizeProfilesConfig(cfg)
|
||||
if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal profiles: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
path := ProfilesPath(configDir)
|
||||
// Per-write random temp name: a fixed "profiles.json.tmp" lets two
|
||||
// concurrent writers interleave into the same temp file and rename a
|
||||
// corrupted result into place.
|
||||
tmp := path + "." + uuid.New().String() + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
|
||||
return fmt.Errorf("write profiles tmp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("rename profiles: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed.
|
||||
// EnsureProfilesMigration migrates a legacy single-slot token into the
|
||||
// profiles registry. It acquires the lock; call ensureProfilesMigrationLocked
|
||||
// from contexts that already hold it (refresh / read paths).
|
||||
func EnsureProfilesMigration(configDir string) error {
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return ensureProfilesMigrationLocked(configDir)
|
||||
})
|
||||
}
|
||||
|
||||
func ensureProfilesMigrationLocked(configDir string) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Profiles) > 0 {
|
||||
return nil
|
||||
}
|
||||
if !TokenDataExistsKeychain() {
|
||||
return nil
|
||||
}
|
||||
data, err := LoadTokenDataKeychain()
|
||||
if err != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertProfileFromToken(configDir, cfg, data, false)
|
||||
}
|
||||
|
||||
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
|
||||
func UpsertProfileFromToken(configDir string, data *TokenData) error {
|
||||
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
|
||||
}
|
||||
|
||||
// UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes
|
||||
// the token's corp the persistent current profile.
|
||||
func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error {
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent)
|
||||
})
|
||||
}
|
||||
|
||||
func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertProfileFromToken(configDir, cfg, data, makeCurrent)
|
||||
}
|
||||
|
||||
func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenData, makeCurrent bool) error {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if corpID == "" {
|
||||
return nil
|
||||
}
|
||||
normalizeProfilesConfig(cfg)
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
idx := profileIndexByCorpID(cfg, corpID)
|
||||
if idx < 0 {
|
||||
profile := Profile{
|
||||
Name: chooseProfileName(cfg, data),
|
||||
CorpID: corpID,
|
||||
CorpName: strings.TrimSpace(data.CorpName),
|
||||
UserID: strings.TrimSpace(data.UserID),
|
||||
UserName: strings.TrimSpace(data.UserName),
|
||||
ClientID: strings.TrimSpace(data.ClientID),
|
||||
Status: ProfileStatusActive,
|
||||
ExpiresAt: timeOrRFC3339(data.ExpiresAt),
|
||||
RefreshExpAt: timeOrRFC3339(data.RefreshExpAt),
|
||||
LastLoginAt: now,
|
||||
LastUsedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
cfg.Profiles = append(cfg.Profiles, profile)
|
||||
} else {
|
||||
p := &cfg.Profiles[idx]
|
||||
if shouldRefreshProfileName(p, data) {
|
||||
p.Name = chooseProfileName(cfg, data)
|
||||
}
|
||||
if v := strings.TrimSpace(data.CorpName); v != "" {
|
||||
p.CorpName = v
|
||||
}
|
||||
if v := strings.TrimSpace(data.UserID); v != "" {
|
||||
p.UserID = v
|
||||
}
|
||||
if v := strings.TrimSpace(data.UserName); v != "" {
|
||||
p.UserName = v
|
||||
}
|
||||
if v := strings.TrimSpace(data.ClientID); v != "" {
|
||||
p.ClientID = v
|
||||
}
|
||||
p.Status = ProfileStatusActive
|
||||
p.ExpiresAt = timeOrRFC3339(data.ExpiresAt)
|
||||
p.RefreshExpAt = timeOrRFC3339(data.RefreshExpAt)
|
||||
p.LastLoginAt = now
|
||||
p.LastUsedAt = now
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
if cfg.PrimaryProfile == "" {
|
||||
cfg.PrimaryProfile = corpID
|
||||
}
|
||||
if makeCurrent && cfg.CurrentProfile != corpID {
|
||||
if cfg.CurrentProfile != "" {
|
||||
cfg.PreviousProfile = cfg.CurrentProfile
|
||||
}
|
||||
cfg.CurrentProfile = corpID
|
||||
}
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = corpID
|
||||
}
|
||||
return SaveProfiles(configDir, cfg)
|
||||
}
|
||||
|
||||
// ResolveProfile returns a profile selected by name/corpId or by current/primary fallback.
|
||||
func ResolveProfile(configDir, selector string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector != "" {
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector != "" {
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", selector)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
|
||||
if p := findProfile(cfg, candidate); p != nil && TokenDataExistsKeychainForCorpID(p.CorpID) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
|
||||
return p, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SetCurrentProfile persists the selected current profile.
|
||||
func SetCurrentProfile(configDir, selector string) (*Profile, error) {
|
||||
var result *Profile
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
p, e := setCurrentProfileLocked(configDir, selector)
|
||||
result = p
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
||||
}
|
||||
if cfg.CurrentProfile != p.CorpID {
|
||||
if cfg.CurrentProfile != "" {
|
||||
cfg.PreviousProfile = cfg.CurrentProfile
|
||||
}
|
||||
cfg.CurrentProfile = p.CorpID
|
||||
}
|
||||
touchProfile(cfg, p.CorpID)
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return findProfile(cfg, p.CorpID), nil
|
||||
}
|
||||
|
||||
// UsePreviousProfile toggles currentProfile and previousProfile.
|
||||
func UsePreviousProfile(configDir string) (*Profile, error) {
|
||||
var result *Profile
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
p, e := usePreviousProfileLocked(configDir)
|
||||
result = p
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func usePreviousProfileLocked(configDir string) (*Profile, error) {
|
||||
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prev := strings.TrimSpace(cfg.PreviousProfile)
|
||||
if prev == "" {
|
||||
return nil, fmt.Errorf("previous profile is empty")
|
||||
}
|
||||
p := findProfile(cfg, prev)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("previous profile %q not found", prev)
|
||||
}
|
||||
cfg.PreviousProfile, cfg.CurrentProfile = cfg.CurrentProfile, p.CorpID
|
||||
touchProfile(cfg, p.CorpID)
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return findProfile(cfg, p.CorpID), nil
|
||||
}
|
||||
|
||||
// RemoveProfile removes a profile from metadata and returns the removed profile.
|
||||
func RemoveProfile(configDir, selector string) (*Profile, error) {
|
||||
var result *Profile
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
p, e := removeProfileLocked(configDir, selector)
|
||||
result = p
|
||||
return e
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := findProfile(cfg, selector)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
||||
}
|
||||
removed := *p
|
||||
kept := cfg.Profiles[:0]
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.CorpID != removed.CorpID {
|
||||
kept = append(kept, profile)
|
||||
}
|
||||
}
|
||||
cfg.Profiles = kept
|
||||
if cfg.PrimaryProfile == removed.CorpID {
|
||||
cfg.PrimaryProfile = firstProfileCorpID(cfg)
|
||||
}
|
||||
if cfg.CurrentProfile == removed.CorpID {
|
||||
cfg.CurrentProfile = cfg.PrimaryProfile
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = firstProfileCorpID(cfg)
|
||||
}
|
||||
}
|
||||
if cfg.PreviousProfile == removed.CorpID {
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
cfg.PrimaryProfile = ""
|
||||
cfg.CurrentProfile = ""
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &removed, nil
|
||||
}
|
||||
|
||||
// MarkProfileStatus updates a profile status if it exists.
|
||||
func MarkProfileStatus(configDir, corpID, status string) error {
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return nil
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return markProfileStatusLocked(configDir, corpID, status)
|
||||
})
|
||||
}
|
||||
|
||||
func markProfileStatusLocked(configDir, corpID, status string) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := findProfile(cfg, corpID)
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.Status = strings.TrimSpace(status)
|
||||
p.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
return SaveProfiles(configDir, cfg)
|
||||
}
|
||||
|
||||
// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token.
|
||||
func SyncLegacyTokenMirror(configDir string) error {
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return syncLegacyTokenMirrorLocked(configDir)
|
||||
})
|
||||
}
|
||||
|
||||
func syncLegacyTokenMirrorLocked(configDir string) error {
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hadReadError := false
|
||||
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
|
||||
p := findProfile(cfg, candidate)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID)
|
||||
if loadErr != nil {
|
||||
// Transient keychain read failure: do NOT touch the existing mirror.
|
||||
hadReadError = true
|
||||
continue
|
||||
}
|
||||
if data != nil {
|
||||
if err := SaveTokenDataKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteTokenMarker(configDir)
|
||||
}
|
||||
}
|
||||
if hadReadError {
|
||||
// Keep the existing legacy mirror untouched rather than wiping a host
|
||||
// app's login state just because keychain was momentarily unavailable.
|
||||
return nil
|
||||
}
|
||||
// All candidate profiles confirmed absent (no token): clear the mirror.
|
||||
_ = DeleteTokenDataKeychain()
|
||||
_ = DeleteTokenMarker(configDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProfilesConfig(cfg *ProfilesConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cfg.Version = 1
|
||||
seen := make(map[string]bool, len(cfg.Profiles))
|
||||
profiles := cfg.Profiles[:0]
|
||||
for _, p := range cfg.Profiles {
|
||||
p.CorpID = strings.TrimSpace(p.CorpID)
|
||||
if p.CorpID == "" || seen[p.CorpID] {
|
||||
continue
|
||||
}
|
||||
seen[p.CorpID] = true
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" {
|
||||
p.Name = p.CorpID
|
||||
}
|
||||
if corpName := strings.TrimSpace(p.CorpName); p.Name == p.CorpID && corpName != "" && !profileNameTakenByOtherCorp(cfg, corpName, p.CorpID) {
|
||||
p.Name = corpName
|
||||
}
|
||||
if p.Status == "" {
|
||||
p.Status = ProfileStatusActive
|
||||
}
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
cfg.Profiles = profiles
|
||||
if cfg.PrimaryProfile != "" && findProfile(cfg, cfg.PrimaryProfile) == nil {
|
||||
cfg.PrimaryProfile = ""
|
||||
}
|
||||
if cfg.CurrentProfile != "" && findProfile(cfg, cfg.CurrentProfile) == nil {
|
||||
cfg.CurrentProfile = ""
|
||||
}
|
||||
if cfg.PreviousProfile != "" && findProfile(cfg, cfg.PreviousProfile) == nil {
|
||||
cfg.PreviousProfile = ""
|
||||
}
|
||||
if cfg.PrimaryProfile == "" {
|
||||
cfg.PrimaryProfile = firstProfileCorpID(cfg)
|
||||
}
|
||||
if cfg.CurrentProfile == "" {
|
||||
cfg.CurrentProfile = cfg.PrimaryProfile
|
||||
}
|
||||
}
|
||||
|
||||
func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string {
|
||||
base := strings.TrimSpace(data.CorpName)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(data.CorpID)
|
||||
}
|
||||
if base == "" {
|
||||
base = "profile"
|
||||
}
|
||||
if !profileNameTakenByOtherCorp(cfg, base, data.CorpID) {
|
||||
return base
|
||||
}
|
||||
suffix := shortCorpID(data.CorpID)
|
||||
name := base + "-" + suffix
|
||||
if !profileNameTakenByOtherCorp(cfg, name, data.CorpID) {
|
||||
return name
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
candidate := fmt.Sprintf("%s-%s-%d", base, suffix, i)
|
||||
if !profileNameTakenByOtherCorp(cfg, candidate, data.CorpID) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRefreshProfileName(p *Profile, data *TokenData) bool {
|
||||
if p == nil || data == nil {
|
||||
return false
|
||||
}
|
||||
name := strings.TrimSpace(p.Name)
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(data.CorpName) != "" && name == strings.TrimSpace(p.CorpID)
|
||||
}
|
||||
|
||||
func profileNameTakenByOtherCorp(cfg *ProfilesConfig, name, corpID string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
for _, p := range cfg.Profiles {
|
||||
if p.CorpID != corpID && p.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findProfile(cfg *ProfilesConfig, selector string) *Profile {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return nil
|
||||
}
|
||||
var corpNameMatch *Profile
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == selector || cfg.Profiles[i].Name == selector {
|
||||
return &cfg.Profiles[i]
|
||||
}
|
||||
if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
|
||||
if corpNameMatch != nil {
|
||||
return nil
|
||||
}
|
||||
corpNameMatch = &cfg.Profiles[i]
|
||||
}
|
||||
}
|
||||
return corpNameMatch
|
||||
}
|
||||
|
||||
func profileIndexByCorpID(cfg *ProfilesConfig, corpID string) int {
|
||||
if cfg == nil {
|
||||
return -1
|
||||
}
|
||||
for i := range cfg.Profiles {
|
||||
if cfg.Profiles[i].CorpID == corpID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func firstProfileCorpID(cfg *ProfilesConfig) string {
|
||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
return cfg.Profiles[0].CorpID
|
||||
}
|
||||
|
||||
func touchProfile(cfg *ProfilesConfig, corpID string) {
|
||||
if p := findProfile(cfg, corpID); p != nil {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
p.LastUsedAt = now
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
func timeOrRFC3339(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func shortCorpID(corpID string) string {
|
||||
corpID = strings.TrimSpace(corpID)
|
||||
if len(corpID) <= 8 {
|
||||
return corpID
|
||||
}
|
||||
return corpID[len(corpID)-8:]
|
||||
}
|
||||
+167
-10
@@ -22,8 +22,11 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -82,7 +85,7 @@ func WriteTokenMarker(configDir string) error {
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(configDir, tokenJSONFile+".tmp")
|
||||
tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp")
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -91,7 +94,10 @@ func WriteTokenMarker(configDir string) error {
|
||||
|
||||
// DeleteTokenMarker removes the token.json marker file.
|
||||
func DeleteTokenMarker(configDir string) error {
|
||||
return os.Remove(filepath.Join(configDir, tokenJSONFile))
|
||||
if err := os.Remove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
|
||||
@@ -99,20 +105,67 @@ func DeleteTokenMarker(configDir string) error {
|
||||
// to the default keychain-based storage.
|
||||
func SaveTokenData(configDir string, data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling token data for hook: %w", err)
|
||||
}
|
||||
return h.SaveToken(configDir, jsonData)
|
||||
return saveTokenViaHook(h, configDir, data)
|
||||
}
|
||||
return SaveTokenDataKeychain(data)
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return saveTokenDataLocked(configDir, data)
|
||||
})
|
||||
}
|
||||
|
||||
// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror
|
||||
// writes assuming the auth dual-layer lock is already held. Callers that
|
||||
// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain
|
||||
// migration in LoadTokenDataForProfile) must use this instead of SaveTokenData
|
||||
// to avoid deadlocking on the non-reentrant lock.
|
||||
func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return saveTokenViaHook(h, configDir, data)
|
||||
}
|
||||
if data != nil && strings.TrimSpace(data.CorpID) != "" {
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
return err
|
||||
}
|
||||
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
|
||||
if err := upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent); err != nil {
|
||||
return err
|
||||
}
|
||||
if makeCurrent {
|
||||
if err := SaveTokenDataKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteTokenMarker(configDir)
|
||||
}
|
||||
if err := SaveTokenDataKeychain(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteTokenMarker(configDir)
|
||||
}
|
||||
|
||||
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling token data for hook: %w", err)
|
||||
}
|
||||
return h.SaveToken(configDir, jsonData)
|
||||
}
|
||||
|
||||
// LoadTokenData reads TokenData. When an edition hook (LoadToken) is
|
||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
||||
// to keychain with legacy .data migration.
|
||||
func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
return LoadTokenDataForProfile(configDir, RuntimeProfile())
|
||||
}
|
||||
|
||||
// LoadTokenDataForProfile reads TokenData for a profile selector without mutating
|
||||
// currentProfile. Empty selector follows the default resolution chain.
|
||||
func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if h := edition.Get(); h.LoadToken != nil {
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||
}
|
||||
jsonData, err := h.LoadToken(configDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -125,6 +178,28 @@ func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
}
|
||||
|
||||
// Default: keychain with legacy .data migration
|
||||
selected, err := resolveProfileForLoad(configDir, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selected != nil {
|
||||
data, err := LoadTokenDataKeychainForCorpID(selected.CorpID)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return nil, err
|
||||
}
|
||||
// No explicit --profile: `selected` is the resolved current/primary
|
||||
// profile. Only fall back to the legacy single slot when it belongs to
|
||||
// the SAME org; otherwise surface the error instead of silently acting
|
||||
// as a different organization (the legacy mirror may have drifted).
|
||||
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
|
||||
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
|
||||
return legacy, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if TokenDataExistsKeychain() {
|
||||
return LoadTokenDataKeychain()
|
||||
}
|
||||
@@ -132,7 +207,9 @@ func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := SaveTokenDataKeychain(data); err == nil {
|
||||
// One-time legacy secure-store -> keychain migration. This read path may run
|
||||
// while the refresh lock is already held, so use the lock-free saver.
|
||||
if err := saveTokenDataLocked(configDir, data); err == nil {
|
||||
_ = DeleteSecureData(configDir)
|
||||
}
|
||||
return data, nil
|
||||
@@ -142,15 +219,95 @@ func LoadTokenData(configDir string) (*TokenData, error) {
|
||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
||||
// to keychain + legacy cleanup.
|
||||
func DeleteTokenData(configDir string) error {
|
||||
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
|
||||
}
|
||||
|
||||
// DeleteTokenDataForProfile removes one profile's token data. Empty selector
|
||||
// removes the current/default profile, falling back to legacy single-slot auth.
|
||||
func DeleteTokenDataForProfile(configDir, profile string) error {
|
||||
if h := edition.Get(); h.DeleteToken != nil {
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||
}
|
||||
return h.DeleteToken(configDir)
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
return deleteTokenDataForProfileLocked(configDir, profile)
|
||||
})
|
||||
}
|
||||
|
||||
func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
||||
selected, err := resolveProfileForLoad(configDir, profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if selected != nil {
|
||||
keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID)
|
||||
_, removeErr := removeProfileLocked(configDir, selected.CorpID)
|
||||
legacyErr := syncLegacyTokenMirrorLocked(configDir)
|
||||
secureErr := DeleteSecureData(configDir)
|
||||
if keychainErr != nil {
|
||||
return keychainErr
|
||||
}
|
||||
if removeErr != nil {
|
||||
return removeErr
|
||||
}
|
||||
if legacyErr != nil {
|
||||
return legacyErr
|
||||
}
|
||||
return secureErr
|
||||
}
|
||||
|
||||
keychainErr := DeleteTokenDataKeychain()
|
||||
legacyErr := DeleteSecureData(configDir)
|
||||
markerErr := DeleteTokenMarker(configDir)
|
||||
if keychainErr != nil {
|
||||
return keychainErr
|
||||
}
|
||||
return legacyErr
|
||||
if legacyErr != nil {
|
||||
return legacyErr
|
||||
}
|
||||
return markerErr
|
||||
}
|
||||
|
||||
// DeleteAllTokenData removes all profile-scoped and legacy token data.
|
||||
func DeleteAllTokenData(configDir string) error {
|
||||
if h := edition.Get(); h.DeleteToken != nil {
|
||||
return h.DeleteToken(configDir)
|
||||
}
|
||||
return withProfilesLock(configDir, func() error {
|
||||
var firstErr error
|
||||
// Best-effort: even if profiles.json is unreadable, still clear every
|
||||
// other slot so the user can always self-heal via auth reset / logout.
|
||||
if cfg, err := LoadProfiles(configDir); err == nil {
|
||||
for _, profile := range cfg.Profiles {
|
||||
if e := DeleteTokenDataKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := os.Remove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
// Sweep any quarantined corrupt-profiles files so they don't accumulate.
|
||||
if matches, _ := filepath.Glob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 {
|
||||
for _, m := range matches {
|
||||
if e := os.Remove(m); e != nil && !os.IsNotExist(e) && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := DeleteTokenDataKeychain(); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
if e := DeleteSecureData(configDir); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
if e := DeleteTokenMarker(configDir); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
return firstErr
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -25,8 +26,10 @@ import (
|
||||
// written by these tests, and removes test data on completion.
|
||||
func cleanupKeychain(t *testing.T) {
|
||||
t.Helper()
|
||||
SetRuntimeProfile("")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
SetRuntimeProfile("")
|
||||
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
})
|
||||
}
|
||||
@@ -127,6 +130,271 @@ func TestTokenOverwrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiProfileSaveLoadAndSwitch(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
dataA := testToken("at_a", "corp_a", "A Org")
|
||||
dataB := testToken("at_b", "corp_b", "B Org")
|
||||
if err := SaveTokenData(configDir, dataA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, dataB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
||||
t.Fatalf("profile pointers = primary %q current %q previous %q", cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile)
|
||||
}
|
||||
|
||||
loadedB, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "at_b" {
|
||||
t.Fatalf("default token = %q, want at_b", loadedB.AccessToken)
|
||||
}
|
||||
loadedA, err := LoadTokenDataForProfile(configDir, "A Org")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(A Org) error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "at_a" {
|
||||
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
|
||||
}
|
||||
|
||||
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
|
||||
t.Fatalf("SetCurrentProfile(A) error = %v", err)
|
||||
}
|
||||
loadedA, err = LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after switch error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "at_a" {
|
||||
t.Fatalf("default token after switch = %q, want at_a", loadedA.AccessToken)
|
||||
}
|
||||
if _, err := UsePreviousProfile(configDir); err != nil {
|
||||
t.Fatalf("UsePreviousProfile() error = %v", err)
|
||||
}
|
||||
loadedB, err = LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() after previous error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "at_b" {
|
||||
t.Fatalf("default token after previous = %q, want at_b", loadedB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeProfileOverrideDoesNotMutateCurrent(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
|
||||
t.Fatalf("SetCurrentProfile(A) error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp_b")
|
||||
if err := SaveTokenData(configDir, testToken("at_b_refreshed", "corp_b", "B Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(B refresh) error = %v", err)
|
||||
}
|
||||
SetRuntimeProfile("")
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.CurrentProfile != "corp_a" {
|
||||
t.Fatalf("current profile = %q, want corp_a", cfg.CurrentProfile)
|
||||
}
|
||||
loadedB, err := LoadTokenDataForProfile(configDir, "corp_b")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(B) error = %v", err)
|
||||
}
|
||||
if loadedB.AccessToken != "at_b_refreshed" {
|
||||
t.Fatalf("profile B token = %q, want at_b_refreshed", loadedB.AccessToken)
|
||||
}
|
||||
loadedDefault, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loadedDefault.AccessToken != "at_a" {
|
||||
t.Fatalf("default token = %q, want at_a", loadedDefault.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfilePreservesOtherProfiles(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if err := DeleteTokenDataForProfile(configDir, "corp_b"); err != nil {
|
||||
t.Fatalf("DeleteTokenDataForProfile(B) error = %v", err)
|
||||
}
|
||||
if _, err := LoadTokenDataForProfile(configDir, "corp_b"); err == nil {
|
||||
t.Fatal("LoadTokenDataForProfile(B) error = nil after delete, want failure")
|
||||
}
|
||||
loadedA, err := LoadTokenDataForProfile(configDir, "corp_a")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile(A) error = %v", err)
|
||||
}
|
||||
if loadedA.AccessToken != "at_a" {
|
||||
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 || cfg.CurrentProfile != "corp_a" {
|
||||
t.Fatalf("profiles after delete = %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProfileFromTokenOverwritesSameCorp(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
first := testToken("at_first", "corp_same", "旧组织名")
|
||||
if err := SaveTokenData(configDir, first); err != nil {
|
||||
t.Fatalf("SaveTokenData(first) error = %v", err)
|
||||
}
|
||||
second := testToken("at_second", "corp_same", "新组织名")
|
||||
second.UserID = "user_updated"
|
||||
second.UserName = "Updated User"
|
||||
second.ClientID = "client_updated"
|
||||
if err := SaveTokenData(configDir, second); err != nil {
|
||||
t.Fatalf("SaveTokenData(second) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
|
||||
}
|
||||
profile := cfg.Profiles[0]
|
||||
if profile.CorpName != "新组织名" {
|
||||
t.Fatalf("corpName = %q, want 新组织名", profile.CorpName)
|
||||
}
|
||||
if profile.UserID != "user_updated" || profile.UserName != "Updated User" || profile.ClientID != "client_updated" {
|
||||
t.Fatalf("profile metadata was not overwritten: %#v", profile)
|
||||
}
|
||||
loaded, err := LoadTokenDataForProfile(configDir, "corp_same")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != "at_second" {
|
||||
t.Fatalf("access token = %q, want at_second", loaded.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProfileFromTokenPromotesCorpIDNameToCorpName(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
first := testToken("at_first", "corp_same", "")
|
||||
if err := SaveTokenData(configDir, first); err != nil {
|
||||
t.Fatalf("SaveTokenData(first) error = %v", err)
|
||||
}
|
||||
second := testToken("at_second", "corp_same", "新组织名")
|
||||
if err := SaveTokenData(configDir, second); err != nil {
|
||||
t.Fatalf("SaveTokenData(second) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
|
||||
}
|
||||
if cfg.Profiles[0].Name != "新组织名" {
|
||||
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
|
||||
}
|
||||
|
||||
resolved, err := ResolveProfile(configDir, "新组织名")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveProfile(corpName) error = %v", err)
|
||||
}
|
||||
if resolved.CorpID != "corp_same" {
|
||||
t.Fatalf("resolved corpId = %q, want corp_same", resolved.CorpID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProfilesPromotesLegacyCorpIDNameToCorpName(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
raw := `{
|
||||
"version": 1,
|
||||
"primaryProfile": "corp_same",
|
||||
"currentProfile": "corp_same",
|
||||
"profiles": [
|
||||
{
|
||||
"name": "corp_same",
|
||||
"corpId": "corp_same",
|
||||
"corpName": "新组织名"
|
||||
}
|
||||
]
|
||||
}`
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(ProfilesPath(configDir), []byte(raw), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profiles.json) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if len(cfg.Profiles) != 1 {
|
||||
t.Fatalf("profiles len = %d, want 1", len(cfg.Profiles))
|
||||
}
|
||||
if cfg.Profiles[0].Name != "新组织名" {
|
||||
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyKeychainMigrationInitializesProfile(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
legacy := testToken("at_legacy", "corp_legacy", "Legacy Org")
|
||||
if err := SaveTokenDataKeychain(legacy); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychain() error = %v", err)
|
||||
}
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != "at_legacy" {
|
||||
t.Fatalf("loaded token = %q, want at_legacy", loaded.AccessToken)
|
||||
}
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
if cfg.PrimaryProfile != "corp_legacy" || cfg.CurrentProfile != "corp_legacy" {
|
||||
t.Fatalf("profile pointers after migration = %#v", cfg)
|
||||
}
|
||||
if !TokenDataExistsKeychainForCorpID("corp_legacy") {
|
||||
t.Fatal("corp-scoped token should exist after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenDataExistsKeychain(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
|
||||
@@ -152,6 +420,21 @@ func TestTokenDataExistsKeychain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testToken(accessToken, corpID, corpName string) *TokenData {
|
||||
now := time.Now().UTC()
|
||||
return &TokenData{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: "rt_" + accessToken,
|
||||
ExpiresAt: now.Add(2 * time.Hour),
|
||||
RefreshExpAt: now.Add(30 * 24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
CorpName: corpName,
|
||||
UserID: "user_" + corpID,
|
||||
UserName: "User " + corpID,
|
||||
ClientID: "client_" + corpID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidityChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Vendored
+68
@@ -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 "<partition>.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")
|
||||
}
|
||||
|
||||
+131
@@ -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)
|
||||
}
|
||||
}
|
||||
+105
-14
@@ -103,15 +103,15 @@ func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Ru
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
func NewSchemaCommand(loader CatalogLoader, helperTools HelperToolFetcher) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
|
||||
Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。
|
||||
|
||||
不带参数时列出所有产品及其工具数量;带路径时输出该工具的完整
|
||||
输入 Schema(JSON Schema 格式)、输出 Schema、MCP 注解和 CLI
|
||||
层的 flag overlay(alias/transform/env_default)。
|
||||
输入 Schema(JSON Schema 格式)、输出 Schema、授权元数据、MCP
|
||||
注解和 CLI 层的 flag overlay(alias/transform/env_default)。
|
||||
|
||||
路径支持三种写法:
|
||||
product.rpc_name 规范路径 (e.g. ding.send_ding_message)
|
||||
@@ -123,8 +123,15 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
dws schema ding.send_ding_message # 规范路径
|
||||
dws schema "ding message send" # CLI 路径(空格)
|
||||
dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好)
|
||||
dws schema calendar.create_event --jq '.tool.auth'
|
||||
dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示
|
||||
dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay`,
|
||||
dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay
|
||||
|
||||
helper-only 命令组(如 dev,不走服务发现)也支持查询,schema 从 op-app
|
||||
MCP 服务端实时拉取,输出对齐 gws 的扁平格式(parameters 内联 required,
|
||||
键为 CLI flag):
|
||||
dws schema "dev app robot config" # 实时 MCP 参数 schema(gws-flat)
|
||||
dws schema "dev app" # 列出该分组下的子命令`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -136,6 +143,29 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
}
|
||||
args = []string{cliPath}
|
||||
}
|
||||
|
||||
// Helper-only subtrees (e.g. `dws dev ...`) aren't in the discovery
|
||||
// catalog; their schema CONTENT is fetched LIVE from the helper's
|
||||
// pinned MCP server (op-app) and rendered in the gws-flat shape, so
|
||||
// `dws schema "dev app robot config"` answers without touching
|
||||
// discovery. Only the `dev` root claims this path; everything else
|
||||
// falls through to the catalog below.
|
||||
if len(args) > 0 {
|
||||
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return output.WriteFiltered(
|
||||
cmd.OutOrStdout(),
|
||||
output.ResolveFormat(cmd, output.FormatJSON),
|
||||
payload,
|
||||
output.ResolveFields(cmd),
|
||||
output.ResolveJQ(cmd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
catalog, err := loader.Load(cmd.Context())
|
||||
if err != nil {
|
||||
var degraded *CatalogDegraded
|
||||
@@ -165,6 +195,17 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append helper-only subtrees (e.g. `dev`) to the no-arg product
|
||||
// listing so browsing all products also surfaces helper commands.
|
||||
if len(args) == 0 {
|
||||
if helpers := helperProductSummaries(cmd.Root()); len(helpers) > 0 {
|
||||
if products, ok := payload["products"].([]map[string]any); ok {
|
||||
payload["products"] = append(products, helpers...)
|
||||
payload["count"] = len(payload["products"].([]map[string]any))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output.WriteFiltered(
|
||||
cmd.OutOrStdout(),
|
||||
output.ResolveFormat(cmd, output.FormatJSON),
|
||||
@@ -222,9 +263,27 @@ func newProductCommand(product ir.CanonicalProduct, runner executor.Runner, engi
|
||||
if shortDescription == "" {
|
||||
shortDescription = product.ID
|
||||
}
|
||||
aliases := make([]string, 0, 1)
|
||||
if preferred := preferredProductRouteToken(product); preferred != "" && preferred != product.ID {
|
||||
aliases = append(aliases, preferred)
|
||||
aliases := make([]string, 0, 2)
|
||||
seenAlias := map[string]bool{product.ID: true}
|
||||
addAlias := func(s string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || seenAlias[s] {
|
||||
return
|
||||
}
|
||||
seenAlias[s] = true
|
||||
aliases = append(aliases, s)
|
||||
}
|
||||
if preferred := preferredProductRouteToken(product); preferred != "" {
|
||||
addAlias(preferred)
|
||||
}
|
||||
// Consume only cli.Aliases (canonical alternate-name field).
|
||||
// cli.Prefixes is the tool-name-prefix pool consumed by deriveCommandName;
|
||||
// treating prefixes[1:] as aliases over-registers names the wukong edition
|
||||
// does not expose, breaking cross-edition parity.
|
||||
if product.CLI != nil {
|
||||
for _, a := range product.CLI.Aliases {
|
||||
addAlias(a)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -515,6 +574,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
|
||||
@@ -522,41 +609,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)
|
||||
@@ -785,6 +873,9 @@ func compactTool(t ir.ToolDescriptor) map[string]any {
|
||||
if t.Annotations != nil {
|
||||
tool["annotations"] = t.Annotations
|
||||
}
|
||||
if t.Auth != nil {
|
||||
tool["auth"] = t.Auth
|
||||
}
|
||||
if len(t.FlagOverlay) > 0 {
|
||||
tool["flag_overlay"] = t.FlagOverlay
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,12 @@ func TestCompactToolEmitsExtendedFields(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Annotations: &ir.ToolAnnotations{DestructiveHint: &destructive},
|
||||
Auth: &ir.ToolAuthMetadata{
|
||||
ProductCode: "calendar",
|
||||
RequiredPermissions: []string{"Calendar.Event.Write"},
|
||||
GrantProductCodes: []string{"calendar"},
|
||||
AuthMetaHash: "sha256:test",
|
||||
},
|
||||
FlagOverlay: map[string]ir.FlagOverlay{
|
||||
"receiverUserIdList": {Alias: "users", Transform: "csv_to_array"},
|
||||
},
|
||||
@@ -171,6 +177,13 @@ func TestCompactToolEmitsExtendedFields(t *testing.T) {
|
||||
if _, ok := out["annotations"]; !ok {
|
||||
t.Errorf("annotations missing, keys = %v", keysOf(out))
|
||||
}
|
||||
auth, ok := out["auth"].(*ir.ToolAuthMetadata)
|
||||
if !ok {
|
||||
t.Fatalf("auth type = %T", out["auth"])
|
||||
}
|
||||
if auth.RequiredPermissions[0] != "Calendar.Event.Write" {
|
||||
t.Errorf("auth required permissions = %#v", auth.RequiredPermissions)
|
||||
}
|
||||
overlay, ok := out["flag_overlay"].(map[string]ir.FlagOverlay)
|
||||
if !ok {
|
||||
t.Fatalf("flag_overlay type = %T", out["flag_overlay"])
|
||||
@@ -275,7 +288,7 @@ func TestSchemaCommandCLIPathFlag(t *testing.T) {
|
||||
}}
|
||||
|
||||
t.Run("resolves via --cli-path", func(t *testing.T) {
|
||||
cmd := NewSchemaCommand(loader)
|
||||
cmd := NewSchemaCommand(loader, nil)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
@@ -297,7 +310,7 @@ func TestSchemaCommandCLIPathFlag(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("rejects positional + flag collision", func(t *testing.T) {
|
||||
cmd := NewSchemaCommand(loader)
|
||||
cmd := NewSchemaCommand(loader, nil)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
@@ -1239,7 +1252,7 @@ func TestSchemaCommandOutputsDegradedOnUnauthenticated(t *testing.T) {
|
||||
Reason: DegradedUnauthenticated,
|
||||
Hint: "未登录,无法发现 MCP 服务。请先执行: dws auth login",
|
||||
}
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr})
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr}, nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -1272,9 +1285,9 @@ func TestSchemaCommandOutputsDegradedOnMarketUnreachable(t *testing.T) {
|
||||
|
||||
degradedErr := &CatalogDegraded{
|
||||
Reason: DegradedMarketUnreachable,
|
||||
Hint: "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络",
|
||||
Hint: "无法连接 MCP 市场,请检查网络",
|
||||
}
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr})
|
||||
cmd := NewSchemaCommand(errorLoader{err: degradedErr}, nil)
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -1297,7 +1310,7 @@ func TestSchemaCommandPropagatesNonDegradedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantErr := errors.New("unexpected failure")
|
||||
cmd := NewSchemaCommand(errorLoader{err: wantErr})
|
||||
cmd := NewSchemaCommand(errorLoader{err: wantErr}, nil)
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// helperSchemaRoots are top-level command names whose subtrees are helper-only
|
||||
// (hard-coded cobra commands, not in the discovery catalog). `dws schema` still
|
||||
// answers for them, but unlike discovery products the schema CONTENT is fetched
|
||||
// LIVE from the helper's pinned MCP server (op-app) and rendered in the flat
|
||||
// gws-aligned shape — never synthesized from local cobra flags, never
|
||||
// hardcoded. The mapping from a leaf command to its MCP tool comes from the
|
||||
// `mcp-tool` cobra annotation set in internal/helpers/devapp.go.
|
||||
var helperSchemaRoots = map[string]bool{"dev": true}
|
||||
|
||||
// HelperToolSchema is the live op-app tool schema the renderer needs: the raw
|
||||
// MCP description plus the inputSchema's properties/required, exactly as the
|
||||
// server returned them (no local transformation of CONTENT).
|
||||
type HelperToolSchema struct {
|
||||
Name string
|
||||
Description string
|
||||
Properties map[string]any // MCP param name → property object {type,description,default?,...}
|
||||
Required []string // MCP param names that are required
|
||||
}
|
||||
|
||||
// HelperToolFetcher loads a helper MCP server's tools/list LIVE and returns
|
||||
// toolName→schema for the given source (e.g. "op-app" for dev app commands,
|
||||
// "devdoc" for dev doc commands). The schema command injects this so
|
||||
// dev_schema.go can resolve a command's MCP tool and render its real schema
|
||||
// without the cli package importing app/transport. Implementations should
|
||||
// cache per-source per-process so repeated `dws schema dev.*` only hit the
|
||||
// network once per source.
|
||||
type HelperToolFetcher func(ctx context.Context, source string) (map[string]HelperToolSchema, error)
|
||||
|
||||
// renderHelperSchema builds the `dws schema` payload for helper-only command
|
||||
// subtrees. Returns (payload, true) when the path targets a helper subtree (so
|
||||
// the caller skips catalog resolution); (nil, false) otherwise so the caller
|
||||
// falls back to the discovery catalog.
|
||||
//
|
||||
// Leaf commands render the gws-flat object {description, path, source,
|
||||
// parameters{<kebab>:{type,description,default?,required}}} with all CONTENT
|
||||
// pulled live from the MCP tool named by the command's `mcp-tool` annotation.
|
||||
// Group/root paths render a browse listing {path, commands:[...]} from the
|
||||
// cobra tree (no MCP needed).
|
||||
func renderHelperSchema(ctx context.Context, root *cobra.Command, rawPath string, fetch HelperToolFetcher) (map[string]any, bool, error) {
|
||||
if root == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tokens := splitSchemaPathTokens(rawPath)
|
||||
if len(tokens) == 0 || !helperSchemaRoots[tokens[0]] {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
target, rest, err := root.Find(tokens)
|
||||
if err != nil || target == nil {
|
||||
target = root
|
||||
rest = tokens[1:]
|
||||
}
|
||||
// Find resolves to the deepest matching command and returns trailing tokens
|
||||
// it couldn't match as (sub)commands. Any non-flag leftover means a typo'd
|
||||
// or unknown subcommand — surface it with the closest group's children.
|
||||
if unknown := firstNonFlag(rest); unknown != "" {
|
||||
return map[string]any{
|
||||
"path": rawPath,
|
||||
"error": "unknown subcommand \"" + unknown + "\" under \"" + helperCommandPath(target) + "\"",
|
||||
"available": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// A runnable leaf → emit its live MCP schema in gws-flat shape.
|
||||
// A group → browse its subcommands.
|
||||
if target.Runnable() && !target.HasAvailableSubCommands() {
|
||||
payload, err := helperLeafSchema(ctx, target, fetch)
|
||||
return payload, true, err
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"path": helperCommandPath(target),
|
||||
"commands": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// helperLeafSchema renders a single leaf command as the gws-flat object,
|
||||
// fetching its MCP tool schema live. The command must carry an `mcp-tool`
|
||||
// annotation; commands without one (e.g. `dev connect`, `dev doc search`) are
|
||||
// not devapp tools and get a clear, non-fatal explanation instead.
|
||||
func helperLeafSchema(ctx context.Context, cmd *cobra.Command, fetch HelperToolFetcher) (map[string]any, error) {
|
||||
toolName, source := "", ""
|
||||
if cmd.Annotations != nil {
|
||||
toolName = strings.TrimSpace(cmd.Annotations["mcp-tool"])
|
||||
source = strings.TrimSpace(cmd.Annotations["mcp-source"])
|
||||
}
|
||||
// Default source is op-app (dev app commands); dev doc commands annotate
|
||||
// mcp-source=devdoc to pull from the devdoc MCP server instead.
|
||||
if source == "" {
|
||||
source = "op-app"
|
||||
}
|
||||
path := helperCommandPath(cmd)
|
||||
if toolName == "" {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": "no MCP tool bound to this command; schema is unavailable",
|
||||
}, nil
|
||||
}
|
||||
if fetch == nil {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": "live MCP schema fetcher not configured",
|
||||
}, nil
|
||||
}
|
||||
|
||||
tools, err := fetch(ctx, source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s tool schemas: %w", source, err)
|
||||
}
|
||||
tool, ok := tools[toolName]
|
||||
if !ok {
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"error": fmt.Sprintf("MCP tool %q not found in %s tools/list", toolName, source),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"description": tool.Description,
|
||||
"path": path,
|
||||
"source": "mcp:" + source,
|
||||
"parameters": helperFlatParameters(tool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// helperFlatParameters projects an MCP tool's inputSchema into the gws-flat
|
||||
// per-parameter object. Keys are kebab-case of the MCP param name (== the CLI
|
||||
// flag); each value is {type, description, default?, required} with type mapped
|
||||
// to a JSON-type string, description verbatim from MCP, default only when MCP
|
||||
// provides one (stringified), and required inline (true iff the param is in the
|
||||
// tool's required[]).
|
||||
func helperFlatParameters(tool HelperToolSchema) map[string]any {
|
||||
required := make(map[string]bool, len(tool.Required))
|
||||
for _, r := range tool.Required {
|
||||
required[r] = true
|
||||
}
|
||||
|
||||
params := make(map[string]any, len(tool.Properties))
|
||||
for name, raw := range tool.Properties {
|
||||
prop, _ := raw.(map[string]any)
|
||||
entry := map[string]any{
|
||||
"type": mcpJSONType(prop),
|
||||
"description": mcpString(prop, "description"),
|
||||
"required": required[name],
|
||||
}
|
||||
if def, ok := mcpDefault(prop); ok {
|
||||
entry["default"] = def
|
||||
}
|
||||
params[kebabCase(name)] = entry
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// mcpJSONType normalizes the MCP property "type" to a JSON-type string. MCP
|
||||
// reports standard JSON Schema types; pass them through, defaulting to "string"
|
||||
// when absent/unknown so the contract is always populated.
|
||||
func mcpJSONType(prop map[string]any) string {
|
||||
t, _ := prop["type"].(string)
|
||||
switch t {
|
||||
case "string", "integer", "number", "boolean", "array", "object":
|
||||
return t
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// mcpString reads a string field from an MCP property object.
|
||||
func mcpString(prop map[string]any, key string) string {
|
||||
if prop == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := prop[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// mcpDefault returns the MCP-provided default, stringified, only when present.
|
||||
// gws renders default as a string; mirror that. Non-string JSON defaults
|
||||
// (numbers/bools) are formatted with %v so e.g. 0 → "0", true → "true".
|
||||
func mcpDefault(prop map[string]any) (string, bool) {
|
||||
if prop == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := prop["default"]
|
||||
if !ok || v == nil {
|
||||
return "", false
|
||||
}
|
||||
switch tv := v.(type) {
|
||||
case string:
|
||||
return tv, true
|
||||
case float64:
|
||||
// JSON numbers decode to float64; render integers without a fraction.
|
||||
if tv == float64(int64(tv)) {
|
||||
return fmt.Sprintf("%d", int64(tv)), true
|
||||
}
|
||||
return fmt.Sprintf("%v", tv), true
|
||||
default:
|
||||
return fmt.Sprintf("%v", tv), true
|
||||
}
|
||||
}
|
||||
|
||||
// kebabCase converts an MCP camelCase param name to the CLI flag's kebab form,
|
||||
// matching how flags are registered in internal/helpers/devapp.go:
|
||||
//
|
||||
// eventCallbackUrl → event-callback-url
|
||||
// unifiedAppId → unified-app-id
|
||||
// disableSSLVerify → disable-ssl-verify
|
||||
//
|
||||
// A boundary is inserted before an uppercase letter that follows a lowercase
|
||||
// letter or digit, and before the final uppercase of a run that starts a new
|
||||
// lowercase word (so SSLVerify → ssl-verify, not s-s-l-verify).
|
||||
func kebabCase(name string) string {
|
||||
runes := []rune(name)
|
||||
var b strings.Builder
|
||||
for i, r := range runes {
|
||||
if unicode.IsUpper(r) {
|
||||
prevLowerOrDigit := i > 0 && (unicode.IsLower(runes[i-1]) || unicode.IsDigit(runes[i-1]))
|
||||
nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
|
||||
if i > 0 && (prevLowerOrDigit || nextLower) {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
// Collapse any accidental double dashes and trim, just in case the source
|
||||
// already contained separators.
|
||||
out := strings.ReplaceAll(b.String(), "_", "-")
|
||||
for strings.Contains(out, "--") {
|
||||
out = strings.ReplaceAll(out, "--", "-")
|
||||
}
|
||||
return strings.Trim(out, "-")
|
||||
}
|
||||
|
||||
// helperProductSummaries returns light product entries for every helper-only
|
||||
// subtree, appended to the no-arg `dws schema` product listing so agents
|
||||
// browsing all products also see helper commands. Tools are listed by path +
|
||||
// summary only; drill in with `dws schema "<path>"` for full parameter schema.
|
||||
func helperProductSummaries(root *cobra.Command) []map[string]any {
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for name := range helperSchemaRoots {
|
||||
top, _, err := root.Find([]string{name})
|
||||
if err != nil || top == nil || !top.HasParent() {
|
||||
continue
|
||||
}
|
||||
leaves := []map[string]any{}
|
||||
walkLeafCommands(top, func(leaf *cobra.Command) {
|
||||
leaves = append(leaves, map[string]any{
|
||||
"cli_name": leaf.Name(),
|
||||
"cli_path": helperCommandPath(leaf),
|
||||
"description": strings.TrimSpace(leaf.Short),
|
||||
})
|
||||
})
|
||||
out = append(out, map[string]any{
|
||||
"id": name,
|
||||
"name": strings.TrimSpace(top.Short),
|
||||
"description": "helper-only 命令组(不走服务发现);schema 从 op-app MCP 实时拉取,用 `dws schema \"" + helperCommandPath(top) + " ...\"` 查具体参数",
|
||||
"helper": true,
|
||||
"tools": leaves,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// walkLeafCommands invokes fn for every runnable leaf under cmd (depth-first).
|
||||
func walkLeafCommands(cmd *cobra.Command, fn func(*cobra.Command)) {
|
||||
if cmd.Runnable() && !cmd.HasAvailableSubCommands() {
|
||||
fn(cmd)
|
||||
return
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if !sub.IsAvailableCommand() || sub.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
walkLeafCommands(sub, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// helperSubcommands lists a group's runnable children for browse mode, sorted
|
||||
// by name for deterministic output.
|
||||
func helperSubcommands(cmd *cobra.Command) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if !sub.IsAvailableCommand() || sub.Name() == "help" {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"cli_path": helperCommandPath(sub),
|
||||
"description": strings.TrimSpace(sub.Short),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i]["cli_path"].(string) < out[j]["cli_path"].(string)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// helperCommandPath returns the space-joined path from root to cmd, e.g.
|
||||
// "dev app robot config".
|
||||
func helperCommandPath(cmd *cobra.Command) string {
|
||||
parts := []string{}
|
||||
for c := cmd; c != nil && c.HasParent(); c = c.Parent() {
|
||||
parts = append([]string{c.Name()}, parts...)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// firstNonFlag returns the first token that is not a flag (does not start with
|
||||
// "-"), or "" if there is none.
|
||||
func firstNonFlag(tokens []string) string {
|
||||
for _, t := range tokens {
|
||||
if t != "" && !strings.HasPrefix(t, "-") {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildHelperTestTree mirrors the shape of the real `dws dev` subtree closely
|
||||
// enough to exercise the live-schema renderer: a group and leaves carrying the
|
||||
// `mcp-tool` annotation that names the op-app tool to fetch.
|
||||
func buildHelperTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
|
||||
create := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建应用",
|
||||
Annotations: map[string]string{"mcp-tool": "create_dev_app"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
|
||||
config := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "配置机器人",
|
||||
Annotations: map[string]string{"mcp-tool": "set_extension_robot_config"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
|
||||
// A leaf without an mcp-tool annotation (e.g. dev connect / dev doc search).
|
||||
noTool := &cobra.Command{Use: "connect", Short: "无 MCP 工具", Run: func(*cobra.Command, []string) {}}
|
||||
|
||||
robot := &cobra.Command{Use: "robot", Short: "机器人能力"}
|
||||
robot.AddCommand(config)
|
||||
|
||||
app := &cobra.Command{Use: "app", Short: "应用"}
|
||||
app.AddCommand(create, robot)
|
||||
|
||||
dev := &cobra.Command{Use: "dev", Short: "开放平台开发者命令"}
|
||||
dev.AddCommand(app, noTool)
|
||||
|
||||
root.AddCommand(dev)
|
||||
return root
|
||||
}
|
||||
|
||||
// fakeFetcher returns a canned op-app tools/list so the renderer is exercised
|
||||
// without network. It mirrors the MCP shape: properties keyed by camelCase param
|
||||
// name, required[] listing the camelCase names.
|
||||
func fakeFetcher(tools map[string]HelperToolSchema) HelperToolFetcher {
|
||||
return func(context.Context, string) (map[string]HelperToolSchema, error) {
|
||||
return tools, nil
|
||||
}
|
||||
}
|
||||
|
||||
func robotConfigToolSchema() HelperToolSchema {
|
||||
return HelperToolSchema{
|
||||
Name: "set_extension_robot_config",
|
||||
Description: "创建或更新现有应用的机器人配置",
|
||||
Properties: map[string]any{
|
||||
"unifiedAppId": map[string]any{"type": "string", "description": "统一应用 ID"},
|
||||
"eventCallbackUrl": map[string]any{"type": "string", "description": "事件回调地址"},
|
||||
"skills": map[string]any{"type": "array", "description": "技能列表"},
|
||||
"mode": map[string]any{"type": "string", "description": "机器人模式", "enum": []any{"HTTPS", "STREAM", "AISKILL"}},
|
||||
},
|
||||
Required: []string{"unifiedAppId"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_LeafGwsFlat(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
fetch := fakeFetcher(map[string]HelperToolSchema{
|
||||
"set_extension_robot_config": robotConfigToolSchema(),
|
||||
})
|
||||
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app robot config", fetch)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected helper renderer to claim the path")
|
||||
}
|
||||
|
||||
// Flat top-level: description / path / source / parameters; no wrapper.
|
||||
if payload["description"] != "创建或更新现有应用的机器人配置" {
|
||||
t.Fatalf("description = %v", payload["description"])
|
||||
}
|
||||
if payload["path"] != "dev app robot config" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
if payload["source"] != "mcp:op-app" {
|
||||
t.Fatalf("source = %v", payload["source"])
|
||||
}
|
||||
for _, leaked := range []string{"kind", "tool", "product", "helper"} {
|
||||
if _, present := payload[leaked]; present {
|
||||
t.Fatalf("gws-flat output must not carry %q wrapper key", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
params, _ := payload["parameters"].(map[string]any)
|
||||
if params == nil {
|
||||
t.Fatalf("no parameters: %#v", payload)
|
||||
}
|
||||
|
||||
// Keys are kebab-case of the MCP param name == the CLI flag.
|
||||
uid, _ := params["unified-app-id"].(map[string]any)
|
||||
if uid == nil {
|
||||
t.Fatalf("missing unified-app-id param: %#v", params)
|
||||
}
|
||||
if uid["type"] != "string" || uid["required"] != true {
|
||||
t.Fatalf("unified-app-id = %#v, want string+required", uid)
|
||||
}
|
||||
if _, hasDefault := uid["default"]; hasDefault {
|
||||
t.Fatal("unified-app-id must not carry a default (MCP provides none)")
|
||||
}
|
||||
|
||||
cb, _ := params["event-callback-url"].(map[string]any)
|
||||
if cb == nil || cb["required"] != false {
|
||||
t.Fatalf("event-callback-url = %#v, want required=false", cb)
|
||||
}
|
||||
|
||||
skills, _ := params["skills"].(map[string]any)
|
||||
if skills == nil || skills["type"] != "array" {
|
||||
t.Fatalf("skills = %#v, want array", skills)
|
||||
}
|
||||
|
||||
mode, _ := params["mode"].(map[string]any)
|
||||
if mode == nil || mode["type"] != "string" {
|
||||
t.Fatalf("mode = %#v, want string", mode)
|
||||
}
|
||||
if _, hasDefault := mode["default"]; hasDefault {
|
||||
t.Fatalf("mode default = %v, want none", mode["default"])
|
||||
}
|
||||
if mode["required"] != false {
|
||||
t.Fatalf("mode required = %v, want false", mode["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_Group(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["path"] != "dev app" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
cmds, _ := payload["commands"].([]map[string]any)
|
||||
if len(cmds) != 2 { // create + robot
|
||||
t.Fatalf("commands count = %d, want 2", len(cmds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_NoAnnotation(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev connect", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected a clear no-MCP-tool error, got %#v", payload)
|
||||
}
|
||||
if _, present := payload["parameters"]; present {
|
||||
t.Fatal("no-tool command must not emit parameters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_UnknownSubcommand(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app nope", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected error for unknown subcommand, got %#v", payload)
|
||||
}
|
||||
if avail, _ := payload["available"].([]map[string]any); len(avail) == 0 {
|
||||
t.Fatal("expected available subcommands listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_ToolMissingInList(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
// Fetcher returns an empty list — the annotated tool isn't present.
|
||||
payload, ok, err := renderHelperSchema(context.Background(), root, "dev app create", fakeFetcher(map[string]HelperToolSchema{}))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected not-found error, got %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_FetchError(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
failing := func(context.Context, string) (map[string]HelperToolSchema, error) {
|
||||
return nil, errors.New("network down")
|
||||
}
|
||||
_, ok, err := renderHelperSchema(context.Background(), root, "dev app create", failing)
|
||||
if !ok {
|
||||
t.Fatal("expected claim even on fetch error")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected the fetch error to surface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHelperSchema_NonHelperPathDeclined(t *testing.T) {
|
||||
root := buildHelperTestTree()
|
||||
if _, ok, _ := renderHelperSchema(context.Background(), root, "ding.message.send", nil); ok {
|
||||
t.Fatal("non-helper path must not be claimed by the helper renderer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKebabCase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"eventCallbackUrl": "event-callback-url",
|
||||
"unifiedAppId": "unified-app-id",
|
||||
"disableSSLVerify": "disable-ssl-verify",
|
||||
"mode": "mode",
|
||||
"skills": "skills",
|
||||
"i18nName": "i18n-name",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := kebabCase(in); got != want {
|
||||
t.Errorf("kebabCase(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func degradedHint(reason CatalogDegradedReason, serverCount int) string {
|
||||
if embedded {
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
}
|
||||
return "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络"
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
case DegradedRuntimeAllFailed:
|
||||
if embedded {
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount)
|
||||
@@ -113,7 +113,7 @@ const (
|
||||
CatalogFixtureEnv = "DWS_CATALOG_FIXTURE"
|
||||
CacheDirEnv = "DWS_CACHE_DIR"
|
||||
PluginColdTimeoutEnv = "DWS_PLUGIN_COLD_TIMEOUT"
|
||||
DefaultMarketBaseURL = "https://mcp.dingtalk.com"
|
||||
DefaultMarketBaseURL = config.DefaultMCPBaseURL
|
||||
|
||||
// defaultDiscoveryTimeout bounds the time spent on live registry discovery.
|
||||
// Tightened to 4s so a slow/unreachable discovery endpoint cannot block
|
||||
@@ -203,7 +203,7 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
// eliminating the historical split where the command tree came from
|
||||
// Wukong Portal while runtime endpoint resolution silently read the
|
||||
// open-source Market cache (see fix-wukong-endpoint-partition plan).
|
||||
baseURL := DefaultMarketBaseURL
|
||||
baseURL := config.GetMCPBaseURL()
|
||||
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
|
||||
baseURL = editionURL
|
||||
}
|
||||
@@ -279,7 +279,7 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
// accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3.
|
||||
logDiscoveryWarnings(response.Metadata.Warnings)
|
||||
|
||||
servers := market.NormalizeServers(response, "live_market")
|
||||
servers := market.NormalizeServersForBaseURL(response, "live_market", baseURL)
|
||||
_ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
|
||||
|
||||
changedKeys := cache.ChangedServerKeysByUpdatedAt(cached.Registry.Servers, servers)
|
||||
|
||||
+45
-9
@@ -19,6 +19,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
@@ -122,19 +123,51 @@ func readStdinBounded() (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// looksLikeFilePath returns true when value should be interpreted as the
|
||||
// `@<path>` file-injection syntax. Heuristic: value must start with '@', and
|
||||
// the character right after '@' must be ASCII (letter, digit, or one of the
|
||||
// common path-prefix characters: . / ~ _ -). This rules out mistaken matches
|
||||
// for natural-language messages that happen to start with '@' followed by
|
||||
// non-ASCII text — e.g. "@所有人" should be a chat mention, not a file path.
|
||||
func looksLikeFilePath(value string) bool {
|
||||
if !strings.HasPrefix(value, "@") || len(value) < 2 {
|
||||
return false
|
||||
}
|
||||
rest := value[1:]
|
||||
if rest == "-" {
|
||||
return true // @- = stdin
|
||||
}
|
||||
first := rune(rest[0])
|
||||
if first > unicode.MaxASCII {
|
||||
// First byte is part of a multi-byte rune (e.g. Chinese) — not a path.
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case first >= 'A' && first <= 'Z',
|
||||
first >= 'a' && first <= 'z',
|
||||
first >= '0' && first <= '9',
|
||||
first == '.', first == '/', first == '~', first == '_', first == '-':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadFileArg reads the contents of a file referenced by the @filename syntax.
|
||||
// Returns the original value unchanged if it does not start with "@".
|
||||
// Returns the original value unchanged if it does not start with "@" or is
|
||||
// otherwise not a file-path-shaped value (e.g. "@所有人" is treated as plain
|
||||
// text, not a path).
|
||||
// Returns an error if the file cannot be read or exceeds the size limit.
|
||||
//
|
||||
// Note: @- (stdin) is NOT handled here; use ResolveInputSource instead.
|
||||
func ReadFileArg(value string) (string, bool, error) {
|
||||
if !strings.HasPrefix(value, "@") {
|
||||
// Preserve the historical bare-"@" behaviour (empty filename → error).
|
||||
if value == "@" {
|
||||
return "", false, apperrors.NewValidation("@file: filename must not be empty")
|
||||
}
|
||||
if !looksLikeFilePath(value) {
|
||||
return value, false, nil
|
||||
}
|
||||
path := value[1:]
|
||||
if path == "" {
|
||||
return "", false, apperrors.NewValidation("@file: filename must not be empty")
|
||||
}
|
||||
// @- is stdin, not a file — callers should use ResolveInputSource.
|
||||
if path == "-" {
|
||||
return value, false, nil
|
||||
@@ -156,14 +189,17 @@ func ReadFileArg(value string) (string, bool, error) {
|
||||
//
|
||||
// The flagName parameter is used only for error messages and StdinGuard tracking.
|
||||
func ResolveInputSource(value string, flagName string, guard *StdinGuard) (string, error) {
|
||||
if !strings.HasPrefix(value, "@") {
|
||||
// Preserve the historical bare-"@" behaviour (empty filename → error).
|
||||
if value == "@" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
|
||||
}
|
||||
if !looksLikeFilePath(value) {
|
||||
// Pass through natural-language strings that happen to start with
|
||||
// '@' (e.g. "@所有人 早上好") so they reach the MCP payload intact.
|
||||
return value, nil
|
||||
}
|
||||
|
||||
path := value[1:]
|
||||
if path == "" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
|
||||
}
|
||||
|
||||
// @- reads from stdin.
|
||||
if path == "-" {
|
||||
|
||||
@@ -47,6 +47,38 @@ func TestReadFileArgPlainValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFileArgChineseAtMention guards the @所有人-style mentions that the
|
||||
// chat bot Webhook tests rely on: an '@' followed by non-ASCII text must be
|
||||
// treated as a literal message, not as the @file injection syntax.
|
||||
func TestReadFileArgChineseAtMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{
|
||||
"@所有人 这是 @ 所有人 的消息",
|
||||
"@张三",
|
||||
"@A 但接下来都是中文@测试",
|
||||
}
|
||||
for _, in := range cases {
|
||||
val, isFile, err := ReadFileArg(in)
|
||||
if in == "@A 但接下来都是中文@测试" {
|
||||
// '@A' starts with ASCII, treated as path → expect file error
|
||||
if err == nil {
|
||||
t.Errorf("@A... should attempt file lookup; got val=%q isFile=%v", val, isFile)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%q: unexpected error %v", in, err)
|
||||
continue
|
||||
}
|
||||
if isFile {
|
||||
t.Errorf("%q: should be plain text, got isFile=true", in)
|
||||
}
|
||||
if val != in {
|
||||
t.Errorf("%q: got %q", in, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileArgReadsFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user