Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9e7e7dbc7f | ||
|
|
7652bda320 | ||
|
|
dd1c4e34fd | ||
|
|
e653616aa3 | ||
|
|
3ce64db2fc | ||
|
|
4bbd52fc58 | ||
|
|
9eb3099881 | ||
|
|
73e2de7fe8 | ||
|
|
8b4f05e44e | ||
|
|
a7879edca1 | ||
|
|
3f09eb5c0f | ||
|
|
202cb509a0 | ||
|
|
dd42fd833b | ||
|
|
b5f241c719 | ||
|
|
4cdc3e7320 | ||
|
|
b558bda6df | ||
|
|
1063a6425d | ||
|
|
0a8517432f | ||
|
|
83bfca8d35 | ||
|
|
9e58a062f8 | ||
|
|
aa06d9d5c9 | ||
|
|
bb3d1270b9 | ||
|
|
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: 57.5%"><title>coverage: 57.5%</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">57.5%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">57.5%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">57.5%</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.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,103 @@
|
||||
name: Auto Dev Release
|
||||
|
||||
# 推到 dev 分支 → 跑测试 → 通过才自动出一个 dev 预览 release。
|
||||
# install-devapp.sh / .ps1 取 fork 最新 release,所以发完安装链接即最新。
|
||||
#
|
||||
# 设计要点:
|
||||
# - 自包含:测试 + 算版本 + 打 tag + 发 release 全在一个 job,不依赖 tag 触发
|
||||
# release.yml(GITHUB_TOKEN 推的 tag 本来也不会触发别的 workflow)。
|
||||
# - 质量门:测试这一步失败,后面发布步骤就不会跑。
|
||||
# - 不含 npm publish,所以自动发布天然是绿的。
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feat/dws-devapp
|
||||
|
||||
# 串行:两次 push 不会同时算版本号撞车
|
||||
concurrency:
|
||||
group: auto-dev-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
# ---- 质量门:测试不过就不发 ----
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Test
|
||||
run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
|
||||
# ---- 算下一个 dev 版本号并打 tag ----
|
||||
- name: Compute next dev version
|
||||
id: ver
|
||||
run: |
|
||||
set -eu
|
||||
LATEST=$(git tag -l 'v*-dev.*' --sort=-v:refname | head -1 || true)
|
||||
if [ -z "$LATEST" ]; then
|
||||
BASE="v1.0.39"; N=0
|
||||
else
|
||||
BASE=$(printf '%s' "$LATEST" | sed -E 's/-dev\.[0-9]+$//')
|
||||
N=$(printf '%s' "$LATEST" | sed -E 's/.*-dev\.([0-9]+)$/\1/')
|
||||
fi
|
||||
NEW="${BASE}-dev.$((N + 1))"
|
||||
echo "version=$NEW" >> "$GITHUB_OUTPUT"
|
||||
echo "next dev release: $NEW (prev: ${LATEST:-none})"
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
set -eu
|
||||
V="${{ steps.ver.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$V"
|
||||
git push origin "$V"
|
||||
|
||||
# ---- 自己发 release(不靠 tag 触发 release.yml)----
|
||||
- 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:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post-release packaging
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ steps.ver.outputs.version }}
|
||||
|
||||
- name: Upload dws-skills.zip to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ steps.ver.outputs.version }}" dist/dws-skills.zip --clobber
|
||||
@@ -0,0 +1,44 @@
|
||||
# 把本仓库代码自动镜像到 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
|
||||
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'
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub)
|
||||
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
|
||||
@@ -58,6 +58,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:
|
||||
@@ -65,6 +87,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:
|
||||
|
||||
@@ -26,6 +26,7 @@ test/cli_compat/testdata/
|
||||
*.pem
|
||||
*.key
|
||||
credentials*
|
||||
!skills/**/credentials.md
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
@@ -34,3 +35,10 @@ dws.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
|
||||
|
||||
+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
|
||||
|
||||
+111
@@ -6,6 +6,117 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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).
|
||||
|
||||
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 20 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 20 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -113,6 +113,28 @@ 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.
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
@@ -473,6 +495,23 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
</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 --robot-client-id <id> --robot-client-secret <secret>
|
||||
```
|
||||
|
||||
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 |
|
||||
@@ -492,13 +531,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
|
||||
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
|
||||
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
|
||||
| DevDoc | `devdoc` | 2 | `article` `error` | Search the DingTalk Open Platform documentation and diagnose API errors |
|
||||
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| AI App | `aiapp` | 4 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **334 commands across 19 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.
|
||||
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
@@ -558,6 +596,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
|
||||
|
||||
+43
-4
@@ -113,6 +113,28 @@ 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 的公开包,国内可直接使用。
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
@@ -274,7 +296,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 20 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
@@ -469,6 +491,23 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
|
||||
</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 → 拉进群)。
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
@@ -488,13 +527,12 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
|
||||
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
|
||||
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
|
||||
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档并排查 API 调用错误 |
|
||||
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| AI 应用 | `aiapp` | 4 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **19 个产品,334 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`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` 产品。
|
||||
|
||||
@@ -556,6 +594,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")),
|
||||
|
||||
@@ -12,6 +12,8 @@ const AGENT_DIRS = [
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Agent identification (agent_code & agentId)
|
||||
|
||||
dws tags every MCP request with **which agent host is driving it** and a
|
||||
**per-instance id**, so usage can be sliced by channel/instance in the data
|
||||
warehouse. This page is the integration contract.
|
||||
|
||||
## What dws sends on the wire
|
||||
|
||||
| Header | Meaning | Granularity |
|
||||
|--------|---------|-------------|
|
||||
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel |
|
||||
| `x-dws-agent-instance-id` | `dwsa_<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 — `custom`:** unknown host. Never guessed.
|
||||
|
||||
## Declaring your agent (recommended — the only fully-general path)
|
||||
|
||||
Auto-detection cannot cover every agent: most terminal agents (gemini/
|
||||
antigravity, aider, opencode, qwen-code, crush, goose, kimi, amazon-q,
|
||||
continue, …) expose **no reliable self-identifying env var** — only user-set
|
||||
API keys, which must not be used as identity. The robust answer is: **the host
|
||||
sets `DINGTALK_DWS_AGENTCODE` in the env block where it launches dws as an MCP
|
||||
server.** This is accurate for any agent, on any OS, and is future-proof.
|
||||
|
||||
MCP server config example (JSON-style hosts):
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"dingtalk-workspace": {
|
||||
"command": "dws",
|
||||
"args": ["mcp", "..."],
|
||||
"env": { "DINGTALK_DWS_AGENTCODE": "your-agent-code" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Canonical codes
|
||||
|
||||
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
|
||||
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
|
||||
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
|
||||
Use a stable lowercase slug; unknown values are kept as-is (lowercased,
|
||||
spaces stripped), so a new agent name flows through cleanly.
|
||||
|
||||
## Trust & limitations — READ THIS
|
||||
|
||||
**`agent_code` AND the ids (`x-dws-agent-id`, `x-dws-agent-instance-id`) are
|
||||
self-reported, best-effort signals, NOT an authenticated identity.**
|
||||
|
||||
- `agent_code`: every declaration/auto-detect signal is an env var the
|
||||
host/user controls — spoofable (`export CLAUDECODE=1` → dws reports
|
||||
`claudecode`).
|
||||
- The ids are **even easier to forge**: they are generated, stored, and sent
|
||||
entirely client-side. `machineId` is a random UUID in the plaintext
|
||||
`~/.dws/identity.json` (which the user owns), and the instance id is just
|
||||
`sha256(machineId + agent_code)`. Editing that one file — or rewriting the
|
||||
header — lets anyone mint, split, rotate, or impersonate ids at will. The
|
||||
`dwsa_` prefix does NOT make it a secure identifier.
|
||||
|
||||
- ✅ **Fit for statistics / observability** (the intended use): there is no
|
||||
incentive to misreport one's own agent, and real hosts emit real signals, so
|
||||
aggregate per-channel metrics are reliable in practice.
|
||||
- ❌ **NOT fit for authentication, authorization, rate-limiting, billing, or
|
||||
revocation.** Anything where a party benefits from lying must not trust this
|
||||
field. For control-plane use you need a gateway-issued **authoritative**
|
||||
agentId bound to a verified credential (clientId / PAT / OAuth) — a separate,
|
||||
heavier mechanism, deliberately out of scope here.
|
||||
|
||||
Treat `agent_code` / `x-dws-agent-instance-id` as analytics dimensions only.
|
||||
|
||||
## Gateway side (required for the data to land)
|
||||
|
||||
dws sending the headers is necessary but not sufficient. The gateway must:
|
||||
1. add `x-dingtalk-dws-agent-code`, `x-dws-agent-instance-id`, `X-Cli-Version`
|
||||
to the upstream-header pass-through allowlist (otherwise they are stripped);
|
||||
2. log them as fields, and deliver them to the warehouse (alongside the
|
||||
existing flow-control / execution logs).
|
||||
@@ -4,7 +4,7 @@ Every runtime command the `dws` CLI exposes when loaded with the **pre** environ
|
||||
|
||||
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
|
||||
- **Products**: 13
|
||||
- **Total commands**: 159
|
||||
- **Total commands**: 160
|
||||
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Edit `pre/config.json`, not this file.
|
||||
@@ -36,7 +36,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
|
||||
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
|
||||
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
|
||||
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
|
||||
@@ -182,11 +182,12 @@ _Users, departments, and directory lookups._
|
||||
|
||||
_Search the DingTalk Open Platform documentation._
|
||||
|
||||
**1 commands**
|
||||
**2 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
|
||||
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, 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 +321,3 @@ _Personal todo task management._
|
||||
| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
|
||||
| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
|
||||
| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Running the connector as a 7x24 service
|
||||
|
||||
`dws devapp robot 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.
|
||||
|
||||
## Option A: built-in daemon (recommended for a quick start)
|
||||
|
||||
```bash
|
||||
# Detach into a background supervisor that restarts the connector if it crashes.
|
||||
dws devapp robot connect --daemon \
|
||||
--channel claudecode \
|
||||
--robot-client-id <clientId> --robot-client-secret <clientSecret>
|
||||
|
||||
# Inspect / stop it.
|
||||
dws devapp robot connect status --robot-client-id <clientId>
|
||||
dws devapp robot connect stop --robot-client-id <clientId>
|
||||
```
|
||||
|
||||
- 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/<clientId>/daemon.log` with size-based rotation
|
||||
(5 MB x 2 backups), and the pid file lives at
|
||||
`~/.dws/connect/<clientId>/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 credentials, 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>devapp</string>
|
||||
<string>robot</string>
|
||||
<string>connect</string>
|
||||
<string>--channel</string>
|
||||
<string>claudecode</string>
|
||||
<string>--robot-client-id</string>
|
||||
<string>REPLACE_CLIENT_ID</string>
|
||||
<string>--robot-client-secret</string>
|
||||
<string>REPLACE_CLIENT_SECRET</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/credentials,
|
||||
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 devapp robot connect \
|
||||
--channel claudecode \
|
||||
--robot-client-id REPLACE_CLIENT_ID \
|
||||
--robot-client-secret REPLACE_CLIENT_SECRET
|
||||
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`).
|
||||
|
||||
## 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 --robot-client-id x --robot-client-secret y --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
|
||||
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。
|
||||
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话。
|
||||
|
||||
### 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,305 @@
|
||||
# DevApp 一键安装与 Agent 接入指南
|
||||
|
||||
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
|
||||
|
||||
这份指南参考 Notion Developer Platform 的引导方式:先给出一条可复制的安装命令,再用最短路径完成验证、登录、Agent 调用和排障。
|
||||
|
||||
## 一键安装
|
||||
|
||||
当前 DevApp 能力在 `feat/dws-devapp` 预览分支上。要安装这个分支里的最新能力,请使用 DevApp 专用安装脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
这个脚本会:
|
||||
|
||||
1. 拉取 `wxianfeng/dingtalk-workspace-cli` 的 `feat/dws-devapp` 分支。
|
||||
2. 使用本地源码构建 `dws`。
|
||||
3. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
4. 安装 Agent Skill 到本机已检测到的 Agent 目录,只安装通用 `dws` 和 DevApp 专用 `dws-devapp` 两个 skill。
|
||||
|
||||
> 预览分支安装需要本机已有 `git`、`go` 和 `make`。Go 版本要求以仓库 `go.mod` 为准。
|
||||
|
||||
如果 DevApp 能力已经发布到正式 Release,可以改用正式安装命令:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Windows PowerShell 正式安装命令:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
安装脚本支持这些环境变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `DEVAPP_REPO_URL` | 覆盖源码仓库地址,默认 `https://github.com/wxianfeng/dingtalk-workspace-cli.git` |
|
||||
| `DEVAPP_BRANCH` | 覆盖安装分支,默认 `feat/dws-devapp` |
|
||||
| `DEVAPP_SOURCE_DIR` | 使用已有源码目录安装,跳过 clone |
|
||||
| `DEVAPP_KEEP_SOURCE=1` | 保留临时源码目录,便于调试 |
|
||||
| `DEVAPP_SKIP_SKILL_SETUP=1` | 跳过自动安装 `dws` 与 `dws-devapp` skill |
|
||||
| `DEVAPP_SKILL_NAME` | 覆盖 DevApp skill 安装名称,默认 `dws-devapp` |
|
||||
| `DWS_INSTALL_DIR` | 传给底层 `scripts/install.sh`,覆盖 `dws` 安装目录 |
|
||||
| `DWS_SKILL_MODE` | 传给底层 `scripts/install.sh`,选择 `mono` 或 `multi` |
|
||||
|
||||
## 安装后验证
|
||||
|
||||
先确认 `dws` 可执行:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
```
|
||||
|
||||
确认 DevApp 命令存在:
|
||||
|
||||
```bash
|
||||
dws devapp --help --format json
|
||||
```
|
||||
|
||||
如果能看到 `list`、`get`、`create`、`permission`、`robot`、`security`、`version` 等能力,说明 DevApp 已安装成功。
|
||||
|
||||
确认登录状态:
|
||||
|
||||
```bash
|
||||
dws auth status
|
||||
```
|
||||
|
||||
如果尚未登录:
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
登录完成后读取应用列表:
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
```
|
||||
|
||||
## DevApp 是什么
|
||||
|
||||
DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。
|
||||
|
||||
它让 Agent 可以完成这些工作:
|
||||
|
||||
- 查询、创建、更新、启用、停用、删除开放平台应用。
|
||||
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
|
||||
- 配置网页应用首页和管理后台地址。
|
||||
- 查询、申请、移除权限点。
|
||||
- 管理应用成员。
|
||||
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
|
||||
- 创建、查询、更新、启用、停用机器人。
|
||||
- 创建版本、发起发布、查询审批和发布状态。
|
||||
|
||||
## 给 Agent 使用
|
||||
|
||||
安装完成后,可以直接让 Agent 操作 DevApp。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
帮我查一下最近创建的开放平台应用。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我给 unifiedAppId=<unifiedAppId> 的应用配置机器人,先 dry-run 给我确认。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我查询这个应用缺哪些权限点,并申请 Contact.User.mobile。
|
||||
```
|
||||
|
||||
```text
|
||||
帮我发布这个应用版本,先检查发布前置条件。
|
||||
```
|
||||
|
||||
Agent 写操作必须遵循:
|
||||
|
||||
1. 先查询定位应用。
|
||||
2. 先 dry-run 预览。
|
||||
3. 明确展示将要修改的应用、字段和值。
|
||||
4. 用户确认后加 `--yes` 执行。
|
||||
5. 执行后回读验证。
|
||||
|
||||
## 第一个写操作
|
||||
|
||||
推荐用机器人配置作为 smoke test。先 dry-run:
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--dry-run \
|
||||
--format json
|
||||
```
|
||||
|
||||
确认预览无误后执行:
|
||||
|
||||
```bash
|
||||
dws devapp robot config \
|
||||
--unified-app-id <unifiedAppId> \
|
||||
--name "告警机器人" \
|
||||
--brief "告警通知" \
|
||||
--desc "处理告警通知和事件回调" \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
回读验证:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 应用管理
|
||||
|
||||
```bash
|
||||
dws devapp list --format json
|
||||
dws devapp get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp create --name "考勤应用" --dry-run --format json
|
||||
dws devapp update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
|
||||
dws devapp inactive --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp active --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp delete --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 凭证查询
|
||||
|
||||
```bash
|
||||
dws devapp credentials get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
|
||||
|
||||
### 权限点管理
|
||||
|
||||
```bash
|
||||
dws devapp permission list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp permission add --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
dws devapp permission remove --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
|
||||
```
|
||||
|
||||
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
|
||||
|
||||
### 机器人配置
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
|
||||
dws devapp robot enable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp robot disable --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
```
|
||||
|
||||
### 成员与安全
|
||||
|
||||
```bash
|
||||
dws devapp member list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp member add --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp member remove --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --redirect-url <url> --dry-run --format json
|
||||
dws devapp security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
|
||||
```
|
||||
|
||||
### 版本发布
|
||||
|
||||
```bash
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --format json
|
||||
dws devapp version list --unified-app-id <unifiedAppId> --cursor <nextCursor> --format json
|
||||
dws devapp version create --unified-app-id <unifiedAppId> --dry-run --format json
|
||||
dws devapp version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
|
||||
dws devapp version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
|
||||
```
|
||||
|
||||
## 安全边界
|
||||
|
||||
DevApp 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
|
||||
|
||||
默认安全策略:
|
||||
|
||||
- 写操作先 dry-run。
|
||||
- 删除、停用、发布必须由用户确认。
|
||||
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
|
||||
- 应用定位优先使用 `agentId`、`unifiedAppId`、`appKey`。
|
||||
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
|
||||
|
||||
## 排障
|
||||
|
||||
### `dws devapp` 不存在
|
||||
|
||||
先确认安装的是预览分支源码,而不是正式 Release:
|
||||
|
||||
```bash
|
||||
dws version
|
||||
dws devapp --help --format json
|
||||
```
|
||||
|
||||
如果正式 Release 尚未包含 DevApp,请重新执行本文的一键源码安装命令。
|
||||
|
||||
### `dws devapp list` 失败
|
||||
|
||||
优先检查登录态:
|
||||
|
||||
```bash
|
||||
dws auth status
|
||||
dws auth login
|
||||
```
|
||||
|
||||
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
|
||||
|
||||
### 页面能操作,但 CLI 或 MCP 提示无权限
|
||||
|
||||
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。
|
||||
|
||||
先确认当前用户是否满足以下任一条件:
|
||||
|
||||
- 应用 owner。
|
||||
- 应用管理员。
|
||||
- 应用开发者。
|
||||
- 企业管理员或具备开放平台应用管理权限的角色。
|
||||
|
||||
### 机器人配置失败
|
||||
|
||||
先查当前机器人状态:
|
||||
|
||||
```bash
|
||||
dws devapp robot get --unified-app-id <unifiedAppId> --format json
|
||||
```
|
||||
|
||||
如果机器人不存在,使用 `robot config` 创建或配置。
|
||||
如果机器人已存在,继续用 `robot config` 修改配置,或用 `robot enable` 重新启用。
|
||||
|
||||
## 页面文案建议
|
||||
|
||||
用于产品页顶部:
|
||||
|
||||
```text
|
||||
Install DevApp 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
|
||||
一行命令接入 DevApp。
|
||||
|
||||
让 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
# 钉钉 AI 群机器人快速上手
|
||||
|
||||
10 分钟搭一个自己的钉钉群答疑机器人:群里 @它 提问,它用你本地的 AI(Claude Code / Codex / Qoder 等)回答,支持发文字和报错截图。
|
||||
|
||||
只需四步:装工具 → 建机器人 → 接上 AI → 拉进群。
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
打开终端,整段复制执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
|
||||
```
|
||||
|
||||
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
|
||||
|
||||
### Windows
|
||||
|
||||
打开 PowerShell,整段复制执行:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
|
||||
```
|
||||
|
||||
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
|
||||
|
||||
> 能打印出版本号即安装成功(脚本默认装当前最新的预览版)。脚本走 GitHub API 取最新预览版,无需手动填版本号;想钉某个版本可设环境变量 `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` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。
|
||||
|
||||
## 第三步:把机器人接上你本地的 AI
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy
|
||||
```
|
||||
|
||||
- 把 `dingxxxxxxxxxxxxxxxx` 和 `yyyyyyyyyyyyyyyyyyyy` 换成第二步返回的 `clientId` 和 `clientSecret` 的实际值
|
||||
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
|
||||
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
|
||||
|
||||
## 第四步:拉进群聊
|
||||
|
||||
在钉钉里打开目标群:
|
||||
|
||||
**群设置 → 机器人 → 添加机器人 → 在企业机器人里搜"小助手"(你起的名字)→ 添加**
|
||||
|
||||
完成。现在在群里 @小助手 提问试试,发文字、发报错截图都能答。
|
||||
|
||||
## 进阶配置(可选)
|
||||
|
||||
按需加在第三步的命令后面:
|
||||
|
||||
| 参数 | 作用 |
|
||||
|------|------|
|
||||
| `--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命令 一次性问答参数" \
|
||||
--robot-client-id dingxxxx --robot-client-secret yyyy
|
||||
```
|
||||
|
||||
机器人收到群消息后,会执行 `你的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,12 @@ 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/huh v1.0.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
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,14 +17,12 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15 // indirect
|
||||
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/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/huh v1.0.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
@@ -33,6 +34,7 @@ require (
|
||||
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
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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=
|
||||
@@ -20,15 +24,27 @@ github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh
|
||||
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=
|
||||
@@ -43,6 +59,8 @@ 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=
|
||||
@@ -68,6 +86,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.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=
|
||||
@@ -88,6 +108,8 @@ github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cma
|
||||
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=
|
||||
|
||||
+415
-24
@@ -30,18 +30,39 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type authLoginConfig struct {
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
}
|
||||
|
||||
func buildAuthCommand() *cobra.Command {
|
||||
type authLoginGuideAction string
|
||||
|
||||
const (
|
||||
authLoginGuideDirectCLI authLoginGuideAction = "direct_cli"
|
||||
authLoginGuideConfigureAgentApp authLoginGuideAction = "configure_agent_app"
|
||||
authLoginGuideManualCredentials authLoginGuideAction = "manual_credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
authLoginBrandBlue = lipgloss.AdaptiveColor{Light: "#1677FF", Dark: "#69B1FF"}
|
||||
authLoginInk = lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#EAF2FF"}
|
||||
authLoginMuted = lipgloss.AdaptiveColor{Light: "#667085", Dark: "#8A96A8"}
|
||||
authLoginLine = lipgloss.AdaptiveColor{Light: "#D6E4FF", Dark: "#2F3B52"}
|
||||
authLoginDanger = lipgloss.AdaptiveColor{Light: "#D92D20", Dark: "#FF6B6B"}
|
||||
)
|
||||
|
||||
func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "认证管理",
|
||||
@@ -55,7 +76,7 @@ func buildAuthCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
if !edition.Get().HideAuthLogin {
|
||||
cmd.AddCommand(newAuthLoginCommand())
|
||||
cmd.AddCommand(newAuthLoginCommand(patCaller))
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
@@ -68,7 +89,7 @@ func buildAuthCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthLoginCommand() *cobra.Command {
|
||||
func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "登录钉钉(自动刷新 token,必要时扫码)",
|
||||
@@ -88,7 +109,8 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
|
||||
示例:
|
||||
dws auth login # 本机扫码登录 (loopback 流)
|
||||
dws auth login # 本机登录后选择推荐/全部权限与授权业务域
|
||||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
@@ -100,6 +122,10 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
var tokenData *authpkg.TokenData
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
postLoginTUIMode := !cfg.Yes && authLoginShouldUsePostLoginTUIMode(cmd, format, cfg.Recommend)
|
||||
recommendAuthMode := cfg.Recommend || postLoginTUIMode
|
||||
humanAuthMode := !cfg.Yes && authLoginShouldUseHumanAuthorizationMode(cmd, format, recommendAuthMode)
|
||||
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Token) != "":
|
||||
@@ -116,6 +142,7 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
|
||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
tokenData, err = provider.Login(loginCtx)
|
||||
if err != nil {
|
||||
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
|
||||
@@ -126,6 +153,7 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = provider.Login(loginCtx, cfg.Force)
|
||||
if err != nil {
|
||||
@@ -137,41 +165,93 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
clearCompatCache()
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
runPostLoginAuthorization := func() error {
|
||||
if !recommendAuthMode {
|
||||
return nil
|
||||
}
|
||||
recommendScopeMode := pat.LoginRecommendScopeRecommended
|
||||
var initialPlan *pat.LoginRecommendPlan
|
||||
if postLoginTUIMode {
|
||||
var planErr error
|
||||
initialPlan, planErr = pat.PlanLoginRecommendAuthorization(cmd.Context(), patCaller)
|
||||
if planErr != nil {
|
||||
return planErr
|
||||
}
|
||||
if authLoginRecommendPlanSkipsInteractiveAuthorization(initialPlan) {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "推荐权限已全部授权或没有可授权项")
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
recommendScopeMode, err = loginRecommendScopeModeSelector()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode, InitialPlan: initialPlan}
|
||||
if postLoginTUIMode {
|
||||
opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) {
|
||||
return loginRecommendProductSelector(products)
|
||||
}
|
||||
}
|
||||
retryFormat := format
|
||||
if humanAuthMode {
|
||||
retryFormat = "table"
|
||||
}
|
||||
run := func(ctx context.Context) error {
|
||||
return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts)
|
||||
}
|
||||
err := run(cmd.Context())
|
||||
if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil {
|
||||
return runDirectPATAuthCheckWaitOnly(
|
||||
cmd.Context(),
|
||||
&GlobalFlags{Format: retryFormat},
|
||||
patErr,
|
||||
cmd.ErrOrStderr(),
|
||||
)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") && !humanAuthMode {
|
||||
if err := runPostLoginAuthorization(); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAuthLoginJSON(w, tokenData, cfg.Force)
|
||||
}
|
||||
|
||||
// Default table output
|
||||
if err := runPostLoginAuthorization(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
|
||||
fmt.Fprintf(w, "[OK] Token 有效,无需重新登录\n")
|
||||
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
|
||||
} else {
|
||||
fmt.Fprintf(w, "[OK] 登录成功!\n")
|
||||
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
||||
}
|
||||
if tokenData != nil {
|
||||
if tokenData.CorpName != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
|
||||
fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName))
|
||||
}
|
||||
if tokenData.CorpID != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||||
fmt.Fprintln(w, authLoginInfoLine("企业 ID", tokenData.CorpID))
|
||||
}
|
||||
if tokenData.UserName != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "用户:", tokenData.UserName)
|
||||
fmt.Fprintln(w, authLoginInfoLine("用户", tokenData.UserName))
|
||||
}
|
||||
if expiry := authLoginDisplayExpiry(tokenData); expiry != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", expiry)
|
||||
fmt.Fprintln(w, authLoginInfoLine("有效期", expiry))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "Token 将自动刷新,无需重复登录\n")
|
||||
fmt.Fprintln(w, authLoginMutedStyle().Render("Token 将自动刷新,无需重复登录"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
|
||||
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
|
||||
// Hidden compatibility flags
|
||||
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
|
||||
cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes")
|
||||
@@ -186,10 +266,112 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
_ = cmd.Flags().MarkHidden("token-url")
|
||||
_ = cmd.Flags().MarkHidden("refresh-url")
|
||||
_ = cmd.Flags().MarkHidden("login-timeout")
|
||||
_ = cmd.Flags().MarkHidden("no-browser")
|
||||
return cmd
|
||||
}
|
||||
|
||||
var (
|
||||
authLoginGuideActionSelector = selectAuthLoginGuideAction
|
||||
authLoginGuideActionApplier = applyAuthLoginGuideAction
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
choice := authLoginGuideDirectCLI
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[authLoginGuideAction]().
|
||||
Title("选择操作").
|
||||
Options(
|
||||
huh.NewOption("直接使用CLI", authLoginGuideDirectCLI),
|
||||
huh.NewOption("一键配置智能体应用", authLoginGuideConfigureAgentApp),
|
||||
huh.NewOption("手动输入应用凭证", authLoginGuideManualCredentials),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("使用引导选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action authLoginGuideAction) error {
|
||||
switch action {
|
||||
case authLoginGuideDirectCLI:
|
||||
return nil
|
||||
case authLoginGuideConfigureAgentApp:
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "一键配置智能体应用暂未开放,已继续使用 CLI 登录")
|
||||
return nil
|
||||
case authLoginGuideManualCredentials:
|
||||
clientID, clientSecret, err := promptAuthLoginManualCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authpkg.SetClientID(clientID)
|
||||
authpkg.SetClientSecret(clientSecret)
|
||||
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
|
||||
ClientID: clientID,
|
||||
ClientSecret: authpkg.PlainSecret(clientSecret),
|
||||
}); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist app credentials: %v", err))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("未知操作: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func promptAuthLoginManualCredentials() (string, string, error) {
|
||||
var clientID, clientSecret string
|
||||
nonEmpty := func(label string) func(string) error {
|
||||
return func(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s 不能为空", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("输入 AppKey").
|
||||
Value(&clientID).
|
||||
Validate(nonEmpty("AppKey")),
|
||||
huh.NewInput().
|
||||
Title("输入 AppSecret").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&clientSecret).
|
||||
Validate(nonEmpty("AppSecret")),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return "", "", fmt.Errorf("应用凭证输入中止: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil
|
||||
}
|
||||
|
||||
func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
||||
choice := pat.LoginRecommendScopeRecommended
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[pat.LoginRecommendScopeMode]().
|
||||
Title("选择授权范围").
|
||||
Description("空格选择 回车确认").
|
||||
Options(
|
||||
huh.NewOption("推荐授权", pat.LoginRecommendScopeRecommended),
|
||||
huh.NewOption("全部授权", pat.LoginRecommendScopeAll),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return "", fmt.Errorf("授权范围选择中止: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func newAuthLogoutCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
@@ -229,7 +411,7 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -297,7 +479,7 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||||
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -337,7 +519,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
))
|
||||
}
|
||||
if !authpkg.PortableAuthSourceReady() {
|
||||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login")
|
||||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend")
|
||||
}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
@@ -511,7 +693,7 @@ func newAuthResetCommand() *cobra.Command {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||||
if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -552,6 +734,205 @@ func authLoginDisplayExpiry(data *authpkg.TokenData) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]string, error) {
|
||||
if len(products) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
selected := make([]string, 0, len(products))
|
||||
options := make([]huh.Option[string], 0, len(products))
|
||||
for _, product := range products {
|
||||
code := strings.TrimSpace(product.ProductCode)
|
||||
if code == "" {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, code)
|
||||
options = append(options, huh.NewOption(loginRecommendProductLabel(product), code).Selected(true))
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
height := len(options)
|
||||
if height > 15 {
|
||||
height = 15
|
||||
}
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
Title("选择要授权的业务域").
|
||||
Description("空格选择 回车确认").
|
||||
Options(options...).
|
||||
Height(height).
|
||||
Value(&selected).
|
||||
Validate(func(values []string) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("至少选择一个授权业务域")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
).WithTheme(authLoginHuhTheme())
|
||||
if err := form.Run(); err != nil {
|
||||
return nil, fmt.Errorf("授权业务域选择中止: %w", err)
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func authLoginHuhTheme() *huh.Theme {
|
||||
t := huh.ThemeBase()
|
||||
|
||||
t.Form.Base = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.FieldSeparator = lipgloss.NewStyle().SetString("\n")
|
||||
|
||||
t.Focused.Base = t.Focused.Base.BorderForeground(authLoginBrandBlue)
|
||||
t.Focused.Card = t.Focused.Base
|
||||
t.Focused.Title = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true)
|
||||
t.Focused.NoteTitle = t.Focused.Title.MarginBottom(1)
|
||||
t.Focused.Description = authLoginMutedStyle()
|
||||
t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger)
|
||||
t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger)
|
||||
t.Focused.SelectSelector = lipgloss.NewStyle().SetString("› ").Foreground(authLoginBrandBlue).Bold(true)
|
||||
t.Focused.MultiSelectSelector = t.Focused.SelectSelector
|
||||
t.Focused.Option = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true)
|
||||
t.Focused.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue)
|
||||
t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted)
|
||||
t.Focused.NextIndicator = lipgloss.NewStyle().SetString("→").Foreground(authLoginBrandBlue)
|
||||
t.Focused.PrevIndicator = lipgloss.NewStyle().SetString("←").Foreground(authLoginMuted)
|
||||
t.Focused.FocusedButton = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B1220"}).
|
||||
Background(authLoginBrandBlue).
|
||||
Padding(0, 2).
|
||||
Bold(true)
|
||||
t.Focused.BlurredButton = lipgloss.NewStyle().
|
||||
Foreground(authLoginInk).
|
||||
Background(authLoginLine).
|
||||
Padding(0, 2)
|
||||
t.Focused.Next = t.Focused.FocusedButton
|
||||
t.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(authLoginBrandBlue)
|
||||
t.Focused.TextInput.CursorText = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Focused.TextInput.Placeholder = authLoginMutedStyle()
|
||||
t.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginBrandBlue)
|
||||
t.Focused.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
|
||||
t.Blurred = t.Focused
|
||||
t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()).BorderForeground(authLoginLine)
|
||||
t.Blurred.Card = t.Blurred.Base
|
||||
t.Blurred.Title = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Blurred.NoteTitle = t.Blurred.Title.MarginBottom(1)
|
||||
t.Blurred.Description = authLoginMutedStyle()
|
||||
t.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ")
|
||||
t.Blurred.MultiSelectSelector = t.Blurred.SelectSelector
|
||||
t.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
t.Blurred.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue)
|
||||
t.Blurred.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted)
|
||||
t.Blurred.NextIndicator = lipgloss.NewStyle()
|
||||
t.Blurred.PrevIndicator = lipgloss.NewStyle()
|
||||
t.Blurred.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
t.Blurred.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk)
|
||||
|
||||
t.Group.Title = t.Focused.Title
|
||||
t.Group.Description = t.Focused.Description
|
||||
|
||||
t.Help.ShortKey = authLoginMutedStyle()
|
||||
t.Help.ShortDesc = authLoginMutedStyle()
|
||||
t.Help.ShortSeparator = authLoginMutedStyle()
|
||||
t.Help.FullKey = authLoginMutedStyle()
|
||||
t.Help.FullDesc = authLoginMutedStyle()
|
||||
t.Help.FullSeparator = authLoginMutedStyle()
|
||||
t.Help.Ellipsis = authLoginMutedStyle()
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func authLoginStatusLine(message string) string {
|
||||
return fmt.Sprintf("%s %s",
|
||||
lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true).Render("[OK]"),
|
||||
lipgloss.NewStyle().Foreground(authLoginInk).Bold(true).Render(message),
|
||||
)
|
||||
}
|
||||
|
||||
func authLoginInfoLine(key, value string) string {
|
||||
label := authLoginMutedStyle().Width(14).Render(key + ":")
|
||||
return fmt.Sprintf("%s %s", label, value)
|
||||
}
|
||||
|
||||
func authLoginMutedStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(authLoginMuted)
|
||||
}
|
||||
|
||||
func authLoginShouldShowPostLoginTUI(cmd *cobra.Command, format string, recommend bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldShowPostLoginTUIForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, interactive)
|
||||
}
|
||||
|
||||
func authLoginShouldUsePostLoginTUIMode(cmd *cobra.Command, format string, recommend bool) bool {
|
||||
return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldUsePostLoginTUIModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool {
|
||||
if recommend || !interactive {
|
||||
return false
|
||||
}
|
||||
return authLoginAllowsInteractiveDefault(cmd, format)
|
||||
}
|
||||
|
||||
func authLoginShouldUseHumanAuthorizationMode(cmd *cobra.Command, format string, hasAuthorizationFlow bool) bool {
|
||||
return authLoginShouldUseHumanAuthorizationModeForTerminal(cmd, format, hasAuthorizationFlow, authLoginInteractiveTerminal())
|
||||
}
|
||||
|
||||
func authLoginShouldUseHumanAuthorizationModeForTerminal(cmd *cobra.Command, format string, hasAuthorizationFlow bool, interactive bool) bool {
|
||||
if !hasAuthorizationFlow || !interactive {
|
||||
return false
|
||||
}
|
||||
return authLoginAllowsInteractiveDefault(cmd, format)
|
||||
}
|
||||
|
||||
func authLoginRecommendPlanSkipsInteractiveAuthorization(plan *pat.LoginRecommendPlan) bool {
|
||||
if plan == nil {
|
||||
return false
|
||||
}
|
||||
return plan.AllGranted || len(plan.Scopes) == 0
|
||||
}
|
||||
|
||||
func authLoginAllowsInteractiveDefault(cmd *cobra.Command, format string) bool {
|
||||
if cmd == nil || cmd.Root() == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return true
|
||||
}
|
||||
flags := cmd.Root().PersistentFlags()
|
||||
return !flags.Changed("format")
|
||||
}
|
||||
|
||||
func loginRecommendProductLabel(product pat.LoginRecommendProduct) string {
|
||||
name := strings.TrimSpace(product.ProductName)
|
||||
if name == "" || name == product.ProductCode {
|
||||
name = product.ProductCode
|
||||
}
|
||||
summary := strings.TrimSpace(product.Summary)
|
||||
if summary != "" {
|
||||
summary = " - " + clipRunes(summary, 42)
|
||||
}
|
||||
return fmt.Sprintf("%-10s %s%s", product.ProductCode, name, summary)
|
||||
}
|
||||
|
||||
func clipRunes(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func clearCompatCache() {
|
||||
store := cacheStoreFromEnv()
|
||||
if store != nil {
|
||||
@@ -572,10 +953,20 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
|
||||
}
|
||||
recommend, err := cmd.Flags().GetBool("recommend")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||||
}
|
||||
yes := false
|
||||
if cmd.Root() != nil {
|
||||
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
||||
}
|
||||
return authLoginConfig{
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -26,6 +27,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/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) {
|
||||
@@ -180,8 +184,461 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -220,12 +230,9 @@ func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
if te == nil {
|
||||
return "", false
|
||||
}
|
||||
endpoint, ok := te[toolName]
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
endpoint, ok := dynamicToolEndpoints[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
@@ -241,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,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.
|
||||
@@ -279,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
|
||||
@@ -365,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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"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"
|
||||
@@ -112,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)
|
||||
}
|
||||
@@ -123,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()
|
||||
@@ -186,7 +188,7 @@ 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 := config.GetMCPBaseURL()
|
||||
@@ -228,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()
|
||||
@@ -300,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
|
||||
@@ -348,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,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,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
|
||||
}
|
||||
+80
-4
@@ -16,6 +16,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -49,7 +50,75 @@ 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)
|
||||
merged := mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
|
||||
// Post-merge product hooks: tasks the envelope cannot express on its
|
||||
@@ -58,7 +127,7 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
|
||||
// command surface remains predictable from the envelope alone.
|
||||
helpers.AttachReportLegacyInboxAlias(merged, runner)
|
||||
helpers.AttachReportListReadableEnrichment(merged, runner)
|
||||
return merged
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// pickCommands returns the union of dynamic and helpers commands. For
|
||||
@@ -241,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,
|
||||
@@ -499,6 +568,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.
|
||||
@@ -511,7 +587,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/bamboo":
|
||||
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/bamboo":
|
||||
_ = 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))
|
||||
}
|
||||
}
|
||||
+217
-73
@@ -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,27 +228,34 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
data = map[string]any{}
|
||||
payload["data"] = data
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI)
|
||||
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)
|
||||
@@ -252,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:
|
||||
@@ -279,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
|
||||
}
|
||||
@@ -287,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,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)
|
||||
@@ -339,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
|
||||
)
|
||||
@@ -349,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)
|
||||
@@ -363,8 +382,8 @@ func openPATAuthorizationURI(rawURI string) error {
|
||||
}
|
||||
// The PAT service returns the complete authorization URL. Treat it as an
|
||||
// opaque string unless it is the known legacy DingTalk hash-route variant.
|
||||
// That variant is normalized by the PAT error contract helper while still
|
||||
// preserving the original data.uri in structured output.
|
||||
// That variant is normalized by the PAT error contract helper before being
|
||||
// printed, opened, or returned in structured output.
|
||||
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
|
||||
}
|
||||
|
||||
@@ -381,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,
|
||||
@@ -398,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,
|
||||
@@ -415,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,
|
||||
@@ -465,44 +557,40 @@ func handlePatAuthCheck(
|
||||
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 != "" {
|
||||
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL))
|
||||
fmt.Fprintf(output, " PAT_AUTHORIZATION_URL=%s\n\n", authURL)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", tui.Dim("🔗"), authURL)
|
||||
fmt.Fprintln(output)
|
||||
if openBrowser {
|
||||
_ = 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 {
|
||||
@@ -518,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")
|
||||
}
|
||||
@@ -532,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"),
|
||||
@@ -547,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"),
|
||||
@@ -555,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"),
|
||||
@@ -563,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"),
|
||||
@@ -571,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
|
||||
@@ -595,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
|
||||
}
|
||||
@@ -629,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))
|
||||
|
||||
@@ -656,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 {
|
||||
@@ -671,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 {
|
||||
@@ -723,6 +853,20 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
}
|
||||
}
|
||||
|
||||
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":
|
||||
|
||||
@@ -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")
|
||||
@@ -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", "")
|
||||
@@ -928,6 +1143,12 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
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)
|
||||
@@ -936,8 +1157,11 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %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"])
|
||||
@@ -972,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{
|
||||
@@ -992,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) {
|
||||
@@ -1082,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) {
|
||||
@@ -1208,8 +1453,8 @@ 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="+rawURI) {
|
||||
t.Fatalf("output missing copy-safe PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1254,8 +1499,8 @@ func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *test
|
||||
if opened != wantURL {
|
||||
t.Fatalf("opened url = %q, want normalized %q", opened, wantURL)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+wantURL) {
|
||||
t.Fatalf("output missing normalized PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+75
-7
@@ -323,9 +323,10 @@ 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),
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
@@ -354,7 +355,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 {
|
||||
@@ -372,8 +372,8 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
return root
|
||||
}
|
||||
|
||||
func newAuthCommand() *cobra.Command {
|
||||
return buildAuthCommand()
|
||||
func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
return buildAuthCommand(patCaller)
|
||||
}
|
||||
|
||||
func newSkillCommand() *cobra.Command {
|
||||
@@ -474,7 +474,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)
|
||||
@@ -596,7 +596,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 {
|
||||
@@ -682,8 +682,76 @@ func newGenerateSkillsCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// buildMCPCommandFn is a test seam for newMCPCommand so a panic in the
|
||||
// catalog-driven canonical build can be simulated without crafting a
|
||||
// poisoned on-disk cache.
|
||||
var buildMCPCommandFn = cli.NewMCPCommand
|
||||
|
||||
// newMCPCommand builds the canonical `dws mcp` tree, self-healing a poisoned
|
||||
// cache when the build panics and degrading to an inert stub if that also
|
||||
// fails.
|
||||
//
|
||||
// Why this guard exists: the canonical tree is assembled from cached catalog
|
||||
// data BEFORE the legacy command build and before Cobra dispatches anything,
|
||||
// so a panic here (e.g. a tool schema property named after the reserved
|
||||
// --params flag, as cached during the 1.0.32 incident) used to abort every
|
||||
// invocation — including `dws cache refresh` and `dws upgrade` — and was NOT
|
||||
// covered by the legacy-path guards (#447/#452). Same two-staged recovery as
|
||||
// buildEnvelopeCommandsSafe: quarantine the partition, retry once against a
|
||||
// fresh fetch, then degrade with a `dws cache refresh` hint.
|
||||
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
|
||||
return cli.NewMCPCommand(ctx, loader, runner, engine)
|
||||
cmd, panicked := tryBuildMCPCommand(ctx, loader, runner, engine)
|
||||
if panicked == nil {
|
||||
return cmd
|
||||
}
|
||||
slog.Error("newMCPCommand: canonical command build panicked", "panic", panicked)
|
||||
|
||||
quarantined, qErr := cacheStoreFromEnv().QuarantinePartition(editionPartition())
|
||||
if qErr != nil {
|
||||
slog.Error("newMCPCommand: failed to quarantine discovery cache", "error", qErr)
|
||||
}
|
||||
if quarantined != "" {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
|
||||
"The cached discovery data was moved to %s; rebuilding from a fresh fetch...\n",
|
||||
panicked, quarantined)
|
||||
cmd, panicked = tryBuildMCPCommand(ctx, loader, runner, engine)
|
||||
if panicked == nil {
|
||||
fmt.Fprintln(os.Stderr, "Canonical commands rebuilt successfully.")
|
||||
return cmd
|
||||
}
|
||||
slog.Error("newMCPCommand: rebuild after cache quarantine panicked again, degrading to a stub", "panic", panicked)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"Warning: building canonical commands from the local discovery cache failed: %v\n"+
|
||||
"The 'dws mcp' surface is temporarily unavailable; other commands still work.\n"+
|
||||
"Run 'dws cache refresh' to rebuild the cache.\n", panicked)
|
||||
buildErr := apperrors.NewInternal(fmt.Sprintf("canonical command build failed: %v; run 'dws cache refresh'", panicked))
|
||||
stub := &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "Canonical MCP-derived CLI surface (unavailable)",
|
||||
Hidden: true,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return buildErr
|
||||
},
|
||||
}
|
||||
return stub
|
||||
}
|
||||
|
||||
// tryBuildMCPCommand runs one attempt of the canonical build, converting a
|
||||
// panic into a return value so the caller can decide between self-heal and
|
||||
// degradation.
|
||||
func tryBuildMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) (cmd *cobra.Command, panicked any) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
cmd = nil
|
||||
panicked = r
|
||||
}
|
||||
}()
|
||||
return buildMCPCommandFn(ctx, loader, runner, engine), nil
|
||||
}
|
||||
|
||||
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
|
||||
|
||||
@@ -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/bamboo":
|
||||
_ = 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/bamboo":
|
||||
marketHits.Add(1)
|
||||
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
|
||||
default:
|
||||
|
||||
+26
-10
@@ -6,6 +6,7 @@ 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"
|
||||
)
|
||||
@@ -50,38 +51,42 @@ 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.`)
|
||||
_, _ = 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
|
||||
@@ -90,10 +95,21 @@ func renderRootHelp(root *cobra.Command) {
|
||||
// it and dropped this, so we restore it explicitly here.
|
||||
if long := strings.TrimSpace(root.Long); long != "" {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, long)
|
||||
_, _ = fmt.Fprintln(w, tui.Dim(long))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// be treated as visible. It unions the edition's VisibleProducts hook (when
|
||||
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
|
||||
|
||||
+68
-6
@@ -111,7 +111,7 @@ func logHostOwnedPATDecisionOnce() {
|
||||
hostOwnedPATDecisionOnce.Do(func() {
|
||||
slog.Debug("runtime.host_owned_pat",
|
||||
"hostOwned", authpkg.HostOwnsPATFlow(),
|
||||
"agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "",
|
||||
"agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -259,13 +259,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...),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -687,9 +696,30 @@ func resolveIdentityHeaders() map[string]string {
|
||||
if sessionID == "" {
|
||||
sessionID = os.Getenv(envRewindSessionID)
|
||||
}
|
||||
// Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the
|
||||
// per-(machine × agent_code) instance id. This is what makes agent_code
|
||||
// actually report a value: previously it was sent only when the host
|
||||
// injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the
|
||||
// gateway logged no agent_code at all. DetectAgentCode always yields a code.
|
||||
//
|
||||
// Backward-compat by design (additive, not breaking):
|
||||
// - x-dws-agent-id keeps its v1 meaning = machine-level install UUID
|
||||
// (set by id.Headers() above), so old/new clients stay comparable.
|
||||
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id.
|
||||
// Old clients don't send it, which is itself a clean old/new signal.
|
||||
// Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched.
|
||||
agentCode, agentCodeSig := authpkg.DetectAgentCode()
|
||||
headers["x-dws-agent-instance-id"] = id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig)
|
||||
|
||||
// Emit the CLI version on the wire so the gateway can segment old vs new
|
||||
// clients (and scope agent_code coverage / adoption). The header constant
|
||||
// existed but was never set; wire it here.
|
||||
if version != "" {
|
||||
headers[transport.HeaderVersion] = version
|
||||
}
|
||||
envHeaders := map[string]string{
|
||||
"x-dingtalk-agent": os.Getenv(envDingtalkAgent),
|
||||
"x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)),
|
||||
"x-dingtalk-dws-agent-code": agentCode,
|
||||
"x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID),
|
||||
"x-dingtalk-session-id": sessionID,
|
||||
"x-dingtalk-message-id": os.Getenv(envDingtalkMessageID),
|
||||
@@ -716,13 +746,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)
|
||||
@@ -733,6 +773,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 {
|
||||
|
||||
@@ -321,6 +321,7 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
|
||||
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
|
||||
t.Setenv(authpkg.AgentCodeEnvCompat, "")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
|
||||
@@ -328,6 +329,65 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersAgentIdentityFields(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, "qoder")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
|
||||
// x-dws-agent-id stays machine-level (v1 install UUID): non-empty and NOT
|
||||
// the dwsa_ instance form — this is the cross-version continuity anchor.
|
||||
machineID := headers["x-dws-agent-id"]
|
||||
if machineID == "" {
|
||||
t.Fatal("x-dws-agent-id must stay populated (machine-level)")
|
||||
}
|
||||
if strings.HasPrefix(machineID, "dwsa_") {
|
||||
t.Fatalf("x-dws-agent-id must remain machine-level, got instance form %q", machineID)
|
||||
}
|
||||
|
||||
// x-dws-agent-instance-id is the NEW per-(machine × agent_code) id.
|
||||
instID := headers["x-dws-agent-instance-id"]
|
||||
if !strings.HasPrefix(instID, "dwsa_") {
|
||||
t.Fatalf("x-dws-agent-instance-id must be a derived instance id, got %q", instID)
|
||||
}
|
||||
if instID == machineID {
|
||||
t.Fatal("instance id must differ from machine id")
|
||||
}
|
||||
|
||||
// CLI version must now be on the wire so the gateway can segment old/new.
|
||||
if headers[transport.HeaderVersion] == "" {
|
||||
t.Fatalf("%s must be emitted", transport.HeaderVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
t.Setenv("DWS_DINGTALK_AGENTCODE", " compat ")
|
||||
// Isolate from ambient agent-host detection signals so this test asserts
|
||||
// only the reversed-env-name behavior (the suite itself may run under
|
||||
// Claude Code / Qoder / VS Code, whose signals would otherwise be detected).
|
||||
for _, k := range []string{
|
||||
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
|
||||
"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE", "HERMES_HOME",
|
||||
"CODEX_SANDBOX", "VSCODE_BRAND", "__CFBundleIdentifier",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
// The reversed env name must never be consumed. With no canonical
|
||||
// declaration and no host signature, agent_code resolves to the honest
|
||||
// "custom" fallback — and crucially is NOT the reversed value.
|
||||
got := headers["x-dingtalk-dws-agent-code"]
|
||||
if got == "compat" {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, reversed env must be ignored", got)
|
||||
}
|
||||
if got != authpkg.AgentCodeCustom {
|
||||
t.Fatalf("x-dingtalk-dws-agent-code = %q, want %q (fallback)", got, authpkg.AgentCodeCustom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv(envDingtalkSessionID, "ding-session")
|
||||
@@ -884,6 +944,20 @@ func jsonRPCToolName(req map[string]any) 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{
|
||||
|
||||
@@ -95,12 +95,13 @@ var agentSkillPaths = map[string]string{
|
||||
// `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",
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "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",
|
||||
|
||||
@@ -21,6 +21,8 @@ var skillSetupAgentHomes = []string{
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".qoder/skills",
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".github/skills",
|
||||
@@ -56,7 +58,8 @@ multi 模式支持按产品挑选:
|
||||
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
|
||||
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。`,
|
||||
不带 --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/
|
||||
@@ -68,7 +71,7 @@ multi 模式支持按产品挑选:
|
||||
}
|
||||
cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)")
|
||||
cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets())
|
||||
cmd.Flags().String("source", "", "skill 源目录(默认自动查找二进制旁边或当前目录)")
|
||||
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 互斥)")
|
||||
@@ -95,10 +98,11 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
|
||||
}
|
||||
|
||||
skillSrc, err := resolveSkillSetupSource(source, mode)
|
||||
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcCleanup()
|
||||
|
||||
dests, err := resolveSkillSetupTargets(target, mode)
|
||||
if err != nil {
|
||||
@@ -313,7 +317,31 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
func resolveSkillSetupSource(explicit, mode string) (string, error) {
|
||||
subdir := mode // "mono" or "multi"
|
||||
|
||||
candidates := skillSourceCandidates(explicit, subdir)
|
||||
// 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
|
||||
@@ -639,7 +667,14 @@ func copyFileContent(src, dst string, mode os.FileMode) error {
|
||||
}
|
||||
|
||||
func isInteractiveTerminal() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,22 @@ func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -317,36 +333,6 @@ func TestSkillSourceCandidatesIncludesUserCache(t *testing.T) {
|
||||
|
||||
// TestResolveSkillSetupSourceFallsBackToUserCache verifies that when no
|
||||
// --source / DWS_SKILL_SOURCE / source checkout is available, the resolver
|
||||
// successfully discovers ~/.dws/skills/multi/ as the source.
|
||||
func TestResolveSkillSetupSourceFallsBackToUserCache(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||
|
||||
cacheRoot := filepath.Join(fakeHome, ".dws", "skills", "multi")
|
||||
for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} {
|
||||
if err := os.MkdirAll(filepath.Join(cacheRoot, n), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cacheRoot, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run resolver from a tempdir that has no skills/ on disk, simulating a
|
||||
// fresh user machine without a source checkout.
|
||||
scratch := t.TempDir()
|
||||
t.Chdir(scratch)
|
||||
|
||||
got, err := resolveSkillSetupSource("", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("expected user-cache fallback to succeed, got err=%v", err)
|
||||
}
|
||||
if got != cacheRoot {
|
||||
t.Fatalf("expected %s, got %s", cacheRoot, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMultiSkillName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
|
||||
@@ -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)}},
|
||||
}
|
||||
|
||||
+60
-8
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -14,20 +15,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/edition"
|
||||
"github.com/fatih/color"
|
||||
"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
|
||||
@@ -56,6 +57,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
dws upgrade --list --all # 列出所有版本
|
||||
dws upgrade --version v1.0.5 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -68,6 +70,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
if flagList {
|
||||
@@ -88,6 +91,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
force: flagForce,
|
||||
skipSkills: flagSkipSkills,
|
||||
yes: yes,
|
||||
dryRun: dryRun,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -108,6 +112,7 @@ type upgradeOptions struct {
|
||||
force bool
|
||||
skipSkills bool
|
||||
yes bool
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
// --- dws upgrade --check ---
|
||||
@@ -298,6 +303,29 @@ func runUpgradeRollback(yes bool) error {
|
||||
// Phase 2 (Apply): replace binary + install skills — only runs if Phase 1 fully succeeds.
|
||||
// If anything fails in Phase 1, no files on disk are modified.
|
||||
|
||||
// writeDryRunPlan renders the steps that `dws upgrade` would perform, without
|
||||
// touching the filesystem. Kept side-effect-free and writer-injectable so the
|
||||
// --dry-run contract can be asserted in tests.
|
||||
func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills bool) {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s 预览模式,不会下载或修改任何文件\n", ugBold("[dry-run]"))
|
||||
fmt.Fprintf(w, " 将执行以下操作:\n")
|
||||
fmt.Fprintf(w, " [1/5] 备份当前版本 %s\n", ugDim(ensureV(currentVer)))
|
||||
fmt.Fprintf(w, " [2/5] 下载 %s\n", ugCyan(binaryAssetName))
|
||||
if hasSkills {
|
||||
fmt.Fprintf(w, " 下载 %s\n", ugCyan("dws-skills.zip"))
|
||||
}
|
||||
fmt.Fprintf(w, " [3/5] 校验 SHA256\n")
|
||||
fmt.Fprintf(w, " [4/5] 解压并验证\n")
|
||||
replaceStep := "替换二进制"
|
||||
if hasSkills {
|
||||
replaceStep += " 并安装技能包"
|
||||
}
|
||||
fmt.Fprintf(w, " [5/5] %s\n", replaceStep)
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, " %s\n", ugDim("移除 --dry-run 以实际执行升级"))
|
||||
}
|
||||
|
||||
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s\n", ugDim("检查更新..."))
|
||||
|
||||
@@ -339,6 +367,20 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
fmt.Printf(" %s %s\n", ugBold("通道: "), ugYellow("pre-release"))
|
||||
}
|
||||
|
||||
// --dry-run: preview only. Resolve the platform asset so a missing build is
|
||||
// still reported, then describe the steps that *would* run and return before
|
||||
// any side effect (no backup, no download, no replace). Matches the global
|
||||
// flag's contract: "预览操作内容,不实际执行".
|
||||
if opts.dryRun {
|
||||
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
|
||||
writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !opts.yes {
|
||||
fmt.Println()
|
||||
fmt.Printf("是否升级? [y/N] ")
|
||||
@@ -518,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)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) {
|
||||
func TestUpgradeCommand_BlockedWhenEmbedded(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
@@ -430,6 +430,60 @@ func TestNewUpgradeCommand_Help(t *testing.T) {
|
||||
if !strings.Contains(help, "--rollback") {
|
||||
t.Error("help should contain --rollback")
|
||||
}
|
||||
// Regression for #364: --dry-run must be discoverable from upgrade help so
|
||||
// users know it is supported (and is now actually honored).
|
||||
if !strings.Contains(help, "--dry-run") {
|
||||
t.Error("help should advertise --dry-run for upgrade")
|
||||
}
|
||||
}
|
||||
|
||||
// --- writeDryRunPlan (#364) ---
|
||||
//
|
||||
// Regression for #364: `dws upgrade --dry-run` previously performed a real
|
||||
// upgrade because the flag was silently ignored. The dry-run path must now be
|
||||
// preview-only — it describes the steps without downloading or replacing
|
||||
// anything. writeDryRunPlan is the side-effect-free renderer for that preview.
|
||||
|
||||
func TestWriteDryRunPlan_PreviewOnly(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeDryRunPlan(&buf, "v1.0.30", "dws-darwin-arm64.tar.gz", false)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "dry-run") {
|
||||
t.Errorf("output should be marked as dry-run, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "不会下载或修改任何文件") {
|
||||
t.Errorf("output should state nothing is downloaded or modified, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "dws-darwin-arm64.tar.gz") {
|
||||
t.Errorf("output should name the resolved platform asset, got:\n%s", out)
|
||||
}
|
||||
// All five steps should be previewed, including the (skipped) replace step.
|
||||
for _, step := range []string{"[1/5]", "[2/5]", "[3/5]", "[4/5]", "[5/5]"} {
|
||||
if !strings.Contains(out, step) {
|
||||
t.Errorf("output missing step %s, got:\n%s", step, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDryRunPlan_WithSkills(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeDryRunPlan(&buf, "v1.0.30", "dws-linux-amd64.tar.gz", true)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "dws-skills.zip") {
|
||||
t.Errorf("with skills, output should mention dws-skills.zip, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "安装技能包") {
|
||||
t.Errorf("with skills, replace step should mention installing skills, got:\n%s", out)
|
||||
}
|
||||
|
||||
// Without skills, neither should appear.
|
||||
var buf2 bytes.Buffer
|
||||
writeDryRunPlan(&buf2, "v1.0.30", "dws-linux-amd64.tar.gz", false)
|
||||
if strings.Contains(buf2.String(), "dws-skills.zip") {
|
||||
t.Errorf("without skills, output should not mention dws-skills.zip, got:\n%s", buf2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- isLikelyAMFIKill ---
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// agent_code_detect.go resolves the agent_code — which agent HOST is driving
|
||||
// dws (claudecode / qoder / cursor / vscode / openclaw / hermes / ...). It
|
||||
// fills the x-dingtalk-dws-agent-code header for per-channel statistics.
|
||||
//
|
||||
// SEPARATE axis from DWS_CHANNEL / x-dws-channel (a distribution channel code);
|
||||
// the two are never conflated here.
|
||||
//
|
||||
// Design contract — ACCURACY OVER COVERAGE, but maximize accurate coverage:
|
||||
// - Prefer generalizable, host-declared signals so one rule covers a whole
|
||||
// family (VSCODE_BRAND covers every VS Code fork, present and future).
|
||||
// - Every per-host signature below is OBSERVED on a real host (live process
|
||||
// env via `ps eww`, or the app bundle Info.plist), not guessed.
|
||||
// - Anything unidentified falls back to AgentCodeCustom — never guess.
|
||||
// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
|
||||
// not the agent host) and fuzzy parent-process name matching.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentCodeCustom is the honest fallback for any host we cannot identify.
|
||||
const AgentCodeCustom = "custom"
|
||||
|
||||
// hostSignature is a verified env fingerprint for a known agent host. EnvKeys
|
||||
// match when any listed key is present and non-empty.
|
||||
type hostSignature struct {
|
||||
Code string
|
||||
EnvKeys []string
|
||||
}
|
||||
|
||||
// knownSignatures: CLI / daemon agents that inject a distinctive env var, which
|
||||
// the dws subprocess they spawn inherits. All verified on a real machine
|
||||
// (2026-06-16) via live process env / launch env — not guessed.
|
||||
var knownSignatures = []hostSignature{
|
||||
// Claude Code — verified: CLAUDECODE=1, CLAUDE_CODE_ENTRYPOINT=cli.
|
||||
{Code: "claudecode", EnvKeys: []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}},
|
||||
// OpenClaw — verified on the running daemon: OPENCLAW_BUNDLE_ROOT.
|
||||
{Code: "openclaw", EnvKeys: []string{"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE"}},
|
||||
// Hermes — verified on the running gateway: HERMES_HOME.
|
||||
{Code: "hermes", EnvKeys: []string{"HERMES_HOME"}},
|
||||
// OpenAI Codex — CODEX_SANDBOX is auto-set by Codex for the subprocesses it
|
||||
// spawns (e.g. CODEX_SANDBOX=seatbelt on macOS), and Codex filters this
|
||||
// CODEX_-prefixed name out of user .env to prevent spoofing — so its
|
||||
// presence reliably means "running under Codex".
|
||||
// Source: developers.openai.com/codex/concepts/sandboxing
|
||||
{Code: "codex", EnvKeys: []string{"CODEX_SANDBOX"}},
|
||||
}
|
||||
|
||||
// NOTE on coverage limits (honest, not a TODO to silently ignore):
|
||||
// Most terminal agents (gemini-cli/antigravity, aider, opencode, qwen-code,
|
||||
// crush, goose, kimi, amazon-q, continue, ...) expose NO reliable
|
||||
// self-identifying env marker — only user-set API-key/config vars, which we
|
||||
// must not key off (a user setting GEMINI_API_KEY is not "running under
|
||||
// gemini"). They therefore resolve to custom unless they declare themselves.
|
||||
//
|
||||
// The authoritative, fully-general path to 100% coverage is the T0 declaration
|
||||
// contract: a host sets DINGTALK_DWS_AGENTCODE=<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 custom.
|
||||
//
|
||||
// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
|
||||
// this map is simply a no-op there (os.Getenv returns "").
|
||||
var bundleIDToCode = map[string]string{
|
||||
"com.qoder.ide": "qoder",
|
||||
"com.todesktop.230313mzl4w4u92": "cursor", // Cursor's ToDesktop bundle id
|
||||
"com.microsoft.VSCode": "vscode",
|
||||
"com.workbuddy.workbuddy": "workbuddy",
|
||||
}
|
||||
|
||||
// DetectAgentCode resolves the agent_code via a confidence ladder and returns
|
||||
// the normalized code plus the signal that decided it:
|
||||
//
|
||||
// T0 explicit host declaration (DINGTALK_DWS_AGENTCODE — dedicated field)
|
||||
// T1 verified per-agent env signature (CLI/daemon agents)
|
||||
// T2 VSCODE_BRAND value (every VS Code fork declares its brand)
|
||||
// T3 macOS app bundle id (known agent bundles only)
|
||||
// T4 fallback -> custom (never guess)
|
||||
func DetectAgentCode() (code string, signal string) {
|
||||
// T0: host explicitly declares its agent_code — highest confidence.
|
||||
if v, name := AgentCodeFromEnv(); v != "" {
|
||||
return normalizeAgentCode(v), "env:" + name
|
||||
}
|
||||
|
||||
// T1: verified per-agent env signature (most specific — wins over the IDE
|
||||
// it may be running inside).
|
||||
for _, sig := range knownSignatures {
|
||||
for _, k := range sig.EnvKeys {
|
||||
if strings.TrimSpace(os.Getenv(k)) != "" {
|
||||
return sig.Code, "sig:" + k
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// T2: VS Code fork family. The brand value IS the host's self-declaration,
|
||||
// so this single rule covers Qoder/Cursor/VS Code/Windsurf/Trae/Kiro/... —
|
||||
// including forks that don't exist yet.
|
||||
if b := strings.TrimSpace(os.Getenv("VSCODE_BRAND")); b != "" {
|
||||
return normalizeAgentCode(b), "env:VSCODE_BRAND"
|
||||
}
|
||||
|
||||
// T3: macOS app bundle id (known agent bundles only).
|
||||
if id := strings.TrimSpace(os.Getenv("__CFBundleIdentifier")); id != "" {
|
||||
if c, ok := bundleIDToCode[id]; ok {
|
||||
return c, "bundle:" + id
|
||||
}
|
||||
}
|
||||
|
||||
// T4: unknown host — honest fallback, no guessing.
|
||||
return AgentCodeCustom, "fallback"
|
||||
}
|
||||
|
||||
// normalizeAgentCode maps host-declared names/brands to canonical agent_code
|
||||
// values. Unrecognized but non-empty input is lowercased, space-stripped and
|
||||
// kept as-is — still a host declaration, so still accurate (this is what gives
|
||||
// automatic coverage of new VS Code forks via VSCODE_BRAND).
|
||||
func normalizeAgentCode(raw string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(raw))
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
switch s {
|
||||
case "":
|
||||
return AgentCodeCustom
|
||||
case "claude", "claude-code", "claude_code", "claudecode":
|
||||
return "claudecode"
|
||||
case "qoder", "qoderwork":
|
||||
return "qoder"
|
||||
case "workbuddy", "work-buddy":
|
||||
return "workbuddy"
|
||||
case "visualstudiocode", "code", "code-oss", "vscode":
|
||||
return "vscode"
|
||||
case "cursor":
|
||||
return "cursor"
|
||||
case "windsurf":
|
||||
return "windsurf"
|
||||
case "trae", "traecn":
|
||||
return "trae"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// agentCodeSignalEnvs is every env DetectAgentCode consults. Tests clear them
|
||||
// all so each case starts clean (the suite itself runs under a real host).
|
||||
var agentCodeSignalEnvs = []string{
|
||||
AgentCodeEnv,
|
||||
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
|
||||
"OPENCLAW_BUNDLE_ROOT", "OPENCLAW_RUNTIME_ROLE",
|
||||
"HERMES_HOME", "CODEX_SANDBOX",
|
||||
"VSCODE_BRAND", "__CFBundleIdentifier",
|
||||
"TERM_PROGRAM", "DWS_CHANNEL",
|
||||
}
|
||||
|
||||
func clearAgentCodeEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, k := range agentCodeSignalEnvs {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv(AgentCodeEnv, "Qoder")
|
||||
code, sig := DetectAgentCode()
|
||||
if code != "qoder" {
|
||||
t.Fatalf("want qoder, got %q", code)
|
||||
}
|
||||
if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
|
||||
t.Fatalf("want env signal, got %q", sig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_VerifiedSignatures_T1(t *testing.T) {
|
||||
cases := []struct {
|
||||
env, val, want string
|
||||
}{
|
||||
{"CLAUDECODE", "1", "claudecode"},
|
||||
{"CLAUDE_CODE_ENTRYPOINT", "cli", "claudecode"},
|
||||
{"OPENCLAW_BUNDLE_ROOT", "/Users/x/.openclaw-bundle", "openclaw"},
|
||||
{"HERMES_HOME", "/Users/x/.hermes", "hermes"},
|
||||
{"CODEX_SANDBOX", "seatbelt", "codex"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.env, func(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv(c.env, c.val)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != c.want {
|
||||
t.Fatalf("%s=%s: want %q, got %q", c.env, c.val, c.want, code)
|
||||
}
|
||||
if !strings.HasPrefix(sig, "sig:") {
|
||||
t.Fatalf("want sig:* signal, got %q", sig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_VSCodeBrand_T2(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Qoder": "qoder",
|
||||
"Cursor": "cursor",
|
||||
"Visual Studio Code": "vscode",
|
||||
"Windsurf": "windsurf",
|
||||
"Trae": "trae",
|
||||
"SomeNewFork": "somenewfork", // generic coverage of future forks
|
||||
}
|
||||
for brand, want := range cases {
|
||||
t.Run(brand, func(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("VSCODE_BRAND", brand)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != want {
|
||||
t.Fatalf("VSCODE_BRAND=%q: want %q, got %q", brand, want, code)
|
||||
}
|
||||
if sig != "env:VSCODE_BRAND" {
|
||||
t.Fatalf("want env:VSCODE_BRAND signal, got %q", sig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_BundleID_T3(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"com.qoder.ide": "qoder",
|
||||
"com.todesktop.230313mzl4w4u92": "cursor",
|
||||
"com.microsoft.VSCode": "vscode",
|
||||
"com.workbuddy.workbuddy": "workbuddy",
|
||||
}
|
||||
for id, want := range cases {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("__CFBundleIdentifier", id)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != want {
|
||||
t.Fatalf("bundle %q: want %q, got %q", id, want, code)
|
||||
}
|
||||
if !strings.HasPrefix(sig, "bundle:") {
|
||||
t.Fatalf("want bundle:* signal, got %q", sig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to
|
||||
// custom.
|
||||
func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
|
||||
code, _ := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("unknown bundle must be custom, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAgentCode_Fallback_Custom(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
code, sig := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("want custom, got %q", code)
|
||||
}
|
||||
if sig != "fallback" {
|
||||
t.Fatalf("want fallback, got %q", sig)
|
||||
}
|
||||
}
|
||||
|
||||
// TERM_PROGRAM and DWS_CHANNEL must never decide agent_code.
|
||||
func TestDetectAgentCode_IgnoresNoise(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("TERM_PROGRAM", "iTerm.app")
|
||||
t.Setenv("DWS_CHANNEL", "Qoderwork")
|
||||
code, _ := DetectAgentCode()
|
||||
if code != AgentCodeCustom {
|
||||
t.Fatalf("noise must not decide agent_code; want custom, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence: explicit declaration (T0) > env signature (T1) > VSCODE_BRAND
|
||||
// (T2). A CLI agent running inside an IDE reports the CLI agent.
|
||||
func TestDetectAgentCode_Precedence(t *testing.T) {
|
||||
clearAgentCodeEnv(t)
|
||||
t.Setenv("CLAUDECODE", "1") // T1
|
||||
t.Setenv("VSCODE_BRAND", "Qoder") // T2
|
||||
if code, _ := DetectAgentCode(); code != "claudecode" {
|
||||
t.Fatalf("T1 must beat T2, got %q", code)
|
||||
}
|
||||
t.Setenv(AgentCodeEnv, "workbuddy") // T0
|
||||
if code, _ := DetectAgentCode(); code != "workbuddy" {
|
||||
t.Fatalf("T0 must beat all, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentCode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"claude": "claudecode",
|
||||
"Claude-Code": "claudecode",
|
||||
"CLAUDECODE": "claudecode",
|
||||
"Qoderwork": "qoder",
|
||||
"WorkBuddy": "workbuddy",
|
||||
"Visual Studio Code": "vscode",
|
||||
"Cursor": "cursor",
|
||||
"": AgentCodeCustom,
|
||||
"some-new-ide": "some-new-ide",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeAgentCode(in); got != want {
|
||||
t.Errorf("normalizeAgentCode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -582,20 +591,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 +631,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 +640,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) {
|
||||
|
||||
@@ -290,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 is treated as the
|
||||
// custom bucket.
|
||||
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
|
||||
if agentCode == "" {
|
||||
agentCode = AgentCodeCustom
|
||||
}
|
||||
if id.Agents == nil {
|
||||
id.Agents = make(map[string]*AgentEntry)
|
||||
}
|
||||
if e, ok := id.Agents[agentCode]; ok && e.AgentID != "" {
|
||||
return e.AgentID
|
||||
}
|
||||
aid := deriveAgentID(id.machineSeed(), agentCode)
|
||||
id.Agents[agentCode] = &AgentEntry{
|
||||
AgentID: aid,
|
||||
FirstSeen: time.Now().UTC().Format(time.RFC3339),
|
||||
Detect: signal,
|
||||
}
|
||||
_ = save(configDir, id) // best-effort cache; recomputable if it fails
|
||||
return aid
|
||||
}
|
||||
|
||||
// Headers returns the identity as static HTTP header key-value pairs.
|
||||
// x-dws-agent-id carries the stable machine-level id (== v1 install UUID), kept
|
||||
// continuous across versions. The per-(machine × agent_code) instance id is a
|
||||
// SEPARATE header (x-dws-agent-instance-id) injected by the caller via
|
||||
// ResolveAgentID — it does not override x-dws-agent-id.
|
||||
func (id *Identity) Headers() map[string]string {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
h := make(map[string]string, 5)
|
||||
if id.AgentID != "" {
|
||||
h["x-dws-agent-id"] = id.AgentID
|
||||
if seed := id.machineSeed(); seed != "" {
|
||||
h["x-dws-agent-id"] = seed
|
||||
}
|
||||
if id.Source != "" {
|
||||
h["x-dws-source"] = id.Source
|
||||
@@ -104,6 +193,38 @@ func save(configDir string, id *Identity) error {
|
||||
return os.WriteFile(filepath.Join(configDir, identityFile), data, config.FilePerm)
|
||||
}
|
||||
|
||||
// deriveAgentID computes a stable, client-side agentId for a (machine,
|
||||
// agentCode) pair: dwsa_<12 base62 chars of sha256(seed|agentCode)>.
|
||||
// Deterministic and idempotent; no gateway allocation needed for statistics.
|
||||
func deriveAgentID(seed, agentCode string) string {
|
||||
sum := sha256.Sum256([]byte(seed + "|" + agentCode))
|
||||
enc := base62Encode(sum[:])
|
||||
for len(enc) < 12 {
|
||||
enc = "0" + enc
|
||||
}
|
||||
return "dwsa_" + enc[:12]
|
||||
}
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
func base62Encode(b []byte) string {
|
||||
n := new(big.Int).SetBytes(b)
|
||||
if n.Sign() == 0 {
|
||||
return "0"
|
||||
}
|
||||
base := big.NewInt(62)
|
||||
mod := new(big.Int)
|
||||
var out []byte
|
||||
for n.Sign() > 0 {
|
||||
n.DivMod(n, base, mod)
|
||||
out = append(out, base62Alphabet[mod.Int64()])
|
||||
}
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// generateUUID produces a UUID v4 string.
|
||||
func generateUUID() string {
|
||||
var u [16]byte
|
||||
@@ -113,6 +234,22 @@ func generateUUID() string {
|
||||
}
|
||||
u[6] = (u[6] & 0x0f) | 0x40 // version 4
|
||||
u[8] = (u[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
u[0:4], u[4:6], u[6:8], u[8:10], u[10:16])
|
||||
return fmtUUID(u)
|
||||
}
|
||||
|
||||
func fmtUUID(u [16]byte) string {
|
||||
const hexdig = "0123456789abcdef"
|
||||
// 8-4-4-4-12 with dashes => 36 bytes
|
||||
buf := make([]byte, 36)
|
||||
pos := 0
|
||||
for i := 0; i < 16; i++ {
|
||||
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||
buf[pos] = '-'
|
||||
pos++
|
||||
}
|
||||
buf[pos] = hexdig[u[i]>>4]
|
||||
buf[pos+1] = hexdig[u[i]&0x0f]
|
||||
pos += 2
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveAgentID_Format(t *testing.T) {
|
||||
id := deriveAgentID("machine-abc", "claudecode")
|
||||
if !strings.HasPrefix(id, "dwsa_") {
|
||||
t.Fatalf("want dwsa_ prefix, got %q", id)
|
||||
}
|
||||
if len(id) != len("dwsa_")+12 {
|
||||
t.Fatalf("want 12 base62 chars after prefix, got %q (len %d)", id, len(id))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAgentID_Deterministic(t *testing.T) {
|
||||
a := deriveAgentID("seed", "claudecode")
|
||||
b := deriveAgentID("seed", "claudecode")
|
||||
if a != b {
|
||||
t.Fatalf("derivation must be deterministic: %q != %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAgentID_DistinctByChannelAndMachine(t *testing.T) {
|
||||
m1c1 := deriveAgentID("machine1", "claudecode")
|
||||
m1c2 := deriveAgentID("machine1", "cursor")
|
||||
m2c1 := deriveAgentID("machine2", "claudecode")
|
||||
if m1c1 == m1c2 {
|
||||
t.Errorf("same machine, different channel must differ: %q", m1c1)
|
||||
}
|
||||
if m1c1 == m2c1 {
|
||||
t.Errorf("different machine, same channel must differ: %q", m1c1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := EnsureExists(dir)
|
||||
|
||||
first := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
|
||||
second := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE")
|
||||
if first != second {
|
||||
t.Fatalf("ResolveAgentID must be idempotent: %q != %q", first, second)
|
||||
}
|
||||
|
||||
// Reload from disk — the channel entry must have persisted.
|
||||
reloaded := Load(dir)
|
||||
if reloaded == nil {
|
||||
t.Fatal("expected identity to persist")
|
||||
}
|
||||
e, ok := reloaded.Agents["claudecode"]
|
||||
if !ok || e.AgentID != first {
|
||||
t.Fatalf("persisted agentId mismatch: %+v", reloaded.Agents)
|
||||
}
|
||||
if e.Detect != "sig:CLAUDECODE" {
|
||||
t.Errorf("want detect signal recorded, got %q", e.Detect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := EnsureExists(dir)
|
||||
got := id.ResolveAgentID(dir, "", "fallback")
|
||||
want := id.ResolveAgentID(dir, AgentCodeCustom, "fallback")
|
||||
if got != want {
|
||||
t.Fatalf("empty agent_code must map to custom bucket: %q != %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A v1 file ({agentId, source}) must migrate: machineId backfilled from the
|
||||
// legacy agentId, and per-channel derivation keyed off that stable seed.
|
||||
func TestLoad_MigratesV1(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v1 := `{"agentId":"504ddd36-3acf-45f6-9c1f-82f99260a419","source":"dws"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, identityFile), []byte(v1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id := Load(dir)
|
||||
if id == nil {
|
||||
t.Fatal("v1 file should load")
|
||||
}
|
||||
if id.MachineID != "504ddd36-3acf-45f6-9c1f-82f99260a419" {
|
||||
t.Fatalf("machineId must backfill from legacy agentId, got %q", id.MachineID)
|
||||
}
|
||||
if id.machineSeed() != id.MachineID {
|
||||
t.Fatalf("seed should be machineId, got %q", id.machineSeed())
|
||||
}
|
||||
// Derivation is stable against the migrated seed.
|
||||
want := deriveAgentID(id.MachineID, "claudecode")
|
||||
if got := id.ResolveAgentID(dir, "claudecode", "sig:CLAUDECODE"); got != want {
|
||||
t.Fatalf("post-migration derivation mismatch: %q != %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ type OAuthProvider struct {
|
||||
logger *slog.Logger
|
||||
Output io.Writer
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -56,8 +57,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 +110,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.
|
||||
@@ -393,8 +401,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("authorization URL", "url", authURL)
|
||||
}
|
||||
if err := openBrowser(authURL); err != nil && p.logger != nil {
|
||||
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
|
||||
if !p.NoBrowser {
|
||||
if err := openBrowser(authURL); err != nil && p.logger != nil {
|
||||
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ 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 (产品列表 / 工具参数)",
|
||||
@@ -125,7 +125,13 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
|
||||
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 {
|
||||
@@ -137,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
|
||||
@@ -166,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),
|
||||
@@ -534,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
|
||||
@@ -541,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -288,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{})
|
||||
@@ -310,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{})
|
||||
@@ -1252,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)
|
||||
@@ -1285,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)
|
||||
@@ -1310,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)
|
||||
@@ -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)
|
||||
|
||||
@@ -381,7 +381,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
|
||||
"article": {Description: "文档文章"},
|
||||
},
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"search_open_platform_docs": {
|
||||
"search_open_platform_docs_rag": {
|
||||
CLIName: "search",
|
||||
Group: "article",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
|
||||
@@ -185,7 +185,7 @@ func executePipelineCall(
|
||||
return nil, err
|
||||
}
|
||||
actual := getDotPath(resp, step.PollUntilField)
|
||||
if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
|
||||
if actual != nil && strings.EqualFold(fmt.Sprint(actual), step.PollUntilValue) {
|
||||
return resp, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
|
||||
+50
-11
@@ -415,6 +415,33 @@ func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, de
|
||||
return
|
||||
}
|
||||
|
||||
// canRegisterFlag reports whether a long flag named name can be registered
|
||||
// on cmd without panicking pflag ("flag redefined"). The reserved payload
|
||||
// names are excluded too: ApplyBindings unconditionally registers hidden
|
||||
// --json/--params after the bindings loop. The envelope is remote data —
|
||||
// a duplicate or reserved name there must degrade to "flag unavailable",
|
||||
// never abort the process.
|
||||
func canRegisterFlag(cmd *cobra.Command, name string) bool {
|
||||
if name == "" || name == "json" || name == "params" {
|
||||
return false
|
||||
}
|
||||
return cmd.Flags().Lookup(name) == nil
|
||||
}
|
||||
|
||||
// safeShorthand returns short when it is a single-character shorthand not
|
||||
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
|
||||
// pflag panics on both multi-character and duplicate shorthands.
|
||||
func safeShorthand(cmd *cobra.Command, short string) string {
|
||||
short = strings.TrimSpace(short)
|
||||
if len(short) != 1 {
|
||||
return ""
|
||||
}
|
||||
if cmd.Flags().ShorthandLookup(short) != nil {
|
||||
return ""
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
for _, binding := range bindings {
|
||||
// Positional bindings are collected from cobra args rather than flags.
|
||||
@@ -464,7 +491,7 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
||||
|
||||
registerHidden := func(name string, suffix string) {
|
||||
if name == "" {
|
||||
if !canRegisterFlag(cmd, name) {
|
||||
return
|
||||
}
|
||||
switch binding.Kind {
|
||||
@@ -484,19 +511,27 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
|
||||
if !canRegisterFlag(cmd, primary) {
|
||||
// Duplicate or reserved primary name in the envelope. Skip the
|
||||
// whole binding: CollectBindings tolerates the missing flag
|
||||
// (Lookup → nil → continue) and the value can still be supplied
|
||||
// via the --params payload.
|
||||
continue
|
||||
}
|
||||
short := safeShorthand(cmd, binding.Short)
|
||||
switch binding.Kind {
|
||||
case ValueString:
|
||||
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage)
|
||||
cmd.Flags().StringP(primary, short, defStr, binding.Usage)
|
||||
case ValueInt:
|
||||
cmd.Flags().IntP(primary, binding.Short, defInt, binding.Usage)
|
||||
cmd.Flags().IntP(primary, short, defInt, binding.Usage)
|
||||
case ValueFloat:
|
||||
cmd.Flags().Float64P(primary, binding.Short, defFloat, binding.Usage)
|
||||
cmd.Flags().Float64P(primary, short, defFloat, binding.Usage)
|
||||
case ValueBool:
|
||||
cmd.Flags().BoolP(primary, binding.Short, defBool, binding.Usage)
|
||||
cmd.Flags().BoolP(primary, short, defBool, binding.Usage)
|
||||
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
|
||||
cmd.Flags().StringSliceP(primary, binding.Short, defSlice, binding.Usage)
|
||||
cmd.Flags().StringSliceP(primary, short, defSlice, binding.Usage)
|
||||
case ValueJSON:
|
||||
cmd.Flags().StringP(primary, binding.Short, defStr, binding.Usage+" (JSON)")
|
||||
cmd.Flags().StringP(primary, short, defStr, binding.Usage+" (JSON)")
|
||||
}
|
||||
registerHidden(alias, " (alias)")
|
||||
for _, extra := range extras {
|
||||
@@ -513,8 +548,12 @@ func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
|
||||
}
|
||||
}
|
||||
}
|
||||
cmd.Flags().String("json", "", "Base JSON object payload for this command")
|
||||
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
cmd.Flags().String("json", "", "Base JSON object payload for this command")
|
||||
}
|
||||
if cmd.Flags().Lookup("params") == nil {
|
||||
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
||||
}
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
_ = cmd.Flags().MarkHidden("params")
|
||||
}
|
||||
@@ -553,12 +592,12 @@ func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
|
||||
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
|
||||
|
||||
register := func(name string, withShort bool, hidden bool, usageSuffix string) {
|
||||
if name == "" {
|
||||
if !canRegisterFlag(cmd, name) {
|
||||
return
|
||||
}
|
||||
short := ""
|
||||
if withShort {
|
||||
short = binding.Short
|
||||
short = safeShorthand(cmd, binding.Short)
|
||||
}
|
||||
usage := binding.Usage + usageSuffix
|
||||
switch binding.Kind {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// The envelope is remote data; none of these malformed shapes may panic the
|
||||
// command build — pflag panics on duplicate long names, duplicate shorthands,
|
||||
// and multi-character shorthands, and a poisoned discovery cache used to take
|
||||
// down every CLI invocation this way (pre-1.0.32 lockout class).
|
||||
func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]market.CLIFlagOverride
|
||||
}{
|
||||
{
|
||||
name: "duplicate shorthand across two flags",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "x"},
|
||||
"beta": {Shorthand: "x"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi-character shorthand",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "xy"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "primary collides with reserved payload flag",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"params": {},
|
||||
"json": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cross-binding duplicate primary via alias",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"user_id": {Alias: "target"},
|
||||
"member_id": {Alias: "target"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cross-binding alias collides with another primary",
|
||||
flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {},
|
||||
"beta": {Aliases: []string{"alpha"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-guard",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "guard",
|
||||
Command: "guard",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"guard_tool": {
|
||||
CLIName: "boom",
|
||||
Flags: tc.flags,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Must not panic; the command must build and stay executable.
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
|
||||
}
|
||||
cmds[0].SetArgs([]string{"boom", "--help"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
if err := cmds[0].Execute(); err != nil {
|
||||
t.Fatalf("execute --help: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommandsKeepsFirstShorthand pins the winner: when two
|
||||
// flags claim the same shorthand, the first (sorted param order) keeps it
|
||||
// and the second still registers its long flag.
|
||||
func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-guard",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "guard",
|
||||
Command: "guard",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"guard_tool": {
|
||||
CLIName: "boom",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"alpha": {Shorthand: "x"},
|
||||
"beta": {Shorthand: "x"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
|
||||
boom, _, err := cmds[0].Find([]string{"boom"})
|
||||
if err != nil {
|
||||
t.Fatalf("find boom: %v", err)
|
||||
}
|
||||
short := boom.Flags().ShorthandLookup("x")
|
||||
if short == nil || short.Name != "alpha" {
|
||||
t.Fatalf("shorthand -x bound to %v, want alpha", short)
|
||||
}
|
||||
if boom.Flags().Lookup("beta") == nil {
|
||||
t.Fatalf("long flag --beta missing; dropping the shorthand must not drop the flag")
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (s *Service) DiscoverServers(ctx context.Context) ([]market.ServerDescripto
|
||||
|
||||
response, err := s.MarketClient.FetchServers(ctx, 200)
|
||||
if err == nil {
|
||||
servers := market.NormalizeServers(response, "live_market")
|
||||
servers := market.NormalizeServersForBaseURL(response, "live_market", s.MarketClient.BaseURL)
|
||||
_ = s.Cache.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers})
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
)
|
||||
|
||||
// newTestMCPServer returns an httptest.Server that handles both market registry
|
||||
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis
|
||||
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/bamboo
|
||||
// succeeds, and mcpOK controls whether initialize+tools/list succeed.
|
||||
func newTestMCPServer(t *testing.T, marketOK, mcpOK bool) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !marketOK {
|
||||
http.Error(w, "market unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
+21
-19
@@ -21,6 +21,8 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
@@ -309,7 +311,7 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
}
|
||||
payload := map[string]any{"error": errorPayload}
|
||||
|
||||
data, marshalErr := json.MarshalIndent(payload, "", " ")
|
||||
data, marshalErr := jsonutil.MarshalIndent(payload, "", " ")
|
||||
if marshalErr != nil {
|
||||
_, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n")
|
||||
return writeErr
|
||||
@@ -344,25 +346,25 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
|
||||
var typed *Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
_, writeErr := fmt.Fprintf(w, "Error: %s\n", err.Error())
|
||||
_, writeErr := fmt.Fprintf(w, "%s %s\n", tui.StateMark("error"), tui.Danger("Error: "+err.Error()))
|
||||
return writeErr
|
||||
}
|
||||
|
||||
// Line 1: Error summary
|
||||
lines := []string{
|
||||
fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message),
|
||||
fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))),
|
||||
}
|
||||
|
||||
// Always shown: hint, actions, retryable
|
||||
if typed.Hint != "" {
|
||||
lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint))
|
||||
lines = append(lines, tui.Cyan(fmt.Sprintf("Hint: %s", typed.Hint)))
|
||||
}
|
||||
|
||||
// Add user-friendly hint for specific server error codes
|
||||
switch typed.ServerDiag.ServerErrorCode {
|
||||
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
|
||||
lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")
|
||||
lines = append(lines, "Action: 开启地址: "+config.GetDeveloperSettingsURL())
|
||||
lines = append(lines, tui.Cyan("Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。"))
|
||||
lines = append(lines, tui.White("Action: 开启地址: "+config.GetDeveloperSettingsURL()))
|
||||
}
|
||||
|
||||
if len(typed.Actions) > 0 {
|
||||
@@ -370,53 +372,53 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
if strings.TrimSpace(action) == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("Action: %s", action))
|
||||
lines = append(lines, tui.White(fmt.Sprintf("Action: %s", action)))
|
||||
}
|
||||
}
|
||||
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
|
||||
lines = append(lines, line)
|
||||
lines = append(lines, tui.Dim(line))
|
||||
}
|
||||
if typed.Retryable {
|
||||
lines = append(lines, "Retryable: true")
|
||||
lines = append(lines, tui.Warning("Retryable: true"))
|
||||
}
|
||||
|
||||
// Always shown when present: Trace ID, Server Code
|
||||
if typed.ServerDiag.TraceID != "" {
|
||||
lines = append(lines, fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID)))
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "" {
|
||||
lines = append(lines, fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode)))
|
||||
}
|
||||
|
||||
// Verbose+: technical detail, snapshot, reason, server key
|
||||
if v >= VerbosityVerbose {
|
||||
if typed.ServerDiag.TechnicalDetail != "" {
|
||||
lines = append(lines, fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail)))
|
||||
}
|
||||
if typed.Reason != "" {
|
||||
lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Reason: %s", typed.Reason)))
|
||||
}
|
||||
if typed.ServerKey != "" {
|
||||
lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
|
||||
}
|
||||
if typed.Snapshot != "" {
|
||||
lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
|
||||
}
|
||||
if typed.Cause != nil {
|
||||
lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error()))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Cause: %s", typed.Cause.Error())))
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: all internal diagnostics
|
||||
if v >= VerbosityDebug {
|
||||
if typed.Operation != "" {
|
||||
lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Operation: %s", typed.Operation)))
|
||||
}
|
||||
if typed.RPCCode != 0 {
|
||||
lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("RPC Code: %d", typed.RPCCode)))
|
||||
}
|
||||
if len(typed.RPCData) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("RPC Data: %s", string(typed.RPCData))))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+42
-13
@@ -14,12 +14,15 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
|
||||
)
|
||||
|
||||
// hostControlProvider returns the host-owned clawType for the current
|
||||
@@ -104,12 +107,10 @@ const ExitCodePermission = 4
|
||||
// json.Unmarshal-able payload of the form
|
||||
// {"success":false,"code":<frozen enum>,"data":{...}}.
|
||||
//
|
||||
// When the payload includes data.uri, that URL is the authoritative
|
||||
// server-provided authorization link. Hosts must treat it as opaque and open
|
||||
// it verbatim instead of parsing and reconstructing it locally, because
|
||||
// required parameters may live in query, encoded hash, or fragment sections.
|
||||
// New hosts may prefer data.authorizationUrl when present; it preserves data.uri
|
||||
// while adding a copy/open-safe URL for legacy DingTalk hash-route variants.
|
||||
// When the payload includes data.uri/authUrl/authorizationUrl, that value is
|
||||
// the authoritative server-provided authorization link. The CLI accepts all
|
||||
// legacy aliases, normalizes the known DingTalk hash-route variant, and emits a
|
||||
// single data.uri field so terminals and hosts do not need to deduplicate links.
|
||||
type PATError struct {
|
||||
RawJSON string
|
||||
}
|
||||
@@ -146,6 +147,7 @@ var patNoPermissionCodes = map[string]bool{
|
||||
// `dws auth login --scope <data.missingScope>`.
|
||||
var patAuthRequiredCodes = map[string]bool{
|
||||
"AGENT_CODE_NOT_EXISTS": true,
|
||||
"PAT_BATCH_AUTH_PENDING": true,
|
||||
"PAT_SCOPE_AUTH_REQUIRED": true,
|
||||
}
|
||||
|
||||
@@ -240,7 +242,7 @@ func isBusinessError(body map[string]any) bool {
|
||||
// Check order: DWS gateway auth > PAT permission.
|
||||
func ClassifyToolResultContent(content map[string]any) error {
|
||||
if _, ok := getDWSGatewayErrorCode(content); ok {
|
||||
raw, _ := json.Marshal(content)
|
||||
raw, _ := jsonutil.Marshal(content)
|
||||
return NewAuth(string(raw),
|
||||
WithReason("gateway_auth_expired"),
|
||||
WithHint(authExpiredHint()),
|
||||
@@ -352,8 +354,11 @@ func ApplyHostMutations(out map[string]any) {
|
||||
data = map[string]any{}
|
||||
out["data"] = data
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = PATAuthorizationURL(rawURI)
|
||||
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
|
||||
authURL := PATAuthorizationURL(rawURI)
|
||||
data["uri"] = authURL
|
||||
delete(data, "authUrl")
|
||||
delete(data, "authorizationUrl")
|
||||
}
|
||||
if block := HostControlBlock(); block != nil {
|
||||
delete(data, "callbacks")
|
||||
@@ -362,6 +367,16 @@ func ApplyHostMutations(out map[string]any) {
|
||||
data["openBrowser"] = PATOpenBrowserValue()
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
// PATAuthorizationURL returns the best URL for hosts to open or show to users.
|
||||
// It keeps already-complete PAT URLs unchanged. For DingTalk's legacy
|
||||
// /fe/old#%2FpersonalAuthorization?... hash-route form, it adds the explicit
|
||||
@@ -442,9 +457,9 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
"code": code,
|
||||
}
|
||||
if data, ok := body["data"]; ok {
|
||||
// Keep data.uri exactly as returned by the service. Host consumers open
|
||||
// that link directly, so local normalization would risk dropping
|
||||
// parameters embedded in query/hash/fragment sections.
|
||||
// ApplyHostMutations canonicalizes PAT URL aliases into one data.uri
|
||||
// before JSON encoding, while stripClassFields keeps the rest of the
|
||||
// service payload intact.
|
||||
out["data"] = stripClassFields(data)
|
||||
} else {
|
||||
fallback := map[string]any{}
|
||||
@@ -462,13 +477,27 @@ func cleanPATJSON(body map[string]any, code string) string {
|
||||
// stderr JSON MUST be a single-line, directly json.Unmarshal-able
|
||||
// payload — pretty-printing would break naïve host parsers that read
|
||||
// stderr line-by-line and fail on leading whitespace.
|
||||
b, err := json.Marshal(out)
|
||||
b, err := marshalSingleLineJSONNoHTMLEscape(out)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"success":false,"code":"%s"}`, code)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ---- Runner adapter functions ------------------------------------------------
|
||||
// These match the function signatures referenced by runner.go's PAT check
|
||||
// framework (ClassifyPatAuthCheck / AsPatAuthCheckError).
|
||||
|
||||
@@ -378,6 +378,25 @@ func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_PATBatchAuthPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-1","uri":"https://example.test/auth"}}`
|
||||
err := ClassifyMCPResponseText(text)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
var patErr *PATError
|
||||
if !stderrors.As(err, &patErr) {
|
||||
t.Fatalf("expected *PATError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "PAT_BATCH_AUTH_PENDING") {
|
||||
t.Errorf("RawJSON should contain PAT_BATCH_AUTH_PENDING, got: %s", patErr.RawJSON)
|
||||
}
|
||||
if !strings.Contains(patErr.RawJSON, "flow-1") {
|
||||
t.Errorf("RawJSON should preserve flowId, got: %s", patErr.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyMCPResponseText_BusinessError(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := `{"success":false,"errorMsg":"搜索内容不能为空"}`
|
||||
@@ -729,6 +748,19 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON escaped ampersands in URL: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "&userCode=Q8RY-X6E9") {
|
||||
t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result)
|
||||
}
|
||||
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON should keep URL ampersands readable for mobile copy/linkify, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "&userCode=Q8RY-X6E9") {
|
||||
t.Fatalf("cleanPATJSON output missing readable fragment separator, got: %s", result)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
@@ -738,8 +770,40 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted when data.uri is present")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted when data.uri is present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_BackfillsSingleURIFromAuthURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9"
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_BATCH_AUTH_PENDING",
|
||||
"data": map[string]any{
|
||||
"flowId": "50dff7654b7444e88ced7489b07cce8d",
|
||||
"authUrl": rawURI,
|
||||
},
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_BATCH_AUTH_PENDING")
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
}
|
||||
data, _ := parsed["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 after backfilling data.uri")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,7 +859,7 @@ func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) {
|
||||
func TestCleanPATJSON_NormalizesSingleURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
|
||||
want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
|
||||
@@ -810,17 +874,26 @@ func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) {
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
if strings.Contains(result, `\u0026`) {
|
||||
t.Fatalf("cleanPATJSON escaped ampersands in normalized URL: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "&userCode=CFTC-27ZN") {
|
||||
t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
if got, _ := data["uri"].(string); got != want {
|
||||
t.Fatalf("data.uri = %q, want normalized %q", got, want)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != want {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, want)
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("data.authUrl should be omitted after normalizing data.uri")
|
||||
}
|
||||
if _, ok := data["authorizationUrl"]; ok {
|
||||
t.Fatalf("data.authorizationUrl should be omitted after normalizing data.uri")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestResourceName(t *testing.T) {
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", input: "search_open_platform_docs"},
|
||||
{name: "valid", input: "search_open_platform_docs_rag"},
|
||||
{name: "valid-cjk", input: "审批查询"},
|
||||
{name: "leading-digit", input: "1tool", wantErr: true},
|
||||
{name: "shell-char", input: "tool;rm", wantErr: true},
|
||||
|
||||
@@ -259,7 +259,7 @@ func newDocsMCPGateway(expectations []docsServerExpectation) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
|
||||
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
|
||||
@@ -82,7 +82,6 @@ var writeOperationTokens = map[string]struct{}{
|
||||
}
|
||||
|
||||
var legacy17CoverageTargets = []string{
|
||||
"aiapp",
|
||||
"aitable",
|
||||
"attendance",
|
||||
"calendar",
|
||||
@@ -102,7 +101,6 @@ var legacy17CoverageTargets = []string{
|
||||
}
|
||||
|
||||
var extended22CoverageTargets = []string{
|
||||
"aiapp",
|
||||
"aitable",
|
||||
"attendance",
|
||||
"calendar",
|
||||
|
||||
@@ -77,7 +77,6 @@ type RecipeEntry struct {
|
||||
}
|
||||
|
||||
var knownRegistryProducts = map[string]struct{}{
|
||||
"aiapp": {},
|
||||
"aidesign": {},
|
||||
"aitable": {},
|
||||
"attendance": {},
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(func() Handler {
|
||||
return aiappHandler{}
|
||||
})
|
||||
}
|
||||
|
||||
type aiappHandler struct{}
|
||||
|
||||
func (aiappHandler) Name() string {
|
||||
return "aiapp"
|
||||
}
|
||||
|
||||
func (aiappHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "aiapp",
|
||||
Short: "AI 应用创建 / 查询 / 修改",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
root.AddCommand(
|
||||
newAIAppCreateCommand(runner),
|
||||
newAIAppQueryCommand(runner),
|
||||
newAIAppModifyCommand(runner),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
func newAIAppCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建 AI 应用",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
prompt, err := aiappRequiredFlag(cmd, "prompt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{"prompt": prompt}
|
||||
if err := addAIAppOptionalInputs(cmd, params); err != nil {
|
||||
return err
|
||||
}
|
||||
return runAIAppTool(cmd, runner, "create_ai_app", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("prompt", "", "创建 AI 应用的 prompt (必填)")
|
||||
cmd.Flags().String("attachments", "", "附件对象数组 JSON")
|
||||
cmd.Flags().String("skills", "", "技能 ID 列表,逗号分隔")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAIAppQueryCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: "查询 AI 应用任务",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
taskID, err := aiappRequiredFlag(cmd, "task-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAIAppTool(cmd, runner, "query_ai_app", map[string]any{"taskId": taskID})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("task-id", "", "AI 应用任务 ID (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAIAppModifyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "modify",
|
||||
Short: "修改 AI 应用",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
prompt, err := aiappRequiredFlag(cmd, "prompt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
threadID, err := aiappRequiredFlag(cmd, "thread-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"prompt": prompt,
|
||||
"threadId": threadID,
|
||||
}
|
||||
if skills := aiappStringFlag(cmd, "skills"); skills != "" {
|
||||
params["officialSkillUids"] = aiappCSV(skills)
|
||||
}
|
||||
return runAIAppTool(cmd, runner, "modify_ai_app", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("prompt", "", "新的 prompt (必填)")
|
||||
cmd.Flags().String("thread-id", "", "threadId (必填)")
|
||||
cmd.Flags().String("skills", "", "技能 ID 列表,逗号分隔")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addAIAppOptionalInputs(cmd *cobra.Command, params map[string]any) error {
|
||||
if attachments := aiappStringFlag(cmd, "attachments"); attachments != "" {
|
||||
var values []any
|
||||
if err := json.Unmarshal([]byte(attachments), &values); err != nil {
|
||||
return apperrors.NewValidation("--attachments must be a JSON array: " + err.Error())
|
||||
}
|
||||
if len(values) > 0 {
|
||||
params["attachments"] = values
|
||||
}
|
||||
}
|
||||
if skills := aiappStringFlag(cmd, "skills"); skills != "" {
|
||||
params["officialSkillUids"] = aiappCSV(skills)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAIAppTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"aiapp",
|
||||
tool,
|
||||
params,
|
||||
)
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, invocation)
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
func aiappRequiredFlag(cmd *cobra.Command, name string) (string, error) {
|
||||
if value := aiappStringFlag(cmd, name); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", apperrors.NewValidation("--" + name + " is required")
|
||||
}
|
||||
|
||||
func aiappStringFlag(cmd *cobra.Command, name string) string {
|
||||
if value, err := cmd.Flags().GetString(name); err == nil && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func aiappCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value := strings.TrimSpace(part); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
type aiappCommandRunner struct {
|
||||
last executor.Invocation
|
||||
}
|
||||
|
||||
func (r *aiappCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.last = invocation
|
||||
return executor.Result{Invocation: invocation}, nil
|
||||
}
|
||||
|
||||
func TestAIAppModifyMatchesWukongPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aiappCommandRunner{}
|
||||
cmd := aiappHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{
|
||||
"modify",
|
||||
"--prompt", "根据新图片优化首页视觉风格",
|
||||
"--thread-id", "THREAD_001",
|
||||
"--skills", "s1,s2",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
||||
}
|
||||
if runner.last.Tool != "modify_ai_app" {
|
||||
t.Fatalf("tool = %q, want modify_ai_app", runner.last.Tool)
|
||||
}
|
||||
if got := runner.last.Params["threadId"]; got != "THREAD_001" {
|
||||
t.Fatalf("threadId = %#v, want THREAD_001", got)
|
||||
}
|
||||
if _, ok := runner.last.Params["attachments"]; ok {
|
||||
t.Fatalf("attachments should not be sent by modify_ai_app: %#v", runner.last.Params["attachments"])
|
||||
}
|
||||
skills, ok := runner.last.Params["officialSkillUids"].([]string)
|
||||
if !ok || len(skills) != 2 || skills[0] != "s1" || skills[1] != "s2" {
|
||||
t.Fatalf("officialSkillUids = %#v, want [s1 s2]", runner.last.Params["officialSkillUids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAppCreateRejectsInvalidAttachmentsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &aiappCommandRunner{}
|
||||
cmd := aiappHandler{}.Command(runner)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"create", "--prompt", "创建应用", "--attachments", `{"bad":true}`})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("Execute() error = nil, want JSON array validation failure")
|
||||
}
|
||||
if runner.last.Tool != "" {
|
||||
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -332,9 +334,19 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
|
||||
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
|
||||
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才在消息下方显示 AI 发送角标)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
|
||||
// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
|
||||
// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
|
||||
func attachAITag(cmd *cobra.Command, params map[string]any) {
|
||||
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
|
||||
params["clawType"] = edition.ClawType()
|
||||
}
|
||||
}
|
||||
|
||||
// deriveTitleFromText 在未显式指定 --title 时,从正文截取一个标题
|
||||
// (首行、最多 20 个字符),与 wukong 行为对齐 (send_personal_message 的
|
||||
// content 内携带 title)。
|
||||
@@ -441,6 +453,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
return nil, "", apperrors.NewValidation("unsupported --msg-type: " + msgType + " (supported: image, file)")
|
||||
}
|
||||
params := map[string]any{"msgType": msgType, "content": contentJSON}
|
||||
attachAITag(cmd, params)
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -475,12 +488,12 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
b, _ := json.Marshal(map[string]string{"title": title, "text": text})
|
||||
params := map[string]any{
|
||||
"openConversationId": group,
|
||||
"msgType": "markdown",
|
||||
"content": string(b),
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
}
|
||||
@@ -493,14 +506,15 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
return params, "send_personal_message", nil
|
||||
case hasUser:
|
||||
params := map[string]any{"title": title, "text": text, "receiverUserId": user}
|
||||
attachAITag(cmd, params)
|
||||
return params, "send_direct_message_as_user", nil
|
||||
default:
|
||||
b, _ := json.Marshal(map[string]string{"title": title, "text": text})
|
||||
params := map[string]any{
|
||||
"receiverOpenDingTalkId": openID,
|
||||
"msgType": "markdown",
|
||||
"content": string(b),
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -976,8 +990,10 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
"openConversationId": convID,
|
||||
"msgType": "reply",
|
||||
"content": contentJSON,
|
||||
"clawType": "wukong",
|
||||
}
|
||||
// clawType 仅在 --ai-tag 时携带;默认不带,回复不强加 AI 角标。
|
||||
// edition 决定取值 (开源 openClaw / 悟空 wukong)。
|
||||
attachAITag(cmd, params)
|
||||
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -1001,6 +1017,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
|
||||
cmd.Flags().String("text", "", "回复正文 (必填)")
|
||||
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才显示 AI 发送角标)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1011,3 +1028,19 @@ func jsonMarshal(v any) (string, error) {
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// marshalMessageContent builds the send_personal_message content payload
|
||||
// ({"title","text"}) WITHOUT HTML-escaping < > &. DingTalk's client renders
|
||||
// @-mentions by matching literal <@openDingTalkId> / <@all> tokens in the
|
||||
// message text; the default json.Marshal escaping turns them into
|
||||
// <@...>, which the client shows as plain text instead of a rendered
|
||||
// mention. encoding/json offers no escape toggle on Marshal, so use an Encoder.
|
||||
func marshalMessageContent(title, text string) string {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
// Encoder errors are impossible for a map[string]string; ignore safely.
|
||||
_ = enc.Encode(map[string]string{"title": title, "text": text})
|
||||
// Encoder.Encode appends a trailing newline; strip it.
|
||||
return strings.TrimRight(buf.String(), "\n")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type captureRunner struct {
|
||||
@@ -222,6 +224,55 @@ func TestChatMessageSendForwardsAtMentions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageSendContentNotHTMLEscaped guards the @-mention rendering fix:
|
||||
// the send_personal_message content must keep literal <@openDingTalkId> / <@all>
|
||||
// tokens. If json.Marshal's default HTML escaping is reintroduced, the tokens
|
||||
// become <@...> and the DingTalk client renders them as plain text
|
||||
// instead of a real @-mention.
|
||||
func TestChatMessageSendContentNotHTMLEscaped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string // literal token that must survive in content
|
||||
}{
|
||||
{
|
||||
name: "group-at-all",
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@all> hi", "--at-all"},
|
||||
want: "<@all>",
|
||||
},
|
||||
{
|
||||
name: "group-at-open-dingtalk-id",
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@op-1> hi", "--at-open-dingtalk-ids", "op-1"},
|
||||
want: "<@op-1>",
|
||||
},
|
||||
{
|
||||
name: "direct-open-dingtalk-id",
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "<@OP123> hi"},
|
||||
want: "<@OP123>",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
content, _ := runner.last.Params["content"].(string)
|
||||
if !strings.Contains(content, tc.want) {
|
||||
t.Fatalf("content %q missing literal %q (HTML-escaped?)", content, tc.want)
|
||||
}
|
||||
if strings.Contains(content, "\\u003c") || strings.Contains(content, "\\u003e") {
|
||||
t.Fatalf("content %q is HTML-escaped; @-mention will not render", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageSendRejectsAtMentionsOutsideGroup ensures we do not silently
|
||||
// drop user intent when --at-* is combined with --user / --open-dingtalk-id
|
||||
// (single-chat tools have no @-mention semantics, so the flag would never
|
||||
@@ -259,6 +310,104 @@ func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageAITagControlsClawType guards the opt-in "Send from AI" indicator:
|
||||
// by default NO user-identity send carries the clawType tool argument (so the IM
|
||||
// server renders no AI badge). Only when --ai-tag is passed does each path attach
|
||||
// the edition claw identity (open-source build pins it to edition.DefaultOSSClawType,
|
||||
// "openClaw"); the wukong overlay would attach its own value. The label is opt-in so
|
||||
// dws does not surprise users by branding every message they send.
|
||||
func TestChatMessageAITagControlsClawType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
make func(runner executor.Runner) *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "group-markdown",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||||
},
|
||||
{
|
||||
name: "user-direct",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
|
||||
},
|
||||
{
|
||||
name: "open-dingtalk-id-direct",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
|
||||
},
|
||||
{
|
||||
name: "group-rich-media-image",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--group", "cid-xyz", "--msg-type", "image", "--media-id", "media-1"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
make: newChatMessageReplyCommand,
|
||||
args: []string{
|
||||
"--conversation-id", "cid-xyz",
|
||||
"--ref-msg-id", "msg-1",
|
||||
"--ref-sender", "op-1",
|
||||
"--text", "got it",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
// Default: no --ai-tag → must omit clawType entirely (no badge).
|
||||
t.Run(tc.name+"/default-no-tag", func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := tc.make(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if v, ok := runner.last.Params["clawType"]; ok {
|
||||
t.Fatalf("default send must omit clawType, got %#v", v)
|
||||
}
|
||||
})
|
||||
// Opt-in: --ai-tag → attach the edition claw identity.
|
||||
t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := tc.make(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag"))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
got, ok := runner.last.Params["clawType"]
|
||||
if !ok {
|
||||
t.Fatalf("--ai-tag send missing clawType; got %#v", runner.last.Params)
|
||||
}
|
||||
if got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Robot sends are rendered as bot messages already; they must NOT carry the
|
||||
// user-identity clawType argument.
|
||||
func TestChatMessageSendByBotOmitsClawType(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendByBotCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--group", "cid-xyz", "--robot-code", "robot-001", "--title", "t", "--text", "x"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if _, ok := runner.last.Params["clawType"]; ok {
|
||||
t.Fatalf("bot send must not carry clawType; got %#v", runner.last.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func equalAny(a, b any) bool {
|
||||
switch av := a.(type) {
|
||||
case []any:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// connectGate enforces the connector's access policy: optional user/group
|
||||
// allowlists plus a per-sender rate limit. A long-lived Q&A bot is otherwise
|
||||
// drivable (and billable — every message is an LLM call) by anyone who can
|
||||
// reach it in a group.
|
||||
type connectGate struct {
|
||||
allowedUsers map[string]struct{}
|
||||
allowedGroups map[string]struct{}
|
||||
perMinute int
|
||||
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
now func() time.Time // test hook
|
||||
}
|
||||
|
||||
// splitCommaList splits a comma-separated flag/env value, trimming blanks.
|
||||
func splitCommaList(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newConnectGate builds a gate from staffId / openConversationId allowlists
|
||||
// and a per-sender messages-per-minute cap (0 = unlimited).
|
||||
func newConnectGate(users, groups []string, perMinute int) *connectGate {
|
||||
g := &connectGate{perMinute: perMinute, hits: map[string][]time.Time{}, now: time.Now}
|
||||
for _, u := range users {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
if g.allowedUsers == nil {
|
||||
g.allowedUsers = map[string]struct{}{}
|
||||
}
|
||||
g.allowedUsers[u] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, grp := range groups {
|
||||
if grp = strings.TrimSpace(grp); grp != "" {
|
||||
if g.allowedGroups == nil {
|
||||
g.allowedGroups = map[string]struct{}{}
|
||||
}
|
||||
g.allowedGroups[grp] = struct{}{}
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// enabled reports whether any policy is configured at all.
|
||||
func (g *connectGate) enabled() bool {
|
||||
return g != nil && (len(g.allowedUsers) > 0 || len(g.allowedGroups) > 0 || g.perMinute > 0)
|
||||
}
|
||||
|
||||
// allow reports whether a message may proceed; on denial, reason names the
|
||||
// rule that fired (for the connector log). Group messages must pass the group
|
||||
// allowlist AND, when a user allowlist is set, the sender allowlist too.
|
||||
func (g *connectGate) allow(staffID, convType, convID string) (bool, string) {
|
||||
if g == nil {
|
||||
return true, ""
|
||||
}
|
||||
if convType == "2" && len(g.allowedGroups) > 0 {
|
||||
if _, ok := g.allowedGroups[convID]; !ok {
|
||||
return false, "group-not-allowed"
|
||||
}
|
||||
}
|
||||
if len(g.allowedUsers) > 0 {
|
||||
if _, ok := g.allowedUsers[staffID]; !ok {
|
||||
return false, "user-not-allowed"
|
||||
}
|
||||
}
|
||||
if g.perMinute > 0 && staffID != "" {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := g.now()
|
||||
cutoff := now.Add(-time.Minute)
|
||||
kept := g.hits[staffID][:0]
|
||||
for _, t := range g.hits[staffID] {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= g.perMinute {
|
||||
g.hits[staffID] = kept
|
||||
return false, "rate-limited"
|
||||
}
|
||||
g.hits[staffID] = append(kept, now)
|
||||
// Bound memory against sender-id churn: keep only the current sender
|
||||
// when the map grows absurdly large.
|
||||
if len(g.hits) > 4096 {
|
||||
g.hits = map[string][]time.Time{staffID: g.hits[staffID]}
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConnectGateAllowlists(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
users []string
|
||||
groups []string
|
||||
staff string
|
||||
convType string
|
||||
convID string
|
||||
wantOK bool
|
||||
wantReason string
|
||||
}{
|
||||
{"no policy allows everyone", nil, nil, "u1", "1", "", true, ""},
|
||||
{"user in list", []string{"u1", "u2"}, nil, "u1", "1", "", true, ""},
|
||||
{"user not in list", []string{"u1"}, nil, "u9", "1", "", false, "user-not-allowed"},
|
||||
{"group in list", nil, []string{"cid-a"}, "u9", "2", "cid-a", true, ""},
|
||||
{"group not in list", nil, []string{"cid-a"}, "u9", "2", "cid-b", false, "group-not-allowed"},
|
||||
{"group list does not gate DMs", nil, []string{"cid-a"}, "u9", "1", "", true, ""},
|
||||
{"both lists: group ok user not", []string{"u1"}, []string{"cid-a"}, "u9", "2", "cid-a", false, "user-not-allowed"},
|
||||
{"both lists: both ok", []string{"u1"}, []string{"cid-a"}, "u1", "2", "cid-a", true, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := newConnectGate(tc.users, tc.groups, 0)
|
||||
ok, reason := g.allow(tc.staff, tc.convType, tc.convID)
|
||||
if ok != tc.wantOK || reason != tc.wantReason {
|
||||
t.Fatalf("allow = (%v, %q), want (%v, %q)", ok, reason, tc.wantOK, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectGateRateLimit(t *testing.T) {
|
||||
g := newConnectGate(nil, nil, 2)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
g.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if ok, _ := g.allow("u1", "1", ""); !ok {
|
||||
t.Fatalf("message %d should pass", i+1)
|
||||
}
|
||||
}
|
||||
if ok, reason := g.allow("u1", "1", ""); ok || reason != "rate-limited" {
|
||||
t.Fatalf("3rd message = (%v, %q), want rate-limited", ok, reason)
|
||||
}
|
||||
// Another sender is unaffected.
|
||||
if ok, _ := g.allow("u2", "1", ""); !ok {
|
||||
t.Fatal("other sender should pass")
|
||||
}
|
||||
// The window slides: a minute later the sender passes again.
|
||||
now = now.Add(61 * time.Second)
|
||||
if ok, _ := g.allow("u1", "1", ""); !ok {
|
||||
t.Fatal("after window slides the sender should pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectGateDisabled(t *testing.T) {
|
||||
var nilGate *connectGate
|
||||
if nilGate.enabled() {
|
||||
t.Fatal("nil gate must report disabled")
|
||||
}
|
||||
if ok, _ := nilGate.allow("u", "1", ""); !ok {
|
||||
t.Fatal("nil gate must allow")
|
||||
}
|
||||
if newConnectGate(nil, nil, 0).enabled() {
|
||||
t.Fatal("empty gate must report disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCommaList(t *testing.T) {
|
||||
got := splitCommaList(" a, b ,, c ")
|
||||
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
|
||||
t.Fatalf("splitCommaList = %v", got)
|
||||
}
|
||||
if splitCommaList("") != nil {
|
||||
t.Fatal("empty input should yield nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeExecStub drops an executable shell stub named name into dir so PATH
|
||||
// lookups resolve without the real CLI installed.
|
||||
func writeExecStub(dir, name string) error {
|
||||
return os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755)
|
||||
}
|
||||
|
||||
// TestConvSessions covers the per-conversation session contract: first message
|
||||
// creates (--session-id), follow-ups resume (--resume), reset re-mints.
|
||||
func TestConvSessions(t *testing.T) {
|
||||
s := newConvSessions("")
|
||||
|
||||
first := s.args("conv-1")
|
||||
if len(first) != 2 || first[0] != "--session-id" || first[1] == "" {
|
||||
t.Fatalf("first args = %v, want [--session-id <uuid>]", first)
|
||||
}
|
||||
second := s.args("conv-1")
|
||||
if len(second) != 2 || second[0] != "--resume" || second[1] != first[1] {
|
||||
t.Fatalf("second args = %v, want [--resume %s]", second, first[1])
|
||||
}
|
||||
|
||||
// A different conversation gets its own session.
|
||||
other := s.args("conv-2")
|
||||
if other[0] != "--session-id" || other[1] == first[1] {
|
||||
t.Fatalf("conv-2 args = %v, want a fresh --session-id distinct from %s", other, first[1])
|
||||
}
|
||||
|
||||
// reset self-heals a broken session: next message starts fresh with a NEW
|
||||
// uuid (the old one may or may not exist agent-side; a new one is safe).
|
||||
s.reset("conv-1")
|
||||
again := s.args("conv-1")
|
||||
if again[0] != "--session-id" || again[1] == first[1] {
|
||||
t.Fatalf("post-reset args = %v, want fresh --session-id != %s", again, first[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyModelArg covers both shapes: replacing an existing model pin
|
||||
// (claudecode's built-in haiku) and inserting before the tail (gemini-style
|
||||
// tails that end with -p and need the prompt to stay trailing).
|
||||
func TestApplyModelArg(t *testing.T) {
|
||||
replaced := applyModelArg(
|
||||
[]string{"claude", "-p", "--model", "claude-haiku-4-5-20251001", "--strict-mcp-config"},
|
||||
"--model", "claude-sonnet-4-6")
|
||||
want := []string{"claude", "-p", "--model", "claude-sonnet-4-6", "--strict-mcp-config"}
|
||||
if strings.Join(replaced, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("replace: got %v, want %v", replaced, want)
|
||||
}
|
||||
|
||||
inserted := applyModelArg([]string{"gemini", "-p"}, "-m", "gemini-2.5-pro")
|
||||
wantIns := []string{"gemini", "-m", "gemini-2.5-pro", "-p"}
|
||||
if strings.Join(inserted, " ") != strings.Join(wantIns, " ") {
|
||||
t.Fatalf("insert: got %v, want %v", inserted, wantIns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwarderSessionAndModelWiring checks forwarderForChannel applies the
|
||||
// options: memory only on ccSessions channels, model only via the spec's flag.
|
||||
func TestForwarderSessionAndModelWiring(t *testing.T) {
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
t.Setenv("DWS_AGENT_CMD", "") // ensure no override
|
||||
// Use DWS_AGENT_CMD-free resolution; claudecode requires the binary on
|
||||
// PATH, which CI may lack — fake it via DWS_AGENT_CMD is wrong (disables
|
||||
// extras by design), so point PATH at a stub.
|
||||
stub := t.TempDir()
|
||||
for _, name := range []string{"claude", "qodercli"} {
|
||||
if err := writeExecStub(stub, name); err != nil {
|
||||
t.Fatalf("stub %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
fwd, err := forwarderForChannel("claudecode", "", connectAgentOptions{Memory: true, Model: "claude-sonnet-4-6"})
|
||||
if err != nil {
|
||||
t.Fatalf("claudecode forwarder: %v", err)
|
||||
}
|
||||
ef := fwd.(*execForwarder)
|
||||
if ef.sessions == nil {
|
||||
t.Fatal("claudecode with Memory=true should have sessions enabled")
|
||||
}
|
||||
if !strings.Contains(strings.Join(ef.argv, " "), "--model claude-sonnet-4-6") {
|
||||
t.Fatalf("model not applied: %v", ef.argv)
|
||||
}
|
||||
if strings.Contains(strings.Join(ef.argv, " "), "haiku") {
|
||||
t.Fatalf("built-in haiku pin should be replaced: %v", ef.argv)
|
||||
}
|
||||
|
||||
// qoder family runs a persistent stream-json subprocess and carries the
|
||||
// addressable Qoder session id inside each JSON user message. DWS persists
|
||||
// the mapping so a connector restart can resume the same conversation.
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
qf, err := forwarderForChannel("qoder", "qoder-client", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("qoder forwarder: %v", err)
|
||||
}
|
||||
qsf := qf.(*qoderStreamForwarder)
|
||||
if qsf.sessions == nil {
|
||||
t.Fatal("qoder with Memory=true should have sessions")
|
||||
}
|
||||
if qsf.sessions.path == "" {
|
||||
t.Fatal("qoder sessions should persist to disk")
|
||||
}
|
||||
if got := strings.Join(qsf.commandArgs(), " "); !strings.Contains(got, "--input-format stream-json") || !strings.Contains(got, "--output-format stream-json") {
|
||||
t.Fatalf("qoder stream-json args mismatch: argv = %v", qsf.commandArgs())
|
||||
}
|
||||
|
||||
qwf, err := forwarderForChannel("qoderwork", "robot-client", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("qoderwork forwarder: %v", err)
|
||||
}
|
||||
qwsf := qwf.(*qoderStreamForwarder)
|
||||
if qwsf.sessions == nil {
|
||||
t.Fatal("qoderwork with Memory=true should have sessions")
|
||||
}
|
||||
if qwsf.sessions.path == "" {
|
||||
t.Fatal("qoderwork sessions should persist to disk")
|
||||
}
|
||||
|
||||
// Memory off on a supporting channel.
|
||||
off, err := forwarderForChannel("claudecode", "", connectAgentOptions{Memory: false})
|
||||
if err != nil {
|
||||
t.Fatalf("claudecode memory-off forwarder: %v", err)
|
||||
}
|
||||
if off.(*execForwarder).sessions != nil {
|
||||
t.Fatal("Memory=false must disable sessions")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobotConnectAgentFlagsInDryRun checks the new flags surface in the
|
||||
// dry-run preview so callers can see the effective agent tuning.
|
||||
func TestRobotConnectAgentFlagsInDryRun(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"dev", "connect",
|
||||
"--channel", "claudecode",
|
||||
"--robot-client-id", "id1", "--robot-client-secret", "sec1",
|
||||
"--agent-model", "claude-sonnet-4-6", "--agent-workdir", "/tmp/kb",
|
||||
"--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\n%s", err, out.String())
|
||||
}
|
||||
for _, sub := range []string{"claude-sonnet-4-6", "/tmp/kb", "per-conversation"} {
|
||||
if !strings.Contains(out.String(), sub) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", sub, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// memory=false shows as disabled.
|
||||
out.Reset()
|
||||
root = newDevAppTestRoot(&captureRunner{})
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"dev", "connect",
|
||||
"--channel", "claudecode",
|
||||
"--robot-client-id", "id1", "--robot-client-secret", "sec1",
|
||||
"--agent-memory=false", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"memory": "disabled"`) {
|
||||
t.Fatalf("dry-run output missing disabled memory:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQoderAgentMemoryPayloadIsPerConversation(t *testing.T) {
|
||||
payload := connectAgentOptionsPayload("qoder", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-qoder-stream" {
|
||||
t.Fatalf("qoder memory = %v, want per-conversation-qoder-stream", got)
|
||||
}
|
||||
payload = connectAgentOptionsPayload("qoderwork", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-qoder-stream" {
|
||||
t.Fatalf("qoderwork memory = %v, want per-conversation-qoder-stream", got)
|
||||
}
|
||||
payload = connectAgentOptionsPayload("qoder", connectAgentOptions{Memory: false})
|
||||
if got := payload["memory"]; got != "disabled" {
|
||||
t.Fatalf("qoder memory disabled = %v, want disabled", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobotConnectDryRunShowsCliStatus checks the dependency preflight agents
|
||||
// rely on: dry-run reports whether the channel CLI is installed, with the
|
||||
// install hint when missing.
|
||||
func TestRobotConnectDryRunShowsCliStatus(t *testing.T) {
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
stub := t.TempDir()
|
||||
if err := writeExecStub(stub, "claude"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
run := func(channel string) string {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"dev", "connect", "--channel", channel,
|
||||
"--robot-client-id", "a", "--robot-client-secret", "b", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute(%s): %v\n%s", channel, err, out.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// claude on PATH → installed true with path
|
||||
if got := run("claudecode"); !strings.Contains(got, `"installed": true`) {
|
||||
t.Fatalf("claudecode should be installed:\n%s", got)
|
||||
}
|
||||
// codex NOT on PATH → installed false + hint
|
||||
got := run("codex")
|
||||
if !strings.Contains(got, `"installed": false`) || !strings.Contains(got, "@openai/codex") {
|
||||
t.Fatalf("codex should be missing with hint:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "per-conversation-app-server") {
|
||||
t.Fatalf("codex dry-run should advertise app-server memory:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
// The "digital twin" confirmation gate. When someone @-mentions the bot with an
|
||||
// *action* request ("create a todo for me"), the bot does NOT execute it: it
|
||||
// records an ApprovalRequest, sends an interactive card with [Approve]/[Reject]
|
||||
// buttons to the owner, and only runs the planned action after the owner taps
|
||||
// Approve. A Reject (or timeout) declines without executing.
|
||||
//
|
||||
// This file owns the gate engine: the request/state model, the thread-safe
|
||||
// store with crash-safe on-disk persistence (so a Pending request survives a
|
||||
// connector restart), and the Submit/Decide/Await/Get API. Card delivery and
|
||||
// the connector wiring live in connect_approval_card.go and connect_stream.go.
|
||||
|
||||
// approvalState is the request lifecycle: Pending → Approved|Rejected →
|
||||
// Executed|Failed. A decision (Approve/Reject) is the owner's call; execution
|
||||
// is what the orchestrator does afterwards on an approved request.
|
||||
type approvalState string
|
||||
|
||||
const (
|
||||
approvalPending approvalState = "pending"
|
||||
approvalApproved approvalState = "approved"
|
||||
approvalRejected approvalState = "rejected"
|
||||
approvalExecuted approvalState = "executed"
|
||||
approvalFailed approvalState = "failed"
|
||||
// approvalDeferred is an approved request whose execution could not complete
|
||||
// right now — typically because the connector's dws login is not (yet) the
|
||||
// bot's owner, so an owner-scoped action can't run. The request is NOT lost:
|
||||
// it stays on disk in this state, the owner is told to recover (log in), and
|
||||
// a later "retry" flushes it. Distinct from Failed, which is terminal.
|
||||
approvalDeferred approvalState = "deferred"
|
||||
)
|
||||
|
||||
// plannedAction is the structured command the gate will run once approved. It
|
||||
// is intentionally generic (product + tool + params) so it maps straight onto
|
||||
// executor.NewHelperInvocation without the gate knowing about any specific
|
||||
// command. For the M2 vertical slice the orchestrator only emits todo.create,
|
||||
// but the shape already supports any helper tool.
|
||||
type plannedAction struct {
|
||||
// Product/Tool/LegacyPath feed executor.NewHelperInvocation. Product is the
|
||||
// canonical product (e.g. "todo"); Tool is the RPC name (e.g.
|
||||
// "create_personal_todo"); LegacyPath is the human command path for logs.
|
||||
Product string `json:"product"`
|
||||
Tool string `json:"tool"`
|
||||
LegacyPath string `json:"legacy_path,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// ApprovalRequest is one pending/decided confirmation. It is the on-disk record
|
||||
// too (marshalled as-is), so every field is JSON-tagged and self-describing.
|
||||
type ApprovalRequest struct {
|
||||
ID string `json:"id"`
|
||||
Requester string `json:"requester"` // staffId of who asked
|
||||
ConvID string `json:"conv_id"` // conversation to reply into
|
||||
Summary string `json:"summary"` // human-readable "what will happen"
|
||||
Verb string `json:"verb,omitempty"` // action verb, e.g. "todo.create" (remember key)
|
||||
Action plannedAction `json:"action"` // structured command to run on approve
|
||||
State approvalState `json:"state"`
|
||||
// OutTrackID is the delivered card's instance id, recorded so a button
|
||||
// callback that only carries the card id (not the approval id in its action
|
||||
// params) can still be mapped back to this request.
|
||||
OutTrackID string `json:"out_track_id,omitempty"`
|
||||
DecidedBy string `json:"decided_by,omitempty"` // staffId who approved/rejected
|
||||
ExecErr string `json:"exec_err,omitempty"` // failure detail when State=failed
|
||||
// AutoApproved marks a request the owner made of THEMSELVES: no second
|
||||
// confirmation is asked (the owner asking IS the authorization), but the
|
||||
// full record is still persisted for audit, with DecidedBy=owner. Lets the
|
||||
// audit trail distinguish an auto-run from an explicitly-confirmed one.
|
||||
AutoApproved bool `json:"auto_approved,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DecidedAt time.Time `json:"decided_at,omitempty"`
|
||||
}
|
||||
|
||||
// decided reports whether the owner has made a call (approved or rejected),
|
||||
// regardless of any later execution outcome.
|
||||
func (r *ApprovalRequest) decided() bool {
|
||||
switch r.State {
|
||||
case approvalApproved, approvalRejected, approvalExecuted, approvalFailed, approvalDeferred:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// approved reports whether the owner approved (in any post-approval state,
|
||||
// including deferred — a deferred request WAS approved, its execution just
|
||||
// hasn't completed yet).
|
||||
func (r *ApprovalRequest) approved() bool {
|
||||
switch r.State {
|
||||
case approvalApproved, approvalExecuted, approvalFailed, approvalDeferred:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// approvalGate is the thread-safe confirmation-gate store. It keeps every
|
||||
// request in memory and mirrors each one to <config>/connect/<clientId>/
|
||||
// approvals/<id>.json so a Pending request survives a connector restart.
|
||||
// Persistence is best-effort and never blocks a decision: a write failure only
|
||||
// logs (mirroring connect_sessions_store's contract). An empty clientId means
|
||||
// in-memory only (used by tests).
|
||||
type approvalGate struct {
|
||||
clientID string
|
||||
|
||||
mu sync.Mutex
|
||||
reqs map[string]*ApprovalRequest
|
||||
waiters map[string]chan struct{} // id → closed when the request is decided
|
||||
}
|
||||
|
||||
// newApprovalGate builds the gate and eagerly loads any persisted requests from
|
||||
// disk (so a restart can still resolve a card tapped while the bot was down).
|
||||
func newApprovalGate(clientID string) *approvalGate {
|
||||
g := &approvalGate{
|
||||
clientID: strings.TrimSpace(clientID),
|
||||
reqs: make(map[string]*ApprovalRequest),
|
||||
waiters: make(map[string]chan struct{}),
|
||||
}
|
||||
g.loadAll()
|
||||
return g
|
||||
}
|
||||
|
||||
// approvalDir returns the on-disk directory for this gate's requests, or "" when
|
||||
// persistence is disabled (empty clientId). The clientId is sanitized exactly
|
||||
// like the session store / lock file so it is always filesystem-safe.
|
||||
func (g *approvalGate) approvalDir() string {
|
||||
if g.clientID == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(g.clientID), "approvals")
|
||||
}
|
||||
|
||||
// Submit records a new request in the Pending state, persists it, and returns
|
||||
// the stored request (with a generated ID and CreatedAt). A blank requester or
|
||||
// summary is allowed — the gate does not police content, it only sequences the
|
||||
// approval — but an empty action means "nothing to run on approve", which the
|
||||
// orchestrator must guard before calling Submit.
|
||||
func (g *approvalGate) Submit(req ApprovalRequest) *ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if strings.TrimSpace(req.ID) == "" {
|
||||
req.ID = uuid.NewString()
|
||||
}
|
||||
req.State = approvalPending
|
||||
if req.CreatedAt.IsZero() {
|
||||
req.CreatedAt = time.Now()
|
||||
}
|
||||
stored := req // copy
|
||||
g.reqs[stored.ID] = &stored
|
||||
g.persist(&stored)
|
||||
return &stored
|
||||
}
|
||||
|
||||
// setOutTrackID records the delivered card instance id for a request so a
|
||||
// callback that carries only the card id can be mapped back. No-op for unknown
|
||||
// ids; persists on success.
|
||||
func (g *approvalGate) setOutTrackID(id, outTrackID string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
r, ok := g.reqs[id]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.OutTrackID = outTrackID
|
||||
g.persist(r)
|
||||
}
|
||||
|
||||
// findByOutTrackID returns the request whose delivered card has the given
|
||||
// instance id, used as the fallback association when a callback's action params
|
||||
// did not carry the approval id. Returns nil when none match.
|
||||
func (g *approvalGate) findByOutTrackID(outTrackID string) *ApprovalRequest {
|
||||
outTrackID = strings.TrimSpace(outTrackID)
|
||||
if outTrackID == "" {
|
||||
return nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for _, r := range g.reqs {
|
||||
if r.OutTrackID == outTrackID {
|
||||
cp := *r
|
||||
return &cp
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingForConv returns a snapshot of the most-recent still-Pending request in
|
||||
// the given conversation, or nil when none is awaiting a decision there. It is
|
||||
// the lookup the text-approval mode needs to map an owner's "同意/拒绝" reply
|
||||
// (which only carries the conversation, not an approval id) back to its request.
|
||||
func (g *approvalGate) pendingForConv(convID string) *ApprovalRequest {
|
||||
convID = strings.TrimSpace(convID)
|
||||
if convID == "" {
|
||||
return nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
var best *ApprovalRequest
|
||||
for _, r := range g.reqs {
|
||||
if r.State == approvalPending && r.ConvID == convID {
|
||||
if best == nil || r.CreatedAt.After(best.CreatedAt) {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *best
|
||||
return &cp
|
||||
}
|
||||
|
||||
// latestPending returns a snapshot of the most-recent still-Pending request
|
||||
// across all conversations, or nil when none awaits a decision. Text approval
|
||||
// uses it to map the owner's "同意/拒绝" — sent from the owner's OWN 1:1 chat
|
||||
// with the bot, a different conversation than where the request originated —
|
||||
// onto the request they are deciding. One bot has one owner, so every pending
|
||||
// request belongs to that owner; newest-first matches "decide what I was just
|
||||
// asked about".
|
||||
func (g *approvalGate) latestPending() *ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
var best *ApprovalRequest
|
||||
for _, r := range g.reqs {
|
||||
if r.State == approvalPending {
|
||||
if best == nil || r.CreatedAt.After(best.CreatedAt) {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *best
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Decide records the owner's call on a Pending request and wakes any Await.
|
||||
// It is idempotent and race-safe: a second decision (e.g. a double-tap, or a
|
||||
// reject after an approve) is ignored once the request has already been
|
||||
// decided, and the function reports whether THIS call was the deciding one.
|
||||
func (g *approvalGate) Decide(id string, approve bool, by string) (*ApprovalRequest, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
r, ok := g.reqs[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if r.decided() {
|
||||
cp := *r
|
||||
return &cp, false // already decided; not the deciding call
|
||||
}
|
||||
if approve {
|
||||
r.State = approvalApproved
|
||||
} else {
|
||||
r.State = approvalRejected
|
||||
}
|
||||
r.DecidedBy = strings.TrimSpace(by)
|
||||
r.DecidedAt = time.Now()
|
||||
g.persist(r)
|
||||
g.wake(id)
|
||||
cp := *r
|
||||
return &cp, true
|
||||
}
|
||||
|
||||
// markExecuted / markFailed record the post-approval execution outcome. They do
|
||||
// not gate or wake anything (Await already returned on the decision); they exist
|
||||
// so the on-disk record reflects what actually happened, for audit and restart.
|
||||
func (g *approvalGate) markExecuted(id string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r, ok := g.reqs[id]; ok && r.approved() {
|
||||
r.State = approvalExecuted
|
||||
g.persist(r)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *approvalGate) markFailed(id string, cause string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r, ok := g.reqs[id]; ok && r.approved() {
|
||||
r.State = approvalFailed
|
||||
r.ExecErr = truncateRunes(strings.TrimSpace(cause), 500)
|
||||
g.persist(r)
|
||||
}
|
||||
}
|
||||
|
||||
// markDeferred records that an approved request could not execute now and is
|
||||
// being held for a later retry (it is NOT lost). The cause is kept so the owner
|
||||
// and the audit trail can see why. Persisted so a connector restart still
|
||||
// remembers the backlog.
|
||||
func (g *approvalGate) markDeferred(id string, cause string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r, ok := g.reqs[id]; ok && r.approved() {
|
||||
r.State = approvalDeferred
|
||||
r.ExecErr = truncateRunes(strings.TrimSpace(cause), 500)
|
||||
g.persist(r)
|
||||
}
|
||||
}
|
||||
|
||||
// allDeferred returns snapshots of every request awaiting retry, oldest first
|
||||
// (so a flush replays them in the order they were asked).
|
||||
func (g *approvalGate) allDeferred() []*ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
out := make([]*ApprovalRequest, 0)
|
||||
for _, r := range g.reqs {
|
||||
if r.State == approvalDeferred {
|
||||
cp := *r
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns a snapshot copy of a request, or nil if unknown.
|
||||
func (g *approvalGate) Get(id string) *ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if r, ok := g.reqs[id]; ok {
|
||||
cp := *r
|
||||
return &cp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Await blocks until the request is decided (approved or rejected), the context
|
||||
// is cancelled, or the timeout elapses, and returns the final snapshot. A
|
||||
// request already decided returns immediately. On timeout the request stays
|
||||
// Pending (a late tap can still resolve it) and Await returns (snapshot, false).
|
||||
// The bool reports whether a decision was observed within the wait.
|
||||
func (g *approvalGate) Await(ctx context.Context, id string, timeout time.Duration) (*ApprovalRequest, bool) {
|
||||
// Fast path + waiter registration under the lock so we never miss a Decide
|
||||
// that lands between the check and the channel subscribe.
|
||||
g.mu.Lock()
|
||||
r, ok := g.reqs[id]
|
||||
if !ok {
|
||||
g.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
if r.decided() {
|
||||
cp := *r
|
||||
g.mu.Unlock()
|
||||
return &cp, true
|
||||
}
|
||||
ch, exists := g.waiters[id]
|
||||
if !exists {
|
||||
ch = make(chan struct{})
|
||||
g.waiters[id] = ch
|
||||
}
|
||||
g.mu.Unlock()
|
||||
|
||||
var timer *time.Timer
|
||||
var timeout_c <-chan time.Time
|
||||
if timeout > 0 {
|
||||
timer = time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
timeout_c = timer.C
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
return g.Get(id), true
|
||||
case <-ctx.Done():
|
||||
return g.Get(id), false
|
||||
case <-timeout_c:
|
||||
return g.Get(id), false
|
||||
}
|
||||
}
|
||||
|
||||
// wake closes and clears the waiter channel for id (must hold g.mu). Safe to
|
||||
// call when there is no waiter.
|
||||
func (g *approvalGate) wake(id string) {
|
||||
if ch, ok := g.waiters[id]; ok {
|
||||
close(ch)
|
||||
delete(g.waiters, id)
|
||||
}
|
||||
}
|
||||
|
||||
// persist atomically writes one request to disk (must hold g.mu). Best-effort:
|
||||
// a failure only logs and never blocks the decision path, exactly like
|
||||
// saveConvSessionMap. No-op when persistence is disabled.
|
||||
func (g *approvalGate) persist(r *ApprovalRequest) {
|
||||
dir := g.approvalDir()
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批目录失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
data, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 序列化审批失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dir, sanitizeLockID(r.ID)+".json")
|
||||
tmp, err := os.CreateTemp(dir, "approval-*.json.tmp")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批临时文件失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if err := tmp.Chmod(config.FilePerm); err != nil {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 设置审批文件权限失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 写入审批临时文件失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 关闭审批临时文件失败,跳过落盘:%v\n", err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 原子替换审批存档失败,跳过落盘:%v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// loadAll reads every persisted request back into memory at startup. It is
|
||||
// forgiving like loadConvSessionMap: a missing dir (first run) is silent, and a
|
||||
// single corrupt file is skipped with a warning rather than aborting the load.
|
||||
func (g *approvalGate) loadAll() {
|
||||
dir := g.approvalDir()
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批目录失败,按空起:%v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批存档 %s 失败,跳过:%v\n", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
var r ApprovalRequest
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 审批存档 %s 已损坏,跳过:%v\n", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(r.ID) == "" {
|
||||
continue
|
||||
}
|
||||
g.reqs[r.ID] = &r
|
||||
}
|
||||
}
|
||||
|
||||
// ---- "Execution-class" request detection (simplified for the M2 slice) ----
|
||||
//
|
||||
// We do NOT build a general NLP intent classifier here (that is a later task).
|
||||
// Instead the orchestrator instructs the agent to emit a structured marker for
|
||||
// any action it would take, and we parse that marker out of the reply:
|
||||
//
|
||||
// [[ACTION:todo.create title="交方案" due="2026-06-14T18:00:00+08:00"]]
|
||||
//
|
||||
// Matched → route through the gate. No marker → ordinary Q&A, replied directly.
|
||||
// The grammar is deliberately tiny: ACTION:<verb> followed by key="value"
|
||||
// pairs. The only verb wired end-to-end in this slice is todo.create.
|
||||
|
||||
var actionMarkerRe = regexp.MustCompile(`\[\[ACTION:\s*([a-zA-Z0-9_.]+)\s*(.*?)\]\]`)
|
||||
|
||||
// actionKVRe captures key="value" (or key='value') pairs inside a marker.
|
||||
var actionKVRe = regexp.MustCompile(`([a-zA-Z0-9_]+)\s*=\s*"([^"]*)"|([a-zA-Z0-9_]+)\s*=\s*'([^']*)'`)
|
||||
|
||||
// detectedAction is one parsed [[ACTION:...]] marker: its verb (e.g.
|
||||
// "todo.create") and its key/value arguments.
|
||||
type detectedAction struct {
|
||||
Verb string
|
||||
Args map[string]string
|
||||
}
|
||||
|
||||
// parseActionMarker extracts the first [[ACTION:...]] marker from an agent
|
||||
// reply, returning (action, cleaned-reply, found). cleanedReply is the reply
|
||||
// with the marker stripped (and surrounding whitespace tidied) so it can be
|
||||
// shown to a human without the machine syntax leaking through.
|
||||
func parseActionMarker(reply string) (detectedAction, string, bool) {
|
||||
loc := actionMarkerRe.FindStringSubmatchIndex(reply)
|
||||
if loc == nil {
|
||||
return detectedAction{}, reply, false
|
||||
}
|
||||
verb := strings.TrimSpace(reply[loc[2]:loc[3]])
|
||||
argStr := reply[loc[4]:loc[5]]
|
||||
act := detectedAction{Verb: verb, Args: map[string]string{}}
|
||||
for _, m := range actionKVRe.FindAllStringSubmatch(argStr, -1) {
|
||||
if m[1] != "" {
|
||||
act.Args[m[1]] = m[2]
|
||||
} else if m[3] != "" {
|
||||
act.Args[m[3]] = m[4]
|
||||
}
|
||||
}
|
||||
cleaned := strings.TrimSpace(reply[:loc[0]] + reply[loc[1]:])
|
||||
return act, cleaned, true
|
||||
}
|
||||
|
||||
// toPlannedAction maps a detected marker onto the structured command the gate
|
||||
// will execute. It returns (action, summary, ok): ok is false for an unknown or
|
||||
// malformed verb (e.g. todo.create without a title), so the orchestrator can
|
||||
// fall back to a plain reply instead of submitting a no-op approval.
|
||||
//
|
||||
// ownerStaffID is the todo executor (the owner is who the reminder is for); the
|
||||
// slice intentionally creates the todo for the owner, matching "create a todo
|
||||
// for me" where "me" is the digital-twin owner.
|
||||
func toPlannedAction(act detectedAction, ownerStaffID string) (plannedAction, string, bool) {
|
||||
switch act.Verb {
|
||||
case "todo.create":
|
||||
title := strings.TrimSpace(act.Args["title"])
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(act.Args["subject"])
|
||||
}
|
||||
if title == "" {
|
||||
return plannedAction{}, "", false
|
||||
}
|
||||
vo := map[string]any{
|
||||
"subject": title,
|
||||
"executorIds": []string{ownerStaffID},
|
||||
}
|
||||
summary := fmt.Sprintf("创建待办:%s", title)
|
||||
if due := strings.TrimSpace(act.Args["due"]); due != "" {
|
||||
if ms, err := parseDueToMillis(due); err == nil {
|
||||
vo["dueTime"] = ms
|
||||
summary += fmt.Sprintf("(截止 %s)", due)
|
||||
}
|
||||
}
|
||||
pa := plannedAction{
|
||||
Product: "todo",
|
||||
Tool: "create_personal_todo",
|
||||
LegacyPath: "todo task create",
|
||||
Params: map[string]any{"PersonalTodoCreateVO": vo},
|
||||
}
|
||||
return pa, summary, true
|
||||
|
||||
case "calendar.create":
|
||||
title := firstNonEmpty(act.Args["title"], act.Args["summary"])
|
||||
start := strings.TrimSpace(act.Args["start"])
|
||||
end := strings.TrimSpace(act.Args["end"])
|
||||
if title == "" || start == "" || end == "" {
|
||||
return plannedAction{}, "", false
|
||||
}
|
||||
pa := plannedAction{
|
||||
Product: "calendar",
|
||||
Tool: "create_calendar_event",
|
||||
LegacyPath: "calendar event create",
|
||||
Params: map[string]any{
|
||||
"summary": title,
|
||||
"startDateTime": start,
|
||||
"endDateTime": end,
|
||||
},
|
||||
}
|
||||
return pa, fmt.Sprintf("创建日程:%s(%s ~ %s)", title, start, end), true
|
||||
|
||||
case "doc.create":
|
||||
name := firstNonEmpty(act.Args["name"], act.Args["title"])
|
||||
if name == "" {
|
||||
return plannedAction{}, "", false
|
||||
}
|
||||
pa := plannedAction{
|
||||
Product: "doc",
|
||||
Tool: "create_document",
|
||||
LegacyPath: "doc create",
|
||||
Params: map[string]any{"name": name},
|
||||
}
|
||||
return pa, fmt.Sprintf("创建文档:%s", name), true
|
||||
|
||||
default:
|
||||
return plannedAction{}, "", false
|
||||
}
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first trimmed non-empty string among the args.
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// approveWords / rejectWords are the whole-message replies the text-approval
|
||||
// mode treats as the owner's decision. The match is on the ENTIRE trimmed
|
||||
// message (case-insensitive) so a casual mention ("同意他的看法") never
|
||||
// accidentally approves an action — only a bare "同意" / "拒绝" decides.
|
||||
var (
|
||||
approveWords = map[string]struct{}{
|
||||
"同意": {}, "通过": {}, "批准": {}, "确认": {}, "同意执行": {},
|
||||
"approve": {}, "ok": {}, "yes": {}, "y": {},
|
||||
}
|
||||
rejectWords = map[string]struct{}{
|
||||
"拒绝": {}, "驳回": {}, "不行": {}, "不同意": {}, "取消": {}, "否决": {},
|
||||
"reject": {}, "no": {}, "n": {},
|
||||
}
|
||||
)
|
||||
|
||||
// retryWords are the whole-message replies that flush the deferred backlog
|
||||
// (the owner has recovered — e.g. logged dws in as themselves — and wants the
|
||||
// held requests replayed).
|
||||
var retryWords = map[string]struct{}{
|
||||
"重试": {}, "恢复": {}, "补做": {}, "重新执行": {}, "继续": {},
|
||||
"retry": {}, "resume": {},
|
||||
}
|
||||
|
||||
// isRetryWord reports whether a whole message is a bare "flush the backlog"
|
||||
// command.
|
||||
func isRetryWord(msg string) bool {
|
||||
_, ok := retryWords[strings.ToLower(strings.TrimSpace(msg))]
|
||||
return ok
|
||||
}
|
||||
|
||||
// parseDecisionWord classifies a whole message as an approve/reject decision.
|
||||
// It returns (approve, ok): ok is false when the message is not a bare decision
|
||||
// keyword, so the caller forwards it to the agent as an ordinary message.
|
||||
func parseDecisionWord(msg string) (approve bool, ok bool) {
|
||||
m := strings.ToLower(strings.TrimSpace(msg))
|
||||
if _, yes := approveWords[m]; yes {
|
||||
return true, true
|
||||
}
|
||||
if _, no := rejectWords[m]; no {
|
||||
return false, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// classifyPlannedAction maps a planned command onto its read/write class so the
|
||||
// gate can honor the "写类才拦" design: only a write (or Unknown — per the
|
||||
// CmdClass safety contract) action needs the owner's sign-off; a read-class
|
||||
// action is safe to let through without gating.
|
||||
//
|
||||
// It classifies on the human command path (LegacyPath, e.g. "todo task create")
|
||||
// first, since that is exactly the space-joined segment shape ClassifyDwsCommand
|
||||
// expects. When LegacyPath is absent or yields Unknown, it falls back to the
|
||||
// product + RPC tool name (e.g. "todo" + "create_personal_todo"), whose leading
|
||||
// verb token ("create") the classifier can still recognise.
|
||||
func classifyPlannedAction(pa plannedAction) CmdClass {
|
||||
if path := strings.TrimSpace(pa.LegacyPath); path != "" {
|
||||
if c := ClassifyDwsCommand(strings.Fields(path)...); c != CmdClassUnknown {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ClassifyDwsCommand(pa.Product, pa.Tool)
|
||||
}
|
||||
|
||||
// parseDueToMillis converts an ISO-8601 due string to epoch millis, accepting
|
||||
// the common RFC3339 shapes the agent is asked to emit. Kept local to the gate
|
||||
// so detection has no dependency on the cobra todo command's flag plumbing.
|
||||
func parseDueToMillis(due string) (int64, error) {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z07:00", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, due); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("unrecognized due time %q", due)
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
// This file wires the confirmation gate to DingTalk:
|
||||
// - approvalCardSender: the dependency-injected boundary that delivers the
|
||||
// [Approve]/[Reject] card to the owner and updates it with the outcome.
|
||||
// Tests inject a fake; production uses dingtalkApprovalCardSender (HTTP).
|
||||
// - approvalOrchestrator: the connector-side glue. It owns the gate + sender +
|
||||
// runner, decides whether an agent reply is an action request, drives the
|
||||
// Submit → card → Await → execute → reply loop, and routes button callbacks
|
||||
// into gate.Decide. connect_stream.go calls into it; it never imports stream
|
||||
// internals back, so it stays unit-testable without a live connection.
|
||||
|
||||
// Button action constants. The card's two buttons each carry a fixed action
|
||||
// id; the approval id and the decision travel as private action params so a
|
||||
// callback can be associated and resolved without any server-side lookup table.
|
||||
const (
|
||||
approvalActionApprove = "dws_approval_approve"
|
||||
approvalActionReject = "dws_approval_reject"
|
||||
approvalParamID = "dwsApprovalId" // approval request id
|
||||
approvalParamDecision = "dwsDecision" // "approve" | "reject"
|
||||
approvalDecisionApprove = "approve"
|
||||
approvalDecisionReject = "reject"
|
||||
)
|
||||
|
||||
// approvalCardSender delivers and updates the owner-facing confirmation card.
|
||||
// It is an interface so the orchestrator and its tests never touch the network:
|
||||
// the real sender talks to the DingTalk card API, the fake records calls.
|
||||
type approvalCardSender interface {
|
||||
// SendApprovalCard delivers an interactive card with [Approve]/[Reject]
|
||||
// buttons (each carrying req.ID + its decision in the action params) to the
|
||||
// owner's 1:1 chat with the bot, returning the delivered card instance id
|
||||
// (outTrackId) for later result updates and callback association.
|
||||
SendApprovalCard(ctx context.Context, ownerUserID string, req *ApprovalRequest) (string, error)
|
||||
// UpdateApprovalCard replaces the card body with the final outcome text
|
||||
// (best-effort; an error must not fail the surrounding flow).
|
||||
UpdateApprovalCard(ctx context.Context, outTrackID, text string) error
|
||||
}
|
||||
|
||||
// approvalRunner is the subset of executor.Runner the orchestrator needs. Kept
|
||||
// as a named alias so the dependency is explicit and easy to fake.
|
||||
type approvalRunner interface {
|
||||
Run(context.Context, executor.Invocation) (executor.Result, error)
|
||||
}
|
||||
|
||||
// approvalReplier abstracts "send a plain text line back into the conversation"
|
||||
// (the group reply after approve/reject). connect_stream.go supplies a closure
|
||||
// over the chatbot replier + sessionWebhook; tests supply a recorder.
|
||||
type approvalReplier func(ctx context.Context, convID, text string) error
|
||||
|
||||
// ownerNotifier sends a proactive 1:1 message to specific users (the owner, the
|
||||
// requester), independent of any inbound sessionWebhook. Text approval needs it
|
||||
// because the approval conversation (owner's 1:1 with the bot) is not the
|
||||
// conversation the request arrived on, and because the decision can land minutes
|
||||
// later when the original webhook is stale. *aiCardClient implements it.
|
||||
type ownerNotifier interface {
|
||||
sendOTOText(ctx context.Context, userIDs []string, text string) error
|
||||
}
|
||||
|
||||
// auditSink records a terminal-state request to a durable, reviewable place
|
||||
// (e.g. a DingTalk online sheet) so every action the twin takes is auditable
|
||||
// beyond the local approvals JSON. Best-effort: a sink failure must never block
|
||||
// or fail the action.
|
||||
type auditSink interface {
|
||||
record(ctx context.Context, req *ApprovalRequest)
|
||||
}
|
||||
|
||||
// sheetAuditSink appends one row per action to a DingTalk online sheet (axls)
|
||||
// via the sheet `append_rows` tool, run under the connector's (bot) identity.
|
||||
// Columns: 时间 | 摘要 | 请求人 | 状态 | 批准人 | 是否自动 | 失败原因 | 单据ID.
|
||||
type sheetAuditSink struct {
|
||||
runner approvalRunner
|
||||
nodeID string // axls doc id / URL
|
||||
sheetID string // worksheet id or name (e.g. "Sheet1")
|
||||
}
|
||||
|
||||
func (s *sheetAuditSink) record(ctx context.Context, req *ApprovalRequest) {
|
||||
if s == nil || s.runner == nil || req == nil {
|
||||
return
|
||||
}
|
||||
auto := ""
|
||||
if req.AutoApproved {
|
||||
auto = "自动"
|
||||
}
|
||||
decidedAt := ""
|
||||
if !req.DecidedAt.IsZero() {
|
||||
decidedAt = req.DecidedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
row := []any{
|
||||
req.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
req.Summary,
|
||||
req.Requester,
|
||||
string(req.State),
|
||||
req.DecidedBy,
|
||||
auto,
|
||||
req.ExecErr,
|
||||
req.ID,
|
||||
decidedAt,
|
||||
}
|
||||
inv := executor.NewHelperInvocation("sheet append", "sheet", "append_rows", map[string]any{
|
||||
"nodeId": s.nodeID,
|
||||
"sheetId": s.sheetID,
|
||||
"values": [][]any{row},
|
||||
})
|
||||
// The online-sheet API throttles (e.g. THREADPOOL_BUSY) under bursts, dropping
|
||||
// an audit row on a transient error. Retry a few times with backoff so the
|
||||
// trail stays complete; still best-effort — give up (log) after the last try.
|
||||
var err error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
if _, err = s.runner.Run(ctx, inv); err == nil {
|
||||
return
|
||||
}
|
||||
if attempt < 3 && isTransientSheetErr(err) {
|
||||
time.Sleep(time.Duration(attempt) * 600 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][audit] 写审计表格失败(已重试)approvalId=%s: %v\n", req.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// isTransientSheetErr reports whether a sheet-append error is a transient
|
||||
// throttle/timeout worth retrying (vs. a permanent error like a bad node id).
|
||||
func isTransientSheetErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToUpper(err.Error())
|
||||
for _, sig := range []string{"BUSY", "THREADPOOL", "TIMEOUT", "RATE", "LIMIT", "TOO MANY", "503", "429"} {
|
||||
if strings.Contains(s, sig) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// approvalOrchestrator is the connector-side controller for the gate. It is
|
||||
// constructed per connector with the owner's userId, the gate, the card sender
|
||||
// and the runner. Zero owner / nil sender disables the gate (the connector then
|
||||
// behaves exactly as before — plain Q&A).
|
||||
type approvalOrchestrator struct {
|
||||
gate *approvalGate
|
||||
sender approvalCardSender
|
||||
runner approvalRunner
|
||||
ownerUserID string
|
||||
awaitWindow time.Duration
|
||||
// textMode swaps the interactive [Approve]/[Reject] card for a private
|
||||
// text confirmation: the bot DMs the OWNER "X 请求执行……" and the owner
|
||||
// replies "同意"/"拒绝" in their own 1:1 chat with the bot. The requester
|
||||
// never sees the approval — they only get the final result. Used when no
|
||||
// approval-card template is configured, so the gate works with zero
|
||||
// card-platform setup. The decision is captured asynchronously (see
|
||||
// handleOwnerDecision), not by blocking on Await.
|
||||
textMode bool
|
||||
// notifier delivers the proactive owner/requester 1:1 messages text mode
|
||||
// needs (nil in card mode).
|
||||
notifier ownerNotifier
|
||||
// audit, when set, records every terminal-state request to a durable sink
|
||||
// (e.g. a DingTalk online sheet) on top of the local approvals JSON. nil =
|
||||
// local-file audit only.
|
||||
audit auditSink
|
||||
// allowedScopes is the role's capability allowlist (product names). When
|
||||
// non-empty, an action whose product is not listed is refused before it ever
|
||||
// reaches the gate, keeping the role in its lane. Empty = no restriction.
|
||||
allowedScopes []string
|
||||
// confirmPolicy governs how OTHERS' requests are confirmed: "manual" (ask
|
||||
// every time, the default), "auto" (run without asking, still audited),
|
||||
// "remember" (ask once per action verb then reuse). The owner's own requests
|
||||
// always auto-run regardless.
|
||||
confirmPolicy string
|
||||
// remembered caches the owner's decision per action verb for the "remember"
|
||||
// policy (verb → approved). Guarded by rememberMu.
|
||||
rememberMu sync.Mutex
|
||||
remembered map[string]bool
|
||||
}
|
||||
|
||||
// gateDecision decides how to handle a write action: "auto" (run now), "ask"
|
||||
// (route to the owner), or "reject" (a remembered rejection). The owner's own
|
||||
// requests always auto-run. For others it follows confirmPolicy.
|
||||
func (o *approvalOrchestrator) gateDecision(requester, verb string) string {
|
||||
if strings.TrimSpace(requester) != "" && strings.TrimSpace(requester) == o.ownerUserID {
|
||||
return "auto"
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(o.confirmPolicy)) {
|
||||
case "auto":
|
||||
return "auto"
|
||||
case "remember":
|
||||
o.rememberMu.Lock()
|
||||
decided, ok := o.remembered[verb]
|
||||
o.rememberMu.Unlock()
|
||||
if !ok {
|
||||
return "ask"
|
||||
}
|
||||
if decided {
|
||||
return "auto"
|
||||
}
|
||||
return "reject"
|
||||
default: // manual / empty
|
||||
return "ask"
|
||||
}
|
||||
}
|
||||
|
||||
// rememberDecision records the owner's decision for an action verb so the
|
||||
// "remember" policy can reuse it. No-op unless the policy is "remember".
|
||||
func (o *approvalOrchestrator) rememberDecision(verb string, approved bool) {
|
||||
if strings.ToLower(strings.TrimSpace(o.confirmPolicy)) != "remember" || strings.TrimSpace(verb) == "" {
|
||||
return
|
||||
}
|
||||
o.rememberMu.Lock()
|
||||
if o.remembered == nil {
|
||||
o.remembered = make(map[string]bool)
|
||||
}
|
||||
o.remembered[verb] = approved
|
||||
o.rememberMu.Unlock()
|
||||
}
|
||||
|
||||
// scopeAllows reports whether the role may use the given product. An empty
|
||||
// allowlist means no restriction (allow all).
|
||||
func (o *approvalOrchestrator) scopeAllows(product string) bool {
|
||||
if len(o.allowedScopes) == 0 {
|
||||
return true
|
||||
}
|
||||
product = strings.ToLower(strings.TrimSpace(product))
|
||||
for _, s := range o.allowedScopes {
|
||||
if strings.ToLower(strings.TrimSpace(s)) == product {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recordAudit writes the request's current (terminal) state to the audit sink,
|
||||
// fetching the fresh snapshot so state/exec_err reflect the final outcome.
|
||||
// No-op when no sink is configured; best-effort.
|
||||
func (o *approvalOrchestrator) recordAudit(ctx context.Context, id string) {
|
||||
if o == nil || o.audit == nil {
|
||||
return
|
||||
}
|
||||
if r := o.gate.Get(id); r != nil {
|
||||
o.audit.record(ctx, r)
|
||||
}
|
||||
}
|
||||
|
||||
// newApprovalOrchestrator builds the card-mode controller. awaitWindow caps how
|
||||
// long the connector blocks for the owner's decision before giving up (the
|
||||
// request stays Pending on disk, so a late tap still records and executes
|
||||
// nothing twice).
|
||||
func newApprovalOrchestrator(gate *approvalGate, sender approvalCardSender, runner approvalRunner, ownerUserID string) *approvalOrchestrator {
|
||||
return &approvalOrchestrator{
|
||||
gate: gate,
|
||||
sender: sender,
|
||||
runner: runner,
|
||||
ownerUserID: strings.TrimSpace(ownerUserID),
|
||||
awaitWindow: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// newTextApprovalOrchestrator builds the text-mode controller (no card sender).
|
||||
// notifier delivers the private owner/requester messages. The owner confirms by
|
||||
// replying "同意"/"拒绝" in their 1:1 chat with the bot; the decision is captured
|
||||
// by handleOwnerDecision on the next inbound message, so handleReply must NOT
|
||||
// block waiting for it.
|
||||
func newTextApprovalOrchestrator(gate *approvalGate, runner approvalRunner, ownerUserID string, notifier ownerNotifier) *approvalOrchestrator {
|
||||
o := newApprovalOrchestrator(gate, nil, runner, ownerUserID)
|
||||
o.textMode = true
|
||||
o.notifier = notifier
|
||||
return o
|
||||
}
|
||||
|
||||
// enabled reports whether the gate is active. Card mode needs an owner + a card
|
||||
// sender; text mode needs an owner + a notifier to reach them privately.
|
||||
func (o *approvalOrchestrator) enabled() bool {
|
||||
if o == nil || o.ownerUserID == "" || o.gate == nil {
|
||||
return false
|
||||
}
|
||||
if o.textMode {
|
||||
return o.notifier != nil
|
||||
}
|
||||
return o.sender != nil
|
||||
}
|
||||
|
||||
// agentSystemHint is appended to the forwarded prompt so the agent emits the
|
||||
// structured action marker (see parseActionMarker) instead of executing
|
||||
// anything itself. It is the simplified "intent detection" for the slice.
|
||||
const agentSystemHint = "\n\n[系统提示] 如果用户的请求是要执行一个动作(而不是单纯提问)," +
|
||||
"不要假装已经完成,而是在回复末尾用下面其中一个标记声明这个动作,由主人确认后再执行:\n" +
|
||||
`· 建待办:[[ACTION:todo.create title="待办标题" due="2026-06-14T18:00:00+08:00"]](due 可省略)` + "\n" +
|
||||
`· 建日程:[[ACTION:calendar.create title="日程标题" start="2026-06-20T10:00:00+08:00" end="2026-06-20T11:00:00+08:00"]]` + "\n" +
|
||||
`· 建文档:[[ACTION:doc.create name="文档名"]]` + "\n" +
|
||||
"时间一律用 ISO-8601(带时区)。如果只是普通提问,正常回答即可,不要输出任何标记。"
|
||||
|
||||
// decorateForActionDetection appends the action-marker instruction to a prompt
|
||||
// when the gate is enabled. A no-op when disabled, so plain Q&A is untouched.
|
||||
func (o *approvalOrchestrator) decorateForActionDetection(prompt string) string {
|
||||
if !o.enabled() {
|
||||
return prompt
|
||||
}
|
||||
return prompt + agentSystemHint
|
||||
}
|
||||
|
||||
// handleReply is the orchestration hook the connector calls with the agent's
|
||||
// raw reply. It returns (finalReply, handled): when handled is true the gate
|
||||
// took over (it has already sent the card / will reply asynchronously via
|
||||
// reply) and the connector must NOT send finalReply itself. When false the
|
||||
// connector replies normally with finalReply (the marker, if any, is stripped).
|
||||
//
|
||||
// requester is the staffId who asked; convID is where the group answer goes;
|
||||
// reply is the function used to post the approve/reject outcome back.
|
||||
func (o *approvalOrchestrator) handleReply(ctx context.Context, requester, convID, agentReply string, reply approvalReplier) (string, bool) {
|
||||
if !o.enabled() {
|
||||
return agentReply, false
|
||||
}
|
||||
act, cleaned, found := parseActionMarker(agentReply)
|
||||
if !found {
|
||||
return agentReply, false // plain Q&A
|
||||
}
|
||||
pa, summary, ok := toPlannedAction(act, o.ownerUserID)
|
||||
if !ok {
|
||||
// Unknown/blank action: degrade to a plain reply (marker stripped) so a
|
||||
// malformed marker never silently swallows the answer.
|
||||
return cleaned, false
|
||||
}
|
||||
|
||||
// Role scope: an action outside the role's capability allowlist is refused
|
||||
// up front (never reaches the gate or the owner). The requester is told it is
|
||||
// out of lane — this is a capability boundary, not an approval, so it is fine
|
||||
// to surface to them.
|
||||
if !o.scopeAllows(pa.Product) {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 越权拦截:角色无 %q 能力,拒绝动作 %s\n", pa.Product, summary)
|
||||
return fmt.Sprintf("(该数字员工没有「%s」能力,无法执行此操作:%s)", pa.Product, summary), false
|
||||
}
|
||||
|
||||
// 写类才拦:route the gate-or-not decision through the read/write classifier
|
||||
// instead of gating every detected marker. A definitively read-class action
|
||||
// is safe and bypasses the gate (the marker is stripped and the reply goes
|
||||
// out normally). Write — and Unknown, per the CmdClass safety contract — keep
|
||||
// the owner's sign-off requirement.
|
||||
if classifyPlannedAction(pa) == CmdClassRead {
|
||||
return cleaned, false
|
||||
}
|
||||
|
||||
// Confirmation strategy: owner-self always auto-runs; others follow
|
||||
// confirmPolicy (manual=ask / auto=run / remember=reuse last decision).
|
||||
decision := o.gateDecision(requester, act.Verb)
|
||||
req := o.gate.Submit(ApprovalRequest{
|
||||
Requester: requester,
|
||||
ConvID: convID,
|
||||
Summary: summary,
|
||||
Verb: act.Verb,
|
||||
Action: pa,
|
||||
AutoApproved: decision == "auto",
|
||||
})
|
||||
|
||||
switch decision {
|
||||
case "auto":
|
||||
// Asking IS the authorization (owner-self), or the policy pre-authorized
|
||||
// it (auto / remembered-approve). Execute directly; still fully audited.
|
||||
return o.autoApproveAndExecute(ctx, req, convID, reply)
|
||||
case "reject":
|
||||
// Remembered rejection for this action kind — decline without bothering
|
||||
// the owner, and tell the requester.
|
||||
o.gate.Decide(req.ID, false, o.ownerUserID)
|
||||
o.recordAudit(ctx, req.ID)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 记忆策略:%q 此前被拒,自动拒绝 approvalId=%s\n", act.Verb, req.ID)
|
||||
return fmt.Sprintf("(主人此前已拒绝这类操作,本次未执行:%s)", summary), false
|
||||
default: // "ask"
|
||||
// Text mode: privately DM the owner and return immediately — the owner's
|
||||
// "同意/拒绝" reply is captured asynchronously by handleOwnerDecision
|
||||
// (blocking here would deadlock the per-conversation worker that must also
|
||||
// process that reply). The requester (this conversation) sees nothing.
|
||||
if o.textMode {
|
||||
return o.handleReplyText(ctx, req)
|
||||
}
|
||||
return o.handleReplyCard(ctx, req, convID, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// autoApproveAndExecute runs an owner's own request without a second
|
||||
// confirmation, while still recording the full lifecycle (Decide by the owner →
|
||||
// execute) so the on-disk approvals log audits it like any other action. It
|
||||
// replies into the conversation the owner asked in (the inbound webhook is still
|
||||
// fresh — the owner just messaged). Used for both card and text mode.
|
||||
func (o *approvalOrchestrator) autoApproveAndExecute(ctx context.Context, req *ApprovalRequest, convID string, reply approvalReplier) (string, bool) {
|
||||
decided, _ := o.gate.Decide(req.ID, true, o.ownerUserID)
|
||||
if decided == nil {
|
||||
decided = req
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval][audit] 主人自助操作,自动执行 approvalId=%s owner=%s: %s\n",
|
||||
decided.ID, o.ownerUserID, decided.Summary)
|
||||
out, execErr := o.execute(ctx, decided)
|
||||
if execErr != nil {
|
||||
// Do not lose it: hold for retry and tell the owner to recover. Most
|
||||
// likely the connector's dws login is not the bot owner, so an
|
||||
// owner-scoped action can't run yet.
|
||||
o.gate.markDeferred(decided.ID, execErr.Error())
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
o.notifyOwnerDeferred(ctx, decided, execErr)
|
||||
if reply != nil {
|
||||
_ = reply(ctx, convID, "收到,但现在没能直接完成(已记下,稍后会补做)。")
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
o.gate.markExecuted(decided.ID)
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
if reply != nil {
|
||||
_ = reply(ctx, convID, "已为你完成:"+decided.Summary+"\n"+out)
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// notifyOwnerDeferred privately tells the owner that an action could not run now
|
||||
// and is being held, with the likely cause and how to recover (log dws in as the
|
||||
// bot owner, then reply "重试"). Best-effort.
|
||||
func (o *approvalOrchestrator) notifyOwnerDeferred(ctx context.Context, req *ApprovalRequest, execErr error) {
|
||||
if o.notifier == nil {
|
||||
return
|
||||
}
|
||||
who := strings.TrimSpace(req.Requester)
|
||||
if who == "" || who == o.ownerUserID {
|
||||
who = "你"
|
||||
}
|
||||
msg := fmt.Sprintf("⚠️ %s 请求执行:%s\n但我现在没能以你的身份完成(很可能这台连接器的 dws 没有登录成你本人的账号)。\n"+
|
||||
"已先记下,不会丢。把 dws 登录成机器人主人账号后会自动补做;想立即补做可回复「重试」。\n(错误:%s)",
|
||||
who, req.Summary, truncateRunes(execErr.Error(), 120))
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, msg)
|
||||
}
|
||||
|
||||
// handleReplyText privately DMs the OWNER the approval request and hands control
|
||||
// back to the connector (handled=true). The requester sees nothing — the
|
||||
// approval happens only between the owner and the bot. It never blocks: the
|
||||
// owner's decision arrives later as an inbound message routed through
|
||||
// handleOwnerDecision.
|
||||
func (o *approvalOrchestrator) handleReplyText(ctx context.Context, req *ApprovalRequest) (string, bool) {
|
||||
who := strings.TrimSpace(req.Requester)
|
||||
if who == "" {
|
||||
who = "有人"
|
||||
}
|
||||
prompt := fmt.Sprintf("🔔 %s 请求执行一个操作,需要你确认:\n%s\n回复「同意」执行,或「拒绝」取消。", who, req.Summary)
|
||||
if err := o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, prompt); err != nil {
|
||||
// Cannot reach the owner: the action does not run. Do NOT leak the
|
||||
// approval to the requester — text mode's whole point is privacy — so
|
||||
// just log and swallow (handled), leaving the request Pending on disk.
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 私聊主人 %s 失败,本次未执行: %v\n", o.ownerUserID, err)
|
||||
return "", true
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 已私聊主人 %s 待确认 approvalId=%s requester=%s: %s\n",
|
||||
o.ownerUserID, req.ID, req.Requester, req.Summary)
|
||||
return "", true
|
||||
}
|
||||
|
||||
// handleOwnerDecision intercepts an inbound message that is the OWNER replying
|
||||
// "同意/拒绝" (in their own 1:1 chat with the bot) to the pending request. It
|
||||
// decides, executes-or-declines, privately acks the owner, and sends the
|
||||
// outcome to the original requester. Returns true when it consumed the message
|
||||
// (so the connector skips forwarding it to the agent). A non-owner sender, a
|
||||
// non-decision message, or no pending request all return false (ordinary
|
||||
// message). Only active in text mode.
|
||||
func (o *approvalOrchestrator) handleOwnerDecision(ctx context.Context, senderStaffID, text string) bool {
|
||||
if o == nil || !o.textMode || !o.enabled() {
|
||||
return false
|
||||
}
|
||||
if senderStaffID == "" || senderStaffID != o.ownerUserID {
|
||||
return false
|
||||
}
|
||||
// "重试/恢复": the owner has recovered — flush the deferred backlog.
|
||||
if isRetryWord(text) {
|
||||
return o.flushDeferred(ctx)
|
||||
}
|
||||
approve, ok := parseDecisionWord(text)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
req := o.gate.latestPending()
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
decided, deciding := o.gate.Decide(req.ID, approve, senderStaffID)
|
||||
if !deciding {
|
||||
// Already decided (e.g. a double reply): consume the keyword, do not
|
||||
// re-execute.
|
||||
return decided != nil
|
||||
}
|
||||
// "remember" policy: cache this decision for the action kind so the next
|
||||
// same-verb request reuses it without asking again.
|
||||
o.rememberDecision(decided.Verb, approve)
|
||||
if !decided.approved() {
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, "好的,已拒绝,未执行。")
|
||||
o.notifyRequester(ctx, decided, "你的请求未获主人批准,本次未执行。")
|
||||
return true
|
||||
}
|
||||
out, execErr := o.execute(ctx, decided)
|
||||
if execErr != nil {
|
||||
// Approved but could not run now → hold for retry, tell the owner how to
|
||||
// recover. The requester is NOT told it failed (their task is not lost).
|
||||
o.gate.markDeferred(decided.ID, execErr.Error())
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
o.notifyOwnerDeferred(ctx, decided, execErr)
|
||||
return true
|
||||
}
|
||||
o.gate.markExecuted(decided.ID)
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, "已同意并执行完成:"+decided.Summary)
|
||||
o.notifyRequester(ctx, decided, "已为你完成:"+decided.Summary+"\n"+out)
|
||||
return true
|
||||
}
|
||||
|
||||
// flushDeferred replays the deferred backlog after the owner recovers. Each
|
||||
// request is re-executed; on success it is marked executed and the original
|
||||
// requester gets the outcome, on failure it stays deferred (still not lost).
|
||||
// The owner gets a summary. Returns true (the "重试" message is always consumed).
|
||||
func (o *approvalOrchestrator) flushDeferred(ctx context.Context) bool {
|
||||
pending := o.gate.allDeferred()
|
||||
if len(pending) == 0 {
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, "当前没有积压的请求。")
|
||||
return true
|
||||
}
|
||||
done, stuck := o.flushDeferredOnce(ctx, pending)
|
||||
// Manual "重试" always reports back (the owner asked), naming what was done.
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "已补做 %d 个积压请求", len(done))
|
||||
if len(done) > 0 {
|
||||
b.WriteString(":\n")
|
||||
for i, s := range done {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, s)
|
||||
}
|
||||
} else {
|
||||
b.WriteString("。")
|
||||
}
|
||||
if stuck > 0 {
|
||||
fmt.Fprintf(&b, "仍有 %d 个未成功(身份可能还没对上,确认 dws 登录后再回复「重试」)。", stuck)
|
||||
}
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, strings.TrimRight(b.String(), "\n"))
|
||||
return true
|
||||
}
|
||||
|
||||
// flushDeferredOnce re-executes each given deferred request once. Successful
|
||||
// ones are marked executed and their requester is notified; failed ones stay
|
||||
// deferred. Returns the completed summaries and the still-stuck count. Shared by
|
||||
// the manual "重试" path and the background auto-retry.
|
||||
func (o *approvalOrchestrator) flushDeferredOnce(ctx context.Context, pending []*ApprovalRequest) (done []string, stuck int) {
|
||||
for _, req := range pending {
|
||||
out, execErr := o.execute(ctx, req)
|
||||
if execErr != nil {
|
||||
o.gate.markDeferred(req.ID, execErr.Error())
|
||||
o.recordAudit(ctx, req.ID)
|
||||
stuck++
|
||||
continue
|
||||
}
|
||||
o.gate.markExecuted(req.ID)
|
||||
o.recordAudit(ctx, req.ID)
|
||||
o.notifyRequester(ctx, req, "(已恢复)已为你完成:"+req.Summary+"\n"+out)
|
||||
done = append(done, req.Summary)
|
||||
}
|
||||
return done, stuck
|
||||
}
|
||||
|
||||
// autoFlushDeferred is the background-retry pass: it replays the backlog and
|
||||
// messages the owner ONLY when something actually completed (so a periodic tick
|
||||
// while the identity is still wrong stays silent — no spam). The requester is
|
||||
// still notified per completed item inside flushDeferredOnce.
|
||||
func (o *approvalOrchestrator) autoFlushDeferred(ctx context.Context) {
|
||||
pending := o.gate.allDeferred()
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
done, _ := o.flushDeferredOnce(ctx, pending)
|
||||
if len(done) == 0 {
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "(已自动恢复)补做了 %d 个积压请求:\n", len(done))
|
||||
for i, s := range done {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, s)
|
||||
}
|
||||
_ = o.notifier.sendOTOText(ctx, []string{o.ownerUserID}, strings.TrimRight(b.String(), "\n"))
|
||||
}
|
||||
|
||||
// startAutoRetry runs autoFlushDeferred on an interval until ctx is cancelled,
|
||||
// so a deferred backlog drains by itself once the owner's identity comes back —
|
||||
// no manual "重试" needed. A no-op when there is no notifier (card mode) or a
|
||||
// non-positive interval.
|
||||
func (o *approvalOrchestrator) startAutoRetry(ctx context.Context, interval time.Duration) {
|
||||
if o == nil || o.notifier == nil || interval <= 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
o.autoFlushDeferred(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// notifyRequester sends the outcome to the original requester, unless they are
|
||||
// the owner themselves (who already got the owner-side ack — no need to double
|
||||
// message). Best-effort.
|
||||
func (o *approvalOrchestrator) notifyRequester(ctx context.Context, req *ApprovalRequest, text string) {
|
||||
who := strings.TrimSpace(req.Requester)
|
||||
if who == "" || who == o.ownerUserID {
|
||||
return
|
||||
}
|
||||
_ = o.notifier.sendOTOText(ctx, []string{who}, text)
|
||||
}
|
||||
|
||||
// handleReplyCard runs the interactive-card flow: deliver the [Approve]/[Reject]
|
||||
// card to the owner, block for the decision, then execute-or-decline and report
|
||||
// the outcome into the conversation.
|
||||
func (o *approvalOrchestrator) handleReplyCard(ctx context.Context, req *ApprovalRequest, convID string, reply approvalReplier) (string, bool) {
|
||||
outTrackID, err := o.sender.SendApprovalCard(ctx, o.ownerUserID, req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 确认卡片投放失败: %v\n", err)
|
||||
// Cannot ask the owner → tell the requester the gate could not engage,
|
||||
// rather than silently executing or silently dropping.
|
||||
return "(需要主人确认,但确认卡片发送失败,本次未执行)", false
|
||||
}
|
||||
o.gate.setOutTrackID(req.ID, outTrackID)
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 已向主人 %s 发送确认卡片 approvalId=%s outTrackId=%s: %s\n",
|
||||
o.ownerUserID, req.ID, outTrackID, req.Summary)
|
||||
|
||||
// Block for the owner's decision off the connector's per-conversation
|
||||
// worker is fine: the worker already serializes one conversation, and the
|
||||
// gate persists so a restart mid-wait loses only the in-memory block.
|
||||
decided, gotDecision := o.gate.Await(ctx, req.ID, o.awaitWindow)
|
||||
if !gotDecision || decided == nil {
|
||||
_ = reply(ctx, convID, "(等待主人确认超时,本次未执行)")
|
||||
return "", true
|
||||
}
|
||||
if !decided.approved() {
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.sender.UpdateApprovalCard(ctx, decided.OutTrackID, "已拒绝:未执行。")
|
||||
_ = reply(ctx, convID, "主人暂时没批准。")
|
||||
return "", true
|
||||
}
|
||||
|
||||
// Approved → the orchestrator runs the planned command directly (no agent
|
||||
// round-trip), then reports the outcome to the group and the card.
|
||||
out, execErr := o.execute(ctx, decided)
|
||||
if execErr != nil {
|
||||
o.gate.markFailed(decided.ID, execErr.Error())
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.sender.UpdateApprovalCard(ctx, decided.OutTrackID, "已同意,但执行失败:"+execErr.Error())
|
||||
_ = reply(ctx, convID, "主人已同意,但执行失败:"+execErr.Error())
|
||||
return "", true
|
||||
}
|
||||
o.gate.markExecuted(decided.ID)
|
||||
o.recordAudit(ctx, decided.ID)
|
||||
_ = o.sender.UpdateApprovalCard(ctx, decided.OutTrackID, "已同意并执行完成。")
|
||||
_ = reply(ctx, convID, "主人已同意,已执行:"+decided.Summary+"\n"+out)
|
||||
return "", true
|
||||
}
|
||||
|
||||
// execute runs the request's planned action via the runner and returns a short
|
||||
// human-readable result line. It is the only place the gate touches the
|
||||
// executor, so adding a new approvable verb is a matter of toPlannedAction +
|
||||
// the runner already supporting that tool.
|
||||
func (o *approvalOrchestrator) execute(ctx context.Context, req *ApprovalRequest) (string, error) {
|
||||
if o.runner == nil {
|
||||
return "", fmt.Errorf("no runner configured")
|
||||
}
|
||||
inv := executor.NewHelperInvocation(req.Action.LegacyPath, req.Action.Product, req.Action.Tool, req.Action.Params)
|
||||
res, err := o.runner.Run(ctx, inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !res.Invocation.Implemented {
|
||||
// The helper override was not wired at runtime (e.g. dry-run / discovery
|
||||
// gap): surface it rather than claim success.
|
||||
return "(动作已受理,但运行时未实际执行——请检查命令是否在当前环境可用)", nil
|
||||
}
|
||||
return "(待办已创建)", nil
|
||||
}
|
||||
|
||||
// handleCardCallback maps a button tap to gate.Decide. It pulls the approval id
|
||||
// and decision from the action params (primary association), falling back to the
|
||||
// card instance id (OutTrackId) when the params are absent. Returns a card
|
||||
// response that flips the buttons to a decided state. Safe to call repeatedly:
|
||||
// a second tap after a decision is a no-op (Decide is idempotent).
|
||||
func (o *approvalOrchestrator) handleCardCallback(ctx context.Context, req *card.CardRequest) (*card.CardResponse, error) {
|
||||
if o == nil || o.gate == nil || req == nil {
|
||||
return &card.CardResponse{}, nil
|
||||
}
|
||||
id, approve, ok := decodeCardAction(req)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 卡片回调无法解析动作 outTrackId=%s\n", req.OutTrackId)
|
||||
return &card.CardResponse{}, nil
|
||||
}
|
||||
if id == "" {
|
||||
// No approval id in the params: associate by the card instance id.
|
||||
if found := o.gate.findByOutTrackID(req.OutTrackId); found != nil {
|
||||
id = found.ID
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 卡片回调无法关联审批 outTrackId=%s\n", req.OutTrackId)
|
||||
return &card.CardResponse{}, nil
|
||||
}
|
||||
decided, deciding := o.gate.Decide(id, approve, strings.TrimSpace(req.UserId))
|
||||
verb := "同意"
|
||||
if !approve {
|
||||
verb = "拒绝"
|
||||
}
|
||||
if deciding {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 主人 %s 点了[%s] approvalId=%s\n", req.UserId, verb, id)
|
||||
} else if decided != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][approval] 审批 %s 已是终态(%s),忽略重复点击\n", id, decided.State)
|
||||
}
|
||||
// Echo the decision back into the card's private data so the renderer can
|
||||
// show a decided state. Best-effort; the gate state is the source of truth.
|
||||
return &card.CardResponse{
|
||||
UserPrivateData: &card.CardDataDto{
|
||||
CardParamMap: map[string]string{"dwsDecision": verb},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeCardAction extracts (approvalId, approve, ok) from a card callback. It
|
||||
// prefers the explicit decision param, then the tapped action id, so the card
|
||||
// can carry the decision either way. ok is false when neither identifies a
|
||||
// decision (a non-approval card, or a malformed payload).
|
||||
func decodeCardAction(req *card.CardRequest) (id string, approve bool, ok bool) {
|
||||
id = strings.TrimSpace(req.GetActionString(approvalParamID))
|
||||
decision := strings.TrimSpace(req.GetActionString(approvalParamDecision))
|
||||
switch decision {
|
||||
case approvalDecisionApprove:
|
||||
return id, true, true
|
||||
case approvalDecisionReject:
|
||||
return id, false, true
|
||||
}
|
||||
// Fall back to which button was pressed (its action id).
|
||||
for _, a := range req.CardActionData.CardPrivateData.ActionIdList {
|
||||
switch strings.TrimSpace(a) {
|
||||
case approvalActionApprove:
|
||||
return id, true, true
|
||||
case approvalActionReject:
|
||||
return id, false, true
|
||||
}
|
||||
}
|
||||
return id, false, false
|
||||
}
|
||||
|
||||
// ---- Real (HTTP) card sender ----
|
||||
|
||||
// dingtalkApprovalCardSender delivers the interactive approval card via the
|
||||
// DingTalk card API, reusing aiCardClient for auth + the create/deliver/update
|
||||
// HTTP plumbing. The card template must define two buttons whose action params
|
||||
// the connector fills in (approval id + decision); see the docstring on
|
||||
// SendApprovalCard for the contract.
|
||||
type dingtalkApprovalCardSender struct {
|
||||
cli *aiCardClient
|
||||
templateID string
|
||||
}
|
||||
|
||||
// newDingtalkApprovalCardSender builds the real sender. An empty templateID
|
||||
// returns nil: without an interactive-card template the connector cannot render
|
||||
// buttons, so the caller must keep the gate disabled rather than deliver a card
|
||||
// the owner cannot act on.
|
||||
func newDingtalkApprovalCardSender(clientID, clientSecret, templateID string) approvalCardSender {
|
||||
templateID = strings.TrimSpace(templateID)
|
||||
if templateID == "" {
|
||||
return nil
|
||||
}
|
||||
return &dingtalkApprovalCardSender{
|
||||
cli: newAICardClient(clientID, clientSecret, templateID),
|
||||
templateID: templateID,
|
||||
}
|
||||
}
|
||||
|
||||
// SendApprovalCard creates an interactive card instance and delivers it to the
|
||||
// owner's 1:1 chat with the bot. The approval id and per-button decision are
|
||||
// passed in cardData.cardParamMap so the template's [Approve]/[Reject] buttons
|
||||
// echo them back as action params on tap (see decodeCardAction). Best-effort
|
||||
// result-check via callChecked surfaces a per-target deliver failure that the
|
||||
// card API hides inside an HTTP 200.
|
||||
func (s *dingtalkApprovalCardSender) SendApprovalCard(ctx context.Context, ownerUserID string, req *ApprovalRequest) (string, error) {
|
||||
outTrackID := "dws_approval_" + req.ID
|
||||
create := map[string]any{
|
||||
"cardTemplateId": s.templateID,
|
||||
"outTrackId": outTrackID,
|
||||
"cardData": map[string]any{
|
||||
"cardParamMap": map[string]any{
|
||||
"title": "需要你确认",
|
||||
"summary": req.Summary,
|
||||
"requester": req.Requester,
|
||||
approvalParamID: req.ID,
|
||||
"approveDecision": approvalDecisionApprove,
|
||||
"rejectDecision": approvalDecisionReject,
|
||||
"approveActionId": approvalActionApprove,
|
||||
"rejectActionId": approvalActionReject,
|
||||
"config": `{"autoLayout":true}`,
|
||||
},
|
||||
},
|
||||
"callbackType": "STREAM",
|
||||
"imRobotOpenSpaceModel": map[string]any{"supportForward": true},
|
||||
}
|
||||
if err := s.cli.call(ctx, http.MethodPost, "/v1.0/card/instances", create); err != nil {
|
||||
return "", err
|
||||
}
|
||||
deliver := map[string]any{
|
||||
"outTrackId": outTrackID,
|
||||
"userIdType": 1,
|
||||
"openSpaceId": "dtv1.card//IM_ROBOT." + ownerUserID,
|
||||
"imRobotOpenDeliverModel": map[string]any{
|
||||
"spaceType": "IM_ROBOT",
|
||||
"robotCode": s.cli.clientID,
|
||||
},
|
||||
}
|
||||
if err := s.cli.callChecked(ctx, http.MethodPost, "/v1.0/card/instances/deliver", deliver); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outTrackID, nil
|
||||
}
|
||||
|
||||
// UpdateApprovalCard rewrites the card's result line after a decision. It is
|
||||
// best-effort: a stuck card is bad UX but must never fail the gate flow, so
|
||||
// callers ignore the error.
|
||||
func (s *dingtalkApprovalCardSender) UpdateApprovalCard(ctx context.Context, outTrackID, text string) error {
|
||||
return s.cli.call(ctx, http.MethodPut, "/v1.0/card/instances", map[string]any{
|
||||
"outTrackId": outTrackID,
|
||||
"cardData": map[string]any{"cardParamMap": map[string]any{"result": text, "summary": text}},
|
||||
"cardUpdateOptions": map[string]any{"updateCardDataByKey": true},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
)
|
||||
|
||||
// ---- fakes (no network, no real runner) ----
|
||||
|
||||
type fakeCardSender struct {
|
||||
mu sync.Mutex
|
||||
sent []*ApprovalRequest
|
||||
updates []string
|
||||
failSend bool
|
||||
lastTrack string
|
||||
}
|
||||
|
||||
func (f *fakeCardSender) SendApprovalCard(_ context.Context, _ string, req *ApprovalRequest) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failSend {
|
||||
return "", context.DeadlineExceeded
|
||||
}
|
||||
f.sent = append(f.sent, req)
|
||||
f.lastTrack = "track-" + req.ID
|
||||
return f.lastTrack, nil
|
||||
}
|
||||
|
||||
func (f *fakeCardSender) UpdateApprovalCard(_ context.Context, _, text string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.updates = append(f.updates, text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeRunner records the invocations it is asked to run and reports them as
|
||||
// implemented (so the orchestrator treats them as executed).
|
||||
type fakeRunner struct {
|
||||
mu sync.Mutex
|
||||
runs []executor.Invocation
|
||||
fail bool // when true, Run returns an error (simulates identity not ready)
|
||||
}
|
||||
|
||||
func (r *fakeRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.runs = append(r.runs, inv)
|
||||
if r.fail {
|
||||
return executor.Result{}, context.DeadlineExceeded
|
||||
}
|
||||
inv.Implemented = true
|
||||
return executor.Result{Invocation: inv}, nil
|
||||
}
|
||||
|
||||
func (r *fakeRunner) setFail(v bool) { r.mu.Lock(); defer r.mu.Unlock(); r.fail = v }
|
||||
|
||||
func (r *fakeRunner) count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.runs) }
|
||||
|
||||
// fakeNotifier records the proactive 1:1 messages text-mode approval sends to
|
||||
// the owner and the requester.
|
||||
type fakeNotifier struct {
|
||||
mu sync.Mutex
|
||||
sent []sentMsg
|
||||
}
|
||||
|
||||
type sentMsg struct {
|
||||
to []string
|
||||
text string
|
||||
}
|
||||
|
||||
func (n *fakeNotifier) sendOTOText(_ context.Context, userIDs []string, text string) error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.sent = append(n.sent, sentMsg{to: append([]string(nil), userIDs...), text: text})
|
||||
return nil
|
||||
}
|
||||
|
||||
// toUser reports whether any recorded message was sent to userID.
|
||||
func (n *fakeNotifier) toUser(userID string) bool {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
for _, m := range n.sent {
|
||||
for _, u := range m.to {
|
||||
if u == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (n *fakeNotifier) count() int { n.mu.Lock(); defer n.mu.Unlock(); return len(n.sent) }
|
||||
|
||||
// cardClickReq builds a card callback as DingTalk delivers it: the tapped
|
||||
// button's action id plus its private params (approval id + decision).
|
||||
func cardClickReq(outTrackID, approvalID, decision, actionID, userID string) *card.CardRequest {
|
||||
return &card.CardRequest{
|
||||
OutTrackId: outTrackID,
|
||||
UserId: userID,
|
||||
CardActionData: card.PrivateCardActionData{
|
||||
CardPrivateData: card.CardPrivateData{
|
||||
ActionIdList: []string{actionID},
|
||||
Params: map[string]any{
|
||||
approvalParamID: approvalID,
|
||||
approvalParamDecision: decision,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeCardAction verifies the action parser pulls the approval id and the
|
||||
// approve/reject decision from a button tap, via params and via the action id
|
||||
// fallback.
|
||||
func TestDecodeCardAction(t *testing.T) {
|
||||
approve := cardClickReq("t1", "appr-1", approvalDecisionApprove, approvalActionApprove, "owner")
|
||||
id, ok2, ok := decodeCardAction(approve)
|
||||
if !ok || !ok2 || id != "appr-1" {
|
||||
t.Fatalf("approve decode: id=%q approve=%v ok=%v", id, ok2, ok)
|
||||
}
|
||||
|
||||
reject := cardClickReq("t2", "appr-2", approvalDecisionReject, approvalActionReject, "owner")
|
||||
id, ok2, ok = decodeCardAction(reject)
|
||||
if !ok || ok2 || id != "appr-2" {
|
||||
t.Fatalf("reject decode: id=%q approve=%v ok=%v", id, ok2, ok)
|
||||
}
|
||||
|
||||
// Decision param missing → fall back to the tapped action id.
|
||||
fallback := &card.CardRequest{
|
||||
OutTrackId: "t3",
|
||||
CardActionData: card.PrivateCardActionData{
|
||||
CardPrivateData: card.CardPrivateData{
|
||||
ActionIdList: []string{approvalActionApprove},
|
||||
Params: map[string]any{approvalParamID: "appr-3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
id, ok2, ok = decodeCardAction(fallback)
|
||||
if !ok || !ok2 || id != "appr-3" {
|
||||
t.Fatalf("fallback decode: id=%q approve=%v ok=%v", id, ok2, ok)
|
||||
}
|
||||
|
||||
// A non-approval card → not ok.
|
||||
if _, _, ok := decodeCardAction(&card.CardRequest{OutTrackId: "x"}); ok {
|
||||
t.Fatal("non-approval card should not decode as a decision")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCardCallback_DrivesDecide verifies a button callback flips the gate
|
||||
// state and that an approval id missing from params is recovered by outTrackId.
|
||||
func TestHandleCardCallback_DrivesDecide(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("c1")
|
||||
o := newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{}, "owner")
|
||||
|
||||
app := gate.Submit(ApprovalRequest{Summary: "x"})
|
||||
gate.setOutTrackID(app.ID, "track-x")
|
||||
|
||||
// Callback carries no approval id in params → associate by outTrackId.
|
||||
req := &card.CardRequest{
|
||||
OutTrackId: "track-x",
|
||||
UserId: "owner",
|
||||
CardActionData: card.PrivateCardActionData{
|
||||
CardPrivateData: card.CardPrivateData{ActionIdList: []string{approvalActionApprove}},
|
||||
},
|
||||
}
|
||||
if _, err := o.handleCardCallback(context.Background(), req); err != nil {
|
||||
t.Fatalf("callback err: %v", err)
|
||||
}
|
||||
if got := gate.Get(app.ID); !got.approved() {
|
||||
t.Fatalf("state = %s, want approved after approve tap", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_RejectDoesNotExecute is the reject-path guarantee: a rejected
|
||||
// approval must NOT run the planned command.
|
||||
func TestHandleReply_RejectDoesNotExecute(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("c2")
|
||||
sender := &fakeCardSender{}
|
||||
runner := &fakeRunner{}
|
||||
o := newApprovalOrchestrator(gate, sender, runner, "owner")
|
||||
o.awaitWindow = time.Second
|
||||
|
||||
var replies []string
|
||||
var rmu sync.Mutex
|
||||
reply := func(_ context.Context, _, text string) error {
|
||||
rmu.Lock()
|
||||
replies = append(replies, text)
|
||||
rmu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// The owner rejects shortly after the card is sent.
|
||||
go func() {
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
sender.mu.Lock()
|
||||
n := len(sender.sent)
|
||||
var id string
|
||||
if n > 0 {
|
||||
id = sender.sent[0].ID
|
||||
}
|
||||
sender.mu.Unlock()
|
||||
if id != "" {
|
||||
gate.Decide(id, false, "owner")
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
agentReply := `好的。[[ACTION:todo.create title="交方案"]]`
|
||||
out, handled := o.handleReply(context.Background(), "requester", "conv-1", agentReply, reply)
|
||||
if !handled {
|
||||
t.Fatal("an action reply must be handled by the gate")
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("handled reply should return empty connector reply, got %q", out)
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("reject path must NOT execute, runner ran %d times", runner.count())
|
||||
}
|
||||
if gate.Get(gate.list()[0].ID).State != approvalRejected {
|
||||
t.Fatal("request should be rejected")
|
||||
}
|
||||
rmu.Lock()
|
||||
joined := strings.Join(replies, " | ")
|
||||
rmu.Unlock()
|
||||
if !strings.Contains(joined, "没批准") {
|
||||
t.Fatalf("reject reply not posted: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToPlannedAction_Actions covers the action verbs the twin can execute:
|
||||
// each maps to the right product/tool, and missing required args degrade to
|
||||
// "not an action" (ok=false) so a malformed marker never submits a no-op.
|
||||
func TestToPlannedAction_Actions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
act detectedAction
|
||||
wantOK bool
|
||||
wantProduct string
|
||||
wantTool string
|
||||
}{
|
||||
{"todo", detectedAction{Verb: "todo.create", Args: map[string]string{"title": "交方案"}}, true, "todo", "create_personal_todo"},
|
||||
{"calendar", detectedAction{Verb: "calendar.create", Args: map[string]string{"title": "评审会", "start": "2026-06-20T10:00:00+08:00", "end": "2026-06-20T11:00:00+08:00"}}, true, "calendar", "create_calendar_event"},
|
||||
{"calendar missing time", detectedAction{Verb: "calendar.create", Args: map[string]string{"title": "评审会"}}, false, "", ""},
|
||||
{"doc", detectedAction{Verb: "doc.create", Args: map[string]string{"name": "周报"}}, true, "doc", "create_document"},
|
||||
{"doc missing name", detectedAction{Verb: "doc.create", Args: map[string]string{}}, false, "", ""},
|
||||
{"unknown verb", detectedAction{Verb: "drive.delete", Args: map[string]string{}}, false, "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pa, summary, ok := toPlannedAction(tc.act, "owner")
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok=%v want %v", ok, tc.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if pa.Product != tc.wantProduct || pa.Tool != tc.wantTool {
|
||||
t.Fatalf("got %s/%s want %s/%s", pa.Product, pa.Tool, tc.wantProduct, tc.wantTool)
|
||||
}
|
||||
if summary == "" {
|
||||
t.Fatal("summary should not be empty for a valid action")
|
||||
}
|
||||
// Every twin action is write-class → must be gated.
|
||||
if classifyPlannedAction(pa) != CmdClassWrite {
|
||||
t.Fatalf("%s should classify as write (gated), got %s", tc.name, classifyPlannedAction(pa))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyPlannedAction verifies the gate's read/write bridge: it classifies
|
||||
// on the human command path first and falls back to product + RPC tool name.
|
||||
func TestClassifyPlannedAction(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pa plannedAction
|
||||
want CmdClass
|
||||
}{
|
||||
{
|
||||
name: "write via legacy path",
|
||||
pa: plannedAction{Product: "todo", Tool: "create_personal_todo", LegacyPath: "todo task create"},
|
||||
want: CmdClassWrite,
|
||||
},
|
||||
{
|
||||
name: "read via legacy path",
|
||||
pa: plannedAction{Product: "todo", Tool: "list_personal_todo", LegacyPath: "todo task list"},
|
||||
want: CmdClassRead,
|
||||
},
|
||||
{
|
||||
name: "empty legacy path falls back to product+tool",
|
||||
pa: plannedAction{Product: "todo", Tool: "create_personal_todo"},
|
||||
want: CmdClassWrite,
|
||||
},
|
||||
{
|
||||
name: "unrecognised path falls back to tool verb",
|
||||
pa: plannedAction{Product: "todo", Tool: "delete_personal_todo", LegacyPath: "todo xyz123"},
|
||||
want: CmdClassWrite,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := classifyPlannedAction(tc.pa); got != tc.want {
|
||||
t.Fatalf("classifyPlannedAction(%+v) = %s, want %s", tc.pa, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_ReadClassBypassesGate proves the "写类才拦" wiring: when the
|
||||
// planned command classifies as read-only, the gate does not engage — no card is
|
||||
// sent, nothing runs, and the cleaned reply goes out normally (handled=false).
|
||||
// We force the todo.create path to read-class via an override so the test drives
|
||||
// the real handleReply → classifyPlannedAction path end to end.
|
||||
func TestHandleReply_ReadClassBypassesGate(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
SetCmdClassOverride("todo task create", CmdClassRead)
|
||||
defer SetCmdClassOverride("todo task create", CmdClassUnknown)
|
||||
|
||||
gate := newApprovalGate("c3")
|
||||
sender := &fakeCardSender{}
|
||||
runner := &fakeRunner{}
|
||||
o := newApprovalOrchestrator(gate, sender, runner, "owner")
|
||||
|
||||
reply := func(_ context.Context, _, _ string) error { return nil }
|
||||
agentReply := `好的,已为你查到。[[ACTION:todo.create title="交方案"]]`
|
||||
out, handled := o.handleReply(context.Background(), "requester", "conv-1", agentReply, reply)
|
||||
|
||||
if handled {
|
||||
t.Fatal("a read-class action must NOT be handled by the gate")
|
||||
}
|
||||
if !strings.Contains(out, "已为你查到") || strings.Contains(out, "[[ACTION") {
|
||||
t.Fatalf("read-class reply should be the cleaned text without the marker, got %q", out)
|
||||
}
|
||||
sender.mu.Lock()
|
||||
nSent := len(sender.sent)
|
||||
sender.mu.Unlock()
|
||||
if nSent != 0 {
|
||||
t.Fatalf("read-class action must not send a confirmation card, sent %d", nSent)
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("read-class action must not execute, runner ran %d times", runner.count())
|
||||
}
|
||||
if len(gate.list()) != 0 {
|
||||
t.Fatalf("read-class action must not create an approval request, got %d", len(gate.list()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDecisionWord checks whole-message decision parsing: a bare keyword
|
||||
// decides, anything else (including a keyword embedded in a sentence) does not.
|
||||
func TestParseDecisionWord(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantApprove bool
|
||||
wantOk bool
|
||||
}{
|
||||
{"同意", true, true},
|
||||
{" 同意 ", true, true},
|
||||
{"通过", true, true},
|
||||
{"Yes", true, true},
|
||||
{"OK", true, true},
|
||||
{"拒绝", false, true},
|
||||
{"no", false, true},
|
||||
{"不同意", false, true},
|
||||
{"同意他的看法", false, false}, // embedded, not a bare decision
|
||||
{"帮我创建个待办", false, false},
|
||||
{"", false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
gotApprove, gotOk := parseDecisionWord(tc.in)
|
||||
if gotOk != tc.wantOk || (tc.wantOk && gotApprove != tc.wantApprove) {
|
||||
t.Fatalf("parseDecisionWord(%q) = (%v,%v), want (%v,%v)", tc.in, gotApprove, gotOk, tc.wantApprove, tc.wantOk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPendingForConv finds the pending request for a conversation and stops
|
||||
// returning it once decided.
|
||||
func TestPendingForConv(t *testing.T) {
|
||||
gate := newApprovalGate("") // in-memory
|
||||
a := gate.Submit(ApprovalRequest{ConvID: "c1", Summary: "a"})
|
||||
gate.Submit(ApprovalRequest{ConvID: "c2", Summary: "b"})
|
||||
|
||||
if got := gate.pendingForConv("c1"); got == nil || got.ID != a.ID {
|
||||
t.Fatalf("pendingForConv(c1) = %v, want request %s", got, a.ID)
|
||||
}
|
||||
if got := gate.pendingForConv("none"); got != nil {
|
||||
t.Fatalf("pendingForConv(none) = %v, want nil", got)
|
||||
}
|
||||
gate.Decide(a.ID, true, "owner")
|
||||
if got := gate.pendingForConv("c1"); got != nil {
|
||||
t.Fatalf("decided request must not be pending, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_TextMode_OwnerApproves is the private text-approval happy
|
||||
// path: an action privately DMs the OWNER (the requester sees nothing, no
|
||||
// execution yet); a non-owner "同意" is ignored; the owner's "同意" — sent from
|
||||
// their own chat, NOT the request conversation — decides and executes.
|
||||
func TestHandleReply_TextMode_OwnerApproves(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ct1")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
// A group requester (not the owner) asks for an action.
|
||||
out, handled := o.handleReply(context.Background(), "requester", "conv-group", `好的。[[ACTION:todo.create title="交方案"]]`, nil)
|
||||
if !handled || out != "" {
|
||||
t.Fatalf("text-mode action: handled=%v out=%q, want handled with empty out (requester sees nothing)", handled, out)
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("must not execute before the owner decides, ran %d", runner.count())
|
||||
}
|
||||
if len(gate.list()) != 1 {
|
||||
t.Fatalf("expected one pending request, got %d", len(gate.list()))
|
||||
}
|
||||
// The approval prompt went privately to the OWNER, never to the requester.
|
||||
if !notifier.toUser("owner") {
|
||||
t.Fatalf("approval prompt was not DMed to the owner: %+v", notifier.sent)
|
||||
}
|
||||
if notifier.toUser("requester") {
|
||||
t.Fatal("the requester must NOT be notified of the approval")
|
||||
}
|
||||
|
||||
// A non-owner saying 同意 must not decide or execute.
|
||||
if o.handleOwnerDecision(context.Background(), "intruder", "同意") {
|
||||
t.Fatal("non-owner must not be able to approve")
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("non-owner approval must not execute, ran %d", runner.count())
|
||||
}
|
||||
|
||||
// The owner saying 同意 decides and executes; the requester then gets the
|
||||
// outcome.
|
||||
if !o.handleOwnerDecision(context.Background(), "owner", "同意") {
|
||||
t.Fatal("owner approval must be consumed")
|
||||
}
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("owner approval must execute exactly once, ran %d", runner.count())
|
||||
}
|
||||
if gate.list()[0].State != approvalExecuted {
|
||||
t.Fatalf("request state = %s, want executed", gate.list()[0].State)
|
||||
}
|
||||
if !notifier.toUser("requester") {
|
||||
t.Fatal("the requester should get the final outcome after execution")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_TextMode_OwnerSelfAutoApproves: when the requester IS the
|
||||
// owner, the action runs immediately with NO confirmation round-trip, but is
|
||||
// still recorded (auto_approved=true, decided-by owner, executed) for audit.
|
||||
func TestHandleReply_TextMode_OwnerSelfAutoApproves(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctself")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
var replies []string
|
||||
reply := func(_ context.Context, _, text string) error { replies = append(replies, text); return nil }
|
||||
|
||||
// The OWNER asks for the action themselves.
|
||||
out, handled := o.handleReply(context.Background(), "owner", "conv-owner", `好。[[ACTION:todo.create title="自己的待办"]]`, reply)
|
||||
if !handled || out != "" {
|
||||
t.Fatalf("owner self-request: handled=%v out=%q", handled, out)
|
||||
}
|
||||
// Executed immediately — no second confirmation asked.
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("owner self-request must execute immediately, ran %d", runner.count())
|
||||
}
|
||||
if notifier.count() != 0 {
|
||||
t.Fatalf("owner self-request must NOT send an approval DM, sent %d", notifier.count())
|
||||
}
|
||||
// Still recorded for audit: auto_approved + executed + decided by owner.
|
||||
rec := gate.list()[0]
|
||||
if !rec.AutoApproved || rec.State != approvalExecuted || rec.DecidedBy != "owner" {
|
||||
t.Fatalf("audit record wrong: autoApproved=%v state=%s decidedBy=%s", rec.AutoApproved, rec.State, rec.DecidedBy)
|
||||
}
|
||||
if !strings.Contains(strings.Join(replies, "|"), "已为你完成") {
|
||||
t.Fatalf("owner should get the result, replies=%v", replies)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAudit records the requests handed to the audit sink.
|
||||
type fakeAudit struct {
|
||||
mu sync.Mutex
|
||||
recs []*ApprovalRequest
|
||||
}
|
||||
|
||||
func (a *fakeAudit) record(_ context.Context, req *ApprovalRequest) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
cp := *req
|
||||
a.recs = append(a.recs, &cp)
|
||||
}
|
||||
|
||||
func (a *fakeAudit) count() int { a.mu.Lock(); defer a.mu.Unlock(); return len(a.recs) }
|
||||
|
||||
// TestAudit_RecordsTerminalOutcomes verifies the audit sink gets one terminal
|
||||
// record per action: an auto-executed owner request, and an approved request.
|
||||
func TestAudit_RecordsTerminalOutcomes(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("cta")
|
||||
audit := &fakeAudit{}
|
||||
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{})
|
||||
o.audit = audit
|
||||
reply := func(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
// Owner self-request → auto-executed → audited once, as executed.
|
||||
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="自己"]]`, reply)
|
||||
if audit.count() != 1 {
|
||||
t.Fatalf("auto-exec must produce one audit record, got %d", audit.count())
|
||||
}
|
||||
if audit.recs[0].State != approvalExecuted || !audit.recs[0].AutoApproved {
|
||||
t.Fatalf("auto audit record: state=%s auto=%v", audit.recs[0].State, audit.recs[0].AutoApproved)
|
||||
}
|
||||
|
||||
// Someone else's request, approved by the owner → audited as executed.
|
||||
o.handleReply(context.Background(), "requester", "conv-g", `[[ACTION:todo.create title="别人"]]`, reply)
|
||||
if audit.count() != 1 {
|
||||
t.Fatal("a pending (not yet decided) request must not be audited yet")
|
||||
}
|
||||
o.handleOwnerDecision(context.Background(), "owner", "同意")
|
||||
if audit.count() != 2 {
|
||||
t.Fatalf("owner approval must produce a second audit record, got %d", audit.count())
|
||||
}
|
||||
if audit.recs[1].State != approvalExecuted || audit.recs[1].DecidedBy != "owner" {
|
||||
t.Fatalf("approved audit record: state=%s decidedBy=%s", audit.recs[1].State, audit.recs[1].DecidedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeferredExecution_HoldsAndRetries is the graceful-degradation path: when
|
||||
// execution fails (identity not ready), the request is DEFERRED (not lost, not
|
||||
// failed), the owner is privately notified; after the owner recovers and replies
|
||||
// "重试", the backlog is replayed, executes, and the requester gets the result.
|
||||
func TestDeferredExecution_HoldsAndRetries(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctdef")
|
||||
runner := &fakeRunner{fail: true} // identity not ready → execution fails
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
// A non-owner request, approved by the owner, but execution fails → deferred.
|
||||
o.handleReply(context.Background(), "requester", "conv-g", `[[ACTION:todo.create title="待恢复"]]`, nil)
|
||||
if !o.handleOwnerDecision(context.Background(), "owner", "同意") {
|
||||
t.Fatal("owner approval must be consumed")
|
||||
}
|
||||
rec := gate.list()[0]
|
||||
if rec.State != approvalDeferred {
|
||||
t.Fatalf("failed execution must DEFER (not fail), got state=%s", rec.State)
|
||||
}
|
||||
// Owner was told to recover; requester was NOT told it failed (task not lost).
|
||||
if !notifier.toUser("owner") {
|
||||
t.Fatal("owner should be notified of the deferred action")
|
||||
}
|
||||
if notifier.toUser("requester") {
|
||||
t.Fatal("requester must NOT be told it failed — their task is held, not lost")
|
||||
}
|
||||
|
||||
// A bare non-retry word from the owner should pass through (not consumed).
|
||||
if o.handleOwnerDecision(context.Background(), "owner", "在吗") {
|
||||
t.Fatal("ordinary owner message must not be consumed")
|
||||
}
|
||||
|
||||
// Owner recovers (identity now works) and replies "重试" → backlog flushes.
|
||||
runner.setFail(false)
|
||||
if !o.handleOwnerDecision(context.Background(), "owner", "重试") {
|
||||
t.Fatal("retry word must be consumed")
|
||||
}
|
||||
if gate.list()[0].State != approvalExecuted {
|
||||
t.Fatalf("after retry the deferred request must execute, got %s", gate.list()[0].State)
|
||||
}
|
||||
if !notifier.toUser("requester") {
|
||||
t.Fatal("requester should get the outcome after the backlog is flushed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmPolicy_Auto: a non-owner request runs without asking under "auto".
|
||||
func TestConfirmPolicy_Auto(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctauto2")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
o.confirmPolicy = "auto"
|
||||
|
||||
_, handled := o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="x"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
if !handled {
|
||||
t.Fatal("auto policy should handle (run) the action")
|
||||
}
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("auto policy must execute without asking, ran %d", runner.count())
|
||||
}
|
||||
if gate.list()[0].State != approvalExecuted {
|
||||
t.Fatalf("state=%s want executed", gate.list()[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmPolicy_Manual: a non-owner request asks the owner (default).
|
||||
func TestConfirmPolicy_Manual(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctman")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier) // no policy = manual
|
||||
|
||||
o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="x"]]`, nil)
|
||||
if runner.count() != 0 {
|
||||
t.Fatal("manual policy must NOT execute before the owner decides")
|
||||
}
|
||||
if gate.list()[0].State != approvalPending {
|
||||
t.Fatalf("state=%s want pending (awaiting owner)", gate.list()[0].State)
|
||||
}
|
||||
if !notifier.toUser("owner") {
|
||||
t.Fatal("manual policy should DM the owner for approval")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmPolicy_Remember: ask once per verb, then reuse the decision.
|
||||
func TestConfirmPolicy_Remember(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctrem")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
o.confirmPolicy = "remember"
|
||||
|
||||
// First todo.create from a non-owner → asks (pending).
|
||||
o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="第一次"]]`, nil)
|
||||
if gate.latestPending() == nil {
|
||||
t.Fatal("first request should ask the owner")
|
||||
}
|
||||
// Owner approves → remembered, executes.
|
||||
o.handleOwnerDecision(context.Background(), "owner", "同意")
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("approved request should execute, ran %d", runner.count())
|
||||
}
|
||||
|
||||
// Second todo.create → reused decision, auto-runs (no new pending).
|
||||
_, handled := o.handleReply(context.Background(), "someone", "c2", `[[ACTION:todo.create title="第二次"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
if !handled || runner.count() != 2 {
|
||||
t.Fatalf("remembered approve should auto-run the 2nd request, handled=%v ran=%d", handled, runner.count())
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopeEnforcement: an action outside the role's allowlist is refused up
|
||||
// front (the requester is told, nothing reaches the gate); an in-scope action
|
||||
// proceeds; an empty allowlist allows everything.
|
||||
func TestScopeEnforcement(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctscope")
|
||||
runner := &fakeRunner{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", &fakeNotifier{})
|
||||
o.allowedScopes = []string{"todo"} // HR-style: only todo
|
||||
|
||||
// doc.create is out of scope → refused, NOT gated, requester told.
|
||||
out, handled := o.handleReply(context.Background(), "requester", "c1", `[[ACTION:doc.create name="周报"]]`, nil)
|
||||
if handled {
|
||||
t.Fatal("out-of-scope action must not be handled by the gate")
|
||||
}
|
||||
if !strings.Contains(out, "没有") || !strings.Contains(out, "doc") {
|
||||
t.Fatalf("requester should be told it's out of scope, got %q", out)
|
||||
}
|
||||
if len(gate.list()) != 0 {
|
||||
t.Fatal("out-of-scope action must not create an approval request")
|
||||
}
|
||||
|
||||
// todo.create is in scope → proceeds (owner self-request auto-executes).
|
||||
o.handleReply(context.Background(), "owner", "c2", `[[ACTION:todo.create title="排期"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
if len(gate.list()) != 1 {
|
||||
t.Fatalf("in-scope action should be processed, got %d requests", len(gate.list()))
|
||||
}
|
||||
|
||||
// scopeAllows: empty allowlist = allow all.
|
||||
o.allowedScopes = nil
|
||||
if !o.scopeAllows("anything") {
|
||||
t.Fatal("empty allowlist must allow all products")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoFlushDeferred verifies the background retry: silent while the identity
|
||||
// is still wrong (no spam), and it drains + notifies once execution works.
|
||||
func TestAutoFlushDeferred(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctauto")
|
||||
runner := &fakeRunner{fail: true}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
// Defer one (identity not ready).
|
||||
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="自动恢复"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
deferNotices := notifier.count() // the deferred DM
|
||||
if gate.list()[0].State != approvalDeferred {
|
||||
t.Fatalf("want deferred, got %s", gate.list()[0].State)
|
||||
}
|
||||
|
||||
// Auto-retry while still failing → stays deferred, NO new owner message.
|
||||
o.autoFlushDeferred(context.Background())
|
||||
if gate.list()[0].State != approvalDeferred {
|
||||
t.Fatal("should still be deferred while identity is wrong")
|
||||
}
|
||||
if notifier.count() != deferNotices {
|
||||
t.Fatalf("auto-retry must stay silent when nothing completes (sent %d, was %d)", notifier.count(), deferNotices)
|
||||
}
|
||||
|
||||
// Identity recovers → auto-retry drains it and notifies once.
|
||||
runner.setFail(false)
|
||||
o.autoFlushDeferred(context.Background())
|
||||
if gate.list()[0].State != approvalExecuted {
|
||||
t.Fatalf("auto-retry should execute once identity is back, got %s", gate.list()[0].State)
|
||||
}
|
||||
notifier.mu.Lock()
|
||||
last := notifier.sent[len(notifier.sent)-1].text
|
||||
notifier.mu.Unlock()
|
||||
if !strings.Contains(last, "已自动恢复") || !strings.Contains(last, "自动恢复") {
|
||||
t.Fatalf("owner should get an auto-recovery notice, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTransientSheetErr distinguishes retryable throttles from permanent errors.
|
||||
func TestIsTransientSheetErr(t *testing.T) {
|
||||
for _, e := range []string{"system error: THREADPOOL_BUSY", "server busy", "request timeout", "HTTP 429 too many requests"} {
|
||||
if !isTransientSheetErr(fmt.Errorf("%s", e)) {
|
||||
t.Fatalf("%q should be transient", e)
|
||||
}
|
||||
}
|
||||
for _, e := range []string{"invalid nodeId", "permission denied", "not found"} {
|
||||
if isTransientSheetErr(fmt.Errorf("%s", e)) {
|
||||
t.Fatalf("%q should NOT be transient", e)
|
||||
}
|
||||
}
|
||||
if isTransientSheetErr(nil) {
|
||||
t.Fatal("nil is not transient")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlushDeferred_ReportsCompletedItems verifies the owner gets a per-item
|
||||
// completion list (not just a count) when the backlog is flushed.
|
||||
func TestFlushDeferred_ReportsCompletedItems(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ctflush")
|
||||
runner := &fakeRunner{fail: true}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="我的待办"]]`, func(_ context.Context, _, _ string) error { return nil })
|
||||
if gate.list()[0].State != approvalDeferred {
|
||||
t.Fatalf("want deferred, got %s", gate.list()[0].State)
|
||||
}
|
||||
|
||||
runner.setFail(false)
|
||||
o.handleOwnerDecision(context.Background(), "owner", "重试")
|
||||
|
||||
notifier.mu.Lock()
|
||||
var last string
|
||||
if len(notifier.sent) > 0 {
|
||||
last = notifier.sent[len(notifier.sent)-1].text
|
||||
}
|
||||
notifier.mu.Unlock()
|
||||
if !strings.Contains(last, "已补做 1 个") || !strings.Contains(last, "创建待办:我的待办") {
|
||||
t.Fatalf("flush summary should list the completed item, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsRetryWord checks whole-message retry-keyword matching.
|
||||
func TestIsRetryWord(t *testing.T) {
|
||||
for _, w := range []string{"重试", " 恢复 ", "retry", "继续"} {
|
||||
if !isRetryWord(w) {
|
||||
t.Fatalf("%q should be a retry word", w)
|
||||
}
|
||||
}
|
||||
for _, w := range []string{"重试一下他的方案", "", "你好"} {
|
||||
if isRetryWord(w) {
|
||||
t.Fatalf("%q should NOT be a retry word", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleReply_TextMode_OwnerRejects: the owner's "拒绝" declines without
|
||||
// executing.
|
||||
func TestHandleReply_TextMode_OwnerRejects(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ct2")
|
||||
runner := &fakeRunner{}
|
||||
notifier := &fakeNotifier{}
|
||||
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
|
||||
|
||||
o.handleReply(context.Background(), "requester", "conv-group", `[[ACTION:todo.create title="x"]]`, nil)
|
||||
if !o.handleOwnerDecision(context.Background(), "owner", "拒绝") {
|
||||
t.Fatal("owner rejection must be consumed")
|
||||
}
|
||||
if runner.count() != 0 {
|
||||
t.Fatalf("rejection must NOT execute, ran %d", runner.count())
|
||||
}
|
||||
if gate.list()[0].State != approvalRejected {
|
||||
t.Fatalf("state = %s, want rejected", gate.list()[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleOwnerDecision_NotADecision: an ordinary owner message (not a bare
|
||||
// keyword) is not consumed, so it still reaches the agent.
|
||||
func TestHandleOwnerDecision_NotADecision(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
gate := newApprovalGate("ct3")
|
||||
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{})
|
||||
gate.Submit(ApprovalRequest{ConvID: "conv-1", Summary: "x", State: approvalPending})
|
||||
|
||||
if o.handleOwnerDecision(context.Background(), "owner", "顺便帮我查下天气") {
|
||||
t.Fatal("a non-decision owner message must pass through to the agent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLatestPending returns the newest pending request across conversations.
|
||||
func TestLatestPending(t *testing.T) {
|
||||
gate := newApprovalGate("")
|
||||
gate.Submit(ApprovalRequest{ConvID: "c1", Summary: "old", CreatedAt: time.Unix(100, 0)})
|
||||
newer := gate.Submit(ApprovalRequest{ConvID: "c2", Summary: "new", CreatedAt: time.Unix(200, 0)})
|
||||
if got := gate.latestPending(); got == nil || got.ID != newer.ID {
|
||||
t.Fatalf("latestPending = %v, want newest %s", got, newer.ID)
|
||||
}
|
||||
gate.Decide(newer.ID, true, "owner")
|
||||
if got := gate.latestPending(); got == nil || got.Summary != "old" {
|
||||
t.Fatalf("after deciding newest, latestPending should fall back to the older pending, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// list is a tiny test helper to enumerate the gate's requests.
|
||||
func (g *approvalGate) list() []*ApprovalRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
out := make([]*ApprovalRequest, 0, len(g.reqs))
|
||||
for _, r := range g.reqs {
|
||||
cp := *r
|
||||
out = append(out, &cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
)
|
||||
|
||||
// TestApprovalGate_EndToEndApprove exercises the whole vertical slice with no
|
||||
// network: an agent reply carrying a todo.create marker → Submit → approval card
|
||||
// → owner taps [Approve] via a card callback → the planned todo.create runs on
|
||||
// the (fake) runner → the group is told it was executed.
|
||||
//
|
||||
// It mirrors exactly what runStreamConnector wires together, but with the card
|
||||
// sender and runner faked, so the orchestration logic is verified end to end.
|
||||
func TestApprovalGate_EndToEndApprove(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
gate := newApprovalGate("e2e-client")
|
||||
sender := &fakeCardSender{}
|
||||
runner := &fakeRunner{}
|
||||
o := newApprovalOrchestrator(gate, sender, runner, "owner-007")
|
||||
o.awaitWindow = 2 * time.Second
|
||||
|
||||
var replies []string
|
||||
var rmu sync.Mutex
|
||||
groupReply := func(_ context.Context, _, text string) error {
|
||||
rmu.Lock()
|
||||
replies = append(replies, text)
|
||||
rmu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Simulate the owner tapping [Approve] once the card has been delivered, via
|
||||
// the exact card-callback path the Stream router would invoke.
|
||||
go func() {
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
sender.mu.Lock()
|
||||
track := sender.lastTrack
|
||||
sender.mu.Unlock()
|
||||
if track != "" {
|
||||
_, _ = o.handleCardCallback(context.Background(), &card.CardRequest{
|
||||
OutTrackId: track,
|
||||
UserId: "owner-007",
|
||||
CardActionData: card.PrivateCardActionData{
|
||||
CardPrivateData: card.CardPrivateData{
|
||||
ActionIdList: []string{approvalActionApprove},
|
||||
Params: map[string]any{
|
||||
approvalParamDecision: approvalDecisionApprove,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
// This is the agent reply for: 群里有人 @机器人 "帮我建个待办:明天下班前交方案".
|
||||
agentReply := `收到,我来帮你建这个待办。[[ACTION:todo.create title="明天下班前交方案" due="2026-06-14T18:00:00+08:00"]]`
|
||||
|
||||
out, handled := o.handleReply(context.Background(), "requester-zhang", "group-conv-9", agentReply, groupReply)
|
||||
|
||||
if !handled {
|
||||
t.Fatal("action reply must be handled by the gate")
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("handled reply returns empty connector reply, got %q", out)
|
||||
}
|
||||
|
||||
// The planned command ran exactly once, and it was todo.create.
|
||||
if runner.count() != 1 {
|
||||
t.Fatalf("expected exactly 1 execution, got %d", runner.count())
|
||||
}
|
||||
runner.mu.Lock()
|
||||
inv := runner.runs[0]
|
||||
runner.mu.Unlock()
|
||||
if inv.CanonicalProduct != "todo" || inv.Tool != "create_personal_todo" {
|
||||
t.Fatalf("executed wrong command: %s.%s", inv.CanonicalProduct, inv.Tool)
|
||||
}
|
||||
vo, _ := inv.Params["PersonalTodoCreateVO"].(map[string]any)
|
||||
if vo == nil || vo["subject"] != "明天下班前交方案" {
|
||||
t.Fatalf("todo subject wrong: %+v", vo)
|
||||
}
|
||||
|
||||
// State machine reached Executed.
|
||||
final := gate.list()[0]
|
||||
if final.State != approvalExecuted {
|
||||
t.Fatalf("final state = %s, want executed", final.State)
|
||||
}
|
||||
|
||||
// The owner saw [Approve]/[Reject] (the card was delivered), and the group
|
||||
// got the executed-confirmation reply.
|
||||
if len(sender.sent) != 1 {
|
||||
t.Fatalf("expected 1 card delivered, got %d", len(sender.sent))
|
||||
}
|
||||
rmu.Lock()
|
||||
joined := strings.Join(replies, " | ")
|
||||
rmu.Unlock()
|
||||
if !strings.Contains(joined, "已同意") || !strings.Contains(joined, "已执行") {
|
||||
t.Fatalf("group not told of execution: %q", joined)
|
||||
}
|
||||
|
||||
t.Logf("E2E approve chain OK:\n"+
|
||||
" card delivered to owner: %v\n"+
|
||||
" approval state: %s\n"+
|
||||
" command executed: %s.%s (subject=%q)\n"+
|
||||
" group replies: %s",
|
||||
sender.sent[0].Summary, final.State, inv.CanonicalProduct, inv.Tool, vo["subject"], joined)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestApprovalGate_SubmitPending verifies Submit lands a request in Pending with
|
||||
// a generated id and timestamp.
|
||||
func TestApprovalGate_SubmitPending(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("client-A")
|
||||
req := g.Submit(ApprovalRequest{Requester: "u1", ConvID: "cid", Summary: "do thing"})
|
||||
if req.ID == "" {
|
||||
t.Fatal("Submit should assign an ID")
|
||||
}
|
||||
if req.State != approvalPending {
|
||||
t.Fatalf("state = %s, want pending", req.State)
|
||||
}
|
||||
if req.CreatedAt.IsZero() {
|
||||
t.Fatal("CreatedAt should be set")
|
||||
}
|
||||
if got := g.Get(req.ID); got == nil || got.State != approvalPending {
|
||||
t.Fatalf("Get returned %+v, want pending", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_DecideApproveReject covers both decision branches and the
|
||||
// idempotency of a second decision.
|
||||
func TestApprovalGate_DecideApproveReject(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("client-B")
|
||||
|
||||
app := g.Submit(ApprovalRequest{Summary: "approve me"})
|
||||
decided, deciding := g.Decide(app.ID, true, "owner")
|
||||
if !deciding || decided.State != approvalApproved || decided.DecidedBy != "owner" {
|
||||
t.Fatalf("approve decision = %+v deciding=%v", decided, deciding)
|
||||
}
|
||||
// Second decision is a no-op.
|
||||
if _, again := g.Decide(app.ID, false, "owner"); again {
|
||||
t.Fatal("second Decide must not be the deciding call")
|
||||
}
|
||||
if g.Get(app.ID).State != approvalApproved {
|
||||
t.Fatal("state must stay approved after a redundant reject")
|
||||
}
|
||||
|
||||
rej := g.Submit(ApprovalRequest{Summary: "reject me"})
|
||||
d2, _ := g.Decide(rej.ID, false, "owner")
|
||||
if d2.State != approvalRejected {
|
||||
t.Fatalf("reject decision = %s, want rejected", d2.State)
|
||||
}
|
||||
|
||||
// Unknown id.
|
||||
if _, ok := g.Decide("nope", true, "owner"); ok {
|
||||
t.Fatal("deciding unknown id should report false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_AwaitWakesOnDecide verifies Await blocks until a decision and
|
||||
// returns the decided snapshot.
|
||||
func TestApprovalGate_AwaitWakesOnDecide(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("client-C")
|
||||
app := g.Submit(ApprovalRequest{Summary: "wait for me"})
|
||||
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
g.Decide(app.ID, true, "owner")
|
||||
}()
|
||||
|
||||
decided, got := g.Await(context.Background(), app.ID, time.Second)
|
||||
if !got || decided == nil || !decided.approved() {
|
||||
t.Fatalf("Await = %+v got=%v, want approved", decided, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_AwaitTimeout verifies a timeout leaves the request Pending and
|
||||
// reports no decision.
|
||||
func TestApprovalGate_AwaitTimeout(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("client-D")
|
||||
app := g.Submit(ApprovalRequest{Summary: "nobody decides"})
|
||||
decided, got := g.Await(context.Background(), app.ID, 10*time.Millisecond)
|
||||
if got {
|
||||
t.Fatal("Await should report no decision on timeout")
|
||||
}
|
||||
if decided.State != approvalPending {
|
||||
t.Fatalf("state = %s, want pending after timeout", decided.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_PersistAndRecover verifies a Pending request written by one
|
||||
// gate is recovered by a fresh gate over the same config dir (restart survival).
|
||||
func TestApprovalGate_PersistAndRecover(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
|
||||
g1 := newApprovalGate("client-E")
|
||||
app := g1.Submit(ApprovalRequest{Requester: "u9", Summary: "survive restart",
|
||||
Action: plannedAction{Product: "todo", Tool: "create_personal_todo"}})
|
||||
g1.setOutTrackID(app.ID, "card-123")
|
||||
|
||||
// Fresh gate, same dir → must recover the request and its outTrackId.
|
||||
g2 := newApprovalGate("client-E")
|
||||
got := g2.Get(app.ID)
|
||||
if got == nil {
|
||||
t.Fatal("request not recovered from disk")
|
||||
}
|
||||
if got.State != approvalPending || got.Summary != "survive restart" {
|
||||
t.Fatalf("recovered = %+v", got)
|
||||
}
|
||||
if got.OutTrackID != "card-123" {
|
||||
t.Fatalf("recovered outTrackId = %q, want card-123", got.OutTrackID)
|
||||
}
|
||||
if byTrack := g2.findByOutTrackID("card-123"); byTrack == nil || byTrack.ID != app.ID {
|
||||
t.Fatal("findByOutTrackID failed after recovery")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalGate_NoPersistWhenEmptyClient verifies an empty clientId keeps the
|
||||
// gate in memory only (no disk writes, mirroring the session store).
|
||||
func TestApprovalGate_NoPersistWhenEmptyClient(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
g := newApprovalGate("")
|
||||
if g.approvalDir() != "" {
|
||||
t.Fatal("empty clientId must disable persistence")
|
||||
}
|
||||
app := g.Submit(ApprovalRequest{Summary: "memory only"})
|
||||
if g.Get(app.ID) == nil {
|
||||
t.Fatal("in-memory request should still be retrievable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionMarker covers the simplified execution-class detection: a
|
||||
// marker is extracted with its args and stripped from the human-facing reply,
|
||||
// while plain Q&A is left untouched.
|
||||
func TestParseActionMarker(t *testing.T) {
|
||||
reply := `好的,我来帮你建。[[ACTION:todo.create title="交方案" due="2026-06-14T18:00:00+08:00"]]`
|
||||
act, cleaned, found := parseActionMarker(reply)
|
||||
if !found {
|
||||
t.Fatal("marker should be detected")
|
||||
}
|
||||
if act.Verb != "todo.create" {
|
||||
t.Fatalf("verb = %s", act.Verb)
|
||||
}
|
||||
if act.Args["title"] != "交方案" || act.Args["due"] != "2026-06-14T18:00:00+08:00" {
|
||||
t.Fatalf("args = %+v", act.Args)
|
||||
}
|
||||
if cleaned != "好的,我来帮你建。" {
|
||||
t.Fatalf("cleaned = %q", cleaned)
|
||||
}
|
||||
|
||||
if _, _, f := parseActionMarker("这是一个普通问题的回答,没有动作。"); f {
|
||||
t.Fatal("plain reply must not match")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToPlannedAction verifies the marker → command mapping, including the
|
||||
// reject of a malformed (titleless) action.
|
||||
func TestToPlannedAction(t *testing.T) {
|
||||
pa, summary, ok := toPlannedAction(detectedAction{
|
||||
Verb: "todo.create",
|
||||
Args: map[string]string{"title": "交方案", "due": "2026-06-14T18:00:00+08:00"},
|
||||
}, "owner-1")
|
||||
if !ok {
|
||||
t.Fatal("valid todo.create should map")
|
||||
}
|
||||
if pa.Product != "todo" || pa.Tool != "create_personal_todo" {
|
||||
t.Fatalf("planned = %+v", pa)
|
||||
}
|
||||
vo, _ := pa.Params["PersonalTodoCreateVO"].(map[string]any)
|
||||
if vo == nil || vo["subject"] != "交方案" {
|
||||
t.Fatalf("vo = %+v", vo)
|
||||
}
|
||||
if _, hasDue := vo["dueTime"]; !hasDue {
|
||||
t.Fatal("due should be parsed into dueTime")
|
||||
}
|
||||
execs, _ := vo["executorIds"].([]string)
|
||||
if len(execs) != 1 || execs[0] != "owner-1" {
|
||||
t.Fatalf("executors = %+v, want [owner-1]", execs)
|
||||
}
|
||||
if summary == "" {
|
||||
t.Fatal("summary should be non-empty")
|
||||
}
|
||||
|
||||
if _, _, ok := toPlannedAction(detectedAction{Verb: "todo.create", Args: map[string]string{}}, "o"); ok {
|
||||
t.Fatal("titleless action must be rejected")
|
||||
}
|
||||
if _, _, ok := toPlannedAction(detectedAction{Verb: "unknown.verb"}, "o"); ok {
|
||||
t.Fatal("unknown verb must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
)
|
||||
|
||||
// AI-card reply support for the stream-bridge channels: instead of a plain
|
||||
// one-shot text message, the bot answers with a DingTalk AI card that shows a
|
||||
// "Thinking" state while the local agent runs and flips to done with the
|
||||
// final content — the same UX the hermes official pipeline provides.
|
||||
//
|
||||
// The experience combines the two verified official implementations:
|
||||
// - the "🤔Thinking/🥳Done" chip on the user's message is a text emotion
|
||||
// (message reaction), ported from the hermes gateway adapter;
|
||||
// - the card itself uses the openclaw connector's public template and
|
||||
// payload contract (msgContent + flowStatus state machine), because card
|
||||
// templates are app-scoped: hermes' own template (c629162a-...) renders
|
||||
// "内容加载失败" for any other app — confirmed by A/B on the same robot.
|
||||
//
|
||||
// Call order: create instance → deliver → flowStatus INPUTING → streaming
|
||||
// frames (throttled, isFull) → finalize frame → flowStatus FINISHED.
|
||||
const (
|
||||
// defaultAICardTemplateID is the public AI-card streaming template the
|
||||
// openclaw connector ships with (messaging/card.ts) — a best-effort
|
||||
// default. Card templates are APP-SCOPED: the reliable production setup is
|
||||
// registering an AI-card template under YOUR app in the DingTalk developer
|
||||
// console (the hermes docs prescribe exactly this) and passing its ID via
|
||||
// --card-template / DWS_CARD_TEMPLATE. Using another app's template (e.g.
|
||||
// hermes' c629162a-...) renders "内容加载失败" — confirmed by live A/B on
|
||||
// the same robot.
|
||||
defaultAICardTemplateID = "02fcf2f4-5e02-4a85-b672-46d1f715543e.schema"
|
||||
|
||||
// aiCardMaxContent mirrors hermes' MAX_MESSAGE_LENGTH truncation.
|
||||
aiCardMaxContent = 20000
|
||||
|
||||
// flowStatus states of the AI-card template (openclaw AICardStatus).
|
||||
aiCardFlowInputing = "2"
|
||||
aiCardFlowFinished = "3"
|
||||
aiCardFlowFailed = "5"
|
||||
)
|
||||
|
||||
// dingtalkCardAPIBase is a var so tests can point it at a httptest server.
|
||||
var dingtalkCardAPIBase = "https://api.dingtalk.com"
|
||||
|
||||
// aiCardClient creates and finalizes AI cards using the robot's own
|
||||
// credentials (clientId/clientSecret → app access token, cached ~2h).
|
||||
type aiCardClient struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
templateID string
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
// newAICardClient builds the reply-UX client. With an empty templateID the
|
||||
// client does emotions only (Thinking/Done chips) and no cards — mirroring
|
||||
// hermes, where cards are an opt-in enabled by configuring a template ID and
|
||||
// replies stay plain text otherwise. This avoids the silent-failure trap:
|
||||
// card APIs all succeed even when the client cannot render the template.
|
||||
func newAICardClient(clientID, clientSecret, templateID string) *aiCardClient {
|
||||
templateID = strings.TrimSpace(templateID)
|
||||
return &aiCardClient{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
templateID: templateID,
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// hasTemplate reports whether card replies are enabled (a template is
|
||||
// configured); without one the client is used for emotions only.
|
||||
func (c *aiCardClient) hasTemplate() bool { return c.templateID != "" }
|
||||
|
||||
// aiCardInstance is one delivered card, identified by its outTrackId.
|
||||
type aiCardInstance struct {
|
||||
outTrackID string
|
||||
// inputing marks that the INPUTING flow state was already set (once per
|
||||
// card, before the first streaming frame).
|
||||
inputing bool
|
||||
// lastFrame timestamps the last non-final streaming frame for the
|
||||
// per-card throttle (the streaming endpoint 403s on <~500ms updates;
|
||||
// hermes uses 800ms).
|
||||
lastFrame time.Time
|
||||
}
|
||||
|
||||
// accessToken returns a cached app access token, refreshing 5 minutes early.
|
||||
func (c *aiCardClient) accessToken(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.token != "" && time.Now().Before(c.tokenExp.Add(-5*time.Minute)) {
|
||||
return c.token, nil
|
||||
}
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"appKey": c.clientID,
|
||||
"appSecret": c.clientSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
dingtalkCardAPIBase+"/v1.0/oauth2/accessToken", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("accessToken HTTP %d: %s", resp.StatusCode, truncateRunes(string(raw), 200))
|
||||
}
|
||||
var parsed struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpireIn int64 `json:"expireIn"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil || parsed.AccessToken == "" {
|
||||
return "", fmt.Errorf("accessToken parse failed: %s", truncateRunes(string(raw), 200))
|
||||
}
|
||||
c.token = parsed.AccessToken
|
||||
expire := parsed.ExpireIn
|
||||
if expire <= 0 {
|
||||
expire = 7200
|
||||
}
|
||||
c.tokenExp = time.Now().Add(time.Duration(expire) * time.Second)
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
// call performs one authenticated card-API request and fails on non-2xx.
|
||||
func (c *aiCardClient) call(ctx context.Context, method, path string, payload map[string]any) error {
|
||||
_, err := c.callRaw(ctx, method, path, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *aiCardClient) callRaw(ctx context.Context, method, path string, payload map[string]any) (string, error) {
|
||||
token, err := c.accessToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, dingtalkCardAPIBase+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-acs-dingtalk-access-token", token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("%s %s HTTP %d: %s", method, path, resp.StatusCode, truncateRunes(string(raw), 300))
|
||||
}
|
||||
// Surface every response body in the connector log: the card APIs report
|
||||
// business-level failures inside a 200, and a silently dropped frame is
|
||||
// otherwise indistinguishable from the client-side "内容加载失败".
|
||||
fmt.Fprintf(os.Stderr, "[connect][card][api] %s %s -> %d %s\n", method, path, resp.StatusCode, truncateRunes(string(raw), 200))
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// sendOTOText sends a plain-text 1:1 (robot → person) message to each userId,
|
||||
// using the robot batch one-to-one send API. It is how the connector reaches a
|
||||
// specific person (e.g. the digital-twin owner) proactively, outside the
|
||||
// ephemeral inbound sessionWebhook — the webhook can only reply into the
|
||||
// conversation a message arrived on, and may be stale by the time an owner
|
||||
// decides. Auth reuses the app access token (the bot's own clientId/secret), so
|
||||
// the message is sent as the bot in its own org.
|
||||
func (c *aiCardClient) sendOTOText(ctx context.Context, userIDs []string, text string) error {
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
param, err := json.Marshal(map[string]string{"content": text})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.call(ctx, http.MethodPost, "/v1.0/robot/oToMessages/batchSend", map[string]any{
|
||||
"robotCode": c.clientID,
|
||||
"userIds": userIDs,
|
||||
"msgKey": "sampleText",
|
||||
"msgParam": string(param),
|
||||
})
|
||||
}
|
||||
|
||||
// createAndDeliver creates an AI-card instance and delivers it into the
|
||||
// conversation the message came from. With empty content the template
|
||||
// renders the "Thinking" state while the agent runs. Group messages deliver
|
||||
// to IM_GROUP (with robotCode), 1:1 to IM_ROBOT (spaceType only) — exactly
|
||||
// the hermes deliver shapes.
|
||||
func (c *aiCardClient) createAndDeliver(ctx context.Context, data *chatbot.BotCallbackDataModel) (*aiCardInstance, error) {
|
||||
outTrackID := "dws_" + uuid.NewString()
|
||||
create := map[string]any{
|
||||
"cardTemplateId": c.templateID,
|
||||
"outTrackId": outTrackID,
|
||||
"cardData": map[string]any{
|
||||
"cardParamMap": map[string]any{"config": `{"autoLayout":true}`},
|
||||
},
|
||||
"callbackType": "STREAM",
|
||||
"imGroupOpenSpaceModel": map[string]any{"supportForward": true},
|
||||
"imRobotOpenSpaceModel": map[string]any{"supportForward": true},
|
||||
}
|
||||
if err := c.call(ctx, http.MethodPost, "/v1.0/card/instances", create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deliver := map[string]any{
|
||||
"outTrackId": outTrackID,
|
||||
"userIdType": 1,
|
||||
}
|
||||
if data.ConversationType == "2" { // group chat
|
||||
deliver["openSpaceId"] = "dtv1.card//IM_GROUP." + data.ConversationId
|
||||
deliver["imGroupOpenDeliverModel"] = map[string]any{"robotCode": c.clientID}
|
||||
} else { // 1:1 chat with the robot
|
||||
if data.SenderStaffId == "" {
|
||||
return nil, fmt.Errorf("missing senderStaffId for 1:1 card delivery")
|
||||
}
|
||||
deliver["openSpaceId"] = "dtv1.card//IM_ROBOT." + data.SenderStaffId
|
||||
deliver["imRobotOpenDeliverModel"] = map[string]any{
|
||||
"spaceType": "IM_ROBOT",
|
||||
"robotCode": c.clientID,
|
||||
"extension": map[string]any{"dynamicSummary": "true"},
|
||||
}
|
||||
}
|
||||
if err := c.callChecked(ctx, http.MethodPost, "/v1.0/card/instances/deliver", deliver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &aiCardInstance{outTrackID: outTrackID}, nil
|
||||
}
|
||||
|
||||
// callChecked is call() plus a business-level result check: the deliver API
|
||||
// reports per-target failures INSIDE a HTTP 200 (e.g. {"result":[{"success":
|
||||
// false,"errorMsg":"spaceId is illegal"}],"success":true}) — observed live.
|
||||
func (c *aiCardClient) callChecked(ctx context.Context, method, path string, payload map[string]any) error {
|
||||
raw, err := c.callRaw(ctx, method, path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(raw, `"success":false`) {
|
||||
return fmt.Errorf("%s %s business failure: %s", method, path, truncateRunes(raw, 300))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamingUpdate sends one full content frame to the card.
|
||||
func (c *aiCardClient) streamingUpdate(ctx context.Context, card *aiCardInstance, content string, finalize, isError bool) error {
|
||||
if r := []rune(content); len(r) > aiCardMaxContent {
|
||||
content = string(r[:aiCardMaxContent])
|
||||
}
|
||||
return c.call(ctx, http.MethodPut, "/v1.0/card/streaming", map[string]any{
|
||||
"outTrackId": card.outTrackID,
|
||||
"guid": uuid.NewString(),
|
||||
"key": "msgContent",
|
||||
"content": content,
|
||||
"isFull": true,
|
||||
"isFinalize": finalize,
|
||||
"isError": isError,
|
||||
})
|
||||
}
|
||||
|
||||
// aiCardFrameGap spaces the deliver → content → finalize frames. Delivering
|
||||
// and finalizing back-to-back races the client's card fetch and intermittently
|
||||
// renders "内容加载失败" (the very failure that killed the #407 card attempt);
|
||||
// a short gap lets the client subscribe before the closing frame lands.
|
||||
var aiCardFrameGap = 500 * time.Millisecond
|
||||
|
||||
// cardContentParams is the cardParamMap contract of the openclaw template.
|
||||
func cardContentParams(flowStatus, content string) map[string]any {
|
||||
return map[string]any{
|
||||
"flowStatus": flowStatus,
|
||||
"msgContent": content,
|
||||
"staticMsgContent": "",
|
||||
"sys_full_json_obj": `{"order":["msgContent"]}`,
|
||||
"config": `{"autoLayout":true}`,
|
||||
}
|
||||
}
|
||||
|
||||
// setFlowStatus updates the card instance's flow state (openclaw contract:
|
||||
// INPUTING before streaming, FINISHED after the finalize frame).
|
||||
func (c *aiCardClient) setFlowStatus(ctx context.Context, card *aiCardInstance, flowStatus, content string, byKey bool) error {
|
||||
payload := map[string]any{
|
||||
"outTrackId": card.outTrackID,
|
||||
"cardData": map[string]any{"cardParamMap": cardContentParams(flowStatus, content)},
|
||||
}
|
||||
if byKey {
|
||||
payload["cardUpdateOptions"] = map[string]any{"updateCardDataByKey": true}
|
||||
}
|
||||
return c.call(ctx, http.MethodPut, "/v1.0/card/instances", payload)
|
||||
}
|
||||
|
||||
// aiCardFrameThrottle is the minimum spacing between non-final streaming
|
||||
// frames per card (hermes uses 800ms; the endpoint 403s on rapid updates).
|
||||
var aiCardFrameThrottle = 800 * time.Millisecond
|
||||
|
||||
// streamFrame pushes one full-content frame into the card, switching the card
|
||||
// to INPUTING before the first frame. Non-final frames are throttled per
|
||||
// card — a skipped frame is fine because every frame carries the full text so
|
||||
// far (isFull). The final frame always goes out.
|
||||
func (c *aiCardClient) streamFrame(ctx context.Context, card *aiCardInstance, content string, final bool) error {
|
||||
normalized := normalizeForCard(content)
|
||||
if !card.inputing {
|
||||
if err := c.setFlowStatus(ctx, card, aiCardFlowInputing, normalized, false); err != nil {
|
||||
return err
|
||||
}
|
||||
card.inputing = true
|
||||
card.lastFrame = time.Now()
|
||||
}
|
||||
if !final {
|
||||
if time.Since(card.lastFrame) < aiCardFrameThrottle {
|
||||
return nil // skip; a later frame re-sends the full text
|
||||
}
|
||||
}
|
||||
if err := c.streamingUpdate(ctx, card, normalized, final, false); err != nil {
|
||||
return err
|
||||
}
|
||||
card.lastFrame = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// finish closes the card: the finalized streaming frame plus the FINISHED
|
||||
// flow state. A failure leaves the card spinning, so callers must treat an
|
||||
// error as "fall back to a plain webhook reply" (after markFailed).
|
||||
func (c *aiCardClient) finish(ctx context.Context, card *aiCardInstance, content string) error {
|
||||
normalized := normalizeForCard(content)
|
||||
if err := c.streamFrame(ctx, card, content, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sleepCtx(ctx, aiCardFrameGap); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.setFlowStatus(ctx, card, aiCardFlowFinished, normalized, true)
|
||||
}
|
||||
|
||||
// finalize is the one-shot path for channels without incremental output:
|
||||
// INPUTING → final frame → FINISHED in one call.
|
||||
func (c *aiCardClient) finalize(ctx context.Context, card *aiCardInstance, content string) error {
|
||||
if err := sleepCtx(ctx, aiCardFrameGap); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.finish(ctx, card, content)
|
||||
}
|
||||
|
||||
// repair re-pushes the finalize frame once, a few seconds after the reply.
|
||||
// hermes streams many frames so a client that misses one recovers on the
|
||||
// next; our burst of two frames has no such retry, and a missed fetch shows
|
||||
// "内容加载失败" until a new frame arrives. Best-effort by design.
|
||||
func (c *aiCardClient) repair(ctx context.Context, card *aiCardInstance, content string) {
|
||||
if err := sleepCtx(ctx, aiCardRepairDelay); err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.setFlowStatus(ctx, card, aiCardFlowFinished, normalizeForCard(content), true)
|
||||
}
|
||||
|
||||
// aiCardRepairDelay is how long after finalize the repair frame goes out.
|
||||
var aiCardRepairDelay = 3 * time.Second
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// markFailed best-effort closes a stuck card in the error state so the
|
||||
// Thinking chip does not spin forever when finalize failed and the reply
|
||||
// went out as a plain message instead. Errors are ignored by design.
|
||||
func (c *aiCardClient) markFailed(ctx context.Context, card *aiCardInstance) {
|
||||
_ = c.setFlowStatus(ctx, card, aiCardFlowFailed, "", true)
|
||||
}
|
||||
|
||||
// ---- Message reactions (the "🤔Thinking" chip) ----
|
||||
//
|
||||
// The status chip attached to the user's message is NOT a card: it is a text
|
||||
// emotion (message reaction) — hermes fires "🤔Thinking" on receive and swaps
|
||||
// it for "🥳Done" when the reply lands (gateway/platforms/dingtalk.py,
|
||||
// _send_emotion / _fire_done_reaction). During thinking there is no card at
|
||||
// all; the card only appears with the final content.
|
||||
|
||||
const (
|
||||
emotionThinking = "🤔Thinking"
|
||||
emotionDone = "🥳Done"
|
||||
)
|
||||
|
||||
// sendEmotion adds (or recalls) a text reaction on the user's message.
|
||||
// Failures are logged by callers at most — the reaction is decoration, never
|
||||
// worth failing the reply over. Payload contract per the robot_1_0 SDK:
|
||||
// POST /v1.0/robot/emotion/reply | /v1.0/robot/emotion/recall.
|
||||
func (c *aiCardClient) sendEmotion(ctx context.Context, conversationID, msgID, name string, recall bool) error {
|
||||
if conversationID == "" || msgID == "" {
|
||||
return fmt.Errorf("emotion needs openConversationId and openMsgId")
|
||||
}
|
||||
path := "/v1.0/robot/emotion/reply"
|
||||
if recall {
|
||||
path = "/v1.0/robot/emotion/recall"
|
||||
}
|
||||
return c.call(ctx, http.MethodPost, path, map[string]any{
|
||||
"robotCode": c.clientID,
|
||||
"openConversationId": conversationID,
|
||||
"openMsgId": msgID,
|
||||
"emotionType": 2,
|
||||
"emotionName": name,
|
||||
"textEmotion": map[string]any{
|
||||
"emotionId": "2659900",
|
||||
"emotionName": name,
|
||||
"text": name,
|
||||
"backgroundId": "im_bg_1",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// markThinking fires the "🤔Thinking" chip on the user's message.
|
||||
func (c *aiCardClient) markThinking(ctx context.Context, conversationID, msgID string) error {
|
||||
return c.sendEmotion(ctx, conversationID, msgID, emotionThinking, false)
|
||||
}
|
||||
|
||||
// swapThinkingToDone replaces the chip with "🥳Done" after the reply landed.
|
||||
// Best-effort by design (mirrors hermes' fire-and-forget swap).
|
||||
func (c *aiCardClient) swapThinkingToDone(ctx context.Context, conversationID, msgID string) {
|
||||
_ = c.sendEmotion(ctx, conversationID, msgID, emotionThinking, true)
|
||||
_ = c.sendEmotion(ctx, conversationID, msgID, emotionDone, false)
|
||||
}
|
||||
|
||||
// ---- Markdown normalization for the AI-card renderer ----
|
||||
//
|
||||
// Ported from openclaw's normalizeForCard (messaging/card.ts): the 02fcf2f4
|
||||
// template renders <br> as visual line breaks in plain text, needs real \n
|
||||
// inside code fences and before Markdown block syntax, and a blank line
|
||||
// before tables.
|
||||
|
||||
var (
|
||||
cardTableDividerRe = regexp.MustCompile(`^\s*\|?\s*:?-+:?\s*(\|?\s*:?-+:?\s*)+\|?\s*$`)
|
||||
cardTableRowRe = regexp.MustCompile(`^\s*\|?.*\|.*\|?\s*$`)
|
||||
cardBlockStartRe = regexp.MustCompile(`^(\s{0,3}(?:[-*+]|\d+[.)])[ ])|(\s{0,3}\|)|(\s{0,3}#{1,6}\s)|(\s{0,3}(?:[-*_])\s*(?:[-*_])\s*(?:[-*_]))`)
|
||||
cardFenceRe = regexp.MustCompile("^\\s{0,3}```")
|
||||
cardQuoteRe = regexp.MustCompile(`^\s{0,3}>\s?`)
|
||||
)
|
||||
|
||||
func normalizeCardLineEndings(text string) string {
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
return strings.ReplaceAll(text, "\r", "\n")
|
||||
}
|
||||
|
||||
// ensureCardTableBlankLines inserts a blank line before a table header that
|
||||
// directly follows non-table text, which the card renderer requires.
|
||||
func ensureCardTableBlankLines(text string) string {
|
||||
lines := strings.Split(normalizeCardLineEndings(text), "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
isDivider := func(line string) bool {
|
||||
return strings.Contains(line, "|") && cardTableDividerRe.MatchString(line)
|
||||
}
|
||||
for i, line := range lines {
|
||||
next := ""
|
||||
if i+1 < len(lines) {
|
||||
next = lines[i+1]
|
||||
}
|
||||
if cardTableRowRe.MatchString(line) && isDivider(next) && i > 0 &&
|
||||
strings.TrimSpace(lines[i-1]) != "" && !cardTableRowRe.MatchString(lines[i-1]) {
|
||||
out = append(out, "")
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// fixCardNewlines converts single \n to <br> for plain text while keeping
|
||||
// real newlines inside code fences and before Markdown block syntax, and
|
||||
// merges consecutive quote lines with <br> (lazy continuation).
|
||||
func fixCardNewlines(text string) string {
|
||||
normalized := normalizeCardLineEndings(text)
|
||||
|
||||
var merged []string
|
||||
var pendingQuote []string
|
||||
inCode := false
|
||||
flushQuote := func() {
|
||||
if len(pendingQuote) > 0 {
|
||||
merged = append(merged, strings.Join(pendingQuote, "<br>"))
|
||||
pendingQuote = nil
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(normalized, "\n") {
|
||||
isFence := cardFenceRe.MatchString(line)
|
||||
if inCode {
|
||||
flushQuote()
|
||||
merged = append(merged, line)
|
||||
if isFence {
|
||||
inCode = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isFence {
|
||||
flushQuote()
|
||||
merged = append(merged, line)
|
||||
inCode = true
|
||||
continue
|
||||
}
|
||||
if cardQuoteRe.MatchString(line) {
|
||||
if len(pendingQuote) == 0 {
|
||||
pendingQuote = append(pendingQuote, line)
|
||||
} else {
|
||||
pendingQuote = append(pendingQuote, cardQuoteRe.ReplaceAllString(line, ""))
|
||||
}
|
||||
} else {
|
||||
flushQuote()
|
||||
merged = append(merged, line)
|
||||
}
|
||||
}
|
||||
flushQuote()
|
||||
|
||||
inCode = false
|
||||
var b strings.Builder
|
||||
for i, line := range merged {
|
||||
nextInCode := inCode
|
||||
if cardFenceRe.MatchString(line) {
|
||||
nextInCode = !inCode
|
||||
}
|
||||
if i < len(merged)-1 {
|
||||
next := merged[i+1]
|
||||
keepNewline := nextInCode || line == "" || next == "" ||
|
||||
cardFenceRe.MatchString(next) || cardBlockStartRe.MatchString(next)
|
||||
b.WriteString(line)
|
||||
if keepNewline {
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
b.WriteString("<br>")
|
||||
}
|
||||
} else {
|
||||
b.WriteString(line)
|
||||
}
|
||||
inCode = nextInCode
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// normalizeForCard prepares agent output for the AI-card renderer.
|
||||
func normalizeForCard(content string) string {
|
||||
return fixCardNewlines(ensureCardTableBlankLines(content))
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
)
|
||||
|
||||
// cardAPIRecorder fakes the DingTalk card endpoints and records every call.
|
||||
type cardAPIRecorder struct {
|
||||
mu sync.Mutex
|
||||
calls []string // "METHOD path"
|
||||
bodies []map[string]any // parsed request bodies, same order
|
||||
tokens int // accessToken request count
|
||||
fail map[string]int // "METHOD path" -> HTTP status to return
|
||||
}
|
||||
|
||||
func newCardAPIServer(t *testing.T) (*cardAPIRecorder, *httptest.Server) {
|
||||
t.Helper()
|
||||
rec := &cardAPIRecorder{fail: map[string]int{}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
key := r.Method + " " + r.URL.Path
|
||||
|
||||
rec.mu.Lock()
|
||||
if r.URL.Path == "/v1.0/oauth2/accessToken" {
|
||||
rec.tokens++
|
||||
} else {
|
||||
rec.calls = append(rec.calls, key)
|
||||
rec.bodies = append(rec.bodies, body)
|
||||
}
|
||||
status := rec.fail[key]
|
||||
rec.mu.Unlock()
|
||||
|
||||
if status != 0 {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(`{"code":"boom"}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1.0/oauth2/accessToken" {
|
||||
_, _ = w.Write([]byte(`{"accessToken":"tok-1","expireIn":7200}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return rec, srv
|
||||
}
|
||||
|
||||
func withCardAPIBase(t *testing.T, base string) {
|
||||
t.Helper()
|
||||
old := dingtalkCardAPIBase
|
||||
dingtalkCardAPIBase = base
|
||||
oldGap, oldRepair := aiCardFrameGap, aiCardRepairDelay
|
||||
aiCardFrameGap, aiCardRepairDelay = 0, 0
|
||||
t.Cleanup(func() {
|
||||
dingtalkCardAPIBase = old
|
||||
aiCardFrameGap, aiCardRepairDelay = oldGap, oldRepair
|
||||
})
|
||||
}
|
||||
|
||||
func groupCallback() *chatbot.BotCallbackDataModel {
|
||||
return &chatbot.BotCallbackDataModel{
|
||||
ConversationId: "cid-group-1",
|
||||
ConversationType: "2",
|
||||
SenderStaffId: "staff-1",
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardCreateFinalizeSequence verifies the hermes-contract happy path:
|
||||
// create (empty content = Thinking) → deliver → ONE finalized streaming frame.
|
||||
func TestAICardCreateFinalizeSequence(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
card, err := c.createAndDeliver(context.Background(), groupCallback())
|
||||
if err != nil {
|
||||
t.Fatalf("createAndDeliver: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(card.outTrackID, "dws_") {
|
||||
t.Fatalf("outTrackID = %q, want dws_ prefix", card.outTrackID)
|
||||
}
|
||||
if err := c.finalize(context.Background(), card, "你好,**答案是 42**"); err != nil {
|
||||
t.Fatalf("finalize: %v", err)
|
||||
}
|
||||
|
||||
wantSeq := []string{
|
||||
"POST /v1.0/card/instances",
|
||||
"POST /v1.0/card/instances/deliver",
|
||||
"PUT /v1.0/card/instances", // INPUTING + content
|
||||
"PUT /v1.0/card/streaming", // finalize frame
|
||||
"PUT /v1.0/card/instances", // FINISHED
|
||||
}
|
||||
if strings.Join(rec.calls, ",") != strings.Join(wantSeq, ",") {
|
||||
t.Fatalf("call sequence = %v, want %v", rec.calls, wantSeq)
|
||||
}
|
||||
if rec.tokens != 1 {
|
||||
t.Fatalf("token requests = %d, want 1 (cached)", rec.tokens)
|
||||
}
|
||||
|
||||
// create: hermes template + empty "content" param (renders Thinking)
|
||||
create := rec.bodies[0]
|
||||
if create["cardTemplateId"] != defaultAICardTemplateID || create["callbackType"] != "STREAM" {
|
||||
t.Fatalf("create payload wrong: %v", create)
|
||||
}
|
||||
pm := create["cardData"].(map[string]any)["cardParamMap"].(map[string]any)
|
||||
if _, ok := pm["config"]; !ok {
|
||||
t.Fatalf("create cardParamMap = %v, want config key (openclaw contract)", pm)
|
||||
}
|
||||
|
||||
// deliver: group space + robotCode
|
||||
deliver := rec.bodies[1]
|
||||
if deliver["openSpaceId"] != "dtv1.card//IM_GROUP.cid-group-1" {
|
||||
t.Fatalf("deliver openSpaceId = %v", deliver["openSpaceId"])
|
||||
}
|
||||
if deliver["imGroupOpenDeliverModel"].(map[string]any)["robotCode"] != "ding-client" {
|
||||
t.Fatalf("deliver robotCode missing: %v", deliver)
|
||||
}
|
||||
|
||||
// INPUTING then FINISHED flowStatus around a finalized streaming frame
|
||||
statusOf := func(b map[string]any) string {
|
||||
pm := b["cardData"].(map[string]any)["cardParamMap"].(map[string]any)
|
||||
v, _ := pm["flowStatus"].(string)
|
||||
return v
|
||||
}
|
||||
if statusOf(rec.bodies[2]) != aiCardFlowInputing {
|
||||
t.Fatalf("flowStatus[2] = %v, want INPUTING", statusOf(rec.bodies[2]))
|
||||
}
|
||||
stream := rec.bodies[3]
|
||||
if stream["key"] != "msgContent" || stream["isFinalize"] != true || stream["isError"] != false {
|
||||
t.Fatalf("finalize frame wrong: %v", stream)
|
||||
}
|
||||
if statusOf(rec.bodies[4]) != aiCardFlowFinished {
|
||||
t.Fatalf("flowStatus[4] = %v, want FINISHED", statusOf(rec.bodies[4]))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardDeliverOneToOne checks 1:1 messages deliver into the IM_ROBOT
|
||||
// space with the hermes shape (spaceType only, no robotCode).
|
||||
func TestAICardDeliverOneToOne(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
data := &chatbot.BotCallbackDataModel{ConversationType: "1", SenderStaffId: "staff-9"}
|
||||
if _, err := c.createAndDeliver(context.Background(), data); err != nil {
|
||||
t.Fatalf("createAndDeliver: %v", err)
|
||||
}
|
||||
deliver := rec.bodies[1]
|
||||
if deliver["openSpaceId"] != "dtv1.card//IM_ROBOT.staff-9" {
|
||||
t.Fatalf("deliver openSpaceId = %v, want IM_ROBOT.staff-9", deliver["openSpaceId"])
|
||||
}
|
||||
model := deliver["imRobotOpenDeliverModel"].(map[string]any)
|
||||
if model["spaceType"] != "IM_ROBOT" || model["robotCode"] != "ding-client" {
|
||||
t.Fatalf("imRobotOpenDeliverModel = %v", model)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardCreateFailure checks errors surface so callers fall back to plain
|
||||
// replies (never silently lost), and that missing staffId in 1:1 is rejected.
|
||||
func TestAICardCreateFailure(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
rec.fail["POST /v1.0/card/instances"] = 500
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
if _, err := c.createAndDeliver(context.Background(), groupCallback()); err == nil {
|
||||
t.Fatal("want error when card create fails")
|
||||
}
|
||||
|
||||
rec.fail = map[string]int{}
|
||||
if _, err := c.createAndDeliver(context.Background(),
|
||||
&chatbot.BotCallbackDataModel{ConversationType: "1"}); err == nil {
|
||||
t.Fatal("want error for 1:1 without senderStaffId")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardMarkFailed checks the stuck-Thinking remedy: a finalized error
|
||||
// frame, errors swallowed.
|
||||
func TestAICardMarkFailed(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
c.markFailed(context.Background(), &aiCardInstance{outTrackID: "dws_x"})
|
||||
last := rec.bodies[len(rec.bodies)-1]
|
||||
pm := last["cardData"].(map[string]any)["cardParamMap"].(map[string]any)
|
||||
if pm["flowStatus"] != aiCardFlowFailed {
|
||||
t.Fatalf("markFailed frame wrong: %v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRobotConnectReplyCardFlag checks the flag default and dry-run surface.
|
||||
func TestRobotConnectReplyCardFlag(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"default: no template -> text + emotions", nil, "thinking/done表态"},
|
||||
{"template configured -> ai-card", []string{"--card-template", "tpl-1.schema"}, `"replyStyle": "ai-card"`},
|
||||
{"public alias -> ai-card", []string{"--card-template", "public"}, `"replyStyle": "ai-card"`},
|
||||
{"explicit off", []string{"--reply-card=false"}, `"replyStyle": "text/markdown"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := newDevAppTestRoot(&captureRunner{})
|
||||
var out strings.Builder
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
args := append([]string{"dev", "connect",
|
||||
"--channel", "claudecode",
|
||||
"--robot-client-id", "a", "--robot-client-secret", "b", "--dry-run"}, tc.args...)
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), tc.want) {
|
||||
t.Fatalf("missing %q in:\n%s", tc.want, out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardEmotions covers the Thinking chip contract: reply on receive,
|
||||
// recall+Done on completion (POST /v1.0/robot/emotion/*).
|
||||
func TestAICardEmotions(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", defaultAICardTemplateID)
|
||||
if err := c.markThinking(context.Background(), "cid-1", "msg-1"); err != nil {
|
||||
t.Fatalf("markThinking: %v", err)
|
||||
}
|
||||
c.swapThinkingToDone(context.Background(), "cid-1", "msg-1")
|
||||
|
||||
wantSeq := []string{
|
||||
"POST /v1.0/robot/emotion/reply", // Thinking
|
||||
"POST /v1.0/robot/emotion/recall", // Thinking recalled
|
||||
"POST /v1.0/robot/emotion/reply", // Done
|
||||
}
|
||||
if strings.Join(rec.calls, ",") != strings.Join(wantSeq, ",") {
|
||||
t.Fatalf("call sequence = %v, want %v", rec.calls, wantSeq)
|
||||
}
|
||||
first := rec.bodies[0]
|
||||
if first["emotionName"] != "🤔Thinking" || first["openMsgId"] != "msg-1" ||
|
||||
first["robotCode"] != "ding-client" {
|
||||
t.Fatalf("thinking payload wrong: %v", first)
|
||||
}
|
||||
te := first["textEmotion"].(map[string]any)
|
||||
if te["emotionId"] != "2659900" || te["backgroundId"] != "im_bg_1" || te["text"] != "🤔Thinking" {
|
||||
t.Fatalf("textEmotion wrong: %v", te)
|
||||
}
|
||||
if rec.bodies[2]["emotionName"] != "🥳Done" {
|
||||
t.Fatalf("done payload wrong: %v", rec.bodies[2])
|
||||
}
|
||||
|
||||
// Missing ids must error (e.g. payloads without MsgId).
|
||||
if err := c.markThinking(context.Background(), "", "msg-1"); err == nil {
|
||||
t.Fatal("want error for missing conversation id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAICardCustomTemplate checks --card-template plumbs through to create.
|
||||
func TestAICardCustomTemplate(t *testing.T) {
|
||||
rec, srv := newCardAPIServer(t)
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", "my-own-template.schema")
|
||||
if _, err := c.createAndDeliver(context.Background(), groupCallback()); err != nil {
|
||||
t.Fatalf("createAndDeliver: %v", err)
|
||||
}
|
||||
if rec.bodies[0]["cardTemplateId"] != "my-own-template.schema" {
|
||||
t.Fatalf("cardTemplateId = %v, want custom template", rec.bodies[0]["cardTemplateId"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import "strings"
|
||||
|
||||
// CmdClass describes whether a dws command is read-only or mutating. It is the
|
||||
// signal a connector confirmation gate consumes to decide whether a robot may
|
||||
// run a command directly (read-only) or must first ask the principal to
|
||||
// approve it (write / mutating).
|
||||
//
|
||||
// SAFETY CONTRACT: callers MUST treat CmdClassUnknown conservatively, i.e. as
|
||||
// if it were CmdClassWrite (require confirmation). The classifier deliberately
|
||||
// returns Unknown rather than silently coercing it to Write so that the
|
||||
// confirmation gate keeps full information and can, for example, log/telemeter
|
||||
// "unclassified" commands separately. Never auto-allow an Unknown command.
|
||||
type CmdClass int
|
||||
|
||||
const (
|
||||
// CmdClassUnknown means the leaf verb was not recognised by the
|
||||
// heuristics or any override. Callers must default to requiring
|
||||
// confirmation (treat as write) for safety.
|
||||
CmdClassUnknown CmdClass = iota
|
||||
// CmdClassRead is a read-only / non-mutating command that may be run
|
||||
// without principal confirmation.
|
||||
CmdClassRead
|
||||
// CmdClassWrite is a mutating / state-changing command that must be
|
||||
// confirmed by the principal before it runs.
|
||||
CmdClassWrite
|
||||
)
|
||||
|
||||
// String renders the class as a stable lowercase token, handy for logs and
|
||||
// telemetry.
|
||||
func (c CmdClass) String() string {
|
||||
switch c {
|
||||
case CmdClassRead:
|
||||
return "read"
|
||||
case CmdClassWrite:
|
||||
return "write"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// readVerbs and writeVerbs are calibrated against the verbs that real dws
|
||||
// cobra commands expose (scanned from `Use:` definitions across the repo, e.g.
|
||||
// todo task create/list/get/update/delete/done, attendance get, approval
|
||||
// submit, chat send / recall, drive upload / download / mkdir / chmod, doc
|
||||
// export, contact search, etc). They are NOT invented: every entry below has
|
||||
// at least one real command using it (or a "<verb>-by-..." / "batch-<verb>"
|
||||
// compound that normalises to it).
|
||||
var (
|
||||
readVerbs = map[string]struct{}{
|
||||
"list": {},
|
||||
"get": {},
|
||||
"search": {},
|
||||
"read": {},
|
||||
"view": {},
|
||||
"query": {},
|
||||
"show": {},
|
||||
"detail": {},
|
||||
"details": {},
|
||||
"status": {},
|
||||
"download": {},
|
||||
"export": {},
|
||||
"fetch": {},
|
||||
"info": {},
|
||||
"find": {},
|
||||
"stats": {},
|
||||
"stat": {},
|
||||
"summary": {},
|
||||
"check": {},
|
||||
"inspect": {},
|
||||
"doctor": {},
|
||||
"version": {},
|
||||
"whoami": {},
|
||||
"ls": {},
|
||||
"cat": {},
|
||||
"describe": {},
|
||||
"diff": {},
|
||||
"preview": {},
|
||||
}
|
||||
|
||||
writeVerbs = map[string]struct{}{
|
||||
"create": {},
|
||||
"update": {},
|
||||
"delete": {},
|
||||
"submit": {},
|
||||
"send": {},
|
||||
"done": {},
|
||||
"cancel": {},
|
||||
"offline": {},
|
||||
"online": {},
|
||||
"enable": {},
|
||||
"disable": {},
|
||||
"remove": {},
|
||||
"add": {},
|
||||
"set": {},
|
||||
"unset": {},
|
||||
"approve": {},
|
||||
"reject": {},
|
||||
"write": {},
|
||||
"upload": {},
|
||||
"move": {},
|
||||
"mv": {},
|
||||
"copy": {},
|
||||
"cp": {},
|
||||
"rename": {},
|
||||
"reply": {},
|
||||
"recall": {},
|
||||
"publish": {},
|
||||
"insert": {},
|
||||
"import": {},
|
||||
"install": {},
|
||||
"uninstall": {},
|
||||
"mkdir": {},
|
||||
"share": {},
|
||||
"unshare": {},
|
||||
"commit": {},
|
||||
"reset": {},
|
||||
"stop": {},
|
||||
"start": {},
|
||||
"restart": {},
|
||||
"hide": {},
|
||||
"unhide": {},
|
||||
"finalize": {},
|
||||
"execute": {},
|
||||
"run": {},
|
||||
"exec": {},
|
||||
"chmod": {},
|
||||
"rm": {},
|
||||
"clean": {},
|
||||
"clear": {},
|
||||
"refresh": {},
|
||||
"recover": {},
|
||||
"recovery": {},
|
||||
"login": {},
|
||||
"logout": {},
|
||||
"register": {},
|
||||
"connect": {},
|
||||
"disconnect": {},
|
||||
"upgrade": {},
|
||||
"setup": {},
|
||||
"generate": {},
|
||||
"batch": {},
|
||||
"apply": {},
|
||||
"patch": {},
|
||||
"put": {},
|
||||
"post": {},
|
||||
"sync": {},
|
||||
"link": {},
|
||||
"unlink": {},
|
||||
"grant": {},
|
||||
"revoke": {},
|
||||
"assign": {},
|
||||
"close": {},
|
||||
"open": {},
|
||||
"archive": {},
|
||||
"restore": {},
|
||||
"finish": {},
|
||||
}
|
||||
)
|
||||
|
||||
// defaultClassOverrides is the package-level override table. It is intentionally
|
||||
// empty by default: heuristics are expected to cover the common case, and the
|
||||
// override table exists only to correct individual misclassifications without
|
||||
// touching the heuristic verb tables. Mutate it via SetCmdClassOverride.
|
||||
//
|
||||
// Keys may be either a single verb ("download") or a full space-joined command
|
||||
// path ("doc export"); both are matched case-insensitively. A full-path key
|
||||
// wins over a single-verb key when both are present.
|
||||
var defaultClassOverrides = map[string]CmdClass{}
|
||||
|
||||
// SetCmdClassOverride registers a process-wide classification override for the
|
||||
// given key. The key may be a single verb or a space-joined command path; it is
|
||||
// normalised to lowercase. Passing CmdClassUnknown removes the override.
|
||||
func SetCmdClassOverride(key string, class CmdClass) {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if class == CmdClassUnknown {
|
||||
delete(defaultClassOverrides, key)
|
||||
return
|
||||
}
|
||||
defaultClassOverrides[key] = class
|
||||
}
|
||||
|
||||
// ClassifyDwsCommand classifies a dws command given its path segments (e.g.
|
||||
// "todo", "task", "create"). It consults the package-level override table and
|
||||
// then the read/write verb heuristics, scanning segments right-to-left so the
|
||||
// leaf action verb dominates a container/noun segment.
|
||||
//
|
||||
// Remember the SAFETY CONTRACT on CmdClass: a CmdClassUnknown result MUST be
|
||||
// treated as write (require confirmation) by the caller.
|
||||
func ClassifyDwsCommand(parts ...string) CmdClass {
|
||||
return ClassifyDwsCommandWith(defaultClassOverrides, parts...)
|
||||
}
|
||||
|
||||
// ClassifyDwsCommandWith is like ClassifyDwsCommand but lets the caller supply
|
||||
// an explicit override table (e.g. a per-tenant or per-request map) instead of
|
||||
// the package-level one. A nil overrides map is allowed and means "no
|
||||
// overrides". Override lookups always win over the heuristics.
|
||||
func ClassifyDwsCommandWith(overrides map[string]CmdClass, parts ...string) CmdClass {
|
||||
norm := normalizeParts(parts)
|
||||
if len(norm) == 0 {
|
||||
return CmdClassUnknown
|
||||
}
|
||||
|
||||
// 1. Full-path override (most specific) wins.
|
||||
if overrides != nil {
|
||||
fullKey := strings.Join(norm, " ")
|
||||
if c, ok := overrides[fullKey]; ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Walk segments right-to-left: the leaf action verb is the most
|
||||
// meaningful, but a leaf may be a noun/placeholder (e.g. an id) that
|
||||
// we do not recognise, so fall back toward the root.
|
||||
for i := len(norm) - 1; i >= 0; i-- {
|
||||
seg := norm[i]
|
||||
if overrides != nil {
|
||||
if c, ok := overrides[seg]; ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
if c, ok := classifyVerb(seg); ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
return CmdClassUnknown
|
||||
}
|
||||
|
||||
// classifyVerb classifies a single segment, handling compound verbs such as
|
||||
// "send-by-bot", "list-forms", "batch-update" or "create_inline" by also
|
||||
// trying the token before the first separator. Returns (class, true) when the
|
||||
// segment maps to a known verb.
|
||||
func classifyVerb(seg string) (CmdClass, bool) {
|
||||
candidates := verbCandidates(seg)
|
||||
for _, v := range candidates {
|
||||
if _, ok := readVerbs[v]; ok {
|
||||
return CmdClassRead, true
|
||||
}
|
||||
if _, ok := writeVerbs[v]; ok {
|
||||
return CmdClassWrite, true
|
||||
}
|
||||
}
|
||||
return CmdClassUnknown, false
|
||||
}
|
||||
|
||||
// verbCandidates expands a segment into the tokens worth checking against the
|
||||
// verb tables, in priority order: the whole segment first, then the leading
|
||||
// token before the first '-' or '_' separator (so "batch-update" tries
|
||||
// "update"-as-prefix... no: it tries "batch" first which is itself a write
|
||||
// verb; and "send-by-bot" tries "send"). This handles both "<verb>-<qualifier>"
|
||||
// and "<verb>_<qualifier>" compounds where the action sits at the front.
|
||||
func verbCandidates(seg string) []string {
|
||||
out := []string{seg}
|
||||
if i := strings.IndexAny(seg, "-_"); i > 0 {
|
||||
out = append(out, seg[:i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeParts lowercases, trims and drops empty/whitespace segments so that
|
||||
// callers can pass raw argv-style slices without pre-cleaning them.
|
||||
func normalizeParts(parts []string) []string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClassifyDwsCommandReadVerbs(t *testing.T) {
|
||||
readLeaves := []string{
|
||||
"list", "get", "search", "read", "view", "query", "show",
|
||||
"detail", "status", "download", "export", "fetch", "info",
|
||||
"find", "summary",
|
||||
}
|
||||
for _, v := range readLeaves {
|
||||
if got := ClassifyDwsCommand("todo", "task", v); got != CmdClassRead {
|
||||
t.Errorf("verb %q: got %v, want CmdClassRead", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandWriteVerbs(t *testing.T) {
|
||||
writeLeaves := []string{
|
||||
"create", "update", "delete", "submit", "send", "done",
|
||||
"cancel", "offline", "enable", "disable", "remove", "add",
|
||||
"set", "approve", "reject", "write", "upload", "move", "copy",
|
||||
}
|
||||
for _, v := range writeLeaves {
|
||||
if got := ClassifyDwsCommand("approval", "instance", v); got != CmdClassWrite {
|
||||
t.Errorf("verb %q: got %v, want CmdClassWrite", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandUnknown(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{},
|
||||
{"todo"}, // container only, no action verb
|
||||
{"todo", "task"}, // still only containers
|
||||
{"frobnicate"}, // nonsense verb
|
||||
{"todo", "task", "wibble"}, // unknown leaf, no known ancestor
|
||||
{" ", ""}, // all empty after trim
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyDwsCommand(c...); got != CmdClassUnknown {
|
||||
t.Errorf("parts %v: got %v, want CmdClassUnknown", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandRealSamples(t *testing.T) {
|
||||
cases := []struct {
|
||||
parts []string
|
||||
want CmdClass
|
||||
}{
|
||||
// Real dws command paths sampled from the repo's cobra Use: defs.
|
||||
{[]string{"todo", "task", "create"}, CmdClassWrite},
|
||||
{[]string{"todo", "task", "list"}, CmdClassRead},
|
||||
{[]string{"todo", "task", "get"}, CmdClassRead},
|
||||
{[]string{"todo", "task", "update"}, CmdClassWrite},
|
||||
{[]string{"todo", "task", "delete"}, CmdClassWrite},
|
||||
{[]string{"todo", "task", "done"}, CmdClassWrite},
|
||||
{[]string{"approval", "instance", "submit"}, CmdClassWrite},
|
||||
{[]string{"attendance", "get"}, CmdClassRead},
|
||||
{[]string{"chat", "message", "send"}, CmdClassWrite},
|
||||
{[]string{"chat", "message", "list"}, CmdClassRead},
|
||||
{[]string{"drive", "file", "download"}, CmdClassRead},
|
||||
{[]string{"drive", "file", "upload"}, CmdClassWrite},
|
||||
{[]string{"drive", "dir", "mkdir"}, CmdClassWrite},
|
||||
{[]string{"doc", "export"}, CmdClassRead},
|
||||
{[]string{"contact", "user", "search"}, CmdClassRead},
|
||||
{[]string{"report", "status"}, CmdClassRead},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyDwsCommand(c.parts...); got != c.want {
|
||||
t.Errorf("cmd %v: got %v, want %v", c.parts, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandCompoundVerbs(t *testing.T) {
|
||||
cases := []struct {
|
||||
parts []string
|
||||
want CmdClass
|
||||
}{
|
||||
// Compound leaf verbs that exist in the repo.
|
||||
{[]string{"chat", "send-by-bot"}, CmdClassWrite},
|
||||
{[]string{"chat", "send-by-webhook"}, CmdClassWrite},
|
||||
{[]string{"chat", "recall-by-bot"}, CmdClassWrite},
|
||||
{[]string{"aitable", "form", "list-forms"}, CmdClassRead},
|
||||
{[]string{"wiki", "space", "list-spaces"}, CmdClassRead},
|
||||
{[]string{"record", "batch-update"}, CmdClassWrite},
|
||||
{[]string{"group", "add-bot"}, CmdClassWrite},
|
||||
{[]string{"group", "remove-bot"}, CmdClassWrite},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyDwsCommand(c.parts...); got != c.want {
|
||||
t.Errorf("compound %v: got %v, want %v", c.parts, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDwsCommandLeafFallbackToAncestor(t *testing.T) {
|
||||
// When the true leaf is a placeholder/id segment we don't recognise,
|
||||
// classification falls back to the nearest known ancestor verb.
|
||||
if got := ClassifyDwsCommand("todo", "task", "get", "abc123"); got != CmdClassRead {
|
||||
t.Errorf("trailing id segment: got %v, want CmdClassRead", got)
|
||||
}
|
||||
if got := ClassifyDwsCommand("todo", "task", "create", "xyz"); got != CmdClassWrite {
|
||||
t.Errorf("trailing id segment: got %v, want CmdClassWrite", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdClassOverrideTable(t *testing.T) {
|
||||
// Suppose "download" should require confirmation for some tenant: a
|
||||
// full-path override flips a normally-read command to write without
|
||||
// touching the heuristic tables.
|
||||
overrides := map[string]CmdClass{
|
||||
"drive file download": CmdClassWrite, // full-path override
|
||||
"export": CmdClassWrite, // single-verb override
|
||||
}
|
||||
|
||||
if got := ClassifyDwsCommandWith(overrides, "drive", "file", "download"); got != CmdClassWrite {
|
||||
t.Errorf("full-path override: got %v, want CmdClassWrite", got)
|
||||
}
|
||||
// A different path whose leaf is "download" is NOT affected by the
|
||||
// full-path override and stays read.
|
||||
if got := ClassifyDwsCommandWith(overrides, "media", "download"); got != CmdClassRead {
|
||||
t.Errorf("unrelated download path: got %v, want CmdClassRead", got)
|
||||
}
|
||||
// Single-verb override flips every "export" leaf.
|
||||
if got := ClassifyDwsCommandWith(overrides, "doc", "export"); got != CmdClassWrite {
|
||||
t.Errorf("single-verb override: got %v, want CmdClassWrite", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCmdClassOverrideProcessWide(t *testing.T) {
|
||||
defer SetCmdClassOverride("status", CmdClassUnknown) // cleanup
|
||||
|
||||
// Baseline: status is read.
|
||||
if got := ClassifyDwsCommand("report", "status"); got != CmdClassRead {
|
||||
t.Fatalf("baseline status: got %v, want CmdClassRead", got)
|
||||
}
|
||||
// Register a process-wide override making status write.
|
||||
SetCmdClassOverride("STATUS", CmdClassWrite) // case-insensitive key
|
||||
if got := ClassifyDwsCommand("report", "status"); got != CmdClassWrite {
|
||||
t.Errorf("after override: got %v, want CmdClassWrite", got)
|
||||
}
|
||||
// Removing the override restores the heuristic result.
|
||||
SetCmdClassOverride("status", CmdClassUnknown)
|
||||
if got := ClassifyDwsCommand("report", "status"); got != CmdClassRead {
|
||||
t.Errorf("after override removal: got %v, want CmdClassRead", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdClassString(t *testing.T) {
|
||||
cases := map[CmdClass]string{
|
||||
CmdClassRead: "read",
|
||||
CmdClassWrite: "write",
|
||||
CmdClassUnknown: "unknown",
|
||||
}
|
||||
for c, want := range cases {
|
||||
if got := c.String(); got != want {
|
||||
t.Errorf("String(%d): got %q, want %q", c, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。"
|
||||
|
||||
// codexAppServerForwarder uses Codex's official app-server JSON-RPC protocol to
|
||||
// keep one Codex thread per DingTalk conversation.
|
||||
type codexAppServerForwarder struct {
|
||||
bin string
|
||||
env []string
|
||||
timeout time.Duration
|
||||
workDir string
|
||||
model string
|
||||
sessions *codexThreadSessions
|
||||
}
|
||||
|
||||
func newCodexAppServerForwarder(bin string, env []string, timeout time.Duration, opts connectAgentOptions, clientID string) forwarder {
|
||||
var sessions *codexThreadSessions
|
||||
if opts.Memory {
|
||||
// Scope the on-disk thread store by clientId so multiple bots on one
|
||||
// machine stay isolated; an empty clientId disables persistence and the
|
||||
// map stays in memory (original behaviour).
|
||||
sessions = newCodexThreadSessions(codexThreadStorePath(clientID))
|
||||
}
|
||||
return &codexAppServerForwarder{
|
||||
bin: bin,
|
||||
env: env,
|
||||
timeout: timeout,
|
||||
workDir: opts.WorkDir,
|
||||
model: opts.Model,
|
||||
sessions: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
// codexThreadStorePath returns the on-disk location for a robot's codex
|
||||
// conversation→thread map, scoped by clientId so multiple bots on one machine
|
||||
// stay isolated: <config dir>/connect/<clientId>/codex-threads.json. It mirrors
|
||||
// the Claude-family connectSessionStorePath layout but uses a distinct filename
|
||||
// so the two stores never collide. An empty clientId means "do not persist"
|
||||
// (in-memory only). The clientId is sanitized with the same rule as the connect
|
||||
// lock file so it is always filesystem-safe.
|
||||
func codexThreadStorePath(clientID string) string {
|
||||
if clientID == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(config.DefaultConfigDir(), "connect", sanitizeLockID(clientID), "codex-threads.json")
|
||||
}
|
||||
|
||||
// resetSession drops the conversation's Codex thread so the next message starts
|
||||
// a fresh one. Implements sessionResetter for the built-in /new and /clear
|
||||
// commands. A no-op when per-conversation memory is disabled.
|
||||
func (f *codexAppServerForwarder) resetSession(convID string) {
|
||||
if f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) canStream() bool { return true }
|
||||
|
||||
func (f *codexAppServerForwarder) label() string {
|
||||
memo := "stateless"
|
||||
if f.sessions != nil {
|
||||
memo = "thread-memory"
|
||||
}
|
||||
return fmt.Sprintf("codex-app-server:%s (%s)", f.bin, memo)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
return f.forwardAppServer(ctx, convID, text, onDelta)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
var state *codexThreadState
|
||||
if f.sessions != nil {
|
||||
state = f.sessions.state(convID)
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
}
|
||||
|
||||
cli, err := newCodexAppServerClient(ctx, f.bin, f.env, f.cwd())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cli.close()
|
||||
|
||||
if err := cli.initialize(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_ = state // held only for its per-conversation turn lock (see above)
|
||||
threadID := ""
|
||||
if f.sessions != nil {
|
||||
threadID = f.sessions.threadID(convID)
|
||||
}
|
||||
if threadID != "" {
|
||||
resumed, err := cli.resumeThread(ctx, f.threadParams(threadID))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][codex] resume thread %s 失败,重建会话: %v\n", threadID, err)
|
||||
threadID = ""
|
||||
f.sessions.setThreadID(convID, "")
|
||||
} else {
|
||||
threadID = resumed
|
||||
}
|
||||
}
|
||||
if threadID == "" {
|
||||
started, err := cli.startThread(ctx, f.threadParams(""))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
threadID = started
|
||||
if f.sessions != nil {
|
||||
f.sessions.setThreadID(convID, threadID)
|
||||
}
|
||||
}
|
||||
|
||||
reply, err := cli.runTurn(ctx, threadID, text, onDelta)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) cwd() string {
|
||||
if f.workDir == "" {
|
||||
return connectWorkDir()
|
||||
}
|
||||
if abs, err := filepath.Abs(f.workDir); err == nil {
|
||||
return abs
|
||||
}
|
||||
return f.workDir
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) threadParams(threadID string) map[string]any {
|
||||
params := map[string]any{
|
||||
"approvalPolicy": "never",
|
||||
"cwd": f.cwd(),
|
||||
"developerInstructions": codexRobotDeveloperInstructions,
|
||||
"sandbox": "read-only",
|
||||
}
|
||||
if f.model != "" {
|
||||
params["model"] = f.model
|
||||
}
|
||||
if threadID != "" {
|
||||
params["threadId"] = threadID
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// codexThreadSessions maps a DingTalk conversation to its Codex thread so
|
||||
// multi-turn context survives within a conversation. The convID→threadID map
|
||||
// (threads, guarded by mu) is the authoritative store and is persisted to disk
|
||||
// so the context also survives a connector restart — the codex equivalent of
|
||||
// the Claude-family convSessions store. An empty path keeps it in memory only
|
||||
// (persistence disabled), preserving the original behaviour exactly.
|
||||
//
|
||||
// states holds a per-conversation lock that serializes turns within one
|
||||
// conversation; it carries no thread identity of its own.
|
||||
type codexThreadSessions struct {
|
||||
mu sync.Mutex
|
||||
states map[string]*codexThreadState // per-conversation turn lock
|
||||
threads map[string]string // convID→threadID, persisted
|
||||
path string // on-disk store; empty disables persistence
|
||||
}
|
||||
|
||||
// codexThreadState is a per-conversation turn lock. Holding it for the duration
|
||||
// of a turn keeps two messages in the same conversation from interleaving their
|
||||
// app-server calls; thread identity lives in codexThreadSessions.threads.
|
||||
type codexThreadState struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newCodexThreadSessions builds the session map, restoring any persisted
|
||||
// convID→threadID entries from path. A missing or corrupt file degrades to an
|
||||
// empty map (see loadConvSessionMap) — it never panics or blocks startup. An
|
||||
// empty path means in-memory only.
|
||||
func newCodexThreadSessions(path string) *codexThreadSessions {
|
||||
return &codexThreadSessions{
|
||||
states: make(map[string]*codexThreadState),
|
||||
threads: loadConvSessionMap(path),
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
// codexConvKey normalizes a conversation ID into a stable, non-empty map key.
|
||||
func codexConvKey(convID string) string {
|
||||
key := strings.TrimSpace(convID)
|
||||
if key == "" {
|
||||
key = "_default"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// state returns the per-conversation turn lock, minting one on first sight.
|
||||
func (s *codexThreadSessions) state(convID string) *codexThreadState {
|
||||
key := codexConvKey(convID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st, ok := s.states[key]; ok {
|
||||
return st
|
||||
}
|
||||
st := &codexThreadState{}
|
||||
s.states[key] = st
|
||||
return st
|
||||
}
|
||||
|
||||
// threadID returns the Codex thread bound to a conversation, or "" if none.
|
||||
func (s *codexThreadSessions) threadID(convID string) string {
|
||||
key := codexConvKey(convID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.threads[key]
|
||||
}
|
||||
|
||||
// setThreadID binds a conversation to a Codex thread and persists the snapshot.
|
||||
// A "" threadID forgets the binding (same as reset). Persistence is best-effort:
|
||||
// a failed save only logs a warning and never blocks message handling.
|
||||
func (s *codexThreadSessions) setThreadID(convID, threadID string) {
|
||||
key := codexConvKey(convID)
|
||||
s.mu.Lock()
|
||||
if threadID == "" {
|
||||
delete(s.threads, key)
|
||||
} else {
|
||||
s.threads[key] = threadID
|
||||
}
|
||||
snapshot := make(map[string]string, len(s.threads))
|
||||
for k, v := range s.threads {
|
||||
snapshot[k] = v
|
||||
}
|
||||
path := s.path
|
||||
s.mu.Unlock()
|
||||
saveConvSessionMap(path, snapshot)
|
||||
}
|
||||
|
||||
// reset forgets a conversation's thread so the next message starts a fresh one.
|
||||
// The removal is persisted so a restart does not resurrect the dropped thread.
|
||||
func (s *codexThreadSessions) reset(convID string) {
|
||||
s.setThreadID(convID, "")
|
||||
}
|
||||
|
||||
type codexAppServerClient struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
msgs chan codexRPCMessage
|
||||
readErr chan error
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
stderr *lockedBuffer
|
||||
nextID int
|
||||
}
|
||||
|
||||
type lockedBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
type codexRPCMessage struct {
|
||||
ID *int `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *codexRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type codexRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func newCodexAppServerClient(ctx context.Context, bin string, env []string, cwd string) (*codexAppServerClient, error) {
|
||||
cmd := exec.CommandContext(ctx, bin, "app-server", "--stdio")
|
||||
cmd.Dir = cwd
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stderr := &lockedBuffer{}
|
||||
cmd.Stderr = stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cli := &codexAppServerClient{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
msgs: make(chan codexRPCMessage, 64),
|
||||
readErr: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
stderr: stderr,
|
||||
nextID: 1,
|
||||
}
|
||||
go cli.readLoop(stdout)
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) readLoop(stdout io.Reader) {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var msg codexRPCMessage
|
||||
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||
c.reportReadErr(fmt.Errorf("parse app-server JSONL: %w", err))
|
||||
close(c.msgs)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.msgs <- msg:
|
||||
case <-c.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
c.reportReadErr(err)
|
||||
} else {
|
||||
c.reportReadErr(io.EOF)
|
||||
}
|
||||
close(c.msgs)
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) close() {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.done)
|
||||
_ = c.stdin.Close()
|
||||
if c.cmd.Process != nil {
|
||||
_ = c.cmd.Process.Kill()
|
||||
}
|
||||
_ = c.cmd.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) reportReadErr(err error) {
|
||||
select {
|
||||
case c.readErr <- err:
|
||||
case <-c.done:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) initialize(ctx context.Context) error {
|
||||
id := c.requestID()
|
||||
if err := c.send(map[string]any{
|
||||
"id": id,
|
||||
"method": "initialize",
|
||||
"params": map[string]any{
|
||||
"capabilities": map[string]any{"experimentalApi": true},
|
||||
"clientInfo": map[string]any{
|
||||
"name": "dws-devapp-robot-connect",
|
||||
"title": "DWS DevApp Robot Connect",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.waitResponse(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.send(map[string]any{"method": "initialized", "params": map[string]any{}})
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) startThread(ctx context.Context, params map[string]any) (string, error) {
|
||||
id := c.requestID()
|
||||
if err := c.send(map[string]any{"id": id, "method": "thread/start", "params": params}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return codexThreadIDFromResult(c.waitResponse(ctx, id))
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) resumeThread(ctx context.Context, params map[string]any) (string, error) {
|
||||
id := c.requestID()
|
||||
if err := c.send(map[string]any{"id": id, "method": "thread/resume", "params": params}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return codexThreadIDFromResult(c.waitResponse(ctx, id))
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) runTurn(ctx context.Context, threadID, text string, onDelta func(string)) (string, error) {
|
||||
id := c.requestID()
|
||||
if err := c.send(map[string]any{
|
||||
"id": id,
|
||||
"method": "turn/start",
|
||||
"params": map[string]any{
|
||||
"input": []map[string]string{{"type": "text", "text": text}},
|
||||
"threadId": threadID,
|
||||
},
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var acc strings.Builder
|
||||
for {
|
||||
msg, err := c.next(ctx)
|
||||
if err != nil {
|
||||
return "", c.withStderr("app-server stream ended before turn/completed", err)
|
||||
}
|
||||
if msg.ID != nil && msg.Method != "" {
|
||||
c.rejectServerRequest(*msg.ID, msg.Method)
|
||||
continue
|
||||
}
|
||||
if msg.ID != nil && *msg.ID == id && msg.Error != nil {
|
||||
return "", fmt.Errorf("turn/start: %s", msg.Error.Message)
|
||||
}
|
||||
switch msg.Method {
|
||||
case "item/agentMessage/delta":
|
||||
var p struct {
|
||||
Delta string `json:"delta"`
|
||||
ThreadID string `json:"threadId"`
|
||||
}
|
||||
if json.Unmarshal(msg.Params, &p) == nil && p.ThreadID == threadID && p.Delta != "" {
|
||||
acc.WriteString(p.Delta)
|
||||
if onDelta != nil {
|
||||
onDelta(acc.String())
|
||||
}
|
||||
}
|
||||
case "turn/completed":
|
||||
final, status, errMsg, ok := codexTurnCompletedText(msg.Params, threadID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if status == "failed" {
|
||||
if errMsg == "" {
|
||||
errMsg = "turn failed"
|
||||
}
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if final == "" {
|
||||
final = strings.TrimSpace(acc.String())
|
||||
}
|
||||
if final == "" {
|
||||
return "", fmt.Errorf("turn completed without agent message")
|
||||
}
|
||||
return final, nil
|
||||
case "error":
|
||||
return "", fmt.Errorf("app-server error notification: %s", truncateRunes(string(msg.Params), 300))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) requestID() int {
|
||||
id := c.nextID
|
||||
c.nextID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) send(v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(c.stdin, string(b))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) waitResponse(ctx context.Context, id int) (json.RawMessage, error) {
|
||||
for {
|
||||
msg, err := c.next(ctx)
|
||||
if err != nil {
|
||||
return nil, c.withStderr("app-server exited before response", err)
|
||||
}
|
||||
if msg.ID != nil && msg.Method != "" {
|
||||
c.rejectServerRequest(*msg.ID, msg.Method)
|
||||
continue
|
||||
}
|
||||
if msg.ID == nil || *msg.ID != id {
|
||||
continue
|
||||
}
|
||||
if msg.Error != nil {
|
||||
return nil, fmt.Errorf("%s", msg.Error.Message)
|
||||
}
|
||||
return msg.Result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) next(ctx context.Context) (codexRPCMessage, error) {
|
||||
// The read loop reports EOF before closing msgs, so when the process
|
||||
// exits right after its last frame both channels are ready and select
|
||||
// would pick one at random — drain buffered frames (e.g. the final
|
||||
// turn/completed) before honoring a read error.
|
||||
select {
|
||||
case msg, ok := <-c.msgs:
|
||||
if !ok {
|
||||
return codexRPCMessage{}, io.EOF
|
||||
}
|
||||
return msg, nil
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case msg, ok := <-c.msgs:
|
||||
if !ok {
|
||||
return codexRPCMessage{}, io.EOF
|
||||
}
|
||||
return msg, nil
|
||||
case err := <-c.readErr:
|
||||
return codexRPCMessage{}, err
|
||||
case <-c.done:
|
||||
return codexRPCMessage{}, io.EOF
|
||||
case <-ctx.Done():
|
||||
return codexRPCMessage{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) rejectServerRequest(id int, method string) {
|
||||
_ = c.send(map[string]any{
|
||||
"id": id,
|
||||
"error": map[string]any{
|
||||
"code": -32000,
|
||||
"message": "DWS robot connect does not support interactive Codex app-server request: " + method,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) withStderr(prefix string, err error) error {
|
||||
stderr := strings.TrimSpace(c.stderr.String())
|
||||
if stderr == "" {
|
||||
return fmt.Errorf("%s: %w", prefix, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %w: %s", prefix, err, truncateRunes(stderr, 300))
|
||||
}
|
||||
|
||||
func codexThreadIDFromResult(result json.RawMessage, err error) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var r struct {
|
||||
Thread struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"thread"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.Thread.ID == "" {
|
||||
return "", fmt.Errorf("app-server response missing thread.id")
|
||||
}
|
||||
return r.Thread.ID, nil
|
||||
}
|
||||
|
||||
func codexTurnCompletedText(params json.RawMessage, wantThreadID string) (final, status, errMsg string, ok bool) {
|
||||
var p struct {
|
||||
ThreadID string `json:"threadId"`
|
||||
Turn struct {
|
||||
Status string `json:"status"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
AdditionalDetails string `json:"additionalDetails"`
|
||||
} `json:"error"`
|
||||
Items []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"items"`
|
||||
} `json:"turn"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil || p.ThreadID != wantThreadID {
|
||||
return "", "", "", false
|
||||
}
|
||||
for i := len(p.Turn.Items) - 1; i >= 0; i-- {
|
||||
if p.Turn.Items[i].Type == "agentMessage" && strings.TrimSpace(p.Turn.Items[i].Text) != "" {
|
||||
final = strings.TrimSpace(p.Turn.Items[i].Text)
|
||||
break
|
||||
}
|
||||
}
|
||||
if p.Turn.Error != nil {
|
||||
errMsg = strings.TrimSpace(p.Turn.Error.Message)
|
||||
if p.Turn.Error.AdditionalDetails != "" {
|
||||
errMsg = strings.TrimSpace(errMsg + ": " + p.Turn.Error.AdditionalDetails)
|
||||
}
|
||||
}
|
||||
return final, p.Turn.Status, errMsg, true
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeShellExecutable(t *testing.T, dir, name, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil {
|
||||
t.Fatalf("write stub %s: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestForwarderForChannelCodexPrefersAppServer(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
stub := t.TempDir()
|
||||
writeShellExecutable(t, stub, "codex", "exit 0\n")
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
fwd, err := forwarderForChannel("codex", "", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("codex forwarder: %v", err)
|
||||
}
|
||||
cf, ok := fwd.(*codexAppServerForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("codex should prefer app-server forwarder, got %T", fwd)
|
||||
}
|
||||
if cf.sessions == nil {
|
||||
t.Fatal("codex app-server should keep per-conversation thread memory by default")
|
||||
}
|
||||
|
||||
t.Setenv("DWS_CODEX_APP_SERVER", "0")
|
||||
fwd, err = forwarderForChannel("codex", "", connectAgentOptions{Memory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("codex forwarder with deprecated app-server env: %v", err)
|
||||
}
|
||||
if _, ok := fwd.(*codexAppServerForwarder); !ok {
|
||||
t.Fatalf("DWS_CODEX_APP_SERVER=0 should be ignored and keep app-server, got %T", fwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexConnectPlanIgnoresAppServerEnv(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
plan := buildConnectPlan("codex", "cid", "")
|
||||
if got := plan["method"]; got != "stream-bridge-codex-app-server" {
|
||||
t.Fatalf("default codex plan method = %v", got)
|
||||
}
|
||||
payload := connectAgentOptionsPayload("codex", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-app-server" {
|
||||
t.Fatalf("default codex memory = %v", got)
|
||||
}
|
||||
|
||||
t.Setenv("DWS_CODEX_APP_SERVER", "0")
|
||||
plan = buildConnectPlan("codex", "cid", "")
|
||||
if got := plan["method"]; got != "stream-bridge-codex-app-server" {
|
||||
t.Fatalf("deprecated app-server env should be ignored, method = %v", got)
|
||||
}
|
||||
payload = connectAgentOptionsPayload("codex", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-app-server" {
|
||||
t.Fatalf("deprecated app-server env should keep codex memory, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexConnectPlanIgnoresAgentCmdOverride(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_AGENT_CMD", "my-codex exec")
|
||||
plan := buildConnectPlan("codex", "cid", "")
|
||||
if got := plan["method"]; got != "stream-bridge-codex-app-server" {
|
||||
t.Fatalf("codex should ignore DWS_AGENT_CMD and keep app-server plan, method = %v", got)
|
||||
}
|
||||
payload := connectAgentOptionsPayload("codex", connectAgentOptions{Memory: true})
|
||||
if got := payload["memory"]; got != "per-conversation-app-server" {
|
||||
t.Fatalf("codex should ignore DWS_AGENT_CMD and keep app-server memory, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerForwarderStreamsAndRemembersThread(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "requests.log")
|
||||
codex := writeShellExecutable(t, dir, "codex", `
|
||||
while IFS= read -r line; do
|
||||
printf '%s\n' "$line" >> "$CODEX_STUB_LOG"
|
||||
case "$line" in
|
||||
*\"method\":\"initialize\"*) printf '%s\n' '{"id":1,"result":{}}' ;;
|
||||
*\"method\":\"thread/start\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
|
||||
*\"method\":\"thread/resume\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
|
||||
*\"method\":\"turn/start\"*)
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}'
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}'
|
||||
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
`)
|
||||
fwd := &codexAppServerForwarder{
|
||||
bin: codex,
|
||||
env: []string{"CODEX_STUB_LOG=" + logPath},
|
||||
timeout: 5 * time.Second,
|
||||
workDir: dir,
|
||||
sessions: newCodexThreadSessions(""),
|
||||
}
|
||||
|
||||
var deltas []string
|
||||
reply, err := fwd.forwardStream(context.Background(), "conv-1", "第一问", func(s string) {
|
||||
deltas = append(deltas, s)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first forward: %v", err)
|
||||
}
|
||||
if reply != "你好" {
|
||||
t.Fatalf("first reply = %q, want 你好", reply)
|
||||
}
|
||||
if strings.Join(deltas, "|") != "你|你好" {
|
||||
t.Fatalf("deltas = %v, want [你 你好]", deltas)
|
||||
}
|
||||
|
||||
if _, err := fwd.forwardStream(context.Background(), "conv-1", "第二问", nil); err != nil {
|
||||
t.Fatalf("second forward: %v", err)
|
||||
}
|
||||
logBytes, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log := string(logBytes)
|
||||
if strings.Count(log, `"method":"thread/start"`) != 1 {
|
||||
t.Fatalf("expected one thread/start, log:\n%s", log)
|
||||
}
|
||||
if strings.Count(log, `"method":"thread/resume"`) != 1 {
|
||||
t.Fatalf("expected one thread/resume, log:\n%s", log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerForwarderReturnsAppServerError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
codex := writeShellExecutable(t, dir, "codex", `
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*\"method\":\"initialize\"*)
|
||||
printf '%s\n' '{"id":1,"error":{"code":123,"message":"app-server-broken"}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
`)
|
||||
fwd := &codexAppServerForwarder{
|
||||
bin: codex,
|
||||
timeout: 10 * time.Second,
|
||||
workDir: dir,
|
||||
}
|
||||
|
||||
reply, err := fwd.forward(context.Background(), "conv-1", "hello")
|
||||
if err == nil {
|
||||
t.Fatal("forward should return the app-server error instead of falling back to exec")
|
||||
}
|
||||
if reply != "" {
|
||||
t.Fatalf("reply = %q, want empty reply on app-server error", reply)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "app-server-broken") {
|
||||
t.Fatalf("error = %v, want app-server-broken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerReadLoopStopsWhenClosedWithFullQueue(t *testing.T) {
|
||||
reader, writer := io.Pipe()
|
||||
c := &codexAppServerClient{
|
||||
msgs: make(chan codexRPCMessage, 1),
|
||||
readErr: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
loopDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(loopDone)
|
||||
c.readLoop(reader)
|
||||
}()
|
||||
|
||||
writerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writerDone)
|
||||
for i := 0; i < 200; i++ {
|
||||
if _, err := fmt.Fprintf(writer, `{"method":"event/%d","params":{}}`+"\n", i); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for len(c.msgs) == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if len(c.msgs) == 0 {
|
||||
t.Fatal("readLoop did not enqueue the first message")
|
||||
}
|
||||
|
||||
close(c.done)
|
||||
_ = reader.Close()
|
||||
_ = writer.Close()
|
||||
select {
|
||||
case <-loopDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("readLoop did not stop after client close")
|
||||
}
|
||||
select {
|
||||
case <-writerDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("writer stayed blocked after pipe close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedBufferConcurrentReadWrite(t *testing.T) {
|
||||
var b lockedBuffer
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
_, _ = b.Write([]byte("stderr\n"))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
_ = b.String()
|
||||
}
|
||||
close(stop)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("writer did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexTurnCompletedText(t *testing.T) {
|
||||
final, status, errMsg, ok := codexTurnCompletedText([]byte(`{"threadId":"thr","turn":{"status":"completed","items":[{"type":"agentMessage","text":"早"},{"type":"agentMessage","text":"最终"}]}}`), "thr")
|
||||
if !ok || status != "completed" || final != "最终" || errMsg != "" {
|
||||
t.Fatalf("completed = (%q,%q,%q,%v)", final, status, errMsg, ok)
|
||||
}
|
||||
|
||||
_, status, errMsg, ok = codexTurnCompletedText([]byte(`{"threadId":"thr","turn":{"status":"failed","error":{"message":"boom","additionalDetails":"detail"},"items":[]}}`), "thr")
|
||||
if !ok || status != "failed" || errMsg != "boom: detail" {
|
||||
t.Fatalf("failed = (%q,%q,%v)", status, errMsg, ok)
|
||||
}
|
||||
}
|
||||
@@ -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 helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCodexThreadSessionsPersist verifies that a codex conversation's thread
|
||||
// survives a connector restart: a second store opened on the same path restores
|
||||
// the convID→threadID mapping, and a reset is persisted too.
|
||||
func TestCodexThreadSessionsPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "codex-threads.json")
|
||||
|
||||
s := newCodexThreadSessions(path)
|
||||
if got := s.threadID("conv-1"); got != "" {
|
||||
t.Fatalf("fresh store threadID = %q, want empty", got)
|
||||
}
|
||||
s.setThreadID("conv-1", "thr_a")
|
||||
s.setThreadID("conv-2", "thr_b")
|
||||
|
||||
// Simulate a restart: a new store on the same path must restore both.
|
||||
restarted := newCodexThreadSessions(path)
|
||||
if got := restarted.threadID("conv-1"); got != "thr_a" {
|
||||
t.Errorf("after restart conv-1 = %q, want thr_a", got)
|
||||
}
|
||||
if got := restarted.threadID("conv-2"); got != "thr_b" {
|
||||
t.Errorf("after restart conv-2 = %q, want thr_b", got)
|
||||
}
|
||||
|
||||
// Reset is persisted: the dropped thread must not resurrect on restart.
|
||||
restarted.reset("conv-1")
|
||||
again := newCodexThreadSessions(path)
|
||||
if got := again.threadID("conv-1"); got != "" {
|
||||
t.Errorf("after reset+restart conv-1 = %q, want empty", got)
|
||||
}
|
||||
if got := again.threadID("conv-2"); got != "thr_b" {
|
||||
t.Errorf("after reset+restart conv-2 = %q, want thr_b (untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexThreadSessionsInMemory verifies that an empty path keeps the store
|
||||
// purely in memory: it still works within the process but writes nothing to
|
||||
// disk, preserving the pre-persistence behaviour.
|
||||
func TestCodexThreadSessionsInMemory(t *testing.T) {
|
||||
s := newCodexThreadSessions("")
|
||||
s.setThreadID("conv-1", "thr_a")
|
||||
if got := s.threadID("conv-1"); got != "thr_a" {
|
||||
t.Errorf("in-memory conv-1 = %q, want thr_a", got)
|
||||
}
|
||||
// A separate in-memory store shares no state (nothing was persisted).
|
||||
other := newCodexThreadSessions("")
|
||||
if got := other.threadID("conv-1"); got != "" {
|
||||
t.Errorf("separate in-memory store conv-1 = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -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 helpers
|
||||
|
||||
import "strings"
|
||||
|
||||
// connectControlAction is a built-in slash command a user can type in the chat
|
||||
// to control the connector's per-conversation session, instead of a message
|
||||
// that gets forwarded to the agent. Modelled on codex / OpenClaw connect's
|
||||
// preset commands: the set is fixed (never dynamically extended) so the bot's
|
||||
// behaviour stays predictable.
|
||||
type connectControlAction struct {
|
||||
// name is the canonical action ("new" | "clear"). Both reset the current
|
||||
// conversation's session today (the next message starts fresh with no prior
|
||||
// context), but they stay distinct actions so the ack wording can differ and
|
||||
// a future channel can diverge their behaviour.
|
||||
name string
|
||||
// ack is the user-facing confirmation sent back into the chat (Chinese, for
|
||||
// DingTalk users). It is never forwarded to the agent.
|
||||
ack string
|
||||
}
|
||||
|
||||
// resetsSession reports whether this action should drop the conversation's
|
||||
// agent session. Both built-in commands do today; kept as a predicate so adding
|
||||
// a non-resetting command later does not require touching the call site.
|
||||
func (a connectControlAction) resetsSession() bool {
|
||||
return a.name == "new" || a.name == "clear"
|
||||
}
|
||||
|
||||
// connectControlCommands maps the recognised slash tokens to their action. The
|
||||
// map is the single source of truth for "which commands exist" — both the
|
||||
// parser and any help text should read from it. "/new", "/start" and "/reset"
|
||||
// are aliases for opening a fresh session; "/clear" wipes the current one.
|
||||
var connectControlCommands = map[string]connectControlAction{
|
||||
"/new": {name: "new", ack: "🆕 已为你开启新会话,之前的上下文不再带入。"},
|
||||
"/start": {name: "new", ack: "🆕 已为你开启新会话,之前的上下文不再带入。"},
|
||||
"/reset": {name: "new", ack: "🆕 已为你开启新会话,之前的上下文不再带入。"},
|
||||
"/clear": {name: "clear", ack: "🧹 已清空当前对话的上下文,我们从头开始。"},
|
||||
}
|
||||
|
||||
// parseConnectControlCommand recognises a built-in slash command. It only
|
||||
// matches when the WHOLE trimmed message is exactly one known token (case
|
||||
// insensitive), so a normal question that merely starts with a slash — e.g.
|
||||
// "/new 这个功能怎么实现?" — is forwarded to the agent untouched rather than
|
||||
// silently swallowed as a command. Returns (action, true) on a match.
|
||||
func parseConnectControlCommand(text string) (connectControlAction, bool) {
|
||||
token := strings.ToLower(strings.TrimSpace(text))
|
||||
if token == "" || !strings.HasPrefix(token, "/") {
|
||||
return connectControlAction{}, false
|
||||
}
|
||||
// A command is a bare token: reject anything carrying arguments/whitespace.
|
||||
if strings.ContainsAny(token, " \t\n") {
|
||||
return connectControlAction{}, false
|
||||
}
|
||||
action, ok := connectControlCommands[token]
|
||||
return action, ok
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseConnectControlCommand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantName string // "" means no match
|
||||
}{
|
||||
// Recognised commands and their aliases.
|
||||
{"/new", "new"},
|
||||
{"/start", "new"},
|
||||
{"/reset", "new"},
|
||||
{"/clear", "clear"},
|
||||
// Case-insensitive and trimmed.
|
||||
{"/NEW", "new"},
|
||||
{" /Clear ", "clear"},
|
||||
{"/New\n", "new"},
|
||||
// A normal question that merely starts with a slash is NOT a command —
|
||||
// it must be forwarded to the agent, not swallowed.
|
||||
{"/new 这个功能怎么实现?", ""},
|
||||
{"/clear the cache please", ""},
|
||||
{"/new x", ""},
|
||||
// Non-commands.
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"你好", ""},
|
||||
{"new", ""}, // missing slash
|
||||
{"/unknown", ""}, // unknown token
|
||||
{"/", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseConnectControlCommand(c.in)
|
||||
if c.wantName == "" {
|
||||
if ok {
|
||||
t.Errorf("parseConnectControlCommand(%q) = (%+v, true), want no match", c.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("parseConnectControlCommand(%q) = no match, want name %q", c.in, c.wantName)
|
||||
continue
|
||||
}
|
||||
if got.name != c.wantName {
|
||||
t.Errorf("parseConnectControlCommand(%q).name = %q, want %q", c.in, got.name, c.wantName)
|
||||
}
|
||||
if got.ack == "" {
|
||||
t.Errorf("parseConnectControlCommand(%q).ack is empty, want a user-facing confirmation", c.in)
|
||||
}
|
||||
if !got.resetsSession() {
|
||||
t.Errorf("parseConnectControlCommand(%q).resetsSession() = false, want true", c.in)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// connect_daemon turns the foreground `robot connect` connector into a 7x24
|
||||
// background service. Three responsibilities live here and nowhere else (the
|
||||
// forwarding/session/knowledge logic in devapp_connect.go and connect_stream.go
|
||||
// is untouched):
|
||||
//
|
||||
// 1. detach: `connect --daemon` re-execs dws in supervisor mode in a new
|
||||
// session (POSIX setsid), prints pid + log path, and exits.
|
||||
// 2. supervise: the supervisor process runs the real connector as a worker
|
||||
// child and restarts it with exponential backoff when it crashes.
|
||||
// 3. status/stop: read the daemon pid file, probe liveness (reusing
|
||||
// processAlive from connect_lock.go), and signal a graceful stop.
|
||||
//
|
||||
// Two hidden internal flags select the mode of a re-exec:
|
||||
//
|
||||
// --daemon-supervise : run the supervisor loop (set by the --daemon parent)
|
||||
// --daemon-worker : run a single foreground connector (set by the supervisor)
|
||||
const (
|
||||
daemonSuperviseFlag = "daemon-supervise"
|
||||
daemonWorkerFlag = "daemon-worker"
|
||||
daemonFlag = "daemon"
|
||||
)
|
||||
|
||||
// daemonState is the JSON persisted to the daemon pid file. It records the
|
||||
// supervisor pid plus enough context for `status` to report without re-deriving
|
||||
// it (start time for uptime, log path, the dir key it was filed under).
|
||||
type daemonState struct {
|
||||
Pid int `json:"pid"`
|
||||
StartUnix int64 `json:"startUnix"`
|
||||
LogPath string `json:"logPath"`
|
||||
DirKey string `json:"dirKey"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
}
|
||||
|
||||
// connectDaemonDirOverride lets tests redirect the per-client daemon directory
|
||||
// away from the real ~/.dws tree. Empty means use config.DefaultConfigDir.
|
||||
var connectDaemonDirOverride string
|
||||
|
||||
// daemonDirKey derives a filesystem-safe directory key identifying a connector.
|
||||
// Priority: clientId (the robot's AppKey, the natural identity) > unifiedAppID.
|
||||
// Reuses sanitizeLockID (connect_lock.go) so the key matches the lock naming
|
||||
// convention. Returns "" when neither is available.
|
||||
func daemonDirKey(clientID, unifiedAppID string) string {
|
||||
if v := strings.TrimSpace(clientID); v != "" {
|
||||
return sanitizeLockID(v)
|
||||
}
|
||||
if v := strings.TrimSpace(unifiedAppID); v != "" {
|
||||
return "app-" + sanitizeLockID(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// connectDaemonDir returns <configDir>/connect/<dirKey>, creating it. This holds
|
||||
// daemon.pid and daemon.log for one connector.
|
||||
func connectDaemonDir(dirKey string) (string, error) {
|
||||
base := connectDaemonDirOverride
|
||||
if base == "" {
|
||||
base = config.DefaultConfigDir()
|
||||
}
|
||||
dir := filepath.Join(base, "connect", dirKey)
|
||||
if err := os.MkdirAll(dir, config.DirPerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") }
|
||||
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") }
|
||||
|
||||
// writeDaemonState atomically persists the daemon pid file (write temp + rename)
|
||||
// so a reader never sees a half-written file.
|
||||
func writeDaemonState(dir string, st daemonState) error {
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := daemonPidPath(dir) + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, daemonPidPath(dir))
|
||||
}
|
||||
|
||||
// readDaemonState loads the daemon pid file. A missing file yields (nil, nil) so
|
||||
// callers can treat "not running" distinctly from a real I/O error.
|
||||
func readDaemonState(dir string) (*daemonState, error) {
|
||||
data, err := os.ReadFile(daemonPidPath(dir))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var st daemonState
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
return nil, fmt.Errorf("daemon pid file %s is corrupt: %w", daemonPidPath(dir), err)
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// backoffDelay computes the restart delay for the Nth consecutive worker
|
||||
// failure: base * 2^(n-1) capped at cap. Pure and unit-tested. n<=0 returns 0
|
||||
// (first start is immediate). Caller resets n when a worker stays healthy.
|
||||
func backoffDelay(consecutiveFailures int, base, maxDelay time.Duration) time.Duration {
|
||||
if consecutiveFailures <= 0 {
|
||||
return 0
|
||||
}
|
||||
d := base
|
||||
for i := 1; i < consecutiveFailures; i++ {
|
||||
d *= 2
|
||||
if d >= maxDelay {
|
||||
return maxDelay
|
||||
}
|
||||
}
|
||||
if d > maxDelay {
|
||||
return maxDelay
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
const (
|
||||
daemonBackoffBase = time.Second
|
||||
daemonBackoffCap = 60 * time.Second
|
||||
// daemonMaxFastFailures is the consecutive fast-failure ceiling: after this
|
||||
// many crashes that each happened within daemonHealthyAfter, the supervisor
|
||||
// gives up rather than spin forever (e.g. bad credentials).
|
||||
daemonMaxFastFailures = 10
|
||||
// daemonHealthyAfter is how long a worker must run before the supervisor
|
||||
// considers it healthy and resets the failure counter.
|
||||
daemonHealthyAfter = 60 * time.Second
|
||||
// daemonStopTimeout bounds the graceful wait in `stop` before SIGKILL.
|
||||
daemonStopTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// buildWorkerArgs rewrites the supervisor's own argv into a worker argv: it
|
||||
// strips the daemon-control flags (--daemon / --daemon-supervise) and appends
|
||||
// --daemon-worker, preserving every other flag (credentials, channel, knowledge,
|
||||
// etc.) so the worker connects exactly as the foreground command would. Pure for
|
||||
// unit testing.
|
||||
func buildWorkerArgs(args []string) []string {
|
||||
out := make([]string, 0, len(args)+1)
|
||||
for _, a := range args {
|
||||
switch {
|
||||
case a == "--"+daemonFlag, a == "--"+daemonSuperviseFlag, a == "--"+daemonWorkerFlag:
|
||||
continue
|
||||
case strings.HasPrefix(a, "--"+daemonFlag+"="),
|
||||
strings.HasPrefix(a, "--"+daemonSuperviseFlag+"="),
|
||||
strings.HasPrefix(a, "--"+daemonWorkerFlag+"="):
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
out = append(out, "--"+daemonWorkerFlag)
|
||||
return out
|
||||
}
|
||||
|
||||
// startDaemon implements `connect --daemon`: it re-execs dws in supervisor mode
|
||||
// detached from the terminal, writes nothing itself to the worker log (the
|
||||
// supervisor does), prints the pid + log path, and returns so the parent exits.
|
||||
func startDaemon(cmd *cobra.Command, dirKey, clientID string) error {
|
||||
if !daemonDetachSupported {
|
||||
return apperrors.NewValidation("--daemon is not supported on this OS; run the foreground connector under a service manager instead")
|
||||
}
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("create daemon dir: " + err.Error())
|
||||
}
|
||||
// Refuse to start a second supervisor for the same connector. The Stream
|
||||
// single-instance lock would also catch this at the worker layer, but a
|
||||
// pre-flight check gives a clearer message and avoids an orphaned supervisor.
|
||||
if st, _ := readDaemonState(dir); st != nil && st.Pid > 0 && processAlive(st.Pid) {
|
||||
return apperrors.NewValidation(fmt.Sprintf("a connect daemon is already running for %s (pid %d); use `robot connect status`/`stop`", dirKey, st.Pid))
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve executable: " + err.Error())
|
||||
}
|
||||
superviseArgs := buildSuperviseArgs(os.Args[1:])
|
||||
|
||||
logPath := daemonLogPath(dir)
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("open daemon log: " + err.Error())
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
child := exec.Command(exe, superviseArgs...)
|
||||
child.Stdout = logFile
|
||||
child.Stderr = logFile
|
||||
child.Stdin = nil
|
||||
// Pass the resolved dir key + clientId through the environment so the
|
||||
// supervisor files its pid under the same key the parent computed (rather
|
||||
// than re-deriving and risking a mismatch).
|
||||
child.Env = append(os.Environ(),
|
||||
"DWS_CONNECT_DAEMON_DIRKEY="+dirKey,
|
||||
"DWS_CONNECT_DAEMON_CLIENTID="+clientID,
|
||||
)
|
||||
if connectDaemonDirOverride != "" {
|
||||
child.Env = append(child.Env, "DWS_CONNECT_DAEMON_DIR="+connectDaemonDirOverride)
|
||||
}
|
||||
applyDetach(child)
|
||||
|
||||
if err := child.Start(); err != nil {
|
||||
return apperrors.NewInternal("start daemon: " + err.Error())
|
||||
}
|
||||
// Release the child so the parent can exit without leaving a zombie.
|
||||
pid := child.Process.Pid
|
||||
_ = child.Process.Release()
|
||||
|
||||
writeConnectDaemonStarted(cmd.OutOrStdout(), pid, logPath, clientID, dirKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeConnectDaemonStarted(w io.Writer, pid int, logPath, clientID, dirKey string) {
|
||||
fmt.Fprintf(w, "connect daemon started (pid %d)\n", pid)
|
||||
fmt.Fprintf(w, " logs: %s\n", logPath)
|
||||
fmt.Fprintf(w, " status: dws devapp robot connect status%s\n", statusHintArgs(clientID, dirKey))
|
||||
fmt.Fprintf(w, " stop: dws devapp robot connect stop%s\n", statusHintArgs(clientID, dirKey))
|
||||
fmt.Fprint(w, connectLocalDebugNotice())
|
||||
}
|
||||
|
||||
// buildSuperviseArgs rewrites argv to run the supervisor: strip --daemon, append
|
||||
// --daemon-supervise. Pure for testing.
|
||||
func buildSuperviseArgs(args []string) []string {
|
||||
out := make([]string, 0, len(args)+1)
|
||||
for _, a := range args {
|
||||
if a == "--"+daemonFlag || strings.HasPrefix(a, "--"+daemonFlag+"=") {
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
out = append(out, "--"+daemonSuperviseFlag)
|
||||
return out
|
||||
}
|
||||
|
||||
func statusHintArgs(clientID, dirKey string) string {
|
||||
if strings.TrimSpace(clientID) != "" {
|
||||
return " --robot-client-id " + clientID
|
||||
}
|
||||
if strings.HasPrefix(dirKey, "app-") {
|
||||
return " --unified-app-id " + strings.TrimPrefix(dirKey, "app-")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runSupervisor is the entry point when dws is started with --daemon-supervise.
|
||||
// It writes the daemon pid file, then loops launching the worker child and
|
||||
// restarting it with backoff until told to stop (SIGTERM/SIGINT) or it exhausts
|
||||
// the fast-failure budget. On stop it forwards the signal to the worker, waits,
|
||||
// and removes its pid file.
|
||||
func runSupervisor(cmd *cobra.Command) error {
|
||||
dirKey := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_DIRKEY"))
|
||||
if dirKey == "" {
|
||||
return apperrors.NewInternal("supervisor started without DWS_CONNECT_DAEMON_DIRKEY")
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_DIR")); v != "" {
|
||||
connectDaemonDirOverride = v
|
||||
}
|
||||
clientID := strings.TrimSpace(os.Getenv("DWS_CONNECT_DAEMON_CLIENTID"))
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("create daemon dir: " + err.Error())
|
||||
}
|
||||
st := daemonState{
|
||||
Pid: os.Getpid(),
|
||||
StartUnix: time.Now().Unix(),
|
||||
LogPath: daemonLogPath(dir),
|
||||
DirKey: dirKey,
|
||||
ClientID: clientID,
|
||||
}
|
||||
if err := writeDaemonState(dir, st); err != nil {
|
||||
return apperrors.NewInternal("write daemon pid file: " + err.Error())
|
||||
}
|
||||
defer os.Remove(daemonPidPath(dir))
|
||||
|
||||
// Route worker stdout/stderr through the shared size-rotating writer
|
||||
// (logging.NewRotatingFile) so a long-running connector's logs don't grow
|
||||
// unbounded. The detached parent already redirected our own fds to the same
|
||||
// daemon.log; from here we append through the rotator. Fall back to stderr if
|
||||
// the rotator can't be opened.
|
||||
var out io.Writer = cmd.ErrOrStderr()
|
||||
if rot, rerr := logging.NewRotatingFile(daemonLogPath(dir)); rerr == nil {
|
||||
defer rot.Close()
|
||||
out = rot
|
||||
}
|
||||
|
||||
// The supervisor reacts to SIGTERM/SIGINT itself (cmd.Context from root is
|
||||
// already wired to these). We use an independent NotifyContext so cancelling
|
||||
// it stops the loop and lets us forward the signal to the worker explicitly.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
workerArgs := buildWorkerArgs(os.Args[1:])
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve executable: " + err.Error())
|
||||
}
|
||||
|
||||
failures := 0
|
||||
for {
|
||||
if delay := backoffDelay(failures, daemonBackoffBase, daemonBackoffCap); delay > 0 {
|
||||
fmt.Fprintf(out, "[daemon] restarting worker in %s (consecutive failures: %d)\n", delay, failures)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
worker := exec.Command(exe, workerArgs...)
|
||||
worker.Stdout = out
|
||||
worker.Stderr = out
|
||||
worker.Env = os.Environ()
|
||||
if err := worker.Start(); err != nil {
|
||||
fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err)
|
||||
failures++
|
||||
if failures >= daemonMaxFastFailures {
|
||||
return apperrors.NewInternal("daemon worker failed to start too many times; giving up")
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, "[daemon] worker started (pid %d)\n", worker.Process.Pid)
|
||||
|
||||
waitErr := superviseWait(ctx, worker)
|
||||
ran := time.Since(started)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
// We were asked to stop; the worker has been (or is being) signalled.
|
||||
fmt.Fprintln(out, "[daemon] stop requested, worker shut down; exiting supervisor")
|
||||
return nil
|
||||
}
|
||||
|
||||
if ran >= daemonHealthyAfter {
|
||||
failures = 0
|
||||
} else {
|
||||
failures++
|
||||
}
|
||||
fmt.Fprintf(out, "[daemon] worker exited after %s (err=%v); consecutive failures: %d\n", ran.Round(time.Second), waitErr, failures)
|
||||
if failures >= daemonMaxFastFailures {
|
||||
return apperrors.NewInternal(fmt.Sprintf("daemon worker crashed %d times in a row; giving up (check %s)", failures, daemonLogPath(dir)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// superviseWait waits for the worker to exit, but if the supervisor's ctx is
|
||||
// cancelled first it forwards SIGTERM to the worker for a graceful shutdown
|
||||
// (releasing the Stream single-instance lock) and then waits.
|
||||
func superviseWait(ctx context.Context, worker *exec.Cmd) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
_ = worker.Process.Signal(syscall.SIGTERM)
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(daemonStopTimeout):
|
||||
_ = worker.Process.Kill()
|
||||
return <-done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// daemonStatus reports the state of the connector daemon to w.
|
||||
func daemonStatus(w io.Writer, dirKey string) error {
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
}
|
||||
st, err := readDaemonState(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil {
|
||||
fmt.Fprintf(w, "connect daemon: not running (no pid file under %s)\n", dir)
|
||||
return nil
|
||||
}
|
||||
if st.Pid <= 0 || !processAlive(st.Pid) {
|
||||
fmt.Fprintf(w, "connect daemon: not running (stale pid file for pid %d at %s)\n", st.Pid, daemonPidPath(dir))
|
||||
return nil
|
||||
}
|
||||
uptime := time.Since(time.Unix(st.StartUnix, 0)).Round(time.Second)
|
||||
fmt.Fprintf(w, "connect daemon: running\n")
|
||||
fmt.Fprintf(w, " pid: %d\n", st.Pid)
|
||||
fmt.Fprintf(w, " uptime: %s\n", uptime)
|
||||
fmt.Fprintf(w, " logs: %s\n", st.LogPath)
|
||||
if st.ClientID != "" {
|
||||
fmt.Fprintf(w, " client: %s\n", st.ClientID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// daemonStop gracefully stops the connector daemon: SIGTERM the supervisor (it
|
||||
// forwards to the worker, which releases the lock and Stream connection), poll
|
||||
// until it exits, escalate to SIGKILL on timeout, and clean up the pid file.
|
||||
func daemonStop(w io.Writer, dirKey string) error {
|
||||
dir, err := connectDaemonDir(dirKey)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve daemon dir: " + err.Error())
|
||||
}
|
||||
st, err := readDaemonState(dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(err.Error())
|
||||
}
|
||||
if st == nil || st.Pid <= 0 {
|
||||
fmt.Fprintf(w, "connect daemon: not running (nothing to stop)\n")
|
||||
return nil
|
||||
}
|
||||
if !processAlive(st.Pid) {
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon: was not running (cleaned up stale pid file for pid %d)\n", st.Pid)
|
||||
return nil
|
||||
}
|
||||
proc, err := os.FindProcess(st.Pid)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("find daemon process %d: %v", st.Pid, err))
|
||||
}
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("signal daemon %d: %v", st.Pid, err))
|
||||
}
|
||||
fmt.Fprintf(w, "sent SIGTERM to connect daemon (pid %d), waiting for graceful stop...\n", st.Pid)
|
||||
|
||||
deadline := time.Now().Add(daemonStopTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(st.Pid) {
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon stopped (pid %d)\n", st.Pid)
|
||||
return nil
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
// Graceful window elapsed; force kill.
|
||||
_ = proc.Signal(syscall.SIGKILL)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_ = os.Remove(daemonPidPath(dir))
|
||||
fmt.Fprintf(w, "connect daemon did not stop in %s; sent SIGKILL (pid %d)\n", daemonStopTimeout, st.Pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// newDevAppRobotConnectStatusCommand implements `dws devapp robot connect
|
||||
// status`: report whether a background connector daemon is running for the
|
||||
// robot identified by --robot-client-id (or --unified-app-id).
|
||||
func newDevAppRobotConnectStatusCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "查看后台连接器守护进程状态(pid、运行时长、日志路径)",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
dirKey, err := connectDaemonDirKeyFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemonStatus(cmd.OutOrStdout(), dirKey)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newDevAppRobotConnectStopCommand implements `dws devapp robot connect stop`:
|
||||
// gracefully stop the background connector daemon (SIGTERM, escalate to SIGKILL
|
||||
// on timeout) and clean up its pid file.
|
||||
func newDevAppRobotConnectStopCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "优雅停止后台连接器守护进程(释放单实例锁与 Stream 连接)",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
dirKey, err := connectDaemonDirKeyFromFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemonStop(cmd.OutOrStdout(), dirKey)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// connectDaemonDirKeyFromFlags resolves the daemon directory key from the
|
||||
// status/stop flags, requiring at least one identifier.
|
||||
func connectDaemonDirKeyFromFlags(cmd *cobra.Command) (string, error) {
|
||||
clientID := devAppStringFlag(cmd, "robot-client-id")
|
||||
unifiedAppID := devAppStringFlag(cmd, "unified-app-id")
|
||||
dirKey := daemonDirKey(clientID, unifiedAppID)
|
||||
if dirKey == "" {
|
||||
return "", apperrors.NewValidation("需要 --robot-client-id 或 --unified-app-id 以定位守护进程")
|
||||
}
|
||||
return dirKey, nil
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoffDelay(t *testing.T) {
|
||||
base := time.Second
|
||||
cap := 60 * time.Second
|
||||
cases := []struct {
|
||||
failures int
|
||||
want time.Duration
|
||||
}{
|
||||
{0, 0},
|
||||
{-1, 0},
|
||||
{1, time.Second},
|
||||
{2, 2 * time.Second},
|
||||
{3, 4 * time.Second},
|
||||
{4, 8 * time.Second},
|
||||
{5, 16 * time.Second},
|
||||
{6, 32 * time.Second},
|
||||
{7, cap}, // 64s capped to 60s
|
||||
{8, cap},
|
||||
{100, cap},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := backoffDelay(c.failures, base, cap); got != c.want {
|
||||
t.Errorf("backoffDelay(%d) = %v, want %v", c.failures, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonDirKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
clientID string
|
||||
unifiedAppID string
|
||||
want string
|
||||
}{
|
||||
{"clientABC", "", "clientABC"},
|
||||
{"client/with:bad*chars", "", "client_with_bad_chars"},
|
||||
{"", "app-123", "app-app-123"},
|
||||
{"", "u/n.id", "app-u_n_id"},
|
||||
{" ", " ", ""},
|
||||
{"cid", "uid", "cid"}, // clientID wins
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := daemonDirKey(c.clientID, c.unifiedAppID); got != c.want {
|
||||
t.Errorf("daemonDirKey(%q,%q) = %q, want %q", c.clientID, c.unifiedAppID, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkerArgs(t *testing.T) {
|
||||
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc", "--channel=claudecode"}
|
||||
got := buildWorkerArgs(in)
|
||||
joined := strings.Join(got, " ")
|
||||
if strings.Contains(joined, "--daemon ") || strings.HasSuffix(joined, "--daemon") {
|
||||
// only the appended --daemon-worker may contain "daemon"
|
||||
}
|
||||
if !strings.HasSuffix(joined, "--daemon-worker") {
|
||||
t.Errorf("worker args must end with --daemon-worker, got %q", joined)
|
||||
}
|
||||
for _, a := range got[:len(got)-1] {
|
||||
if a == "--daemon" || a == "--daemon-supervise" || a == "--daemon-worker" {
|
||||
t.Errorf("daemon-control flag leaked into worker args: %q", a)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(joined, "--robot-client-id abc") || !strings.Contains(joined, "--channel=claudecode") {
|
||||
t.Errorf("credential/channel flags must be preserved, got %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSuperviseArgs(t *testing.T) {
|
||||
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc"}
|
||||
got := buildSuperviseArgs(in)
|
||||
joined := strings.Join(got, " ")
|
||||
if !strings.HasSuffix(joined, "--daemon-supervise") {
|
||||
t.Errorf("supervise args must end with --daemon-supervise, got %q", joined)
|
||||
}
|
||||
if strings.Contains(joined, " --daemon ") || strings.Contains(joined, " --daemon\b") {
|
||||
t.Errorf("--daemon should be stripped, got %q", joined)
|
||||
}
|
||||
for _, a := range got[:len(got)-1] {
|
||||
if a == "--daemon" {
|
||||
t.Errorf("--daemon leaked into supervise args: %q", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConnectDaemonStartedIncludesLocalDebugNotice(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeConnectDaemonStarted(&buf, 1234, "/tmp/daemon.log", "cid", "cid")
|
||||
out := buf.String()
|
||||
for _, want := range []string{"connect daemon started", "/tmp/daemon.log", "本地调试", "不代表线上发布完成", "不会提交版本发布"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("daemon started output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStateRoundTrip(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
|
||||
dir, err := connectDaemonDir("roundtrip")
|
||||
if err != nil {
|
||||
t.Fatalf("connectDaemonDir: %v", err)
|
||||
}
|
||||
// No file yet → nil, nil.
|
||||
if st, err := readDaemonState(dir); err != nil || st != nil {
|
||||
t.Fatalf("expected (nil,nil) for missing pid file, got (%v,%v)", st, err)
|
||||
}
|
||||
want := daemonState{Pid: 4242, StartUnix: time.Now().Unix(), LogPath: "/x/y.log", DirKey: "roundtrip", ClientID: "cid"}
|
||||
if err := writeDaemonState(dir, want); err != nil {
|
||||
t.Fatalf("writeDaemonState: %v", err)
|
||||
}
|
||||
got, err := readDaemonState(dir)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("readDaemonState: (%v,%v)", got, err)
|
||||
}
|
||||
if got.Pid != want.Pid || got.DirKey != want.DirKey || got.ClientID != want.ClientID || got.LogPath != want.LogPath {
|
||||
t.Errorf("round trip mismatch: got %+v want %+v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDaemonStateCorrupt(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
dir, _ := connectDaemonDir("corrupt")
|
||||
if err := os.WriteFile(daemonPidPath(dir), []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readDaemonState(dir); err == nil {
|
||||
t.Error("expected error for corrupt pid file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusNotRunning(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStatus(&buf, "nope"); err != nil {
|
||||
t.Fatalf("daemonStatus: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not running") {
|
||||
t.Errorf("expected 'not running', got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusStalePid(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
dir, _ := connectDaemonDir("stale")
|
||||
// pid that is essentially certain to be dead.
|
||||
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), LogPath: "/l", DirKey: "stale"})
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStatus(&buf, "stale"); err != nil {
|
||||
t.Fatalf("daemonStatus: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "stale pid file") {
|
||||
t.Errorf("expected stale pid report, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusRunning(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
dir, _ := connectDaemonDir("live")
|
||||
// Use our own pid: guaranteed alive.
|
||||
writeDaemonState(dir, daemonState{Pid: os.Getpid(), StartUnix: time.Now().Add(-90 * time.Second).Unix(), LogPath: "/l.log", DirKey: "live", ClientID: "cidX"})
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStatus(&buf, "live"); err != nil {
|
||||
t.Fatalf("daemonStatus: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"running", "pid:", "uptime:", "/l.log", "cidX"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("status output missing %q; got %q", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStopNotRunning(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStop(&buf, "ghost"); err != nil {
|
||||
t.Fatalf("daemonStop: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not running") {
|
||||
t.Errorf("expected 'not running', got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStopStaleCleansPidFile(t *testing.T) {
|
||||
connectDaemonDirOverride = t.TempDir()
|
||||
t.Cleanup(func() { connectDaemonDirOverride = "" })
|
||||
dir, _ := connectDaemonDir("stalestop")
|
||||
writeDaemonState(dir, daemonState{Pid: deadPid(t), StartUnix: time.Now().Unix(), DirKey: "stalestop"})
|
||||
var buf bytes.Buffer
|
||||
if err := daemonStop(&buf, "stalestop"); err != nil {
|
||||
t.Fatalf("daemonStop: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(daemonPidPath(dir)); !os.IsNotExist(err) {
|
||||
t.Errorf("stale pid file should be removed, stat err=%v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "stale") {
|
||||
t.Errorf("expected stale cleanup message, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// deadPid returns a pid that is not alive. It spawns `true`, waits for it to
|
||||
// exit, and returns its pid — guaranteed reaped and gone.
|
||||
func deadPid(t *testing.T) int {
|
||||
t.Helper()
|
||||
// A very high pid is almost never live; combine with a sanity probe.
|
||||
for _, candidate := range []int{999999, 524287, 99999} {
|
||||
if !processAlive(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
t.Skip("could not find a guaranteed-dead pid on this host")
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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.
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// daemonDetachSupported reports whether the current OS can detach a daemon.
|
||||
const daemonDetachSupported = true
|
||||
|
||||
// detachSysProcAttr returns the SysProcAttr that detaches a child from the
|
||||
// controlling terminal: Setsid starts a new session so the child survives the
|
||||
// parent shell closing (no SIGHUP) and has no controlling tty. Used for the
|
||||
// `connect --daemon` re-exec. Unix-only; Windows uses the stub.
|
||||
func detachSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
|
||||
// applyDetach configures cmd so it runs in its own session, detached from the
|
||||
// terminal.
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = detachSysProcAttr()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user